Go
swagger
echo
validator
xorm

nodeエンジニアがGoでつくるREST APIサーバー【ベストプラクティス!?】ʕ ◔ϖ◔ʔ

はじめに

今回は競馬予想 siva の改修に伴い、バックエンドをnode.jsで作っていたものをGoで実装した際のメモになります。
Go経験ゼロから最低限必要な機能をサクッと実装するまでの記録です。

〇 ここでやること
- 依存ライブラリの導入説明
- echoでRESTサーバを立てたときの設定
- 個人的にやってしまったミス

× ここでやらないこと
- goのインストール説明
- goの基本構文等の説明
- テストに関すること

環境

  • Windows 10
  • Go 1.10.1

1. 導入

まずは以下のコマンドをインストールします。

  • 依存関係管理(dep)
  • xormツール(xorm)
  • Swaggerコード生成(swag)
  • タスクランナー(godo)

依存関係管理(dep)

node.jsでいうnpmのようなパッケージ管理ツールです。

インストール

go get -u github.com/golang/dep/cmd/dep

使い方

プロジェクトのディレクトリでコマンドを実行します。

初期化(初回一回のみ実行)

dep init

ライブラリの追加

dep ensure -add XXXX

定義済みのGopkg.tomlに従いダウンロード

Gopkg.toml
[prune]
  go-tests = true
  unused-packages = true

[[constraint]]
  branch = "master"
  name = "github.com/comail/colog"

[[constraint]]
  name = "github.com/labstack/echo"
  version = "3.3.5"

[[constraint]]
  name = "github.com/spf13/viper"
  version = "1.0.2"

[[constraint]]
  name = "github.com/go-xorm/xorm"
  version = "0.7.0"

[[constraint]]
  branch = "master"
  name = "github.com/mitchellh/mapstructure"

[[constraint]]
  branch = "master"
  name = "github.com/swaggo/echo-swagger"

[[constraint]]
  name = "github.com/thoas/go-funk"
  version = "0.2.0"

[[constraint]]
  name = "github.com/go-resty/resty"
  version = "1.5.0"

[[constraint]]
  name = "github.com/antonholmquist/jason"
  version = "1.0.0"

[[constraint]]
  name = "github.com/fatih/structs"
  version = "1.0.0"

[[constraint]]
  name = "github.com/miiton/kanaconv"
  version = "1.0.0"
dep ensure

xormツール

DBに関するツールで今回はリバースでテーブル用の構造体を作成するのに使用しました。

インストール

go get github.com/go-xorm/cmd/xorm

使い方

コードを出力する前にテンプレートを作成する必要があります。
今回はgoxormをダウンロードして使用しています。

DBからcode生成

xorm reverse mysql user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8 templates/goxorm

Swaggerコード生成(swag)

Godocを解析してSwagger UIを出力してくれます。

インストール

go get -u github.com/swaggo/swag/cmd/swag

使い方

設定できる項目を参考にコードにSwagger UI用のコメントを書いていきます。
コメントを書いたら生成用のコマンドを実行します。

swag init

正常に完了したら「create docs.go at docs/docs.go」のメッセージが出力されます。
サーバを起動後、http://localhost:1323/swagger/index.htmlにアクセスすることでSwagger UIにアクセスできます。

タスクランナー(godo)

Windowsでは動作しないため不要であれば読み飛ばしてください。

インストール

go get -u gopkg.in/godo.v2/cmd/godo

使い方

今回はソースが修正されたのを検知してサーバをリスタートするタスクを使用しました。
godoが参照するディレクトリ名は固定でGododirになり、その配下のmain.goのタスクが実行されます。

Gododir/main.go
package main

import (
    do "gopkg.in/godo.v2"
)

func tasks(p *do.Project) {
    p.Task("server", nil, func(c *do.Context) {
        c.Start("main.go", do.M{"$in": "./"})
    }).Src("*.go", "**/*.go").Debounce(3000)
}

func main() {
    do.Godo(tasks)
}

以下のコマンドでサーバが起動します。

godo server --watch

2. 開発

プロジェクト構造

新しく開発者がJOINしたときにもすぐ開発できるように作業対象となるディレクトリを明確にしたいをコンセプトに構築しました。
基本的には3ディレクトリが開発対象となります。

  • dto
  • handlers
  • resources
backend-template
|   .gitignore
|   Gopkg.toml
|   main.go
|   README.md
|   
+---application             ・・・アプリコア部分
|   |   application.go
|   |   context.go
|   |   validator.go
|   |   
|   +---configadapter
|   |       configadapter.go
|   |       
|   +---constants
|   |       constants.go
|   |       
|   +---dbadapter
|   |       dbadapter.go
|   |       
|   \---kvsadapter
|           kvsadapter.go
|           
+---config                ・・・アプリケーション設定ファイル
|       default.json
|       production.json
|       
+---docs                  ・・・swagコマンド出力結果(Swagger UI)
|   |   docs.go
|   |   
|   \---swagger
|           swagger.json
|           swagger.yaml
|           
+---dto                   ・・・ 構造体格納先
|   +---request
|   |       params.go
|   |       sandbox.go
|   |       
|   \---response
|           sandbox.go
|           
+---Gododir               ・・・ godo用
|       main.go
|       
+---handlers              ・・・ ハンドラ(コントローラー)
|       horses.go         ・・・ 出走馬に関するAPIグループ
|       routes.go         ・・・ ルーティング設定メイン部分
|       sandbox.go        ・・・ テスト用APIグループ
|       
+---models                ・・・ xorm tools出力先
|       m_horse.go
|       
+---resources      
|   \---sql               ・・・ 生のSQLクエリを実行するときのファイル格納(ハンドラごとにディレクトリを作成する)
|       +---horses
|       |       test.sql
|       \---sandbox 
|               test.sql
|               
\---templates
    \---goxorm            ・・・ xormツールテンプレート格納先
            config
            struct.go.tpl

main

あまり太りすぎないように初期化とMiddlewareの設定のみを行うようにしました。
echo.Contextのラップは他のMiddleware設定よりも先に行わなければならないので注意です。

main.go
// Copyright 2018 GAUSS All Rights Reserved.
// API サーバメイン
// @title SIVA API
// @version 1.0.0
// @description SIVA, バックエンドAPIサーバ
// @contact.name GAUSS
// @contact.url https://gauss-ai.jp
// @contact.email support@gauss-ai.jp
// @host localhost:1323
// @BasePath /
// @securityDefinitions.apikey Bearer XXXXXXXXXXXXXXXX
// @in header
// @name Authorization

package main

import (
    "fmt"
    "log"
    "strings"

    "gauss/backend-template/application"
    "gauss/backend-template/handlers"

    "github.com/comail/colog"
    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
)

func main() {
    //===================================
    // Initialize
    //===================================
    colog.SetFormatter(&colog.StdFormatter{
        Flag: log.Ldate | log.Ltime | log.Lshortfile,
    })
    colog.Register()
    e := echo.New()
    e.Validator = application.GetValidator()

    // アプリケーション設定
    config := application.GetInstance().GetConfig()

    // Router初期化
    handlers.Router(e)

    //===================================
    // Middleware
    //===================================
    // echo.Context をラップして扱うために middleware として登録する
    e.Use(func(h echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            return h(&application.AppContext{c})
        }
    })
    e.Use(middleware.Recover())
    e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
        Format: "${host} [${time_rfc3339}] \"${method} ${uri}\" ${status} ${bytes_in} ${bytes_out}\n",
    }))
    e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
        AllowOrigins: []string{"*"},
        AllowMethods: []string{echo.GET, echo.HEAD, echo.PUT, echo.PATCH, echo.POST, echo.DELETE},
        AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
    }))
    e.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
        Skipper: func(c echo.Context) bool {
            if strings.Index(c.Path(), "/swagger/") > -1 {
                return true
            }
            return false
        },
        Validator: func(key string, c echo.Context) (bool, error) {
            return key == config.GetString("server.validKey"), nil
        },
    }))

    //===================================
    // server start
    //===================================
    host := config.Get("server.host")
    port := config.Get("server.port")
    e.Start(fmt.Sprintf("%v:%v", host, port))
}

ルーター

各ハンドラグループを読み込むための設定用ルーターを用意しました。
このへんはリフレクションで自動で設定するほうがシンプルな気がしているのですが、現状は数も少ないですしリフレクションを多用すべきではないということから手で追加する方法としています。

handlers/routes.go
package handlers

import (
    "os"

    "sample/backend-template/application"
    _ "sample/backend-template/docs"

    "github.com/swaggo/echo-swagger"
)

var app = application.GetInstance()

// Router ルーティング設定
func Router(e *echo.Echo) {

  // ハンドラを作成するたびにここに追加していく
    HorsesRouter(e)

    key, ok := os.LookupEnv("GO_ENV")
    if !ok || key != "production" {
        SandBoxRouter(e)

        // swagger
        e.GET("/swagger/*", echoSwagger.WrapHandler)
    }

}

context

echo.Contextをラップしてリクエストパラメータのバインドとバリデーションを行います。
この辺は参考元をそのまま利用させていただきました。

application/context.go
package application

import (
    "fmt"
    "log"
    "net/http"

    "github.com/labstack/echo"
)

// AppContext echo.Context をラップ
type AppContext struct {
    echo.Context
}

// BindValidate BindとValidateを合わせたメソッド
func (c *AppContext) BindValidate(i interface{}) error {
    if err := c.Bind(i); err != nil {
        return c.String(http.StatusBadRequest, "Request is failed: "+err.Error())
    }
    if err := c.Validate(i); err != nil {
        return c.String(http.StatusBadRequest, "Validate is failed: "+err.Error())
    }
    return nil
}

// callFunc
type callFunc func(c *AppContext) error

// AppHandler Router登録用
func AppHandler(h callFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        log.Printf("debug: start: %v %v", c.Request().Method, c.Request().RequestURI)
        return h(c.(*AppContext))
    }
}

validator

validatorを利用してリクエストパラメータの入力チェックを行っています。
型定義がstringで、必須ではない値の桁数チェックや数値変換可能な文字列のチェックを行いたかったためカスタムのチェックを追加しています。

application/validator.go
package application

import (
    "strconv"

    "gopkg.in/go-playground/validator.v9"
)

var valIns = newValidator()

// Validator Validator
type Validator struct {
    validator *validator.Validate
}

// Validate Validate
func (v *Validator) Validate(i interface{}) error {
    return v.validator.Struct(i)
}

// New コンストラクタ
func newValidator() *Validator {
    ins := new(Validator)
    ins.validator = validator.New()
    ins.validator.RegisterValidation("optlen", optionLenValid)
    ins.validator.RegisterValidation("atoi", atoiValid)

    return ins
}

// GetValidator GetValidator.
func GetValidator() *Validator {
    return valIns
}

// 任意指定桁数チェック
func optionLenValid(fl validator.FieldLevel) bool {
    if fl.Field().Len() < 1 {
        return true
    }
    if l, err := strconv.Atoi(fl.Param()); err == nil && len(fl.Field().String()) == l {
        return true
    }

    return false
}

// 数値変換チェック
func atoiValid(fl validator.FieldLevel) bool {
    if fl.Field().Len() < 1 {
        return true
    }
    if _, err := strconv.Atoi(fl.Field().String()); err == nil {
        return true
    }
    return false
}

dto

リクエストとレスポンスで設定するタグが異なっているためディレクトリから分けて格納することにしました。
リクエストの構造体はバリデーション用のタグリクエストクエリパラメータのタグを書いています。
また、レスポンスの構造体はmapstructure.WeakDecode()するとき用にmapstructureのタグを書いています。

dto/request/sandbox.go
package request

// SandBoxParam サンプルリクエストパラメータ
type BaseRaceParam struct {
    HoldingYear    string `json:"holding_year" query:"holding_year" validate:"required,len=4"`               // 開催年
    HoldingDate    string `json:"holding_date" query:"holding_date" validate:"optlen=4,atoi"`                // 開催月日
    RaceCourseCode string `json:"race_course_code" query:"race_course_code" validate:"required,min=1,max=2"` // 競馬場コード
    RaceNo         int    `json:"race_no" query:"race_no" validate:"required,min=0,max=99"`                  // レース番号
    Hits           int    `json:"hits" query:"hits"`                                                         // 取得数
    Offset         int    `json:"offset" query:"offset"`                                                     // オフセット
}
dto/response/sandbox.go
package response

// SandBoxList test struct.
type SandBoxList struct {
    RecordTypeID    string `json:"record_type_id" mapstructure:"record_type_id"`
    DataCreatedDate string `json:"data_created_date" mapstructure:"data_created_date"`
    RaceNo          int    `json:"race_no" mapstructure:"race_no"`
}

handlers

各グループ単位でファイルを設けてエンドポイントを作成しました。
XXXRouterにRoutingを書くことでハンドラの追加・削除がシンプルになったと思います。

handlers/sandbox.go
package handlers

import (
    "net/http"

    "gauss/backend-template/application"
    "gauss/backend-template/dto/request"
    "gauss/backend-template/utils/iterator"

    "github.com/labstack/echo"
)

// SandBoxRouter APIテスト
func SandBoxRouter(e *echo.Echo) {
    g := e.Group("/sandbox")

    g.GET("/bbs", application.AppHandler(getBbs))
    g.POST("/bbs", application.AppHandler(postBbs))

}

// getBbs godoc
// @Summary 掲示板投稿内容一覧取得
// @Description 掲示板投稿内容一覧を取得する。
// @Security ApiKeyAuth
// @Accept  json
// @Produce  json
// @Param Authorization header string true "Authentication header"
// @Param holding_year query string false "開催年"
// @Param holding_date query string false "開催月日"
// @Param race_course_code query string false "競馬場コード"
// @Param race_no query int false "レース番号"
// @Param hits query int false "取得数"
// @Param offset query int false "オフセット"
// @Success 200 {object} models.TBbs
// @Router /bbs [get]
func getBbs(c *application.AppContext) error {
    param := new(request.BbsGet)
    if err := c.BindValidate(param); err != nil {
        return err
    }
    findRes := make([]*models.TBbs, 0)
    var count int64
    var err error
    db := app.GetDb().GetDBInstance()
    if param.Hits > 0 {
        count, err = db.OrderBy("post_date").Limit(param.Hits, param.Offset).FindAndCount(&findRes, param)
    } else {
        count, err = db.OrderBy("post_date").FindAndCount(&findRes, param)
    }
    if err != nil {
        return err
    }
    result := &response.BbsList{TotalCount: count, Posts: findRes}
    copier.Copy(&result, &param)

    return c.JSON(http.StatusOK, result)
}

// postBbs godoc
// @Summary 掲示板投稿
// @Description 掲示板投稿する。
// @Security ApiKeyAuth
// @Accept  json
// @Produce  json
// @Param Authorization header string true "Authentication header"
// @Param param body request.BbsPost true "掲示板投稿内容"
// @Success 201 {object} models.TBbs
// @Router /bbs [post]
func postBbs(c *application.AppContext) error {
    param := new(request.BbsPost)
    if err := c.BindValidate(param); err != nil {
        return err
    }

    guid := xid.New()
    dec := &models.TBbs{PostDate: time.Now(), DeleteFlg: "0", Xid: guid.String()}
    copier.Copy(&dec, &param)

    res, err := app.GetDb().Insert(dec)
    if err != nil {
        return err
    }
    fmt.Printf("%+v", res)

    return c.JSON(http.StatusCreated, res)
}

3. ちょっとはまったところ

$GOPATH

ワークスペースとしてユーザ任意の場所を指定することができるのですがディレクトリ構造が固定されてしまうところがあまりイケてるとは思いませんでした。(importの解決とかで使用されるので仕方ないのでしょうが。。。)
依存関係管理(dep)はこのGOPATH配下でしか利用できないため、ちょっと拾いものを一時ディレクトリに展開して動かしてみるというやりかたはできません。
なお、複数指定することができるのですがあまりおすすめされていないようです。

import相対指定

これもまたドキュメントに書かれていますが、ワークスペース($GOPATH)配下では相対パス指定ができません。

自前のパッケージをimportする場合はパスを明記するようにします。

例:handlersからみたパスの指定方法
OK)import "gauss/backend-template/application"
NG)import "../application"

構造体の初期化

構造体を初期化するには3パターンあります。
関数呼び出し時のパラメータなど多くの場合にアドレスが求められるため、1か2を使うことが多かったです。
さらにxormで使用する場合は1を、その他の場合は1行で初期化までできるため2が個人的には好みでした。

1.new()を使う場合

s := new(SampleStruct)

2.アドレス演算子付き

s := &SampleStruct{}

3.アドレス演算子なし

s := SampleStruct{}

まとめ

ささっと作りたいなと思ったときに外部ライブラリを頼りたくなるのですが、あまりstarがついていなかったりメンテされてるのかわからないものが多い印象を受けました。シンプルゆえに物足りないと思う部分もあり、そのへん頑張って書く必要があるのでお手軽感が若干かけるかなぁと。

静的型付け全般の話でGoに限ったことではないですが、javascriptでガシガシとオブジェクトにデータを突っ込んでいた頃に比べるといちいち構造体作ったりするのめんどくさいなぁと思っていましたが、設計書から構造体を出力できるツールをつくったり、gojsonを活用することで多少ストレスは解消されました。

キャリアとして元々C言語からスタートしていたのでポインタはすんなりでしたが、LL言語スタートだとこの辺はちょっと理解するのに時間が必要なのかもしれません。
ただ、アドレスを意識する言語を覚えておくことは凄く有益だと思いますし、ビルドも実行速度も速いのでGoは今後社内でも広めていきたいと思いました。

折を見て今回作ったライブラリもgithubに公開していきます。

参考文献