業務アプリケーション開発にGoを採用する理由
この記事は MICIN Advent Calendar 2022 の24日目の記事です。
前回は熊沢さんの2つの新規事業立ち上げで経験したタイプ別MVP検証の進め方でした。
はじめに
本記事では、業務アプリケーションのバックエンドとしてGoを採用することによるメリットを、実際の業務経験を振り返りつつ考察してみます。
近年では多くの企業でGoが採用されています。その採用理由は、「並行処理をたくさん行いたいから」「学習コストが低いから」「フットプリントが小さくコンテナベースのプラットフォームに向いてるから」「Googleが使ってるから」「高速だから」といったところが挙げられるんじゃないでしょうか。
一方で、単なるモノリスなAPIとしてGoを選ぶ必要はないんじゃないのか、といった声もよく聞きます。「初期フェーズはスピード重視でRuby on Railsが最強だ」「枯れた技術であるJava + Spring Bootが安定しててよい」「フロントに合わせてバックエンドもTypeScriptで」といった意見はもっともだと思います。
本記事では、様々な対抗馬もありつつなぜあえてGoを採用するのかについて、理想だけでなく実際の現場の泥臭さも踏まえつつ考えてみようと思います。
業務アプリケーション開発に求められる、"無視できない"観点
業務でのアプリケーション開発には、趣味の開発にはない要素がたくさんあります。会社として利益を出すため、期限遵守であるため、多くの人が関わるため。様々な目的のために考慮しなければいけない点が出てきます。そのうちのいくつかを挙げてみます。
人の入れ替わり
IT関連の会社において、人の入れ替わりは常に起こり得るものです。エンジニアが異動・退職してしまっても、サービスが継続する限りはアプリケーションのコードの管理は別のメンバーが対応していかなければなりません。最初に開発していたメンバーが誰もいなくなってしまったというケースもあるあるだと思います。
人が入れ替わることで、「なぜこのような設計にしたのか」「どのような思想で処理を行っているのか」「このメソッドを扱う上での注意点」といった情報が失われることもしばしばです。これらの対策のために、ドキュメント化やコーディング・コメントのルールを徹底することなども挙げられますが、人間が関わる以上完璧にするのは非常に難しいです。
技術習得における認知負荷
技術(言語・ライブラリ・フレームワークなど)の使い方について、習得するための認知負荷が大きいこともあります。例えば、Ruby on RailsやSpring Bootなどは多機能なWebフレームワークである一方で、多くの知識が必要となります。また言語によっては、「この文法は現在では非推奨だから使わないほうがよい」といった罠もあります。
これらの認知負荷について、もちろん個人差はありますし、「それくらいは勉強しろ」というのも確かです。しかし、可能な限り採用する技術や仕組みでカバーできたほうが楽になり、本質となる箇所の開発に集中できるのではないでしょうか。
パターンの熟練度
技術習得における認知負荷と近い観点ですが、いわゆるデザインパターンなど実装する上でのテクニックについて、熟練した開発ができると楽しいという方もいると思います。筆者も良いパターンを真似したり生み出したりすることは大好きなのですが、経験が浅い技術だと、後から見ると今ひとつな実装となってしまうこともよくあります。そういった未熟な実装は放置され、やがてはただの可読性の低いコードとなるかもしれません。
「GoFのデザインパターン」や「リファクタリング」といった有名な書籍のパターンを参考に、取り組んでいこうという動きもよくあることだと思います。一方で、それが良いとされることをチームで共通認識を持つようにできることも地道な活動が必要で、現実に至らせるのには難しいこともあると思います。
バージョンアップ対応
アプリケーションが使われ続ける以上、バージョンアップ対応を行っていく必要があります。たとえ新規機能を追加することがなくなっても、脆弱性が発見された場合は対応が必要になります。
言語やライブラリによっては、バージョンアップに破壊的変更を含み、その対応に多くの手間を取られてしまうこともあります。1日程度で対応できればよいのですが、場合によっては数週間かかってしまう対応もあります。「早く終わらせて機能開発したいのに…」そう思いながら対応した方も多いのではないでしょうか。
Goを採用することによる効果
挙げてきた業務アプリケーション開発における"無視できない"観点に対し、Goの特徴によってそれなりに対処できるのでは、と筆者は考えます。以下にその特徴を挙げてみます。
表現の選択肢が少ない文法
多くのところで言及されるところですが、Goの文法は非常にシンプルです。何か処理を実行するために書くプログラムのバリエーションは限られます。
例えばSlice(他の言語でいうList,Arrayなど)の操作について、filter/mapといった関数型的な処理は標準として提供されていません。for文で愚直に取り出すか、自前で定義するかになります。以下は自前で定義してみた例です。
func genericFilter[T any](sl []T, test func(T) bool) []T {
res := make([]T, 0)
for _, e := range sl {
if test(e) {
res = append(res, e)
}
}
return res
}
type User struct {
Name string
Age int
}
type UserList []User
func (ul UserList) Filter(test func(User) bool) UserList {
return genericFilter(ul, test)
}
func main() {
users := UserList{
User{Name: "Alice", Age: 22},
User{Name: "Bob", Age: 10},
User{Name: "Carol", Age: 38},
User{Name: "Dan", Age: 18},
}
filtered := users.Filter(func(u User) bool {
return u.Age >= 20
})
fmt.Printf("filtered: %+v\n", filtered)
// filtered: [{Name:Alice Age:22} {Name:Carol Age:38}]
}
また、例外の扱いもthrow/try-catchなどは使わず、エラーオブジェクトを戻り値として返却する必要があります。
type UserRepository struct{}
func (ur UserRepository) FindByID(id string) (User, error) {
// TODO: find query
// エラーを第2戻り値として返却
return User{}, errors.New("not implemented")
}
func GetUser(id string) (User, error) {
repo := UserRepository{}
user, err := repo.FindByID(id)
if err != nil {
// エラーをWrapして返却
return User{}, errors.Wrap(err, "failed to get User")
}
return user, nil
}
func main() {
user, err := GetUser("123")
if err != nil {
log.Fatal(err)
// 2022/12/19 09:23:52 failed to get User: not implemented
}
fmt.Printf("user: %+v", user)
}
その他、言語として「この型を使わない方がよい」みたいな非推奨な機能も(今のところは)ほぼありません。これは言語としてまだ歴史が浅いからとも捉えられますが。
こういった表現の選択肢が少ないという特徴は、初めてGoを触る人にとっての学習コストを下げ、すぐにコーディングに取りかかれますし、実装した人がいなくなったとしても「何をしているかわからない」というコードを避けられるメリットがあります。もちろんその分冗長にたくさんコードを書かなければなりませんが、結果として人が入れ替わりがちな組織にとってよい効果をもたらしているのではと思います。
"おまじない"の少なさ
Goで書くWebアプリケーションはその他の言語のフレームワークと比べ、隠蔽された仕様、いわゆる”おまじない”が非常に少ない印象です。
例えばSpring BootではAnnotation、Ruby on Railsでは独自のDSLなどが多く扱われており、裏で様々なことをやってくれます。これらは覚えてしまうと非常に便利なのですが、
-
フレームワークの扱いだけで副読本が必要になるほどの学習コストがかかる
-
中でどういうことやってるか理解せずとも使えてしまう→スキル向上機会の損失
-
細かい動きを変えたいとき変えにくいことも
-
バージョンごとに増えたり挙動が変わったりして、覚え直しが必要
といったところがデメリットとも捉えられます。
Goで書くWebアプリケーションでは、アノテーションや独自DSLのようなものは登場させずに素朴に書くことが一般的だと思います。筆者の会社ではWebフレームワークとしてEchoを採用しておりますが、ルーティング・ミドルウェアまわりが分厚くなる程度です。より小さなマイクロサービスであればEchoを使わず、素のnet/httpパッケージだけでも良いのではとも思います。フレームワークの使い方ばかりに足を取られず、純粋な言語仕様に近いところで開発していく体験はかなり良好だと筆者は考えます。
無理しすぎない実装パターン
エンジニアとしての経験を積んでいくと、いわゆるオブジェクト指向、リファクタリング、デザインパターンなどの知識を身につけてより美しいコードを書こうという気になる人は多いと思います。実装パターンの適用について、Goについては他の言語よりも「やりすぎは禁物」と筆者は考えています。
リファクタリングの奥義として有名な1つ「関数の抽出」について考えてみます。「関数の抽出」は、その意図に注目して別の関数に切り出し、その意図に合う関数名の命名を行う技法です。これをGoのコードに適用した場合、 err
, ok
を返す場合が多くむしろ見にくくなるかもしれません。[1]
func getUsersFromRepo() (UserList, error) {
repo := UserRepository{}
users, err := repo.FindAll()
if err != nil {
return UserList{}, errors.Wrap(err, "failed to get all Users")
} else {
return users, nil
}
}
func getMaxAgeUser(ul UserList) (User, bool) {
if len(ul) == 0 {
return User{}, false
}
var res *User
maxAge := -1
for _, u := range ul {
if u.Age > maxAge {
res = &u
}
}
return *res, true
}
func main() {
users, err := getUsersFromRepo()
if err != nil {
log.Fatal(err)
}
maxAgeUser, ok := getMaxAgeUser(users)
if !ok {
log.Fatal(errors.New("users are empty"))
}
fmt.Printf("maxAgeUser: %+v", maxAgeUser)
}
この場合、「関数の抽出」の対となる奥義「関数のインライン化」をしてあげたほうが見やすいとも考えられます。勿論、重複が多く存在する場合などは切り出すべきですが、適用するしきい値が他言語と比べ低めだと筆者は考えます。
func main() {
repo := UserRepository{}
users, err := repo.FindAll()
if err != nil {
log.Fatal(errors.Wrap(err, "failed to get all Users"))
}
if len(users) == 0 {
log.Fatal(errors.New("users are empty"))
}
var maxAgeUser *User
maxAge := -1
for _, u := range users {
if u.Age > maxAge {
maxAgeUser = &u
}
}
fmt.Printf("maxAgeUser: %+v", maxAgeUser)
}
実装パターンが活きて見やすくなった!わかりやすくなった!となれば、それには明確に課題があってそれに対応できた良い例となりますが、そうならないこともあると考慮すべきです。頭ごなしにえいえいと実装パターンを適用するのは危険であり、特にGoは言語仕様のせいか、アンマッチなパターンが敏感に感じとれる気がします。
このコードへのパターンの適用と可読性の関係については、A Philosophy of Software Designという本が参考になります。「メソッドは短いほどよいわけではない。多少長くても知識を凝集させ理解しやすいコードが大切だ」といった主張もあり、今一度コードの可読性について考えさせられる一冊となっておりおすすめです。
安心感のある互換性
Go本体は(少なくとも現在の1系において)後方互換性が守られることが保証されております[2]。実際、運用しているアプリケーションを1.17→1.18→1.19とバージョンアップしてきましたが、ほんの10分程度で対応でき不具合も発生せずにいました。
その他のライブラリ等についても、破壊的変更に困ることは基本的にありませんでした[3]。以前筆者がJava + Spring Bootを運用していたときに度々苦戦していたときと比べても消耗する機会がなくなって助かっております。
しかし、まだ長期間運用していないため大きなメジャーアップデートにぶつかったこともないのも事実です。最近は著名なライブラリがメンテされなくなることもあり[4]、「Goだから安心」とは断言できません。とはいえ、前節で挙げたとおり「誰が書いたものでも読める」コードとなる特徴があるため、フォークしてメンテし続けることなども比較的やりやすいのかもしれません。
Goを採用した開発チームの取り組み
最後に筆者の所属する、MICINのMiROHAチームでのGoへの取り組みを少しだけ紹介します。
MiROHAは治験業務支援のためのアプリケーションです。ブラウザ上で動作するWebアプリケーションとして提供しております。まだまだ発展途上かつ1チームでの開発であるため、モノリシックなアーキテクチャを採用しております。フロントエンドはTypeScript + React、バックエンドはGo、インフラはAWS(ECS、RDS、S3など)という構成です。
MiROHAはプロジェクトがスタートして3年ほど経っており、筆者は入社してから1年ほど関わっています。それまでに何度か担当者が変更となっており、初期のアーキテクチャの考え方などが引き継ぎが薄い面もあります。幸い、 Goを採用したおかげか「何やってるのかわからん!」と投げ出したくなるような箇所はほとんどありませんでした。 それは担当してきた人の手腕に加え、Goという言語の機能・文化的な側面が活きた結果だと思います。
また、「ここはポリモーフィズム意識した形にしよう」「ここのレイヤーの考え方変えよう」「テスティングライブラリこれ試そう」など様々なアプローチを試行し、やはりやめておこうといったことを繰り返していますが、大きな負債になることは少ないです。もちろん「一貫した規律こそ正義」という意見もありますが、試行錯誤を楽しみつつ、失敗したら簡単に対処できるのも楽しめるというのは良い感触です。
とはいえ、知識共有・作業効率化・スキル向上のための試みも重要です。MiROHAチームではコーディングまわりに関して、以下のようなことをこの1年で実施してきました。
-
ADR(Architecture Decision Records)
- アーキテクチャ方針の決定について、その背景も含めて記載するようにしております。MiROHAではコーディング規約についてもADRで同様に管理しております。
-
ライブラリの見直し
- GORM→sqlc、gomock→moq、go-playground/validator→ozzo-validationなど、後発の良さそうなライブラリを新規箇所で試す・可能なら置き換えといったことをやってみています。Goのエコシステムはまだ発展途上なのか、一番使われてるライブラリが今ひとつ…ということも多い印象です。
-
APIスキーマをフロントエンドと共有
- もとよりswagを利用してドキュメント化できていたので、そこからフロントエンドに対してスキーマ共有できる仕組みを整えました。チームメンバーが↓で解説しています。
-
勉強会・読書会の実施
- リポジトリ全体を俯瞰してみて「ディレクトリ構造変えたいね」「今後どうしていこうか」という議論する会や、「現場で役立つシステム設計の原則」「レガシーコードからの脱却」「アジャイルサムライ」の読書会などを実施してきました。
これらの取り組みを通し、もっとGoを、ひいてはプロダクト全体を邁進させるべく日々チャレンジしております。
おわりに
「業務アプリケーション開発にGoを採用する理由」について、短い経験ながら考えていたことを述べてみました。何かしら技術を採用するとき、かっこいいキラキラしたところに目が眩みますが、もっと泥臭い観点から選ぶことも忘れてはいけないと思います。Goの高速性・並行処理が第一の目的とならずとも、一度採用を検討してみてはいかがでしょうか。
参考
-
A Philosophy of Software Design, 2nd Edition
- ソフトウェアの複雑性の本質と、複雑性を最小化するテクニックについて述べられている本です。かなり人間らしさに寄った内容でしみじみ納得できる面白さがあります。
-
- Martin Fowler御大による名著。リファクタリングの奥義がカタログとしてたくさん紹介されていますが、「関数の抽出」「関数のインライン化」のように対になるものも多く、適材適所に活用すべきという点が心に刺さります。
-
元JavaエンジニアがGoに感じた「表現力の低さ」と「開発生産性」の話 - DMM inside
- DMMさんによるGoの良さを伝える記事。筆者もJava出身なので大きく共感しました。本記事で伝えたいことと大きく被っておりオススメです。
MICINではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!
MICIN採用ページ:https://recruit.micin.jp/
-
ここでの
getMaxAgeUser
はドメインモデルを意識するとUserList
のメソッドにしてあげるのが望ましいです。例のためあえてこう書きます。 ↩︎ -
GORMに関連したものは何度か発生しましたが… ↩︎
-
mux,websocket等で有名なGorilla Web Toolkitがアーカイブされてしまいました https://github.com/gorilla ↩︎
Discussion