1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Go】Google Books APIs を使って書籍検索アプリを作成する

Last updated at Posted at 2023-08-25

はじめに

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.gotype 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 を見てみてください。

初期画面

コンテナ起動後、最初に表示される画面です。
初期画面.png

新規登録機能

入力したユーザー名、メールアドレス、パスワードが、ユーザーデータとして users テーブルへ登録されます。
新規登録.png

登録データ : phpmyadmin

新規登録したユーザーデータは users テーブルで管理されています。
スクリーンショット 2023-08-25 12.19.39.png

ログイン機能

ユーザー登録したメールアドレス、パスワードでログインが可能です。
ログイン.png

ログアウト機能

その名の通り、ログアウトを行う機能です。
ログアウト.png

書籍検索機能

検索エリアで入力した書籍タイトル、著者名等をもとに、書籍情報が検索・リスト表示されます。
今回は下記情報のみを表示していますが、APIで取得される他のパラメータを追加することで簡単にカスタマイズ可能です。
 ・書籍のサムネイル画像
 ・書籍タイトル・サブタイトル
 ・著者名 / 出版社 / 出版日
 ・書籍概要
書籍検索.png

その他

アイコンは主に Google の Material Icons を使用しています。

参考文献

Google Books APIs
MVCモデルについて
go.mod、go.sumファイルは何なのか
O/Rマッパーとは?O/Rマッピングの基本概念と実践的な使い方を解説
Material Symbols and Icons - Google Fonts

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?