概要
Go で API サーバーを構築する手順について、忘れてしまったので自分用にメモをしておく。
似たような記事は他にも多くあるので、ここでは簡易的な手順と設計のみを残しておく。
また、Go言語 チートシートも書いたのでよければ。
開発環境
- macOS Catalina 10.15.4
- go version go1.13.5 darwin/amd64
- mysql Ver 8.0.23 for Linux on x86_64 (MySQL Community Server - GPL)
利用ライブラリなど
名称 | 役割 |
---|---|
gvm | goのバージョン管理 |
Go Modules | パッケージ管理 |
Gin | HTTPサーバ |
fvbock/endless | Zero downtime restarts |
GORM | ORMライブラリ |
migorate | マイグレーション |
direnv | 環境変数管理 |
簡易手順
gvm は macOSにGVM(Golang)をインストール 等を見ながら予めインストールしておく。
やることは下記の通り。
- 必要な go バージョンをインストール
- Go Modules のセットアップ
- Migorate のインストール
$ gvm listall
$ gvm install goバージョン
$ gvm use goバージョン
$ go mod init 組織ドメイン/プロジェクト名
$ go get github.com/ClusterVR/migorate
その他パッケージのインストールは Goland の vgo integration support で適宜行う。(vgo の有効化設定が必要)
環境変数の設定
direnv を利用するとディレクトリごとに環境変数を分けてくれるので便利。
brew install direnv
でインストール可能、プロジェクトディレクトリで .envrc
を作成して下記を書き込み。
# データベース関連 (適宜設定)
export DB_USER=""
export DB_PASS=""
export DB_HOST=""
export DB_PORT=""
export DB_NAME=""
# サーバ設定
export PORT="8080"
編集完了後に direnv allow
で読み込み完了。
アーキテクチャ
レイヤードアーキテクチャ風で実装した。
目的は「関心の分離」と「データフローの明確化」のみ。
DI を利用した依存関係の逆転は面倒だったので今回はしない。
Endpoint
↓↑ JSON
Handler: リクエスト/レスポンス生成
↓↑ Model Model
Usecase: Repositoryでデータ処理 ←-------|
↓↑ Model ↓
Repository: DBとのやりとりを行う Adapter: 外部API通信
↓↑ Model ↓↑ Model
Database ExternalAPI
簡易実装でUsecase層は必要なのか?
以前 Handler と Repository のみでフローを実装したところ、Handler が肥大化した。
原因は下記の通りで、いずれもビジネスロジックとは少し違うものだと考えられる。
- リクエストのバリデーション
- リクエストのマッピング
- リクエストの内容によって処理の分岐
- Model -> JSON への変換処理
そのため、ビジネスロジックを Usecase に分離、Handler はリクエストの処理とレスポンスJSONの生成に集中させたほうが、後々の無茶な変更でもストレス控えめで実装ができると考えられる。
GoでOOP風の実装をする
構造体定義をクラス定義として考えて、インタンス化する関数を作ることで実装する。
Go の構造体には振る舞いが定義可能で、先頭大文字が public、小文字が private になる。
基本的に全てのオブジェクトは上記実装をしている、下記はリポジトリの例。
func NewCommentRepository() *CommentRepository {
return &CommentRepository{}
}
type CommentRepository struct {}
func (r *CommentRepository) Store(comment *models.Comment) error {
return DB.Create(comment).Error
}
利用例は下記の通り。
var repo := repositories.NewCommentRepository(DB)
err := repo.Store(comment)
リクエストオブジェクト
パスパラメータ
https://example.org/111 の 111
の部分、まずはルーティング設定で定義。
apiGroupV1.DELETE("comments/:id", commentsHandler.Delete)
その後、Handler 内で gin.Context を通じて取得する。
func (h *commentsHandler) Delete(c *gin.Context) {
path := c.Param("id")
...
クエリパラメータ
https://example.org?key=value の key=value
の部分、構造体定義は下記の通り。
package requests
type CommentsGetRequest struct {
ChannelId string `form:"channel_id"`
Page string `form:"page"`
Limit string `form:"limit"`
}
デシリアライズは下記の通り。
var request requests.CommentsGetRequest
if err := c.Bind(&request); err != nil {
c.JSON(http.StatusBadRequest, errors.NewBadRequest())
return
}
リクエストボディ (application/json)
POST 等で送るリクエスト、リクエストの構造体定義は下記の通り。
package requests
type CommentsPostRequest struct {
ChannelId string `json:"channel_id" binding:"required"`
Message string `json:"message" binding:"required"`
}
デシリアライズは下記の通り。
var request requests.CommentsPostRequest
if err := c.BindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, errors.NewBadRequest())
return
}
リクエストボディ (multipart/form-data)
ファイル等を送るときのリクエスト、gin.Context を通じて multipart.FileHeader
型で取得可能。
multipart.FileHeader
型の使い方はPackage multipart を参照。
func UsersPutRequestMapper(c *gin.Context) *UsersPutRequest {
name := c.PostForm("name")
icon, _ := c.FormFile("icon") // *multipart.FileHeader型
...
レスポンスオブジェクト
レスポンスの構造体定義は下記の通り。
他のレスポンスで使い回せるようにするために細分化してある。
package responses
type CommentsGetResponse struct {
CommentsWithUsers []responses.CommentWithUser `json:"comments"`
}
type CommentWithUser struct {
Comment Comment `json:"comment"`
User User `json:"user"`
}
type Comment struct {
Id string `json:"id"`
Message string `json:"message"`
}
type User struct {
Id string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Role string `json:"role"`
}
シリアライズは下記の通り。
var commentWithUserValues []responses.CommentWithUser
for _, comment := range *comments {
userValue := responses.User{
Id: strconv.Itoa(int(comment.User.ID)),
Name: comment.User.Name,
Icon: comment.User.Icon,
}
commentValue := responses.Comment{
Id: strconv.Itoa(int(comment.ID)),
Message: comment.Message,
}
commentWithUserValue := responses.CommentWithUser{
Comment: commentValue,
User: userValue,
}
commentWithUserValues = append(commentWithUserValues, commentWithUserValue)
}
response := responses.CommentsGetResponse{CommentsWithUsers: commentWithUserValues}
c.JSON(http.StatusOK, response)
DBアクセスを行わないAPIを実装してみる
GET /ping
で {message: 'pong'}
が返る API を下記の手順に沿って作る。
(シンプルな API なので Usecase は利用しない)
- Handler, Response を実装
- CORSやロギング等のミドルウェアを実装
- サーバ立ち上げ → ルーティングの仕組みを実装
Handler, Response を実装
2つのファイルを作成。
- presentations/handlers/ping_handler.go
- presentations/responses/ping/ping_response.go
ping_handler.go がやることは「レスポンスJSONの生成」のみ。
package handlers
import (
"github.com/gin-gonic/gin"
"組織ドメイン/プロジェクト名/presentations/responses/ping"
"net/http"
)
// PingHandlerを生成して返す
func NewPingHandler() *pingHandler {
return &pingHandler{}
}
// PingAPIのハンドラ
type pingHandler struct {
}
func (h *pingHandler) Get(c *gin.Context) {
response := responses.PingResponse{Message: "Pong!"}
c.JSON(http.StatusOK, response)
}
ping_response.go ではメッセージの定義を行う。
package responses
// レスポンスJSONの型を定義
type PingResponse struct {
Message string `json:"message"`
}
CORSやロギング等のミドルウェアを実装
2つのファイルを作成。
- middleware/cors.go
- middleware/request_logger.go
CORS の設定をしないと web ブラウザからアクセスできないので必須。
package middleware
import "github.com/gin-gonic/gin"
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, DELETE, GET, PUT")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
また、最初のままだとリクエストもレスポンスもあまりログを出してくれないので、独自に実装した logger を挟む。Request Body の内容に関しては Ginのリクエストボディはストリーム を参考にさせて頂いている。
package middleware
import (
"bytes"
"fmt"
"github.com/gin-gonic/gin"
"io/ioutil"
)
type bodyLogWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
// リクエストのログをコンソールに表示する
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
// Responseに書き込まれる内容を傍受する
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
c.Writer = blw
fmt.Print("Request: ")
fmt.Println(c.Request)
fmt.Print("Parameters; ")
// TODO: multipart/form-data だと {"message":"unexpected EOF"} になるのでコメントアウトする
// TODO: 本番環境でコンソールロギングは必要無いので変えられるようにしておく
// requestLogging(c)
c.Next()
fmt.Print("Response: ")
fmt.Println(blw.body.String())
}
}
func (w bodyLogWriter) Write(b []byte) (int, error) {
w.body.Write(b)
return w.ResponseWriter.Write(b)
}
func requestLogging(c *gin.Context) {
// リクエストボディを取得して元に戻す
// https://qiita.com/IzumiSy/items/e9bafb122c8cfab72361 を参照
buf := make([]byte, 2048)
n, _ := c.Request.Body.Read(buf)
b := string(buf[0:n])
fmt.Println(b)
c.Request.Body = ioutil.NopCloser(bytes.NewBuffer([]byte(b)))
}
サーバ立ち上げ → ルーティングの仕組みを実装
3つのファイルを作成。
- main.go
- config/server.go
- config/route.go
main.go はエントリーポイントで、server.goの呼び出しとエラーハンドリングを行う。
また、DB 接続する際はここで DB への接続処理も実装する。
package main
import (
"fmt"
"組織ドメイン/プロジェクト名/config"
)
func main() {
// server.goの呼び出しとエラーハンドリング
if err := config.Start(); err != nil {
fmt.Printf("%v¥n", err)
return
}
}
config/server.go はサーバーの立ち上げ処理を行う。
package config
import (
"github.com/fvbock/endless"
"github.com/gin-gonic/gin"
"os"
)
// サーバの起動設定
func Start() error {
app := setup()
return endless.ListenAndServe(":"+os.Getenv("PORT"), app)
}
func setup() *gin.Engine {
app := gin.New()
RouteV1(app)
return app
}
config/route.go はミドルウェア設定とルーティング設定を行う。
認証が必要/不要な API で関数を分けて見やすくする。
package config
import (
"github.com/gin-gonic/gin"
"組織ドメイン/プロジェクト名/middleware"
"組織ドメイン/プロジェクト名/presentations/handlers"
)
// ルーティング設定
func RouteV1(app *gin.Engine) {
app.Use(middleware.CORS())
app.Use(gin.Logger())
app.Use(middleware.RequestLogger())
openAPI(app)
authAPI(app)
}
// 認証不要のAPI
func openAPI(app *gin.Engine) {
pingHandler := handlers.NewPingHandler()
openApiGroup := app.Group("open")
// 疎通確認
openApiGroup.GET("ping", pingHandler.Get)
}
// 認証必須のAPI
func authAPI(app *gin.Engine) {
}
検証してみる
これで API Endpoint -> PingHandler までの流れができて、レスポンスも返せるようになったので、早速テストしてみる。GET API なのでブラウザからテストが可能。
ターミナルから go run main.go
を実行。
その後、http://localhost:8080/open/ping へアクセスすると確認できる。
これで PING API の実装が完了、その後の API も実装も同じようにすれば良い。
DBアクセスを行うAPIを実装してみる
MySQL + GORM で実装する。
- MySQL を立ち上げる
- MySQL の接続情報を環境変数に設定する
- DB接続処理を実装する
- テスト用のテーブルを作る
- テスト用のテーブルとやりとりするAPIを実装する
MySQL を立ち上げる
方法は何でも良いが、自分は docker-compose で立ち上げている。
docker-compose でMySQL環境簡単構築 - Qiita らへんの記事が参考になると思われる。
その後のデータベース作成なども必要ではあるが、本記事では詳細は省く。
MySQL の接続情報を環境変数に設定する
上述した .envrc
に下記の情報を追記する。
編集が完了したら direnv allow
を忘れずに。
- ユーザー名
- パスワード
- ホスト名
- ポート番号
- DB名
DB接続処理を実装する
GORM を通じて DB へ接続、まずは db/db.go
というファイルを実装する。
package db
import (
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
"os"
)
// Repository から直接参照する DB 構造体
var DB *gorm.DB
func NewDatabaseManager() *DatabaseManager {
return &DatabaseManager{}
}
// データベース管理
type DatabaseManager struct {
}
// データベース接続
func (d *DatabaseManager) Connect() {
DBMS := "mysql"
USER := os.Getenv("DB_USER")
PASS := os.Getenv("DB_PASS")
HOST := "tcp(" + os.Getenv("DB_HOST") + ":" + os.Getenv("DB_PORT") + ")"
DBNAME := os.Getenv("DB_NAME")
CONNECT := USER + ":" + PASS + "@" + HOST + "/" + DBNAME + "?charset=utf8mb4&parseTime=true"
db, err := gorm.Open(DBMS, CONNECT)
db.LogMode(true)
// グローバル変数にセット
DB = db
if err != nil {
// 接続失敗したら起動させたく無いのでpanicさせる
// TODO: Slack等に通知するようにする
panic(err.Error())
}
}
// データベース切断 (エラー処理のためにラップしている)
func (d *DatabaseManager) Close() {
if err := DB.Close(); err != nil {
panic(err.Error())
}
}
その後、先程作成した main.go
から DatabaseManager.Connect()
を呼び出す。
package main
import (
"fmt"
"組織ドメイン/プロジェクト名/db"
)
func main() {
// deferで閉じたいのでここで接続している
dbm := db.NewDatabaseManager()
dbm.Connect()
defer dbm.Close()
// server.goの呼び出しとエラーハンドリング
if err := config.Start(); err != nil {
fmt.Printf("%v¥n", err)
return
}
}
テスト用のテーブルを作る
DB の更新は migorate を使ってマイグレーション可能な状態にする。
まずは migorate の設定を行う、プロジェクトディレクトリに .migoraterc
を作成。
mysql:
host: localhost
port: 3306
user: migorate
password: migorate
database: migorate
次に、テスト用テーブルを作成する SQL を migorate で生成する。
$ migorate generate create_examples id:id message:string created_at:timestamp updated_at:timestamp deleted_at:timestamp
これで db/migrations/xxxxxxxxxxx_create_examples.sql
が生成される。
-- +migrate Up
CREATE TABLE examples(id , message VARCHAR(255), created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
-- +migrate Down
DROP TABLE examples;
しかし、生成された SQL は不完全なので下記のように調整を行う。
- id は PRIMARY KEY AUTO_INCREMENT
- 全てのパラメータは基本 NOT NULL (update_at, deleted_at のみ NULL 許可)
- created_at は DEFAULT CURRENT_TIMESTAMP
- 見やすいように適当にインデントを設定 (SQLフォーマッターFor WEBが便利)
-- +migrate Up
CREATE TABLE examples(
id INT PRIMARY KEY AUTO_INCREMENT,
message VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
);
-- +migrate Down
DROP TABLE examples;
最後に migorate exec all
をすれば SQL が実行されてテーブルが生成される。(画像は Sequel Ace)
詳しい使い方は https://github.com/ClusterVR/migorate を参照すると良い。
テスト用のテーブルとやりとりするAPIを実装する
今回はリクエストの内容を DB に保存する POST API のみを実装する、手順は下記の通り。
- Repository, Model を実装
- Usecase を実装
- Handler, Request, Responseを実装
- ルーティングを設定
Repository, Model を実装
Model は infrastructures/models/example.go
として実装する。
package models
import "github.com/jinzhu/gorm"
type Example struct {
gorm.Model
Message string
}
func (Example) TableName() string {
return "examples"
}
ちなみに構造体内部に入っている gorm.Model
の中身は下記の通り。
package gorm
import "time"
// Model base model definition, including fields `ID`, `CreatedAt`, `UpdatedAt`, `DeletedAt`, which could be embedded in your models
// type User struct {
// gorm.Model
// }
type Model struct {
ID uint `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `sql:"index"`
}
次に、引数で受け取った Model を DB に保存する Repository を実装する。
ファイルは infrastructures/repositories/example_repository.go
。
今回は POST しか実装していないが、その他の DB 操作は GORM公式ドキュメント を確認すると良い。
package repositories
import (
"組織ドメイン/プロジェクト名/db"
"組織ドメイン/プロジェクト名/infrastructures/models"
)
func NewExampleRepository() *ExampleRepository {
return &ExampleRepository{}
}
type ExampleRepository struct {}
func (r *ExampleRepository) Store(example *models.Example) error {
return db.DB.Create(example).Error
}
Usecase を実装
Usecase は Repository へモデルを渡し、エラーを返す。
GET 系の API の場合はモデルとエラーの両方を返す。
ファイルは usecases/examples_usecase.go
。
package usecases
import (
"組織ドメイン/プロジェクト名/infrastructures/models"
"組織ドメイン/プロジェクト名/infrastructures/repositories"
)
func NewExampleUsecase() *examplesUsecase {
return &examplesUsecase{}
}
type examplesUsecase struct {}
func (u *examplesUsecase) Exec(example *models.Example) error {
r := repositories.NewExampleRepository()
if err := r.Store(example); err != nil {
return err
}
return nil
}
Handler, Request, Response を実装
Request は Message の内容で、Response は {message: "success!"}
と返す。
(この Response は使い回せるので、汎用的な形で実装する)
まずは requests/examples/examples_post_request.go
を実装。
package requests
type ExamplesPostRequest struct {
Message string `json:"message" binding:"required"`
}
次に presentations/responses/values/message_response.go
を実装。
なお、このモデルはエラー発生時のレスポンスにも利用する。
汎用的なレスポンス構造体は responses/values
ディレクトリ、特定のレスポンスは responses/<APIパス>
ディレクトリに実装し、特定のレスポンスの中に response/values
の構造体を入れ子にすることでスッキリする。
(今回は responses/values
のみ実装する)
package responses
type MessageResponse struct {
Message string `json:"message"`
}
最後に、これらを取り回す presentations/handlers/examples_handler.go
を実装。
Handler の責務は「バリデーション」「リクエストのモデル化」「Usecaseの橋渡し」「レスポンスの生成」。
(ここでリクエストをモデルにせず、DTOにしたほうが良いパターンもあると考えられる)
package handlers
import (
"github.com/gin-gonic/gin"
"組織ドメイン/プロジェクト名/infrastructures/models"
"組織ドメイン/プロジェクト名/presentations/requests/examples"
"組織ドメイン/プロジェクト名/presentations/responses/values"
"組織ドメイン/プロジェクト名/usecases"
"net/http"
)
func NewExamplesHandler() *examplesHandler {
return &examplesHandler{}
}
type examplesHandler struct{}
func (h *examplesHandler) Post(c *gin.Context) {
// リクエストのデシリアライズ/バリデーション
var request requests.ExamplesPostRequest
if err := c.BindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, responses.MessageResponse{Message: "Bad Request."})
return
}
// リクエストのモデル化/Usecaseの橋渡し
usecase := usecases.NewExampleUsecase()
example := models.Example{Message: request.Message}
if err := usecase.Exec(&example); err != nil {
c.JSON(http.StatusInternalServerError, responses.MessageResponse{Message: "Internal Server Error."})
return
}
// レスポンスの生成
response := responses.MessageResponse{Message: "Success"}
c.JSON(http.StatusOK, response)
}
ルーティングを設定
上述した config/route.go
を下記のように編集。
package config
import (
"github.com/gin-gonic/gin"
"組織ドメイン/プロジェクト名/middleware"
"組織ドメイン/プロジェクト名/presentations/handlers"
)
// ルーティング設定
func RouteV1(app *gin.Engine) {
app.Use(middleware.CORS())
app.Use(gin.Logger())
app.Use(middleware.RequestLogger())
openAPI(app)
authAPI(app)
}
// 認証不要のAPI
func openAPI(app *gin.Engine) {
openApiGroup := app.Group("open")
// 疎通確認
pingHandler := handlers.NewPingHandler()
openApiGroup.GET("ping", pingHandler.Get)
// DBの疎通確認
exampleHandler := handlers.NewExamplesHandler()
openApiGroup.POST("example", exampleHandler.Post)
}
// 認証必須のAPI
func authAPI(app *gin.Engine) {
}
検証してみる
今回実装した API は POST なのでブラウザではテストできない。
そのため、Advanced REST clientを使って検証する。
まずは go run main.go
でサーバーを立ち上げる。
次に、Advanced REST client で画像のように設定し、「SEND」をクリック。
最後に Sequel Ace で examples テーブルを見てみると、データが格納されたことが確認できる。
(created_at と update_at は自動で設定される)
また、リクエストボディを空で送信すると Bad Request になることも確認可能。
これで、APIサーバの環境構築ができた、あとはここに実務で利用する API を追加していくだけで良い。
最終的なディレクトリ構成
画像の通りになった。
(db/init
は初期データ用のディレクトリ, Docker 立ち上げ時に SQL を実行するようにしている)
各ディレクトリの役割を整理すると下記の通り。
ディレクトリ名 | 役割 |
---|---|
config | HTTPサーバ関係の処理 |
db | GORM関係の処理とSQL |
infrastructures | DBや外部APIとの接続関係の処理 |
middleware | handlerより先に実行される, CORSとLogger |
presentations | HTTPのリクエスト/レスポンス関係の処理 |
usecases | ビジネスロジック |
さいごに
環境構築からDB接続テストまで実装ができたので、次から実装するときはこの記事が参考にできるようになった。基本的に変更に強い形を目指して実装しているが、オレオレ実装に加えて DI やユニットテスト等を省いているので不完全ではある(モデルもただのDTOになっている...)。ただ、今回は簡易実装なので、最低限の部分はカバーできているのではないかと思う。また、今回作ったプロジェクトは業務用の雛形なので GitHub 等での公開はしていないが、いずれ余裕があるときにアップできればと考えている。
Go を用いた API サーバの構成はまだまだ勉強中なので、ご指摘等あればコメントでいただけますと幸いです。