【go】echoでapiサーバーを実装するときに最低限必要そうなことをまとめておく
以前にgoでのapiサーバーの実装を調べたけどすでに色々忘れかけてるので手順などまとめておく。使おうと思った理由としては以下の要件が厳しめで、phpとかでは厳しいと思ったため。
・レスポンスの高速化
・APIサーバー数の最小化
あと、時間が空くと環境作ったりやらも忘れてしまうので合わせてメモしておく。で、実際にやりたかったことは以下
- macにgoをインストールして開発環境構築
- dep(パッケージ管理ソフト)導入
- ルーティングを設定してget, post, put, deleteメソッドに対応してjsonを返す
- 環境毎にconfigファイルを定義
- gormでmysqlを使用
- ログ出力
- エラー処理
- go-server-starterでプロセス監視
- おわりに
macにgoをインストールして開発環境構築
http://kimagureneet.hatenablog.com/entry/2017/10/23/002705
こちらのとおりにgvm(Go Version Manger)を使ってプロジェクト毎にGoのバージョンを変更できるようにする。今回はバージョン1.9で進めます。
また、goではGOPATHという環境変数で指定されているディレクトリ以下にプロジェクトを作っていくのだがこちらもプロジェクト毎に設定できるようにしたい。
そこでgvmを使うとディレクトリ毎にGOPATHも設定できるので使わない手はなさそう。
# echo_sampleという名前で設定作成 $ gvm pkgset create echo_sample # 設定の一覧にecho_sampleが追加されていることを確認 $ gvm pkgset list gvm go package sets (go1.9) echo_sample => global
echo_sampleの設定を編集
$ gvm pkgenv echo_sample ・・・ # GOPATHにプロジェクトのパス(/Users/hoge/echo_sample)を追加 export GOPATH; GOPATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample:$GOPATH" → export GOPATH; GOPATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample:/Users/hoge/echo_sample:$GOPATH" # PATHにプロジェクトのパス+bin(/Users/hoge/echo_sample/bin)を追加 export PATH; PATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample/bin:${GVM_OVERLAY_PREFIX}/bin:{PATH}" → export PATH; PATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample/bin:${GVM_OVERLAY_PREFIX}/bin:/Users/hoge/echo_sample/bin:${PATH}" ・・・
echo_sampleの設定を反映
# 設定を反映 $ gvm pkgset use echo_sample # 設定されたことを確認 $ gvm pkgset list gvm go package sets (go1.9) => echo_sample global # 設定が反映されていることを確認 $ go env | grep -i gopath GOPATH="/Users/hoge/.gvm/pkgsets/go1.9/echo_sample:/Users/hoge/echo_sample:/Users/hoge/.gvm/pkgsets/go1.9/global"
とりあえずこれで開発の準備ができた。
dep(パッケージ管理ソフト)導入
パッケージ管理は、glideからdepに移行されてるぽい。depはgo公式で開発されているので今後はdepを使うようにした方がよさそう。go getでglobalにdepをインストールする。
$ go get -u github.com/golang/dep/cmd/dep
depは、goのソースコードが1つもないと動かないようなのでとりあえずhello.goを用意する。
※ensureで、「no dirs contained any Go code」と怒れられるみたい。
src/app/hello.go
package main import "fmt" func main() { fmt.Printf("Hello") }
を作成後に以下のコマンドで初期化とechoのインストールを行う。
$ cd src/app # 初期化処理 $ dep init # echoのインストール $ dep ensure -add github.com/labstack/echo
src/app/vendor以下にechoがinstallされる。今後、外部のパッケージを追加する際は同じ要領で追加していく。
ルーティングを設定してget, post, put, deleteメソッドに対応してjsonを返す
とりあえず、echoのインストールまでできたので実際にプログラムを書いていく。echoの公式ページにhello worldがあるので、こちらを試してみる。
app/server.go
package main import ( "net/http" "github.com/labstack/echo" ) func main() { e := echo.New() e.GET("/", func(c echo.Context) error { return c.String(http.StatusOK, "Hello, World!") }) e.Logger.Fatal(e.Start(":1323")) }
ビルドして起動
# ビルド $ go build -o bin/server src/app/server.go # 起動 $ server (./bin/serverだけどpathを通しているのでserverでOK)
ブラウザで「http://localhost:1323」を表示して、「Hello, World!」と表示されればOK。とりあえずechoが動作することが確認できたので、次は/api/membersというURLにget, post, put, deleteでアクセスした際にレスポンスをかえすようにする。
server.go
package main import ( "net/http" "github.com/labstack/echo" ) func main() { e := echo.New() // /api/members でアクセス api := e.Group("/api") { api.GET("/members", func(c echo.Context) error { return c.String(http.StatusOK, "members GET") }) api.POST("/members", func(c echo.Context) error { return c.String(http.StatusOK, "members POST") }) api.PUT("/members", func(c echo.Context) error { return c.String(http.StatusOK, "members PUT") }) api.DELETE("/members", func(c echo.Context) error { return c.String(http.StatusOK, "members POST") }) } e.Logger.Fatal(e.Start(":1323")) }
Groupを使うと階層毎にまとめられる。この例では単純なのでこれで良いが実際にはもっとコード量も増えてくるのでファイルを分割する。
ここでは以下を分ける方針とする。まぁこの辺りの分け方や構成はどんな形がベストなのかまだわからないけれども。。
・ルーティング処理
・各コントローラ
route/router.go
package route import ( "app/controller" "github.com/labstack/echo" ) func Init() *echo.Echo { e := echo.New() api := e.Group("/api") { api.GET("/members", controller.GetMembers()) api.POST("/members", controller.PostMembers()) api.PUT("/members", controller.PutMembers()) api.DELETE("/members", controller.DeleteMembers()) } return e }
controller/member.go
package controller import( "net/http" "github.com/labstack/echo" ) type Member struct { Id int `json:"id"` Name string `json:"name"` } func GetMembers() echo.HandlerFunc { return func(c echo.Context) error { members := []Member { {Id: 1, Name: "ユーザー1"}, {Id: 2, Name: "ユーザー2"}, } return c.JSON(http.StatusOK, members) } } // POST, PUT, DELETEも同じ形で・・・
server.go
package main import ( "app/route" ) func main() { e := route.Init() e.Logger.Fatal(e.Start(":1323")) }
JSONのレスポンスは適当な形だが、実際に作り込む際はフォーマットも検討が必要になると思う。
環境毎にconfigファイルを定義
開発環境と本番環境とでDBの設定ファイル等を分けたりはよくやると思うがそれをechoでもやる。
今回は、設定をyamlで管理できるgopkg.in/yaml.v2 こちらを使ってみる。
まずはパッケージをインストール
$ cd src/app $ dep ensure -add gopkg.in/yaml.v2
configというディレクトリを作ってconfigパッケージとして管理しようと思う。
src/app/config/environment.yml
development: db: user: dev_user password: dev_pass name: dev_db production: db: user: pro_user password: pro_pass name: pro_db
設定を読み込むためのパッケージを作る
src/app/config/environment.yml
package config import( "io/ioutil" yaml "gopkg.in/yaml.v2" ) var Config Conf type Environment struct { Development Conf `yaml:"development"` Production Conf `yaml:"production"` } type Conf struct { Database Database `yaml:"db"` } type Database struct { User string `yaml:"user"` Password string `yaml:"password"` Name string `yaml:"name"` } func SetEnvironment(env string) { buf, err := ioutil.ReadFile("src/app/config/environment.yml") if err != nil { panic(err) } var environment Environment err = yaml.Unmarshal(buf, &environment) if (err != nil) { panic(err) } if (env == "development") { Config = environment.Development } else { Config = environment.Production } }
実際に呼び出してみる。
src/app/server.go
package main import ( "app/route" "app/config" "flag" "fmt" ) func main() { // 設定ファイル読込 setConfig() // 確認用出力 c := config.Config.Database fmt.Printf("DBユーザー::%s", c.User) ・・・ } // コマンドライン引数でproが指定された場合は本番環境の設定を取得 func setConfig() { env := "development" flag.Parse() if args := flag.Args(); 0 < len(args) && args[0] == "pro" { env = "production" } config.SetEnvironment(env) }
ビルドして起動してみると、期待したどおりの動きとなることが確認できる。
$ server DBユーザー::dev_user $ server pro DBユーザー::pro_user
gormでmysqlを使用
「go mysql」で検索するとgormというormマッパーがけっこう使われているぽいのでこちらを使ってみる。まずは、先ほど作った設定ファイルからDB情報を取得して接続するところまでを実装する。db関連の処理はapp/dbフォルダ以下にまとめてdbパッケージとして管理したいと思う。
gormのインストール
$ cd src/app $ dep ensure -add github.com/jinzhu/gorm $ dep ensure -add github.com/go-sql-driver/mysql
app/db/db.go
package db import( "app/config" "fmt" "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/mysql" ) var db *gorm.DB var err error func Init() { c := config.Config.Database user := c.User password := c.Password dbname := c.Name db, err = gorm.Open("mysql", user + ":" + password + "@/" + dbname + "?charset=utf8&parseTime=True&loc=Local") if err != nil { panic(fmt.Sprintf("[Error]: %s", err)) } } func GetConnection() *gorm.DB { return db }
サーバー起動時にDB接続処理を行うようにする。
app/server.go
package main import ( "app/route" "app/config" "app/db" "flag" ) func main() { setConfig() db.Init() ・・・ }
コントローラでDBからデータを取得する。
app/db/member.go
package db import( "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/mysql" ) type Member struct { gorm.Model Name string }
app/controller/members.go
package controller import( "app/db" "net/http" "github.com/labstack/echo" ) func GetMembers() echo.HandlerFunc { return func(c echo.Context) error { members := []db.Member{} db := db.GetConnection() data := db.Find(&members) return c.JSON(http.StatusOK, data) } } ・・・
gormの細かい使い方は都度調べる必要があるが、イメージはこんなかんじでmembersテーブルの全レコードをかえす形のapiが作れた。
ログ出力
アプリ独自のログ出力できるようにする。logrusというパッケージを使う。ここでは単純に初期化だけ行なっているが、フォーマットや複数ファイルにログを出力したい場合はプロジェクトごとに設定するのがよいと思う。
app/log/log.go
package log import ( "os" "fmt" "github.com/Sirupsen/logrus" ) var AppLog *logrus.Logger = logrus.New() func Init() { file, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { panic(fmt.Sprintf("[Error]: %s", err)) } AppLog.Out = file AppLog.Formatter = &logrus.JSONFormatter{} }
app.server.go
package main import ( "app/route" "app/config" "app/db" "app/log" "flag" ) func main() { setConfig() db.Init() // 初期化してログ出力 log.Init() log.AppLog.Info("サーバーが起動しました"); ・・・ }
エラー処理
エラー処理については、サーバーエラーとビジネスエラーとを決まった形式のJSONでかえすようにしたい。
app/error/system.go
package error import( "fmt" ) type SystemError struct { Message string LogMessage string } func (err *SystemError) Error() string { return fmt.Sprintf("%s", err.Message) }
app/error/business.go
package error import( "fmt" ) type BusinessError struct { Message string LogMessage string } func (err *BusinessError) Error() string { return fmt.Sprintf("%s", err.Message) }
app/handler/handler.go
package handler import( AppErr "app/error" "net/http" "github.com/labstack/echo" ) type ApiError struct { Status int `json:status` Message string `json:message` } func JSONErrorHandler(err error, c echo.Context) { switch e := err.(type) { case *AppErr.BusinessError: // Business Error c.JSON( http.StatusOK, ApiError{ Status: 200, Message: e.Message, }) case *AppErr.SystemError: // System Error c.JSON( http.StatusOK, ApiError{ Status: 500, Message: e.Message, }) default: if he, ok := err.(*echo.HTTPError); ok { if he.Code == 404 { // 404 c.JSON(he.Code, ApiError{ Status: he.Code, Message: "Not Found", }) } else { // その他サーバーエラー c.JSON(he.Code, ApiError{ status: he.Code, Message: "System Error", }) } } } }
server.go
package main import ( ・・・ "app/handler" ・・・ ) func main() { ・・・ e := route.Init() e.HTTPErrorHandler = handler.JSONErrorHandler ・・・ }
こんな感じでエラーハンドリングできる。今回はこんな感じにしてしまったけど、ここはけっこう悩ましくてどう実装するのがよいか引き続き考えてゆきたいと思う。
go-server-starterでプロセス監視
あとでまとめる
おわりに
とりあえずこれくらい書いておけば後は要件ごとに調べながら実装まで根性でもっていけるかな。goの文法とかちゃんとやらないとダメですね。
rubyとかnode.jsとかもそうだけど1つのマシンに複数バージョンの言語を入れるのが当たり前になってる。以上です。