2018年でGolangでWebAPI開発して感じた反省点


はじめに

2018年はいくつかのPJでGolangを使ったバックエンドのWebAPIを開発しました。どれもメンバーの確保・開発期間がかなりシビアだったため色々と落とし所を設けつつ前に進めたのですが、今になってもその中でもこうしておけばよかったというものがいくつか出てきましたので、供養とともにクリスマス・イブイブを祝いたいと思います。


  • どれもチームメンバーとしては3~6名程度の規模で、期間は2ヶ月~半年程度


    • Golangやった事ある人は比較的少なく、大体がSpringBoot(Java)経験者でした



  • プロダクトとしてPoCで終わったものもあれば、システムカットオーバーで運用フェーズに入っているものもあります

  • わたしの立場はテックリードや開発リーダーといった具合で、構成管理・開発フロー・採用ライブラリなど技術的な判断を行えるポジションにいました。


tl;dr


  • みんながハマるようなところを同じように踏んだ話


やったこと


  1. CMS(Content Management System)のバックエンド開発


    • テキスト、メディアデータ(画像やら動画やら)、オフィス系データなど様々なコンテンツを永続化するバックエンドAPI

    • バージョン管理・アクセス認可・全文検索・メタデータ検索など様々

    • 内部的にはElasticsearch, DataStore on GCPを利用



  2. IoTシステムのバックエンド開発


    • センサーデータへのアクセスのための認証認可、位置情報の計算

    • 画像データの解析(AWS Rekognitionを利用)サービス



Golangで作ったAPIは、大体がBFF(Node.js)など別のバックエンドサーバやAWS Lambdaから叩かれることが多く、直接フロントエンドの要件に影響を受けないように一段サーバをかましていました。


開発においての反省


パッケージ構成

POSTDのGoでクリーンアーキテクチャを試す の記事に大きく影響を受けた構成にしました。この特徴としてまずあげられるのが技術レイヤではなく、業務ドメインをトップレベルにパッケージを切っていることだと思います。

つまり、こういうことではなく...


よくありそうなパッケージ構成

(サービス名)

.controller
.user
.user_controller.go
.content
.content_controller.go
.usercase
.user
.user_usecase.go
.gcp_user_usecase.go
.content
.content_user_usecase.go
.gcp_user_usecase.go
.model
.user
.user.go
.content
.content.go
.repositrory
.user
.user_repository.go
.gcp_user_repository.go
.content
.content_repository.go
.gcp_content_repository.go

こういう感じで切りました。


ドメインを最上位にしたパッケージ構成

(サービス名)

.user
.repository
.user_repository.go
.gcp_user_repository.go
.controller
.user_controller.go
.usecase
.user_usecase.go
.gcp_user_usecase.go
.user.go
.content
.repository
.content_repository.go
.gcp_content_repository.go
.controller
.content_controller.go
.usecase
.content_usecase.go
.gcp_content_usecase.go
.content.go

これはこれで新規参画者からは分かりやすくて良いと絶賛はされたのですが、色々と問題がでました。

一つはパッケージのimportです。あるControllerから例えばuserとcontentという2つのrepositoryを利用しようとすると、パッケージ名が重複するため、importでエイリアスする必要があります。

import (

userrepo "(サービス名).user.repository" // エイリアスが必要
contentrepo "(サービス名).content.repository " // エイリアスが必要
)

func (r *CommentUsecase) Post(userID, contentID, comment string) error {
ur := userrepo.NewGcpUserRepository()
ur := contentrepo.NewGcpContentRepository()
// 略
}

各開発者にパッケージをエイリアスをつけさせる運用だと、人によって命名がズレやすく統制を取るのが大変でした。そのため、いっそパッケージ名を userrepostiroy, contentrepository といった具合に変更しました。そうするとimportでのエイリアス付けが不要になりました。

つまり、以下のような構成です。


ドメインを最上位にしたパッケージ構成パート2

(サービス名)

.user
.userrepository
.user_repository.go
.gcp_user_repository.go
.usercontroller
.user_controller.go
.userusecase
.user_usecase.go
.gcp_user_usecase.go
.user.go
.content
.contentrepository
.content_repository.go
.gcp_content_repository.go
.contentcontroller
.content_controller.go
.contentusecase
.content_usecase.go
.gcp_content_usecase.go
.content.go

これでimport時にエイリアスが不要になりました。

めでたしと言いたいですが、なんだかパッケージ名が長くて気持ち悪い..。

もはや、ドメイン単位にパッケージを切っているのであれば、controller, usercase, repository のパッケージが不要ではないか?という気持ちになってきます。

また、もう一つこういった特殊なパッケージ構成にすると困るのが自動生成系との接続の悪さです。

例えば、Swaggerファイルからgo-swaggerなどでファイルを自動生成しようとすると、こういったパッケージ構成とかなりギャップがあります。最終的には自動生成周りを作り込むべきだったと思いますが、今回はそういった時間が取れず、Swaggerファイルを作成したもののそこから自動生成による利益はあまり享受できませんでした。

結論的には controller, repository などをトップレベルのパッケージに持ってくるのが1番良いのでは無いかと今では思っています。


Structファイルの作成粒度

今回は、1Struct=1ファイルといった規約で開発していました。これにより、パッケージに寄ってはかなりファイル数が多くなり見通しが悪くなりました。一部のファイルに関しては1ファイルに複数structを定義させても良かったのでは?と思っています。

1Struct=1ファイルにした理由は以下2点です


  • GitのConflictを極力無くしたかった


    • GitHubでの開発が初めての人がいて、参画当初にConflictの解決に2日かかっていた..

    • リモートでみんなが非同期的に開発していたため、多少の見通しの悪さより、お互いの共有ポイントを減らし、スループットを上げたかった



  • Structにドメインロジックの関数を生やす思想だった


    • DDDというわけでもないですが、ドメインロジックをStructにどんどん追加し、そこでUnitTestを回し品質を高めようという狙いがありました。そのため、1Struct=1ファイルに始めからしておくと、メソッドを生やしやすく都合が良いと考えました



現時点の結論は、ロジックが増えてきたら別ファイルに切り出すくらいで良かったのでは?と考えています。


context.Contextの引き回し

Repository階層まで、 context.Context を引き回していなかったです。

今回、わたしが担当した開発ではRDBで重い検索タスクを実行すると言ったことが無かったため、キャンセルは不要では?と考えたためです。

しかし、途中でZipkinの分散トレースIDを引き回したいという要件がでてき、さすがに個別に引き回すのが辛いということで、context.Contextを全Repositoryの引数に追加し、その中にトレースIDを追加することにしました。


RepositoryのError

当初、RepositoryからUsecaseに戻すerrが、infrastructure層バリバリ漏れてしまってました。

つまり、以下のような状態です。

err = h.contentRepo.SaveContent(newContent)

if err == datastore.ErrConcurrentTransaction {
// ...略...
}

もちろんこの状態だとrepositoryをインターフェースで抽象化した意味が片手落ちです。

errも何かしら独自形式で作成してWrapが必要でしょう。

今回は以下のような形式で独自errorを定義しました。


error.go

var (

NotFoundError = errors.New("not found")
ConflictError = errors.New("unique_violation")
// ..略
)

func IsNotFoundError(err error) bool {
return err == NotFoundError || errors.Cause(err) == NotFoundError
}

func IsConflictError(err error) bool {
return err == ConflictError || errors.Cause(err) == ConflictError
}


Repository側は異常時にこの定義したerrをそのままreturnするか、errors.Wrap を利用してさらにカスタムメッセージを詰めるといった使い方をしてもらってました。

反省としては、特に errors.Wrap を利用した場合のエラーハンドリングは、 errors.go で定義した、IsNotFountError関数 などを利用しないと正しく判定できないことです。

このあたりの独自ルールを増やすのが良かったどうかは判断が分かれると思います。


マイクロ過ぎたサービス分割

今回のリポジトリ構成はモノリポで、一つのGitリポジトリに複数の開発PJがぶら下がっていました。

これはとても成功していましたが、逆にPJがホイホイ作られるという悪運用がされました。

ローカル開発そのものはdocker-composeで一発で起動できるものの、そのコンテナ数が10以上存在してしまい、低スペックのPCでは起動時にかなりストレスが発生しました。

そもそも開発メンバーがそこまで多くないため、よりモノリス志向な一枚岩な構成で良かったのでは無いかと考えています。


コーディング規約

最初から容易はできず、GitHubのPullRequestで指摘した中で、共通的な事項をIssueにまとめていきました。

これは非常に良い活動だったのですが、当然最初からある程度用意できるとかなり生産性が異なっていたと思います。特にフロントエンド開発メンバーが、バックエンド開発を行う際に、どのような点に注意をすればよいかよく質問を受けました。このときに、最初からコーディング規約的なものを出せて入れば、、と反省しています。


まとめ

GolangでWebAPIを開発したよ。

先人のエンジニアがみんなパッケージとかエラーハンドリングとかに悩んだって言っていたけど、同じように悩んだよ。