皆さんこんにちは。とらのあなラボのY.Fです。
先日、弊社主催の定例LTイベントである、『オタクが最新技術を追うLTイベント#23』が開催されました。
今回の記事では当LTで発表した『Go言語でのWeb APIの作り方3選』について書いて行きたいと思います。
(当日の発表資料はこちら)
www.slideshare.net
動機
はじめに、発表者である私はGo言語初心者に毛が生えた程度のレベルです。
では、今回なぜこのタイトルでLTしようと思ったかというと、以下のようなものがありました。
- 業務上はRuby on Railsを利用しているが、単なるJSON APIを作るなら違う言語を使いたい
- 社内の一部システムですでにGo言語が使われている部分がある
- 個人的に違う言語を使いたかった
- 上記から、Go言語をプロダクトに採用する際の判断基準が欲しかった
- 手を動かしてみるのが手っ取り早いと思った
等があります。
また、Go言語自体も最近ではWeb APIでの採用をよく聞くようになっていて、Webを主戦場にしているからには一度本格的に触っておきたいという気持ちもありました。
環境等
発表資料にも記載していますが、今回LTするにあたって用意した環境は以下になります。
- Go1.16.3
- Intel版 Mac Book Pro
- Web APIを作ることを目的にするのでHTMLレンダリングなどは度外視
- 同様にフルスタック系のフレームワークも選外
- APIの形式はJSON
- 2021/04時点での情報です
実際のところ、PCはMacでなくても問題ないかと思います。 また、フルスタックフレームワークを除いているのは自分の趣味も若干あります。
上記環境下で、以下3つについて調査してみました。
- net/http(生Go)
- Gin
- go-swagger
レスポンス形式は共通で以下のような形式にします。
type Person struct { Name string `json:"name"` Height int `json:"height"` Mass int `json:"mass"` HomeWorld string `json:"home_world"` Films []string `json:"filmes"` } type Payload struct { Data interface{} `json:"data"` } type Response struct { Status int `json:"status"` Result string `json:"result"` Payload Payload `json:"payload"` }
{ "status": 200, "result": "ok", "payload": { "data": { "name": "Luke Skywalker", "height": 172, "mass": 77, "home_world": "https://swapi.dev/api/planets/1/", "filmes": [ "https://swapi.dev/api/films/2/", "https://swapi.dev/api/films/6/", "https://swapi.dev/api/films/3/", "https://swapi.dev/api/films/1/", "https://swapi.dev/api/films/7/" ] } } }
LTでは時間の都合でGET系のみとしましたが、本記事ではPOSTも一緒に試してみたいとおもいます。 POSTで想定するリクエストは以下のような形にします。
{ "climate": "Arid", "gravity": 1, "name": "Tatooine", "orbital_period": 304, "population": 120000, "residents": [ "https://swapi.dev/api/people/1/", "https://swapi.dev/api/people/2/", "https://swapi.dev/api/people/3/", "https://swapi.dev/api/people/4/", ], "rotation_period": 23, "surface_water": 1, }
Goの構造体は以下のような形を想定します。
type Planet struct { Climate string `json:"climate"` Gravity int `json:"gravity"` Name string `json:"name"` OrbitalPeriod int `json:"orbital_period"` Population int `json:"population"` Residents []string `json:"residents"` RotationPeriod int `json:"rotation_period"` SurfaceWater int `json:"surface_water"` }
レスポンスとしては、上記構造体がPayload、Responseでラップされた以下のような形とします。
{ "status": 200, "result": "ok", "payload": { "data": { "climate": "Arid", "gravity": 1, "name": "Tatooine", "orbital_period": 304, "population": 120000, "residents": [ "https://swapi.dev/api/people/1/", "https://swapi.dev/api/people/2/", "https://swapi.dev/api/people/3/", "https://swapi.dev/api/people/4/" ], "rotation_period": 23, "surface_water": 1 } } }
net/http
早速ソースコードの紹介から始めようと思います。上記で紹介した構造体の定義は間引きます。
package main import ( "encoding/json" "fmt" "io" "log" "net/http" ) // swapiの抜粋 // 構造体の定義省略 func getSwPersonHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { http.Error(w, "Not Found", http.StatusNotFound) return } person := Person{ "Luke Skywalker", 172, 77, "https://swapi.dev/api/planets/1/", []string{ "https://swapi.dev/api/films/2/", "https://swapi.dev/api/films/6/", "https://swapi.dev/api/films/3/", "https://swapi.dev/api/films/1/", "https://swapi.dev/api/films/7/", }} payload := Payload{Data: person} ping := Response{http.StatusOK, "ok", payload} dump, err := json.Marshal(ping) if err != nil { http.Error(w, fmt.Sprint(err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, string(dump)) } func postSwHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Not Found", http.StatusNotFound) return } body, err := io.ReadAll(r.Body) if err != nil && err != io.EOF { w.WriteHeader(http.StatusInternalServerError) return } planet := new(Planet) err = json.Unmarshal(body, &planet) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } fmt.Printf("%v\n", planet) payload := Payload{Data: planet} ping := Response{http.StatusOK, "ok", payload} dump, err := json.Marshal(ping) if err != nil { http.Error(w, fmt.Sprint(err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, string(dump)) } func main() { var httpServer http.Server http.HandleFunc("/", getSwPersonHandler) http.HandleFunc("/ping", postSwHandler) log.Println("start http listening :18888") httpServer.Addr = ":18888" log.Println(httpServer.ListenAndServe()) }
素のGoだけあって色々と自分で実装しないといけません。ただし、簡単なルーティングやヘッダーの設定、レスポンスの送信、サーバーの起動などはやってもらえるので、最低限の機能は持っているのでは無いかと思います。
ソース上で気になったところを上げてみます。
ライブラリなどの依存がまったくない
当然ですが、素のGoについているものだけで組み立てられるので単にAPIを作るだけだと依存関係は必要ありません。
当然、少し複雑なことをしようと思うとライブラリ等が必要になると思いますが、それでも根幹となる部分に依存が無いということはメリットになると思います。
HTTPメソッドの判別が手動
if r.Method != "POST" { http.Error(w, "Not Found", http.StatusNotFound) return }
のようなことをしている部分です。あくまでパスと関数を紐付けることしかできないようでしたので、この様に手動で判別する必要がありそうです。
ルーティング用のライブラリもあるようなので、依存が増えてしまいますが導入するのもありなのかなと思いました。
以下のようにするだけで内部の処理はほぼそのまま使うことが来ます。
// 引数を追加 func getSwPersonHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { // 略 } func postSwHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { // 略 } func main() { router := httprouter.New() router.GET("/", getSwPersonHandler) router.POST("/ping", postSwHandler) log.Println("start http listening :18888") log.Println(http.ListenAndServe(":18888", router)) }
レスポンスの制御も自分で頑張る必要がある
json.Marshal
などしている部分ですね。
dump, err := json.Marshal(ping) if err != nil { log.Println(err) http.Error(w, fmt.Sprint(err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json")
レスポンス/リクエストを構造体などに起こしたい場合は、自分でMarshal、Unmarshalしないといけません。
次に、WebフレームワークであるGinを紹介してみます。
Gin
パフォーマンスを売りにしているフレームワークです。説明によると上記で紹介した httprouter
を使うことで高速化しているとのことです。
ではソースを見ていきたいと思います。
package main import ( "github.com/gin-gonic/gin" "net/http" ) // 略 func getSwPersonHandler(c *gin.Context) { res := buildResponse() c.JSON(http.StatusOK, res) } func postSwHandler(c *gin.Context) { var planet Planet // BindJSONとの違いはバインドに失敗とかした場合自動で400エラーを返すかどうか if err := c.ShouldBindJSON(&planet); err != nil { c.String(http.StatusBadRequest, "Bas Request") return } payload := Payload{Data: planet} res := Response{http.StatusOK, "ok", payload} c.JSON(http.StatusOK, res) } func main() { r := gin.Default() r.GET("/ping", getSwPersonHandler) r.POST("/ping", postSwHandler) r.Run() }
だいぶスッキリしたと思います。たまに見かけるAPI用のマイクロフレームワーク等と似たような見た目なのではないでしょうか?
httprouter
を使ってるということなので、上記で紹介した net/http + httprouter
の場合とルーティング方法はそっくりです。
各ハンドラメソッドもだいぶスッキリしました。 gin.Context
のメソッドを使うことで明示的なマーシャル/アンマーシャルは不要になりました。
また、ドキュメントも日本語訳されたものが揃っています。
特に補足も無いので最後の go-swagger
の紹介をしたいと思います。
go-swagger
Open API 2.0に準拠したSwaggerを扱うフレームワークです。
今回はcliツールも使うのでそちらインストールします。
$ go install github.com/go-swagger/go-swagger/cmd/swagger@latest
上記でなくとも、公式ドキュメントにあるようにHomebrewなどを使っても大丈夫かと思います。
また、プロジェクトディレクトリ配下に gen
ディレクトリを作成しておきます。
$ mkdir gen
これは、swaggerコマンドで自動生成されるソースの置き場所になるディレクトリです。
早速、SwaggerなのでAPI定義のファイルを先に作ります。
--- swagger: '2.0' info: version: 1.0.0 title: SWAPI paths: /: get: produces: - application/json operationId: GetPerson responses: 200: description: returns a person schema: $ref: "#/definitions/Response" /ping: post: produces: - application/json operationId: PostPlanet parameters: # 名前bodyで、bodyの中にあるParam 他inをpathにするとパスになったりなど - in: body name: body schema: $ref: "#/definitions/Planet" responses: 200: description: returns a planet schema: $ref: "#/definitions/Response" definitions: Person: type: "object" properties: name: type: "string" height: type: "number" format: "int64" mass: type: "number" format: "int64" home_world: type: "string" films: type: "array" items: type: "string" Planet: type: "object" properties: climate: type: "string" gravity: type: "number" format: "int64" name: type: "string" orbital_period: type: "number" format: "int64" population: type: "number" format: "int64" residents: type: "array" items: type: "string" rotation_period: type: "number" format: "int64" surface_water: type: "number" format: "int64" Payload: type: "object" properties: data: type: "object" Response: type: "object" properties: status: type: "number" format: "int64" result: type: "string" payload: $ref: "#/definitions/Payload"
Payload
の Data
メンバーは interface{}
型を想定するので object
とだけ書いておきます。
上記のyamlファイルをプロジェクトディレクトリの swagger/swagger.yaml
として配置します。その上で、以下コマンドを実行します。
$ swagger generate server -t gen -f ./swagger/swagger.yaml --exclude-main -A swapi
これで gen
フォルダ配下にソースが一式用意されます。ただし、エントリポイントである main.go
がまだないため、そちらを作ります。
基本的にはドキュメントの内容に従います。
package main import ( "flag" "github.com/go-openapi/loads" "github.com/go-openapi/runtime/middleware" "gitlab.com/y-fujiwara/swagger-api/gen/models" "gitlab.com/y-fujiwara/swagger-api/gen/restapi" "gitlab.com/y-fujiwara/swagger-api/gen/restapi/operations" "log" "net/http" ) func main() { var portFlag = flag.Int("port", 3003, "Port to run this service on") // load embedded swagger file swaggerSpec, err := loads.Analyzed(restapi.SwaggerJSON, "") if err != nil { log.Fatalln(err) } // create new service API api := operations.NewSwapiAPI(swaggerSpec) server := restapi.NewServer(api) defer server.Shutdown() // parse flags flag.Parse() // set the port this service will be run on server.Port = *portFlag // 自前でハンドラーは実装(ガワだけできてて実際にデータをどこから取るかなどは実装側に任されている) api.GetPersonHandler = operations.GetPersonHandlerFunc( func(params operations.GetPersonParams) middleware.Responder { person := models.Person{ Name: "Luke Skywalker", Height: 172, Mass: 77, HomeWorld: "https://swapi.dev/api/planets/1/", Films: []string{ "https://swapi.dev/api/films/2/", "https://swapi.dev/api/films/6/", "https://swapi.dev/api/films/3/", "https://swapi.dev/api/films/1/", "https://swapi.dev/api/films/7/", }} payload := models.Payload{Data: &person} ping := models.Response{Status: http.StatusOK, Result: "ok", Payload: &payload} return operations.NewGetPersonOK().WithPayload(&ping) }) // 自前でハンドラーは実装(ガワだけできてて実際にデータをどこから取るかなどは実装側に任されている) api.PostPlanetHandler = operations.PostPlanetHandlerFunc( func(params operations.PostPlanetParams) middleware.Responder { planet := params.Body payload := models.Payload{Data: &planet} ping := models.Response{Status: http.StatusOK, Result: "ok", Payload: &payload} return operations.NewPostPlanetOK().WithPayload(&ping) }) // serve API if err := server.Serve(); err != nil { log.Fatalln(err) } }
おまじない的にswaggerコマンドで生成されたソースを呼び出す部分もありますが、大事なのはハンドラー部分になります。
コメントにもありますが、swagger.yamlのAPI定義だけでは、どこから取得するデータなのかは不明です。したがって、それに当たるハンドラーの部分は実装者に任されていると言った形です。
使ってみた感想としては、swagger.yamlの書き方に慣れていれば結構いい選択かなと感じました。逆になれてない人がチーム等にいる場合導入などは少し時間がかかるのではないかなと思いました。
おまけ(Echo)
LTではほとんど触れていませんでしたが、EchoというWebフレームワークも少し触れていました。
なぜ紹介しなかったかというと、時間の都合と、Ginに似ていたためです。
package main import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "net/http" ) // 略 func getSwPersonHandler(c echo.Context) error { res := buildResponse() return c.JSON(http.StatusOK, res) } func postSwHandler(c echo.Context) error { var planet Planet if err := c.Bind(&planet); err != nil { return err } payload := Payload{Data: planet} res := Response{http.StatusOK, "ok", payload} return c.JSON(http.StatusOK, res) } func main() { // Echo instance e := echo.New() // Middleware e.Use(middleware.Logger()) e.Use(middleware.Recover()) // Routes e.GET("/", getSwPersonHandler) e.POST("/ping", postSwHandler) // Start server e.Logger.Fatal(e.Start(":1323")) }
全体的な見かけはGinとそんなに変わりませんが、middlewareという形でLoggerが仕込めたり、リクエストパラメータと構造体のマッピングが Bind
関数でできたり、ハンドラーの戻り値があったりなど、若干Ginより高機能なのかなという印象を受けました。
まとめ
- 作りたいのが単純なAPIかつ依存が少ないほうが良い
- net/http
- 単純なAPIを作りたいがもう少し機能がほしい
- gin、echo
- ドキュメントを必ず残しつつAPIを作りたい
- go-swagger
今回調べた情報を元に、次APIを作るときはGo言語の利用を検討していきたいと思います。
P.S.
直近のイベント情報
■5月21日(金)19:30~ とらのあなラボエンジニア座談会Vol.8【なぜとらラボエンジニアはラジオを始めたのか】
を開催します!興味のある方はぜひご参加ください!
■5月28日(金)19:30~ 【オンライン】オタクが最新技術を追うLTイベント#24【初心者歓迎】【テーマフリー】 を開催します!こちらは発表者も募集中ですので、LT初心者という方でもぜひご応募ください!
採用情報
■募集職種
yumenosora.co.jp
カジュアル面談も随時開催中です
■お申し込みはこちら!
yumenosora.connpass.com
■ToraLab.fmスタートしました!
メンバーによるPodcastを配信中!
是非スキマ時間に聞いて頂けると嬉しいです。
anchor.fm
■Twitterもフォローしてくださいね!
ツイッターでも随時情報発信をしています
twitter.com