はじめに
👋こんにちは!Web系の企業に勤務し、地方からフルリモート勤務をしている@takakouと申します🏙️✨
今回は、「Next.js × Go のWebアプリをDocker上で作成してみませんか? 〜バックエンド実装編〜」というテーマで記事をシェアします!📝🐳
この記事を執筆しようと思った経緯としては、私自身が Next.js × Go × Dockerの組み合わせでアプリを作成しようとした際、この技術スタックに関する情報が少なく、詰まることも多かったからです😫💡
そこで、同じように、この組み合わせの技術スタックに興味を持つ初学者の方々のために、Dockerを使用して環境を構築する方法を詳しくシェアしたいと思い、この連載を始めました。🌟
記事執筆は未熟者で、至らない点もあるかと思いますが、皆さんのコメントやフィードバックをお待ちしています!🚀💬
目次
1 前提条件
今回の記事の前提条件は、前回、前々回で既に Next.js×Goのモノレポ構成のDockerの環境を構築できていることです。そちらの記事をご覧になっていただいた後に、こちらの記事を確認いただけると、記事の内容について深く理解できると思いますのでよろしくお願いいたします。
2 対象者
- ある程度のWebアプリケーションに関する知識がある人
- Dcokerを使ったことがある人
- 必要な知識は自分自身である程度調べられる人
今回の記事ではコードの説明の細い説明は基本的に行いません。
気になることがある場合は、記事を調べたり、公式のリファレンスを参照したり、chatGPTに聞いてみたりして自分で解決していただくようにお願いいたします。
3 動作環境
-
PC : MacBook Air(M1,2020)
-
RAM : 8GB
-
OS : macOS Monterey(ver12.1)
環境上の注意点
Docker が使える前提で始めていきます。
Dockerが入っていない場合は、導入するようにお願いいたします。
Docker Compose V2を使っています。
4 技術選定
フロントエンド
-
Language: TypeScript
-
Library: React
-
FW: Next.js
バックエンド
-
Language: Go
-
FW: Gin
-
ORM: Ent
-
DB: PostgreSQL
ポイント
ORMとは何ぞや?という方向けにORMに関する外部の記事を貼っておきます。
5 実装手順
こちらの章では実装手順について、DB設定、CORS設定、認証基盤、本登録基盤に分けて実装をしておきます。
注意1
ここから先に書いてあるコードは全てmain.go
に追記するものです。
前回の記事までで、作成してあるmain.go
に追記する形で対応をお願いいたします。
完成コードが見たい人はそこまで飛ばしてください
注意2
セキュリティ(パスワードの暗号化、確認用パスワード、etc...) は特に意識していません。
このコードのまま本番環境に出すのは控えてください。
DB設定
DBの設定をします。
PostgreSQLとの接続をし、その後 Auto migrationを実行させます。
わざわざ、PostgreSQLの中に入ってSQLを直打ちしなくても良いのでめちゃくちゃ便利です。
//PostgreSQLに接続
client, err := ent.Open("postgres", "host=db port=5432 user=postgres dbname=bookers2 password=password sslmode=disable")
if err != nil {
log.Fatalf("failed opening connection to postgres: %v", err)
}
// 自動のmigrationを行う
if err := client.Schema.Create(context.Background()); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
CORS設定
CORSの設定をします。
注意点
今回は面倒なので、リクエストを許可するoriginをlocahost:3000
とハードコーディングしていますが、本来なら、環境変数などにすべきなので、実際にデプロイをするときなどは気をつけてください。
// CORS設定
router.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(200)
return
}
c.Next()
})
認証基盤作成
main.go
に記述を追加していく形で対応をお願いいたします。
ここからは順不同です。
登録
// ユーザ新規登録機能
router.POST("users/sign_up",func(c *gin.Context){
// サインアップで送られてくるリクエストを型定義
type SignUpRequest struct{
Email string `json:"email" binding:"required"`
Name string `json:"name" binding:"required"`
Password string `json:"password" binding:"required"`
}
// 変数reqをSignUpRequestで定義
var req SignUpRequest
//reqに取得したデータを格納、変換でエラーが起きた場合はエラーを返して終了
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request"})
return
}
// ユーザ登録を行う
newUser,err := client.User.
Create().
SetEmail(req.Email).
SetName(req.Name).
SetPassword(req.Password).
Save(context.Background())
// エラーの場合はエラーを返して処理終了。
if err != nil {
c.JSON(500, gin.H{"error": err.Error(),"messsage":"sign up missing"})
return
}
// 保存したUserの情報をレスポンスとして返す。
c.JSON(201, gin.H{"user": newUser})
})
ログイン
ログイン機能とは言いつつも、リクエスト内に含まれた認証情報に対して true
or false
を返すだけです。
セッション管理などはフロントエンド側で実装をします。
import(
....
// `user`のメソッドを使うため
"Next_Go_App/ent/user"
....
)
//ユーザログイン機能
router.POST("users/sign_in",func(c *gin.Context){
// ログインで送られてくるリクエストを型定義
type SignInRequest struct{
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
// 変数reqをSignInRequestで定義
var req SignInRequest
//reqに取得したデータを格納、変換でエラーが起きた場合はエラーを返して終了
if err :=c.ShouldBindJSON(&req); err != nil{
c.JSON(400, gin.H{"error": "Invalid request"})
return
}
// ユーザの検索、パスワードの照合を行う
sign_in_user, err := client.User.Query().
Where(user.EmailEQ(req.Email), user.PasswordEQ(req.Password)).
First(context.Background())
//エラーを返す
if err != nil {
c.JSON(401, gin.H{"error": "invalid credentials"})
return
}
// ログイン成功
c.JSON(200, gin.H{"user": sign_in_user})
})
本管理基盤作成
本のCRUDについて順番に記述していきます。
登録
// 本の新規登録
router.POST("/books",func(c *gin.Context){
// 本の新規登録で送られてくるリクエストを型定義
type NewBookRequest struct{
Title string `json:"title" binding:"required"`
Body string `json:"body" binding:"required"`
UserId int`json:"user_id" binding:"required"`
}
// reqをNewBookRequestで定義
var req NewBookRequest
if err:=c.ShouldBindJSON(&req); err!=nil{
c.JSON(400,gin.H{"error":"Invalid request"})
return
}
// 本の情報を保存
newBook,err:=client.Book.
Create().
SetTitle(req.Title).
SetBody(req.Body).
SetUserID(req.UserId).
Save(context.Background())
// エラーがある場合はエラーを返して終了
if err != nil {
c.JSON(500, gin.H{"error": err.Error(),"message":"create book missing"})
return
}
// 保存したBookの情報をレスポンスとして返す
c.JSON(201, newBook)
})
一覧
// 本の一覧を取得
router.GET("/books",func(c *gin.Context){
// Book一覧を取得する
books,err:=client.Book.Query().All(context.Background())
// エラーならエラーを返して終了
if err!=nil{
c.JSON(500,gin.H{"error": err.Error(),"message":"Could not get the book list."})
return
}
// booksをjson形式で返す
c.JSON(200, books)
})
詳細
import(
....
//文字列と数値の相互変換用
"strconv"
....
)
// 本の情報を取得
router.GET("/books/:id",func(c *gin.Context){
// URLパラメータから本のIDを取得する。
bookIDStr:=c.Param("id")
// 文字->数字変換
bookID,err :=strconv.Atoi(bookIDStr)
// パラメータが不正な場合はエラーを出力して終了
if err != nil {
c.JSON(400,gin.H{"error": "Invalid Book ID"})
return
}
// 指定されたIDの本をデータベースから検索
book, err := client.Book.Get(context.Background(), bookID)
// 本が見つからない場合はエラーを返して終了
if err != nil {
c.JSON(404,gin.H{"error": err.Error(),"message":"Book with specified id not found"})
return
}
// 検索した本の情報をJSON形式でレスポンスとして返す
c.JSON(200, book)
})
更新
//本情報を更新する。
router.PATCH("/books/:id",func(c *gin.Context){
// 本の新規登録で送られてくるリクエストを型定義
type UpdateBookRequest struct{
Title string `json:"title" binding:"required"`
Body string `json:"body" binding:"required"`
UserId int`json:"user_id"`
}
// 引数で値を受け取るように変数を定義
var book UpdateBookRequest
// bookに受け取った値を格納
if err:=c.ShouldBindJSON(&book);err!=nil{
c.JSON(400,gin.H{"error": err.Error(),"message":"Invalid Book columnns"})
return
}
// URLパラメータから本のIDを取得
bookIDStr:=c.Param("id")
// 数値に変換
bookID,err:=strconv.Atoi(bookIDStr)
// パラメータが不正な場合はエラーを返して終了
if err!=nil{
c.JSON(400,gin.H{"error": err.Error(),"message":"could not translation string->int"})
return
}
// 指定されたIDの本をデータベースから検索
search_book, err := client.Book.Get(context.Background(), bookID)
if err != nil {
c.JSON(404, gin.H{"error": err.Error(), "message": "Book not found"})
return
}
// UserIdの確認
if *search_book.UserID != book.UserId{
c.JSON(403, gin.H{"error": "Unauthorized", "message": "UserId does not match"})
return
}
// 指定されたIDの本をデータベースから検索、更新
update_book, err := client.Book.
UpdateOneID(bookID).
SetTitle(book.Title).
SetBody(book.Body).
Save(context.Background())
// エラーならエラーを返して終了
if err != nil {
c.JSON(500, gin.H{"error": err.Error(),"message":"Couldn't update"})
return
}
// 本の情報をJSON形式でレスポンスとして返す
c.JSON(200, update_book)
})
削除
//本を削除
router.DELETE("/books/:id",func(c *gin.Context){
// URLパラメータから本のIDを取得する
bookIDStr := c.Param("id")
bookID, err := strconv.Atoi(bookIDStr)
if err != nil {
c.JSON(400, gin.H{"error": "無効な本のIDです。"})
return
}
// 指定されたIDの本をデータベースから削除する
err = client.Book.DeleteOneID(bookID).Exec(context.Background())
if err != nil {
c.JSON(404, gin.H{"error": "削除に失敗しました。"})
return
}
c.JSON(200, gin.H{"message": "削除完了しました。"})
})
6 完成コード(例)
警告
正直言ってかなり可読性が悪い書き方をしています。
可読性を上げるために関数化などをしたり、型宣言を先にしたりと最初はしていたのですが、それだと初学者向けではない...と思い今の形になっています。
コードが動いた後に、各自で、効率的な書き方を試してみていただけますと幸いです。
package main
import (
"github.com/gin-gonic/gin"
"context"
"log"
"Next_Go_App/ent"
"Next_Go_App/ent/user"
_ "github.com/lib/pq"
"strconv"
)
func main() {
// PostgreSQLに接続
client, err := ent.Open("postgres", "host=db port=5432 user=postgres dbname=bookers2 password=password sslmode=disable")
// DB接続でエラーがあった場合にエラーを出力
if err != nil {
log.Fatalf("failed opening connection to postgres: %v", err)
}
// 自動でマイグレーション & エラーがあった場合にエラーを出力
if err := client.Schema.Create(context.Background()); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
// Ginフレームワークのデフォルトの設定を使用してルータを作成
router := gin.Default()
// CORS設定
router.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(200)
return
}
c.Next()
})
// ルートハンドラの定義
router.GET("/", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "Hello, Bookers!",
})
})
// ユーザ新規登録機能
router.POST("users/sign_up",func(c *gin.Context){
// サインアップで送られてくるリクエストを型定義
type SignUpRequest struct{
Email string `json:"email" binding:"required"`
Name string `json:"name" binding:"required"`
Password string `json:"password" binding:"required"`
}
// 変数reqをSignUpRequestで定義
var req SignUpRequest
//reqに取得したデータを格納、変換でエラーが起きた場合はエラーを返して終了
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request"})
return
}
// ユーザ登録を行う
newUser,err := client.User.
Create().
SetEmail(req.Email).
SetName(req.Name).
SetPassword(req.Password).
Save(context.Background())
// エラーの場合はエラーを返して処理終了。
if err != nil {
c.JSON(500, gin.H{"error": err.Error(),"messsage":"sign up missing"})
return
}
// 保存したUserの情報をレスポンスとして返す。
c.JSON(201, gin.H{"user": newUser})
})
// ユーザログイン機能
router.POST("users/sign_in",func(c *gin.Context){
// ログインで送られてくるリクエストを型定義
type SignInRequest struct{
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
// 変数reqをSignInRequestで定義
var req SignInRequest
//reqに取得したデータを格納、変換でエラーが起きた場合はエラーを返して終了
if err :=c.ShouldBindJSON(&req); err != nil{
c.JSON(400, gin.H{"error": "Invalid request"})
return
}
// ユーザの検索を行う
sign_in_user, err := client.User.Query().
Where(user.EmailEQ(req.Email), user.PasswordEQ(req.Password)).
First(context.Background())
//エラーを返す
if err != nil {
c.JSON(401, gin.H{"error": "invalid credentials"})
return
}
// ログイン成功
c.JSON(200, gin.H{"user": sign_in_user})
})
// 本の新規登録
router.POST("/books",func(c *gin.Context){
// 本の新規登録で送られてくるリクエストを型定義
type NewBookRequest struct{
Title string `json:"title" binding:"required"`
Body string `json:"body" binding:"required"`
UserId int`json:"user_id" binding:"required"`
}
// reqをNewBookRequestで定義
var req NewBookRequest
if err:=c.ShouldBindJSON(&req); err!=nil{
c.JSON(400,gin.H{"error":"Invalid request"})
return
}
// 本の情報を保存
newBook,err:=client.Book.
Create().
SetTitle(req.Title).
SetBody(req.Body).
SetUserID(req.UserId).
Save(context.Background())
// エラーがある場合はエラーを返して終了
if err != nil {
c.JSON(500, gin.H{"error": err.Error(),"message":"create book missing"})
return
}
// 保存したBookの情報をレスポンスとして返す
c.JSON(201, newBook)
})
// 本の一覧を取得
router.GET("/books",func(c *gin.Context){
// Book一覧を取得する
books,err:=client.Book.Query().All(context.Background())
// エラーならエラーを返して終了
if err!=nil{
c.JSON(500,gin.H{"error": err.Error(),"message":"Could not get the book list."})
return
}
// booksをjson形式で返す
c.JSON(200, books)
})
// 本の情報を取得
router.GET("/books/:id",func(c *gin.Context){
// URLパラメータから本のIDを取得する。
bookIDStr:=c.Param("id")
// 文字->数字変換
bookID,err :=strconv.Atoi(bookIDStr)
// パラメータが不正な場合はエラーを出力して終了
if err != nil {
c.JSON(400,gin.H{"error": "Invalid Book ID"})
return
}
// 指定されたIDの本をデータベースから検索
book, err := client.Book.Get(context.Background(), bookID)
// 本が見つからない場合はエラーを返して終了
if err != nil {
c.JSON(404,gin.H{"error": err.Error(),"message":"Book with specified id not found"})
return
}
// 検索した本の情報をJSON形式でレスポンスとして返す
c.JSON(200, book)
})
// 本情報を更新する。
router.PATCH("/books/:id",func(c *gin.Context){
// 本の新規登録で送られてくるリクエストを型定義
type UpdateBookRequest struct{
Title string `json:"title" binding:"required"`
Body string `json:"body" binding:"required"`
UserId int`json:"user_id" binding:"required"`
}
// 引数で値を受け取るように変数を定義
var book UpdateBookRequest
// bookに受け取った値を格納
if err:=c.ShouldBindJSON(&book);err!=nil{
c.JSON(400,gin.H{"error": err.Error(),"message":"Invalid Book ID"})
return
}
// URLパラメータから本のIDを取得
bookIDStr:=c.Param("id")
// 数値に変換
bookID,err:=strconv.Atoi(bookIDStr)
// パラメータが不正な場合はエラーを返して終了
if err!=nil{
c.JSON(400,gin.H{"error": err.Error(),"message":"could not translation string->int"})
return
}
// 指定されたIDの本をデータベースから検索、更新
update_book, err := client.Book.
UpdateOneID(bookID).
SetTitle(book.Title).
SetBody(book.Body).
Save(context.Background())
// エラーならエラーを返して終了
if err != nil {
c.JSON(404, gin.H{"error": err.Error(),"message":"Couldn't update"})
return
}
// 本の情報をJSON形式でレスポンスとして返す
c.JSON(200, update_book)
})
// 本を削除
router.DELETE("/books/:id",func(c *gin.Context){
// URLパラメータから本のIDを取得する
bookIDStr := c.Param("id")
// 数値に変換
bookID, err := strconv.Atoi(bookIDStr)
// パラメータが不正な場合はエラーを返して終了
if err != nil {
c.JSON(400, gin.H{"error": "Invalid Book ID"})
return
}
// 指定されたIDの本をデータベースから検索、削除
err = client.Book.DeleteOneID(bookID).Exec(context.Background())
// エラーが起きた場合はエラーをあえして終了
if err != nil {
c.JSON(404, gin.H{"error": "Failed to delete"})
return
}
c.JSON(200, gin.H{"message": "Delete completed"})
})
// サーバーの開始
router.Run(":8080")
}
7 動作検証
動作検証ではTalend APIを使用します。
Talend APIの導入、使い方についてはわかりやすくまとめられている記事がありましたので共有をしておきます。
コンテナを起動していない場合は下記コマンドで起動をしておきましょう
~ Next_GO_App $ docker compose up backend
認証基盤
登録
ユーザのデータを登録してみます。
- リクエストは、
localhost:8080/users/sign_up
にPOSTメソッドで送ります。 - リクエストボディは、
{ "email" : "user@user.com", "name" : "user", "password" : "password" }
で送ります - valueは別に指定はないので好きなものに変えてもらって大丈夫です。
右上の「Sendボタン」を押してリクエストを送ります。
このようにレスポンスが返ってきたら問題なしです。
ログイン
先ほど作成したユーザで認証ができるか試します。
リクエストは、localhost:8080/users/sign_in
にPOSTメソッドで送ります。
- リクエストボディは、
{ "email" : "user@user.com", "password" : "password" }
で送ります。
右上の「Sendボタン」を押してリクエストを送ります。
認証が通ればこんな感じでレスポンスが返ってきます。
逆に通らない場合も試してみます。
パスワードが間違っていたり、ユーザが存在しなかったりするとこんな感じでエラーが返ってきます。
本管理基盤
注意
こちらの章では、エラーが起きた際の再現はしません。
自身でエラーを再現してみてください。
登録
本のデータを登録してみます。
- リクエストは、
localhost:8080/books
にPOSTメソッドで送ります。 - リクエストボディは、
{ "title" : "title1", "body" : "body1", "user_id": "1" }
で送ります
こんな感じでレスポンスが返ってくれば問題ないです。
一覧
本の一覧を取得します。
詳細
本の詳細を取得します。
更新
本のデータを更新してみます。
- リクエストは、
localhost:8080/books/1
にPATCHメソッドで送ります。 - リクエストボディは、
{ "title" : "title2", "body" : "body1", "user_id": "1" }
で送ります
こんな感じで更新後のデータが帰ってくれば問題なしです。
削除
8 参考文献
9 おわりに
今回の記事では、バックエンドの実装方法について説明をしました。
本来なら説明すべき、説明したいところもたくさんあったのですが、あまりに長くなってしまいそうなのでかなり省略しています🙏
今は chatGPTなどのAIツールが優秀なのでわからないことがあればそちらに聞いてみることをお勧めします。
次回の記事では、フロントエンドの実装について説明をしていきます!(年内には出したいという気持ち....)
この連載を通じて、より深い理解とスキルの向上を目指して、一緒に学んでいただけると幸いです🌱👨🎓
(最後に、気づいた方もいらっしゃると思うのですが、絵文字を入れるのは苦手なので、文章に沿った絵文字を挿入するのに、ChatGPTを利用させていただきましたが、いかがでしたでしょうか。不快に思われた方がいたら申し訳ございません🙇♂️)