こんにちは。ホスティング事業部の@matsusukeです。
今年もサッカーのプレミアリーグが開幕しましたね。 日本代表の冨安選手が在籍しているアーセナルを高校生の頃から応援しています。 今年はワールドカップも開催されるので、より一層活躍してほしいですね。
今回は、OpenAPI Generator を用いて自動で生成したAPIサーバーのコードにミドルウェア層を導入した事例を紹介します。
ミドルウェア層を導入する理由
Webアプリケーション開発において、開発の初期段階からプロジェクトの規模が大きくなってコードの量が増えると、各APIの処理の共通化を行えそうなコードが発生します。
例えば、「認証ができなかった場合、その後のアプリケーションロジックの処理を行わず、エラーを返す」といった認証処理が例として挙げられます。
上記のようなケースだと、そもそも認証ができなかった場合にはアプリケーションロジックが関わるコードを呼ばずにエラーを返した方がオーバーヘッドが少なくなるといったメリットがあります。
OpenAPI Generator を用いたGoのコード生成
OpenAPI Generatorは、yamlファイルにAPIの仕様を定義することで各開発言語でAPIクライアント/サーバーのコードを自動で生成できるツールです。
下記のような、OpenAPI Specification v3の仕様に沿ったドキュメントから自動でコードを生成できます。
# api.yaml
openapi: 3.0.3
info:
version: 0.0.1
title: XXX API
description: XXXについてのAPI
tags:
- name: Login
description: Login
paths:
/login:
get:
summary: Login
description: ログイン
tags:
- Login
operationId: startLogin
responses:
'200':
description: OK
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/defaultErrorResponse'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/loginBody'
components:
schemas:
loginBody:
type: object
properties:
id:
type: string
defaultErrorResponse:
type: object
properties:
errors:
type: array
items:
type: object
properties:
message:
type: string
detail:
type: object
required:
- message
required:
- errors
今回はDocker Imageを用いてコードを生成する方法をご紹介します。
コードを生成するプロジェクト上で
docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli:$(GENERATOR_VERSION) generate \
-i /local/spec/api.yaml
-g go-server
-p packageVersion=0.0.1
-o /local
を実行します。 上記のコマンドの各オプションの意味は以下の通りです。
- i
- 使用するyamlファイルの指定をします。
- 今回、使用するyamlファイルは/spec/api.yamlとしています。
- g
- 生成するコードの指定。go-serverは、Go言語でAPIサーバーのコードを生成するための指定ワードです。
- p
- プロパティを追加することができます。ここではpackageVersionをプロパティに指定していますが、このプロパティはgo-serverでコードを生成する時に使用できるプロパティです。
- go-serverで設定できるプロパティの一覧についてはgo-serverのCONFIG OPTIONSでご参照ください。
OpenAPI GeneratorはMacを使っている場合、Homebrewでinstallすることも可能です。 詳しくはGitHubをご参照ください。
また、生成されるコードについてはテンプレートの仕組みを用いてカスタマイズすることも可能です。
コードは以下のような形式で生成されます。
/project
├─ .openapi-generator
│ └─ VERSION
├─ api
│ └─ openapi.yaml
├─ go
│ ├─ api_login_service.go
│ ├─ api_login.go
│ ├─ api.go
│ ├─ error.gp
│ ├─ helpers.go
│ ├─ impl.go
│ ├─ logger.go
│ ├─ model_default_error_response_errors.go│
│ ├─ model_default_error_response.go
│ └─ routers.go
├─ .openapi-generator-ignore
├─ go.mod
├─ go.sum
├─ main.go
└─ README.md
api_login_service.go
ではLoginApiService
構造体が定義され、「引数として受け取った情報をもとにAPIで返したいデータを作る」というビジネスロジックをLoginApiService
構造体のメソッドとして記述します。
// api_login_service.go
// LoginApiService is a service that implements the logic for the LoginApiServicer
// This service should implement the business logic for every endpoint for the LoginApi API.
// Include any external packages or services that will be required by this service.
type LoginApiService struct {
}
// StartLogin - Login
func (s *LoginApiService) StartLogin(ctx context.Context, loginBody LoginBody) (ImplResponse, error) {
// TODO - update StartLogin with the required logic for this service method.
// Add api_login_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation.
//TODO: Uncomment the next line to return response Response(200, {}) or use other options such as http.Ok ...
//return Response(200, nil),nil
//TODO: Uncomment the next line to return response Response(400, DefaultErrorResponse{}) or use other options such as http.Ok ...
//return Response(400, DefaultErrorResponse{}), nil
// リクエストパラメータのloginBody構造体からIDを取得
id := loginBody.id
// idを使って何らかの処理を行うビジネスロジックを記述する
return Response(http.StatusNotImplemented, nil), errors.New("StartLogin method not implemented")
}
go-serverの特徴として、各APIはapi.yaml
の
tags:
- Login
で指定したtagのService構造体のメソッドとして定義されます。
tagがLogin
のAPIを増やすと、LoginApiService
構造体にメソッドが随時生成されます。
別のService構造体にメソッドを生成したい場合、APIのtagを任意のものに指定することで、メソッドを生成することができます。
生成したコードのルーティング
api_login.go
は「/login
のパスにGETリクエストがきたら、所定のapi_login_service.go
のStartLogin
メソッドを呼び出し、レスポンスのもとになるデータを作り、返す」という処理を担っています。
// api_login.go
// LoginApiController binds http requests to an api service and writes the service results to the http response
type LoginApiController struct {
service LoginApiServicer
errorHandler ErrorHandler
}
func (c *LoginApiController) StartLogin(w http.ResponseWriter, r *http.Request) {
loginBodyParam := LoginBody{}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(&loginBodyParam); err != nil {
c.errorHandler(w, r, &ParsingError{Err: err}, nil)
return
}
if err := AssertLoginBodyRequired(loginBodyParam); err != nil {
c.errorHandler(w, r, err, nil)
return
}
result, err := c.service.StartLogin(r.Context(), loginBodyParam)
// If an error occurred, encode the error with the status code
if err != nil {
c.errorHandler(w, r, err, &result)
return
}
// If no error, encode the body and the result code
EncodeJSONResponse(result.Body, &result.Code, result.Headers, w)
}
LoginApiController
構造体のメソッドとして、各API(StartLogin)が定義されています。
また、LoginApiController構造体にはRoutes()
メソッドも定義されています。
// api_login.go
func (c *LoginApiController) Routes() Routes {
return Routes {
{
Name: "StartLogin",
Method: strings.ToUpper("Get"),
Pattern: "/api/login/",
HandlerFunc: c.StartLogin,
},
}
}
「/api/login/
のパスにGETリクエストが来ると、controllerのStartLoginを呼び出す」、といった処理が記載されています。
router.go
の中で、この対応関係から実際のルーティングを生成する関数が定義されています。
go-serverは、特に何も設定しなければ、デフォルトのルーターとしてgithub.com/gorilla/muxが使用され、それを組み立てるNewRouter関数が生成されます。
// router.go
func NewRouter(routers ...Router) *mux.Router {
router := mux.NewRouter().StrictSlash(true)
for _, api := range routers {
for _, route := range api.Routes() {
var handler http.Handler
handler = route.HandlerFunc
handler = Logger(handler, route.Name)
router.
Methods(ro.Method).
Path(ro.Pattern).
Name(ro.Name).
Handler(handler)
}
}
return router
}
NewRouterメソッドの戻り値である*mux.Router
型はgithub.com/gorilla/muxで
type Router struct {
// 省略
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// 省略
}
のように定義されています。
また、Goの標準パッケージであるnet/httpのinterfaceであるhttp.Handler
は下記のように定義されています。
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
ResponseWriter
と*Request
を引数に持つServeHTTP()
というメソッドを持つ構造体であれば、
このhttp.Handler
interfaceを満たします。
つまり、ServeHTTP(w http.ResponseWriter, req *http.Request)
メソッドを持つ*mux.Router
型はhttp.Handler
interfaceを満たしています。
(net/httpについてはGoでHTTPサーバーを作る時には必須と言っても過言ではないパッケージですが、ここでは詳細の説明は割愛します)
サーバーを動かす処理については、net/httpで定義されているListenAndServe
メソッドを使って実装します。
// ListenAndServe listens on the TCP network address addr and then calls
// Serve with handler to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.
//
// The handler is typically nil, in which case the DefaultServeMux is used.
//
// ListenAndServe always returns a non-nil error.
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
ListenAndServe
は第2引数にhttp.Handler
interfaceを取るので、NewRouterメソッドの戻り値を使用することが可能です。
go-serverがデフォルトで生成するmain.go
では、
// main.go
func main() {
log.Printf("Server started")
LoginApiService := openapi.NewLoginApiService()
LoginApiController := openapi.NewLoginApiController(LoginApiService)
router := openapi.NewRouter(LoginApiController)
log.Fatal(http.ListenAndServe(":8080", router))
}
のように記述されており、ルーティングを実装しています。
ミドルウェア層の実装
開発の規模が大きくなると、APIの数が増え、api_*_service.go
が肥大化していきます。
ミドルウェア層を導入することで、認証処理などの各APIで共通している処理を統合・リファクタリングすることができます。
ミドルウェアの実装はGoのミドルウェアパターンとしてよく知られる方法を用いますが、改めて下記で詳細を説明しようと思います。
Goのミドルウェアパターンについて
Goのミドルウェアパターンを満たす最低限の実装は
func exampleMiddleware(next http.Handler) http.Handler {
return next
}
のように、http.Handler
interfaceを引数として受け取り、http.Handler
interfaceを返却する関数として定義します。
http.Handler
と似たような名前のhttp.HandlerFunc
もnet/httpで定義されている型です。
http.HandlerFunc
型については、下記のように定義されています。
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
http.HandlerFunc
型はResponseWriter
と*Request
を引数に取るServeHTTP()
というメソッドを持つ構造体です。
そのため、http.HandlerFunc
型はhttp.Handler
interfaceを満たしていることになります。
また、http.HandlerFunc
型の説明文を日本語訳すると、
HandlerFunc 型は、通常の関数を HTTP ハンドラとして使用できるようにするためのアダプタです。f が適切なシグネチャを持つ関数である場合、HandlerFunc(f) はf を呼び出すハンドラです。
と書かれています。
これは、http.HandlerFunc
はResponseWriter
と*Request
を引数に取る関数であり、ある関数f
がResponseWriter
と*Request
を引数に取る場合、http.HandlerFunc(f)
はf
を呼び出す関数ということになります。
つまり、exampleMiddleware
については、
- 引数・戻り値は
http.Handler
interfaceを満たしているため、http.HandlerFunc
型の関数でも良い。 - 戻り値の
http.HandlerFunc
型の関数については、「任意の処理を行う関数(f)を呼び出すhttp.HandlerFunc
型の関数」でもよい ということになり、下記のようなコードで表現できます。
func exampleMiddleware(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 任意の処理を記述する
// 引数で渡したhttp.HandlerFuncを実行する
next(w, r)
})
}
このようなミドルウェアを作ることで、「任意の処理を実行した後に、次の処理を呼ぶ」という実装を表現することができます。
生成したmux.Routerに対してのミドルウェアパターンの導入
router.go
のNewRouter
で生成した*mux.Routerに対して、どのようにミドルウェアパターンを導入するのかについてご紹介します。
// router.go
func NewRouter(routers ...Router) *mux.Router {
router := mux.NewRouter().StrictSlash(true)
for _, api := range routers {
for _, route := range api.Routes() {
var handler http.Handler
handler = route.HandlerFunc
handler = Logger(handler, route.Name)
router.
Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(handler)
}
}
return router
}
router.go
内では
router := mux.NewRouter().StrictSlash(true)
という記述で、github.com/gorilla/muxのRouter型のrouter
変数を定義しています。
ループ内のhandler
変数は、http.Handler
interfaceで初期化後、http.HandlerFunc
型のroute.HandlerFunc
が代入されています。
また、生成したデフォルトのrouter.go
内ではLogger()
メソッドを使うことでミドルウェアパターンを既に実装しています。
// logger.go
func Logger(inner http.Handler, name string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
inner.ServeHTTP(w, r)
log.Printf(
"%s %s %s %s",
r.Method,
name,
time.Since(start),
)
})
}
Logger()
メソッドは「引数のhttp.Handler
interfaceのServeHTTP()
メソッドを実行後にlogの出力を行う、http.HandlerFunc
型の関数を返す」関数です。
つまり、戻り値のhandler
変数はこの段階で「引数のhandler
を実行後logの出力を行う、http.HandlerFunc
型の関数」となっています。
さらに、このhandler
変数を引数にして
router.
Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(handler)
router
のHandler()
メソッドを呼び出しています。
router
はgithub.com/gorilla/muxのRouter型で定義されていましたが、Methods()
メソッドが
// Methods registers a new route with a matcher for HTTP methods.
// See Route.Methods().
func (r *Router) Methods(methods ...string) *Route {
return r.NewRoute().Methods(methods...)
}
// NewRoute registers an empty route.
func (r *Router) NewRoute() *Route {
// initialize a route with a copy of the parent router's configuration
route := &Route{routeConf: copyRouteConf(r.routeConf), namedRoutes: r.namedRoutes}
r.routes = append(r.routes, route)
return route
}
と定義されているため、Handler()
メソッドを呼び出している型はgithub.com/gorilla/muxのRoute型になっています。
github.com/gorilla/muxのRoute型のHandler()
メソッドは
// Handler sets a handler for the route.
func (r *Route) Handler(handler http.Handler) *Route {
if r.err == nil {
r.handler = handler
}
return r
}
と定義されており、引数はhttp.Handler
interfaceとなっています。
これらのことから、Handler()
メソッドの引数については、http.Handler
interfaceを満たすものであればよい、ということがわかりました。
Handler()
メソッドの引数にはLogger()
メソッドのようなミドルウェアパターンを使えることがわかったので、共通化したい処理をミドルウェアとして新たに作成します。
今回は例として、認証処理のミドルウェアであるAuthMiddleware
を実装します。
// middleware/auth_middleware.go
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
// http.HandlerFunc 型の関数を返す処理
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// リクエストヘッダーからアクセストークンなどを取得
ah := r.Header.Get("Authentication")
// アクセストークンなどを使って認証処理
if err := service.Auth(ah); err != nil {
// アクセストークンを使った認証に失敗した場合、errorを返す
api.EncodeJSONResponse(w, r, errors.New("unauthorized"), nil)
return
}
// 認証に成功した場合、引数であるhttp.HandlerFuncを実行する
next(w, r)
})
}
AuthMiddleware
のポイントは、「認証に成功した場合は、引数のhttp.HandlerFunc
をコールし、認証に失敗した場合は、エラーを返す」関数を作っている、という点です。
// router.go
func NewRouter(routers ...Router) *mux.Router {
router := mux.NewRouter().StrictSlash(true)
for _, api := range routers {
for _, route := range api.Routes() {
router.
Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(middleware.AuthMiddleware(route.HandlerFunc))
}
}
return router
}
AuthMiddleware
の戻り値はhttp.Handler
interfaceを満たすため、Handler()
メソッドの引数として上記のように使用することができます。
ミドルウェアのテスト
middleware/auth_middleware.go
のAuthMiddleware
のテストを、「認証に成功した場合は、mockしたhttp.HandlerFunc
が呼ばれているかどうか、失敗した場合は呼ばれていないかどうか」という観点で書いてみます。
// middleware/auth_middleware_test.go
func TestAuthMiddleware(t *testing.T) {
const okMessage = "OK"
const errMessage = "unauthorized"
tests := []struct {
name string
accessToken string
isValidAccessToken bool
message string
}{
{"認証に成功した場合、mockしたハンドラが呼ばれる", "validAccessToken", true, okMessage},
{"認証に失敗した場合、mockしたハンドラが呼ばれない", "invalidAccessToken", false, errMessage},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// http.HandlerFunc型であるAuthMiddlewareを引数に、テストサーバを作る
ts := httptest.NewServer(AuthMiddleware(getMockHandler()))
t.Cleanup(func() {
ts.Close()
})
// リクエストを作る
p := new(RequestParam)
jsonStr, err := json.Marshal(p)
if err != nil {
t.Error(err)
}
req, err := http.NewRequest(
"GET",
ts.URL+"/api/login",
bytes.NewBuffer([]byte(jsonStr)),
)
if err != nil {
t.Error(err)
}
// Content-Type 設定
req.Header.Set("Content-Type", "application/json")
// AccessToken 設定
req.Header.Set("Authentication", "Bearer "+tt.accessToken)
// リクエスト
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Error(err)
}
t.Cleanup(func() {
err := resp.Body.Close()
if err != nil {
t.Error(err)
}
})
// モックした getMockHandler が呼ばれているかどうかの検証
// AccessTokenが正当なものである場合、後続のhandlerがcallされる
// AccessTokenが不正なものである場合、後続のhandlerがcallされず、errorが返却される
{
var got string
if tt.isValidAccessToken {
buf, err := io.ReadAll(resp.Body)
if err != nil {
t.Error(err)
}
got = string(buf)
} else {
buf, err := io.ReadAll(resp.Body)
if err != nil {
t.Error(err)
}
var errRespBody errRespBody
if json.Unmarshal(buf, &errRespBody) != nil {
t.Error(err)
}
got = errRespBody.errors[0].message
}
if want := tt.message; got != want {
t.Errorf("\ngot %v\nwant %v\n", got, want)
}
}
})
}
}
type errRespBody struct {
errors []respError `json:"errors"`
}
type respError struct {
message string `json:"message"`
}
func getMockHandler() http.HandlerFunc {
fn := func(w http.ResponseWriter, r *http.Request) {
// レスポンスにメッセージを記録する
w.Write([]byte("OK"))
}
return http.HandlerFunc(fn)
}
net/http/httptest
パッケージのNewServer
でテスト用のモックサーバーを立ち上げます。
NewServer
の引数にはhttp.Handler
をとるため、モックハンドラーを返すミドルウェアを引数に使ってモックサーバを作ることができます。
テストではこのモックサーバに対してリクエストを行います。
認証に成功すればモックしたgetMockHandler()
がコールされ、失敗すればコールされない、というテストを書くことで、ミドルウェアの正当性を担保します。
複数のミドルウェアの導入
開発が進むと、認証処理以外にも、ロギングやレート制限など、アプリケーションロジック以外に共通化したい処理が複数出てくるケースもあると思います。
// router.go
func NewRouter(routers ...Router) *mux.Router {
router := mux.NewRouter().StrictSlash(true)
for _, api := range routers {
for _, route := range api.Routes() {
router.
Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(middleware.LoggingMiddleware(middleware.RateLimitMiddleware(middleware.AuthMiddleware(route.HandlerFunc))))
}
}
return router
}
上記のように、ミドルウェアをネストして複数のミドルウェアを実装することも可能です。
しかし、可読性が低く、新しくミドルウェアを追加するたびにrouter.goを更新しなければならないため、実装コストが高くなってしまいます。
そこで、使用するミドルウェアの定義と複数のミドルウェアを使うための関数をミドルウェア層に切り出します。
// middleware/middleware.go
// http.HandlerFuncを引数に取り、http.HandlerFuncを返す型を定義する
type middleware func(next http.HandlerFunc) http.HandlerFunc
type middlewares []middleware
func FetchMiddlewares() middlewares {
var ms middlewares
ms = append(ms, loggingMiddleware)
ms = append(ms, rateLimitMiddleware)
ms = append(ms, authMiddleware)
}
func (m middlewares) Then (h http.HandlerFunc) http.HandlerFunc {
for i := range m {
h = m[len(m)-1-i](h)
}
return h
}
FetchMiddlewares()
で使用するミドルウェアの一覧を定義し、Then()
で、定義したすべてのミドルウェアを使用した後、最後にAPIのメソッドを呼ぶという実装を行うことができます。
// router.go
func NewRouter(routers ...Router) *mux.Router {
ms := middleware.FetchMiddlewares()
router := mux.NewRouter().StrictSlash(true)
for _, api := range routers {
for _, route := range api.Routes() {
r.
Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(ms.Then(route.HandlerFunc))
}
}
}
router.go
の可読性が高まっただけでなく、使用するミドルウェアが何であるかがrouter.go
からは隠蔽された形になり、使用するミドルウェアに変更があった場合にrouter.go
が影響を受けることがなくなりました。
まとめ
開発を進めていく上で処理を共通化し、リファクタリングを適宜進めるのは開発スピードを維持するためにもとても重要なことです。既存の設計に新しくミドルウェア層を導入することで、処理の共通化とアプリケーションロジック/認証の切り離しをうまく進めることができました。
今後、さらに新しい共通処理を作る必要があった場合にも、開発コストをあまりかけずにミドルウェアを導入できるようになったので、とてもいい取り組みができたかなと感じました。