はじめに
Go学習の一環として、Google Books APIsを活用しての簡単な書籍検索アプリを作ってみたので備忘録として記事にしておきます。
目次
1. 概要
2. Google Books APIs とは?
3. ディレクトリ構成
4. 環境構築
4-1. Dockerfile
4-2. compose.yaml
4-3. env
4-4. db/my.cnf
4-5. phpmyadmin/sessions
5. バックエンド側の実装
5-1. main.go
5-2. Controller
5-3. Model
6. フロントエンド側の実装
6-1. View
7. 動作確認
7-1. 初期画面
7-2. 新規登録機能
7-3. ログイン機能
7-4. ログアウト機能
7-5. 書籍検索機能
7-6. その他
8. 参考文献
概要
【アプリ名】
Book Search
【機能・実装概要】
・メニュー画面
メニューページを作成(検索エリア・ボタンを配置)
・書籍検索機能
Google Books APIs を活用しての書籍検索処理を実装
・新規登録機能
新規登録フォームを作成、ユーザーデータ登録処理を実装
・ログイン機能
ログインフォームを作成、ログイン処理を実装
・ログアウト機能
ユーザーデータを含むセッション情報の破棄、ログアウト処理を実装
【使用技術】
・バックエンド : golang 1.20.4
・フロントエンド : HTML5、CSS3、jQuery
・フレームワーク : gin
・DB : MySQL 8.0
・O/R マッパーライブラリ : gorm
・DBクライアントツール : phpmyadmin
・IDE : VSCode
・その他 : docker
Google Books APIs とは?
Google が提供している書籍検索 API の一つです。
API キー等の認証不要で、プログラミング学習の際に便利なライブラリです。
ディレクトリ構成
ディレクトリは下記の通り、MVC(Model-View-Controller)構成としていきます。
MVC
:Model, View, Controllerという役割別に分割してコーディングを行うモデル
Book-Search
|- controller/
|- login/
└─ login.go
|- search/
└─ search.go
└─ signup/
└─ signup.go
|- db/
└─ my.cnf
|- model/
└─ user.go
|- phpmyadmin/
└─ sessions/
|- static/
|- css/
└─ style.css
└─ js/
└─ script.js
|- view/
|- base.html
|- login.html
|- menu.html
|- search_books.html
└─ signup.html
|- .env
|- docker-compose.yml
|- Dockerfile
|- go.mod
|- go.sum
└─ main.go
環境構築
1. 下記のフォルダ、ファイルを作成
Dockerfile
golang:1.20.4-bullseye
:Docker イメージを指定
go install github.com/cosmtrek/air@latest
Go 開発におけるホットリロードツールであるair
のインストール
WORKDIR
:Docker イメージ内の作業ディレクトリを/go/src
へ変更
-
Dockerfile
FROM golang:1.20.4-bullseye RUN go install github.com/cosmtrek/air@latest WORKDIR /go/src
compose.yaml
以下を示すコンテナ定義が記述されています。
・go
:アプリサーバ
・db
:データベース(MySQL)サーバ
・phpmyadmin
:データベースクライアントツール
-
compose.yaml
version: '3' services: go: build: context: . dockerfile: Dockerfile command: /bin/sh -c "go run main.go" stdin_open: true tty: true volumes: - .:/go/src ports: - 8080:8080 depends_on: - "db" db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: go_database MYSQL_USER: go_test MYSQL_PASSWORD: password TZ: 'Asia/Tokyo' command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci volumes: - db-data:/var/lib/mysql - ./db/my.cnf:/etc/mysql/conf.d/my.cnf ports: - 3306:3306 phpmyadmin: image: phpmyadmin/phpmyadmin environment: - PMA_ARBITRARY=1 - PMA_HOST=db # mysqlのサービス名を指定 - PMA_USER=go_test # phpmyadminへログインするユーザ - PMA_PASSWORD=password # phpmyadminにログインするユーザのパスワード links: - db ports: - 4040:80 volumes: - ./phpmyadmin/sessions:/sessions volumes: db-data: driver: local
env
セキュリティや保守、情報管理の観点からデータベースの接続情報は env ファイルにとりまとめておきます。
本ファイルの情報は後程、Controller 処理にてDB接続で使用されます。
-
DB_USER=go_test DB_PASSWORD=password DB=go_database DB_HOST=db DB_PORT=3306 DB_PROTOCOL=tcp DBMS=mysql
db/my.cnf
MySQLサーバの設定ファイルです。
MySQLサーバ起動時に読み込まれ、データベースの動作に関するオプションを設定します。
ファイル内では、以下オプションを設定しています。
[mysqld]セクション
・MySQLサーバーのデフォルト文字セット:UTF-8のmb4(4バイト文字)
・文字列比較やソートにおけるデフォルトの照合方式:UTF-8のmb4バイナリ照合
・デフォルトタイムゾーン:システムのタイムゾーンを指定
・ログエントリに記録されるタイムスタンプ:システムのタイムゾーンを指定
・ユーザーの認証方式:MySQLネイティブパスワード方式を指定
[mysql]セクション
・mysqlコマンドなどで使用するデフォルトの文字セット
[client]セクション
・phpmyadminで使用するデフォルトの文字セット
-
db/my.cnf
[mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_bin default-time-zone = SYSTEM log_timestamps = SYSTEM default-authentication-plugin = mysql_native_password [mysql] default-character-set = utf8mb4 [client] default-character-set = utf8mb4
phpmyadmin/sessions
docker-compose.yml でのphpmyadmin > volumes
にあたるため、中身はありません。
※フォルダのみを作成
2. docker-compose build
を実行
3. go mod init <モジュールパス>
を実行
本コマンドの実行により、go.mod ファイルが作成されます。
モジュールパスは、git等のリポジトリルートURLを指定することが推奨されています。
例:「go mod init github.com/taro12/book-search」
4. go get
を実行
本コマンドの実行により、go.sum ファイルが作成されます。
3, 4については、必ずプロジェクトのルートディレクトリで実行してください。
バックエンド側の実装
main.go
Go では、全てのプログラムは何かしらのパッケージに属する必要があります。
プログラム起動直後は、まず「main」パッケージに属する「main 関数」が実行されます。
(厳密に言うとはじめは init 関数が実行される)
main パッケージあるいは main 関数がない場合は、実行エラーとなります。
要するに Go プログラムにおけるエントリーポイントです。
r.GET()
、r.PUT()
、r.POST()
は画面機能に対応したイベントハンドラーです。
画面操作
⇨ main 関数内のイベントハンドラー
⇨ Controller
という流れで遷移、処理が実行されます。
-
main.go
package main import ( "fmt" "log" "net/http" "os" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" _ "github.com/go-sql-driver/mysql" "github.com/jinzhu/gorm" "github.com/joho/godotenv" "github.com/taro12/book-search/controller/login" "github.com/taro12/book-search/controller/search" "github.com/taro12/book-search/controller/signup" ) // クッキーストアの作成、セキュリティを強化したクッキーの管理が可能 // セキュアクッキー:情報を暗号化・保護し、改ざんを防ぐために署名を付けるなどのセキュリティ対策が施されたクッキー var store = cookie.NewStore([]byte("secret")) func main() { // デフォルトのGinエンジンを生成 r := gin.Default() // 事前にテンプレートをレンダリング r.LoadHTMLGlob("view/*") // 静的ファイルのディレクトリを指定 r.Static("/static", "./static") // セッションの設定 r.Use(sessions.Sessions("mysession", store)) // 設定ファイルから環境変数の情報を取得 envLoad() // テーブル作成 db := dbConnect() db.AutoMigrate(&model.User{}) defer db.Close() // 初期画面表示のハンドラー r.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "menu.html", gin.H{}) }) // メニュー画面 ログインボタンクリック時のハンドラー r.GET("/login", login.ShowLoginForm) // ログインフォーム ログインボタンクリック時のハンドラー r.POST("/login", func(c *gin.Context) { // DB接続 db := dbConnect() // ログイン処理 login.Login(c, db) // DB接続切断 defer db.Close() }) // メニュー画面 ログアウトボタンクリック時のハンドラー r.PUT("/login", func(c *gin.Context) { // ログアウト処理 login.Logout(c) }) // メニュー画面 新規登録ボタンクリック時のハンドラー r.GET("/signup", signup.ShowSignupForm) // 新規登録フォーム ログインボタンクリック時のハンドラー r.POST("/signup", func(c *gin.Context) { // DB接続 db := dbConnect() // 新規登録処理 signup.Signup(c, db) // DB接続切断 defer db.Close() }) // 書籍検索ボタン、ページネーションボタンクリック時のハンドラー r.GET("/search", search.SearchBooks) // サーバーを起動 r.Run(":8080") } // .env ファイル読み込み func envLoad() { err := godotenv.Load(".env") if err != nil { log.Fatalf("Error loading env target") } } // DB 接続 func dbConnect() *gorm.DB { // 環境変数から接続文字列を作成 connect := fmt.Sprintf("%s:%s@%s(%s:%s)/%s?parseTime=true", os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_PROTOCOL"), os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB")) // 環境変数からDBMSの種類を取得 db, err := gorm.Open(os.Getenv("DBMS"), connect) if err != nil { panic(err.Error()) } return db }
Controller
【役割】
・ユーザーからのリクエストを受け取る、ユーザーへレスポンスを返す
・Model へデータベース通信の処理を指示する
・View へ画面表示を指示する
login.go : ログイン / ログアウトの関連処理
-
login/login.go
package login import ( "net/http" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/jinzhu/gorm" "github.com/nao-United92/book-search/model" "golang.org/x/crypto/bcrypt" ) /* * ログインフォーム表示 * 【リクエスト】 * c: リクエストとレスポンスのコンテキストを表すオブジェクト */ func ShowLoginForm(c *gin.Context) { // ログインフォームを表示 c.HTML(http.StatusOK, "login.html", nil) } /* * ログイン * 【リクエスト】 * c: リクエストとレスポンスのコンテキストを表すオブジェクト * db: データベース接続オブジェクト */ func Login(c *gin.Context, db *gorm.DB) { // セッションのデフォルトストアからセッションオブジェクトを取得 session := sessions.Default(c) // フォームデータを取得 address := c.PostForm("address") password := c.PostForm("password") // ログインフォームの入力内容と一致するユーザーデータの存在チェック conditions := []interface{}{"address = ? AND delete_flag = 0", address} user, err := model.GetUserData(db, conditions, "") if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"Error": "メールアドレスに該当するユーザーは存在しません。"}) return } // ハッシュ化されたパスワードと元のパスワードを比較 err = CompareHashAndPassword(user.Password, password) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"Error": "パスワードが不一致です。"}) return } // セッションへユーザーデータを保存 session.Set("user_id", user.User_id) session.Set("address", user.Address) session.Save() // メニューページへリダイレクト c.Redirect(http.StatusSeeOther, "/") } /* * 暗号(Hash)化および入力されたパスワードの比較 * 【リクエスト】 * hashedPassword: ハッシュ化されたパスワード * password: ハッシュ化前のパスワード * * 【レスポンス】 * error: エラー内容 */ func CompareHashAndPassword(hashedPassword, password string) error { return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) } /* * ログアウト * 【リクエスト】 * c: リクエストとレスポンスのコンテキストを表すオブジェクト */ func Logout(c *gin.Context) { // セッションのデフォルトストアからセッションオブジェクトを取得 session := sessions.Default(c) // セッションデータを削除 session.Clear() // セッションの破棄を確定 if err := session.Save(); err != nil { c.JSON(http.StatusInternalServerError, err.Error()) return } }
search/search.go : 書籍検索の関連処理
-
search/search.go
package search import ( "encoding/json" "fmt" "io/ioutil" "net/http" "net/url" "strconv" "github.com/gin-gonic/gin" ) // 書籍データの構造体 type BookInfo struct { Title string `json:"title"` Subtitle string `json:"subtitle"` Authors []string `json:"authors"` Description string `json:"description"` SmallThumbnail string `json:"smallThumbnail"` Publisher string `json:"publisher"` PublishedDate string `json:"publishedDate"` } /* * 書籍検索 * 【リクエスト】 * c: リクエストとレスポンスのコンテキストを表すオブジェクト */ func SearchBooks(c *gin.Context) { // クエリパラメータ取得 q := c.Query("q") // 検索文字列 page, err := strconv.Atoi(c.DefaultQuery("page", "1")) // デフォルトページ番号 if err != nil { page = 1 } // 取得データをキーに書籍検索、検索結果の画面表示 RenderPage(c, q, page) } /* * 書籍検索、検索結果の画面表示 * 【リクエスト】 * c: リクエストとレスポンスのコンテキストを表すオブジェクト * q: フォームで入力された検索文字列 * page: ページ番号 */ func RenderPage(c *gin.Context, q string, page int) { // 書籍検索 books, totalPages, err := SearchBooksInfo(c, q, page) if err != nil { c.HTML(http.StatusBadRequest, "search_books.html", gin.H{ "Error": "書籍データの検索中にエラーが発生しました。", }) return } // ページネーション用のページ番号スライスの作成 var pages []int for i := 1; i <= totalPages; i++ { if page > 10 { pages = append(pages, i+(((page/10)%10)*10)) } else { pages = append(pages, i) } } // 書籍検索結果、ページ番号の画面表示 c.HTML(http.StatusOK, "search_books.html", gin.H{ "Books": books, "TotalPages": totalPages, "Pages": pages, "CurrentPage": page, "Previous": page - 1, "Next": page + 1, }) } /* * 書籍詳細データの検索 * 【リクエスト】 * c: リクエストとレスポンスのコンテキストを表すオブジェクト * q: フォームで入力された検索文字列 * page: ページ番号 * * 【レスポンス】 * []BookInfo: 書籍検索データ * int: ページ総数 * error: エラー内容 */ func SearchBooksInfo(c *gin.Context, q string, page int) ([]BookInfo, int, error) { // Google Books APIのエンドポイントを指定 apiEndpoint := "https://www.googleapis.com/books/v1/volumes" // APIリクエスト url := fmt.Sprintf("%s?q=%s&maxResults=10&startIndex=%d", apiEndpoint, url.QueryEscape(q), (page-1)*10) resp, err := http.Get(url) if err != nil { return nil, 0, fmt.Errorf("Failed to send API request: %v", err) } defer resp.Body.Close() // APIレスポンスの読み込み body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, 0, fmt.Errorf("Failed to read API response: %v", err) } // APIレスポンスデータの解析、取得 var data map[string]interface{} err = json.Unmarshal(body, &data) if err != nil { return nil, 0, fmt.Errorf("Failed to parse API response: %v", err) } // 画面表示する書籍データの抽出、リスト化 var books []BookInfo if items, ok := data["items"].([]interface{}); ok { for _, item := range items { var book BookInfo if volumeInfo, ok := item.(map[string]interface{})["volumeInfo"].(map[string]interface{}); ok { book.Title = getString(volumeInfo, "title") book.Subtitle = getString(volumeInfo, "subtitle") book.Authors = getStringSlice(volumeInfo, "authors") book.Description = getString(volumeInfo, "description") book.Publisher = getString(volumeInfo, "publisher") book.PublishedDate = getString(volumeInfo, "publishedDate") // サムネイル画像URLを取得 if imageLinks, ok := volumeInfo["imageLinks"].(map[string]interface{}); ok { book.SmallThumbnail = getString(imageLinks, "smallThumbnail") } } books = append(books, book) } } totalItems, _ := data["totalItems"].(float64) totalPages := int(totalItems)/10 + 1 return books, totalPages, nil } /* * マップから指定したキーの文字列値を取得 * 【リクエスト】 * m: マッピングデータ * key: マップデータから取得するキー * * 【レスポンス】 * string: 取得文字列値 */ func getString(m map[string]interface{}, key string) string { if value, ok := m[key].(string); ok { return value } return "" } /* * マップから指定したキーの文字列のスライスを取得 * 【リクエスト】 * m: マッピングデータ * key: マップデータから取得するキー * * 【レスポンス】 * []string: 取得文字列のスライス */ func getStringSlice(m map[string]interface{}, key string) []string { if values, ok := m[key].([]interface{}); ok { var result []string for _, value := range values { if s, ok := value.(string); ok { result = append(result, s) } } return result } return nil }
signup/signup.go : 新規登録の関連処理
-
signup/signup.go
package signup import ( "net/http" "regexp" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/jinzhu/gorm" "github.com/nao-United92/book-search/model" "golang.org/x/crypto/bcrypt" ) /* * 新規登録フォーム表示 * 【リクエスト】 * c: リクエストとレスポンスのコンテキストを表すオブジェクト */ func ShowSignupForm(c *gin.Context) { c.HTML(http.StatusOK, "signup.html", nil) } /* * ユーザーデータの新規登録 * 【リクエスト】 * c: リクエストとレスポンスのコンテキストを表すオブジェクト * db: データベース接続オブジェクト */ func Signup(c *gin.Context, db *gorm.DB) { // セッションのデフォルトストアからセッションオブジェクトを取得 session := sessions.Default(c) // フォームデータを取得 user_name := c.PostForm("username") address := c.PostForm("address") password := c.PostForm("password") // チェック対象のメールアドレスを取得 var address_list []string if err := db.Model(&model.User{}).Pluck("address", &address_list).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"Error": "ユーザーデータの検索中にエラーが発生しました。"}) return } // メールアドレスの存在チェック if IsAddressExists(address, address_list) { c.JSON(http.StatusUnauthorized, gin.H{"Error": "既に同じメールアドレスが登録されています。"}) return } // メールアドレスのフォーマットチェック if !IsValidAddress(address) { c.JSON(http.StatusBadRequest, gin.H{"Error": "メールアドレスのフォーマットが不正です。"}) return } // パスワードが半角英数・記号:8~16文字を満たすか否かのチェック valid := ValidatePassword(password) if !valid { c.JSON(http.StatusBadRequest, gin.H{"Error": "パスワードのフォーマットが不正です。"}) return } // パスワードをハッシュ化 hashedPassword, err := PasswordHash(password) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"Error": "パスワードの暗号化中にエラーが発生しました。"}) return } // ユーザーデータの新規登録 user, err := model.CreateUserData(db, user_name, address, hashedPassword) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"Error": "ユーザーデータの登録中にエラーが発生しました。"}) return } // ユーザーデータの新規登録が成功した場合 if user != nil { // セッションへ登録したユーザーデータを保存 session.Set("user_id", user.User_id) session.Set("address", address) session.Save() // メニューページへリダイレクト c.Redirect(http.StatusSeeOther, "/") } } /* * メールアドレスの存在チェック * 【リクエスト】 * input_address: フォームで入力されたメールアドレス * address_list: DBから取得したチェック対象のメールアドレス * * 【レスポンス】 * bool: true / false */ func IsAddressExists(input_address string, address_list []string) bool { for _, address := range address_list { if address == input_address { return true } } return false } /* * メールアドレスのフォーマットチェック * 【リクエスト】 * address: チェック対象のメールアドレス * * 【レスポンス】 * bool: true / false */ func IsValidAddress(address string) bool { // RFC 5322に基づく正規表現パターンを定義 checkPattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` // 正規表現パターンに一致するかどうかを確認 match, err := regexp.MatchString(checkPattern, address) if err != nil { return false } return match } /* * パスワードが半角英数・記号:8~16文字を満たすか否かのチェック * 【リクエスト】 * password: メールアドレス * * 【レスポンス】 * bool: チェック結果 */ func ValidatePassword(password string) bool { // 正規表現でパスワードが半角英数・記号:8~16文字か否かのチェック return regexp.MustCompile(`^[!-~]{8,16}$`).MatchString(password) } /* * パスワードの暗号(Hash)化 * 【リクエスト】 * password: ハッシュ化対象のメールアドレス * * 【レスポンス】 * string: ハッシュ化されたパスワード * error: エラー内容 */ func PasswordHash(password string) (string, error) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) return string(hashedPassword), err }
Model
【役割】
・データベースと通信(データの取得・保存)する
・データベース通信の結果を Controller へ渡す
user.go : users テーブルデータの CRUD 処理
users テーブルは、gorm
を使用して、main 関数の以下処理で作成しています。
gorm
:Go 言語上で SQL を発行せずに DB アクセスを実現する O/R マッパーライブラリ
user.go
のtype User struct
の構造体でテーブルカラムを定義して、db.AutoMigrate
するだけで、テーブルを作成することができます。
-
main.go
// テーブル作成 db := dbConnect() db.AutoMigrate(&model.User{}) defer db.Close()
テーブル作成後、上記処理はコメントアウトまたは削除してください。
-
user.go
package model import ( "time" "github.com/jinzhu/gorm" ) /* * ユーザーテーブルのカラム定義 */ type User struct { User_id int `gorm:"primary_key"` // ユーザーID User_name string // ユーザー名 Address string // メールアドレス Password string // パスワード Create_date time.Time `gorm:"default:CURRENT_TIMESTAMP"` // 登録日時 Update_date time.Time `gorm:"default:CURRENT_TIMESTAMP"` // 更新日時 Delete_date *time.Time // 削除日時 Delete_flag int `gorm:"default:0"` // 削除フラグ } /* * ユーザーデータの単一検索 * 【リクエスト】 * db: データベース接続オブジェクト * conditions: 検索条件 * orderBy: ソート順 * * 【レスポンス】 * *User: 検索結果 * error: エラー内容 */ func GetUserData(db *gorm.DB, conditions []interface{}, orderBy string) (*User, error) { query := db var users_data User // 検索条件セット if len(conditions) > 0 { query = query.Where(conditions[0], conditions[1:]...) } // ソート順セット if orderBy != "" { query = query.Order(orderBy) } else { // ソート順がない場合、user_id の昇順とする query = query.Order("user_id asc") } // データ検索 if err := query.First(&users_data).Error; err != nil { return nil, err } return &users_data, nil } /* * ユーザーデータの新規登録 * 【リクエスト】 * db: データベース接続オブジェクト * user_data: 登録データセット * * 【レスポンス】 * *User: 登録結果 * error: エラー内容 */ func CreateUserData(db *gorm.DB, user_data ...string) (*User, error) { // 登録データをセット newUser := &User{ User_name: user_data[0], Address: user_data[1], Password: user_data[2], } // ユーザーデータを新規登録 result := db.Create(newUser) if result.Error != nil { return nil, result.Error } return newUser, nil }
フロントエンド側の実装
View
【役割】
・Controller から指示された画面表示を行う
base.html : 共通ページ
{{ define テンプレートセクション名 }} 〜 {{ end }}
html/template"パッケージ内で使用される命令の一部で、あるテンプレート内で定義したテンプレートセクションを別のテンプレートから参照・挿入することができます。
今回は典型的な使い方として、
{{ define "base" }} 〜 {{ end }}
:ページの共通部分を分離・管理
{{ template "base" . }}
:他のテンプレート内で上記セクションを参照・挿入
しています。
-
base.html
{{ define "base" }} <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" /> <link rel="stylesheet" type="text/css" href="/static/css/style.css"> <link rel="icon" href="/favicon.ico"> <title>Book Search</title> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script src="/static/js/script.js"></script> </head> <nav> <p class="logo"><a href="/" style="color: #f3f3f3;text-decoration: none;font-size: 18px;">Book Search</a></p> <div class="search-form"> <input type="text" class="textbox search-input" name="keyword" value="" placeholder="書籍タイトル、著者名を検索"> <div style="display:block;height: 38px;background-color: #47CF73;width: 55px;float: left;margin-top: 1px;border-radius: 0 5px 5px 0;"> <button type="submit" class="search-button" style="font-weight: bold;height: 38px;"> <span class="material-symbols-outlined"> search </span> </button> </div> </div> <button id="beforeRedirectContents1" style="width: 150px;" class="signup" type="submit" onclick="location.href='/signup'">新規登録</button> <button id="beforeRedirectContents2" class="login" onclick="location.href='/login'" type="button">ログイン</button> <button id="afterRedirectContents1" class="logout" type="button">ログアウト</button> </nav> </html> <script> // ページ読み込み時の処理 $(document).ready(function () { // ログイン前後における表示切り替え toggleRedirectContents(); // ローカルストレージから検索文字列を取得、表示 var savedSearchInput = localStorage.getItem("search-input"); if (savedSearchInput) { $(".search-input").val(savedSearchInput); } // 最初のli要素にactiveクラスを付与 $("#header li:first-child").addClass("active"); }); // 検索ボタンがクリックされたときの処理 $(document).on("click", ".search-button", function () { // ログイン前後における表示切り替え toggleRedirectContents(); // 入力値をローカルストレージに保存 localStorage.setItem("search-input", $(".search-input").val()); localStorage.setItem("search-orderby", $("#search-orderby").val()); // フォームのデフォルトの送信を防止 event.preventDefault(); // URLに検索文字列、ページ番号、順序種別のパラメータを追加してsearch_books.htmlを再描画 var q = $(".search-input").val(); var page = 1; window.location.href = "/search?q=" + q + "&page=" + page; }); // ログアウトボタンがクリックされたときの処理 $(".logout").on("click", function () { // フォームデータをAjaxで送信する関数 $.ajax({ type: "PUT", url: "/login", success: function (response) { // ログインフォームを表示 window.location.href = "/login"; }, error: function (xhr, status, error) { // エラーの場合はエラーメッセージを表示 console.log(error); } }); }); // マイページメニューへカーソルを合わせたときの処理 $(".mypage").hover(function() { $(".mypage-menu").show(); }, function() { $(".mypage-menu").hide(); }); // li要素がクリックされたときの処理 $("#header li").on('click', function() { // すべてのli要素からactiveクラスを削除 $("#header li").removeClass("active"); // クリックされたli要素にactiveクラスを付与 $("#header li").addClass("active"); }); // ログイン前後における表示切り替え function toggleRedirectContents() { // ローカルストレージからログインステータスを取得 var savedLoginStatus = localStorage.getItem("login-status"); if (!savedLoginStatus) { // リダイレクト前のコンテンツを表示し、リダイレクト後のコンテンツを非表示にする $("#beforeRedirectContents1").show(); $("#beforeRedirectContents2").show(); $("#afterRedirectContents1").hide(); $("#afterRedirectContents2").hide(); return } if (savedLoginStatus == "not-login") { // リダイレクト前のコンテンツを表示し、リダイレクト後のコンテンツを非表示にする $("#beforeRedirectContents1").show(); $("#beforeRedirectContents2").show(); $("#afterRedirectContents1").hide(); $("#afterRedirectContents2").hide(); } else { // リダイレクト後のコンテンツを表示し、リダイレクト前のコンテンツを非表示にする $("#beforeRedirectContents1").hide(); $("#beforeRedirectContents2").hide(); $("#afterRedirectContents1").show(); $("#afterRedirectContents2").show(); } } </script> {{ end }}
login.html : ログインフォーム
-
login.html
{{ template "base" . }} <!DOCTYPE html> <html> <body> <h2 style="text-align: center;">ログイン</h2> <div class="container"> <!-- アラートの表示 --> <p class="error"></p> <p class="success"></p> <form id="loginForm"> <!-- ログインデータの入力エリア --> <div class="form-group" style="margin-top: 15px;"> <label for="address" style="float: left;">メールアドレス</label> <span class="require-item">*</span> <input id="address" type="text" name="address" placeholder="メールアドレス" required> </div> <div class="form-group"> <label for="password" style="float: left;">パスワード</label> <span class="require-item">*</span> <div style="position: relative;"> <input id="password" type="password" name="password" placeholder="パスワード" required> <div class="display-pwd-on-parent"> <span class="material-symbols-outlined display-pwd-on" onclick="togglePasswordVisibility()"> visibility_off </span> </div> </div> <p class="li-label">半角英数・記号:8~16文字</p> </div> <!-- ログイン --> <button class="btn" type="submit"> ログイン </button> <!-- ローディングオーバーレイ --> <div id="loadingOverlay"> <div class="loader"></div> </div> </form> </div> <script> // ページ読み込み時の処理 $(document).ready(function () { // リダイレクト前のコンテンツを表示し、リダイレクト後のコンテンツを非表示にする $("#beforeRedirectContents1").show(); $("#beforeRedirectContents2").show(); $("#afterRedirectContents1").hide(); $("#afterRedirectContents2").hide(); // ログインステータスをローカルストレージへ保存 localStorage.setItem("login-status", "not-login"); }) // ログインボタンがクリックされたときの処理 $("#loginForm").submit(function (e) { e.preventDefault(); // フォームデータをAjaxで送信する関数 var formData = $("#loginForm").serialize(); $.ajax({ type: "POST", url: "/login", data: formData, beforeSend: function() { // ローディングオーバーレイを表示 $("#loadingOverlay").show(); }, success: function (response) { // リダイレクト後のコンテンツを表示し、リダイレクト前のコンテンツを非表示にする $("#afterRedirectContents1").show(); $("#afterRedirectContents2").show(); $("#beforeRedirectContents1").hide(); $("#beforeRedirectContents2").hide(); // ログインフォームを非表示 $(".container").hide(); // ログインステータスをローカルストレージへ保存 localStorage.setItem("login-status", "logined"); window.location.href = "/"; }, complete: function() { // ローディングオーバーレイを非表示 $("#loadingOverlay").hide(); }, error: function (xhr, status, error) { // エラーの場合はエラーメッセージを表示 $(".error").text(xhr.responseJSON.Error); } }); }); </script> </body> </html>
menu.html : メニュー画面(初期表示される画面)
-
menu.html
{{ template "base" . }}
search_books.html : 書籍検索結果表示フォーム
-
search_books.html
{{ template "base" . }} <!DOCTYPE html> <html> <style> #searchResultForm { margin: 0 auto; max-width: 800px; z-index: -1; } #searchResult { border: solid 1px #dcdcdc; border-radius: 5px; overflow: hidden; } .itemListArea { padding: 20px; border-bottom: 1px solid #dcdcdc; background: #fff; position: relative; } .itemListArea:last-child { border-bottom: none; } .itemListArea { float: left; width: 760px; } .itemListImage img { width: 80px; overflow-clip-margin: content-box; overflow: clip; transition-duration: 0.2s; } .clearFix:after, .clearfix:after { content: " "; display: block; clear: both; } .itemListInfo { float: left; width: 660px; } .itemListInfo a { overflow: hidden; text-decoration: none; color: #4ea6cc; } #pagination { font-size: medium; margin: 20px 0 20px 0; display: flex; justify-content: center; align-items: center; gap: 0 8px; list-style-type: none; padding: 0; } #pagination .pagination-button { text-decoration: none; padding: 5px 10px; border: 1px solid #D2B48C; border-radius: 5px; color: #D2B48C; font-weight: 520; cursor: pointer; background-color: #fff; } #pagination .pagination-button.active { background-color: #D2B48C; color: #fff; } .itemImg { display: left; width: 80px; margin-right: 20px; float: left; } </style> <body> <div id="searchResultForm"> <!-- アラートの表示制御 --> {{ if .Error }} <p class="error">{{ .Error }}</p> {{ end }} {{ if .Success }} <p class="success">{{ .Success }}</p> {{ end }} <!-- 書籍データの表示制御 --> {{ if .Books }} <div id="searchResult"> {{range $book := .Books}} <div class="itemListArea clearFix"> <div class="itemListImage"> <!-- サムネイル画像の表示制御 --> {{ if $book.SmallThumbnail }} <img src="{{ $book.SmallThumbnail }}" alt="thumbnail" class="itemImg"> {{ else }} <img src="#" class="itemImg"> {{ end }} </div> <div class="itemListInfo"> <!-- 書籍タイトル・サブタイトルの表示 --> <h3 style="margin-top: 0;">{{ $book.Title }}{{ $book.Subtitle }}</h3> <!-- 著者名 / 出版社 / 出版日 の表示制御 --> {{ if and $book.Authors $book.Publisher }} <p>{{ range $book.Authors }}{{ . }}{{ end }} / {{ $book.Publisher }} / {{ $book.PublishedDate }}</p> {{ else if and $book.Authors (not (len $book.Publisher)) }} <p>{{ range $book.Authors }}{{ . }}{{ end }} / {{ $book.PublishedDate }}</p> {{ else if and (not (len $book.Authors)) $book.Publisher }} <p>{{ $book.Publisher }} / {{ $book.PublishedDate }}</p> {{ else }} <p>{{ $book.PublishedDate }}</p> {{ end }} <!-- 書籍概要 --> <p>{{ $book.Description }}</p> </div> </div> {{ end }} </div> {{ else }} <p>該当する書籍はありませんでした。</p> {{ end }} </div> <!-- ページネーションエリアの表示制御 --> {{ if .Books }} <div id="pagination"> {{if gt .CurrentPage 1}} <button class="pagination-button" data-page="{{ .Previous }}"><<</button> {{end}} {{ range $i := .Pages }} {{ if le . 10 }} {{ if eq . $.CurrentPage }} <button class="pagination-button active" data-page="{{ $i }}">{{ $i }}</button> {{ else }} <button class="pagination-button" data-page="{{ $i }}">{{ $i }}</button> {{ end }} {{ end }} {{ end }} {{if lt .CurrentPage .TotalPages}} <button class="pagination-button" data-page="{{ .Next }}">>></button> {{end}} </div> {{ end }} <script> // ページネーションボタンがクリックされたときの処理 $(document).on("click", ".pagination-button", function() { var searchWord = $(".search-input").val(); var currentPage = parseInt($(".pagination-button.active").data("page")); var page; if ($(this).text() === "<<") { page = currentPage - 1; } else if ($(this).text() === ">>") { page = currentPage + 1; } else { page = $(this).data("page"); } window.location.href = "/search?q=" + searchWord + "&page=" + page; }); </script> </body> </html>
signup.html : 新規登録フォーム
-
signup.html
{{ template "base" . }} <!DOCTYPE html> <html> <body> <h2 style="text-align: center;">ユーザー登録</h2> <div class="container"> <p class="error"></p> <p class="success"></p> <form id="signupForm"> <!-- 新規登録データの入力エリア --> <div class="form-group" style="margin-top: 15px;"> <label for="username" style="float: left;">ユーザー名</label> <span class="require-item">*</span> <input id="username" type="text" name="username" placeholder="ユーザー名" required> </div> <div class="form-group"> <label for="address" style="float: left;">メールアドレス</label> <span class="require-item">*</span> <input id="address" type="text" name="address" placeholder="メールアドレス" required> </div> <div class="form-group"> <label for="password" style="float: left;">パスワード</label> <span class="require-item">*</span> <div style="position: relative;"> <input id="password" type="password" name="password" placeholder="パスワード" required> <div class="display-pwd-on-parent"> <span class="material-symbols-outlined display-pwd-on" onclick="togglePasswordVisibility()"> visibility_off </span> </div> </div> <p class="li-label">半角英数・記号:8~16文字</p> </div> <!-- 新規登録 --> <button class="btn" type="submit"> 登録 </button> <!-- ローディングオーバーレイ --> <div id="loadingOverlay"> <div class="loader"></div> </div> </form> </div> <script> // 登録ボタンがクリックされたときの処理 $("#signupForm").submit(function (e) { e.preventDefault(); // フォームデータをAjaxで送信する関数 var formData = $("#signupForm").serialize(); $.ajax({ type: "POST", url: "/signup", data: formData, beforeSend: function() { // ローディングオーバーレイを表示 $("#loadingOverlay").show(); }, success: function (response) { // リダイレクト後のコンテンツを表示し、リダイレクト前のコンテンツを非表示にする $("#afterRedirectContents1").show(); $("#afterRedirectContents2").show(); $("#beforeRedirectContents1").hide(); $("#beforeRedirectContents2").hide(); // ログインフォームを非表示 $(".container").hide(); // ログインステータスをローカルストレージへ保存 localStorage.setItem("login-status", "logined"); window.location.href = "/"; }, complete: function() { // ローディングオーバーレイを非表示 $("#loadingOverlay").hide(); }, error: function (xhr, status, error) { // エラーの場合はエラーメッセージを表示 $(".error").text(xhr.responseJSON.Error); } }); }); </script> </body> </html>
static/css/style.css : 共通CSS
-
static/css/style.css
.logo { margin-top: -28px; width: 70px; height: 20px; margin-right: auto; margin-left: 0; font-size: 18px; font-weight: bold; padding: 10px 0 0 20px; color: #f3f3f3; -webkit-text-stroke: 0.5px #FFF; } .logo img { width: 100%; height: auto; } nav { position:fixed; z-index: 8000; top: 0; left: 0; display: flex; align-items: center; height: 70px; width: 100%; background: #D2B48C; box-shadow: 0 0 8px gray; } .material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48 } .material-symbols-outlined.icon-style { font-size: 25px; display: block; float: left; margin: -3px 5px 0 0; color: #D2B48C; } .search-form { margin-right: -245px; width: 400px; } .search-input { display:inline-block; height: 35.5px; width: 350px; line-height: 34px; border: none; color: #646464; letter-spacing: .1px; outline: none; float: left; margin: 1px -27px 0 0; padding-left: 5px; border-radius: 5px 0 0 5px; } .signup { margin-left: 250px; margin-right: 10px; padding: .5em 1.5em; background: #47CF73; font-size: 14px; border-radius: 5px; border: 1px solid #47CF73; display: flex; justify-content: space-around; font-weight: bold; } .login { margin-right: 20px; padding: .5em 1.5em; background: #fff; width: 130px; font-size: 15px; border-radius: 5px; border: none; display: flex; justify-content: space-around; outline: 0; color: #D2B48C; font-weight: bold; } .logout { margin-left: 250px; margin-right: 20px; padding: .5em 1.5em; background: #fff; width: 130px; font-size: 15px; border-radius: 5px; border: none; display: flex; justify-content: space-around; outline: 0; color: #D2B48C; font-weight: bold; } .btn { border-radius: 5px; cursor: pointer; box-sizing: border-box; margin: 20px 0 0 -40px; width: 150px; border: 1px solid #47CF73; text-decoration: none; font-size: 15px; font-weight: bold; } button[type="submit"] { padding: 8px 15px; background-color: #47CF73; color: #fff; border: none; border-radius: 5px; cursor: pointer; } button[type="submit"]:hover { border-color: #30ab58; background-color: #30ab58; } button[type="button"] { padding: 8px 15px; background-color: #47CF73; color: #fff; border: none; border-radius: 5px; cursor: pointer; } button[type="button"]:hover { border-color: #30ab58; background-color: #30ab58; } header { position:fixed; left: 0; width: 100%; height: 50px; top: 70px; background-color: #f3f3f3; border: 1px solid #ddd; z-index: 400; } #header { list-style: none; } #header li { text-align: center; float: left; display: flex; padding: 0 10px; } #header li a { text-decoration: none; background: transparent; } .active a { color: #D2B48C; padding-bottom: 11px; border-bottom: 2px solid #D2B48C; font-weight: bold; } #header li:not(.active, .mypage-menu) a { color: #000; } #header li:not(.active) a:hover { color: #D2B48C; padding-bottom: 11px; border-bottom: 2px solid #D2B48C; font-weight: bold; } #header li .mypage-menu { background-color: #f9f9f9; border-radius: 5px; padding: 10px; position: absolute; top: 50px; right: 53.4%; transform: translateX(-50%); display: none; border: 1px #cdcdcd solid; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); width: 112px; } #header li .mypage-menu a { display: block; padding: 10px; text-decoration: none; border-bottom: none; font-size: 12px; } header #search-orderby { float: right; margin-right: 20px; color: #000; border-radius: 5px; height: 25px; font-size: 15px; width: 220px; text-align: center; } body { position:relative; margin-top: 100px; min-width: 960px; color: #000; font-size: 15px; background-color: #fafafa; font-family: YuGothic,'Yu Gothic','Arial','Hiragino Kaku Gothic ProN','ヒラギノ角ゴ ProN W3','メイリオ', Meiryo,'MS ゴシック',sans-serif; } .container { max-width: 400px; margin: 0 auto; padding: 20px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 5px; } .form-group { margin-bottom: 15px; } #username, #address, #password { width: 95.5%; padding: 8px; border: 1px solid #ddd; border-radius: 5px; } .error { color: red; } .success { color: green; } .require-item { font-weight: bold; font-size: 130%; color: #da4740; } .li-label { color: #646464; font-size: small; margin-top: 0; float: left; } .display-pwd-on-parent { cursor: pointer; margin: -28px 0 0 367px; } .display-pwd-on { font-size: x-large; font-weight: bold; color: black; margin-top: 0; text-align: right; } /* ローディングオーバーレイのスタイル */ #loadingOverlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 9999; } /* ローディングアイコンのスタイル */ .loader { border: 4px solid rgba(255, 255, 255, 0.3); border-top: 4px solid #ffffff; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } /* ローディングアイコンのアニメーション */ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
static/js/script.js : 共通JS
-
static/js/script.js
// パスワード表示・非表示切り替え function togglePasswordVisibility() { var passwordField = document.getElementById("password"); var passwordToggle = document.querySelector(".display-pwd-on"); if (passwordField.type === "password") { passwordField.type = "text"; passwordToggle.textContent = "visibility"; } else { passwordField.type = "password"; passwordToggle.textContent = "visibility_off"; } }
動作確認
上記の通り、環境構築および MVC ファイルを準備できたらdocker-compose up -d
を実行してコンテナを立ち上げます。各機能の動きを確認してみてください。
長くなるので、入力チェック等の細かい動作解説は割愛します。
内部的には処理が実装されていますので各 Controller を見てみてください。
初期画面
新規登録機能
入力したユーザー名、メールアドレス、パスワードが、ユーザーデータとして users テーブルへ登録されます。
登録データ : phpmyadmin
新規登録したユーザーデータは users テーブルで管理されています。
ログイン機能
ユーザー登録したメールアドレス、パスワードでログインが可能です。
ログアウト機能
書籍検索機能
検索エリアで入力した書籍タイトル、著者名等をもとに、書籍情報が検索・リスト表示されます。
今回は下記情報のみを表示していますが、APIで取得される他のパラメータを追加することで簡単にカスタマイズ可能です。
・書籍のサムネイル画像
・書籍タイトル・サブタイトル
・著者名 / 出版社 / 出版日
・書籍概要
その他
アイコンは主に Google の Material Icons を使用しています。
参考文献
Google Books APIs
MVCモデルについて
go.mod、go.sumファイルは何なのか
O/Rマッパーとは?O/Rマッピングの基本概念と実践的な使い方を解説
Material Symbols and Icons - Google Fonts