標準ライブラリ概観
.NET (C#) の経験がある開発者向けに、fmt、io、encoding/json、time、os など主要な標準パッケージを紹介します。Go の標準ライブラリは小さな部品を組み合わせる設計で、特に io.Reader / io.Writer を中心とした抽象が、ファイル、ネットワーク、圧縮、ハッシュなどあらゆる場所でつながっています。BCL (Base Class Library) のように巨大なクラス階層を持たない代わりに、少数のインターフェースで多くのことを済ませます。HTTP サーバー、JSON、テスト、構造化ログまで標準で揃っているため、「まず標準で書いてみる」が Go の基本姿勢です。
1. このドキュメントで扱うこと
- io.Reader / io.Writer の抽象
- encoding/json とタグ
- time パッケージの注意点
- 「標準ライブラリで足りるか」の判断基準
io.Reader / io.Writer の抽象
Go の入出力は、次の 2 つの小さなインターフェースが基本です。
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
- ファイル (
*os.File)、ネットワーク接続、HTTP のリクエスト / レスポンスボディ、strings.Reader、bytes.Buffer、圧縮・暗号化・ハッシュなどが、これらを実装しています。 - C# の
Streamは読み書きとシークを 1 つの抽象クラスにまとめていますが、Go は役割ごとに分けます。io.Closer、io.Seeker、io.ReadWriterのように、必要な組み合わせをインターフェースの埋め込みで作ります。 Readは、データの末尾に達するとio.EOFを返します。io.EOFはエラー値ですが、異常ではなく正常な終端を表します。- よく使う補助関数は、
io.Copy(Reader から Writer へコピー)、io.ReadAll(全体を読み込み)、io.TeeReader、io.MultiWriter、io.LimitReader、bufio.Scanner/bufio.Reader(行や区切り単位の読み取り) などです。 - 関数の引数は、具体型 (
*os.File) ではなくio.Reader/io.Writerで受けると、テストでstrings.NewReaderやbytes.Bufferを差し込めます。
encoding/json とタグ
encoding/json は、構造体と JSON の相互変換を行います。System.Text.Json の属性にあたるものが、構造体タグです。
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Password string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
| タグ | 意味 |
|---|---|
json:"name" | JSON のキー名を指定する |
json:"name,omitempty" | 値がゼロ値 (空文字列、0、nil、空のスライスなど) の場合に出力を省略する |
json:"name,omitzero" | ゼロ値の場合に省略する (Go 1.24 以降。time.Time のような構造体のゼロ値にも有効) |
json:"-" | シリアライズの対象から外す |
json:",string" | 数値や真偽値を JSON の文字列として出力する |
- 公開されていないフィールド (小文字始まり) は、エンコード / デコードの対象になりません。
- 主な API は、
json.Marshal/json.Unmarshal(メモリ上のバイト列)、json.NewEncoder/json.NewDecoder(ストリーム) です。HTTP ボディやファイルには、Encoder / Decoder を使います。 - 型がわからない JSON は
anyに読み込むと、オブジェクトがmap[string]any、数値がfloat64になります。 json.RawMessageを使うと、一部のフィールドの解釈を後回しにできます。独自の形式はMarshalJSON/UnmarshalJSONメソッドで定義します。
time パッケージの注意点
time.Time は時刻、time.Duration は時間の長さを表します。DateTime / TimeSpan に近い役割ですが、いくつかの違いがあります。
- 書式は
yyyy-MM-ddのような記号ではなく、基準時刻Mon Jan 2 15:04:05 MST 2006を使って書きます。2006-01-02 15:04:05がyyyy-MM-dd HH:mm:ssに相当します。 - よく使う書式は、
time.RFC3339、time.DateOnly(Go 1.20 以降)、time.DateTime(Go 1.20 以降) などの定数として定義されています。 time.Durationはint64のナノ秒数で、2 * time.Secondのように定数との掛け算で作ります。time.Duration(n) * time.Secondのように、変数との掛け算では型変換が必要です。time.Timeは、タイムゾーン (Location) を内部に持ち、time.Now()はローカルのタイムゾーンで返ります。保存や比較ではUTC()にそろえるのが安全です。- 比較は
==ではなく、t.Equal(u)、t.Before(u)、t.After(u)を使います。==は、タイムゾーンや monotonic clock の情報まで比較してしまうためです。 - ゼロ値の
time.Time{}は、0001-01-01 00:00:00 UTC です。t.IsZero()で判定します。 - 経過時間の計測は
time.Since(start)を使います。time.Now()には単調増加クロックが含まれるため、壁時計が変更されても正しく計測できます。
「標準ライブラリで足りるか」の判断基準
Go では、サードパーティーのライブラリを足す前に、まず標準で書けないかを確認するのが一般的です。
| やりたいこと | 標準 | 外部を検討するケース |
|---|---|---|
| HTTP サーバー / クライアント | net/http | ルートのグルーピングが多い (chi など) |
| JSON | encoding/json | 非常に高い性能が必要 (高速な JSON ライブラリ) |
| 構造化ログ | log/slog (Go 1.21 以降) | 既存のログ基盤との連携 (zap、zerolog など) |
| テスト | testing、net/http/httptest | アサーションの簡略化 (testify)、モック生成 |
| コマンドライン引数 | flag | サブコマンドが多い CLI (cobra など) |
| 設定ファイル / 環境変数 | os.Getenv、encoding/json | YAML / TOML、階層的な設定 (viper など) |
| コレクション操作 | slices、maps (Go 1.21 以降) | 特殊なデータ構造 |
| データベース | database/sql | ORM やコード生成 (sqlc など) |
判断の目安は次のとおりです。
- 標準で数十行書ける程度なら、依存を増やさない方が長期的に保守しやすくなります。
- 標準が提供しているのは「基本的で互換性が保証された部品」です。Go 1 の互換性の約束により、標準のコードは長期にわたって動作し続けます。
- 外部ライブラリは、メンテナンスの状況、依存の数、ライセンスを確認してから採用します。
2. .NET との対応表
| .NET (C#) | Go |
|---|---|
| System.Text.Json | encoding/json |
| System.IO.Stream | io.Reader / io.Writer |
| DateTime / TimeSpan | time.Time / time.Duration |
| Console / String.Format | fmt |
上の表を補う対応関係です。
| .NET (C#) | Go |
|---|---|
File.ReadAllText / File.WriteAllText | os.ReadFile / os.WriteFile |
StreamReader.ReadLine | bufio.Scanner / bufio.Reader |
Stream.CopyTo | io.Copy |
MemoryStream | bytes.Buffer / bytes.Reader |
StringBuilder | strings.Builder |
string.Split / Join / Contains | strings.Split / strings.Join / strings.Contains |
int.Parse / int.TryParse | strconv.Atoi / strconv.ParseInt (エラーを戻り値で返す) |
Environment.GetEnvironmentVariable | os.Getenv / os.LookupEnv |
Environment.GetCommandLineArgs | os.Args / flag パッケージ |
Path.Combine | path/filepath.Join |
Regex | regexp (RE2 構文。後方参照や先読みは使えない) |
List<T>.Sort / LINQ の OrderBy | slices.Sort / slices.SortFunc |
Dictionary<TKey, TValue>.Keys | maps.Keys (イテレーターを返す。Go 1.23 以降) |
Stopwatch | time.Now() と time.Since |
Task.Delay / PeriodicTimer | time.After / time.NewTimer / time.NewTicker |
ILogger | log/slog |
Math.Max / Math.Min | 組み込みの max / min (Go 1.21 以降) |
Guid.NewGuid() | 標準にはなし (crypto/rand で生成するか、外部ライブラリを使う) |
| xUnit / NUnit | testing |
3. C# 開発者がつまずきやすいポイント
Stream と違い、Reader / Writer は小さく分かれている
読むだけなら io.Reader、書くだけなら io.Writer を受け取ります。シークが必要な場合に限って io.Seeker や io.ReaderAt を足します。必要最小限のインターフェースを引数に取るのが慣習です。また、Read は要求したバイト数より少なく返すことがあるため、全体を読むには io.ReadAll や io.ReadFull、ループを使います。
Close を忘れない
using のような仕組みはありません。os.Open や HTTP の resp.Body などは、取得の直後に defer f.Close() と書きます。書き込み用のファイルでは、Close が返すエラー (バッファのフラッシュに失敗した場合など) も確認する必要があります。
JSON のフィールド名は、タグがなければ構造体の名前のまま
System.Text.Json の JsonNamingPolicy.CamelCase のようなグローバル設定はありません。タグを付けなければ、Name というフィールド名のまま ("Name") 出力されます。デコード時は、大文字小文字を区別せずに一致するフィールドを探します。
ゼロ値と「未指定」を区別できない
omitempty は、0、空文字列、false なども省略します。そのため、「値が 0 である」と「値が指定されていない」を区別したい場合は、ポインター (*int) を使います。デコード時も、フィールドが JSON になかったのか、0 が入っていたのかの区別が付きません。
未知のフィールドは、デフォルトでは無視される
デコードの対象にない JSON キーは、黙って無視されます。厳密にしたい場合は、json.Decoder の DisallowUnknownFields を使います。
数値は any に読み込むと float64 になる
型を指定せずに any や map[string]any に読み込むと、数値はすべて float64 になります。大きな整数が精度を失うことがあるため、構造体を定義するか、Decoder.UseNumber を使います。
time.Time の書式は特別な基準日で書く
time.Now().Format("yyyy-MM-dd") は、期待通りに動きません。"2006-01-02" のように、基準時刻の各要素 (年は 2006、月は 01、日は 02、時は 15、分は 04、秒は 05) を使います。go vet の timeformat チェックは、2006-02-01 のように年月日の並びを取り違えた書式を検出します。
time.Duration の型変換
time.Sleep(n * time.Second) は、n が int 型だとコンパイルエラーになります。time.Duration(n) * time.Second と書きます。定数 (2 * time.Second) は型なし定数なので、そのまま使えます。
文字列は UTF-8 のバイト列
len(s) はバイト数で、文字数ではありません。for i, r := range s は rune (Unicode コードポイント) 単位で走査し、i はバイト位置です。日本語の文字数を数えるには、utf8.RuneCountInString(s) か []rune(s) を使います。
fmt.Println と slog の使い分け
fmt は書式付きの出力に使い、サービスの運用ログには、log/slog で構造化ログを出力します。slog は、キーと値のペアで属性を渡し、JSON などの形式で出力できます。
標準の log と slog
log パッケージは古い API で、レベルや構造化の機能がありません。新しいコードでは、slog を選びます。
4. コード例
例 1: io.Reader / io.Writer を組み合わせる
C#:
using var input = File.OpenRead("input.txt");
using var output = File.Create("copy.txt");
using var sha = SHA256.Create();
// コピーしながらハッシュを計算する場合は、手動でバッファーを読み書きする
var buffer = new byte[8192];
int n;
while ((n = input.Read(buffer, 0, buffer.Length)) > 0)
{
output.Write(buffer, 0, n);
sha.TransformBlock(buffer, 0, n, null, 0);
}
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
Console.WriteLine(Convert.ToHexString(sha.Hash!));
Go では、io.MultiWriter を使って、複数の Writer へ同時に書き込めます。
package main
import (
"crypto/sha256"
"fmt"
"io"
"os"
)
func copyWithHash(dstPath, srcPath string) (string, error) {
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return "", err
}
h := sha256.New() // hash.Hash は io.Writer を実装している
// dst と h の両方に書き込む Writer を作り、io.Copy でコピーする
if _, err := io.Copy(io.MultiWriter(dst, h), src); err != nil {
dst.Close()
return "", err
}
// 書き込みのエラーを取りこぼさないよう、Close のエラーも返す
if err := dst.Close(); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func main() {
sum, err := copyWithHash("copy.txt", "input.txt")
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Println(sum)
}
次は、bufio.Scanner で行ごとに読み込む例です。strings.NewReader を使うと、ファイルなしでも同じコードを試せます。
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func countLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
n := 0
for sc.Scan() {
fmt.Println(sc.Text())
n++
}
return n, sc.Err() // 読み取り中のエラーは Err で確認する
}
func main() {
n, err := countLines(strings.NewReader("one\ntwo\nthree\n"))
fmt.Println(n, err)
}
countLines は io.Reader を受け取るため、ファイルでも HTTP ボディでも標準入力 (os.Stdin) でも、そのまま使えます。
例 2: encoding/json の基本
C# (System.Text.Json):
public record User(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("email")]
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Email);
var json = JsonSerializer.Serialize(new User(1, "Alice", null));
var user = JsonSerializer.Deserialize<User>(json);
Go:
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email *string `json:"email,omitempty"` // nil なら出力しない
Password string `json:"-"` // 出力しない
CreatedAt time.Time `json:"created_at"`
}
func main() {
u := User{
ID: 1,
Name: "Alice",
Password: "secret",
CreatedAt: time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC),
}
// 構造体 -> JSON
b, err := json.MarshalIndent(u, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(b))
// JSON -> 構造体 (Decoder で未知のフィールドを拒否する)
in := `{"id": 2, "name": "Bob", "created_at": "2025-03-04T05:06:07Z", "extra": true}`
dec := json.NewDecoder(strings.NewReader(in))
dec.DisallowUnknownFields()
var got User
if err := dec.Decode(&got); err != nil {
fmt.Println("decode error:", err) // extra が未知のフィールドなのでエラー
}
// 型を決めずに読み込む場合、数値は float64 になる
var m map[string]any
if err := json.Unmarshal([]byte(`{"n": 1}`), &m); err != nil {
panic(err)
}
fmt.Printf("%T\n", m["n"]) // float64
// ストリームへの書き出し (HTTP レスポンスやファイルにも使える)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(map[string]int{"count": 3})
}
json.Marshal はエラーを返します。チャネルや関数など、JSON にできない型を含む場合や、MarshalJSON がエラーを返した場合などに発生します。
例 3: time パッケージ
C#:
var sw = Stopwatch.StartNew();
var timeout = TimeSpan.FromSeconds(30);
var t = DateTime.ParseExact("2025-01-02", "yyyy-MM-dd", CultureInfo.InvariantCulture);
Console.WriteLine(t.AddDays(7).ToString("yyyy-MM-dd HH:mm:ss"));
Go:
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
timeout := 30 * time.Second
// パース: レイアウトは基準時刻 2006-01-02 15:04:05 で書く
t, err := time.Parse("2006-01-02", "2025-01-02")
if err != nil {
panic(err)
}
// 加算とフォーマット
next := t.AddDate(0, 0, 7)
fmt.Println(next.Format("2006-01-02 15:04:05"))
fmt.Println(next.Format(time.RFC3339))
// 比較は Equal / Before / After を使う
fmt.Println(next.After(t), next.Equal(t))
// 時間の長さ
d := 90 * time.Minute
fmt.Println(d, d.Hours()) // 1h30m0s 1.5
n := 3
wait := time.Duration(n) * time.Second // 変数との掛け算は型変換が必要
fmt.Println(wait, timeout)
// ゼロ値の判定
var zero time.Time
fmt.Println(zero.IsZero())
// 経過時間の計測
fmt.Println(time.Since(start) < time.Second)
// タイムゾーンの扱い
jst := time.FixedZone("JST", 9*60*60)
fmt.Println(time.Date(2025, 1, 2, 9, 0, 0, 0, jst).UTC())
}
time.LoadLocation("Asia/Tokyo") で IANA のタイムゾーンデータベースを使うこともできます。実行環境にタイムゾーンデータがない場合 (最小のコンテナイメージなど) は、time/tzdata パッケージを空の import (import _ "time/tzdata") で組み込むと、バイナリに埋め込めます。
例 4: fmt、errors、os、flag
package main
import (
"errors"
"flag"
"fmt"
"io/fs"
"os"
)
type Point struct {
X, Y int
}
func main() {
// fmt の動詞
p := Point{1, 2}
fmt.Printf("%v\n", p) // {1 2}
fmt.Printf("%+v\n", p) // {X:1 Y:2}
fmt.Printf("%#v\n", p) // main.Point{X:1, Y:2}
fmt.Printf("%T\n", p) // main.Point
fmt.Printf("%q\n", "hello") // "hello"
// コマンドライン引数
name := flag.String("name", "world", "name to greet")
verbose := flag.Bool("v", false, "verbose output")
flag.Parse()
fmt.Printf("hello, %s (verbose=%v)\n", *name, *verbose)
// 環境変数: 未設定と空文字列を区別したいときは LookupEnv を使う
if v, ok := os.LookupEnv("APP_ENV"); ok {
fmt.Println("APP_ENV =", v)
}
// ファイル読み込みとエラーの判定
data, err := os.ReadFile("config.json")
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("config.json がありません。デフォルト設定を使います")
} else if err != nil {
fmt.Fprintln(os.Stderr, "読み込みに失敗しました:", err)
os.Exit(1)
} else {
fmt.Println(len(data), "bytes")
}
// エラーのラップ (%w)
wrapped := fmt.Errorf("load config: %w", fs.ErrNotExist)
fmt.Println(wrapped, errors.Is(wrapped, fs.ErrNotExist))
}
fmt.Errorfの%wでエラーをラップすると、errors.Isやerrors.Asで、元のエラーを判定できます。- 診断メッセージは
os.Stderrに出力し、異常終了ではos.Exit(1)のようにステータスコードを返します。
例 5: log/slog、slices、maps
C#:
logger.LogInformation("user {UserId} logged in from {Ip}", userId, ip);
var names = users.Select(u => u.Name).OrderBy(n => n).ToList();
var keys = dict.Keys.OrderBy(k => k).ToList();
Go:
package main
import (
"cmp"
"log/slog"
"maps"
"os"
"slices"
"strings"
)
type User struct {
Name string
Age int
}
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
// キーと値のペアで属性を渡す
slog.Info("user logged in", "user_id", 42, "ip", "192.0.2.1")
slog.Debug("cache miss", "key", "user:42")
// 属性を固定した子ロガー
reqLogger := logger.With("request_id", "req-1")
reqLogger.Warn("slow query", "ms", 1200)
users := []User{{"Carol", 30}, {"Alice", 25}, {"Bob", 25}}
// 年齢、次に名前の順で並べ替える (LINQ の OrderBy().ThenBy() に近い)
slices.SortFunc(users, func(a, b User) int {
return cmp.Or(
cmp.Compare(a.Age, b.Age),
strings.Compare(a.Name, b.Name),
)
})
slog.Info("sorted", "users", users)
// 含まれるかの判定
names := []string{"Alice", "Bob"}
slog.Info("contains", "result", slices.Contains(names, "Bob"))
// マップのキーを並べ替えたスライスに変換する (Go 1.23 以降)
m := map[string]int{"b": 2, "a": 1, "c": 3}
keys := slices.Sorted(maps.Keys(m))
slog.Info("keys", "keys", keys)
}
slogは、slog.Info("メッセージ", "キー", 値, ...)の形式で、キーと値を交互に渡します。cmp.Orは Go 1.22 以降で使えます。最初にゼロ値でない値を返すため、複数のキーでの並べ替えを簡潔に書けます。- マップの走査順序は不定です。出力を安定させたい場合は、キーを取り出して並べ替えます。
5. まとめ
- 標準ライブラリは、少数のインターフェースを中心に小さな部品を組み合わせる設計です。特に
io.Reader/io.Writerは、ファイル、ネットワーク、HTTP、圧縮、ハッシュをつなぐ共通の抽象です。 - 関数の引数は、具体型ではなく必要最小限のインターフェースで受けると、テストしやすく再利用しやすくなります。
encoding/jsonは構造体タグで制御します。公開フィールドのみが対象で、未知のフィールドは無視され、anyに読み込んだ数値はfloat64になります。timeは、基準時刻2006-01-02 15:04:05で書式を書き、比較にはEqual/Before/Afterを使います。Durationと変数の掛け算では型変換が必要です。deferでCloseを呼び、エラーは戻り値で扱い、%wでラップしてerrors.Is/errors.Asで判定します。- ログには
log/slog、コレクション操作にはslices/mapsなど、新しい標準パッケージを優先して使います。 - サードパーティーを足す前に、まず標準で足りるかを確認します。標準は Go 1 の互換性の約束により、長期的に安定しています。