このページの本文へ

Goならわかるシステムプログラミング 第2回

低レベルアクセスへの入り口(1):io.Writer

2016年10月05日 18時30分更新

文● 渋川よしき、編集●鹿野桂一郎

  • この記事をはてなブックマークに追加
  • 本文印刷

 今回は、Go言語がOS直上の低レイヤーを扱いやすくするために提供している io.Writer インタフェースの紹介をします。Go言語がシステムプログラミングを簡単に行える言語でありつつも、それなりに少ない記述量で比較的高速で、それでいて多くのことが達成できるのは、これから説明するようなインタフェースにより、低レイヤーが扱いやすい構造になっているからです。インタフェースと、インタフェースに対して提供されるさまざまなサービス関数が、Go言語の安い、早い、うまいの秘密です。

io.WriterはOSが持つファイルのシステムコールの相似形

 前回の記事では "Hello World!" プログラムの関数呼び出しをデバッガーでたどり、最後にシステムコール syscall.Write() が呼び出されているようすを見ました。OSでは、このシステムコールを、ファイルディスクリプタと呼ばれるものに対して呼びます。

 ファイルディスクリプタは一種の識別子(数値)です。この数値を指定してシステムコールを呼び出すと、数値に対応するモノにアクセスできます。実際、前回最後に見たシステムコール syscall.Write() もそのように利用されていました。引数の f.fd が、ファイルディスクリプタに相当します。

func (f *File) write(b []byte) (n int, err error) {
    for {
        bcap := b
        if needsMaxRW && len(bcap) > maxRW {
            bcap = bcap[:maxRW]
        }
        m, err := fixCount(syscall.Write(f.fd, bcap))
        n += m
        :
    }
}

 ファイルディスクリプタに対応するモノは、通常のファイルに限られません。標準入出力、ソケット、OSやCPUに内蔵されている乱数生成の仕組みなど、本来ファイルではないものにもファイルディスクリプタが割り当てられ、どれもファイルと同じようにアクセスできます。

OSのカーネルではいろいろなものが「ファイル」として抽象化されている

 ファイルディスクリプタは、OSがカーネルのレイヤーで用意している抽象化の仕組みです。OSのカーネル内部のデータベースに、プロセスごとに実体が用意されます。OSは、プロセスが起動されるとまず3つの疑似ファイルを作成し、それぞれにファイルディスクリプタを割り当てます。0が標準入力、1が標準出力、2が標準エラー出力です。以降は、そのプロセスでファイルをオープンしたり、ソケットをオープンしたりするたびに、1ずつ大きな数値が割り当てられていきます。

 このようにPOSIX系OSでは、可能な限りさまざまなものが「ファイル」として抽象化されています。ただし同じPOSIX系OSでも、Windowsだとソケットはファイルとして扱えなかったり、ファイル出力とコンソール出力でAPIが違っていたりして、システムによる違いも少なからずあります。そこでGo言語では、ファイルディスクリプタのような共通化の仕組みを言語レベルで模倣して整備し、OSによるAPIの差異を吸収しています。その一例が、今回の記事で取り上げる io.Writer です。

 システムプログラミングという連載タイトルから、ファイルディスクリプタを生で使ってシステムコールを扱ったりカーネルドライバを書いたりする方法を期待する人もいると思います。しかし今回の記事の主題は、カーネルのレイヤーで用意されているのと同じ仕組みを少し上級のレイヤーで言語レベルで模倣している io.Writer を見ることで、Go言語のインタフェースという仕組みを理解することです。

Go言語でも、直接ファイルディスクリプタを指定してファイルを作り出す関数はあります。

file, err := os.NewFile(ファイルディスクリプタ, 名前)

このあたりは個別のファイル入出力、ネットワーク入出力について紹介するときに掘り下げていきたいと思っています。

io.Writerは「インタフェース」

 前回の記事でシステムコール syscall.Write() が呼び出されていたのは、 os/file.go で次のように定義されている、Write() という関数からでした。

func (f *File) Write(b []byte) (n int, err error) {
    if f == nil {
        return 0, ErrInvalid
    }
    n, e := f.write(b)
    :
}

 このように名前の前に型を付けて定義されている関数は、その型に対する メソッド です。つまりこの定義からは、 WriteFile という型のデータに対するメソッドであることがわかります。

 その File は、通常のファイルを表すような型で、os パッケージで定義されている 構造体 です。Go言語における構造体は、複数のデータ型を集めたデータ型で、いわば「データの塊」だといえます。

 さらに、型名にアスタリスク「*」が付いて *File となっていますが、これは構造体そのものではなく、そのポインタに対してメソッドが呼ばれることを意味しています。ポインタと聞いて尻込みする人もいるかもしれませんが、怖がる必要はありません。ポインタが初心者殺しの異名を持つC言語とは異なり、Go言語では構造体がポインタであってもフィールド(構造体に含まれる各データ型)へのアクセスの方法は変わりませんし(いずれも「.」演算子が使えます)、ガベージコレクタが適切に掃除してくれるため、ポインタであることを意識しないと読めないプログラムはあまりないでしょう。

 さて、 os パッケージの File 型には Write というメソッドが定義されている、ということがわかったところで、この Write の仕様に注目してみましょう。 (f *File) の部分は、この定義が File 型の構造体へのポインタ f に対するメソッドであることを示します。次の Write (b []byte) (n int, err error) の部分は、「[]byte 型のデータを引数として受け取って int 型と err 型を返す」と読みます。 []byte はバイト列、 int は整数、 err はエラーをそれぞれ表します。つまりこの Write は、f が指し示すファイルに対してバイト列 b を書き込み、書き込んだバイト数 n と、エラーが起きた場合はそのエラー error を返す、といえます。

Goのバイト列と文字列

Goでは、バイト列と文字列を表す string 型(UTF-8形式ですが不正な文字コードの検出などはしてくれません)とは次のように相互に変換が可能です。

// byteArrayは[]byte{0x41, 0x53, 0x43, 0x49, 0x49}
byteArray := []byte("ASCII")
 
// strは"ASCII"
str := string([]byte{0x41, 0x53, 0x43, 0x49, 0x49})

 ここで、POSIX系OSでは可能な限りさまざまなものが「ファイル」として抽象化されているという前節の話を思い出してください。「バイト列 b を書き込み、書き込んだバイト数 n と、エラーが起きた場合はそのエラー error を返す」という振る舞いは、通常のファイルに限らず、さまざまなものに適用できそうですよね。そこで、同じ仕様のメソッドを持つ型を統一的に扱えると便利そうです。Go言語では、その場合に使える仕組みとして インタフェース という型が用意されています。

いろいろなものを「ファイル」のように扱うために、システムコールではファイルディスクリプタ、Goではインタフェースを使う

 構造体が「データの塊」であるのに対し、インタフェースは「メソッド宣言の塊」であるような型だといえるでしょう。そして、上記のような仕様の Write メソッドが宣言されているインタフェースが io.Writer なのです。実際に io.Writer インタフェースの定義を見てみましょう。


type Writer interface {
    Write(p []byte) (n int, err error)
}

 os/file.go では、まさにこの形式で、 Write*File のメソッドとして定義されていましたね。この節の冒頭で紹介した os/file.go における Write() の定義を再掲します。

func (f *File) Write(b []byte) (n int, err error) {
    if f == nil {
        return 0, ErrInvalid
    }
    n, e := f.write(b)
    :
}

 インタフェースで宣言されているすべてのメソッドが、データ型に対して定義されている場合、そのデータ型は「インタフェースを満たす」と表現します。いま見たように、 io.WriterWrite() メソッドの仕様が宣言されているインタフェースです。そして *File というデータ型には、その仕様通りに定義された Write() メソッドがあります。したがって、「*Fileio.Writer インタフェースを満たす」といえます 1

io.Writerを使う構造体の例

 io.Writer インタフェースを満たす構造体はGo言語のさまざまなところで実装されています。また、このインタフェースを利用する関数やメソッドもたくさん作られています。インタフェースは、構造体と違って何かしら実体を持つものを表すのではなく、「どんなことができるか」を宣言しているだけです。そこで次は、実際にどのようなものが io.Writer インタフェースを満たすのかを見ていきましょう。

異なるデータ型も、あらかじめ宣言されてる振る舞い(インタフェース)を満たしていれば、同じように扱える

 以降の例はすべて実働可能なサンプルですので、前回のようにデバッガーを使って中を覗いていくこともできます。もっとお手軽な方法としては、IntelliJの「Go To -> Declaration」(定義場所に移動)機能があります。キーワードを選択して、右クリックのコンテキストメニューから試してみてください(あるいはWindowsではCtrl+B、Macでは⌘+B)。プログラムを実行することなく行える方法なので、Go言語でプログラムを書くときには頼れる機能です。ただし、この方法でインタフェースの実体に飛ぶことはできないので、デバッガーのほうが確実ではあります。

通常、Goのプログラムは package main と書かれたパッケージ(ディレクトリ)に main 関数が1つだけの状態でないと実行ファイルをコンパイルできません。ただし、単独の1ファイルで完結したコードをIntelliJのRunコマンド(シフト+F10)やコマンドラインの「go run ソースファイル」で実行するときは、同じディレクトリ内に main 関数がいくつあっても実行できます。

ファイル出力

 まずは先ほどから見ている os.File です。 os.File のインスタンスは、os.Create()(新規ファイルの場合)や os.Open()(既存のファイルのオープン)などの関数で作ります。

package main
 
import (
    "os"
)
 
func main() {
    file, err := os.Create("test.txt")
    if err != nil {
        panic(err)
    }
    file.Write([]byte("os.File example\n"))
    file.Close()
}

 Write が受け取るのは文字列ではなくてバイト列なので、変換を行ってから Write メソッドに渡しています。実行すると、実行したフォルダに test.txt というファイルができて、os.File example という文字列が書き込まれます。日本語も使えます。

画面出力

 次は、前回の記事でも出てきた画面への出力です。前回の fmt.Println では、最終的に os.StdoutWrite メソッドを呼び出していました。それと等価なコードは次の通りです。

package main
 
import (
    "os"
)
 
func main() {
    os.Stdout.Write([]byte("os.Stdout example\n"))
}

書かれた内容を記憶しておくバッファ

 ファイルや画面出力のようなOSが提供する出力先に出すだけが io.Writer の機能ではありません。そのような例として Write() メソッドで書き込まれた内容を淡々とためておいて後でまとめて結果を受け取れる bytes.Buffer があります。

package main
 
import (
    "bytes"
    "fmt"
)
 
func main() {
    var buffer bytes.Buffer
    buffer.Write([]byte("bytes.Buffer example\n"))
    fmt.Println(buffer.String())
}

 この例では、いままでの例と同じように、バイト列に変換した文字列を Write() メソッドに渡しています。しかし bytes.Buffer には、特別に文字列を受け取れる WriteString というメソッドもあります。そのため、 Write() メソッドを呼んでいる行は次のように書き換えることもできますが、

buffer.WriteString("bytes.Buffer example\n")

 WriteStringio.Writer のメソッドではないため、他の構造体では使えません。代わりに、次の io.WriteString を使えばキャストは不要になります。

io.WriteString(buffer, "bytes.Buffer example\n")

インターネットアクセス

 実のところ io.Writer のような仕組みは大抵のプログラミング言語でも実装されているため、ここまでの例では物足りないと感じる方もいるでしょう。そこで次は io.Writer でインターネットにアクセスしてみます。この辺から少しずつGoらしさが出てきます。

 net.Dial() 関数を使うと、 net.Conn という通信のコネクションを表すインタフェースが返ってきます。 net.Connio.Writerio.Reader のハイブリッドなインタフェースなので、 io.Writer としても使うことができます。 net.Dial() 関数が返す net.Conn インタフェースの実体は、 net.TCPConn 構造体のポインタです。

package main
 
import (
    "io"
    "os"
    "net"
)
 
func main() {
    conn, err := net.Dial("tcp", "ascii.jp:80")
    if err != nil {
        panic(err)
    }
    conn.Write([]byte("GET / HTTP/1.0\r\nHost: ascii.jp\r\n\r\n"))
    io.Copy(os.Stdout, conn)
}

 実行してみると何らかのHTMLが画面に表示されます。なお、最後の行は io.Reader インタフェースを利用してサーバから返ってきたレスポンスを画面に出力しています。 io.Reader は次回紹介します。

 ちょっと高レイヤーになっていきますが、 http.ResponseWriter というものもあります。ウェブサーバから、ブラウザに対してメッセージを書き込むのに使います。

package main
 
import (
    "net/http"
)
 
func handler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("http.ResponseWriter sample"))
}
 
func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8080", nil)
}

 このプログラムを起動すると、 :8080 ポートでウェブサーバが起動するので、ブラウザでアクセスしてみてください。

 このように、相手がなんであろうと、 io.Writer インタフェースを使うと、どれも同じ Write() メソッドを使って書き出すことができます。

io.Writerのフィルタ

 io.Writer を受け取り、書き込まれたデータを加工して別の io.Writer に書き出す構造体もいくつかあります。次の io.MultiWriter は、複数の io.Writer を受け取り、書き込まれた内容をすべてに同時に書き込むフィルタです。

package main
 
import (
    "io"
    "os"
)
 
func main() {
    file, err := os.Create("multiwriter.txt")
    if err != nil {
        panic(err)
    }
    writer := io.MultiWriter(file, os.Stdout)
    io.WriteString(writer, "io.MultiWriter example\n")
}

 次のコードは書き込まれたデータをgzip圧縮して、あらかじめ渡されていた os.File に中継します。

package main
 
import (
    "compress/gzip"
    "os"
)
 
func main() {
    file, err := os.Create("test.txt.gz")
    if err != nil {
        panic(err)
    }
    writer := gzip.NewWriter(file)
    writer.Header.Name = "test.txt"
    writer.Write([]byte("gzip.Writer example\n"))
    writer.Close()
}

 ファイルの内容を加工するフィルタは圧縮以外にも、ハッシュ値の計算などがあります。

 出力結果を一時的にためておいて、ある程度の分量ごとにまとめて書き出す bufio.Writer というものもあります。メモリにためる io.Writer も名前は Buffer でしたが、さまざまな言語に登場する「バッファ付き出力」はこの構造体が機能を提供しています。 Flush() メソッドを呼ぶと、後続の io.Writer に書き出します。 Flush() メソッドを呼ばないと、書き込まれたデータを腹に抱えたまま消滅してしまうので要注意です。

package main
 
import (
    "bufio"
    "os"
)
 
func main() {
    buffer := bufio.NewWriter(os.Stdout)
    buffer.WriteString("bufio.Writer ")
    buffer.Flush()
    buffer.WriteString("example\n")
    buffer.Flush()
}

 Flush() を自動で呼び出す場合には、バッファサイズ指定の bufio.NewWriterSize(os.Stdout, バッファサイズ) 関数で bufio.Writer を作成します。

 C言語の場合は標準出力に書き出す printf() 関数はバッファリングを行います。標準エラー出力はリアルタイム性を重視したり、出力直後にプログラムが異常終了した場合にログが出力されないのを防ぐためにバッファリングをしない動作になっていました。Go言語の場合はどの出力もバッファリングを行いません。呼び出した回数だけシステムコールが呼び出されます。筆者の環境でベンチマークを取ったところ、バッファリングなし、2回に1回出力、10回に一回出力でそれぞれだいたい800ナノ秒、500ナノ秒、200ナノ秒程度でした。100回ぐらい出力する程度では、コマンドラインツールだと誤差の範囲でしょう。C言語ができた当時と比べると、OSのコードを呼び出して返ってくるまでのオーバーヘッドも大したことがないため、シンプルな実装にしたのかもしれません。

 フィルタは実装してみてもそれほど複雑ではありません。mattnさんのブログ 2 には、PascalCaseに変換しつつ書き出すフィルタの実装が紹介されています。

フォーマットしてデータをio.Writerに書き出す

 整形したデータを io.Writer へと書き出す汎用の関数もあります。 fmt.Fprintf() は、大抵のプログラミング言語にある、C言語の printf のようなフォーマット出力のための関数です。フォーマット(2つめの引数)に従って、io.Writer(最初の引数)にデータ(3つめ以降の引数)を書き出します。

 Go言語にはなんでも表示できる %v というフォーマット指定子があり、プリミティブ型でもそうじゃない型でもString() メソッドがあればそれを表示に使って出力してくれます。これも fmt.Stringer インタフェースとして定義されています。試しに日付を表示してみましょう。

package main
 
import (
    "os"
    "fmt"
    "time"
)
 
func main() {
    fmt.Fprintf(os.Stdout, "Write with os.Stdout at %v", time.Now())
}

 JSONを整形して io.Writer に書き出すこともできます。次の例ではコンソールに出力していますが、さきほどの io.Writer の例と組み合わせれば、サーバにJSONを送ったり、ブラウザにJSONを返すことも簡単にできてしまいます。

package main
 
import (
    "os"
    "encoding/json"
)
 
func main() {
    encoder := json.NewEncoder(os.Stdout)
    encoder.SetIndent("", "    ")
    encoder.Encode(map[string]string{
        "example": "encoding/json",
        "hello": "world",
    })
}

 用途がもっと限定された構造体もあります。 net/http パッケージの Request 構造体です。この構造体は、文字通りHTTPリクエストを取り扱う構造体です。クライアント側のリクエストを送るときにも使えますし、サーバ側でレスポンスを返すときにクライアントの情報をパースするのにも使えます。 io.Writer に書き出すのは前者の用途です。

 先ほど、サーバへのリクエストの通信の例ではHTTPプロトコルを手書きしましたが、この Request 構造体を使えばミスも減ります。この構造体の Write メソッドを使わないといけないケースは実際には少ないですが、Transfer-Encoding: chunked でチャンクに分けて送信したり、プロトコルのアップグレードで別のプロトコルと併用するようなHTTPリクエストを送るときには使うことになるでしょう(もっとも、そういったことをするケースもまれといえばまれですが)。

package main
 
import (
    "os"
    "net/http"
)
 
func main() {
    request, err := http.NewRequest("GET", "http://ascii.jp", nil)
    if err != nil {
        panic(err)
    }
    request.Header.Set("X-TEST", "ヘッダーも追加できます")
    request.Write(os.Stdout)
}

io.Writerの実装状況・利用状況を調べる

 Goでは、JavaのインタフェースやC++の親クラスと異なり、「このインタフェースを持っています」という宣言を構造体側には一切書きません。構造体がインタフェースを満たすメソッドを持っているかどうかは、インタフェースの変数に構造体のポインタを代入したり、メソッドの引数にポインタを渡したりするときに、自動的に確認されます。そのため、どの構造体がこのインタフェースを満たしているかは、コードを単純に検索するだけでは探せません。

 これを調べたいときに便利なツールが、Goをインストールしたときに一緒にインストールされるgodocというコマンドです。このツールは、golang.orgのパッケージのドキュメントをローカルでも見られるようにしてくれるツールです。ウェブサーバとして起動してブラウザで見るのが典型的な使い方ですが、起動時に次のように --analysis type を付けることでインタフェースの分析を行ってくれます。

godoc -http ":6060" -analysis type

 もしすでにGoを使っている方で、GOPATH以下にパッケージがたくさん入っている場合は、解析にものすごい時間がかかってしまいます。GOPATHを一時的に変更して実行するなどしてください。

# Linux/macOSなど
$ GOPATH=/ godoc -http ":6060" -analysis type
 
# Windows
> set GOPATH=C:\
> godoc -http ":6060" -analysis type

 ブラウザで http://localhost:6060 を開くと、次のような golang.org にそっくりのページが表示されます。このようにgodocはオフラインでドキュメントを見ることができて便利なので、覚えておいて損はありません。

 上段のリンクから「Packages」を選び、左側のパッケージ一覧の中段あたりから io を選んで開いてください。その後、インデックスの最後のほうにある Writer にジャンプしてください。直リンクは http://localhost:6060/pkg/io/#Writer になります。 -analysis type を付けて実行すると、golang.orgにはない、 implements という項目が追加されます。ここに io.Writer を実装した構造体などの一覧が表示されます。Go 1.7では標準ライブラリだけで176項目あります。

 io.Writer を使用している箇所を簡単に調べることはできませんが、引数として io.Writer を受け取る公開メソッド(fmt.Fprintln など)を簡単に検索しただけでも100以上見つかります。 net.Conn のような io.Writer を内包するインタフェースも数多くあるため、総数はさらに多いでしょう。

 io.Writer 関連の構造体がたくさんあるといっても、すべてを覚えておく必要はなく、そのつどリファレンスを引けばいいだけです。ですが、よく出てくるものは事前に存在を知って頭にインデックスを作っておくと、コードを読む際に楽ができるはずです。

次回予告

 io.Writer と対照の存在である io.Reader について紹介していきます。こちらも低レベルなデバイスに対して、より高級な読み込みの機能を提供します。


注釈

  1. Javaだとインタフェースの名前は I~able という命名規則で付けることが多いですが、Goの場合は Writer のように ~er ~or のような名前にすることが多いようです。?
  2. http://mattn.kaoriya.net/software/lang/go/20140501172821.htm?

筆者紹介――渋川よしき

 C++/Python/Goを趣味で書くIT系企業のプログラマー(横浜ベイスターズCS進出おめでとうございます)。3児の父。Sphinx-Users.jpのファウンダー。著書に『Mithril』(オライリー・ジャパン)、翻訳に『エキスパートPythonプログラミング』(アスキーMW)、『アート・オブ・コミュニティ』(オライリー・ジャパン)など。

編集者紹介――鹿野桂一郎

 ラムダノート株式会社 代表取締役、TechBooster CEO(Chief Editing Officer)。HaskellとSchemeとLaTeXでコンピュータとかネットワークとか数学の本を作るのをお手伝いする仕事。

カテゴリートップへ

この連載の記事
ピックアップ