LoginSignup
3
4

Go+Echo+GORM(mysql)+DockerでSession,JWT付きログインWebアプリ

Last updated at Posted at 2023-07-27

はじめに

 どうも、Shakkuです。
 都内某高専情報科の3年生です。(2023/7/27時点)
 Go言語をお勉強し始めてから何ヶ月か経過したある日、そろそろちゃんとしたものを作ってアウトプットしたいなーと思ったので、作成,投稿してみました。
 作成したものは、GitHubにもあげています。(https://github.com/Shakkuuu/echo-login-app)

 どんなコードかだけ見たい方は上のGitHubを、ざっくりとした構成や使用技術の概要も見たい方は、下の「概要」「構成」を、コードの中身の説明も見たい方は、最後まで読んでいただければと思います。

概要

 Signup、Login、ログイン後のメモ登録機能があるWebアプリ。
 メモには、作成したユーザーのユーザーIDが紐づけられているため、そのユーザーだけに表示され、ユーザーが削除されると連動してそのユーザーが作成したメモも削除される。

 Docker、DockerComposeを使用して、バックエンド・API・DBのサーバーを同一ネットワークでたてている。

 フロントエンド?をHTML、バックエンドとAPIをGo言語で作り、基本的に、
バックエンドにアクセス

HTMLテンプレートが返ってきて表示

form入力バックエンドに送信

バックエンドで処理&APIとのやりとり

HTMLテンプレートを返す
といったような流れとなっている。

 ユーザー情報やメモはmysqlのデータベースに保存される。
 DBとのやりとりは、APIサーバー内でGo言語のORMの一つであるGORMを使用して行っている。

 ログイン後の認証は、フロントエンド(HTML)とバックエンド間の認証をSessionで行い、バックエンドとAPI間の認証をJWTで行った。

 APIとの通信時やDBに保存されるパスワードは、バックエンドでbcryptを用いてハッシュ化されたものが使用される。

参考画面

index

index.png

signup

signup.png

login

login.png

apptop

apptop.png

userpage

usrepage.png

memotop

memotop.png

memoview

memoview.png

入力忘れ画面

nyuuryokuwasure.png

使用した技術

大きくバージョンが違わなければ、基本的にどのバージョンでも動作できると思います。

使用したパッケージ

  • os
  • net/http
  • fmt
  • log
  • time
  • strconv
  • math/rand
  • io
  • text/template
  • encoding/json
  • bytes
  • github.com/go-sql-driver/mysql (1.7.1)
  • github.com/golang-jwt/jwt (3.2.2)
  • github.com/jinzhu/gorm (1.9.16)
  • github.com/labstack/echo/v4 (4.11.0)
  • github.com/labstack/echo/v4/middleware
  • golang.org/x/crypto (0.11.0)
  • github.com/gorilla/sessions (1.2.1)
  • github.com/labstack/echo-contrib (0.15.0)
  • github.com/labstack/echo-contrib/session

選定理由

Echo

 Goのフレームワークで、処理の速さやミドルウェアの豊富さ、Renderやレスポンスの便利さから利用した。
他にもGinなども使用したことがあるが、Echoの方が処理がはやいらしいのと、ミドルウェアの作成やHTMLテンプレートの操作などにも挑戦してみたかったため、Echoを選んだ。

mysql

 唯一ちゃんと触ったことのあるリレーショナルデータベースだったから...(ちゃんとした理由なくてすみません...)

GORM

 GoのORMと呼ばれるものの一つで、特徴として、マイグレーション機能やクエリを直接書かなくていいため行数が少なくて済むなどがある。
 マイグレーションでは、Goの構造体を直接テーブルに変換でき、構造体のフィールドを追加削除しても、DBのデータは残したままテーブルの変更ができ、そこに魅力を感じた。
 また、クエリ発行時の関数を作りたい時に、クエリのSQL文を長々と書かなくても、関数を使うだけで自動で変換してくれることがとても便利だった。

 他は、その技術で一般的に使用されているパッケージを使用したり、参考資料が豊富だったものを使用しています。

構成

ルーティング

api

ping

GET /

api ユーザー機能

  • 全ユーザー取得

GET /user

  • ユーザー作成

POST /user

  • IDからユーザー取得

GET /user/id/:id

  • ユーザー名からユーザー取得

GET /user/username/:username

  • IDからユーザー更新

PUT /user/:id

  • IDからユーザー削除

DELETE /user/:id

  • ログイン

POST /user/login

api メモ機能

  • 全メモ取得

GET /memo

  • メモ作成

POST /memo

  • IDからメモ取得

GET /memo/id/:id

  • ユーザーIDからメモ取得

GET /memo/user_id/:user_id

  • IDからメモ更新

PUT /memo/:id

  • IDからメモ削除

DELETE /memo/:id

app

app ログイン機能

  • indexページ

GET /

  • SignUpページ

GET /signup

  • Signup

POST /signup

  • Loginページ

GET /login

  • Login

POST /login

app ユーザー設定

  • Logout

GET /setting/logout

  • ユーザー名変更ページ

GET /setting/changename

  • ユーザー名変更

POST /setting/changename

  • パスワード変更ページ

GET /setting/changepassword

  • パスワード変更

POST /setting/changepassword

  • ユーザー削除

DELETE /setting/delete

app ログイン後のアプリ

  • topページ

GET /app

  • ユーザーページ

GET /app/userpage

app メモ機能

  • 自分のメモ一覧表示ページ

GET /app/memo

  • メモ作成ページ

GET /app/memo/create

  • メモ作成

POST /app/memo/create

  • メモの中身表示

GET /app/memo/view/:id

  • メモ削除

GET /app/memo/delete/:id

  • メモ中身更新

POST /app/memo/change/:id

ディレクトリ構成

echo-login-app/
├── api
│   ├── controller
│   │   ├── memo_controller.go
│   │   └── user_controller.go
│   ├── db
│   │   └── db.go
│   ├── entity
│   │   └── entity.go
│   ├── server
│   │   └── server.go
│   ├── service
│   │   ├── memo_service.go
│   │   └── user_service.go
│   ├── dockerfile
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── app
│   ├── controller
│   │   ├── app_controller.go
│   │   ├── auth_controller.go
│   │   ├── memo_controller.go
│   │   └── user_controller.go
│   ├── entity
│   │   └── entity.go
│   ├── server
│   │   └── server.go
│   ├── service
│   │   ├── memo_service.go
│   │   └── user_service.go
│   ├── views
│   │   ├── index.html
│   │   ├── login.html
│   │   ├── memocreate.html
│   │   ├── memotop.html
│   │   ├── signup.html
│   │   ├── top.html
│   │   ├── userchangename.html
│   │   ├── userchangepassword.html
│   │   └── userpage.html
│   ├── dockerfile
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── db
│   └── my.cnf
├── .env
├── .gitignore
├── docker-compose.yml
└── README.md

ポート

  • api:8081
  • app:8082
  • db:3307

環境変数

.envファイルに以下の環境変数を記載しておく。

ELA_ROOTPASS=dbのrootのpassword
ELA_DATABASE=dbのデータベース名
ELA_USERNAME=dbのユーザー名
ELA_USERPASS=dbのユーザーのパスワード
SESSION_KEY=appのセッション用キー
TOKEN_KEY=apiのToken用キー

entity

User

{
    "id":12345678,
    "name":"Shakku",
    "password":"$2a$10$nj.KCcTpJH.9bNrVkPho9.dTDlbXq1jyM7I5gEHmmv5Fu4J4Lpvr6",
    "createdat":"2023-05-21T12:34:56+09:00",
}

Memo

{
    "id":1,
    "title":"タイトル",
    "content":"内容",
    "createdat":"2023-05-21T12:34:56+09:00",
    "user_id":12345678,
}

ResponseMessage

{
    "status":200,
    "Message":"pong",
}

Token

{
    "token":"トークンがここに入ります",
}

補足

  • UserのIDは99999999までのランダムな整数が自動で設定されます。
  • UserのPasswordはbcryptによってハッシュ化されて、apiとのやりとりや、データベースへの保存に使用されます。
  • UserのGetAllやMemoのGetAll,GetByUserIDなど、複数のデータを取得する際は、リスト形式のJSONがレスポンスとして帰ってきます。
  • MemoのUser_IDは、そのメモを作成したユーザーのIDがForeignKeyとして保存されます。それにより、そのユーザーが作成したメモをUser_IDから全取得できます。
| memos | CREATE TABLE `memos` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `content` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `user_id` int DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `memos_user_id_users_id_foreign` (`user_id`),
  CONSTRAINT `memos_user_id_users_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci |

認証

フロントエンド(HTML)とバックエンド間の認証をSessionで行い、バックエンドとAPI間の認証をJWTで行った。

流れは以下の通り。

  • フロントエンドのformにてユーザー名とパスワードを入力する。
  • バックエンドでそのformを受け取り、ユーザー名が存在するかの確認を行ったのち、ユーザーIDとパスワードをAPIにPOSTする。
  • APIでユーザーIDからユーザー情報を取り出し、bcrypt.CompareHashAndPasswordを使用してPOSTされたパスワードとパスワードが一致するか確認する。
  • パスワードが問題なければ、APIでTokenを発行し、バックエンドに送る。
  • バックエンドで、受け取ったTokenやUserIDなどの入ったSessionを作成し、フロントエンドのCookieに保存する。
  • ログイン完了。

appでは、/app以下や/settingのURLにアクセスしようとすると、Session確認用のミドルウェアが実行され、Sessionの確認を行う。

apiでは、/memoのURLにアクセスしようとすると、middleware.JWTが実行されて、Tokenの確認が行われる。

Middleware

APIとバックエンド共通

  • 突然のサーバーダウン時のリカバー用
middleware.Recover()
  • ログ出力のフォーマット化
middleware.LoggerWithConfig()
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
        Format: "\n" + `time: ${time_rfc3339_nano}` + "\n" +
            `method: ${method}` + "\n" +
            `remote_ip: ${remote_ip}` + "\n" +
            `host: ${host}` + "\n" +
            `uri: ${uri}` + "\n" +
            `status: ${status}` + "\n" +
            `error: ${error}` + "\n" +
            `latency: ${latency}(${latency_human})` + "\n",
    }))

api用

  • JWTのToken確認
middleware.JWT([]byte("tokenkey"))

app用

  • Session用キーの設定
session.Middleware(sessions.NewCookieStore([]byte("sessionkey")))
  • controller.AuthController.SessionCheck()オリジナルミドルウェア。
auc.SessionCheck

Session内の"auth"がTrueかどうかで、このSessionが有効か無効かを判断する。

HTMLテンプレート

text/templateとechoのRender機能にて、HTMLを使用した。

type TemplateRender struct {
    templates *template.Template
}

func (t *TemplateRender) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    return t.templates.ExecuteTemplate(w, name, data)
}

func Init() {

    省略

    renderer := &TemplateRender{
        templates: template.Must(template.ParseGlob("./views/*.html")),
    }
    e.Renderer = renderer

    省略
}
// GET indexページ表示
func (uc UserController) Index(c echo.Context) error {
    var us service.UserService

    // ユーザー全取得
    u, err := us.GetAll()
    if err != nil {
        log.Println("us.GetAll error")
    }
    return c.Render(http.StatusOK, "index.html", u)
}
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>index</title>
</head>
<body>
    <h1>index</h1>
    {{range $v := .}}
        <div>
            <div>
                <p>{{$v.Name}}</p>
            </div>
        </div>
    {{end}}
    <p><a href="/login">ログイン</a></p>
    <p><a href="/signup">サインアップ</a></p>
    <p><a href="/app">メインページ(ログインが必要です)</a></p>
</body>
</html>

appのコード

 以下はappのコードの解説です。
 app,api,その他のどれも、お勉強したことのおさらいをしながら書いているので、「こんなの知っとるわ!」「こんなことまで書かなくてもいいだろ!」ってとこまで書いていたりしますが、お気になさらず。

main

 mainでは、環境変数の読み込みと、APIの起動確認が実行される。
 loadEnv()では、docker-compose実行時にコンテナに設定された環境変数を、os.Getenv()で読み込んでいる。
 loadEnv()で読み込まれた環境変数はserver.Initに渡され、使用される。

 ConnectCheck()では、APIサーバーの起動確認が行われており、http.Get()でエラーが発生した場合は、forでcountが180になるまで1秒おきに再接続を試みるようにしている。
 返ってきたStatuscodeが200であれば、完了としている。(ここでアクセスしているものは、apiのPong()コントローラー)

main.go
package main

import (
	"echo-login-app/app/server"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)

var (
	resp *http.Response
	err  error
)

func main() {
	// 環境変数読み込み
	sk := loadEnv()
	ConnectCheck()
	server.Init(sk)
}

// 環境変数読み込み
func loadEnv() string {
	// Docker-compose.ymlでDocker起動時に設定した環境変数の取得
	session_key := os.Getenv("SESSION_KEY")

	return session_key
}

func ConnectCheck() {
	url := "http://echo-login-app-api:8081/"

	// 接続できるまで一定回数リトライ
	count := 0
	resp, err = http.Get(url)
	if err != nil {
		for {
			if err == nil {
				fmt.Println("")
				break
			}
			fmt.Print(".")
			time.Sleep(time.Second)
			count++
			if count > 180 { // countgaが180になるまでリトライ
				fmt.Println("")
				log.Printf("api connect error: %v\n", err)
				panic(err)
			}
			resp, err = http.Get(url)
		}
	}
	if resp.StatusCode != 200 {
		for {
			if resp.StatusCode == 200 {
				fmt.Println("")
				break
			}
			fmt.Print(".")
			time.Sleep(time.Second)
			count++
			if count > 180 { // countgaが180になるまでリトライ
				fmt.Println("")
				log.Printf("api connect error: %v status: %v\n", err, resp.Status)
				panic(err)
			}
			resp, err = http.Get(url)
		}
	}
}

controller

 ユーザー機能系にアクセスされた時のコントローラー。

 type UserController struct{}でメモのコントローラーをクラスのメソッドのようにまとめている。

 基本的には、serviceの関数にアクセスしてユーザーの取得や登録などを行ったのち、Renderでhtmlを返すといった流れとなっている。

 標準パッケージであるnet/httpでサーバーをたてるときは、ハンドラーにw http.ResponseWriter, r *http.Requestを引数として与えているが、それの代わりとして、c echo.Contextを使うことで、echoのコンテキストでルーティングができる。

 echoの機能である c.Render()の第一引数にステータスコード、第二引数に表示したいhtmlファイル名、第三引数に構造体やmapなどの渡したいデータを入れることで、ブラウザに表示させる。
 c.Param()では、リクエストされたURLのパラメータから値をstringで取得することができ、memoのContentView()の場合では、c.Param("id")とすることで、/app/memo/view/:id:idの部分にある値を取得することができる。(例:/app/memo/view/123→"123"が取得される。)
 c.FormValue()では、formからPOSTされたデータを取得できる。

 Signup()では、formから値をもらったのち、入力漏れチェック(空文字列が送られてきていないか)、確認用の再入力パスワードと一致しているか、既にそのユーザー名が使用されていないか(ログイン時にユーザー名からユーザーIDを取得するため)と確認し、"rand"パッケージで1~99999999までのユーザーIDをランダムに生成、"bcrypt"パッケージのGenerateFormPassword()でパスワードをハッシュ化して、それらをUserServiceのユーザー作成の関数に送ってユーザーが作成される。
 作成されたのち、ログイン画面にRedirectでリダイレクトする。

 Login()では、formから値をもらったのち、入力漏れチェック、ユーザーが存在するかどうか、ユーザー名からID取得をした後に、ServiceのLogin関数でパスワードチェックを行い、tokenをもらう。
 その後、セッションを作成し、appのtopページにリダイレクトする。

 Logout()Delete()では、セッションの無効化を行っている。
 authをfalseにして無効判定にしたのち、OptionsのMaxAgeも-1にして、セッションの有効期限をなくしている。(セッションの操作の詳細はauth_controllerで説明する。)

 ChangePassword()のパスワード変更機能では、よくあるパスワード変更画面のように、新しいパスワードと新しいパスワードの確認要塞入力に加えて、現在のパスワードも入力させるようにしている。
 この場合、us.Login()を呼び出す時、tokenは必要ないため、第三引数にfalseが入っている。

user_controller.go
package controller

import (
	"echo-login-app/app/service"
	"fmt"
	"log"
	"math/rand"
	"net/http"
	"time"

	"github.com/labstack/echo-contrib/session"
	"github.com/labstack/echo/v4"
	"golang.org/x/crypto/bcrypt"
)

type UserController struct{}

// GET indexページ表示
func (uc UserController) Index(c echo.Context) error {
	var us service.UserService

	// ユーザー全取得
	u, err := us.GetAll()
	if err != nil {
		log.Println("us.GetAll error")
	}
	return c.Render(http.StatusOK, "index.html", u)
}

// GET Loginページ表示
func (uc UserController) LoginView(c echo.Context) error {
	m := map[string]interface{}{
		"message": "",
	}
	return c.Render(http.StatusOK, "login.html", m)
}

// GET Signupページ表示
func (uc UserController) SignupView(c echo.Context) error {
	m := map[string]interface{}{
		"message": "",
	}
	return c.Render(http.StatusOK, "signup.html", m)
}

// POST Login処理
func (uc UserController) Login(c echo.Context) error {
	var us service.UserService

	// htmlのformから値取得
	username := c.FormValue("username")
	password := c.FormValue("password")

	// 入力漏れチェック
	if username == "" || password == "" {
		log.Println("入力されていない項目があるよ。")
		m := map[string]interface{}{
			"message": "入力されていない項目があるよ。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	// ユーザー名が存在するかチェック
	ulist, err := us.GetAll()
	if err != nil {
		log.Println("us.GetAll error")
	}
	var count int = 0
	for _, v := range ulist {
		if v.Name == username {
			count++
		}
	}
	if count == 0 {
		log.Println("そのユーザー名は存在しません")
		m := map[string]interface{}{
			"message": "そのユーザー名は存在しません",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	// ユーザー名からID取得
	u, err := us.GetByName(username)
	if err != nil {
		log.Println("ID取得時にエラーが発生しました。")
		m := map[string]interface{}{
			"message": "ID取得時にエラーが発生しました。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	// Login処理 パスワードチェック
	token, err := us.Login(u.ID, password, true)
	if err != nil {
		log.Println("us.Login error")
		m := map[string]interface{}{
			"message": err,
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	var auc AuthController
	// セッション
	err = auc.SessionCreate(c, u, token)
	if err != nil {
		log.Printf("auc.SessionCreate error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの確立に失敗しました。もう一度お試しください。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	fmt.Println("ログイン成功したよ")
	return c.Redirect(http.StatusFound, "/app")
}

// POST Signup処理
func (uc UserController) Signup(c echo.Context) error {
	var us service.UserService

	// htmlからformの取得
	username := c.FormValue("username")
	password := c.FormValue("password")
	checkpass := c.FormValue("checkpassword")

	// 入力漏れチェック
	if username == "" || password == "" || checkpass == "" {
		log.Println("入力されていない項目があるよ。")
		m := map[string]interface{}{
			"message": "入力されていない項目があるよ。",
		}
		return c.Render(http.StatusBadRequest, "signup.html", m)
	}

	// 確認用再入力パスワードがあっているか
	if password != checkpass {
		log.Println("パスワードが一致していないよ。")
		m := map[string]interface{}{
			"message": "パスワードが一致していないよ。",
		}
		return c.Render(http.StatusBadRequest, "signup.html", m)
	}

	// 既にユーザー名が使用されていないかチェック
	u, err := us.GetAll()
	if err != nil {
		log.Println("us.GetAll error")
	}
	for _, v := range u {
		if v.Name == username {
			log.Println("そのユーザー名は既に使われているよ")
			m := map[string]interface{}{
				"message": "そのユーザー名は既に使われているよ。",
			}
			return c.Render(http.StatusBadRequest, "signup.html", m)
		}
	}

	// ID生成
	rand.Seed(time.Now().UnixNano())
	id := rand.Intn(100000000)

	// パスワードのハッシュ化
	hashp, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		log.Println("bcrypt.GenerateFromPassword error")
		m := map[string]interface{}{
			"message": "ユーザー作成時にエラーが発生しました。",
		}
		return c.Render(http.StatusBadRequest, "signup.html", m)
	}

	// ユーザー作成
	err = us.Create(id, username, string(hashp))
	if err != nil {
		log.Println("us.Create error")
		m := map[string]interface{}{
			"message": "ユーザー作成時にエラーが発生しました。",
		}
		return c.Render(http.StatusBadRequest, "signup.html", m)
	}

	fmt.Println("ユーザー登録成功したよ")
	return c.Redirect(http.StatusFound, "/login")
}

// GET ログイン後のユーザーページ
func (uc UserController) UserPage(c echo.Context) error {
	var auc AuthController

	// セッション
	id, err := auc.IDGetBySession(c)
	if err != nil {
		log.Printf("auc.IDGetSession error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの取得に失敗しました。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	var us service.UserService
	// セッションに保存されているIDからユーザーデータの取得
	u, err := us.GetByID(id)
	if err != nil {
		log.Printf("service.GetByID error: %v\n", err)
		m := map[string]interface{}{
			"message": "ユーザーデータの取得に失敗しました。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}
	return c.Render(http.StatusOK, "userpage.html", u)
}

// GET ログアウト処理
func (uc UserController) Logout(c echo.Context) error {
	// セッション
	sess, err := session.Get("session", c)
	if err != nil {
		log.Printf("session.Get error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの取得に失敗しました。もう一度お試しください。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	// セッションの無効化
	sess.Values["auth"] = false
	sess.Options.MaxAge = -1
	err = sess.Save(c.Request(), c.Response())
	if err != nil {
		log.Printf("session.Save error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの削除に失敗しました。もう一度お試しください。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}
	m := map[string]interface{}{
		"message": "ログアウトしたよ。",
	}
	return c.Render(http.StatusOK, "login.html", m)
}

// GET ユーザー名変更ページ
func (uc UserController) ChangeNameView(c echo.Context) error {
	m := map[string]interface{}{
		"message": "",
	}
	return c.Render(http.StatusOK, "userchangename.html", m)
}

// POST ユーザー名変更処理
func (uc UserController) ChangeName(c echo.Context) error {
	var us service.UserService
	var auc AuthController

	// htmlのformから値の取得
	username := c.FormValue("username")

	// 入力漏れのチェック
	if username == "" {
		log.Println("入力されていない項目があるよ。")
		m := map[string]interface{}{
			"message": "入力されていない項目があるよ。",
		}
		return c.Render(http.StatusBadRequest, "userchangename.html", m)
	}

	// ユーザー名が既に使用されていないかチェック
	u, err := us.GetAll()
	if err != nil {
		log.Println("us.GetAll error")
	}
	for _, v := range u {
		if v.Name == username {
			log.Println("そのユーザー名は既に使われているよ")
			m := map[string]interface{}{
				"message": "そのユーザー名は既に使われているよ。",
			}
			return c.Render(http.StatusBadRequest, "userchangename.html", m)
		}
	}

	// セッション
	id, err := auc.IDGetBySession(c)
	if err != nil {
		log.Printf("auc.IDGetSession error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの取得に失敗しました。",
		}
		return c.Render(http.StatusBadRequest, "userchangename.html", m)
	}

	// ユーザー名変更処理
	err = us.ChangeName(id, username)
	if err != nil {
		log.Println("us.ChangeName error")
		m := map[string]interface{}{
			"message": "ユーザー名変更時にエラーが発生しました。",
		}
		return c.Render(http.StatusBadRequest, "userchangename.html", m)
	}

	fmt.Println("ユーザー名変更成功したよ")
	return c.Redirect(http.StatusFound, "/app")
}

// GET ユーザーパスワード変更ページ
func (uc UserController) ChangePasswordView(c echo.Context) error {
	m := map[string]interface{}{
		"message": "",
	}
	return c.Render(http.StatusOK, "userchangepassword.html", m)
}

// POST ユーザーパスワード変更処理
func (uc UserController) ChangePassword(c echo.Context) error {
	var us service.UserService
	var auc AuthController

	// htmlのformから値の取得
	oldpassword := c.FormValue("oldpassword")
	newpassword := c.FormValue("newpassword")
	newcheckpassword := c.FormValue("newcheckpassword")

	// 入力漏れのチェック
	if oldpassword == "" || newpassword == "" || newcheckpassword == "" {
		log.Println("入力されていない項目があるよ。")
		m := map[string]interface{}{
			"message": "入力されていない項目があるよ。",
		}
		return c.Render(http.StatusBadRequest, "userchangepassword.html", m)
	}

	// 新しいパスワードと確認用再入力パスワードが一致しているかチェック
	if newpassword != newcheckpassword {
		log.Println("新しいパスワードが一致していないよ。")
		m := map[string]interface{}{
			"message": "新しいパスワードが一致していないよ。",
		}
		return c.Render(http.StatusBadRequest, "userchangepassword.html", m)
	}

	// セッション
	id, err := auc.IDGetBySession(c)
	if err != nil {
		log.Printf("auc.IDGetSession error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの取得に失敗しました。",
		}
		return c.Render(http.StatusBadRequest, "userchangepassword.html", m)
	}

	// パスワードチェック
	_, err = us.Login(id, oldpassword, false)
	if err != nil {
		log.Println("us.Login error")
		m := map[string]interface{}{
			"message": err,
		}
		return c.Render(http.StatusBadRequest, "userchangepassword.html", m)
	}

	// パスワードのハッシュ化
	hashp, err := bcrypt.GenerateFromPassword([]byte(newpassword), bcrypt.DefaultCost)
	if err != nil {
		log.Println("bcrypt.GenerateFromPassword error")
		m := map[string]interface{}{
			"message": "パスワード変更時にエラーが発生しました。",
		}
		return c.Render(http.StatusBadRequest, ".html", m)
	}

	// パスワードの変更処理
	err = us.ChangePassword(id, string(hashp))
	if err != nil {
		log.Println("us.ChangePassword error")
		m := map[string]interface{}{
			"message": "パスワード変更時にエラーが発生しました。",
		}
		return c.Render(http.StatusBadRequest, "userchangepassword.html", m)
	}

	fmt.Println("パスワードの変更成功したよ")
	m := map[string]interface{}{
		"message": "パスワードの変更成功したよ",
	}
	return c.Render(http.StatusFound, "userchangepassword.html", m)
}

// GET ユーザー削除処理
func (uc UserController) Delete(c echo.Context) error {
	var us service.UserService

	// セッション
	sess, err := session.Get("session", c)
	if err != nil {
		log.Printf("session.Get error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの取得に失敗しました。もう一度お試しください。",
		}
		return c.Render(http.StatusBadRequest, "top.html", m)
	}
	if id, ok := sess.Values["ID"].(int); ok != true {
		log.Printf("不明なIDが保存されています: %v\n", id)
		m := map[string]interface{}{
			"message": "セッションの取得に失敗しました。もう一度お試しください。",
		}
		return c.Render(http.StatusBadRequest, "top.html", m)
	}
	id := sess.Values["ID"].(int)

	// ユーザー削除処理
	err = us.Delete(id)
	if err != nil {
		log.Println("us.Delete error")
		m := map[string]interface{}{
			"message": "ユーザー削除時にエラーが発生しました。",
		}
		return c.Render(http.StatusBadRequest, "top.html", m)
	}

	// セッションの無効化
	sess.Values["auth"] = false
	sess.Options.MaxAge = -1
	err = sess.Save(c.Request(), c.Response())
	if err != nil {
		log.Printf("session.Save error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの削除に失敗しました。もう一度お試しください。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	fmt.Println("ユーザーを削除しました")
	m := map[string]interface{}{
		"message": "ユーザーを削除しました。",
	}

	return c.Render(http.StatusFound, "login.html", m)
}

 メモ機能系にアクセスされた時のコントローラー。

 基本的には、ユーザーと同様なので省略。

 ユーザー機能と一部違う点として、メモ機能やメモ機能のAPIとの通信を使用するにはログインして、セッションがある必要があるため、AuthControllerのIDGetBySession()TokenGet()を使用して、セッションからIDやTokenを取得してserviceを使用してる。

memo_controller.go
package controller

import (
	"echo-login-app/app/service"
	"fmt"
	"log"
	"net/http"
	"strconv"

	"github.com/labstack/echo/v4"
)

type MemoController struct{}

// GET Topページ表示
func (mc MemoController) Top(c echo.Context) error {
	var auc AuthController
	var ms service.MemoService

	// セッション
	user_id, err := auc.IDGetBySession(c)
	if err != nil {
		log.Printf("auc.IDGetSession error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの取得に失敗しました。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	token, err := auc.TokenGet(c)
	if err != nil {
		log.Printf("TokenGet error: %v\n", err)
		m := map[string]interface{}{
			"message": "Tokenの取得に失敗しました。もう一度お試しください。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	// メモ全取得
	u, err := ms.GetByUserID(user_id, token)
	if err != nil {
		log.Printf("ms.GetByUserID error: %v\n", err)
		m := map[string]interface{}{
			"message": "メモの取得に失敗しました。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}
	m := map[string]interface{}{
		"message": "",
		"memo":    u,
	}
	return c.Render(http.StatusOK, "memotop.html", m)
}

// GET メモ作成ページ
func (mc MemoController) CreatePage(c echo.Context) error {
	m := map[string]interface{}{
		"message": "",
	}
	return c.Render(http.StatusOK, "memocreate.html", m)
}

// POST メモ作成
func (mc MemoController) Create(c echo.Context) error {
	var auc AuthController
	var ms service.MemoService

	// htmlからformの取得
	title := c.FormValue("title")
	content := c.FormValue("content")

	// 入力漏れチェック
	if title == "" || content == "" {
		log.Println("入力されていない項目があるよ。")
		m := map[string]interface{}{
			"message": "入力されていない項目があるよ。",
		}
		return c.Render(http.StatusBadRequest, "memocreate.html", m)
	}

	// セッション
	user_id, err := auc.IDGetBySession(c)
	if err != nil {
		log.Printf("auc.IDGetSession error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの取得に失敗しました。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	token, err := auc.TokenGet(c)
	if err != nil {
		log.Printf("TokenGet error: %v\n", err)
		m := map[string]interface{}{
			"message": "Tokenの取得に失敗しました。もう一度お試しください。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}

	// メモ作成
	err = ms.Create(title, content, user_id, token)
	if err != nil {
		log.Println("ms.Create error")
		m := map[string]interface{}{
			"message": "メモ作成時にエラーが発生しました。",
		}
		return c.Render(http.StatusBadRequest, "memocreate.html", m)
	}

	fmt.Println("メモ作成成功したよ")
	return c.Redirect(http.StatusFound, "/app/memo")
}

// GET メモの中身表示
func (mc MemoController) ContentView(c echo.Context) error {
	var auc AuthController
	var ms service.MemoService

	param_id := c.Param("id")
	id, err := strconv.Atoi(param_id)
	if err != nil {
		log.Println("strconv.Atoi error")
		m := map[string]interface{}{
			"message": "メモ取得時にエラーが発生しました。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}

	token, err := auc.TokenGet(c)
	if err != nil {
		log.Printf("TokenGet error: %v\n", err)
		m := map[string]interface{}{
			"message": "Tokenの取得に失敗しました。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}

	// IDからメモ取得
	u, err := ms.GetByID(id, token)
	if err != nil {
		log.Printf("ms.GetByID error: %v\n", err)
		m := map[string]interface{}{
			"message": "メモの取得に失敗しました。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}

	return c.Render(http.StatusOK, "memoview.html", u)
}

// GET メモ削除処理
func (mc MemoController) Delete(c echo.Context) error {
	var ms service.MemoService
	var auc AuthController

	form_id := c.Param("id")
	id, err := strconv.Atoi(form_id)
	if err != nil {
		log.Println("strconv.Atoi error")
		m := map[string]interface{}{
			"message": "メモID取得時にエラーが発生しました。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}

	token, err := auc.TokenGet(c)
	if err != nil {
		log.Println("TokenGet error")
		m := map[string]interface{}{
			"message": "Token取得時にエラーが発生しました。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}

	// メモ削除処理
	err = ms.Delete(id, token)
	if err != nil {
		log.Println("ms.Delete error")
		m := map[string]interface{}{
			"message": "メモ削除時にエラーが発生しました。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}

	fmt.Println("メモを削除しました")
	m := map[string]interface{}{
		"message": "メモを削除しました。",
		"memo":    nil,
	}

	return c.Render(http.StatusFound, "memotop.html", m)
}

// POST メモ変更処理
func (mc MemoController) Change(c echo.Context) error {
	var ms service.MemoService
	var auc AuthController

	param_id := c.Param("id")

	// htmlのformから値の取得
	title := c.FormValue("title")
	content := c.FormValue("content")

	// 入力漏れのチェック
	if title == "" || content == "" {
		log.Println("入力されていない項目があるよ。")
		m := map[string]interface{}{
			"message": "入力されていない項目があるよ。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}

	token, err := auc.TokenGet(c)
	if err != nil {
		log.Println("TokenGet error")
		m := map[string]interface{}{
			"message": "Token取得時にエラーが発生しました。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}

	// メモ変更処理
	err = ms.Change(param_id, title, content, token)
	if err != nil {
		log.Println("ms.Change error")
		m := map[string]interface{}{
			"message": "メモ変更時にエラーが発生しました。",
			"memo":    nil,
		}
		return c.Render(http.StatusBadRequest, "memotop.html", m)
	}

	fmt.Println("ユーザー名変更成功したよ")
	return c.Redirect(http.StatusFound, "/app/memo/view/"+param_id)
}

 ログイン後に表示されるページのコントローラー。
 ログイン後にリダイレクトされたらセッションからIDを取得して、そのユーザーのユーザー情報を取得、ページ表示といった流れとなっている。

 基本的には、ユーザーやメモと同様なので省略。

app_controller.go
package controller

import (
	"echo-login-app/app/service"
	"fmt"
	"log"
	"net/http"

	"github.com/labstack/echo/v4"
)

type AppController struct{}

// GET ログイン後のTopページ表示
func (apc AppController) Top(c echo.Context) error {
	// セッション
	var auc AuthController
	user_id, err := auc.IDGetBySession(c)
	if err != nil {
		log.Printf("auc.IDGetSession error: %v\n", err)
		m := map[string]interface{}{
			"message": "セッションの取得に失敗しました。",
		}
		return c.Render(http.StatusBadRequest, "login.html", m)
	}
	var us service.UserService
	// セッションのIDからユーザーデータを取得
	u, err := us.GetByID(user_id)
	m := map[string]interface{}{
		"message": u.Name + "さんこんにちは!!!",
	}
	fmt.Println(m["message"])
	return c.Render(http.StatusOK, "top.html", m)
}

 SessionやTokenに関するコントローラー。
 
 SessionCreate()はログイン後のセッション作成時に呼び出される。
 echo-contrib/sessionパッケージを使用し、Optionsでセッションの有効期限などの情報を設定している。(今回は600秒)
 その後、ユーザーのIDやセッションの有効化としてtrue、Tokenをセッション詰め込んで、Save()で保存する。

 SessionCheck()はオリジナルのミドルウェアで、セッションが必要なURLにアクセスされた時に使用される。(server参照)
 セッションの中身から"auth"がtrueとなっているかを見て、リクエスト先のコントローラーにnextする。(オリジナルのミドルウェアに関しては、説明しきれないので、公式ドキュメントをご覧ください。(https://echo.labstack.com/docs/cookbook/middleware))

 IDやTokenなど、セッションから値を取得する際は注意が必要で、sessionのValuesはinterface型となっているため、intで保存していてもそのままではintとして変数に入れることができない。
 そのため、以下のように型チェックを行ったのち、型を指定してValuesを変数に入れることでできる。(参考:https://qiita.com/sh-tatsuno/items/0c32c01eaeaf2d726fdf)

	if id, ok := sess.Values["ID"].(int); ok != true {
		log.Printf("不明なIDが保存されています: %v\n", id)
		return 0, nil
	}
	id := sess.Values["ID"].(int)

他の細かい部分は基本的には、ユーザーやメモと同様なので省略。

auth_controller.go
package controller

import (
	"echo-login-app/app/entity"
	"log"
	"net/http"

	"github.com/gorilla/sessions"
	"github.com/labstack/echo-contrib/session"
	"github.com/labstack/echo/v4"
)

type AuthController struct{}

// ログインしてセッションがあるか確認するミドルウェア
func (auc AuthController) SessionCheck(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		// セッション
		sess, err := session.Get("session", c)
		if err != nil {
			log.Printf("session.Get error: %v\n", err)
			m := map[string]interface{}{
				"message": "セッションの取得に失敗しました。もう一度お試しください。",
			}
			return c.Render(http.StatusBadRequest, "login.html", m)
		}
		// セッションが有効化されているか
		if sess.Values["auth"] != true {
			m := map[string]interface{}{
				"message": "ログインをしてください。",
			}
			return c.Render(http.StatusOK, "login.html", m)
		}
		if err := next(c); err != nil {
			c.Error(err)
		}
		return nil
	}
}

func (auc AuthController) TokenGet(c echo.Context) (string, error) {
	// セッション
	sess, err := session.Get("session", c)
	if err != nil {
		log.Printf("session.Get error: %v\n", err)
		return "", err
	}
	if id, ok := sess.Values["token"].(string); ok != true {
		log.Printf("不明なIDが保存されています: %v\n", id)
		return "", err
	}
	token := sess.Values["token"].(string)
	return token, nil
}

func (auc AuthController) SessionCreate(c echo.Context, u entity.User, token *entity.Token) error {
	sess, err := session.Get("session", c)
	if err != nil {
		log.Printf("session.Get error: %v\n", err)
		return err
	}
	// セッション作成
	sess.Options = &sessions.Options{
		MaxAge:   600,
		HttpOnly: true,
	}
	// セッションに値入れ
	sess.Values["ID"] = u.ID
	sess.Values["auth"] = true
	sess.Values["token"] = token.Token
	err = sess.Save(c.Request(), c.Response())
	if err != nil {
		return err
	}

	return nil
}

func (auc AuthController) IDGetBySession(c echo.Context) (int, error) {
	sess, err := session.Get("session", c)
	if err != nil {
		log.Printf("session.Get error: %v\n", err)
		return 0, nil
	}
	if id, ok := sess.Values["ID"].(int); ok != true {
		log.Printf("不明なIDが保存されています: %v\n", id)
		return 0, nil
	}
	id := sess.Values["ID"].(int)

	return id, nil
}

entity

 ユーザーやメモ、Tokenなど、Go内でのデータのやり取りや、Go,JSON間の変換に使用する構造体をここで定義している。

 CreatedAtはtimeパッケージのtime.Timeを指定しておくことで、時間を扱うことができる。

entity.go
package entity

import "time"

// ユーザー
type User struct {
	ID       int    `json:"id"`
	Name     string `json:"name"`
	Password string `json:"password"`
}

// メモ
type Memo struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Content   string    `json:"content"`
	CreatedAt time.Time `json:"createdat"`
	User_ID   int       `json:"user_id"`
}

// レスポンスメッセージ用構造体
type ResponseMessage struct {
	Status  int    `json:"status"`
	Message string `json:"message"`
}

// トークン
type Token struct {
	Token string `json:"token"`
}

server

 サーバーのルーティングを行うもの。
 mainでserver.Init()実行時にInitが実行される。

 ルーティングについては、構造のルーティングで説明しているので省略。
 また、ミドルウェア、Templateについても説明しているものは省略している。

 mainでInit実行時に渡された環境変数は、sessions.NewCookieStore()のセッションキーとして使われる。

 echo.New()でechoの実行、変数に入れたeを使って、echoの機能を使っていく。

 e.Use()でミドルウェア使用、e.Group()でルーティングをまとめることができる。
 GET,POST,PUT,DELETEなどの指定と、URL、使いたいコントローラー(ハンドラー関数)の指定は、e.GET("/", uc.Index)のようにすることで設定でき、第一引数にURL、第二引数にコントローラー(ハンドラー関数)を指定している。
 Groupしたものは、usr.GET("", apc.Top)のように使ってあげることで、これのURLは/appとなり、usr.GET("/userpage", uc.UserPage)とした場合は、/app/userpageのようにつながる。
 また、グループしたものでUse()を使用してミドルウェアを指定してあげると、そのグループ内(そのパラメータ以下)にアクセスされた時のみそのミドルウェアが使用される。

e.Logger.Fatal()で起動に失敗したらログを出すように指定した上で、その中にe.Start(アドレス)を入れて、実行されることで、サーバーが起動される。
 アドレスは、ドメインを指定したり、IPアドレスを指定したりすることもできるが、ポート番号だけを指定すると、自動でlocalhostとなる。

server.go
package server

import (
	"echo-login-app/app/controller"
	"io"
	"text/template"

	"github.com/gorilla/sessions"
	"github.com/labstack/echo-contrib/session"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

type TemplateRender struct {
	templates *template.Template
}

func (t *TemplateRender) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
	return t.templates.ExecuteTemplate(w, name, data)
}

func Init(sk string) {
	e := echo.New()
	e.Use(middleware.Recover())
	// ログの整理
	e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
		Format: "\n" + `time: ${time_rfc3339_nano}` + "\n" +
			`method: ${method}` + "\n" +
			`remote_ip: ${remote_ip}` + "\n" +
			`host: ${host}` + "\n" +
			`uri: ${uri}` + "\n" +
			`status: ${status}` + "\n" +
			`error: ${error}` + "\n" +
			`latency: ${latency}(${latency_human})` + "\n",
	}))
	// セッション用ミドルウェア
	e.Use(session.Middleware(sessions.NewCookieStore([]byte(sk))))

	renderer := &TemplateRender{
		templates: template.Must(template.ParseGlob("./views/*.html")),
	}
	e.Renderer = renderer

	var auc controller.AuthController

	// ログイン系
	var uc controller.UserController
	e.GET("/", uc.Index)
	e.GET("/signup", uc.SignupView)
	e.GET("/login", uc.LoginView)
	e.POST("/signup", uc.Signup)
	e.POST("/login", uc.Login)

	// 設定系
	setting := e.Group("/setting")
	setting.Use(auc.SessionCheck)
	setting.GET("/logout", uc.Logout)
	setting.GET("/changename", uc.ChangeNameView)
	setting.POST("/changename", uc.ChangeName)
	setting.GET("/changepassword", uc.ChangePasswordView)
	setting.POST("/changepassword", uc.ChangePassword)
	setting.GET("/delete", uc.Delete)

	// ログイン後のアプリ系
	app := e.Group("/app")
	var apc controller.AppController
	app.Use(auc.SessionCheck)
	app.GET("", apc.Top)
	app.GET("/userpage", uc.UserPage)

	// メモ機能
	memo := app.Group("/memo")
	var mc controller.MemoController
	memo.GET("", mc.Top)
	memo.GET("/create", mc.CreatePage)
	memo.POST("/create", mc.Create)
	memo.GET("/view/:id", mc.ContentView)
	memo.GET("/delete/:id", mc.Delete)
	memo.POST("/change/:id", mc.Change)

	e.Logger.Fatal(e.Start(":8082"))
}

service

 ユーザー機能系のAPIとやり取りする関数。

 type UserService struct{}でユーザーのサービスをクラスのメソッドのようにまとめている。

 基本的には、controllerからアクセスされたら、APIにリクエストして、返ってきたJSONデータをGoの構造体の形式で返すというものとなっている。

 APIへのリクエストは"net/http"パッケージのhttp.Get(), http.Post(), http.NewRequest()を使用して行った。
 Dockerで立てた別コンテナのAPIにアクセスする際は、URLは"localhost"ではなく、APIのコンテナのコンテナ名にする必要がある。そのため、"echo-login-app-api"としている。

 APIから返ってきたレスポンスのBodyを"io"パッケージのReadAll()で読み取り、"encoding/json"パッケージのUnmarshal()で読み取ったBodyをGoの構造体に当てはめて変換している。

 POSTの場合は、引数で受けとったデータを構造体の変数に入れて、"encoding/json"パッケージのMarshal()でGoの構造体のデータをJSONに変換したのち、http.Post()の第二引数に"application/json"とコンテンツタイプを設定した上で、第三引数に"bytes"パッケージのNewBuffer()でバッファーに変換したものを入れている。

 PUT,DELETEの場合は、http.NewRequest()を使用し、第一引数にメソッドを指定する。
 その後、、http.Client{}.Do()をして、リクエストをする。
 なお、PUTの場合は、Header.Set()"Content-Type", "application/json"を入れて、ヘッダーをセットする必要がある。

 Login()では、APIのログインにユーザー情報をPOSTして、パスワードの検証の後、Tokenを受け取り、Tokenを求めるLogin関数の呼び出しであれば、Tokenをコントローラーに返す。

user_service.go
package service

import (
	"bytes"
	"echo-login-app/app/entity"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"strconv"
)

type UserService struct{}

// ユーザー全取得
func (us UserService) GetAll() ([]entity.User, error) {
	var u []entity.User
	url := "http://echo-login-app-api:8081/user"

	// APIから取得
	resp, err := http.Get(url)
	if err != nil {
		log.Printf("error http.Get: %v", err)
		return u, err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Printf("error io.ReadAll: %v", err)
		return u, err
	}

	// JSONをGoのデータに変換
	if err := json.Unmarshal(body, &u); err != nil {
		log.Printf("error json.Unmarshal: %v", err)
		return u, err
	}

	return u, nil
}

// 名前からユーザーデータの取得
func (us UserService) GetByName(username string) (entity.User, error) {
	var u entity.User
	url := "http://echo-login-app-api:8081/user/username/" + username

	// APIから取得
	resp, err := http.Get(url)
	if err != nil {
		log.Printf("error http.Get: %v", err)
		return u, err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Printf("error io.ReadAll: %v", err)
		return u, err
	}

	// JSONをGoのデータに変換
	if err := json.Unmarshal(body, &u); err != nil {
		log.Printf("error json.Unmarshal: %v", err)
		return u, err
	}

	return u, nil
}

// IDからユーザーデータの取得
func (us UserService) GetByID(id int) (entity.User, error) {
	var u entity.User
	sid := strconv.Itoa(id)
	url := "http://echo-login-app-api:8081/user/id/" + sid

	// APIから取得
	resp, err := http.Get(url)
	if err != nil {
		log.Printf("error http.Get: %v", err)
		return u, err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Printf("error io.ReadAll: %v", err)
		return u, err
	}

	// JSONをGoのデータに変換
	if err := json.Unmarshal(body, &u); err != nil {
		log.Printf("error json.Unmarshal: %v", err)
		return u, err
	}

	return u, nil
}

// Login処理
func (us UserService) Login(id int, password string, pleasetoken bool) (*entity.Token, error) {
	var u entity.User
	url := "http://echo-login-app-api:8081/user/login"

	u.ID = id
	u.Password = password

	// GoのデータをJSONに変換
	j, _ := json.Marshal(u)

	// apiへのユーザー情報送信
	req, err := http.Post(
		url,
		"application/json",
		bytes.NewBuffer(j),
	)
	if err != nil {
		log.Printf("error http.POST: %v", err)
		return nil, err
	}
	body, err := io.ReadAll(req.Body)
	if err != nil {
		log.Printf("error io.ReadAll: %v", err)
		return nil, err
	}
	if req.StatusCode != 200 {
		log.Printf("error http.POST: %v", string(body))
		err = fmt.Errorf("パスワードが一致していません。")
		log.Printf("パスワードチェック: %v", err)
		return nil, err
	}
	defer req.Body.Close()

	if pleasetoken == true {
		var token entity.Token
		// JSONをGoのデータに変換
		if err := json.Unmarshal(body, &token); err != nil {
			log.Printf("error json.Unmarshal: %v", err)
			return nil, err
		}

		return &token, nil
	}

	return nil, nil
}

// ユーザー作成処理
func (us UserService) Create(id int, username, password string) error {
	var u entity.User
	url := "http://echo-login-app-api:8081/user"

	u.ID = id
	u.Name = username
	u.Password = password

	// GoのデータをJSONに変換
	j, _ := json.Marshal(u)

	// apiへのユーザー情報送信
	req, err := http.Post(
		url,
		"application/json",
		bytes.NewBuffer(j),
	)
	if err != nil {
		log.Printf("error http.POST: %v", err)
		return err
	}
	defer req.Body.Close()

	return nil
}

// ユーザー名の変更処理
func (us UserService) ChangeName(id int, username string) error {
	var u entity.User
	sid := strconv.Itoa(id)
	url := "http://echo-login-app-api:8081/user/" + sid

	u.Name = username

	// GoのデータをJSONに変換
	j, _ := json.Marshal(u)

	// apiへのユーザー情報送信
	req, err := http.NewRequest(
		"PUT",
		url,
		bytes.NewBuffer(j),
	)
	if err != nil {
		log.Printf("error http.PUT: %v", err)
		return err
	}

	// Headerセット
	req.Header.Set("Content-Type", "application/json")
	client := &http.Client{}
	re, err := client.Do(req)
	if err != nil {
		log.Printf("error http.client.Do: %v", err)
		return err
	}
	defer re.Body.Close()

	return nil
}

// パスワード変更処理
func (us UserService) ChangePassword(id int, password string) error {
	var u entity.User
	sid := strconv.Itoa(id)
	url := "http://echo-login-app-api:8081/user/" + sid

	u.Password = password

	// GoのデータをJSONに変換
	j, _ := json.Marshal(u)

	// apiへのユーザー情報送信
	req, err := http.NewRequest(
		"PUT",
		url,
		bytes.NewBuffer(j),
	)
	if err != nil {
		log.Printf("error http.PUT: %v", err)
		return err
	}

	// Headerのセット
	req.Header.Set("Content-Type", "application/json")
	client := &http.Client{}
	re, err := client.Do(req)
	if err != nil {
		log.Printf("error http.client.Do: %v", err)
		return err
	}
	defer re.Body.Close()

	return nil
}

// ユーザー削除処理
func (us UserService) Delete(id int) error {
	sid := strconv.Itoa(id)
	url := "http://echo-login-app-api:8081/user/" + sid

	// apiへのユーザー情報送信
	req, err := http.NewRequest(
		"DELETE",
		url,
		nil,
	)
	if err != nil {
		log.Printf("error http.DELETE: %v", err)
		return err
	}
	client := &http.Client{}
	re, err := client.Do(req)
	if err != nil {
		log.Printf("error http.client.Do: %v", err)
		return err
	}
	defer re.Body.Close()

	return nil
}

 メモ機能系のAPIとやり取りする関数。

 基本的には、ユーザーと同様なので省略。

memo_service.go
package service

import (
	"bytes"
	"echo-login-app/app/entity"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"strconv"
)

type MemoService struct{}

// メモ全取得
func (ms MemoService) GetAll(token string) ([]entity.Memo, error) {
	var m []entity.Memo
	url := "http://echo-login-app-api:8081/memo"

	// APIから取得
	req, err := http.NewRequest(
		"GET",
		url,
		nil,
	)
	if err != nil {
		log.Printf("error http.Get: %v", err)
		return m, err
	}
	// Headerセット
	req.Header.Set("Authorization", "Bearer "+token)
	client := &http.Client{}
	re, err := client.Do(req)
	if err != nil {
		log.Printf("error http.client.Do: %v", err)
		return m, err
	}
	defer re.Body.Close()

	body, err := io.ReadAll(re.Body)
	if err != nil {
		log.Printf("error io.ReadAll: %v", err)
		return m, err
	}

	// JSONをGoのデータに変換
	if err := json.Unmarshal(body, &m); err != nil {
		log.Printf("error json.Unmarshal: %v", err)
		return m, err
	}

	return m, nil
}

// ユーザーIDからメモの取得
func (ms MemoService) GetByUserID(user_id int, token string) ([]entity.Memo, error) {
	var m []entity.Memo
	sid := strconv.Itoa(user_id)
	url := "http://echo-login-app-api:8081/memo/user_id/" + sid

	// APIから取得
	req, err := http.NewRequest(
		"GET",
		url,
		nil,
	)
	if err != nil {
		log.Printf("error http.Get: %v", err)
		return m, err
	}
	// Headerセット
	req.Header.Set("Authorization", "Bearer "+token)
	client := &http.Client{}
	re, err := client.Do(req)
	if err != nil {
		log.Printf("error http.client.Do: %v", err)
		return m, err
	}
	defer re.Body.Close()

	body, err := io.ReadAll(re.Body)
	if err != nil {
		log.Printf("error io.ReadAll: %v", err)
		return m, err
	}

	// JSONをGoのデータに変換
	if err := json.Unmarshal(body, &m); err != nil {
		log.Printf("error json.Unmarshal: %v", err)
		return m, err
	}

	return m, nil
}

// IDからメモの取得
func (ms MemoService) GetByID(id int, token string) (entity.Memo, error) {
	var m entity.Memo
	sid := strconv.Itoa(id)
	url := "http://echo-login-app-api:8081/memo/id/" + sid

	// APIから取得
	req, err := http.NewRequest(
		"GET",
		url,
		nil,
	)
	if err != nil {
		log.Printf("error http.Get: %v", err)
		return m, err
	}
	// Headerセット
	req.Header.Set("Authorization", "Bearer "+token)
	client := &http.Client{}
	re, err := client.Do(req)
	if err != nil {
		log.Printf("error http.client.Do: %v", err)
		return m, err
	}
	defer re.Body.Close()

	body, err := io.ReadAll(re.Body)
	if err != nil {
		log.Printf("error io.ReadAll: %v", err)
		return m, err
	}

	// JSONをGoのデータに変換
	if err := json.Unmarshal(body, &m); err != nil {
		log.Printf("error json.Unmarshal: %v", err)
		return m, err
	}

	return m, nil
}

// メモ作成処理
func (ms MemoService) Create(title, content string, user_id int, token string) error {
	var m entity.Memo
	url := "http://echo-login-app-api:8081/memo"

	m.Title = title
	m.Content = content
	m.User_ID = user_id

	// GoのデータをJSONに変換
	j, _ := json.Marshal(m)

	// apiへのユーザー情報送信
	req, err := http.NewRequest(
		"POST",
		url,
		bytes.NewBuffer(j),
	)
	if err != nil {
		log.Printf("error http.POST: %v", err)
		return err
	}

	// Headerセット
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	client := &http.Client{}
	re, err := client.Do(req)
	if err != nil {
		log.Printf("error http.client.Do: %v", err)
		return err
	}
	defer re.Body.Close()

	return nil
}

// メモの変更処理
func (ms MemoService) Change(id, title, content, token string) error {
	var m entity.Memo
	url := "http://echo-login-app-api:8081/memo/" + id

	m.Title = title
	m.Content = content

	// GoのデータをJSONに変換
	j, _ := json.Marshal(m)

	// apiへのメモ情報送信
	req, err := http.NewRequest(
		"PUT",
		url,
		bytes.NewBuffer(j),
	)
	if err != nil {
		log.Printf("error http.PUT: %v", err)
		return err
	}

	// Headerセット
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	client := &http.Client{}
	re, err := client.Do(req)
	if err != nil {
		log.Printf("error http.client.Do: %v", err)
		return err
	}
	defer re.Body.Close()

	return nil
}

// メモ削除処理
func (ms MemoService) Delete(id int, token string) error {
	sid := strconv.Itoa(id)
	url := "http://echo-login-app-api:8081/memo/" + sid

	// apiへのユーザー情報送信
	req, err := http.NewRequest(
		"DELETE",
		url,
		nil,
	)
	if err != nil {
		log.Printf("error http.DELETE: %v", err)
		return err
	}
	req.Header.Set("Authorization", "Bearer "+token)
	client := &http.Client{}
	re, err := client.Do(req)
	if err != nil {
		log.Printf("error http.client.Do: %v", err)
		return err
	}
	defer re.Body.Close()

	return nil
}

views

 ブラウザ表示やForm送信用のHTML。
 量が多いので、一部省略している。(ここで解説していないコードはGithubに上げています(https://github.com/Shakkuuu/echo-login-app))

 ルートのURLにアクセスした際に表示されるindexページで、このサービスに登録されているすべてにユーザー名を表示している。

 {{range $v := .}}{{end}}で囲むことで、配列を渡されたら順に変数vに入れられる。
 変数vに入れられて値は、このページではユーザーの構造体のため、$v.Nameとアクセスすることで、ユーザー名を表示できる。
 この際、構造体の場合はフィールド名の頭の文字を大文字にしているため、テンプレート内でも大文字にする必要がある。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>index</title>
</head>
<body>
    <h1>index</h1>
    {{range $v := .}}
        <div>
            <div>
                <p>{{$v.Name}}</p>
            </div>
        </div>
    {{end}}
    <p><a href="/login">ログイン</a></p>
    <p><a href="/signup">サインアップ</a></p>
    <p><a href="/app">メインページ(ログインが必要です)</a></p>
</body>
</html>

 メモのトップページでは、他ページから遷移してきた時のmessageと、メモのタイトルと作成日を表示する必要があったため、mapをテンプレートに渡している。
 例として、mapは以下のような形となっているため、テンプレートでは、{{.message}}``{{range $v ::= .memo}}というようにmapのキーに合わせて頭の文字が小文字となっている。

m := map[string]interface{}{
	"message": "メモの取得に失敗しました。",
	"memo":    nil,
}
memotop.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>MemoTop</title>
</head>
<body>
    <h1>MemoTop</h1>
    <p>{{.message}}</p>
    {{range $v := .memo}}
        <div>
            <div>
                <p>タイトル: {{$v.Title}} 作成日: {{$v.CreatedAt}}</p>
                <p><a href="/app/memo/view/{{$v.ID}}">中身表示</a></p>
            </div>
        </div>
    {{end}}
    <p><a href="/app/memo/create">メモ作成</a></p>
    <p><a href="/app">戻る</a></p>
</body>
</html>

 LoginやSignupなど、パスワードを入力する画面では、inputのtypeをpasswordにすることで、以下の画像のようにパスワードが隠されて入力できる。

signup.png

 ちなみにパスワードは、登録画面によくある感じで確認用のパスワード再入力もするようにしている。

 また、autocompleteをoffにすることで、入力ボックスをクリックすると出てくる入力候補を消すことができる。

signup.html
省略

<form action="/signup" method="POST">
        <div>
            <input type="text" name="username" placeholder="username" autocomplete="off">
        </div>
        <div>
            <input type="password" name="password" placeholder="password" autocomplete="off">
        </div>
        <div>
            <input type="password" name="checkpassword" placeholder="checkpassword" autocomplete="off">
        </div>
        <p><input type="submit" value="登録"></p>
    </form>

省略

 メモ作成ページでは、メモの本文入力画面は、textareaにして、colsとrowsで大きさを指定している。

memocreate.html
省略

    <form action="/app/memo/create" method="POST">
        <div>
            <input type="text" name="title" placeholder="title" autocomplete="off">
        </div>
        <div>
            <textarea name="content", cols="70", rows="20"></textarea>
        </div>
        <p><input type="submit" value="作成"></p>
    </form>

省略

APIのコード

 以下はAPIのコードの解説です。

main

 mainでは、環境変数の読み込みと、DBとserverのInitが実行される。
 loadEnv()では、docker-compose実行時にコンテナに設定された環境変数を、os.Getenv()で読み込んでいる。
loadEnv()で読み込まれた環境変数はdb.Initに渡され、使用される。

main.go
package main

import (
	"echo-login-app/api/db"
	"echo-login-app/api/server"
	"os"
)

func main() {
	// 環境変数読み込み
	un, up, dbn := loadEnv()
	db.Init(un, up, dbn)
	server.Init()

	db.Close()
}

// 環境変数読み込み
func loadEnv() (string, string, string) {
	// Docker-compose.ymlでDocker起動時に設定した環境変数の取得
	username := os.Getenv("USERNAME")
	userpass := os.Getenv("USERPASS")
	database := os.Getenv("DATABASE")

	return username, userpass, database
}

controller

 メモ機能系にアクセスされた時のコントローラー。

 type MemoController struct{}でメモのコントローラーをクラスのメソッドのようにまとめている。

 基本的には、serviceの関数にアクセスしてメモの取得や保存をおこなった後、何かしらをJSONで返すという流れとなっている。

 標準パッケージであるnet/httpでサーバーをたてるときは、ハンドラーにw http.ResponseWriter, r *http.Requestを引数として与えているが、それの代わりとして、c echo.Contextを使うことで、echoのコンテキストでルーティングができる。

 echoの機能である c.JSON()の第一引数にステータスコード、第二引数にJSON形式で送りたいmapやstructを入れてあげることで、JSONでレスポンスすることができる。
 また、c.Bind()を使用すると、POSTされたJSONを引数に入れた構造体の形でGoのデータに変換することができる。
 c.Param()では、リクエストされたURLのパラメータから値をstringで取得することができ、GetByID()の場合では、c.Param("id")とすることで、/memo/id/:id:idの部分にある値を取得することができる。(例:/memo/id/123→"123"が取得される。)

memo_controller.go
package controller

import (
	"echo-login-app/api/entity"
	"echo-login-app/api/service"
	"fmt"
	"log"

	"github.com/labstack/echo/v4"
)

type MemoController struct{}

// GET メモ全取得
func (mc MemoController) GetAll(c echo.Context) error {
	var ms service.MemoService

	// ユーザー全取得処理
	m, err := ms.GetAll()
	if err != nil {
		message := fmt.Sprintf("MemoService.GetAll: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(200, m)
}

// POST メモ作成
func (mc MemoController) Create(c echo.Context) error {
	var ms service.MemoService

	var m entity.Memo
	// JSONをGoのデータに変換
	err := c.Bind(&m)
	if err != nil {
		message := fmt.Sprintf("Memo Create Bind: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	// メモ作成処理
	memo, err := ms.Create(&m)
	if err != nil {
		message := fmt.Sprintf("MemoService.Create: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(201, memo)
}

// GET IDからメモ取得
func (mc MemoController) GetByID(c echo.Context) error {
	id := c.Param("id")

	var ms service.MemoService
	// IDからのメモ取得処理
	m, err := ms.GetByID(id)
	if err != nil {
		message := fmt.Sprintf("MemoService.GetByID: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(200, m)
}

// GET ユーザーIDからメモ取得
func (mc MemoController) GetByUserID(c echo.Context) error {
	user_id := c.Param("user_id")

	var ms service.MemoService
	// ユーザーIDからメモ取得処理
	m, err := ms.GetByUserID(user_id)
	if err != nil {
		message := fmt.Sprintf("MemoService.GetByUserID: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(200, m)
}

// PUT IDからメモ更新
func (mc MemoController) PutByID(c echo.Context) error {
	id := c.Param("id")

	var ms service.MemoService

	// JSONをGoのデータに変換
	var m entity.Memo
	err := c.Bind(&m)
	if err != nil {
		message := fmt.Sprintf("Memo Update Bind: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}
	print(&m)

	// IDからメモ更新処理
	memo, err := ms.PutByID(&m, id)
	if err != nil {
		message := fmt.Sprintf("MemoService.PutByID: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(200, memo)
}

// DELETE メモの削除
func (mc MemoController) Delete(c echo.Context) error {
	id := c.Param("id")

	var ms service.MemoService

	// ユーザー削除処理
	err := ms.Delete(id)
	if err != nil {
		message := fmt.Sprintf("MemoService.Delete: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	m := ResMess{Status: 200, Message: "Memo Deleted: " + id}

	return c.JSON(200, m)
}

 ユーザー機能系にアクセスされた時のコントローラー。

 基本的には、メモと同様なので省略。

user_controller.go
package controller

import (
	"echo-login-app/api/entity"
	"echo-login-app/api/service"
	"fmt"
	"log"

	"github.com/labstack/echo/v4"
)

type ResMess entity.ResponseMessage

type UserController struct{}

// GET ユーザー全取得
func (uc UserController) GetAll(c echo.Context) error {
	var us service.UserService

	// ユーザー全取得処理
	u, err := us.GetAll()
	if err != nil {
		message := fmt.Sprintf("UserService.GetAll: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(200, u)
}

// POST ユーザー作成
func (uc UserController) Create(c echo.Context) error {
	var us service.UserService

	var u entity.User
	// JSONをGoのデータに変換
	err := c.Bind(&u)
	if err != nil {
		message := fmt.Sprintf("User Create Bind: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	// ユーザー作成処理
	user, err := us.Create(&u)
	if err != nil {
		message := fmt.Sprintf("UserService.Create: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(201, user)
}

// GET IDからユーザーデータ取得
func (uc UserController) GetByID(c echo.Context) error {
	id := c.Param("id")

	var us service.UserService
	// IDからのユーザー取得処理
	u, err := us.GetByID(id)
	if err != nil {
		message := fmt.Sprintf("UserService.GetByID: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(200, u)
}

// GET 名前からユーザーデータ取得
func (uc UserController) GetByName(c echo.Context) error {
	username := c.Param("username")

	var us service.UserService
	// 名前からユーザーデータ取得処理
	u, err := us.GetByName(username)
	if err != nil {
		message := fmt.Sprintf("UserService.GetByName: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(200, u)
}

// PUT IDからユーザデータ更新
func (uc UserController) PutByID(c echo.Context) error {
	id := c.Param("id")

	var us service.UserService

	// JSONをGoのデータに変換
	var u entity.User
	err := c.Bind(&u)
	if err != nil {
		message := fmt.Sprintf("User Update Bind: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}
	print(&u)

	// IDからユーザーデータ更新処理
	user, err := us.PutByID(&u, id)
	if err != nil {
		message := fmt.Sprintf("UserService.PutByID: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	return c.JSON(200, user)
}

// DELETE ユーザーの削除
func (uc UserController) Delete(c echo.Context) error {
	id := c.Param("id")

	var us service.UserService

	// ユーザー削除処理
	err := us.Delete(id)
	if err != nil {
		message := fmt.Sprintf("UserService.Delete: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	m := ResMess{Status: 200, Message: "User Deleted: " + id}

	return c.JSON(200, m)
}

// POST ユーザーログイン
func (uc UserController) Login(c echo.Context) error {
	var us service.UserService

	var u entity.User
	// JSONをGoのデータに変換
	err := c.Bind(&u)
	if err != nil {
		message := fmt.Sprintf("User Login Bind: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	// ログイン処理
	err = us.Login(&u)
	if err != nil {
		message := fmt.Sprintf("UserService.Login: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	// Token作成処理
	t, err := us.TokenCreate(u.ID)
	if err != nil || t == "" {
		message := fmt.Sprintf("us.TokenCreate: %v", err)
		log.Println(message)
		e := ResMess{Status: 500, Message: message}
		return c.JSON(e.Status, e)
	}

	jtoken := entity.Token{Token: t}

	return c.JSON(200, jtoken)
}

db

 コンテナで起動しているmysqlのDBにアクセスするためのもの。

 varでdb *gorm.DBを定義してあげることで、以下の複数の関数でDB情報を共有して他のパッケージに与えたり受け取ったり閉じたりできる。

 main.goでdb.Init()が実行されたときに、Initが実行される。
 引数に与えられたユーザー名やパスワード、データベース名をもとに、データベースへ接続する。(データベースの種類と、プロトコルはコロコロ変更しないと思ったため直で書いているが、これらも環境変数で与えた方がわざわざGoのコードを書き換えなくて済むと思う。)
 データベース接続には、"GORM"というORMを使用し、gorm.Open()関数の引数にデータベースの種類名とその他情報を与えることで、接続ができる。
 接続時に与える情報は${ユーザー名}:${ユーザーのパスワード}@tcp(db:3307)/${データベース名}"?charset=utf8&parseTime=true&loc=Asia%2FTokyo"といった形式となっており、最後の?以降は文字コードやロケーションの時刻を設定している。(my.cnfとかで指定しているからいらないかも?)
 なお、tcp(db:3307)は、普段はtcp(localhost:3306)のように、ドメインやIPアドレスを指定するが、DockerのコンテナでDBをたてた場合は、その部分にコンテナ名を入れる必要があるので注意。
 gorm.Open()でエラーが発生した場合は、forでcountが180になるまで1秒おきに再接続を試みるようにしている。

  DBへの接続ができたら、autoMigration()を実行。
 autoMigration()では、entityの構造体をもとにそのかたちのテーブルを作成してくれるマイグレーションというものを行なっている。
 db.AutoMigrate(構造体)で行うことができ、ユーザーはそのままマイグレーション、メモは、AddForeignKey()を使用して外部キー制約を行っている。
 AddForeignKey()では、第一引数にMemo内でForeignKeyにしたいカラム名、第二引数にForeignKey先のテーブル名(カラム名)(ここでのテーブル名はDBに実際に保存されているテーブル名のため、userではなく、自動でusersと複数形になっているので注意)、第三引数第四引数にForeignKey先のレコードがDelete,Updateされた時に、連動させるかどうかを指定している。("CASCADE" or "RESTRICT")
 今回はCASCADEにしているため、ユーザーが削除されると、そのユーザーのメモが連動してすべて削除されます。(IDはUpdateする予定ないから、意味ないかも...)

 Close()はmainのserver.Init()終了時に実行されて、データベースとの接続を閉じる。

 GetDB()では、serviceパッケージでデータベースにアクセスしたい時にdbをあげている。

db.go
package db

import (
	"fmt"
	"log"
	"time"

	"echo-login-app/api/entity"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jinzhu/gorm"
)

var (
	db  *gorm.DB
	err error
)

// データベースと接続
func Init(un string, up string, dbn string) {
	DBMS := "mysql"            // データベースの種類
	USER := un                 // ユーザー名
	PASS := up                 // パスワード
	PROTOCOL := "tcp(db:3307)" // 3307ポート
	DBNAME := dbn              // データベース名

	CONNECT := USER + ":" + PASS + "@" + PROTOCOL + "/" + DBNAME + "?charset=utf8&parseTime=true&loc=Asia%2FTokyo"

	// 接続できるまで一定回数リトライ
	count := 0
	db, err = gorm.Open(DBMS, CONNECT)
	if err != nil {
		for {
			if err == nil {
				fmt.Println("")
				break
			}
			fmt.Print(".")
			time.Sleep(time.Second)
			count++
			if count > 180 { // countgaが180になるまでリトライ
				fmt.Println("")
				log.Printf("db Init error: %v\n", err)
				panic(err)
			}
			db, err = gorm.Open(DBMS, CONNECT)
		}
	}
	autoMigration()
}

// serviceでデータベースとやりとりする用
func GetDB() *gorm.DB {
	return db
}

// サーバ終了時にデータベースとの接続終了
func Close() {
	if err := db.Close(); err != nil {
		log.Printf("db Close error: %v\n", err)
		panic(err)
	}
}

// entityを参照してテーブル作成 マイグレーション
func autoMigration() {
	db.AutoMigrate(&entity.User{})
	db.AutoMigrate(&entity.Memo{}).AddForeignKey("user_id", "users(id)", "CASCADE", "CASCADE")
}

entity

 ユーザーやメモ、Tokenなど、Go内でのデータのやり取りや、Go,JSON間の変換に使用する構造体をここで定義している。
 各フィールドの後にjson:"id"のようにタグをつけてあげるとこで、Go,JSON間の変換時にマッピングされる。
 また、gorm:"primaryKey"を指定することで、DBにどのカラムが保存されるときに、primaryKeyとして保存されてくれる。

 CreatedAtはtimeパッケージのtime.Timeを指定しておくことで、時間を扱うことができる。

entity.go
package entity

import "time"

// ユーザー
type User struct {
	ID        int       `json:"id" gorm:"primaryKey"`
	Name      string    `json:"name"`
	Password  string    `json:"password"`
	CreatedAt time.Time `json:"createdat"`
}

// メモ
type Memo struct {
	ID        int       `json:"id" gorm:"primaryKey"`
	Title     string    `json:"title"`
	Content   string    `json:"content"`
	CreatedAt time.Time `json:"createdat"`
	User_ID   int       `json:"user_id"`
}

// レスポンスメッセージ用構造体
type ResponseMessage struct {
	Status  int    `json:"status"`
	Message string `json:"message"`
}

// トークン
type Token struct {
	Token string `json:"token"`
}

server

 サーバーのルーティングを行うもの。
 mainでserver.Init()実行時にInitが実行される。

 ルーティングについては、構造のルーティングで説明しているので省略。
 また、ミドルウェアについても説明しているものは省略している。

 echo.New()でechoの実行、変数に入れたeを使って、echoの機能を使っていく。

 e.Use()でミドルウェア使用、e.Group()でルーティングをまとめることができる。
 GET,POST,PUT,DELETEなどの指定と、URL、使いたいコントローラー(ハンドラー関数)の指定は、e.GET("/", Pong)のようにすることで設定でき、第一引数にURL、第二引数にコントローラー(ハンドラー関数)を指定している。
 Groupしたものは、usr.GET("", uc.GetAll)のように使ってあげることで、これのURLは/userとなり、usr.GET("/login", uc.Login)とした場合は、/user/loginのようにつながる。
 また、グループしたものでUse()を使用してミドルウェアを指定してあげると、そのグループ内(そのパラメータ以下)にアクセスされた時のみそのミドルウェアが使用される。

e.Logger.Fatal()で起動に失敗したらログを出すように指定した上で、その中にe.Start(アドレス)を入れて、実行されることで、サーバーが起動される。
 アドレスは、ドメインを指定したり、IPアドレスを指定したりすることもできるが、ポート番号だけを指定すると、自動でlocalhostとなる。

 Pong()は、appとapiの接続確認のための、ただレスポンスを返すコントローラー。

server.go
package server

import (
	"echo-login-app/api/controller"
	"net/http"
	"os"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func Init() {
	e := echo.New()
	e.Use(middleware.Recover())
	// ログを見やすいように調整
	e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
		Format: "\n" + `time: ${time_rfc3339_nano}` + "\n" +
			`method: ${method}` + "\n" +
			`remote_ip: ${remote_ip}` + "\n" +
			`host: ${host}` + "\n" +
			`uri: ${uri}` + "\n" +
			`status: ${status}` + "\n" +
			`error: ${error}` + "\n" +
			`latency: ${latency}(${latency_human})` + "\n",
	}))

	e.GET("/", Pong)

	var uc controller.UserController
	usr := e.Group("/user")
	usr.GET("", uc.GetAll)
	usr.POST("", uc.Create)
	usr.GET("/id/:id", uc.GetByID)
	usr.GET("/username/:username", uc.GetByName)
	usr.PUT("/:id", uc.PutByID)
	usr.DELETE("/:id", uc.Delete)
	usr.POST("/login", uc.Login)

	var mc controller.MemoController
	memo := e.Group("/memo")
	memo.Use(middleware.JWT([]byte(os.Getenv("TOKEN_KEY"))))
	memo.GET("", mc.GetAll)
	memo.POST("", mc.Create)
	memo.GET("/id/:id", mc.GetByID)
	memo.GET("/user_id/:user_id", mc.GetByUserID)
	memo.PUT("/:id", mc.PutByID)
	memo.DELETE("/:id", mc.Delete)

	e.Logger.Fatal(e.Start(":8081"))
}

// apiの起動確認用(app起動時に使用)
func Pong(c echo.Context) error {
	type PingCheck struct {
		Status  int
		Message string
	}
	p := PingCheck{Status: 200, Message: "Pong"}
	return c.JSON(http.StatusOK, p)
}

service

 メモ機能系のDBとやり取りする関数。

 type MemoService struct{}でメモのサービスをクラスのメソッドのようにまとめている。

 基本的には、controllerからアクセスされたら、DBからデータを持ってきて、Goの構造体の形式で返すというものとなっている。

 GetDB()から持ってきたDBの変数を使用して、データベースとのやりとりを行う。
varで構造体を変数に定義して、それをdbの関数にポインタで与えてあげることで、その構造体のテーブルを操作して、ポインタで与えた構造体の変数取得したデータが入って返ってくる。
 db.Find()ではデータの全件取得。(配列で返ってくるため、構造体を変数に定義するときは配列にしておく)
 db.Createでは構造体の変数に入っているデータをもとに、DBに登録。
 db.Where()では引数に"id = ?", idと入れてあげることで、idのカラムで検索してデータを持ってくる。
 .Frist()では複数データを取得した時に最初のデータのみに指定できる。
 .Model().Updates()ではどのテーブルかをModelで指定した上で、Updatesで一部のカラムのみでもレコードを更新することができる。
 .DeleteではWhereで指定したレコードを削除できる。
なお、dbの関数の後に.Errorとつけてあげることで、返り値としてerrorがくるため、エラーハンドリングができる。

memo_service.go
package service

import (
	"echo-login-app/api/db"
	"echo-login-app/api/entity"
)

type MemoService struct{}

// メモ全取得処理
func (ms MemoService) GetAll() ([]entity.Memo, error) {
	db := db.GetDB()
	var m []entity.Memo

	err := db.Find(&m).Error
	if err != nil {
		return m, err
	}

	return m, nil
}

// メモ作成処理
func (ms MemoService) Create(m *entity.Memo) (*entity.Memo, error) {
	db := db.GetDB()

	err := db.Create(&m).Error
	if err != nil {
		return m, err
	}

	return m, nil
}

// IDからのメモ取得処理
func (ms MemoService) GetByID(id string) (entity.Memo, error) {
	db := db.GetDB()
	var m entity.Memo

	err := db.Where("id = ?", id).First(&m).Error
	if err != nil {
		return m, err
	}

	return m, nil
}

// ユーザーIDからのメモの全取得処理
func (ms MemoService) GetByUserID(user_id string) ([]entity.Memo, error) {
	db := db.GetDB()
	var m []entity.Memo

	err := db.Where("user_id = ?", user_id).Find(&m).Error
	if err != nil {
		return m, err
	}

	return m, nil
}

// IDからのメモ更新処理
func (ms MemoService) PutByID(m *entity.Memo, id string) (*entity.Memo, error) {
	db := db.GetDB()

	err := db.Where("id = ?", id).Model(&m).Updates(&m).Error
	if err != nil {
		return m, err
	}

	return m, nil
}

// IDからのメモ削除処理
func (ms MemoService) Delete(id string) error {
	db := db.GetDB()

	var m entity.Memo

	err := db.Where("id = ?", id).Delete(&m).Error
	if err != nil {
		return err
	}

	return nil
}

 ユーザー機能系のDBとやり取りする関数。

 基本的には、メモと同様なので省略。

 Login()では、送られてきたユーザー情報からIDを使ってDBのユーザーデータを取得(GetByID()はstringのIDを引数としているため、strconvパッケージでstringに変換している。)し、送られてきたパスワードと、DBのハッシュ化されてるパスワードをbcryptパッケージのCompareHashAndPassword()を使用して、パスワードが一致するか比較している。

 TokenCreate()では、ログイン後に/memoなどのAPIへバックエンドからアクセスする際に必要としたJWTのTokenを発行する。
 jwt.New()で発行ようのアルゴリズムを指定した上で、jwt.MapClaims{}で保存しておきたい情報である"id"と、"exp"でそのTokenの有効期限を設定する。
 そして、.SignedString()にToken発行用のキー(os.Getenv()で環境変数から取得している)をbyteで与えることで、Tokenが発行される。

user_service.go
package service

import (
	"echo-login-app/api/db"
	"echo-login-app/api/entity"
	"fmt"
	"log"
	"os"
	"strconv"
	"time"

	"github.com/golang-jwt/jwt"
	"golang.org/x/crypto/bcrypt"
)

type UserService struct{}

// ユーザー全取得処理
func (us UserService) GetAll() ([]entity.User, error) {
	db := db.GetDB()
	var u []entity.User

	err := db.Find(&u).Error
	if err != nil {
		return u, err
	}

	return u, nil
}

// ユーザー作成処理
func (us UserService) Create(u *entity.User) (*entity.User, error) {
	db := db.GetDB()

	err := db.Create(&u).Error
	if err != nil {
		return u, err
	}

	return u, nil
}

// IDからのユーザー取得処理
func (us UserService) GetByID(id string) (entity.User, error) {
	db := db.GetDB()
	var u entity.User

	err := db.Where("id = ?", id).First(&u).Error
	if err != nil {
		return u, err
	}

	return u, nil
}

// 名前からのユーザー取得処理
func (us UserService) GetByName(username string) (entity.User, error) {
	db := db.GetDB()
	var u entity.User

	err := db.Where("name = ?", username).First(&u).Error
	if err != nil {
		return u, err
	}

	return u, nil
}

// IDからのユーザーデータ更新処理
func (us UserService) PutByID(u *entity.User, id string) (*entity.User, error) {
	db := db.GetDB()

	err := db.Where("id = ?", id).Model(&u).Updates(&u).Error
	if err != nil {
		return u, err
	}

	return u, nil
}

// IDからのユーザー削除処理
func (us UserService) Delete(id string) error {
	db := db.GetDB()

	var u entity.User

	err := db.Where("id = ?", id).Delete(&u).Error
	if err != nil {
		return err
	}

	return nil
}

// ユーザーログイン処理
func (us UserService) Login(u *entity.User) error {
	sid := strconv.Itoa(u.ID)
	// IDからのユーザー取得処理
	getu, err := us.GetByID(sid)
	if err != nil {
		return err
	}
	// ハッシュ化されたパスワードの解読と一致確認
	err = bcrypt.CompareHashAndPassword([]byte(getu.Password), []byte(u.Password))
	if err != nil {
		log.Printf("error bcrypt.CompareHashAndPassword: %v", err)
		err := fmt.Errorf("パスワードが一致していません。")
		log.Printf("パスワードチェック: %v", err)
		return err
	}
	return nil
}

// Token作成処理
func (us UserService) TokenCreate(id int) (string, error) {
	// Token発行用のアルゴリズムの指定
	token := jwt.New(jwt.SigningMethodHS256)
	token.Claims = jwt.MapClaims{
		"id":  id,
		"exp": time.Now().Add(time.Hour * 1).Unix(), // 1時間でToken失効
	}

	t, err := token.SignedString([]byte(os.Getenv("TOKEN_KEY")))
	if err != nil {
		return "", err
	}
	return t, nil
}

その他のコード

docker-compose(.env, my.cnf)

api,app,dbの3つのコンテナをたて、それぞれのコンテナの名前とポートは以下のようになっている。

Services name port
api echo-login-app-api 8081
app echo-login-app-app 8082
db 3307

 environmentにて、.envファイルから環境変数を読み取り、コンテナ内に設定している。
 apiのdepens_onにて、"db"を設定してあげることで、コンテナのdbと通信することができる。

 今回dbはポート被り防止のため、3307ポートを使用しているが、mysqlは基本的に3306ポートを使用するため、exposeで3306を指定している。

 volumesにて、mysql-dataの他に、my.cnfを読み込ませて、文字コードの設定を行なっている。

docker-compose.yml
version: "3"

services:
  api:
    container_name: echo-login-app-api
    build: ./api
    tty: true
    volumes:
      - ./api:/api
    ports:
      - 8081:8081
    environment:
      PMA_HOST: db:3307
      USERNAME: ${ELA_USERNAME}
      USERPASS: ${ELA_USERPASS}
      DATABASE: ${ELA_DATABASE}
      ROOTPASS: ${ELA_ROOTPASS}
      TOKEN_KEY: ${TOKEN_KEY}
    depends_on:
      - "db"
  app:
    container_name: echo-login-app-app
    build: ./app
    tty: true
    volumes:
      - ./app:/app
    ports:
      - 8082:8082
    environment:
      PMA_HOST: ${SESSION_KEY}
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${ELA_ROOTPASS}
      MYSQL_DATABASE: ${ELA_DATABASE}
      MYSQL_USER: ${ELA_USERNAME}
      MYSQL_PASSWORD: ${ELA_USERPASS}
      MYSQL_TCP_PORT: 3307
      TZ: 'Asia/Tokyo'
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    volumes:
      - mysql-data:/var/lib/mysql
      - ./db/my.cnf:/etc/mysql/conf.d/my.cnf
    ports:
      - 3307:3307
    expose:
      - 3306
    tty: true
    restart: always

volumes:
  mysql-data:
    driver: local
/db/my.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci

[client]
default-character-set=utf8mb4
.env
ELA_ROOTPASS=dbのrootのpassword
ELA_DATABASE=dbのデータベース名
ELA_USERNAME=dbのユーザー名
ELA_USERPASS=dbのユーザーのパスワード
SESSION_KEY=appのセッション用キー
TOKEN_KEY=apiのToken用キー

dockerfile

 dockerfileをapiとappそれぞれのディレクトリに配置しており、dockercompose実行時に読み込まれて実行される。

 FROMでgolangの1.20イメージを使用して、WORKDIRでローカルのディレクトリを指定、RUNでmodファイルをインストールしてパッケージのインストール、EXPOSEでポートの指定、CMDで自動的にコードの実行をしている。

/api/dockerfile
FROM golang:1.20

WORKDIR /api

COPY . .

RUN go mod download
RUN go mod tidy

EXPOSE 8081

# 起動コマンド
CMD ["go", "run", "main.go"]
/app/dockerfile
FROM golang:1.20

WORKDIR /app

COPY . .

RUN go mod download
RUN go mod tidy

EXPOSE 8082

# 起動コマンド
CMD ["go", "run", "main.go"]

最後に

 最後まで読んでいただきありがとうございます。
 今回は、Go言語,Echo,GORM,mysql,Docker,Session,JWTなどなど色々使ったWebアプリを作ってみました。
 以前にGinというフレームワークで似たようなアプリケーションを作ってみましたが、そこから色々勉強しなおした上で改めて作ってみたのが今回のプログラムです。
 API,バックエンド,DBをすべてコンテナでたてたり、認証を追加してみたり、パスワードをハッシュ化してみたりなど色々挑戦してみた中で、触ったことのなかった技術や使い方を発見できなのでよかったです。
 今後は、アクセス数の多くなりがちなゲーム系や処理に時間がかかるガチャなどの機能追加やRedisの使用、リアルタイム通信に挑戦してみたいです。
 長ったらしい説明かつ、文章構成や誤字脱字など拙い部分もあるかもしれませんが、誰かの参考になればなと思います。

3
4
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
3
4