2018/05/28

[golang]goaを使ってAPIの設計・開発し、OpenAPIドキュメントを自動生成する

golangでAPIサーバーを開発したい!でも、何からやっていいかわからない。そんなときにはgoaがオススメだ。



goaの特徴


goaの最大の特徴はAPIデザインを書くとモック、クライアントツール、ドキュメントなどを自動生成できるところだ。

開発は以下のような手順になる。

  1. DSLでAPIデザインを書く
  2. goagen(コードジェネレーター)で自動生成する
    • クライアントのスケルトン
    • テストコード
    • CLIツール
    • OpenAPI仕様のドキュメント
  3. APIを実装する
  4. ビルドして実行する

このように人の手が入るのは「1.DLSでAPIデザインを書く」と「3.APIを実装する」だけだ。それ以外はgoagenというコードジェネレーターが自動的にやってくれる。

golang初心者の私でも、はじめて触ってから3時間くらいで動くAPIサーバーをつくることができた。


以下にgoaを使ってAPIサーバーを開発する手順をまとめていく。

0. goa/goagenをインストールする


筆者の開発環境は以下のとおり。

  • macOS
  • go@1.8.3
  • goadesign/goa@1.3.0
    • 執筆時点での最新版は@1.3.1だが、goa内で使っているパッケージに破壊的な変更がされたものがありエラーがでるので1.3.0を使う
    • goa@1.3.1で開発するときのワークアラウンドも紹介する
  • dep@0.2.0
    • パッケージ管理ツールを使っているがなくてもOK

golangの開発環境構築方法は以下の記事を参照してほしい。
さっそくgoaをインストールする。
パッケージ管理ツール(dep)を使っている場合は、以下のコマンドを実行する。
# 開発プロジェクトのディレクトリに移動(learning-goa)
$ cd /path/to/dir/learning-goa

# depの初期化
$ dep init
$ ls
Gopkg.toml Gopkg.lock vendor/

# goファイルを作成
#  goファイルが1つもないとdepを実行したときに「all dirs lacked any go code」というエラーがでるため。このファイルはあとで削除する
$ touch tmp.go

# goa/goagenをインストール
$ dep ensure -add github.com/goadesign/goa/goagen
# dep ensure -add でインストールできない場合は、tmp.go内でgoagenをimportしてdep ensureをすれば良い

前述のとおりgoa@1.3.1ではビルドできないことがあるため、Gopkg.tomlでバージョンを1.3.0に固定する
# Gopkg.toml

[[constraint]]
  name = "github.com/goadesign/goa"
  version = "=1.3.0"


depを使わない場合は、go get -u github.com/goadesign/goa/goagenでインストールする。



1. DSLでAPIデザインを書く


最初にすることはDSLでAPIデザインを書くこと。
慣例として./designというディレクトリの中にgoファイルを入れる。

エンドポイントが増えたときのために以下のような構成にする。
./design
├── api.go        // API全体の定義(APIで定義)
├── mediatypes    // レスポンスのスキーマ(MediaTypeで定義)
│   └── task.go
├── usertypes     // リクエストのpayloadなどのスキーマ(Typeで定義)
│   └── task.go
└── resources     // エンドポイントの定義(Resourceで定義)
    └── task.go


./design/api.go


APIサーバーの全体的な定義を書く。
バージョンやスキーム、ホスト、ベースパス、メディアタイプなど。

// ./design/api.go
package design

// golint:should not use dot imports と怒られるので名前付きでimportする
import (
  goa "github.com/goadesign/goa/design"
  dsl "github.com/goadesign/goa/design/apidsl"
  // API Resources
  _ "github.com/BcRikko/learning-goa/design/resources"
)

// APIの定義(http://localhost:8080/api)
var _ = dsl.API("Task", func() {
  dsl.Title("タスク管理API")
  dsl.Description("タスク管理のAPIです。")
  dsl.Version("0.1")
  dsl.Scheme("http")
  dsl.Host("localhost:8080")
  dsl.BasePath("/api")
  dsl.Consumes("application/json") // リクエストのメディアタイプ
  dsl.Produces("application/json") // レスポンスのメディアタイプ

        // 202:Createdのときのレスポンステンプレートを定義
  dsl.ResponseTemplate(goa.Created, func(pattern string) {
    dsl.Description("タスク作成が完了しました。")
    dsl.Status(201)
    dsl.Headers(func() {
      dsl.Header("Location", goa.String, "作成したタスクのリンク", func() {
        dsl.Pattern(pattern)
      })
    })
  })
})

goaのチュートリアルにはドットインポートで書かれているのだが、golintで怒られるので名前付きでインポートしている。

github.com/goadesign/goa/designにはフィールドの型の定義などが含まれている。
github.com/goadesign/goa/design/apidslにはAPIデザインするためのDSLが含まれている。


./design/resources/task.go


http://example.com/api/tasks以下のエンドポイントを定義する。
# ./design/resources/task.go

package resources

import (
  "github.com/BcRikko/learning-goa/design/mediatypes"
  "github.com/BcRikko/learning-goa/design/usertypes"
  goa "github.com/goadesign/goa/design"
  dsl "github.com/goadesign/goa/design/apidsl"
)

var _ = dsl.Resource("Tasks", func() {
  dsl.DefaultMedia(mediatypes.Task)
  dsl.BasePath("/tasks")

  // GET http://localhost:8080/api/tasks
  dsl.Action("list", func() {
    dsl.Routing(dsl.GET(""))
    dsl.Description("タスク一覧を取得する。")
    dsl.Response(goa.OK, dsl.CollectionOf(mediatypes.Task))
  })

  // GET http://localhost:8080/api/tasks/:taskID
  dsl.Action("show", func() {
    dsl.Routing(dsl.GET("/:taskID"))
    dsl.Description("指定IDのタスクを取得する。")
    dsl.Params(func() {
      dsl.Param("taskID", goa.Integer, "タスクID")
    })
    dsl.Response(goa.OK)
    dsl.Response(goa.NotFound)
    dsl.Response(goa.BadRequest, goa.ErrorMedia)
  })

  // POST http://localhost:8080/api/tasks
  dsl.Action("create", func() {
    dsl.Routing(dsl.POST(""))
    dsl.Description("タスクを作成する。")
    dsl.Payload(usertypes.TaskPayload)
    dsl.Response(goa.Created, "/tasks/[0-9]+")
    dsl.Response(goa.BadRequest, goa.ErrorMedia)
  })

  // PUT http://localhost:8080/api/tasks/:taskID
  dsl.Action("update", func() {
    dsl.Routing(dsl.POST("/:taskID"))
    dsl.Description("指定IDのタスクを更新する。")
    dsl.Params(func() {
      dsl.Param("taskID", goa.Integer, "タスクID")
    })
    dsl.Payload(usertypes.TaskPayload)
    dsl.Response(goa.NoContent)
    dsl.Response(goa.NotFound)
    dsl.Response(goa.BadRequest, goa.ErrorMedia)
  })

  // DELETE http://localhost:8080/api/tasks/:taskID
  dsl.Action("delete", func() {
    dsl.Routing(dsl.DELETE("/:taskID"))
    dsl.Description("指定IDのタスクを削除する。")
    dsl.Params(func() {
      dsl.Param("taskID", goa.Integer, "タスクID")
    })
    dsl.Response(goa.NoContent)
    dsl.Response(goa.NotFound)
    dsl.Response(goa.BadRequest, goa.ErrorMedia)
  })
})


ここでは以下の5つのエンドポイントを定義している。

  • GET /api/tasks … タスク一覧を取得する
  • GET /api/tasks/:taskID … 指定IDのタスクを取得する
  • POST /api/tasks … タスクを作成する
  • PUT /api/tasks/:taskID … 指定IDのタスクを更新する
  • DELETE /api/tasks/:taskID … 指定IDのタスクを削除する


./design/mediatypes/task.go


GET /api/tasksなどのレスポンススキーマを定義する。
# ./design/mediatypes/task.go

package mediatypes

import (
  goa "github.com/goadesign/goa/design"
  dsl "github.com/goadesign/goa/design/apidsl"
)

// Task はタスクリソースのメディアタイプ
var Task = dsl.MediaType("application/x-learning-goa+json", func() {
  dsl.Description("タスク")
  dsl.Attributes(func() {
    dsl.Attribute("id", goa.Integer, "タスクID", func() {
      dsl.Example(12345)
    })
    dsl.Attribute("title", goa.String, "タスクのタイトル", func() {
      dsl.Example("my task")
    })
    dsl.Attribute("done", goa.Boolean, "タスクの完了状態", func() {
      dsl.Example(true)
    })
    dsl.Attribute("created_at", goa.DateTime, "タスクの作成日時")
    dsl.Attribute("updated_at", goa.DateTime, "タスクの更新日時")

    dsl.Required("id", "title", "done", "created_at", "updated_at")
  })

  dsl.View("default", func() {
    dsl.Attribute("id")
    dsl.Attribute("title")
    dsl.Attribute("done")
    dsl.Attribute("created_at")
    dsl.Attribute("updated_at")
  })
})

レスポンスに「id」「title」「done」「created_at」「updated_at」のフィールドを含めることを定義している。


./design/usertypes/task.go


POST /api/tasksのリクエストボディなどの定義をする。
# ./design/usertypes/task.go

package usertypes

import (
  goa "github.com/goadesign/goa/design"
  dsl "github.com/goadesign/goa/design/apidsl"
)

// TaskPayload はリクエストパラメータの定義
var TaskPayload = dsl.Type("TaskPayload", func() {
  dsl.Attribute("title", goa.String, "タスクのタイトル", func() {
    dsl.MinLength(2)
    dsl.MaxLength(128)
    dsl.Example("example task title")
  })

  dsl.Attribute("done", goa.Boolean, "タスクの完了状態", func() {
    dsl.Default(false)
    dsl.Example(false)
  })

  dsl.Required("title")
})

POSTやPUTで必要なリクエスト時のパラメータを定義している。



これでAPIデザインは終了。次にgoagenを使って各種コードやドキュメントを自動生成する。



2. goagen(コードジェネレーター)で自動生成する


DSLで書いたAPIデザインをもとにコードを自動生成する。

depを使ってベンダリングしている場合は、goagenをビルドする必要があるので注意。
# ベンダリングしている場合
$ cd ./vendor/github.com/goadesign/goa/goagen
$ go build
$ cd ../../../../
# GOPATH配下のプロジェクトディレクトリの/designディレクトリを指定して生成する
$ ./vendor/github.com/goadesign/goa/goagen/goagen bootstrap -d github.com/BcRikko/learning-goa/design

# go getでグローバルにインストールしている場合
$ goagen bootstrap -d github.com/BcRikko/learning-goa/design

app                         // HTTPルータに関するコード
app/contexts.go             // - HTTPのコンテキストデータ構造
app/controllers.go          // - リソースごとのインターフェース
app/hrefs.go                // - hrefを構築するための関数
app/media_types.go          // - レスポンスのメディアタイプのデータ構造
app/user_types.go           // - DSL#Typeで定義されたデータ構造
app/test                    // コントロールをテストする用のヘルパー
app/test/tasks_testing.go 
main.go
tasks.go                    // tasksのスケルトンコントローラ(ここに実装する)
tool                        // CLIツール(←この行は表示されない)
tool/task-cli
tool/task-cli/main.go
tool/cli
tool/cli/commands.go
client                      // クライアントのGoパッケージ
client/client.go
client/tasks.go
client/user_types.go
client/media_types.go
swagger                     // OpenAPI仕様のjson/yaml
swagger/swagger.json
swagger/swagger.yaml

上記のようにいろいろ生成されるので、あとはルートディレクトリにある./tasks.goというスケルトンコントローラに処理を実装していくだけだ。



3. APIを実装する


ルートディレクトリに生成された./tasks.goに実装していくのだが、このコントローラには以下のようなコードが吐かれている。
// ./tasks.go

// List runs the list action.
func (c *TasksController) List(ctx *app.ListTasksContext) error {
  // TasksController_List: start_implement

  // Put your logic here

  res := app.XLearningGoaCollection{}
  return ctx.OK(res)
  // TasksController_List: end_implement
}

Put your logic hereのコメント部分に実際の処理を書いていく。

package main

import (
  "time"

  "github.com/BcRikko/learning-goa/app"
  "github.com/goadesign/goa"
)

// TasksController implements the Tasks resource.
type TasksController struct {
  *goa.Controller
}

// NewTasksController creates a Tasks controller.
func NewTasksController(service *goa.Service) *TasksController {
  return &TasksController{Controller: service.NewController("TasksController")}
}

// Create runs the create action.
func (c *TasksController) Create(ctx *app.CreateTasksContext) error {
  // TasksController_Create: start_implement

  ctx.ResponseData.Header().Set("Location", app.TasksHref(999))

  return ctx.Created()

  // TasksController_Create: end_implement
}

// Delete runs the delete action.
func (c *TasksController) Delete(ctx *app.DeleteTasksContext) error {
  // TasksController_Delete: start_implement

  return ctx.NoContent()

  // TasksController_Delete: end_implement
}

// List runs the list action.
func (c *TasksController) List(ctx *app.ListTasksContext) error {
  // TasksController_List: start_implement

  res := app.XLearningGoaCollection{
    {
      ID:        1,
      Title:     "task1",
      Done:      false,
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
    },
    {
      ID:        2,
      Title:     "task2",
      Done:      false,
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
    },
    {
      ID:        3,
      Title:     "task3",
      Done:      true,
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
    },
  }

  return ctx.OK(res)

  // TasksController_List: end_implement
}

// Show runs the show action.
func (c *TasksController) Show(ctx *app.ShowTasksContext) error {
  // TasksController_Show: start_implement

  if ctx.TaskID == 0 {
    return ctx.NotFound()
  }

  res := &app.XLearningGoa{
    ID:        ctx.TaskID,
    Title:     "example task title",
    Done:      false,
    CreatedAt: time.Now(),
    UpdatedAt: time.Now(),
  }
  return ctx.OK(res)

  // TasksController_Show: end_implement
}

// Update runs the update action.
func (c *TasksController) Update(ctx *app.UpdateTasksContext) error {
  // TasksController_Update: start_implement

  return ctx.NoContent()

  // TasksController_Update: end_implement
}

本来ならDB接続したり、いろんな処理をしたりするのだが、今回はgoaの勉強が目的だったのでモックデータを返すように実装している。



4. ビルドして実行する


あとはビルドして実行するだけ。


# ビルドする(apiという名前の実行ファイルを出力する)
$ go build -o api

# 実行
$ ./api

ビルド時に「package not found」エラーがでた場合

package not found系のエラーが出た場合は、goagenで生成したコードの中で使っているパッケージがインストールされていないのが原因なので、dep ensureなどで依存パッケージをインストールする。


ビルド時に「uuid」に関するエラーがでた場合

goa@1.3.1を使っていると以下のようなuuidのエラーがでる。
それはgo.uuidという依存パッケージが破壊的な変更されたためだ。
$ go build -o api
# github.com/BcRikko/learning-goa/vendor/github.com/goadesign/goa/uuid
vendor/github.com/goadesign/goa/uuid/uuid.go:18: not enough arguments in call to uuid.Must
 have (uuid.UUID)
 want (uuid.UUID, error)

回避策としては3つある。

1つ目は、goa@1.3.1のまま、uuidのバージョンだけ変更前のリビジョンを指定すること。(Breaking API Change · Issue #66 · satori/go.uuid
// Gopkg.toml
[override]]
    name = "github.com/satori/go.uuid"
    revision = "063359185d32c6b045fa171ad7033ea545864fa1"


2つ目はインストールするgoaのバージョンを1.3.0に固定すること。
// Gopkg.toml
[[constraint]]
  name = "github.com/goadesign/goa"
  version = "=1.3.0"


3つ目はgoa自体を書き換えること。ただvendorディレクトリもgit管理しなきゃいけなくなるのでちょっとツライかも。
// ./vendor/github.com/goadesign/goa/uuid/uuid.go

// NewV4 Wrapper over the real NewV4 method
func NewV4() UUID {
   //return UUID(uuid.Must(uuid.NewV4())) ←uuid.Must部分を削除する
   return UUID(uuid.NewV4())
}


curlコマンドでAPIを叩いてみる


$ curl localhost:8080/api/tasks | jq .
[
  {
    "created_at": "2018-05-25T08:52:19.781588776+09:00",
    "done": false,
    "id": 1,
    "title": "task1",
    "updated_at": "2018-05-25T08:52:19.78158881+09:00"
  },
  {
    "created_at": "2018-05-25T08:52:19.781588847+09:00",
    "done": false,
    "id": 2,
    "title": "task2",
    "updated_at": "2018-05-25T08:52:19.781588882+09:00"
  },
  {
    "created_at": "2018-05-25T08:52:19.781588919+09:00",
    "done": true,
    "id": 3,
    "title": "task3",
    "updated_at": "2018-05-25T08:52:19.781588953+09:00"
  }
]

$ curl localhost:8080/api/tasks/12345 | jq .
{
  "created_at": "2018-05-23T11:35:59.55470746+09:00",
  "done": false,
  "id": 123,
  "title": "example task title",
  "updated_at": "2018-05-23T11:35:59.554707482+09:00"
}



golang初心者がgoaを使ってみた感想


Node.js(TypeScript) + Express + OpenAPIで実装していたときより格段に楽に実装できた。当記事では紹介しなかったがCLIツールも生成されていたり、なによりドキュメントの整備をしなくてよいのが助かる。

あとgolang初心者なのだが、フレームワークに全乗っかりして開発することで学習スピートがあがるような気がした。おそらくAOJで100本ノックをするより身についてるような…。

DSL覚えるのは面倒だが、大規模なAPIになればなるほどgoaの本領が発揮されると思う。



参考サイト



以上

written by @bc_rikko

0 件のコメント :

コメントを投稿