虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

『オタクが最新技術を追うLTイベント#23』で『Go言語でのWeb APIの作り方3選』というタイトルで発表しました!

f:id:toranoana-lab:20210430160614p:plain

皆さんこんにちは。とらのあなラボのY.Fです。

先日、弊社主催の定例LTイベントである、『オタクが最新技術を追うLTイベント#23』が開催されました。

yumenosora.connpass.com

今回の記事では当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
}

のようなことをしている部分です。あくまでパスと関数を紐付けることしかできないようでしたので、この様に手動で判別する必要がありそうです。

ルーティング用のライブラリもあるようなので、依存が増えてしまいますが導入するのもありなのかなと思いました。

github.com

以下のようにするだけで内部の処理はほぼそのまま使うことが来ます。

// 引数を追加
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 を使うことで高速化しているとのことです。

github.com

ではソースを見ていきたいと思います。

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 のメソッドを使うことで明示的なマーシャル/アンマーシャルは不要になりました。

また、ドキュメントも日本語訳されたものが揃っています。

gin-gonic.com

特に補足も無いので最後の go-swagger の紹介をしたいと思います。

go-swagger

Open API 2.0に準拠したSwaggerを扱うフレームワークです。

github.com

今回は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"

PayloadData メンバーは interface{} 型を想定するので object とだけ書いておきます。

上記のyamlファイルをプロジェクトディレクトリの swagger/swagger.yaml として配置します。その上で、以下コマンドを実行します。

$ swagger generate server -t gen -f ./swagger/swagger.yaml --exclude-main -A swapi

これで gen フォルダ配下にソースが一式用意されます。ただし、エントリポイントである main.go がまだないため、そちらを作ります。 基本的にはドキュメントの内容に従います。

goswagger.io

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【なぜとらラボエンジニアはラジオを始めたのか】
 を開催します!興味のある方はぜひご参加ください!

yumenosora.connpass.com

■5月28日(金)19:30~ 【オンライン】オタクが最新技術を追うLTイベント#24【初心者歓迎】【テーマフリー】  を開催します!こちらは発表者も募集中ですので、LT初心者という方でもぜひご応募ください!

yumenosora.connpass.com

採用情報

■募集職種
yumenosora.co.jp

カジュアル面談も随時開催中です

■お申し込みはこちら!
yumenosora.connpass.com

■ToraLab.fmスタートしました!

メンバーによるPodcastを配信中!
是非スキマ時間に聞いて頂けると嬉しいです。
anchor.fm

■Twitterもフォローしてくださいね!

ツイッターでも随時情報発信をしています
twitter.com