- Go はバックエンド開発の過剰な複雑さを減らす選択肢であり、高速コンパイルと単一バイナリ配布、安定した依存関係管理が中核的な利点
- Go はデコレータ、メタクラス、マクロ、trait、monad のような複雑な抽象化ではなく、struct、関数、インターフェース、goroutine、channel を中心としたシンプルな言語設計を採用
embed, html/template, net/http, database/sql, encoding/json, go test, pprof などの標準ライブラリと基本ツールだけで、Web アプリ、データベース、テスト、ベンチマーク、プロファイリングまで対応可能
- goroutine は約 2KB のコストを持つ stackful な実行単位で、channel、
sync.Mutex、race detector、context.Context によって並行処理とキャンセル伝播をシンプルに扱える
go mod init, go build, scp, systemctl restart と続く流れは、node_modules、複雑な Docker・Kubernetes 構成、過剰なマイクロサービスよりも、1 つの Go バイナリと Postgres を中心にしたシンプルなデプロイを勧めている
Go を選ぶべき理由
- Go はバックエンド開発の過剰な複雑さを減らす選択肢であり、高速コンパイル、単一バイナリ配布、安定した依存関係管理が中核的な利点
- フロントエンドで HTML が過剰な複雑化への代替手段として残っていたように、Go も 10 年以上にわたりバックエンド簡素化の選択肢として存在してきた
- 単純なフォーム提供や秒間およそ 40 リクエスト程度の CRUD アプリのために、多数の Node パッケージ、TypeScript のビルドツール、Kubernetes、Rails のプラットフォームチーム、Rust への書き直しまで持ち出すのはやりすぎ
- Go の志向は「賢い抽象化」よりも、読みやすいコード、デプロイ可能な成果物、小さな運用負荷にある
退屈さを意図した言語設計
- Go が退屈に感じられるのは意図的な設計によるもので、デコレータ、メタクラス、マクロ、trait、monad のような複雑な抽象化を提供しない
- 中核となる構成要素は struct、関数、インターフェース、goroutine、channel くらいに限定されている
- 仕様を短時間で読み、その日のうちに生産的にコードを書けるほどのシンプルさを目標としている
- 退屈さはチームのコードベースでは利点として働く
- 先月入社したジュニアでも、2 年前に principal が書いたコードを読める
gofmt が 1 つのフォーマットを強制するため、コードスタイル論争が減る
- 言語自体が、過度に複雑な抽象化をコードベースに押し込みにくくしている
標準ライブラリがフレームワークの役割を果たす
- Go では別途 Web フレームワークがなくても、標準ライブラリだけで Web アプリを作れる
embed, html/template, net/http を使えば、HTML テンプレートをバイナリに埋め込み、HTTP ハンドラでレンダリングするアプリを構成できる
package main
import (
"embed"
"html/template"
"net/http"
)
//go:embed templates/*.html
var files embed.FS
var tmpl = template.Must(template.ParseFS(files, "templates/*.html"))
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "index.html", map[string]string{
"Name": "asshole",
})
})
http.ListenAndServe(":8080", nil)
}
- この例は実際に動作する Web アプリで、HTML テンプレートはコンパイル時にバイナリへ組み込まれる
webpack、Vite、開発サーバー、巨大な node_modules なしで、go build 後に 1 ファイルをデプロイできる
- 標準ライブラリと基本ツールだけで主要なバックエンド作業を処理できる
- データベース:
database/sql
- JSON:
encoding/json
- 他サービスの呼び出し:
net/http クライアント
- 並行実行:
go キーワード
- テスト:
go test
- ベンチマーク:
go test -bench
- プロファイリング:
pprof
奥行きのある標準ライブラリ構成
-
io.Reader と io.Writer
io.Reader と io.Writer はそれぞれメソッド 1 つだけを持つインターフェースだが、Go エコシステム全体の重要な基盤として機能する
- HTTP レスポンスボディを gzip writer につなぎ、さらにディスクファイルへ流すといった組み合わせを少ないコードで処理できる
- 主要パッケージがこの 2 つのインターフェースを共有しているため、同じパターンをさまざまな場所で繰り返し活用できる
-
context.Context
context.Context はキャンセル伝播のための標準的な方法
- ユーザーがブラウザタブを閉じるとリクエスト context がキャンセルされ、それに続いてデータベースクエリや下位の HTTP 呼び出しまでキャンセルできる
- goroutine リークやコネクションプールを消費するゾンビクエリを避けるには、context を最初の引数として渡し、それを尊重すべき
-
エンコーディングパッケージ
encoding/json, encoding/xml, encoding/csv, encoding/binary はすべて標準ライブラリに含まれている
- struct tag パターンやポインタでデコードする使い勝手が似ているため、1 つ覚えれば他のパッケージも簡単に使える
苦痛を減らす並行処理モデル
- goroutine は OS スレッドそのものではなく、ランタイムが OS スレッド上に多重化する stackful な実行単位
- goroutine の起動コストは約 2KB で、ノート PC でも 10 万個を生成できる
- channel は goroutine 間の型付きパイプとして動作し、片方が送信し、もう片方が受信すると、ランタイムが同期を処理する
- 共有状態が必要なときは
sync.Mutex を使え、race detector がデータ競合を見つけてくれる
- 並列 HTTP fetcher も、別ライブラリやフレームワーク、
async/await の儀式なしに書ける
results := make(chan string, len(urls))
for _, url := range urls {
go func(u string) {
resp, _ := http.Get(u)
results <- resp.Status
}(url)
}
for range urls {
fmt.Println(<-results)
}
実際の CRUD ルート例
- Postgres から投稿を読み出し、HTML をレンダリングする CRUD 的なルートも、1 画面に収まる程度にシンプルに構成できる
//go:embed templates/*.html
var tmplFS embed.FS
var tmpl = template.Must(template.ParseFS(tmplFS, "templates/*.html"))
type Post struct {
ID int
Title string
Body string
}
func postsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
"SELECT id, title, body FROM posts ORDER BY id DESC LIMIT 50")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var p Post
if err := rows.Scan(&p.ID, &p.Title, &p.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
posts = append(posts, p)
}
tmpl.ExecuteTemplate(w, "posts.html", posts)
}
}
- この例はデータベース、テンプレート、HTTP ハンドラを 1 か所で示している
r.Context() が SQL クエリに渡されるため、接続が閉じられればクエリもキャンセルできる
- ORM、DI コンテナ、サービス層、抽象基底クラスの多い
controllers/ ディレクトリがなくても、上から下へ読んで動作を把握できる
週末を台無しにしない依存関係管理
go mod init でモジュールを開始すると、依存関係は go.mod と go.sum に記録される
go.sum は実際に取得した項目についての暗号学的記録であり、想定と異なる依存関係が入り込んだ場合に確認できる
node_modules ディレクトリ、開発環境と CI の間での lockfile drift、peer dependencies、optional dependencies、devDependencies、peerDependenciesMeta のような複雑さはない
- オフラインビルドが必要なら
go mod vendor が依存関係を vendor/ ディレクトリに取得し、ツールチェーンがそれを自動利用する
- プロジェクト全体と依存関係を 1 つの tarball にまとめられるため、運用やセキュリティレビューの面で有利
コンパイラと一緒に提供されるツール
- Go の基本ツールはサードパーティプラグインや別個の設定ファイルなしで提供される
gofmt はコードフォーマットを標準化し、フォーマット論争や空白変更による diff 増加を減らす
go vet は明らかなミスを見つけるのに使える
go test はテストを実行する
go test -race は race detector とともにテストを実行し、データ競合を見つける
go test -bench はベンチマークを実行する
go test -cover はテストカバレッジを確認する
go tool pprof は、実行中の本番サービスの HTTP エンドポイント経由で CPU とメモリ使用量の flame graph を取得できるようにする
デプロイはコピーコマンドで終わる
- Go デプロイの中核的な流れは、バイナリをビルドし、サーバーにコピーして実行すること
GOOS=linux GOARCH=amd64 go build -o myapp ./cmd/myapp
scp myapp user@server:/usr/local/bin/
ssh user@server 'systemctl restart myapp'
- この流れなら、Dockerfile、multi-stage build、base image CVE アラート、Kubernetes manifest、Helm chart、ArgoCD、service mesh、sidecar なしでデプロイできる
- 約 12MB の静的リンクバイナリと 20 行 の systemd unit ファイルだけで本番デプロイが可能
- Docker が本当に必要でも、Go バイナリを
FROM scratch イメージに入れる方式で十分
フレームワークとの対比
- Rails、Django、Express、Next.js のようなフレームワークには、それぞれのデプロイ手順、ORM、admin、middleware、npm 警告、ルーティング規約の変化といった負担がある
- Go バイナリはコンパイルされて実行され、5 年後でも動かせる安定性を利点として持つ
- フレームワークのほうが先に廃れたり、メンテナが燃え尽きを訴えたりしかねないのに対し、Go のシンプルな実行モデルは際立っている
マイクロサービスより単一の Go バイナリ
- マイクロサービスはデフォルトの選択肢であるべきではなく、まずは モノリスを書くほうがよい
- 推奨構成は 1 つの Go バイナリ、1 つの Postgres、そして本当に必要なときだけ 1 つの Redis
- HTML と JSON API を同じポートで提供し、単一の VPS 上で動かす構成も可能
- Go は goroutine のコストが低く並行処理に強いため、秒間 1 万リクエスト程度までは無理なくスケールできる
- 実際に分離が必要になれば、Go モノリスのパッケージを別リポジトリへ移して分割できる
- すでにインターフェースが存在するため、言語自体が自然に分離を見据えた構造を作らせる
ジェネリクスとエラー処理
if err != nil はバグではなく機能
- それぞれの失敗地点で何をするかを自分で判断させ、エラーを隠さない
try/catch のネストはエラーをなくすのではなく、本番障害の時点まで隠してしまうことがある
- ジェネリクスは Go 1.18 で導入されており、必要なときに使えばよい
結論
- フレームワーク、マイクロサービス、Rust への書き直し、新しい JavaScript メタフレームワークが必須というわけではない
go mod init を実行し、main.go を書き、テンプレートを embed してからコンパイルし、デプロイするというシンプルな流れを勧める
- 退屈な選択こそ正しい選択であり、Go がその選択肢だ
1件のコメント
Lobste.rsの意見
伝言役を責めたいわけではないが、こういうブログ文体は疲れるし子どもっぽい。最初は面白かったのかもしれないが、繰り返されるほど苛立ちが指数関数的に増していく
とはいえGoは良い。最近TypeScriptプロジェクトからGoプロジェクトに移ったが、メンタルヘルスと仕事の士気が急速に改善している
if err != nilがバグではなく機能だという話は受け入れるが、それでもなおGo最大の欠点だと思う。直和型(sum types) があれば、実行時の型アサーションに頼らずもっとずっと人間工学的にできただろうこういう書き方をするなら、せめて侮辱にも少しは機知がほしい
他のコメントを見ると不人気な考えのようだし、きつく聞こえたくはないが、Goが本当に嫌いだ
Goは並行性に効率的なランタイムの上に、そこそこ悪くない文法を載せて、Googleの力でエコシステムを押し進めた言語だと思う。それ以外はひどい
最大の問題は、何十年にもわたるプログラミング言語設計研究や、実務上の慣行ですら、意図的に無視しようとして設計されたように見える点だ。何十年も経ってからようやくジェネリクスが入ったが
いつも依存型を使うべきだと言っているわけではないが、それにも程度がある。Goには現代の言語なら備えているべき、データモデリング、不変条件のモデリング、コード構造化の機能がほとんどない。Rustは学習曲線こそ急だが、この点でははるかに優れているし、Rustほど洗練された型システムでなくても十分に良くできる。コンパイル時間が心配なら、シンプルだが実用的な機能だけでも、高速で表現力のある健全な型システムは作れる
それに
if err != nilは、エラー処理のノイズでコードを埋め尽くす最悪のやり方だと思う。Go界隈がなぜそこまで直和型を嫌うのかわからない。この点ではJavaの例外処理ですらましだ。現実には、言語にエラーをもっと上手く扱う機能がないせいで、人々があり得る限り最悪の応急処置を機能だと勘違いしているだけだそもそも原文があんなに偉そうでなければ、こんなコメントも書かなかっただろう。「とにかくXを使え」は馬鹿げた物言いだ。ユースケースに合っていて、快適で、生産的なツールを使えばいい。それがGoならGoを使えばいいし、そうでなければ別のものを選べばいい
とくにGoogleのような組織では、何千人もの開発者がいて、特定のチームや会社にとどまる期間が短いこともあるので助けになる
こういう文脈では、とくに未熟な開発者にとって、高度な型システムの欠如がある程度は利点にもなる。基本型や構造体のようなごく基本的な概念を超えて、型についてほとんど考えなくて済むからだ。データをモデリングする道具はほとんど与えられないが、その代わり深く考えずに大量のコードを書ける
言語レベルの正確性という意味ではあまり良くないと思う。だが大規模組織では、モノレポ解析、CI/CD、カナリアテスト、観測ツールのような周辺インフラへの依存が大きくなる。小規模組織よりも、そのインフラのほうがずっと多くの負荷を支えている
私も同じように認知負荷の低さゆえに、ある程度Goが好きだ。特定のプロジェクトにたまにしかコードを書かず、今は日々長期プロジェクトに深く関わっていないからだ。1か月見ていなかったコードベースに入って1時間未満で作業できるのは大きな利点だ。ただ、複雑なプロジェクトの専任開発者だったら、ここまで好きではなかったと思う
Goの開発者は、基本をきちんと押さえることに集中してきたのだと思う。これまでの言語やプログラミング言語理論の研究コミュニティが、その基本を軽視してきたからだ。人々は最も包括的な型システムに執着しがちだが、型システムが複雑で表現力豊かになるほど、得られるものは逓減していく。そして型システムにどれだけ注力しても、ひどいパッケージ管理、チームが新しいDSLを学ばされるビルドツール、型情報やサードパーティパッケージのドキュメントへのリンクを自動生成しない文書システム、貧弱な標準ライブラリ、深刻な性能問題、静的コンパイル戦略の欠如、苦痛なビルド時間、急峻な学習曲線、懲罰的な型システム、読みにくい文法、劣悪なエディタ統合を補うことはできない
Goにデータモデリング機能がまったくないというのは明らかに間違っている。どんな言語でもデータと不変条件はモデリングできるし、Goもそのモデルを強制するための型システムをかなり提供している
Rustは素晴らしいし、反復速度が重要でない場合や、ベアメタルにデプロイする場合、あるいは正確性・性能要求が非常に強い場合には良い選択だ。だが汎用アプリケーション開発、特にチーム開発のデフォルトとしては向いていない。
if err != nilはたくさん打つことになるが、1秒あたりのキーストローク数がボトルネックな人はいないと思うif err != nilがバグではなく機能で、問題が起こりうるすべての箇所を見せてくれるという話は間違っている実際には強制していない。自分で確認しなければ、エラーを無視するほうがずっと簡単だ
エラーを処理または伝播するやり方では、Rustが今でも際立った例だ
幸い、ここ数年で関わったすべてのGoプロジェクトでは、Goの貧弱な組み込み静的検査の上にgolangci-lintを載せて使っていた。正直、すべてのGoプロジェクトで必須であるべきだ
こういう文章の流行りは本当に嫌いだが、言おうとしている主旨には同意する
「Volkswagenサイズの
node_modulesがない」というのはその通りだが、プロジェクトローカルのnode_modulesではなく、~/goにあるグローバルパッケージキャッシュなだけだwc -l go.sumをやってみろといつも言いたいページを開いた瞬間に「Hey, dipshit.」が見えて、そのまま閉じた
多くのプログラミング言語称賛記事と同じ問題を抱えている。今使っている言語がどれほど素晴らしいかより、以前使っていた言語がどれほどひどかったかに重点を置いている
筆者はRubyとTypeScript、あるいはPythonでかなり苦しんでいて、Goがそれを解決してくれたようだ。しかし私はRubyやTypeScriptを使わないので、あまり刺さらなかった
何年ものあいだ、こういう変奏を何十回も読んできた気がする。PythonやJavaScriptと違って静的型があるからHaskellを使え。PerlやErlangと違って単一バイナリで配布できるからRustを使え。RubyやTclと違ってまともな並行性とチャネルがあるからElixirを使え
筆者が自分に合う言語を見つけたのは嬉しいが、その助言に従うつもりはない
Goのゼロ値はいつも欠点だと感じていた。ユーザーにデフォルト値を明示させるほうが良いと思う。それ以外は、OCamlではないことを踏まえればかなり良い言語だ
boolにtrueを取らせたいJSONオブジェクトをマーシャリングするのが非常に難しいデプロイとコンパイルの体験は素晴らしいが、言語自体を書くのは本当に嫌いだ。使うたびに嫌な体験になる。Goほど制約が強くなくて、デプロイ体験が良い他の言語はあるだろうか?
自分はGoで何かを見落としているのだろうか?
最近小さなRailsアプリケーションをデプロイしてみたら、設定が多すぎて、Goの利点は確かにはっきり感じられた
x86_64-unknown-linux-muslでコンパイルし始めた。こうすると、すべての64ビットLinuxマシンでそのまま動く静的バイナリができる。あとはscpで転送して実行するだけだまだポートを割り当てて手動で起動しなければならない問題は残っているが、少しsystemdの魔法を使って解決するつもりだ
bundlerを使うと単一実行ファイルが作れて、別ディストリビューションのLinuxマシンに置いても、Qtが入っていなくても、ユーザーは実行ファイルを走らせるだけでGUI全体が動く
ただしOpenGLドライバには問題があるという注意点はある。依然として可能ではあるが、「コピーして実行」よりは複雑になる
Goが並行性のために設計されたと主張しながら、誤って簡単に共有できる生ポインタが組み込まれているのが最大の問題だ
退屈さ自体は悪くないが、Goは実際に退屈な言語になることに妙に失敗していると思う
「デコレータがない」と言うが、struct tagsとリフレクションはある。実行してみるまで、それらがどう相互作用するのか把握しづらい
構造的インターフェースとリフレクションは、離れた場所から挙動が変わる恐ろしい原因だ。構造体にメソッドを1つ間違って追加しただけで、ライブラリの振る舞いが完全に変わることがある
ドキュメントの観点でも奇妙だ。型がどのインターフェースを満たす意図なのかを明示したくない理由があるだろうか?
goroutineは、なぜ単にスレッドと呼ばないのかわからない
チャネルは、なぜ言語機能でなければならないのか? ジェネリクスが3種類ほどの型以外にも有用だと認めるのに10年遅れたからだと思う
チャネルがランタイムの一部なのは、goroutineスケジューラがチャネルを理解していれば、チャネルが空でなくなったときに受信側goroutineを起こしやすいからだと思う。おそらくそのほうが簡単だったのだろう