Eng (なりたい)

はやく エンジニア になりたい

goaを使ってAPIサーバをサクッと作る

概要

目的

JSONを返すREST APIサーバをサクッと作りたいという要求がちょくちょくあります。

そのたびにサーバの骨組みを作ってリクエストのバリデーションをして…みたいな作業をするのに嫌気がさしたのでJSON APIのクッキーカッターを作ろうと思い立ちました。

目的は上記のとおりなので「こっちのほうが楽なんだがw」といったものがあれば是非教えて下さい!!

なぜgoaを選んだか

まずgoaとはなにかなんですが以下に公式Docを引用します ref. https://github.com/goadesign/goa

Goa takes a different approach to building services by making it possible to describe thedesignof the service API using a simple Go DSL. Goa uses the description to generate specialized service helper code, client code and documentation.

以下が主な選定理由です。

  • golangベースのDSLで書きやすい(※個人の感想です)
  • DSLによってサーバの枠組みが生成される
    • リクエストのvalidationもしてくれる(少し癖があるが)
    • リクエストの構造体を受け取って処理するロジックのみを書くだけ
  • DSLからSwaggerが生成される
  • クライアント用のコードも生成される(後述)

Swaggerが生成されるのが結構いいなと思っています。(普通は逆かもしれませんが) Swaggerを起点にしてJMeterなどの負荷試験ツールのコードも生成したいという野望があります。

Prerequisites

Install goa

goaコマンドをインストールします。 グローバルを汚したくないという方は適宜moduleを使ってください

$ go get -u goa.design/goa/v3/cmd/goa

作っていく

Design file を書く

プロジェクトを作り、moduleを初期化します。

$ mkdir -p my-project/design
$ cd my-project/
$ export GO111MODULE=on
$ go mod init

# edit design/design.go

後半でDockerも使うのでDocker環境も適宜用意してください。

Design file というインタフェースを定義するファイルを記述します。 今回はリクエストをそのまま返すechoサーバを実装します。 パラメータは

  • name: URLのパスとして受け取る (required)
  • age: クエリストリングで受け取る (required)

です。 POSTでリクエストボディを受け取ったり、デフォルト値を設定したり、フォーマットを指定したりなど便利な機能はたくさんありますが今回は全体の流れを説明するために割愛します。(今後書いていきたい)

DSLの文法自体も詳しくは解説しませんがなんとなく雰囲気で読めると思います。(押し付け)

詳しくは公式のリファレンスを参照してください。v2v3でかなり書き方が違うのでどちらのバージョンかをしっかり確認してください。 dsl - GoDoc

意外とサンプルが転がっていないので(特に日本語サイトはない)公式のExampleも参照するといいと思います。 GitHub - goadesign/examples: Examples for goa showing specific capabilities

design/desing.go

package design

import . "goa.design/goa/v3/dsl"

// API describes the global properties of the API server.
var _ = API("echo", func() {
    Title("Echo Service")
    Description("This is HTTP echo service")
    Server("echo-server", func() {
        Host("localhost", func() { URI("http://0.0.0.0:8088") })
    })
})

// Service describes a service
var _ = Service("echo-service", func() {
    Description("Echo your request")
    // Method describes a service method (endpoint)
    Method("echo-get", func() {
        // define request payload
        Payload(func() {
            // Attribute describes an object field
            Attribute("name", String, "Your name")
            Attribute("age", Int, "Your age")
            // Both attributes must be provided when invoking "add"
            Required("name", "age")
        })
        // define response data type
        Result(String)
        // HTTP describes the HTTP transport mapping
        HTTP(func() {
            // Requests to the service consist of HTTP GET requests
            GET("/name/{name}")
            Param("age")
            // Responses use a "200 OK" HTTP status
            // The result is encoded in the response body
            Response(StatusOK)
        })
    })
})

コードを生成する

Design file をもとにサーバの枠となるファイルを生成します。

# goa gen <MODULE_NAME>
$ goa gen github.com/kaito2/my-project/design
gen/echo_service/client.go
gen/echo_service/endpoints.go
gen/echo_service/service.go
gen/http/cli/echo_server/cli.go
gen/http/echo_service/client/cli.go
gen/http/echo_service/client/client.go
gen/http/echo_service/client/encode_decode.go
gen/http/echo_service/client/paths.go
gen/http/echo_service/client/types.go
gen/http/echo_service/server/encode_decode.go
gen/http/echo_service/server/paths.go
gen/http/echo_service/server/server.go
gen/http/echo_service/server/types.go
gen/http/openapi.json
gen/http/openapi.yaml

# goa example <MODULE_NAME>
$ goa example github.com/kaito2/my-project/design
cmd/echo_server-cli/http.go
cmd/echo_server-cli/main.go
cmd/echo_server/http.go
cmd/echo_server/main.go
echo_service.go

$ tree .
.
├── cmd
│   ├── echo_server
│   │   ├── http.go
│   │   └── main.go
│   └── echo_server-cli
│       ├── http.go
│       └── main.go
├── design
│   └── design.go
├── echo_service.go
├── gen
│   ├── echo_service
│   │   ├── client.go
│   │   ├── endpoints.go
│   │   └── service.go
│   └── http
│       ├── cli
│       │   └── echo_server
│       │       └── cli.go
│       ├── echo_service
│       │   ├── client
│       │   │   ├── cli.go
│       │   │   ├── client.go
│       │   │   ├── encode_decode.go
│       │   │   ├── paths.go
│       │   │   └── types.go
│       │   └── server
│       │       ├── encode_decode.go
│       │       ├── paths.go
│       │       ├── server.go
│       │       └── types.go
│       ├── openapi.json
│       └── openapi.yaml
├── go.mod
└── go.sum

ロジックを書く

リクエストを処理するために編集するファイルはプロジェクトルートディレクトリにある echo_server.go のみです。

echo_server.go

package echo

import (
    "context"
    "log"

    echoservice "github.com/kaito2/my-project/gen/echo_service"
)

// echo-service service example implementation.
// The example methods log the requests and return zero values.
type echoServicesrvc struct {
    logger *log.Logger
}

// NewEchoService returns the echo-service service implementation.
func NewEchoService(logger *log.Logger) echoservice.Service {
    return &echoServicesrvc{logger}
}

// EchoGet implements echo-get.
func (s *echoServicesrvc) EchoGet(ctx context.Context, p *echoservice.EchoGetPayload) (res string, err error) {
    s.logger.Print("echoService.echo-get")
    return
}

gRPCでスタブを生成した事がある人は見慣れたコードかもしれません。 このEchoGet()関数にロジックを記述します。 以下のように記述することでリクエストのパラメータを取得できます。

...
// EchoGet implements echo-get.
func (s *echoServicesrvc) EchoGet(ctx context.Context, p *echoservice.EchoGetPayload) (res string, err error) {
    s.logger.Println("echoService.echo-get")
    s.logger.Printf("Request name: %s", p.Name)
    s.logger.Printf("Request age : %d", p.Age)
    return fmt.Sprintf("Your name: %s, Your age: %d", p.Name, p.Age), nil
}
...

実行する

実際にサーバを起動してみます。

$ go build ./cmd/echo_server
$ ./echo_server
[echo] 21:19:42 HTTP "EchoGet" mounted on GET /add/{name}
[echo] 21:19:42 HTTP server listening on "localhost:8088"

別のターミナルからアクセスすると…

$ curl "http://localhost:8088/name/kaito2?age=13"
"Your name: kaito2, Your age: 13"

無事動作しました。

ちなみに Requiredに指定したパラメータを記述しないと400が返ります。

$ curl "http://localhost:8088/add/kaito2"
{"name":"missing_field","id":"XXXXXXX","message":"\"age\" is missing from query string; invalid value \"\" for \"age\", must be a integer","temporary":false,"timeout":false,"fault":false}

上で書いたクライアントも生成されているので利用します。

$ go build ./cmd/echo_server-cli/
$ ./echo_server-cli --help
./echo_server-cli is a command line client for the echo API.

Usage:
    ./echo_server-cli [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags]

    -host HOST:  server host (localhost). valid values: localhost
    -url URL:    specify service URL overriding host URL (http://localhost:8080)
    -timeout:    maximum number of seconds to wait for response (30)
    -verbose|-v: print request and response details (false)

Commands:
    echo-service echo-get

Additional help:
    ./echo_server-cli SERVICE [ENDPOINT] --help

Example:
    ./echo_server-cli echo-service echo-get --name "Quam dolores." --age 8576636692946696796

$ ./echo_server-cli echo-service echo-get --name "Quam dolores." --age 8576636692946696796
"Your name: Quam dolores., Your age: 8576636692946696796"

デフォルトでsampleの値が用意されているので変な値になっていますが(笑) クライントで動作確認もできます(curlでいい気もするが)

Dockerizeする

せっかく(?)なのでDockerizeしましょう。

Dockerfile

# Dockerfile References: https://docs.docker.com/engine/reference/builder/
FROM golang:1.11.12 as builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/echo_server

FROM alpine:3.10.1
WORKDIR /app
EXPOSE 8080
COPY --from=builder /app/main .
CMD ["./main"]

プロダクションで alpine ってどうなんだろう…結構使われてるのかな…

実行してみます。

$ docker login
$ docker build . -t my-project-image
$ docker run --rm -p 8088:8088 my-project-image
[echo] 12:48:25 HTTP "EchoGet" mounted on GET /name/{name}
[echo] 12:48:25 HTTP server listening on "0.0.0.0:8088"

いい感じに実行できました!!

まとめ

  • goaでAPIサーバをつくる
    • Design file を記述
    • Design file をもとにコード生成
    • リクエスト処理のロジックを記述
  • Dockerで実行

という流れで簡単にサーバができました!! これをCIでビルドしてCloud Runにデプロイできるようにして…と夢が広がります!!

DSLに関しては少し癖があり、色々動作検証をしてみたのでまた記事にしたいと思います。