💪

Goで開発して3年のプラクティスまとめ(1/4): プロジェクトを始めるまで編

2024/07/01に公開

Goで開発して3年のプラクティスまとめ(1/4): プロジェクトを始めるまで編

yet another入門記事です。

はじめに

筆者はGoを触りだして3年ぐらいたったので、触りだしてからもっと早く知りたかったこととかをまとめて置くことで筆者の知識の整理を行います。

元は社内向けに軽いイントロダクションのドキュメントは作っていたんですが、成果が社内に限られるのはいまいちに感じたので私的な時間を使って新規に作り直しています。
比べてみるとファイルサイズで5倍以上になっていました。多分最初に書いたものよりは役に立つと思います。

なるだけワンストップでたくさんの話題を扱うようにして、
読んだ人(会社の同僚)がなんとなく開発を始められるようになって、
なんとなくイディマティックなコードを書けるようにするのが目的です。

ご質問やご指摘がございましたらこの記事のコメントでお願いします。
(ほかの媒体やリンク先に書かれた場合、筆者は気付きません)

Overview

筆者にとって「プロジェクトを始めるまでの方法がわからん」っていうのが新しいプログラミング言語を学ぶときの1つ目のハードルだったりします。
ついでに言うと筆者が所属するようなcorporate proxyの背後にあってprivate gitを用いる環境ではさらにもう1段ハードルがあるため大変です。大変でした。
part 1はそれらを解消しうるものになることを意図しています。

以下の順番で書きます

  • 筆者のバックグラウンドとかおまけ的な話
    • 筆者はGoを使い始める前から開発自体はしていたので、暗黙的な前提知識がたくさんあります。それがなんとなくわかるようにしています。
  • 基本的な読み物系へのリンク
  • プロジェクトの始め方
    • github / gitlabなどにrepositoryを作成してgo moduleを作って動作させるまで
  • Dockerfileの紹介
    • 対象読者には場合によっては高度すぎる話題かもしれないので読み飛ばすほうが良いかもしれません。

記事中にTODOコメントがそのまま残ってるかと思います。読み物を紹介しておいて、筆者が全部読んでないやつがあるんですね。後追いで読み終わったらTODOコメントを消すかもしれません。そのとき万一、筆者が知らなかったことが発覚して記事の修正が発生した場合はgithub上で差分を見れるようにしておきます。追記はあってもおおむね大幅書き直しは起こらないと思っています。

この一連の記事は基本的に突っ込んだ内容をひたすら語ります。突っ込んだ内容に付随する基本的もなるだけ書いていきます。part 1は比較的優しめな内容だと思います。

2種の想定読者

記事中では仮想的な「対象読者」と「ベテランとして取り扱われるその他の読者」が想定されています。

対象読者

記事中で「対象読者」と呼ばれる人々は以下のことを指します。

  • 会社の同僚
  • いままでGoを使ってこなかった人
  • ある程度コンピュータとネットワークとプログラムを理解している人
  • pythonとかNode.jsで開発したことある
  • gitは使える。
  • 高校生レベルの英語能力
    • 作ってるところがアメリカ企業なので英語のリンクが全般的に多い

part1以降はA Tour of Goを完了していることと、
ポインター、メモリアロケーション、POSIX(もしくはLinux) syscallなどの基礎的概念がわかっていることが前提条件になっています。

そのほかの読者

特に断りがない時、他の読者も聴衆として想定されます。

  • 筆者と同程度かそれ以上にGoに長じており
  • POSIX APIや通信プロトコル、他のプログラミング言語でよくやられる方法を知っている

というベテラン的な人々です。

記事中に他にいい方法があったら教えてくださいとか書いてますが、大概はこのベテランな人たちに向けて書いているのであって、対象読者は当面気にしないでください(もちろんあったら教えてください)。

対象環境

  • 下層の仕組みに言及するとき、特に述べない限りlinux/amd64を想定します。
  • OS/archに依存するコードは書きません。

version

検証はgo 1.22.0、リンクとして貼るドキュメントは1.22.3のものになります。

## go version
go version go1.22.0 linux/amd64

最近追加されたAPIをちょいちょい使うので1.22.0以降でないと動かないコードがたくさんあります。

直近の3~4 minor versionのみサポートするライブラリが多いとして、Go 1.18でできなくてそれ以降できるようになったことは、○○以降となるだけ書くようにします。

サンプルコードのrepository

サンプルコードの一部は下記にアップロードされます。

https://github.com/ngicks/go-basics-example

筆者のバックグラウンド

筆者のバックグラウンドを書くことで、アンビエントに存在する前提知識を掲示します。

  • 学生時代はC++を使ってセンサー値を読み込んで計算を行うプログラムを記述していた。
    • つらかった
    • Windows 7
    • Visual Studio Community
    • 筆者は機械系の学徒であって、装置/回路の設計、実験方法の考案など広く浅くなのでこの時点で大してソフトウェアには詳しくありませんでした。
    • まるきりsingle-threadedでした
    • すごい余談ですが四元数で回転を計算するのにEigenを使っていました。
      • 優れたライブラリです。でもC++はほかの人が作ったパッケージを持ってくるのが大変でした。
  • 入社してからメインNode.js、ほんのちょっぴりpythonで開発を行う
  • 独学でThe Rust Programming Language 日本語を(確か2018年エディションを)読了、PDFiumやOpenSSLのbindingをrustで書いたりして使ってました
  • 趣味レベルでReact、仕事のヘルプでちょびっとVue2
  • その後Goを使い始める。多分一番慣れてる言語です。

つまり筆者はGoを使い始める前に以下をなんとなく理解しています

  • C++・・・古い本を読みながらやったので当時の最新の書き方でもなかった。
  • Node.js
  • TypeScript
  • Rust
  • Linux上でファイルを読み書きしたりデータストレージとやり取りするときに起きる諸般の問題
    • open(2)O_TRUNC付きで呼んでからwrite(2)がリターンするまでの間にファイルが0バイトの状態が観測できる、とか

筆者はLinuxが動作する小さ目のデバイスで動くプログラムしか書かないので、
クラウドとかそういったものが視点に入っていません。
結構特殊な視点で書かれているかもしれないです。

基本

Goとは

https://go.dev/doc/

The Go programming language is an open source project to make programmers more productive.

Go is expressive, concise, clean, and efficient. Its concurrency mechanisms make it easy to write programs that get the most out of multicore and networked machines, while its novel type system enables flexible and modular program construction. Go compiles quickly to machine code yet has the convenience of garbage collection and the power of run-time reflection. It's a fast, statically typed, compiled language that feels like a dynamically typed, interpreted language.

GoはGoogleが開発しているオープンソースのプログラミング言語です。

  • C系の文法で
  • 静的型付けで
  • interfaceによるダイナミックディスパッチがあり
  • GCがあり
  • 文法や構文が厳選されており、追加もめったにないため書き方がブレにくく
  • concurrentに関数を実行するgoroutineの仕組みがあるため、非常に容易にconcurrentな実行ができ
    • goroutineはruntimeがスケジューリングを行う軽量なthread of execution(green thread)
    • そのためasync/await的記法がない
  • 逆にgoroutine以外ないのでライブラリ間で分断が起こることがなく
  • コンパイルが非常に早く
  • (C-bindingを使わない限り)クロスコンパイルが簡単で
  • (C-bindingを使わない限り)staticなシングルバイナリを簡単に出力することができ
  • モジュール/パッケージによるネームスペースの分割機能があり
  • モジュールの取得に中央集権的なレジストリがない
    • いい点としてgithubなどからモジュールを取得するのがすごく簡単です

みたいな言語です。

A Tour of Go

https://go.dev/tour/welcome/1

Goの基本的なトピックはここに書いてあります。
インタラクティブなコードスニペットと簡単なエクササイズがあり、これさえこなせばとりあえず開発は始められます。

慣れてない頃に、syntax highlightのかからないwebページでコードを書くのはきついと思うのでローカルのエディターにコピーして実行したほうが良いとは思います。
大体3~5時間ぐらいで全部終わると思います。

TODO: 手元のエディターでA tour of goをやれるかの検証

Go by Example

https://gobyexample.com/

コード例とともに解説がされます。
項目数が多く、知らないstdモジュールを使う部分をきちんと理解しようとすると時間がかかると思います。
手が空いたら少しずつ読むのがいいのではないでしょうか。

公式読み物系

TODO: 読む

公式が出している読み物集。全部英語です。
初めから全部読む必要はないと思いますが折を見て読んでいくのがよいと思います。

Std library

TODO: いった手前自分でも読む

https://pkg.go.dev/std

standard libraryです。

HTTPなどで動作するサーバープログラムを作るのに大体必要な機能がそろっています。
できれば開発に着手する前にすべてのインターフェイスとdoc commentを読んでおくがよいと思います。
CGOSWIGへの言及があるなど、読んどけばよかった系の文章が意外なほどたくさん書いてあります。

https://pkg.go.dev/golang.org/x

こっちはsub-repositoriesです。説明のとおり、Go Projectの一環ですがstd libほど厳密なバージョン管理がされていません。
std libに入ると厳密な後方互換性の約束を守る必要があります。
そのため変更の可能性が高かったり、stdに入れるほどの重要度がないものがこちらにあるというコンセプトのはずです。

  • ここで先に実装されてからstdに昇格されたり(maps, slicesなど)、
  • stdがFrozenなので代わりにこちらのものを使うべきだったり(syscallの代わりにgolang.org/x/sys)
  • 1ファイルにバンドルされてstdに組み込まれていたり(golang.org/x/net/http2)

することもあります。

golang/example

TODO: 全体をざっと眺める。

https://github.com/golang/example

公式でメンテされているexample集。新しめな話題は取り扱っていないこともある。

プロジェクトの始め方

モジュールをセットアップするまでのあれこれをまとめておきます。

公式ドキュメントに網羅されている内容ですのでそちらに当たってもらうほうがよいでしょう。
特に基本的文法はA Tour of Goで網羅的に述べられるので説明しません。

VCS(Version Control System)にrepositoryを一つ作り、そこに1つGo moduleを作るところまでをここでカバーします。

VCSはここではgitしか想定されていません。

インストール

公式の手順に従い、各OS環境に合わせてGoをインストールしましょう。

https://go.dev/doc/install

linux/amd64の場合はいつもの手順です

mkdir /tmp/go-download
cd /tmp/go-download
curl -LO https://go.dev/dl/go1.22.3.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.22.3.linux-amd64.tar.gz

一応チェックサムを確認しておいたほうがいいかもしれません。(筆者はshellを使い倒すのに慣れていないのでコマンド自体は参考程度に)

# checksumの値はダウンロードページから確認できる。
echo 8920ea521bad8f6b7bc377b4824982e011c19af27df88a815e3586ea895f1b36 > checksum
sha256sum go1.22.3.linux-amd64.tar.gz | awk '{print $1}' | diff - checksum

環境変数を設定。使っているOS/terminalに合わせた方法で設定してください。

export PATH=$(/usr/local/go/bin/go env GOPATH)/bin:/usr/local/go/bin:$PATH

/usr/local/go/bin以下に、先ほど.tar.gzから解凍したgoコマンドとgofmtコマンドが置かれます。

$(go env GOPATH)/bin以下にはgo installしたバイナリがおかれます。
パスを通しておけばコマンドとして利用できるようになります。

エディタ

エディタは個人の好みともろもろを合わせて好きに選べばいいと思います。
ただよく聞く+goのsurveyの上位3位は以下の3通りです。

goのsurveyはself-selection, vscodeGo extensionからの誘導, GoLandからの誘導の経路があるのでself-selection biasがかかってるはずなんですが、
それでもこの3種がよく聞くので多分本当にこの3種が多い。

VCSでのrepositoryの作成

手順そのものは説明しませんが、以下の手順はgithubgitlabでrepositoryが存在していることを想定します。

VCS(Version Control System)は, コンピュータファイルのバージョンを管理するシステムのことです。
代表的なものはgitsvn,mercurialあたりだと思います。
この記事ではgitのみを取り扱います(筆者がほか二つのことをほぼまったく知らないからです)

gitは、VCSを構築するためのサーバーおよびクライアントプログラムです。サーバーとして直接使うことはほとんどないかもしれません。
現在ではgitサーバーはgithubというwebサービスを利用するか、 セルフホストすることも可能なgitlab、あるいはgitbucketなどを使うのが一般的だと思います。(この3つがリストされてるのは単に筆者が使ったことあるやつ3種っていうだけです)

先にローカルでrepositoryを作成してあとからremote上に作成する方法もあるはずですが、この説明ではremoteを先に作る方法しか想定されません。

Go moduleの初期化

VCSで作成したrepositoryをローカルにクローンします。

# gitの場合
git clone <<uri>>
cd <<repo-name>>

go mod init <<module-name>>Go moduleに必要なファイルを作成します

go mod init <<module-name>>

<<module-name>>は基本的に上記<<uri>>からプロトコルスキームを抜いたものにするとよいです。
そうするとgo get <<module-name>>でこのモジュールを別のGo moduleへ導入できるためです。

例えば、VCSURIhttps://github.com/ngicks/go-basics-exampleである場合、

go mod init github.com/ngicks/go-basics-example

で作成し、VCSにソースをプッシュすると

go get github.com/ngicks/go-basics-example

で別モジュールから導入、参照できます。

local onlyのモジュール

ローカルオンリーなつもりならおおむね<<module-name>>はなんでもよいはずです。

go mod init whatever-whatever

筆者が知る限りほかのモジュールからgo getできなくなる以外の違いはないです。

ただし、How to write codeでも勧められる通り、なるだけ公開される前提でモジュールを作っておいたほうがよいでしょう。

セルフホストのVCSかつサブグループを使用する場合

ただし、セルフホストのVCS(=URIがgo toolに対して既知でない)でサブグループを作成し、サブグループの中でソースを管理する場合、
<<module-name>>はvcsのsuffixを加えておかないとgo get時に失敗するかもしれません。

つまり上記と同じ例で行くと

# 架空のURLを扱うのでexample.comに変えてあります!
go mod init example.com/ngicks/subgroup/go-basics-example.git

とする必要があるということです。

なぜか?

go getが利用するロジックが以下のように、インポートパスからvcsがなんであるかをマッチしようとしますが、セルフホストの場合既知ではないので末尾の// General syntax for ...のところでマッチするはずです。

https://github.com/golang/go/blob/go1.22.3/src/cmd/go/internal/vcs/vcs.go#L1515-L1577

ここぐらいしか違いを生みそうな行がないです。

この時、.gitがついていればregexpに正しくマッチするが、
そうでなければパスがわからないので、典型的な<<host>>/<<user>>/<<repository>>のパスが.gitと思って通信しようとする、と思われます。

上記の例で行くとgo mod init example.com/ngicks/subgroup.gitだと勘違いしてしまうようです。
筆者がこの問題を観測したgitlabのバージョンだとhttps://example.com/ngicks/subgroup?go-get=1にアクセスされてもエラーなく、
なおかつsubgroupGo moduleであるかのようにメタデータを返していまいます。
(ちなみに.gitサフィックスのついたパスでgo getを試みても上記のsubgroup?go-get=1にアクセスしに行くログが出てます)
その後、git ls-remote ... example.com/ngicks/subgroup.gitを実行して、エラーが返ってくるので、そんなモジュールないよ、という終わり方をします。

筆者の利用するgitlabのバージョンでは.gitサフィックスをモジュール名につけることで解決しています。

筆者はこの現象をgo 1.20あたりからgo 1.22.1までの間で確認しいます。なので、

  • gitlabのバージョンによって修正されているか?
  • go toolのバージョンが上がることで修正されているか?
  • ほかの方法はあるのか?
  • モノレポで複数のGo moduleが管理される場合どうなるか?

はわかりません。もしかしたら直っていないかもしれないので、同じ現象に遭遇したらこの方法で解決できるかもしれません。

実際上は

することでも解決することができると思いますが、それがわかる人は自力で解決できるでしょうからここではこれ以上書きません。筆者はやったことがありません。

プロジェクトの構成

先にまとめを述べます

  • Go moduleではディレクトリ=パッケージ
  • ディレクトリはパッケージを1つしか持てない
    • 例外として_testサフィックスを付けた(e.g. pkgに対してpkg_test)テスト用パッケージを定義することはできる
  • パッケージがネームスペース
    • 複数ファイルあっても同一パッケージ間なら特にimportなどなく参照しあえる
    • 当然、パッケージスコープに同名の変数/型/関数などは複数定義できない。
  • mainパッケージがexecutableとしてビルドできる
  • mainパッケージのmain関数がエントリーポイントになる
  • mainパッケージ以外をビルドするとパッケージの型チェックができる。
  • mainパッケージは任意のパスに任意の個数用意できる。
    • go build ./path/to/main/package/でディレクトリ指定でビルドする
      • ./で指定する。path/to/main...という感じで./を省略するとstd libraryを指定しているとgo toolに思われる。
  • main以外のパッケージはfully-qualified pathで(<<module-name>>/path/to/directory)importできる。
  • 外部のモジュールはgo getで取得することができる。

エントリーポイントを作成する

先ほどクローンしたディレクトリで、以下のようにファイルを作成し、Go moduleを初期化しましょう

説明した目標に反してgit repositoryの直下じゃなくてサブディレクトリにモジュールを作っています。これはこの記事向けのスニペットをまとめて同じrepositoryに置きたい筆者の都合です。
なので対象読者はパスはいい感じに読み替えて都合のいいパスで実行してください。

mkdir mod-organization
cd mod-organization
go mod init github.com/ngicks/go-basics-example/mod-organization

go mod init実行後に以下のファイルが作成されたと思います

go.mod
module github.com/ngicks/go-basics-example/mod-organization

go 1.22.0

このファイルが、モジュールの名前、モジュールが作成されたgo version, モジュールが動作するのに想定するgo toolchain、このモジュールが依存するほかのgo moduleなどを記録するファイルとなります。
対象読者にはpyproject.tomlpackage.jsondeno.jsonに近いものというとわかりやすいかもしれません。

このファイルはgo getgo mod tidyなどのコマンドに編集してもらうことになるので、手で編集することは少ないです。

エントリーポイントを作成します。

mkdir -p cmd/example
touch cmd/example/main.go

ファイルの中身を以下のようにします。

cmd/example/main.go
package main

import "fmt"

func main() {
  fmt.Println("Hello world")
}

main packageのmain関数がエントリーポイントとなります。
実行ファイルの処理内容はここからすべて呼び出されるべきという関数です。
トップレベル関数でfunc init()が定義されているとそちらが先に実行されるのでこれが最初に実行される関数というわけではありません。

以下のコマンドでビルドすることができます。

# go build ./cmd/example

linux/amd64で実行すると./exampleが出力されます

もしくは以下のコマンドで実行します

# go run ./cmd/example
Hello world

go runはOS依存のtmpディレクトリにビルドして実行するショートハンド的コマンドで、毎回ビルドしてしまうので複数回実行したい場合はgo buildしたほうが良いことが多いでしょう。

ちなみに、以下ではダメです。

# go build cmd/example
package cmd/example is not in std (/usr/local/go/src/cmd/example)
# go help packages
...
An import path that is a rooted path or that begins with
a . or .. element is interpreted as a file system path and
denotes the package in that directory.

Otherwise, the import path P denotes the package found in
the directory DIR/src/P for some DIR listed in the GOPATH
environment variable (For more details see: 'go help gopath').
...

とあるように、/C:\...から始まらないパスは$(GOPATH)以下にあるかのように解決されてしまうからです。

以下の場合はエラーなく実行できますが、パッケージが複数のファイルを含む場合うまくビルドできないことを筆者は確認しています。

# go run cmd/example/main.go
Hello world

つまり、cmd/example以下にファイルを足してcmd/example/main.goがそれを参照するようにすると

# cat << EOF > cmd/example/other.go
> package main
>
> var Foo = "foo"
> EOF
cmd/example/main.go
package main

import "fmt"

func main() {
-  fmt.Println("Hello world")
+  fmt.Println("Hello world", Foo)
}

以下のような感じでエラーを吐きます。

実はファイルリストだったら実行できるんですが

go run ./cmd/example/main.go ./cmd/example/other.go
Hello world foo

ファイルが増えると困りますよね?
なのでファイルパスじゃなくてパッケージで指定するとよいでしょう。

# go run ./cmd/example
Hello world foo
# go run ./cmd/example/main.go
# command-line-arguments
cmd/example/main.go:6:29: undefined: Foo
# go run cmd/example/main.go
# command-line-arguments
cmd/example/main.go:6:29: undefined: Foo

相対パスでビルドを行う場合は./を必ず含めて、パッケージ名で指定するとよいでしょう。

パッケージを分ける

モジュールの下にはパッケージという分割単位があり、Go moduleではこれはディレクトリ(フォルダー)と一致します。

1つのディレクトリは1つのパッケージしか持てません。
ただし例外としてテスト用のパッケージは定義可能で、パッケージ名に_testというサフィックスをつけて定義します。

パッケージは内部の処理の関心を強く反映しているのがよいとされます。関心によってパッケージを分けましょう。

また、パッケージ名とディレクトリ名は一致しているのが望ましいとされます。
慣習的にパッケージ名は1語で済む程短いほうが良いとされます。
複数単語を含む場合でも_-でつながず、some_packageの代わりにsomepackageを用いるのがよいとされます。

同じパッケージ内のファイルはネームスペースを共有しています: つまり別のファイルに同名の関数は定義できないし、別のファイルの関数や変数を利用可能です。

mkdir pkg1 pkg2
touch pkg1/some.go
touch pkg2/other.go

ファイルを以下のようにします

pkg1/some.go
package pkg1

var Foo = "foo"
pkg2/other.go
package pkg2

import (
  "fmt"

  "github.com/ngicks/go-basics-example/mod-organization/pkg1"
)

func SayDouble() string {
  return fmt.Sprintf("%q%q", pkg1.Foo, pkg1.Foo)
}

上記のように、ほかのパッケージで定義した内容を利用するには、import宣言内で、fully qualifiedなパッケージパスを書くことで、インポートします。

Go moduleは、循環インポートを許しません。つまり

pkg1/some.go
package pkg1

+import "github.com/ngicks/go-basics-example/mod-organization/pkg2"

var Foo = "foo"

とすると以下のように import cycle not allowed エラーによりビルドできません。

# go vet ./...
package github.com/ngicks/go-basics-example/mod-organization/pkg1
        imports github.com/ngicks/go-basics-example/mod-organization/pkg2
        imports github.com/ngicks/go-basics-example/mod-organization/pkg1: import cycle not allowed

モジュールを取得する(go get)

以下のコマンドドキュメントを参考にすると

https://pkg.go.dev/cmd/go

go get <<fully-qualified-module-path>>

で、モジュールを取得し、go.modgo.sumを編集できます。

例えば

# go get github.com/samber/lo

を実行すると以下のようにgo.modgo.sunmにモジュール情報が追記されます。

go.mod
module github.com/ngicks/go-basics-example/mod-organization

go 1.22.0

+require (
+	github.com/samber/lo v1.39.0 // indirect
+	golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
+)
go.sum
+github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
+github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
+golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
+golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=

importで各ソースコードにモジュールを導入して使用できるようになります。

cmd/example/main.go
package main

- import "fmt"
+import (
+	"fmt"
+
+	"github.com/samber/lo"
+)

func main() {
	fmt.Println("Hello world", Foo)
+	fmt.Println(lo.Without([]string{"foo", "bar", "baz"}, "bar"))
}
# go run ./cmd/example
Hello world foo
[foo baz]

バージョンを指定するためには以下のいずれかで指定します。
末尾の2つは全く同じ効果をもたらすので、git-commit-hashで指定するほうが簡単だと思います。

go get <<fully-qualified-module-path>>@latest
go get <<fully-qualified-module-path>>@v1.2.3
go get <<fully-qualified-module-path>>@<<git-commit-hash>>
# v0.0.0-<<commit-date-time>>-<<commit-hash>>
go get <<fully-qualified-module-path>>@v0.0.0-20230723110635-fd0b45653fa9

git-commit-hashによるバージョン指定はgithub.comとセルフホストのgitlabでは動作しました。
しかしどこにドキュメントされているのかよくわかりませんので100%確証はないです。

モジュールの構成

https://go.dev/doc/modules/layout

  • 実行ファイルを作成するのが主眼となるモジュールはトップディレクトリがメインパッケージになることが多い
  • ライブラリとしてインポートすることもできるが、実行ファイルも提供する場合は以下のような構成になることが多い

実際にはプロジェクトの規模や意図などによってどのようにコードをオーガナイズするとよりよくなるかは変わるので、
個別の議論は避け、ほかの記事に譲るものとします。

.
|-- cmd
| |-- command1
| |   |-- main.go
| |   `-- other_files.go
| `-- command2
|     |-- main.go
|     `-- other_files.go
|-- package_dir (名前はふさわしいものにする)
|   `-- package.go
|-- go.mod
|-- go.sum
`-- lib.go(名前はモジュールにふさわしい何かにする)

Private repositoryでソースをホストする場合

https://go.dev/ref/mod#private-modules

上記の説明より、一般公開されない、つまり特別な認証が必要なVCSでソースを管理し、go getなどでモジュールをインポート/ダウンロードする場合、

  • GOPRIVATEの設定
  • そのVCSのcredentialの適切な保存
    • gitの場合gitが読み込めるなにか
    • .netrc

を行う必要があります。

GORPIVATEとcredentialを設定する

GOPRIVATE

GOPRIVATEの設定は以下で行います。

# git repositoryのURIが https://example.com/base_path
# である場合、<<url_wo_protocol>>は`example.com/base_path`になります。
go env -w GOPRIVATE=<<url_wo_protocol>>

(環境変数で指定すればよいと書かれていますが、筆者はうまくいかなかったのでgo env -wで書き込んでいます。)

GONOPROXY, GONOSUMDB(NOであることに注意)を設定しない場合、GOPRIVATEがデフォルトとして使われます。
GONOPROXYに設定されたホストからのモジュール取得する(direct mode)際には相手VCSに合わせたコマンドが使用されます(gitの場合gitコマンド -> modfetch)。そのため、credentialの設定も多くの場合必要になります。

credential

credentialは以下の2パターンで利用されます

  • gitコマンドを利用するとき
    • gitコマンドが自身の設定に基づき読み込む
  • http(s)で、VCSからgo moduleのメタデータを利用するとき
    • .netrc(windowsでは_netrc)ファイルが読み込まれる
    • なくても動くかもしれないので、エラーしたら設定するぐらいでいいと思います

git credentialの適切な保存には筆者はGit Credential Managerを利用しています。

  • windowsの場合、Git for windowsに付属してきますので、インストールオプションで一緒に入れます。
  • linuxの場合,Install instructionsに従いセットアップを行います。

vscodeの各種Remote extensionがgit credentialのヘルパーをつないでホスト環境につなげてくれるような挙動をしますので、
wslなどの場合はそちらを利用すればwincredにcredentialの保存が簡単にできます。

.netrcはネットワークの認証情報を保存しておくファイルらしく、man pageを検索するといくつかのコマンドがそれらを尊重するのがわかります。
フォーマットはIBMの「.netrc ファイルの作成」を参考にしてください。
${HOME}/.netrcあるいは${NETRC}にあるのが想定されるので適切に配置してください。

.netrcgitコマンドからも読み込まれますが、http GET <<module-uri>>?go-get=1でgo moduleのメタデータを取得しに行く時にも読み込まれます(auth)。こっちはなくても動作するかもしれませんので、エラーしない限りは設定しないほうがいいかもしれないですね(平文なので)。

git-lfsを導入している場合はすべての環境でgit-lfsを使うように気を付ける

git-lfsというよりは導入有無でfetch結果のファイルコンテンツが変わってしまうプラグイン全般なのですが。

上記のような設定でdirectモードでGo moduleが取得される場合、
git-lfsの導入有無でgitからのfetch後の内容が異なることがあります。
これによってsum照合エラーでgo mod downloadが失敗する現象を何度か体験しています。

基本的にはすべての環境(Dockerfileなども含む)でgit-lfsを導入しておくほうがよいでしょう。

Git Large File Storagegitで大きなファイルを取り扱うための拡張機能です。
git-lfsはhookとfilterを活用してコミット前後でトラック対象のファイルをテキストファイルのポインターに変換し、
トラックされた大きなファイルはremote repositoryではなく大容量ファイル用のサーバーに上げるような挙動になります。(参考: https://github.com/git-lfs/git-lfs, Git LFS をちょっと詳しく)

github, gitlab双方ともgit-lfsに対応しています。

開発の経緯的に、想定された用途ははゲームなどで大きなバイナリファイルを一緒に管理することのようです。
それ以外でもテスト用の大きなファイルを管理するときなどにも使うことがあると思います。

Dockerfile

Dockerfileのexample.

dockerを使うとアプリをパッケージ化して送り込んだりするのが楽になります。
プロジェクト構成の話に近いと思うので、ここに載せておきますが実際上違った方法をとったり(e.g. koBazel)、対象読者にとって早すぎる話題かもしれないのでいったん読み飛ばしていただくのもよいかもしれません。

また

  • 暗黙的にUbuntu/Debian系のコマンド/ファイル配置が前提になっているので定義読み替えたり書き換えてください
    • 差を考慮しきれるほど筆者はlinuxに詳しくありません。申し訳ないです。

dockerの軽い紹介

dockerContainer -- アプリケーションとその依存関係をパッケージ化したもの -- のビルダー及びランタイムおよびエコシステムです。

dockerを使うと、アプリケーションを送り込むのが楽になります。
言ってしまえば.tar.gzの1ファイルをdockerのdaemon(dockerd)に投げつけると、アプリと起動コマンドを送り込むことができて、その後、少しずつ設定を変えながらそのアプリケーションを何個か立ち上げる、みたいなことができます。(tarでも送り付けられるが)実際はコンテナを効率的に送りあうための仕組みや公開のためのレジストリなど、多岐にわたる概念の集合体がdocker、もしくはOCI containerです。
詳しい説明はほかの記事やdocker自体のドキュメントに譲ります。

Dockerfileは、そういうContainerのひな型となるImageをビルドするためのレシピを記述できるものです。

docker(およびcontainerd)自体もGoで書かれているので読んでみると面白いと思います。筆者はちょっとしか読めていません。

goをビルドするDockerfile example

以下にGoをstatic binaryにビルドするDockerfileの例を示します。
Dockerfileをまず述べ、各変数とbuildkitのマウントの各パラメータの意味を述べ、ビルドコマンドなどをその後に述べます。

  • 企業プロキシの裏にいてもビルドできるようにします。
  • private repository管理のgo moduleがあってもビルドできるようにします。
  • ほぼすべてがキャッシュに乗るので初回以降はビルド時間のほとんどがdockerのメタデータ解決時間です。
  • apt-getを使いますが、この部分はキャッシュしません。distro/バージョンで差が大きそうな気がしてます。

筆者はおおむねこれでうまくいっていますが、何かがあれば、static binaryに実はならないとか、そういった問題点があるかもしれないので、読者の環境に向けてカスタマイズする必要があるのは当然述べておくべきでしょう。

コードはここに置いてあります: https://github.com/ngicks/go-basics-example/tree/main/dockerfile

Dockerfile

# syntax=docker/dockerfile:1.4

# 上記で新しいsyntaxであることをビルダーに伝える。
# 新しい構文を使うとき、
# なぜかなくても動いたり動かなかったりする環境があってややこしいので
# とりあえず書く。

FROM golang:1.22.3-bookworm AS builder

ARG HTTP_PROXY
ARG HTTPS_PROXY
ARG GOPATH=/go
ARG CGO_ENABLED=0
ARG MAIN_PKG_PATH=.

# WORKDIRの決め方やビルドしたバイナリの置き場所はこれがいいよという自信がない。
# 必要に応じて変えてください。
WORKDIR /usr/local/container-bin/src
# git-lfsの有無でgit fetch結果が異なり、sum照合エラーになることがある。
# Private go moduleをdirect modeでgo getするならば、すべての環境に入れておくほうが安全。
# apt-getでバージョン指定をするとすぐに古いパッケージが消えるのでバージョンは固定しない。
# バージョンを固定したい場合はdebファイルを保存して
# そこからインストールしたり、ソースからビルドする。
RUN --mount=type=secret,id=certs,target=/etc/ssl/certs/ca-certificates.crt\
    apt-get update && apt-get install -yqq --no-install-recommends git-lfs
# 先にgo mod downloadを実行する
# buildkitでマウントするキャッシュ以外に変更が起きない。
# (/root/.cacheと/root/.config/goにマウントされるのでディレクトリは作成される)
# Dockerのimage layerとしてキャッシュするというより、
# コマンドの失敗する点を切り分けてエラーを見やすくする意図がある。
COPY go.mod go.sum ./
RUN --mount=type=secret,id=certs,target=/etc/ssl/certs/ca-certificates.crt\
    --mount=type=secret,id=.netrc,target=/root/.netrc\
    --mount=type=secret,id=goenv,target=/root/.config/go/env\
    --mount=type=cache,target=/go\
    --mount=type=cache,target=/root/.cache/go-build\
    go mod download
# COPY . .をしてしまうとbuildkitの遅延ファイル要求の利点がすっ飛びますが、全部送らざるを得ない
# ソース以外のコンテンツがいろいろ含まれる場合は、`.dockerignore`などをちきんと整備してください。
# https://docs.docker.com/build/building/context/#dockerignore-files
COPY . .
RUN --mount=type=secret,id=certs,target=/etc/ssl/certs/ca-certificates.crt\
    --mount=type=secret,id=.netrc,target=/root/.netrc\
    --mount=type=secret,id=goenv,target=/root/.config/go/env\
    --mount=type=cache,target=/go\
    --mount=type=cache,target=/root/.cache/go-build\
    go build -o ../bin ${MAIN_PKG_PATH}

# distrolessはtagの中身が入れ替わるので再現性を優先するならsha256で指定したほうがよい
FROM gcr.io/distroless/static-debian12@sha256:41972110a1c1a5c0b6adb283e8aa092c43c31f7c5d79b8656fbffff2c3e61f05

COPY --from=builder /usr/local/container-bin/bin /usr/local/container-bin/

ENTRYPOINT [ "/usr/local/container-bin/bin" ]

各変数の説明

Dockerfile中のARGはビルド時に--build-arg ${NAME}=${VALUE}で変数を引き渡せます。
各変数の名前と説明は以下に

変数 説明
HTTP_PROXY proxyがある場合に
HTTPS_PROXY 同上
GOPATH 基本は変えない
CGO_ENABLED 0にするとスタティックバイナリ
MAIN_PKG_PATH ビルド対象のパッケージパス

buildxのマウント機能を使って各種ファイルやキャッシュをマウントできます。
secret--secret id=${ID},src=/path/to/fileでファイルをマウントできます。
名前の通り機密情報(e.g. .netrc)をimageにコピーしないで利用できるようにするためのマウントなのですが、本来の用途に反して単純にファイルがマウントできる方法としても使っています。

それぞれの意味は以下に

mount type id 説明
secret cert PROXYがオレオレ証明書の場合root ca bundleを渡す
secret .netrc go getとかgit ls-remoteとかのための認証情報
secret goenv go env -wで生成できるファイル。GOPRIVATEとかを入れておく。
cache /go ほかになにも設定しなかったらgo getした内容がキャッシュされる
cache /root/.cache/go-build ビルドキャッシュがここに入るらしい
  • .netrcgitやgo toolそのものから読み込まれます。private gitlabなどにアクセス必要なとき渡しますが、いらないなら空のファイルでもいいです。
    • .netrcファイル自体のフォーマットはここなどを参考に
  • certはlinuxだとこのパスが問答無用で読み込まれるので、Ubuntu/Debian系以外でもこのパスでいいはずです。

ビルドコマンド

Dockerfileと同階層で以下のコマンドを./build.sh ${REPO}:${TAG}で実行することで、${REPO}:${TAG}docker imageをビルドできます

build.sh
#! /bin/sh

docker buildx build\
    --build-arg HTTP_PROXY=${HTTP_PROXY}\
    --build-arg HTTPS_PROXY=${HTTPS_PROXY}\
    --build-arg MAIN_PKG_PATH=${MAIN_PKG_PATH:-./}\
    --secret id=certs,src=/etc/ssl/certs/ca-certificates.crt\
    --secret id=.netrc,src=${DOTNETRC_PATH}\
    --secret id=goenv,src=$(go env GOENV)\
    -t $1\
    -f Dockerfile\
    .

キャッシュの効果

上記コマンドに--target=builderオプションを付け足してbuilderステージまでをビルドしてdiveで中身を検査してみましたが、モジュール、ビルドキャッシュともにキャッシュできていることがわかります。

dive-checking-go-cache-effectiveness

実行

ジョークなので./build.sh joke:jokeでイメージをビルドしました。
実行してみると正常に動作しています。

$ docker container run --rm joke:joke
🐤< コンニチハ! ₍₍⁽⁽ 🐧₎₎⁾⁾ ₍₍⁽⁽🐔₎₎⁾⁾ ₍₍⁽⁽🐣₎₎⁾⁾ ₍₍⁽⁽🐓 ₎₎⁾⁾

鳥が踊ります。

おわりに

公式が提供する読み物を紹介し、モジュールを作って実行する手続きと、追加の話題としてGo向けのDockerfileを紹介しました。

private repositoryでモジュールを作るときの手順は筆者は割と躓いたので重点的に述べておきました。

  • 筆者はgitlabでしかprivate go moduleを作ったことがないので、別な環境では別な躓き方をするかもしれません。
    • なのでなるだけgo getコードの内部的な現象にフォーカスを当てました。応用が効けばいいのですが・・・。
  • 企業Proxyのかかった環境でdocker image buildするのも結構大変だったので、これも述べておきました。
    • まだ足りない何かがあったらぜひ教えてほしいです。

なるだけ資料をあたり、リファレンスとソースを読み込んで情報を集めましたが、
扱う話題が広いので間違ってたり、もっといい方法がある可能性も十分あると思います。

GitHubで編集を提案

Discussion