ハッカソンのAPIをGoへフルスクラッチしました

こんにちは、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の活用についての記事を書いてくれています。

あわせて読みたい
企業ページリプレイス ~OpenAPIの活用~
企業ページリプレイス ~OpenAPIの活用~こんにちは、21新卒エンジニアの柳です。先日、PR TIMESの企業ページをSmartyというテンプレートエンジンからReactへリプレイスを行いました。その際に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-generatoroapi-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の金子さんにレビュー依頼や質問をしながら進めていきました。

これからも改善を続けていきます🚀

この記事を書いた人

2022新卒で PR TIMES に入社し、開発本部でバックエンドエンドエンジニアをしています。

目次
閉じる