概要
元々、各プロダクトでマスターデータをJSON、DB、コードなどで管理していました。
PaymentMDM (Master Data Management)というプロダクトをGo言語で実装して、決済システムの共通マスタ情報の取得と運用をRestful APIを目指しています。
開発しやすく、拡張しやすく、低コストでメンテできるプロダクトを作りたいと思いました。まあ、それは普通ですよね! 我々の簡単とは、後で定義します。
最初から完璧にすることを目指していませんですた。気がつく問題や開発の非効率を改善するように、継続的にベストプラクティス、またはライブラリを導入しました。そして、コードのリファクタリングを行いまいした。なんのプロダクトでも適切であるテックスタックなんて存在していないです。おそらく、テックスタックがまた変わっていくと思いますので、現状のテックスタックです。
しかし、Go言語開発の他のプロダクトのニーズに一部のテックスタックが適切であることの確認ができるのではないかと思いました。例えば、PaymentMDMのテックスタックがすでに、別のプロジェクトで完全に再利用され、PaymentMDMがベースになりました。そのプロジェクトから、デプロイをスピードアップする設定を頂きました。
簡単とは、なんの意味?
開発しやすい
PaymentMDMチームとして下記の意味にしています。
- できるだけコンパクトなコード (バグや修正の量は少なくなるため)
- 繰り返す退屈な作業を避ける。例えば、ymlとか、APIのstructの定義とか
- ローカルで簡単に起動でき、全環境に簡単にデプロイできること
- レイアーを本格的に分ける
- 新しいライブラリやテックを覚えなくても良いという意味で簡単だと目指していません。
拡張しやすい
- APIを追加したり、変更したりすることが速くできること
- いつか簡単にgRPCにスイッチできること
- document as codeの方針により、アプリが変更されても、APIの仕様書を書く必要がないこと
- テックスタックを速く変更できること
低コストの運用
- バグはできるだけ少なく、しっかり監視できることは目標でしたが、様子をみていきます。低コストの運用
- まだ運用していないので、これはできたかたどうか、まだ判断できません。
- バグはできるだけ少なく、しっかり監視できることは目標でしたが、様子をみていきます。
6ヶ月PaymentMDMを運用して、アプリの不具合が発生したことがないです。
PaymentMDMのテックスタック
PaymentMDMの開発のテックスタック図は以下になります。 わかりやすくしようと思い、出力と入力を赤く追加しました。
フォルダーの構造はシンプルです。重要なフォルダは以下になります。結構標準的な Go言語のproject-layoutをベースにしました。
PaymentMDMのテックスタック (概要)
スクラムに準拠していますので、レトロスペクティヴ(振り返り)を行っていましたが、メンバー個々人が、そのスプリントで起きたことを他のメンバーに伝えるだけになっていました。
個人的な問題や話題に終始するので、共通の話題はほぼ出てこず、問題が問題と認識されないことが発生していました。
出てきた内容についても議論はされず、端的に言うと「次は頑張ります」で終わってしまいます。
何らかのTRYが出てきても、ほぼ個人に紐付くものばかりです。
共通の話題がないので個人の反省を聞くだけになってしまい、メンバーの結束が生まれず、チームとして機能しなくなってきます。
開発の基本流れ
GoaのDSL(Go言語)でAPIを定義し、「goa gen」だけでRest APIのランタイムコードを生成します。
リクエストとレスポンスの構造体とバリデーションを生成し、PaymentMDMの関数を呼び出すための構造体とコードも生成します。
次に、PaymentMDMのコントローラーに、Goa構造体を使用するサービスの関数を実装します。そして、サービスとリポジトリ(DAO)を実装します。
コントローラー、サービスとリポジトリは、ほとんどただのGo言語のコードになります。データアクセスにはGormを使用し、zapというローガーを使用しました。
静的コード分析ツールとPRでコードを確認して、CDでデプロイをしています。
直後に色々を説明しますが、PaymentMDMの環境(Fargate)は本書では説明しません。
Goa
Goaとは
GoaはAPIの実装とAPIの提供に対して以下をやっています。
構造体、エラー含めてAPIを定義するためのDSLを提供すること
上記のDSLの定義からHTTPリクエストの構造体やデコードを生成するツールを提供すること。イメージとして、SpringのControllerのアノテーションとほぼ同じ機能を提供する。また、コントローラーとOpenAPI3仕様書とのアプリの構造体も生成する
HTTP Router(1.1 / 2) とgRPC Routerを提供すること
テスト用のCLIを生成する。(普段はCLIよりcurlを使用しています)
例
例がないとよくわからないと思いますので、design/design.goのAPI定義の例をみてみましょう。
そして、レスポンスの構造体をとても簡単にdesign/design_type.goに定義します。項目ごとに、サンプル例、バリデーションを定義します。同じStructをgRPCで使うのは可能です。ファイルでdesignを分けることは問題ないです。
下記のMakefileのコマンドだけで、Goaのコードを生成します。 それだけで、Goaのdependenciesをgo getで取得し、goa genでコードを生成します。
$ make gen
それだけで開発できます! HTTPプロトコルのボディやリクエストパラメーターに依存しないコントローラー用の構造体を生成し、design.goに定義した名前を元に、PaymentMDMのパッケージの関数を呼び出します。サービスを実装して、コントローラーでサービスを呼び出し、エラーレスポンスを処理します。 下記、Goaが生成したMakeNotImplemented関数を呼び出します。Goaのエラー関数を呼び出すと、エラーレスポンスを返します。下記のエラーレスポンスの処理の説明を参照してください。
また、生成されたOpenAPI3仕様書をRedocで提供することもできます。
それ以外の開発
HTTPサービスを提供するために、Goaを設定して、起動する必要があります。このリンクに、main.goとhttp.goの定義の参照ができます。
ただ, コードは難しそうですが、簡単にサンプルコードを生成して、コードをベースにしただけです。
goa example cmd/internal/pmdm
修正点は少なかったです。localhost以外listeningにするように修正しました。そして、zapを使い、エラーレスポンスを見直したので、設定コードを少しやりました。データベースの初期化の呼び出しも追加しました。エラーレスポンスの仕様変更だけ手間がかかりました。
バリデーションの開発がいらいないのはすごく気に入りました。dateをdesignに定義しただけで、下記のエラーが出ます。
{"Type":"https://xxx.com/pages/payment-master/payment-mdm/docs/","Title":"There request is not correct.","Detail":"body.operation_end_date must be formatted as a date-time but got value \"2022-07-21T37:32:28+09:00\", parsing time \"2022-07-21T37:32:28+09:00\": hour out of range"}
HTTP router
httptreemuxはGoaのデフォルトのHTTPルーターです。今までのところ、PaymentMDMでは、httptreemuxが適切で、問題はありません。
しかし、Gorilla/Mux、GIN Gonicほど活用(コミット、スター)とパフォーマンスのレベルまで行かないです。
Goa設定は標準的なremuxになりますので、原則として、他のHTTPルーターの使用ができるはずです。現状問題がないので、他のHTTPルーターに変更することを試していません。
他の機能
GoaでHTTPS、gzipの機能はあります。ただ、アプリのサーバーのCPUより、無料でやってくれるCloudFrontにしました。CloudFrontのコストはリクエスト数とデータ量をベースにしているため。
IMO
YMLを書かずに、簡単にOpenAPI 2の仕様書の生成ができるだけですごいメリットがあると思いました。
Goa以外はopenapi-generator、grpc-gatewayも使いましたが、品質やと拡張性が結構悪くて、YMLを修正したり、生成されたコードを変更したりする必要がありました。
Goaでは、生成されたコードが完全に分離され、重要な開発に集中できました。
コードはgen /フォルダーに生成され、.gitignoreできます。
Goaをやめようと考えるようになれば、コントローラーをGINに切り替えることは簡単そうです。
APIがすごく拡張しやすいですが、エラーレスポンスの開発で、Goa自体拡張しにくいかなと思いました。
DSLの書き方に慣れる必要がありますが、DSLでコーディングするのは、SpringのSpringfoxやYMLより大分マシになりました。
また、私の古いMacでも、サーバーの起動は約1秒でできるのは本当に気に入っています。openapi-generatorと比べて、高速です。
おそらく、パフォーマンスはGINほどよくないですが、負荷テストを行ってから、考えましょう。
結局、これまでのところ非常に満足しています。
Gorm
開発がすぐでき、stdlibであるので、最初はdatabase/sqlでリポジトリを作成しました。型変換含め、コードの拡張性と可読性はPaymentMDMとしてよくはなかったです。 簡単なSQLだと、Gormはdatabase/sqlと同じSQLを同じDBにリクエストするので、パフォーマンス的に少しオーバーヘッドあるかと思います。Benchmarkを見ると、違いありますけど、困りなさそうです。まもなく負荷テストを行います。変更前と変更後の負荷テストを行いたいのですが。
コードは大分よくなった
具体的に、下記通り劇的に違います。
正直に他には, エンティティにアノテーションを追記する必要があります。snake形式のstructだとで不要のはずです。
エラーの処理 (とりあえずPanicという方針)
実はインターンシップ中の方がGormを移動した後、問題について気がつきました。 GORMでは、いくつのエラーにたして、パニックが発生します。Segmentation Faultだけではないです。日付のフォーマットのエラーでも発生します。
Let's Panic!
(私ではないですね。Unsplashのコピライトフリーの画像です。 )
したがって、エラーも返されません。Panicしたら、今までのGoaの処理によって、パニックで成功のレスポンスを返してしまっていました。
だから、catchみたいなコードを追加しました。そしてログ出力とエラーの処理。トランザクションの処理含めて、下記のようになります。なんか、Javaコードっぽくなりました!
defer func() {
if r := recover(); r != nil {
tx.Rollback()
logger.Error("Panic on db request requiring rollback", zap.Any("error", r))
err = errors.New("Db panic")
}
}()
トランザクションの処理、エラーの処理により、下記の共通化を実装しました。
type repositoryFunc func(ctx context.Context, dtoByInterface interface{}, tx *gorm.DB) error
//ManageTransaction Manage transaction for a DTO passed by interface and a repository function (See repositoryFunc)
func ManageTransaction(ctx context.Context, dtoByInterface interface{}, repositoryFuncs ...repositoryFunc) (err error) {
tx := config.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
err = errors.New("Db panic")
log.Error(ctx, "Panic on db request requiring rollback", zap.Any("error", r), zap.Error(err))
}
}()
if err := tx.Error; err != nil {
return err
}
for _, repositoryFunc := range repositoryFuncs {
appErr := repositoryFunc(ctx, dtoByInterface, tx)
if appErr != nil {
if err := tx.Rollback().Error; err != nil {
rollbackErr := common.AppError{Msg: common.MsgPreparedStatement, Cat: common.CatInternal, Err: appErr.(*common.AppError).Err}
log.Error(ctx, rollbackErr.Msg, zap.String("category", rollbackErr.Cat), zap.Error(err))
return &rollbackErr
}
return appErr
}
}
err = tx.Commit().Error
if err != nil {
log.Error(ctx, "error", zap.Error(err))
return err
}
return nil
}
同じトランザクションで、複数の関数を呼び出すことは、下記通りに簡単になりました。
repository.ManageTransaction(ctx, clientType, repository.DeleteClientType)
他の作業
データベースの接続設定は簡単です。
Gormのログ出力はzapで少し面倒臭かったです。すでGoaのzapロガーのラッパありましたが、Gormの方結構コードになります。 (see GormLogger)
IMO+メトリック
database/sqlの600行から275行ぐらい無くしました。半分ぐらいですけど、可読性は2倍以上よくなった感じです。Zapでログ出力できるように100行ぐらい追記しましたが、APIを追加してもそのコードはほぼ安定するはずです。結局Gormでリポジトリの拡張性が高くなったと思います。
Error response management
色々試しました。最初はカスタムなフォーマットのJSONで実装しましたがGoaへ移動した際に、RFC 7807にしたがってきました。
既存のPFが提供しているAPIのレスポンス形式に従う必要がないため、スタンダードを使った方が良さそうで、あのスタンダードは気に入りました。 HTTP status codeも、HTTPレスポンスを定義しています。
こんな感じです。
{
"type": "https://example.net/validation-error",
"title": "Your request parameters didn't validate.",
"invalid-params": [ {
"name": "age",
"reason": "must be a positive integer"
},
{
"name": "color",
"reason": "must be 'green', 'red' or 'blue'"}
]
}
エラー(type)ごとに、エラーページを作成するのは少し手間がかかりますので、とりあえず適当にOpen API3の仕様書をtypeに定義しました。
Goaで上記の仕様を実現するには、少し作業ありました。Goaはすでにエラーの固定仕様ありますので。
こんな感じでHTTPエラーごとに、InternalServerErrorなどを定義しました。楽勝!
そして、 http.goにエラーの処理を追記しました。
最後に、あいにく拡張性のないGoa muxの一部をコピー&ペーストしました。そうしないと、Controllerまで行かない404エラーは処理されません。mux.goになりました。
GoaがmakeInternalServiceErrorみたいなエラーの関数がdesignから生成ていますが、ちゃんと適切なエラーレスポンスになります。
IMO
良いスタンダードに従うのはいいなと思いました。フォルダーの構造もスタンダードがあります。ログから、レスポンスまで、JSON形式です。日付でもスタンダードを決めたいです。
ログの管理
Zapになった理由
Zapは簡単にDatadogで監視できるJSONを出力でき、stdlibより、比較ならないほど速いです。Zapを選んだことに関して興味がありましたら、検討:テクノロジースタックに詳細があります。
ロガーは簡単に設定できます。
書き方ちょっと微妙ですが、速く出力できるのでトレードオフになります。Structのintrospectionを行わないように、明示的に形式を定義します。嫌だったら、structに対してAnyあります。
GoaとGormにZapを設定したので、アプリケーションを全体的にZapで出力できています。下記はサンプルになります。
{"level":"info","ts":1599816806.8011541,"caller":"pmdm/logger.go:119","msg":"Logger init succeeded"}
{"level":"info","ts":1599816806.82823,"caller":"cmd/http.go:74","msg":"HTTP \"UpdateClientTypeController\" mounted on PUT /payment-mdm/v1/client-types/{client_type}"}
他の作業
ライブラリごとに、ログの設定方法が異なるようです。設定は結構面倒臭いです。
Goa用とGorm用のラッパとGormの設定みたいなコードになります。それは、Zap以外でも起こりえます。
IMO
Zapは良いと思いましたが、他のロガーもそうかもしれません。Stdlibはあまりおすすめできないですが、いつでも移動できるので、大したことではないかもしれません。
品質とセキュリティ
頑張るのは良いですが、できることに限られずに、分析ツールを導入して、CIと開発に提供しました。
ライブラリの脆弱性確認
nancyを使っています。Sonatype OSS Indexを元にしています。実際に問題の検知を行いました。実行は数秒間で終わるので、とても速いです。
go modはNancyの前提になりますので、さらに使ってよかったです。ちなみに、Javaで使うgradleより、だいぶ定義しやすいです。
Nancy以外色々ありますが、有料です。
$ make lib-check
Non Vulnerable Packages
Nancyで実際に問題を確認しました。毎回感動します! 脆弱性のリンクとCVE IDで確認は簡単です。できるだけ対策しますが、緊急ではない場合、個人責任でCVEエラーの単位で無視できます。
[CVE-2018-17142] Improper Input Validation
Description ┃ The html package (aka x/net/html) through 2018-09-17 in Go mishandles
┃ <math><template><mo><template>, leading to a "panic: runtime error" in
┃ parseCurrentToken in parse.go during an html.Parse call.
OSS Index ID ┃ b178ee7d-070f-49c6-9154-adbd6423d844
CVSS Score ┃ 7.5/10 (High)
CVSS Vector ┃ CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Link for more info ┃ https://ossindex.sonatype.org/vuln/b178ee7d-070f-49c6-9154-adbd6423d844?component-type=golang&component-name=golang.org%2Fx%2Fnet
Summary
Audited Dependencies ┃ 128
Vulnerable Dependencies ┃ 1
Exited with code 1
ossindex.sonatype.orgの脆弱性の表示は、以下の画面コピーの感じ! 😱
Go、ライブラリをバージョンアップして、最新版でも問題が解決されていないし、旧版をdependencyとして持っています。脆弱性はhtmlライブラリ系のいくつかの関数になりますが、本番でHTMLを提供しないので、問題がないと思われます。バージョンアップと修正に関して興味ありましたら、PRあります。
分析ツール
無料で、とても速いし、とても有名なgosecを使っています。
go vetで品質を確認しています。分析しやすいGo言語のGoの分析ツールのスタンダードですから。
Makefileにより、"make check"で簡単に確認できます。"check"と合わせて、"make check lib-check"もできます。
定義
makefileを定義して、circleciに追記しました。NancyはローカルでDockerで実行していますので、circleciではバイナリーを使っているという違いぐらいです。それ以外、makefileをCircleCIで再利用されているので、ローカルとCircleCIで同じテストを行なっていくことは担保されています。Nancyは同じバージョンにしていますが完璧ではないかもしれません。
IMO
分析ツールを導入した際に、いくつかの問題を確認しました。導入と修正をhereで確認できます.
WAF、 Amazon Inspectorもう使っていますが、分析ツール含めて、脆弱性なかったという脆弱性診断の結果になりました。
継続的にコードとライブラリを更新する力になる気がしました。
開発環境
Git、Go 1.15とDockerあれば、リポジトリをクローンして、すでに開発できるという良い結果になりました。
git clone ...
make run
curl http://localhost:8080/payment-mdm/v1/health -v
コードを生成したり、ビルドしたり、DBのDockerを起動したりして、PaymentMDMを起動します。dstartでDockerでも起動できますが、長いです。
Trace
下記だけで、統合テストとユニットテストを実行します。
make test
Go modulesとmakefileで結構簡単化できます. Makefileのタスクの前提タスクの定義できるので、あまり複雑にならないです。
セキュリティチェックは下記でできます。
make check lib-check
忘れても、CIで上記のテストとチェックを行なっています。
AWSのインフラ
デプロイを説明する前に、以下インフラを紹介します。
Continuous Deployment
feature/*で開発して、 コミットをするたびに、DEV環境にデプロイされています。
PRでmasterブランチにマージすれば、CDがSTG環境にデプロイします。こんな感じで定義しました。
Masterブランチにタグを定義する場合, CircleCIで承認されれば、PRD環境にデプロイされます。承認とは、プロセス的な承認ですが、権限とかないです。
PRD環境以外、何もしなくても、デプロイされます。
PRD環境でも、タグ付きだけでなので、readme.mdにしたがって、下記のように簡単にできます。
$ git checkout master; git pull
$ export TAG=v0.0.1
$ git tag -a $TAG -m "RELEASE SUMMARY HERE!"
$ git tag -n
$ git push origin $TAG
IMO
簡単なブランチの仕様と一つのPRでAgility的に良かったと思います。デプロイはいっぱいやっても重くはないです。
なんかmakefileにPRDのデプロイを追加したないな。"make prod v0.0.1"みたいにw
PaymentMDMのコードの未来
コードはどう変わっていくかまだわからないですが、感想あります。
大きい変更
可用性を高めたいので、エージェントとエージェント用のAPIを提供したいです。システム的に、エージェントを追加して、既存の0.9997%の可用性のALBとCloudFrontのfailoverから、99.9998%の可用性までできそうです。 計算の詳細はポイントチームの要求定義とアーキテクチャ案の可用性に記載されています。
小さい変更
エラー処理をもっとよくしたいです。具体的に、現状のカスタムなラッパから、xerrorsへ移動したいです。
まだわからない変更
継続的に、改善していって、次のレガシーになりたくないです。脱却になれば、脱却しやすいコードにしたいです。
おまけ
Master Data Managementの未来についてリンクを多くリスト化します。参考になりました。
Stay tuned
次のプロジェクトで、Linterの導入、pre-pushでgit push前の確認、docker上の実行とビルド、CI/CDの改善、Goaの拡張をチームとやりましたので、まだ共有します!
Thanks
PaymentMDMの開発チーム(ktzwさん、kashiwagumaさん、tonekoさん)が一生懸命にプロダクトを改善するように、色々アイディアを出したり、開発したりしました。 Micro Service Team、Payment Teamともいろいろ相談できました!
さいごに
新規開発を実装したり、既存コードでも色々試したりしています!
ポイントグループでは一緒に働いてくれる仲間を募集していますので、ご興味のある方は是非ぜひ募集ページをご確認ください。