はじめに
どうも、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
signup
login
apptop
userpage
memotop
memoview
入力忘れ画面
使用した技術
大きくバージョンが違わなければ、基本的にどのバージョンでも動作できると思います。
- Go言語 (1.20)
- Echo(https://echo.labstack.com/)
- Docker
- Docker Compose (3)
- mysql (8.0)
- Token
- JWT
- Session
- Cookie
- bcrypt
- GORM(https://gorm.io/ja_JP/)
使用したパッケージ
- 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()コントローラー)
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が入っている。
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を使用してる。
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を取得して、そのユーザーのユーザー情報を取得、ページ表示といった流れとなっている。
基本的には、ユーザーやメモと同様なので省略。
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)
他の細かい部分は基本的には、ユーザーやメモと同様なので省略。
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を指定しておくことで、時間を扱うことができる。
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となる。
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をコントローラーに返す。
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とやり取りする関数。
基本的には、ユーザーと同様なので省略。
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
とアクセスすることで、ユーザー名を表示できる。
この際、構造体の場合はフィールド名の頭の文字を大文字にしているため、テンプレート内でも大文字にする必要がある。
<!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,
}
<!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にすることで、以下の画像のようにパスワードが隠されて入力できる。
ちなみにパスワードは、登録画面によくある感じで確認用のパスワード再入力もするようにしている。
また、autocompleteをoffにすることで、入力ボックスをクリックすると出てくる入力候補を消すことができる。
省略
<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で大きさを指定している。
省略
<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に渡され、使用される。
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"が取得される。)
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)
}
ユーザー機能系にアクセスされた時のコントローラー。
基本的には、メモと同様なので省略。
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をあげている。
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を指定しておくことで、時間を扱うことができる。
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の接続確認のための、ただレスポンスを返すコントローラー。
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がくるため、エラーハンドリングができる。
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が発行される。
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を読み込ませて、文字コードの設定を行なっている。
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
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
[client]
default-character-set=utf8mb4
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で自動的にコードの実行をしている。
FROM golang:1.20
WORKDIR /api
COPY . .
RUN go mod download
RUN go mod tidy
EXPOSE 8081
# 起動コマンド
CMD ["go", "run", "main.go"]
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の使用、リアルタイム通信に挑戦してみたいです。
長ったらしい説明かつ、文章構成や誤字脱字など拙い部分もあるかもしれませんが、誰かの参考になればなと思います。