このページの本文へ

前へ 1 2 次へ

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

低レベルアクセスへの入り口(3):io.Reader後編

2016年11月02日 09時00分更新

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

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

第3回の記事ではGo言語の io.Reader インタフェースの基本と、よく使う構造体を紹介しました。 Go言語が提供する低レベルの仕組みとの接点である io.Writerio.Reader 、それに、これらを利用するさまざまな関数や構造体を使って、いろいろな処理ができることが伝わったのではないでしょうか? 第2回から今回までの内容を把握していれば、ソケット通信(第6回あたりから紹介する予定)さえ恐れることはありません。

今回も、前回に引き続き io.Reader の関連機能を紹介していきます。 具体的には次のような内容です。いずれも少し低レベルな通信を直接行いたいときに便利な機能です。

  • バイナリ解析に便利な機能群
  • テキスト解析に便利な機能群
  • ちょっと抽象的な io.Reader の構造体

バイナリ解析用のio.Reader関連機能

io.Reader から出てくるデータは、テキストデータの場合もあればバイナリデータの場合もあります。 まずはバイナリデータを読み込むときに便利な機能から紹介します1

必要な部位を切り出すio.LimitReaderio.SectionReader

ファイルを読み込みたいけれど、先頭部分だけが必要なので、それ以降の領域は読み込みたくない、という場合があります。 たとえばファイルの先頭にヘッダー領域があって、そこだけを解析するルーチンに処理を渡したいといった場合です。 io.LimitReader を使うと、データがたくさん入っていても、先頭の一部だけしか読み込めないようにブロックしてくれます。

// たくさんデータがあっても先頭の16バイトしか読み込めないようにする。
lReader := io.LimitReader(reader, 16)

長さだけではなく、スタート位置も固定したいことがあります。 PNGファイルやOpenTypeフォントなど、バイナリファイル内がいくつかのチャンク(データの塊)に分かれている場合は、チャンクごとに Reader を分けて読み込ませることで、別々のチャンクを読み込むコード間の独立性が高まり、全体としてメンテナンスしやすいコードになるでしょう。 そのようなときに便利なのが io.SectionReader です。

ただし io.SectionReader では io.Reader が使えず、代わりに io.ReaderAt インタフェースを使います。 os.File 型は io.ReaderAt を満たしますが、それ以外の io.Reader を満たす型から io.SectionReader で直接に読み込むことはできません。 文字列やバイト列にいったん書き出し、 strings.Readerbytes.Reader でラップしてから io.SectionReader に渡します。

下記のコードでは、文字列から Section の部分だけを切り出した Reader をまず作成し、それをすべて os.Stdout に書き出しています(実際には文字列を分けるために io.SectionReader を使うことはまずありませんが)。

package main
import (     "io"     "os"     "strings" )
func main() {     reader := strings.NewReader("Example of io.SectionReader\n")     sectionReader := io.NewSectionReader(reader, 14, 7)     io.Copy(os.Stdout, sectionReader) }

エンディアン変換

バイナリ解析ではエンディアン変換が必要となります。 現在主流のCPU 2 はリトルエンディアンです(サーバや組み込み機器で使用されるCPUにはビッグエンディアンのものもあります)。 リトルエンディアンでは、10000という数値(16進表示で0x2710)があったときに、小さい桁からメモリに格納されます(Go言語で書けば []byte{0x10, 0x27, 0x0, 0x0} と表現されます)。

しかし、ネットワーク上で転送されるデータの多くは、大きい桁からメモリに格納されるビッグエンディアン(ネットワークバイトオーダーとも呼ばれます)です。 そのため多くの環境では、ネットワークで受け取ったデータをリトルエンディアンに修正する必要があるのです。

任意のエンディアンの数値を、現在の実行環境のエンディアンの数値に修正するには、encoding/binaryパッケージを使います。 このパッケージの binary.Read() メソッドに、 io.Reader とデータのエンディアン、それに変換結果を格納する変数のポインタを渡せば、エンディアンが修正されたデータが得られます。

下記のコードを実行すると、 data 変数にビッグエンディアンで格納されている10000という数値が、エンディアン違いのIntel系のCPUのコンピュータでも正しく10000と出力されます。

package main
import (     "bytes"     "encoding/binary"     "fmt" )
func main() {     // 32ビットのビッグエンディアンのデータ(10000)     data := []byte{0x0, 0x0, 0x27, 0x10}     var i int32     // エンディアンの変換     binary.Read(bytes.NewReader(data), binary.BigEndian, &i)     fmt.Printf("data: %d\n", i) }

PNGファイルを分析してみる

PNGファイルはバイナリフォーマットです。 先頭の8バイトがシグニチャ(固定のバイト列)となっています。 それ以降は次のようなチャンク(データの塊)のブロックで構成されています。

長さ 種類 データ CRC(誤り検知符号)
4バイト 4バイト 長さで指定されたバイト数 4バイト

各チャンクとその長さを列挙してみましょう。 以下のコードでは、 readChunks() 関数でチャンクごとに io.SectionReader を作って配列に格納して返しています。 それをチャンクを表示する関数(dumpChunk())で表示しています。

サンプルとして利用しているPNG画像は、コンピュータグラフィックス業界でこれ以上の有名人はいないという、レナ・ソーダバーグさん 3 の画像をお借りしました。

package main
import (     "encoding/binary"     "fmt"     "io"     "os" )
func dumpChunk(chunk io.Reader) {     var length int32     binary.Read(chunk, binary.BigEndian, &length)     buffer := make([]byte, 4)     chunk.Read(buffer)     fmt.Printf("chunk '%v' (%d bytes)\n", string(buffer), length) }
func readChunks(file *os.File) []io.Reader {     // チャンクを格納する配列     var chunks []io.Reader
    // 最初の8バイトを飛ばす     file.Seek(8, 0)     var offset int64 = 8
    for {         var length int32         err := binary.Read(file, binary.BigEndian, &length)         if err == io.EOF {             break         }         chunks = append(chunks, io.NewSectionReader(file, offset, int64(length)+12))         // 次のチャンクの先頭に移動         // 現在位置は長さを読み終わった箇所なので         // チャンク名(4バイト) + データ長 + CRC(4バイト)先に移動         offset, _ = file.Seek(int64(length+8), 1)     }     return chunks }
func main() {     file, err := os.Open("Lenna.png")     if err != nil {         panic(err)     }     chunks := readChunks(file)     for _, chunk := range chunks {         dumpChunk(chunk)     } }

実行すると次のように表示されます。

chunk 'IHDR' (13 bytes)
chunk 'sRGB' (1 bytes)
chunk 'IDAT' (473761 bytes)
chunk 'IEND' (0 bytes)

Wikipediaから取得できるレナさんのPNG画像は、このようなチャンクの構図になっているようですね。

Go言語で配列(厳密にはスライス)に要素を追加するには、

配列 = append(配列, 要素)

と書きます。 なお、多くのオブジェクト指向言語と違い、Go言語では配列やスライスはメソッドを持ったオブジェクトではありません (Go言語のメモリ管理についても将来紹介します)。

PNG画像に秘密のテキストを入れてみる

チャンクを追加してみましょう。

PNGには、テキストを追加するための tEXt というチャンクがあります。また、それに圧縮をかけた zTXt というチャンクもあります。 このようにPNG画像に埋め込まれたテキストは、データの中に埋め込まれるだけで、画像としては表示されません。

たとえば、先ほどレナさんのPNG画像に、「ASCII PROGRAMMING++」というテキストを tEXt チャンクを使って追加すると、次の図のような構造のPNG画像になります。

さきほどのコードをコピーして改造し、この秘密のテキスト入りのPNG画像を作ってみましょう。 readChunks() 関数はそのまま残して、 textChunk() 関数を追加し、 main() 関数を置き換えます。 import の節に "hash/crc32" を追加します。

func textChunk(text string) io.Reader {
    byteData := []byte(text)
    var buffer bytes.Buffer
    binary.Write(&buffer, binary.BigEndian, int32(len(byteData)))
    buffer.WriteString("tEXt")
    buffer.Write(byteData)
    // CRCを計算して追加
    crc := crc32.NewIEEE()
    io.WriteString(crc, "tEXt")
    binary.Write(&buffer, binary.BigEndian, crc.Sum32())
    return &buffer
}
func main() {     file, err := os.Open("Lenna.png")     if err != nil {         panic(err)     }     defer file.Close()     newFile, err := os.Create("Lenna2.png")     if err != nil {         panic(err)     }     defer newFile.Close()     chunks := readChunks(file)     // シグニチャ書き込み     io.WriteString(newFile, "\x89PNG\r\n\x1a\n")     // 先頭に必要なIHDRチャンクを書き込み     io.Copy(newFile, chunks[0])     // テキストチャンクを追加     io.Copy(newFile, textChunk("ASCII PROGRAMMING++"))     // 残りのチャンクを追加     for _, chunk := range chunks[1:] {         io.Copy(newFile, chunk)     } }

テキストチャンクの中は複雑に見えますが、パーツごとにみれば、それぞれ io.Writer の書き込みのみで構成されています。 binary.Write() による長さの書き込み、次にチャンク名の書き込み、本体の書き込み、最後にCRCの計算と、 binary.Write() による書き込みです。 たったこれだけのコードに5回も io.Writer による書き込みが登場しています。

main() 関数は、チャンクを io.Copy() でひたすら書き込んでいます。

読み込むコードも少し改造してみましょう。 tEXt チャンクの場合にのみ、中身をそのまま表示するようにしておきます。

func dumpChunk(chunk io.Reader) {
    var length int32
    binary.Read(chunk, binary.BigEndian, &length)
    buffer := make([]byte, 4)
    chunk.Read(buffer)
    fmt.Printf("chunk '%v' (%d bytes)\n", string(buffer), length)
    if bytes.Equal(buffer, []byte("tEXt")) {
        rawText := make([]byte, length)
        chunk.Read(rawText)
        fmt.Println(string(rawText))
    }
}

さきほど追加したテキストがこんなふうに表示されましたか?

chunk 'IHDR' (13 bytes)
chunk 'tEXt' (19 bytes)
ASCII PROGRAMMING++
chunk 'sRGB' (1 bytes)
chunk 'IDAT' (473761 bytes)
chunk 'IEND' (0 bytes)

注釈

  1. Go言語はシステムプログラミングに適した言語という触れ込みで連載していますが、残念ながらErlangの文法に組み込まれているバイナリパターンマッチのような強力なバイナリ解析はありません。 ?
  2. x86、x86_64はリトルエンディアンです。ARMそのものはどちらも切り替えられますが、iOSもAndroidもリトルエンディアンです。 ?
  3. https://ja.wikipedia.org/wiki/レナ_(画像データ)?

前へ 1 2 次へ

この特集の記事
ピックアップ