// 型名が入っている
var userIDList []string
// Go らしい
var userIDs []string
i、CSV の1行の line)Id や Url ではなく、ID や URL と書く。だから userId ではなく userID になる。静的とは、プログラムを動かす前(コンパイルのとき)に、すべての変数の(宣言した)型がもう決まっていること。
ids := []string{"u1", "u2"} // この時点で ids は []string に決まる
ids = 42 // コンパイルエラー。実行する前に止まる
JavaScript のような動的型付けの言語では、型は実行するまで決まらない。だから名前に型を書いておく意味がある。Go では、コンパイラもエディタも型を知っている。名前に List と書くのは、同じ情報を2回書くことになる。
util・common・api のような何でも入りそうな名前は避ける。
Goはパッケージ名をコード上で関数の前に使うので、重なる内容は避ける。
http.Server // Go らしい
http.HTTPServer // http が2回出てくる
ingest というパッケージの中の型なら、IngestJob ではなく Job にする。呼び出す側からは ingest.Job と読める。
インターフェースは、メソッドが1つだけなら er を付ける(Read を持つ Reader、Write を持つ Writer)。メソッドが複数あるときは、目的が分かる名前にする(net.Conn、context.Context など)。
レシーバーは、メソッドの func (l *Loader) Load() の l の部分。
Go の const に置けるのは、コンパイルのときに値が確実に分かっているものだけ。言語のルールとしては、数値・文字列・真偽値の型(と、それを元にして作った型)しか定数にできない。だから、スライスやマップは置けない。長さが固定の配列([2]int)や構造体も置けない。
関数の引数も定数にできない。理由は、引数が実行するときに渡される値であること。関数を呼んだ結果(戻り値)も同じ理由でできない。
const maxRetry = 3 // OK
const tables = []string{"orders"} // コンパイルエラー
const ErrEmpty = errors.New("empty file") // コンパイルエラー(関数を呼んだ結果)
func f(n int) {
const c = n // コンパイルエラー(引数は実行時に決まる)
}
const batch = 500 のように型を書かない定数と、const limit int64 = 500 のように型を書く定数がある。
int64 の変数に | float64 の変数に | |
|---|---|---|
const batch = 500(型なし) | 入る | 入る |
const limit int64 = 500(型付き) | 入る | 入らない(コンパイルエラー) |
errors.New(...) で作るエラーは関数の結果なので、const にできない。
普通は var でパッケージに用意しておく。
var ErrEmptyFile = errors.New("ingest: file is empty")
type ingestError string
func (e ingestError) Error() string { return string(e) }
const ErrEmptyFile ingestError = "ingest: file is empty"
var は書き換えようと思えば書き換えられる。const なら書き換えられない。その代わり、型を1つ作る手間がかかる。
1.3 は iota で列挙型を作る話。今回は飛ばして、2章を読むのに要る最低限だけ押さえた。
type JobStatus int
const (
Pending JobStatus = iota // 0
Running // 1
Succeeded // 2
Failed // 3
)
const ( ) の中で、定数を1つ宣言するごとに 0, 1, 2… と増える数が iota。Go には enum が無いので、型と const と iota を組み合わせて代わりにする。
Go には try / catch のような例外の構文がない。エラーはただの値。error は組み込みのインターフェースで、Error() string を持つ型なら何でも error として扱える。エラーが無いことは nil で表す。
// 失敗しうる関数は、戻り値の最後を error にする
func parseRow(line string) (string, int, error) {
cols := strings.Split(line, ",")
if len(cols) != 2 {
return "", 0, fmt.Errorf("want 2 columns, got %d", len(cols))
}
qty, err := strconv.Atoi(cols[1])
if err != nil {
return "", 0, err // 失敗したら先に抜ける
}
return cols[0], qty, nil // ここまで来たら正常
}
func main() {
for _, line := range []string{"A-001,3", "A-002,x", "A-003"} {
sku, qty, err := parseRow(line)
if err != nil {
fmt.Println("skip:", err)
continue
}
fmt.Println(sku, qty)
}
}
変数の宣言には var と :=(短縮記法)の2つがある。var rows int のように値を書かないと、その型のゼロ値が入る。ゼロ値は、数値なら 0、文字列なら ""、スライスやポインターなら nil。page := 1 は宣言と代入を同時にして、型は右辺から決まる。
使い分けの基本は「値を入れて始めるなら :=」。var limit int = 500 は動くが、batch := 500 で足りる。
func main() {
// Goらしくない:動くが、型も値も書いていて長い
var limit int = 500
// Goらしい:右辺から int に決まる
batch := 500
fmt.Println(limit, batch) // 500 500
}
反対に、ゼロ値から数え始めるカウンターや、あとで append していくスライスは、var で宣言すると「ここではまだ空」という意図が伝わる。関数の外(パッケージ変数)では := が使えないので var を使う。
// 関数の外は var(:= は使えない)
var endpoint = "https://api.example.com/orders"
func main() {
page := 1 // 値を入れて始めるなら :=
var rows int // ゼロ値(0)から数え始めるなら var
var failed []string // まだ空(nil)。あとで append する
failed = append(failed, "A-002")
rows++
fmt.Println(endpoint, page, rows, failed) // https://api.example.com/orders 1 1 [A-002]
}
右辺と違う型にしたいときは、var limit int32 = 500 でも limit := int32(500) でも書ける。
スライスは、固定長の配列。len は「いま入っている要素数」、cap(容量)は「裏の配列に入る最大数」。append で容量を使い切ると、ランタイムはもっと大きな配列を確保し直し、中身をコピーして移る。
CSV の行数のように件数が先に分かっているなら、make([]Record, 0, n) で容量だけ先に確保する。
recs := make([]Record, 0, len(lines)) // 長さ0・容量は行数ぶん
for _, l := range lines {
id, amt, _ := strings.Cut(l, ",")
recs = append(recs, Record{UserID: id, Amount: amt})
}
fmt.Println(len(recs), cap(recs)) // 10000 10000
wrong := make([]Record, len(lines)) // 長さも10000になる
wrong = append(wrong, Record{UserID: "u0"})
fmt.Println(len(wrong)) // 10001(先頭に空の Record が1万個)
defer は、関数を抜けるときに後ろの処理を実行する予約。途中で return しても走る(os.Exit や log.Fatal で終わるときは走らない)ので、ファイルを開いた直後に defer f.Close() と書けば閉じ忘れが減る。落とし穴は2つある。
1つ目はループの中の defer。予約は「ループの1周」ではなく「関数」が終わるまで溜まる。1万ファイルを回せば、1万個が開きっぱなしになる。1周分を別の関数に切り出し、その中で defer すれば、1周ごとに閉じられる。
2つ目は Close のエラー。書き込み用のファイルは、Close のときに初めて失敗が分かることがある。defer f.Close() だけだと、そのエラーは捨てられる。拾うには戻り値に名前を付け(func f() (err error))、無名関数の defer の中で代入する。ここにさらに罠がある。err = w.Close() と素直に代入すると、本体で入っていたエラーが Close の nil で上書きされて消える。両方を残すには、Go 1.20 で入った errors.Join を使う(全部 nil なら nil を返す)。
// Goらしくない:Close の nil が本体のエラーを上書きする
func exportBad(w io.WriteCloser) (err error) {
defer func() {
err = w.Close()
}()
_, err = io.WriteString(w, "user_id,amount\n")
return err
}
// Goらしい:本体のエラーと Close のエラーを両方残す
func export(w io.WriteCloser) (err error) {
defer func() {
err = errors.Join(err, w.Close())
}()
_, err = io.WriteString(w, "user_id,amount\n")
return err
}
// 書き込みは失敗し、Close は成功する出力先に渡すと
// exportBad → <nil>(失敗が消える)
// export → disk full
Go の文字列は不変。作ったあとで中身を書き換えられない。だから q += cは、「q と c をつないだ新しい文字列」を毎回作り直している。ループで何百回も繰り返すと、途中の文字列が毎回メモリに確保されて遅くなる。
cols := []string{"user_id", "order_id", "amount", "ingested_at"}
// Goらしくない:+= のたびに新しい文字列を作り直す
q := "SELECT "
for i, c := range cols {
if i > 0 {
q += ", "
}
q += c
}
q += " FROM raw.orders"
// Goらしい:Builder に書き足して、最後に1回だけ文字列にする
var b strings.Builder
b.Grow(80) // 仕上がりの大きさの見込み
b.WriteString("SELECT ")
for i, c := range cols {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(c)
}
b.WriteString(" FROM raw.orders")
fmt.Println(b.String())
// SELECT user_id, order_id, amount, ingested_at FROM raw.orders
// 数個をつなぐだけなら1つの式で + でよい
table := "raw." + "orders" + "_" + "20261022"
Go は日付も時刻もタイムゾーンも、time.Time という1つの型で扱う。現在時刻は time.Now()、指定した日時は time.Date(年, 月, 日, 時, 分, 秒, ナノ秒, タイムゾーン) で作る。タイムゾーンは time.UTC・time.Local のほか、time.LoadLocation("Asia/Tokyo") で OS のタイムゾーンデータベースから引くか、time.FixedZone で時差を直接指定する。OS にデータベースが無いコンテナ向けには、time/tzdata を import してバイナリに同梱する方法もある。
文字列との変換は Format(Time → 文字列)と time.Parse(文字列 → Time)。書式に YYYY や %Y は使わない。「2006年1月2日 15時04分05秒」という基準の日時を、出したい形に並べて書く。月=1、日=2、時=3(15 なら24時間表記)、分=4、秒=5、年=6 と覚える。よく使う形は定数になっていて、Go 1.20 からは time.DateTime(2006-01-02 15:04:05)や time.DateOnly もある。CSV の時刻のように文字列にタイムゾーンが書かれていないとき、time.Parse は UTC として読む。JST のつもりなら time.ParseInLocation でタイムゾーンを渡す。
時間の長さは time.Duration で表す。int64 を元にした型で、1 が1ナノ秒。5 * time.Minute のように定数と掛けて作る。設定ファイルから読んだ int の秒数は型が違うので、そのままでは掛けられない。time.Duration(n) * time.Second と変換してから掛ける。過去の時刻は、マイナスの Duration を Add に渡して出す。
jst := time.FixedZone("JST", 9*60*60)
t := time.Date(2026, 10, 22, 14, 5, 9, 0, jst)
fmt.Println(t.Format("2006-01-02 15:04:05")) // 2026-10-22 14:05:09
fmt.Println(t.Format("20060102")) // 20261022(テーブルの日付サフィックス)
s := "2026-10-22 14:05:09" // CSV の値。タイムゾーンが書かれていない
u, _ := time.Parse(time.DateTime, s)
fmt.Println(u.Location()) // UTC(JST のつもりでも UTC として読まれる)
j, _ := time.ParseInLocation(time.DateTime, s, jst)
fmt.Println(j.Location()) // JST
timeoutSec := 90 // ジョブ設定から読んだ int
timeout := time.Duration(timeoutSec) * time.Second
fmt.Println(timeout) // 1m30s
fmt.Println(t.Add(-timeout).Format(time.TimeOnly)) // 14:03:39(90秒前)
最後に月の計算。AddDate(年, 月, 日) で月を足せるが、存在しない日付は自動で繰り上がる(正規化)。1月31日に1か月足すと「2月31日」になり、それが3月3日に直される。月次バッチで気づきにくいバグになる。
closing := time.Date(2026, 1, 31, 0, 0, 0, 0, jst)
// Goらしくない:2月を飛び越える
fmt.Println(closing.AddDate(0, 1, 0).Format(time.DateOnly)) // 2026-03-03
// Goらしい:正規化を逆手に取る。「翌々月の0日」=翌月の末日
y, m, _ := closing.Date()
end := time.Date(y, m+2, 0, 0, 0, 0, 0, jst)
fmt.Println(end.Format(time.DateOnly)) // 2026-02-28
この書き方が返すのは、元の日にかかわらず常に翌月の末日。1月15日から始めても2026-02-28 になるので、月末締めの日付から次の締め日を出すときにだけ使える。