こんにちは、2022新卒で PR TIMES に入社し、バックエンドエンジニアをしている宮崎です。
先日行われたハッカソンに向けてAPIをフルスクラッチしたのでやったこと共有します。
はじめに
PR TIMESでは新卒向けにAPIを提供してハッカソンを行なっています。
優秀者は即内定!23・24新卒向けハッカソン「PR TIMES HACKATHON 2022 Summer」8月8-9日開催
既存のAPIをGoへフルスクラッチした理由は以下の2つです。
- エンドポイントから取得できるリソースが推測できない、パスパラメータで指定する必要のないものがパスパラメータに含まれているなど、分かりにくい点がある
- 社内にNode.jsを使っているプロジェクトはほとんど無く、今後のメンテナンスコストが上がると予想される
Goを選んだ理由は、社内で採用されている&今後もメインで実装されるためです。
本エントリーではAPI仕様書の修正から、APIの実装まで全てご紹介します!
API仕様書の修正
既存のAPIは分かりにくい部分があったため、API仕様書の修正から行いました。修正の方針は以下の通りです。
OpenAPIのフォーマットに沿って記述する
OpenAPIの詳細については、21新卒エンジニアの柳さんがOpenAPIの活用についての記事を書いてくれています。

エンドポイントから取れるリソースが推測できるようにする
エンドポイントから取れるリソースが全く推測できないものがあったので、推測できるようにします。
パスパラメータで指定する必要がないものをクエリパラメータにする
from
, to
, page
などをパスパラメータで指定していました。しかしこれらはクエリパラメータで受け取るのが一般的かと思います。
APIのエンドポイントは/apiで始めるようにする
/
がwikiページ、 /docs
がドキュメントページ、 /css
や /scripts
などのがあり、それに紛れてAPIのエンドポイントが定義されていました。これだと分かりづらくAPIを分けるため、api.~というサブドメインも検討したのですが、 /api
のほうが楽かつ、ISUCON 実装と同じ構成になるという事で採用しました。
/releases
のAPI仕様書の一部抜粋です。
paths:
/releases:
get:
tags:
- Releases
security:
- bearerAuth: []
summary: 最新順リリース一覧取得API
description: 最新順リリース一覧取得API #ToDo: Orderをつけるか?
parameters:
- in: query
name: per_page
description: The number of results per page (max 999).
schema:
type: integer
example: 30
default: 100
maximum: 999
- in: query
name: page
description: Page number of the results to fetch (max 99).
schema:
type: integer
example: 1
default: 0
maximum: 99
- in: query
name: from_date
description: 指定期間開始(YYYY-MM-DD、指定なしは本日の日付から1week前)
schema:
type: string
example: '2021-04-01'
default: '2022-08-01'
- in: query
name: to_date
description: 指定期間終了(YYYY-MM-DD、指定なしは本日の日付)
schema:
type: string
example: '2021-04-02'
default: '2022-08-08'
responses:
200:
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/press_release'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: |
API を利用するにはBearer認証が必要です。
schemas:
press_release:
title: 'プレスリリース情報'
type: object
properties:
company_name:
type: string
description: 企業名
example: '株式会社PR TIMES'
x-oapi-codegen-extra-tags:
db: company_name
company_id:
type: integer
description: 企業ID
example: 1
x-go-name: CompanyID
x-oapi-codegen-extra-tags:
db: company_id
release_id:
type: integer
description: リリースID
example: 1
x-go-name: ReleaseID
x-oapi-codegen-extra-tags:
db: release_id
title:
type: string
description: タイトル
example: '[April Dream] 4月1日を夢の日に。1100社超が「夢」の発信に参加表明'
x-oapi-codegen-extra-tags:
db: title
required:
- company_name
- company_id
- release_id
- title
環境構築
API仕様書が定義できればいざ開発です。まずは環境を作ります。今回はISUCONの過去問などを参考にGoのフレームワークであるEchoを採用しました。また、万が一実装できなかった時のためにDocker ComposeでbuildするファイルをNode.jsとGoで分けられる実装にしました。
api:
# Go実装の場合は golang/ node実装の場合は node/
build: golang/
# Go実装の場合は./golang:/app/go/base, node実装の場合は./node/src/.:/src
volumes:
# - ./node/src/.:/src
- ./golang:/app/go/base
- ./public:/app/go/public
(略)
構成はこのようになっています。
├── docker-compose.yml
├── golang
│ ├── Dockerfile
│ ├── README.md
│ ├── go.mod
│ ├── go.sum
│ ├── main.go (基本的な処理を書く)
│ └── openapi
│ ├── handlers.go (Interface, routerなどが自動生成される)
│ └── openapi.go (structが自動生成される)
└── public
├── spec
└── hackathon-api.yaml (API仕様書を定義する)
Airの導入
Goは基本的に修正したら都度buildするのが一般的かと思います。ただ、ホットリロードに慣れているためホットリロードツールを探していました。そこで見つけたのがAirというライブラリです。dockerでコンテナを立ち上げて、エディターで修正、保存すると修正が反映され、都度buildする必要がなくなります。以下のようにDockerfileでAir のコマンドを実行しています。
(略)
RUN go install github.com/cosmtrek/air@latest
COPY . .
CMD ["air", "-c", "./.air.toml"]
codegenの導入
openapi-generatorとoapi-codegenで実際に生成してみて、oapi-codegenを採用しました。openapi-generator ではなく oapi-codegen を採用した大きな理由は、typesだけ・serverだけなど必要なものだけをコード生成出来たこと、またx-go-name という拡張記法を用いて Go の慣習慣例に合った命名が出来たためです。
API仕様書を修正してコマンドを走らせるだけでInterfaceやstructなどが自動生成されるため、比較的少ない実装で作りたいものが完成するので便利でした。
また、実装フェーズに入ってしまうとAPI仕様書の修正が置き去りになり、APIの実装とAPI仕様書に差分ができてしまうことがあるかと思います。codegenを使うことにより、APIの実装とAPI仕様書の同期が容易になり、差分が発生しにくくなることは1つのメリットと感じました。
上記/releases
のAPI仕様書でコマンドを実行し、自動生成されたコードの一部抜粋です。
API仕様書からリクエスト、レスポンスの型を作成するため以下のコマンドを叩きます。
oapi-codegen -generate "types" --old-config-style -package "openapi" ../public/spec/hackathon-api.yaml > openapi/openapi.go
するとこのようなコードが生成されます。(openapi.go)
// Package openapi provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/deepmap/oapi-codegen version v1.11.0 DO NOT EDIT.
package openapi
const (
BearerAuthScopes = "bearerAuth.Scopes"
)
// PressRelease defines model for press_release.
type PressRelease struct {
// 企業ID
CompanyID int `db:"company_id" json:"company_id"`
// 企業名
CompanyName string `db:"company_name" json:"company_name"`
// リリースID
ReleaseID int `db:"release_id" json:"release_id"`
// タイトル
Title string `db:"title" json:"title"`
}
// GetReleasesParams defines parameters for GetReleases.
type GetReleasesParams struct {
// The number of results per page (max 999).
PerPage *int `form:"per_page,omitempty" json:"per_page,omitempty"`
// Page number of the results to fetch (max 99).
Page *int `form:"page,omitempty" json:"page,omitempty"`
// 指定期間開始(YYYY-MM-DD、指定なしは本日の日付から1week前)
FromDate *string `form:"from_date,omitempty" json:"from_date,omitempty"`
// 指定期間終了(YYYY-MM-DD、指定なしは本日の日付)
ToDate *string `form:"to_date,omitempty" json:"to_date,omitempty"`
}
API仕様書からInterface、routerを作成するために以下コマンドを叩きます。
oapi-codegen -generate "server" --old-config-style -package "openapi" ../public/spec/hackathon-api.yaml > openapi/handlers.go
するとこのようなコードが生成されます。(handlers.go)
// Package openapi provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/deepmap/oapi-codegen version v1.11.0 DO NOT EDIT.
package openapi
import (
"fmt"
"net/http"
"github.com/deepmap/oapi-codegen/pkg/runtime"
"github.com/labstack/echo/v4"
)
// ServerInterface represents all server handlers.
type ServerInterface interface {
// 最新順リリース一覧取得API
// (GET /releases)
GetReleases(ctx echo.Context, params GetReleasesParams) error
}
// ServerInterfaceWrapper converts echo contexts to parameters.
type ServerInterfaceWrapper struct {
Handler ServerInterface
}
// GetReleases converts echo context to params.
func (w *ServerInterfaceWrapper) GetReleases(ctx echo.Context) error {
var err error
ctx.Set(BearerAuthScopes, []string{""})
// Parameter object where we will unmarshal all parameters from the context
var params GetReleasesParams
// ------------- Optional query parameter "per_page" -------------
err = runtime.BindQueryParameter("form", true, false, "per_page", ctx.QueryParams(), ¶ms.PerPage)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter per_page: %s", err))
}
// ------------- Optional query parameter "page" -------------
err = runtime.BindQueryParameter("form", true, false, "page", ctx.QueryParams(), ¶ms.Page)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter page: %s", err))
}
// ------------- Optional query parameter "from_date" -------------
err = runtime.BindQueryParameter("form", true, false, "from_date", ctx.QueryParams(), ¶ms.FromDate)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter from_date: %s", err))
}
// ------------- Optional query parameter "to_date" -------------
err = runtime.BindQueryParameter("form", true, false, "to_date", ctx.QueryParams(), ¶ms.ToDate)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter to_date: %s", err))
}
// Invoke the callback with all the unmarshalled arguments
err = w.Handler.GetReleases(ctx, params)
return err
}
// This is a simple interface which specifies echo.Route addition functions which
// are present on both echo.Echo and echo.Group, since we want to allow using
// either of them for path registration
type EchoRouter interface {
CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
}
// RegisterHandlers adds each server route to the EchoRouter.
func RegisterHandlers(router EchoRouter, si ServerInterface) {
RegisterHandlersWithBaseURL(router, si, "")
}
// Registers handlers, and prepends BaseURL to the paths, so that the paths
// can be served under a prefix.
func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) {
wrapper := ServerInterfaceWrapper{
Handler: si,
}
router.GET(baseURL+"/releases", wrapper.GetReleases)
}
呼び出し側(main.go)
ここではopenapi.goに作成されたstructを使い、handler.goに作成されたInterfaceに沿って実装していきます。
func (Server) GetReleases(ctx echo.Context, params openapi.GetReleasesParams) error {
txn := nrecho.FromContext(ctx)
defer txn.End()
perPage := 100
if params.PerPage != nil {
perPage = *params.PerPage
txn.AddAttribute("per_page", perPage)
}
page := 0
if params.Page != nil {
page = *params.Page
txn.AddAttribute("page", page)
}
if perPage >= 1000 {
return ctx.JSON(http.StatusBadRequest, `{"message": "validation error (out of the limit for par_page. limit is 999)"}`)
}
if page >= 100 {
return ctx.JSON(http.StatusBadRequest, `{"message": "validation error (out of the limit for page. limit is 99)"}`)
}
now := time.Now()
toDate := now
if params.ToDate != nil {
parsedTime, err := stringToTime(ctx, *params.ToDate)
if err != nil {
return ctx.JSON(http.StatusBadRequest, `{"message": "bad request (invalid type for to_date. valid type is YYYY-mm-dd)"}`)
}
toDate = parsedTime
txn.AddAttribute("to_date", toDate)
}
// toDateの日付から1週間前
fromDate := toDate.AddDate(0, 0, -7)
if params.FromDate != nil {
parsedTime, err := stringToTime(ctx, *params.FromDate)
if err != nil {
return ctx.JSON(http.StatusBadRequest, `{"message": "bad request (invalid type for from_date. valid type is YYYY-mm-dd)"}`)
}
fromDate = parsedTime
txn.AddAttribute("from_date", fromDate)
}
var results []openapi.PressRelease
// SQLは適当です
query := `
SELECT
company_id,
company_name,
release_id,
title
FROM
release
WHERE
release_date BETWEEN $1 AND $2
ORDER BY
release_date DESC
LIMIT $3 OFFSET $4
`
err := db.Select(&results, query, fromDate, toDate, perPage, page)
if err != nil {
ctx.Logger().Errorf("db error: %v", err)
return ctx.NoContent(http.StatusInternalServerError)
}
if len(results) == 0 {
return ctx.JSON(http.StatusOK, []openapi.PressRelease{})
}
return ctx.JSON(http.StatusOK, results)
}
APIの実装
ここでは便利だったものをご紹介します。
pq.StringArray
DBはPostgreSQLを使っているため、pqパッケージを使いました。
あるエンドポイントで、配列にして返したいプロパティがあり、SQLで以下のようにarray_aggを使用して配列で取得し、Goのスライスにバインドしようとしたのですが、エラーが出ました。
SELECT array_agg(hoge) AS keywords FROM ~~
db error: sql: Scan error on column index 12, name \"keywords\": unsupported Scan, storing driver.Value type []uint8 into type *[]string
そこで、pg.StringArray
を使い解決しました。
API仕様書を以下のように修正すると、
keywords:
description: キーワード
type: array
x-go-type: pq.StringArray
x-go-type-import:
path: github.com/lib/pq
x-oapi-codegen-extra-tags:
db: keywords
struct 側は以下のようになるので適切にデータを扱えるようになります。
Keywords pq.StringArray `db:"keywords" json:"keywords"`
ただ、このエンドポイントはパフォーマンスがよくなかったため、最終的には別エンドポイントに分け、pg.StringArray
は使いませんでした。
NewRelicの導入
PR TIMESでは普段からNewRelicを使い監視を行なっています。nrechoを導入し、NewRelicで監視できるようにしました。これによって、どのエンドポイントが遅いかや、学生がどのエンドポイントをよく使うのかなどがわかるようになります。以下のNewRelicさんのブログで紹介されている createDataStoreSegment
関数を使い、呼び出し側でこのようにしています。
ISUCON 環境に New Relic APM Agent を入れてみる (Go 編)
txn := nrecho.FromContext(ctx)
nrtrace := createDataStoreSegment(query, "release", "SELECT", fromDate, toDate, perPage, page)
合わせて、以下の記事も参考にしました。
ISUCON10予選問題にNew Relic Go APM Agentを入れてみる
ハッカソン当日は、プレスリリース分析データ(GET /api/companies/:company_id/releases/:release_id/statistics)へのリクエストが一番多く、その他release系のエンドポイントがよく叩かれていること、また企業関連の情報へのリクエストは少ないことが分かりました。

ベンチマーカー
実際にリクエストを飛ばして、実行時間を見るためのベンチマーカーをpytestを用いて簡単に作成してみました。pytestはオプションで—-durations=0
を指定する事によって、全てのテストの実行時間を計測出来ます。それぞれのテストで1度だけAPIへリクエストすることで、1リクエストに掛かった時間を計測することが可能になります。
例として、/releases のエンドポイントではこのように実装しました。リクエストを飛ばして、その結果をassertしています。
def test_get_releases():
url = f'{config.host}{config.basePath}/releases'
headers = {'Authorization': f'Bearer {config.token}'}
res = requests.get(url, headers=headers)
assert res.status_code == 200, res.content
ベンチマーカーを実行すると以下のような結果が出ました。
================================================================================== slowest durations ===================================================================================
40.97s call test_api.py::test_time_releases_from_date
17.28s call test_api.py::test_get_releases
16.26s call test_api.py::test_time_releases_page
0.29s call test_api.py::test_get_companies
(略)
めちゃくちゃ遅いエンドポイントがあることがわかりますね、改善していきます。
動かしてみて詰まったところ
レスポンスタイムが長すぎる
上記のベンチマーカーでも分かる通りめちゃくちゃ遅いエンドポイントがあります。これらは先ほど記載したpg.StringArray
の箇所です。配列を取得するためのNESTED LOOPが遅かった原因でした。 この問題は配列を取得すること自体を諦めて、別のエンドポイントとして分けることで解消しました。
ベンチマーカーを実行してみても問題なさそうです!
================================================================================== slowest durations ===================================================================================
0.40s call test_api.py::test_time_releases_from_date
0.10s call test_api.py::test_get_categories
0.09s call test_api.py::test_get_companies
(略)
CORS
API の動作確認を兼ねた社内ハッカソンをしている時にCORS のエラーが出ることが確認されました(事前の動作確認は大事です)。ハッカソンのAPIはBearer認証をしていて、CORSを通すよりも前にBearer認証をしていたためCORSのエラーが出ていました。そのため、CORSを先に通すことで解決しました。
func main() {
e := echo.New()
api := e.Group("/api")
api.Use(middleware.CORS()) // CORSを通すのが先
api.Use(bearerAuthMiddleware) // Bearer認証が後
}
まとめ
まともにGoを書いたのは初めてでしたが、Airやoapi-codegenを導入することで開発しやすかったです。
また、本エントリーは私が代表して書いていますが、バックエンドエンジニアの江間さんと2人で開発を行い、CTOの金子さんにレビュー依頼や質問をしながら進めていきました。
これからも改善を続けていきます🚀