Raccoon Tech Blog [株式会社ラクーン 技術戦略部ブログ]

株式会社ラクーン 技術戦略部より、tipsやノウハウなど技術的な話題を発信いたします。

Go事始め (2)

開発の松尾です。
前回は環境面について解説しましたが、今回はGo言語の文法と特徴について解説してみたいと思います。

Go言語のシンプルさと特殊さ

Goは言語の文法という側面から見ると実に「シンプル」な言語です。型推論による型指定の省略、ループのための構文は"for"のみ、GCを備えメモリ管理が不要・・・・・・などなどプログラムを最低限のコード量で記述できるように設計されています。
また、一方でGoは他言語経験者の視線から眺めると少々変わった分かりにくい要素をいくつか備えています。「ポインタ型はあるがポインタ演算は無い」、「channelやmapやsliceといった特殊な組み込み参照型」、「変数の値がstackとheapのどちらに配置されるかはコンパイラが適切に決めてくれる」などなどと私自身もGoの言語仕様を理解する途上でいくつか躓くポイントがありました。

このようにGoには「シンプルで分かりやすい」部分と「Go独特の言語仕様で分かりにくい」箇所が混在しています。このような性質を踏まえつつ言語仕様を見ていくことにしましょう。

func main()

C言語で書かれたプログラムがmain関数より実行が開始されるように、またJavaの場合は指定したclass内に含まれるstaticなmainメソッドより実行が開始されるのと似て、Goはmainパッケージに定義されたmain関数より実行が開始されます。
したがって下記のようなmain関数を含まないソースコードは実行できません。
/* no_main.go */
package main import "fmt" func no_main() { fmt.Println("hogehoge") }
$ go run no_main.go
# command-line-arguments
runtime.main: undefined: main.main
mainパッケージのmain関数が定義されているファイルに対してのみ"go run"で実行することができ、"go build"で実行可能ファイルを作成することができます。

定数

定数はconstを使って定義することができます。
package main
import "fmt"
const (
	OUTER = "外側のconst"
)
func main() {
	const inner = "内側のconst"
	fmt.Printf("%s %s \n", OUTER, inner)
}
パッケージ下および関数内で定義することが可能です。
ここでは文字列を値とする整数を定義していますが、整数、浮動小数点など様々な型を定数として定義することができます。
ちなみに上記のコードでは日本語文字列を使用していますがGoが対応するマルチバイト文字列のリテラル表現はUTF-8エンコーディングに対応しています。(それ以外のエンコーディングである場合はコンパイル時のエラーとなります。)

また、Goにはenum(列挙型)に該当する文法はありませんが、iotaを使うことでenumに似た定数定義を行うことができます。
package main
import "fmt"
const (
	A = iota
	B
	C
	_  // 3をスキップ
	D
	E
)
func main() {
	const X = iota
	const Y = iota

	fmt.Printf("%d\n", A) // 0
	fmt.Printf("%d\n", B) // 1
	fmt.Printf("%d\n", C) // 2
	fmt.Printf("%d\n", D) // 4
	fmt.Printf("%d\n", E) // 5

	fmt.Printf("%d\n", X) // 0
	fmt.Printf("%d\n", Y) // 0
}
iotaの挙動はちょっと分かりづらいのですが「コンテキストが異なるiotaの値は0に戻る」(ソース内のX、Yの挙動)、「同じコンテキスト内では0から1ずつ増加する」、「iotaを使った式がそれ以下の定数に対して自動的に適用される」(ソース内のA-Eの挙動)あたりがポイントになります。

reflectパッケージ

ここで突然reflectパッケージについて説明します。
reflectパッケージは主としてGoのリフレクション機能を提供します。といってもその機能は多岐に渡ってしまうのでここではreflectパッケージを利用することで変数の型情報を得ることができる利便性について解説します。
Goの型システムには少々分かりづらいところがあるため、具体的に実行時の型がどのようになっているのかを得る手段を知ることが大変有用だと思われるためです。

さっそく使ってみましょう。
package main

import(
	"fmt"
	"reflect"
)

func main() {
	b := true
	fmt.Println(reflect.TypeOf(b))
	ui8 := uint8(100)
	fmt.Println(reflect.TypeOf(ui8))
	n := 100
	fmt.Println(reflect.TypeOf(n))
	c := 'A'
	fmt.Println(reflect.TypeOf(c))
	r := 'あ'
	fmt.Println(reflect.TypeOf(r))
	u := '\U00101234'
	fmt.Println(reflect.TypeOf(u))
	s := "文字列"
	fmt.Println(reflect.TypeOf(s))
	a := [3]int{1, 2, 3}
	fmt.Println(reflect.TypeOf(a))
}
このプログラムの実行結果は下記のようになります。
bool
uint8
int
int32
int32
int32
string
[3]int
この結果で分かることは、
  • 組込みでbool型をもつ(true or false)
  • 型指定の無い数値の定数はint型となる
  • シングルクォーテーションで囲んだ「ルーンリテラル」はUnicodeの1文字を表す書き方でint32型となる
  • 組込みでstring型をもつ
  • 配列型は[3]intのように要素数も含めてひとつの型を表す
上記のようなポイントを読み取ることができます。

goでは「:=」の構文を使うことで型指定の省略を行うことができます。しかし、何らかのコードを読んでいて変数の実際の型を知りたいなどの場合にはreflectパッケージの機能が有効に利用できます。

もうひとつ気をつけるべきポイントがあります。goでは組込み型としてuint32(符号なし32bit)、int8(符号あり8bit)などの厳密な数値型が用意されているのですが、数値定数がデフォルトでとるint型ではプラットフォーム次第でその内容が異なることがあります。
具体例を示すと、下記のコードは64bit環境であれば問題なく動作しますが、32bit環境ではコンパイルエラーとなります。
package main

import(
	"fmt"
	"reflect"
)

func main() {
	n := (1<<63) - 1 /* 64bit環境におけるintの最大値 */
	fmt.Println(reflect.TypeOf(n))
}
ここまでは明示的にreflectパッケージの機能を使用してみましたが、goではfmt.Printfの書式に「%T」を指定することで同様の出力を得ることができます。
package main

import (
	"fmt"
)

func main() {
	fmt.Printf("%T\n", true)
	fmt.Printf("%T\n", 1024)
	fmt.Printf("%T\n", "string")
	fmt.Printf("%T\n", [3]int{1, 2, 3})
}
変数の型情報を見るには上記の書き方で十分でしょうが、goでは実行時に変数の型情報を取り出すことができるところがポイントです。Javaで言えばinstanceof演算子のような振る舞いを実現するのに利用することができます。

変数

 先に型推論を利用した変数定義とreflect.TypeOfについて解説しましたが型指定も含めた変数の定義は下記のように書きます。
package main

import (
	"fmt"
)

func main() {
	var b bool = false
	fmt.Println(b)
	var c int32 = 'あ'
	fmt.Println(c)
	var s string = "あいうえお"
	fmt.Println(s)
	var a [5]int = [5]int{1, 2, 3, 4, 5}
	fmt.Println(a)
}
CやJavaの経験者であれば変数名の後に型指定があるところや一見奇妙に見える配列型の定義箇所が少々気味が悪く感じるかもしれません。これがgoの構文ですので慣れましょう(笑)

 また、goの慣例として定義する変数名を「短く簡潔に」定義する傾向があります。Javaなどのように「長く具体的な」命名の慣習を持つ文化に馴染みが深いと違和感が大きいとは思いますが、郷に入っては郷に従え、できるだけ学ぼうとする言語の慣習で書くべきでしょう。それによってコードを読む際にも大いに役に立ちます。

ポインタ型

goにはCに良く似たポインタ型が用意されています。
package main

import (
	"fmt"
	"reflect"
)

func main() {
	n  := 100
	np := &n
	fmt.Println(reflect.TypeOf(np))
	fmt.Println(np)
	fmt.Println(*np)
}
「&変数名」でポインタの取り出し、「*変数名」で値の取り出しとCと大きく違うところはありません。
goのポインタで一番重要なのは「ポインタ型はあるがポインタ演算が無い」という特性です。言語仕様からポインタ演算を取り除くことで得られる安全性を重視した結果とも言えるでしょう。

ポインタについての説明は本気でやると際限なく長くなりそうなのでこの程度にとどめておきます。

構造体(Struct)

goにはCによく似た構造体の定義を行うことができます。
package main

import (
	"fmt"
)

type Member struct {
	name string
	age  int
}

func main() {
	var m Member = Member{"まつお", 37}
	fmt.Printf("name = %s\n", m.name)
	fmt.Printf("age  = %d\n", m.age)
}
Cの構造体と異なってフィールドの初期化に便利なリテラルが利用できます。上記ではname、ageといったフィールドに対して変数の定義と同時に初期化を行っています。

goの構造体の作成には「new([構造体型])」という書き方もあります。
package main

import (
	"fmt"
)

type Member struct {
	name string
	age  int
}

func main() {
	m := new(Member)
	fmt.Printf("name = %s\n", m.name)
	fmt.Printf("age  = %d\n", m.age)
}
newによる初期化は構造体の領域のみを作成して「構造体のポインタ型」を返すことに注意してください。上記のコードを実行するとMember型のフィールドnameは空文字列、ageは数値の0になっていることが確認できます。

なぜこのような文法が用意されているのでしょうか?
私見ですがプログラムにおいて構造体で定義されるデータ構造は比較的「長生き」する性質があります。Cにおいてヒープに作成された構造体のポインタを持ち回り各所で処理を行っていくなどの流れは極めて基本的な手順です。この「構造体を作成してそのポインタを取り出す」といった頻出するパターンを簡略化したのがこのnewによる構造体の作成の手法なのだと思われます。

ちなみにgoでは実行時にオプションを指定してどの変数がヒープに格納されるかを確認する手段が用意されています。
/* newを使用して構造体のポインタを返すCreateMember */
package main

import (
	"fmt"
)

type Member struct {
	name string
	age  int
}

func CreateMember(name string, age int) *Member {
	m := new(Member)
	m.name = name
	m.age = age
	return m
}

func main() {
	m := CreateMember("まつお", 37)
	fmt.Printf("name = %s\n", m.name)
	fmt.Printf("age  = %d\n", m.age)
}
/* 構造体をstack上に作成して構造体のコピーを発生させるパターン */
package main

import (
	"fmt"
)

type Member struct {
	name string
	age  int
}

func CreateMember(name string, age int) Member {
	var m Member
	m.name = name
	m.age = age
	return m
}

func main() {
	m := CreateMember("まつお", 37)
	fmt.Printf("name = %s\n", m.name)
	fmt.Printf("age  = %d\n", m.age)
}
上記2パターンを下記のオプションを付加して実行してみてください。異なる結果が得られます。
下記はnewを使用したバージョンの実行例です。
$ go run -gcflags -m test1.go
# command-line-arguments
./test1.go:12: can inline CreateMember
./test1.go:20: inlining call to CreateMember
./test1.go:12: leaking param: name
./test1.go:13: new(Member) escapes to heap
./test1.go:20: CreateMember new(Member) does not escape
./test1.go:21: main ... argument does not escape
./test1.go:22: main ... argument does not escape
name = まつお
age  = 37
「new(Member) escapes to heap」の箇所からヒープに割り当てられたことが分かりました。

goのプログラム内の変数はその参照範囲を解析することでスタックに配置されるかヒープに配置されるかが自動的に判別されてコンパイルされます。非常に便利な機能なのですが反面、知らずに書くと非効率的なコードを書いてしまう危険性をはらみます。
最低限、データがスタックに置かれるかヒープに確保されるかについて多少なりとも意識的にコーディングする必要があります。

ついでにもうひとつ。構造体の出力についてはfmt.Printfに便利な書式が用意されています。
package main

import (
	"fmt"
)

type Member struct {
	name string
	age  int
}

func main() {
	var m Member = Member{"まつお", 37}
	fmt.Printf("%T\n", m)
	fmt.Printf("%+v\n", m)
	fmt.Printf("%#v\n", m)
}
main.Member
{name:まつお age:37}
main.Member{name:"まつお", age:37}
構造体のフィールドとデータを一括して表示させたい場合に役に立つでしょう。

関数

goの関数定義についてはそれほど変わったところはありません。
package main

import (
	"fmt"
)

func power(n int) int {
	return n * n
}

func main() {
	fmt.Println(power(10))
}
あまり他の言語に見られない文法としては関数が「多値」を返すように定義できるところでしょうか。
package main

import (
	"fmt"
)

func oneTwoThree() (int, int, int) {
	return 1, 2, 3
}

func main() {
	a, _, c := oneTwoThree()
	fmt.Printf("%d %d\n", a, c)
}
ちょっと無理矢理ですがint型の数値を3つ戻す関数の定義です。また、関数の呼び出し側の代入部分で分かるように_(アンダースコア)で変数名を省略して戻り値を無視することができます。

goはC++やJavaのような例外機構は備えていません。そのためgoのプログラムにおいては「失敗する可能性のある処理」については処理結果とエラー情報 を多値で返すという下記のようなパターンが頻出します。
package main

import (
	"fmt"
	"os"
)

func main() {
	file, err := os.Open("input.txt")
	if err != nil {
		os.Exit(1)
	}
	fmt.Printf("%#v\n", file)
}
このようにgoのエラー処理はCにおけるエラー処理の不便さとJavaなどの例外処理のようなコストの高い仕組みの中間ぐらいを狙って設計されていることが分かります。

for

goにおけるループのための構文はforのみと大変シンプルです。
package main

import (
	"fmt"
)

func main() {
	/* C言語的なfor */
	for i := 0; i < 5; i++ {
		fmt.Printf("i=%d\n", i)
	}
	/* C言語タイプのwhile */
	c := 0
	for c < 5 {
		fmt.Printf("c=%d\n", c)
		c++
	}
	/* 配列の走査にはrangeを使う */
	a := [3]int{1, 10, 100}
	for i, v := range a {
		fmt.Printf("[%d]=%d\n", i, v)
	}
	/* stringを文字単位で処理する */
	for pos, r := range "日本語" {
		fmt.Printf("[%d]=%c\n", pos, r)
	}
	/* 無限ループ */
	for {
		break
	}
}
forにいくつかの書き方パターンがあるところが独特ですね。

defer

goの特徴的な構文にdeferがあります。
deferで定義された関数呼び出しは実行されている関数の「脱出直前に呼び出される」という動作をします。
package main

import (
	"fmt"
)

func main() {
	fmt.Println("1")
	defer fmt.Println("2")
	defer fmt.Println("3")
	defer fmt.Println("4")
	fmt.Println("5")
}
Javaなどのtry-catch-finallyといった文法のfinallyに近い動作イメージです。
オープンしたファイルを関数の完了時に確実にクローズするなどのリソース処理などを簡潔に記述するのに役に立ちます。

To be continued

長くなってきたので第2回めはここでいったん終わります。
次回は少し分かりづらいgoの「参照型」や非同期処理のためのgoroutineという仕組みについて解説してみようと思います。

Go事始め (1)

開発の松尾です。
特に社内で利用しているとかそういう実績はひとつも無いんですが色々と思うところあってGoの紹介記事を書いてみます。何らかのプログラミング言語をすでに業務などで使用していてパラダイムの違う別の言語を学びたいというような方の一助となれば幸いです。

Goについて 

GoはGoogleを中心として開発されているオープンソースのプログラミング言語です。
発表された当時は「Cっぽい文法が何だかフワフワしていて気持ち悪い」という程度の感想しか持たずチラチラ見る程度の距離感で眺めていたのですが時間が経つにつれ「こういうプログラミング言語のパラダイムって今後のために超重要じゃね?」とあっさり宗旨変えして調べはじめた経緯があります。
それもこれも、Mozillaにより開発されているRustやErlang VMベースで動作するElixirなどの存在を知り、C++やJavaのように長年使われてきた実績のあるシステム言語ではカバーできない/しずらい領域が広がり始めているのかなあという空気感を感じたというのが大きな理由となっています。
必ずしもGoが将来においてメインストリームとなり得ると確信しているわけでは無いのですが、言語環境のエコシステム、並列処理、厳密な型システムといった最近の潮流を取り入れつつ、かつ言語環境が安定しているというポイントからGoを学ぶには良いタイミングなのではないかと思っています。

Goについての日本語の解説も増えてきているという印象もあるので、この記事では「細かいことは抜きにして具体的にプログラミングを書いてみる」ことができるようポイントを絞って書いてみることにします。そのため言語の細かい部分の解説については割愛します。

Goのメリット 

Goを使用するメリットにはどのようなことが考えられるでしょうか?
わたしは下記の3点が特にお気に入りです。
  1. コンパイルして実行可能ファイルを作ることができる。
  2. OSなどの環境への依存性が少なくポータビリティに優れる。
  3. 軽量な非同期処理のサポートが言語に組み込まれている。

インストール

Goが現在サポートしている環境はLinux、OS X、FreeBSD、Windowsです。
わたしがこの記事を書くにあたって使用している環境はUbuntu 13.10(64bit)です。Linux全般では大した違いは無いと思います。OS XであればパッケージがWindowsであればMSIインストーラーが提供されているため導入における問題は少ないでしょう。
また、Goのバージョンは現在の最新バージョンである1.2.1を選択しています。(※とか言ってるうちに1.2.2になってしまいましたが…)
$ curl -L https://go.googlecode.com/files/go1.2.1.linux-amd64.tar.gz | tar zxf -
$ sudo mv go /usr/local/go1.2.1
$ sudo ln -sfn /usr/local/go1.2.1 /usr/local/go
特にGo本体をどうこうする意図は無いため素直に/usr/local/go1.2.1へインストールしています。/usr/local/goへシンボリックリンクを張って環境変数PATHには/usr/local/go/binを追加します。
$ go version
go version go1.2.1 linux/amd64
goコマンドによってバージョン情報が正しく表示されれば成功です。

初期のgoではGOROOTという環境変数が必要でした。JavaでいうところのJAVA_HOMEのような役割を果たします。上記ではLinux向けのバイナリパッケージを利用しており、GOROOTは標準で/usr/local/goを指します。
$ go env GOROOT
/usr/local/go
上記のようにインストールした場所とgoの設定に相違が無い場合は特にGOROOTを指定する必要はありません。goの複数バージョンを切り替えて使うような特殊な場面では有効に機能します。

また、GOPATHという環境変数も必要です。goの外部ライブラリの格納場所を表しています。これは個人的なものなので特にどこに配置しても良いのですが、わたしは$HOME/goを指定しています。
$ mkdir $HOME/go
$ export GOPATH=$HOME/go

go

goで書かれたプログラムの実行、ビルド、テスト実行、外部パッケージ取得など様々な機能を内包したコマンドです。
$ go
Go is a tool for managing Go source code.

Usage:

        go command [arguments]

The commands are:

    build       compile packages and dependencies
    clean       remove object files
    env         print Go environment information
    fix         run go tool fix on packages
    fmt         run gofmt on package sources
    get         download and install packages and dependencies
    install     compile and install packages and dependencies
    list        list packages
    run         compile and run Go program
    test        test packages
    tool        run specified go tool
    version     print Go version
    vet         run go tool vet on packages
gitのようにサブコマンドがいくつか定義されています。それぞれのサブコマンドのヘルプを確認するには下記のようにhelpを利用します。
$ go help run
usage: go run [build flags] gofiles... [arguments...]

Run compiles and runs the main package comprising the named Go source files.
A Go source file is defined to be a file ending in a literal ".go" suffix.

For more about build flags, see 'go help build'.

See also: go build.

go build

C言語との比較のためにベタなHello Worldプログラムを書いてみましょう。まずはC言語バージョン。
/* hello_c.c */
#include "stdio.h" main() { printf("Hello, World!\n"); }
次はGoバージョンのHello World。
/* hello_go.go */
package main import "fmt" func main() { fmt.Println("Hello, World!") }
ビルドして実行可能ファイルを作成します。goプログラムのビルドには"go build -o [出力ファイル] [ソースファイル]" を利用しています。
$ gcc -o hello_c hello.c
$ go build -o hello_go hello_go.go
これでhello_cとhello_goの2つの実行可能ファイルが作成されました。どちらも実行すると"Hello, World!"と表示するだけの極めて単純なプログラムです。
ですがここで作成された実行可能ファイルに大きな違いがあります。

 c_vs_go_on_hello_world

このようにCバージョンもGoバージョンもソースコードのサイズこそ似たようなものですが、生成されたバイナリのサイズに大きな違いがあります。Cバージョンが8.6kBに対してGoバージョンは2.2MB! Goが生成したバイナリはCバージョンのそれに比べてなんと250倍のサイズがあります。
この理由について調べるためにLinuxのELF形式の実行可能ファイルの内部情報を覗くツール"readelf"を使ってみましょう。
$ readelf -d hello_c

オフセット 0xe28 にある動的セクションは 24 個のエントリから構成されています:
  タグ        タイプ                       名前/値
 0x0000000000000001 (NEEDED)             共有ライブラリ: [libc.so.6]
... (中略)

$ readelf -d hello_go このファイルには動的セクションがありません。
Cバージョンのプログラムでは共有ライブラリlibc.so.6への依存が表示されますが、Goバージョンでは「動的セクションが無い」というあっさりとした表示で完了します。実行時にlibc.so.6を動的に使用するかビルドしたバイナリ内に必要な静的ライブラリ全てを組み込むかの差が実行可能ファイルのサイズ差となって現れているわけですね。

ここで理解できるのはGoはその設計方針としてOSの中核をなすような基本的なライブラリ群にすら依存しないように構成されているということです。言い換えるならばOSにインストールされているライブラリ群が古かったり新しくなって互換性が損なわれた場合にもGoの動作にはいささかも影響を与えないということです。「車輪の再発明」になろうとも環境の違いをものともしない一貫性とポータビリティが最重要視されているとも言えるでしょう。

もうひとつ実験

ついでに先ほどのhello_go.goに別のパッケージに依存する処理を書き加えてビルドするとどうなるでしょうか?
/* hello_go.go */
package main import (
"fmt"
"net/http"
) func main() { fmt.Println("Hello, World!")
_, _ = http.Get("http://example.com/") }
importする対象に"net/http"というHTTPプロトコルを操作するためのパッケージを追加して適当なURLの内容をGETする操作を付け加えています。
これを先ほどの手順と同じようにビルドしてみます。
go_binary_file_size

ほんの数行の追加ですが先ほどの2.2MBから7.1MBと盛大にサイズが大きくなりました。これは"net/http"というパッケージへの依存性が追加されたため、今回のわたしの実験環境であれば、/usr/local/go/pkg/linux_amd64/net/http以下に配置されている静的ライブラリが実行可能ファイル内に組み込まれたことを表しています。
goでビルドされたバイナリにどのようなライブラリが組み込まれているかについては先ほどと同様にreadelfを使ってシンボルテーブルを覗くことで調べることができます。
$ readelf -s hello_go | grep http | grep Get
 11037: 000000000043a020   115 FUNC    GLOBAL DEFAULT    1 net/http.Get
 11038: 000000000043a0a0   213 FUNC    GLOBAL DEFAULT    1 net/http.(*Client).Get
 11063: 000000000043db60    84 FUNC    GLOBAL DEFAULT    1 net/http.Header.Get
 11247: 0000000000459850   192 FUNC    GLOBAL DEFAULT    1 net/http.(*Header).Get

go run 

まずはgoによるビルド方法を挙げてみましたが毎回毎回ビルドするのは明らかに面倒です。goではビルドから実行までの流れをサブコマンド"run"を使うことで一発で処理できます。
$ go run hello_go.go
Hello, World!
各種スクリプト言語と同等ぐらいに簡便ですね。

go test

goには標準でテスト機能が付属しており"go test"で実行することができます。
ただし、その実行単位はgoのパッケージ単位で動作するためいくつかのルールに従う必要があります。

ここではシンプルな計算のための関数を含んだmymathパッケージを作成してそのテストを行う手順について記してみます。
まずはmymathパッケージの本体です。$GOPATH/src/mymath/mymath.goにソースファイルを保存します。わたしの環境では$HOME/go/src/mymath/mymath.goに配置されることになります。
package mymath

func Power(n int) int {
	return n * n
}
ちなみに任意のpackage以下で定義された関数はその名前が英大文字で始まる場合にpublicな関数として扱われパッケージ外より呼び出すことが可能になります。関数名の先頭が英小文字である場合はprivateな関数となります。

さて、mymath.goと同じディレクトリに下記のテストファイルを配置します。"[パッケージ名]_test.go"というファイル名にして配置する必要があります。この場合は、$GOPATH/go/src/mymath/mymath_test.goですね。
package mymath

import (
	"testing"
)

func TestPower(t *testing.T) {
	expect := 256
	actual := Power(16)
	if expect != actual {
		t.Error("Power(16) != 256")
	}
}

func TestPower2(t *testing.T) {
	t.Error("error")
}
テスト対象のパッケージと同じパッケージに所属していることに注意してください。さらにテストを実行するためのtestingパッケージのimportが必要で、各テストの関数名をTest*より開始することでテスト関数であると認識されます。
また、各テスト関数はtesting.Tという型の引数をとります。他言語のUnit Testにあるようなassertionといった機能は提供されておらず、testing.T型の引数に対してError()、Fatal()といった関数を使って適宜テストの失敗を報告する構成になっています。 

上記2ファイルを規定の場所に保存していよいよテストを実行することができます。
"go test"に指定するのはファイルやディレクトリではなくパッケージ名であることに注意してください。
$ go test mymath
--- FAIL: TestPower2 (0.00 seconds)
        mymath_test.go:16: error
FAIL
FAIL    mymath  0.001s
意図的にTestPower2()が失敗するように書いたためTestPower2がFAILした旨が表示されましたが、テストの実行自体は成功しています。
パスしたテストも表示するにはオプションに-vを追加します。
$ go test mymath -v
=== RUN TestPower
--- PASS: TestPower (0.00 seconds)
=== RUN TestPower2
--- FAIL: TestPower2 (0.00 seconds)
        mymath_test.go:16: error
FAIL
exit status 1
FAIL    mymath  0.001s
パスしたテストの内容も表示されるようになりました。

go1.2からはテストのcoverage測定もオプションで指定できるようになりました。
テストが失敗するとcoverageの出力ができないためテストコードに手を入れて全てのテストがパスするように書き換えます。
package mymath

import (
	"testing"
)

func TestPower(t *testing.T) {
	expect := 256
	actual := Power(16)
	if expect != actual {
		t.Error("Power(16) != 256")
	}
}

func TestPower2(t *testing.T) {
	t.Log("pass")
}
coverage測定のためのオプション、-coverを付加して実行してみましょう。
$ go test mymath -v -cover
=== RUN TestPower
--- PASS: TestPower (0.00 seconds)
=== RUN TestPower2
--- PASS: TestPower2 (0.00 seconds)
        mymath_test.go:16: pass
PASS
coverage: 100.0% of statements
ok      mymath  0.001s 
テスト実行の出力内容に"coverage: 100% of statements"が表示されるようになりました。
"-cover"オプションに留まらず、CPUのプロファイル情報のための"-cpuprofile"、各テストを並列実行するための"-parallel"など有用なオプションが色々と用意されています。詳しくは"go help testflag"で参照することができます。

To be continued

今回はGoの環境設定から中心となるツールについての概観を行ってみました。
次回の「Go事始め (2)」では例え他言語に精通していたとしても引っかかりがちなgoの言語機能について解説してみたいと思います。

ラクーンのエンジニア評価制度について

こんにちは、たむらです。
今回は、ラクーンのエンジニア評価制度についてのアレコレを書こうと思います。
ラクーンがエンジニアに活き活きと仕事してもらう為にどの様に評価制度を考えているかを知ってもらうキッカケになればと思っています。

評価制度の一般論
 さて、会社の評価制度というものはエンジニアという職種に限らなくてもどの会社でも非常に苦慮して作り上げているものです。働くすべての人のあらゆる状況を正しく評価することはとても難しく、過不足やいびつさを抱えているものが殆どなのではないかと思います。みなさんの会社ではどの様な評価制度で評価されているでしょうか?またその評価制度に満足されていますか?
 私は人事の専門家では無いので正確性は危ういところがありますが、経験上エンジニア界隈で適用されている評価制度は以下の様なものが一般的かと思います。
1. 職能給制度
  スキルレベルにより評価されるものです。号棒と呼ばれるレベル表を定義し、その昇降により給与(=評価)を決めるような方式になります。スキル評価としての建前がありますが、実際にスキルを詳細に定義することは難しく、得てして年功序列的な運用になりやすい面があります。
2. 成果主義による評価制度
  その名の通り、業務の過程は考慮せず、成果のみによって評価する方式です。売上や利益といったもので定義されることが多く、評価基準の分かりやすさがメリットと言えます。エンジニア職種に対しては絶対的な評価基準は定義し難い為、指標が多岐にわたることが殆どです。その為分かりやすさは一段劣る印象があります。
3.目標管理との連携
  多くは期初に業務に則した目標を立て、その達成度により評価をします。個々人のレベルに合せてそれぞれが定義することが多く、成果主義を属人化させたものといえるかもしれません。売上等の統一の評価基準を用いない場合はメンバー間での絶対的な評価が分かりにくくなる傾向にあります。

実際のところ多くの企業では、上述のどれかを適用しているというよりは、それぞれの良い所をうまく取り入れつつ評価制度を組み立てているところが殆どなのではないかと思います。中には名目上は「成果主義」だが、実際にはろくに予実管理も面談もせずに上司の胸先三寸で決まる「ブラックボックス評価制度」というところもあるのかもしれません。


過去のラクーンのエンジニア評価制度
 ではラクーンではどうなのかというと、過去ラクーンでは上で述べたものの1つである「目標管理と連携した評価制度」をエンジニアの評価に利用していました。しかし、この制度には職務的に合わない部分があり見直されることになりました。
 合わない点は2点あります。1つ目は業務内容が目標設定時から変化することが多い点です。例えば目標設定時はプロジェクトAに参画することが予定されていた為、「プロジェクトAでの不具合摘出率を前回参画プロジェクトの半分にする」という目標を立てたが、プロジェクトA自体が実施されなかった、というようなケースです。ラクーンでは実施する案件はその時の状況により都度見直しが入るので、この様な目標倒れがしばしば起きました。
 2つ目は実業務との乖離です。1つ目の反省を踏まえて、目標を実業務から離れたものに設定したことがありました。例えば「未経験のRuby on Railsをマスターし、公開アプリを作成する」などです。業務の隙間時間でスキルを向上させてもらい、その成長を評価しようとの目論見でしたが、逆に本来評価の対象とすべき実業務の評価と離れたものとなってしまい、妥当性を欠く結果となってしまいました。


評価制度見直しに際してのポリシー
 この様な経緯から然るべき評価制度に対しての必要性が高まったのを踏まえて、評価制度の見直しが行われることになりました。策定にあたりポリシーとしたのは以下の点です。
「エンジニアのスキルアップの指針(キャリアパス)となり得るもの」
「客観性・納得感があるもの」
「エンジニア以外に対しても分かり易い評価軸を持ったもの」
「モチベーションの向上に繋がるもの」

このポリシーに則り、実際に働くメンバーにも検討に加わってもらいながら、評価制度の見直しを行いました。
その結果、現在適用している評価制度は「スキルベース評価」、「スキルシート管理」、「資格制度」、「360度評価」に表されるものです。順に概要を説明させてもらいます。


スキルベース評価
 まず、前提とするのはラクーンでのエンジニア評価はスキルをメインとしているということです。エンジニアという職種である以上、その技術力を評価の中心に据えようという思いがあります。そこで観点とする「スキル項目」を選定し、それに沿って評価することを評価の主軸としました。「スキル項目」は大きく分けて5つのカテゴリに分かれており、①技術スキル、②言語スキル、③ヒューマンスキル、④業務スキル、⑤アドオンスキル となっています。①~③は6段階評価、④、⑤は3段階評価としています。

スキルカテゴリの概要
スキルカテゴリ














評価の際に一番重きを置かれるのは ①技術スキル になります。なぜ ②言語スキル や ④業務スキル が入らないのかというと、言語やシステムは新陳代謝がある為です。そこで、よりエンジニアスキルの本質となる要素にターゲットを絞ることで、評価に恒常性をもたせることを意図しています。


スキルシート管理
 スキル項目に沿って、全エンジニアのスキルをスキルシートという表にまとめ、一覧できる様にしています。これは部門内で誰でも参照できる様にしていてスキルの見える化に繋げています。但し、過剰に上下関係を表し人間関係が崩れてしまうことがないように、①技術スキル~③ヒューマンスキル に関しての数値は評価者及び本人以外には公開せず、全体公開するのはある一定のレベル以上かどうかのみ公開するようにしています。
 元々見える化というのは会社にエンジニアの評価を分かり易く伝えたいという意図の他に、メンバー間での教育関係が生まれやすくすることも目的の一つとしています。その為メンバー間においては上下関係を知るというよりも誰に聞けばいいのか?誰が教えるべきなのかが分かることが見える化の大事なポイントだと考えています。

実際のスキルシート
スキルシート


















資格制度
 ラクーンでは会社独自の資格制度を持っていて、その中に「特定分野の専門知識、経験を持つ人」に与えられるスペシャリスト資格(社内では通称S資格と呼んでいます)が存在します。レベルによりS1,S2と2段階あり、年収の想定レンジで言えば750万円~1000万円以上をイメージしています。
技術戦略部のS資格としては、シニアエンジニアや、プロジェクトリーダ、インフラエンジニア等が定義されています。
 さて、このS資格はそれぞれ資格取得条件を定義することになるのですが、それをスキルレベルと紐付ける形で定義しています。それにより、「自身の現在のスキル」、「伸ばすべきスキルの方向性」、「会社的な評価(S資格の取得)」が同じ軸で考えられるようにしています。


技術部で現在用意しているS資格とその資格条件
スキルマップ1







スキルマップ2









360度評価
 評価査定は年に2回半期毎に行いますが、その時に上述のスキルシートの更新を行い、評価資料として用います。
スキルシートの更新の際にはなるべく多角的な評価を集めるため、メンバー間相互評価を集めたり、資格所有者に更新内容の妥当性の検証をしてもらったりといった360度評価の仕組みを取り入れています。
上司からの評価だけだと実業務の目線から離れていることも多く、実際の技術スキルを測るには限界があります。その補完としてメンバー同士からの評価を用いています。また、いつも進捗が遅れがちだが、実際には他メンバーの相談や質問に親身にのっていて他メンバーからの信頼がとても厚い人等、目に見える成果では測れない貢献者を認知する手段にもなっています。

まとめ
 ザックリとした説明になってしまいましたが、こんな仕組みでラクーンではエンジニア評価をしています。
この評価方針はここ1年位で見直した結果なのですが、この事実が物語る通り、今後も必要があれば都度見直しをしてどんどん変化していくことになると思います。ただ、あくまでその目的は、エンジニアが活き活きと仕事ができることや、長くラクーンで働いていきたいと思えることに繋がるべきであると思っています。

最後に・・・
私達は一緒に働く仲間を随時募集しています。
この評価制度の話なり、ラクーンのビジネスモデルなりをちょっとでも面白そうだなと思ってくれた方、是非一緒に働いてみませんか?
ご応募お待ちしております!!

DoS攻撃からサーバーを守る、mod_dosdetector導入事例のご紹介

こんにちは、羽山です。今回はDoS攻撃の話題です。

弊社の運営するファッション・雑貨向けB2Bサイトスーパーデリバリー(以下 SUPER DELIVERY)にはブラウザからの普通のアクセスを逸脱した過度なHTTPリクエストがしばしばやってきます。大半は独自クローラーなどの悪意がある攻撃とは言えないものですがサイトに与える負荷は高く対応の必要がありました。
 
今回の記事では弊社で行ったDoS攻撃対策の顛末をお送りします。
記事の性質上、設定内容など一部ぼやかして表現している点もありますがご容赦ください。
また、当記事では過度なHTTPリクエストをすべてDoS攻撃という呼称で統一しています。


DoS攻撃とは?
そもそもDoS攻撃と一口に言ってもTCPレベルからHTTPレベルまで様々な手法が存在します。
例えばTCPレベルで有名なのはSYN floodという攻撃で、接続開始を表すSYNパケットを攻撃対象に大量に送りつけることでサーバーのリソースを大量に消費させます。しかし幸いなことにTCPレベルのDoS攻撃の多くはFirewallやLoadBalancer(以下LB)に保護機能があることが多く、特別な対策を意識するケースは減ってきています。
しかしその一方で問題が顕在化しているのはHTTPレベルのDoS攻撃です。例えば「正常なHTTPアクセスを大量に行う」などステートレスな手法では防げないものが多いため、防御する仕組みや機器は複雑・高度・高価になりがちです。 


サーバー構成の説明
まずは対策を行う弊社のサーバ環境を簡単に説明します。
以下の図のようにFirawallとL7のLB以下に複数台のウェブサーバが配置されて、LBから各ウェブサーバはリバースプロキシの構成をとっています。
ウェブサーバは主にApache2.2/2.4系を利用しています。

l7proxy

対策方法の検討
1. 専用アプライアンスの導入
ある程度の予算がとれるならばDoS対策専用のアプライアンス導入が第一候補として考えられます。
すでに多岐にわたるアプライアンスが出そろっていてある程度成熟しているといえるでしょう。
シグネチャや振る舞いに基づいた保護を基礎として、SQLインジェクション[1]などのWEBアプリケーションの脆弱性まで保護してくれるものもあります。

[1] SQLインジェクションなどはアプリ側できちんと対策する前提ではありますが、アプライアンス導入には安全性を保証する客観的な指標となるメリットがあります。DoS対策アプライアンスは元々L7をベースに動作しているためHTTPプロトコルを詳細に解析するたぐいの機能追加とは相性がよいことから、付加価値を高めるために両者が同時に提供されることがあります。


弊社の場合、「まずはDoS対策を導入」というステージだったため、いきなり大きく予算を取るリスクを避けたかったこともありアプライアンスの導入は保留にして別の案を検討しました。

2. 各ノードで個別にDoS検査
次に検討したのは各ノード、つまりウェブサーバのApacheレベルでの検査です。各ノードでの検査の場合はソフトウェアで対応できるので必要な費用を最低限におさえることができます。
DoS攻撃によるアクセスがLBによって各サーバに分散されてしまうため検出しにくいという弱点がありますが、幸いSUPER DELIVERYのサーバはLBがノードを選択する際に持続接続機能を利用していて同一クライアントは同一ノードに転送されるため、ノード側でのDoS検査でも上流での検査とさほど変わらないレベルで実施可能でした。
Apache側での防御手段を調べたところmod_dosdetectorというid:stanaka氏が開発・公開してくださっているモジュールがApache2.2/2.4で利用可能で弊社の要件にも近いということが分かりました。
LB配下では接続元IPがLBの内部IPに置き換えられてしまい利用できないためX-Forwarded-Forヘッダを代わりに確認する必要がありますが、dosdetectorはその機能にも対応しています。

まずはこの案を採用してみることになりました。


mod_dosdetectorの基本機能
すでにいくつものサイトで紹介されているので詳細は省略しますが、基本的には下記のような動作をします。
1. クライアントのIPアドレス一覧を共有メモリに保持
2. IPに紐付けてアクセス数やアクセス時間を記録
3. 一定時間内のアクセス数が設定した閾値を超えたらSuspectDoSという環境変数に1をセットする

mod_dosdetector自体の動作は以上で終わりです。
環境変数に入れるだけの動作なのでその後はmod_rewriteなど既存のモジュールで柔軟に処理できます。


いくつか課題が発生
基本機能はほぼ要件にマッチしたのですが、いくつか解決が必要な課題が出てきました。
以下に挙げる点はrewriteで頑張れば回避できるものもありますが、複雑なrewriteルールを避けたかったため、結論としてはmod_dosdetector自体を改修して機能追加することで解決しました。
弊社で利用しているモジュールを記事の最下部でダウンロードできるようにしています。
LB配下の一般的な環境で使いやすいよう最適化しています、無保証ですが興味ある方はご利用ください。

課題1 X-Forwarded-ForからのIP抽出方法
mod_dosdetectorは接続元IPの代わりにX-Forwarded-Forを参照するDoSForwardedというオプションがあります。
基本的にはこの設定で問題ないのですが、X-Forwarded-Forに複数のIPが含まれるパターンで問題となる場合があります。

mod_dosdetectorのX-Forwarded-ForからIPの選択方法は下記のようになっています。
1. X-Forwarded-Forの先頭からカンマ(含まない場合は末尾)までの文字列をIPとして利用
2. X-Forwarded-Forヘッダが存在していて、その文字列が有効なIPではなかった場合はDoS検査の対象外になる

ここで2点問題が出てきますが、その話の前にX-Forwarded-Forヘッダの一般的な動きを理解しましょう。
一般的にプロキシサーバやLBを経由するとX-Forwarded-Forの末尾に接続元IPを追加して、接続元IPを自身のIPとした新しいHTTPリクエストを目的のサーバに送信します。

X-Forwarded-Forヘッダーについて

通常は社内プロキシで転送する場合はX-Forwarded-Forに内部IPを残さない設定にしますが、設定が不十分でそのまま残ってしまってるケースもよくあります。
この例では最終的なX-Forwarded-Forの値は192.168.0.100, 203.0.113.54, 198.51.100.23となるため、検査対象のIPアドレスを先頭から取得すると正しい検査を行えません。

dosdetectorの標準動作である先頭のIPを採用した場合、下記の2つの問題が発生します。

1. 社内プロキシサーバ経由のアクセスの場合に内部ネットワークのIPアドレスが入っているケースがある
2. X-Forwarded-Forヘッダは容易に偽造可能

1つめは不適切なIPアドレスを元にDoS検査を行ってしまうことになり、全く関係のないクライアント同士が同一IPという扱いを受けてしまったり、内部IPを無視する設定を行った場合は検査が作動しなかったりします。
2つめは根本的な問題で、原則としてSUPER DELIVERYネットワークの管理下にあるLBが付与した一番最後のIP以外はすべて信頼できません。それ以外のIPは偽造可能なため例えばX-Forwarded-Forヘッダに毎回異なるIPを適当に設定してアクセスすれば実質DoS検査を無効化できてしまいます。

課題2 X-Forwarded-Forが存在しない場合
X-Forwarded-Forヘッダが存在しなかった場合、dosdetectorは接続元IPを利用してDoS検査を行います。
しかし一般的にLBからのリバースプロキシ構成ではX-Forwarded-Forヘッダを常に付与する設定にできるので、LBを経由するアクセスなのにX-Forwarded-Forヘッダが付与されないということはありません。

しかし各ノードに流れてくる一部のアクセスにはX-Forwarded-Forヘッダが付与されないことがあります。それらはLBから生存確認のために送るheartbeatであったり、サーバー間通信などシステム内部的なアクセスです。X-Forwarded-Forヘッダがない場合に接続元IPを代わりに利用してしまうと、これらの通信を阻害してしまい問題になることがあります。

課題3 DoS検査の対象について
今回DoS検査の対象としたサーバではいくつかのシステムが並列で稼働していて、そのうち一部をDoS検査から除外する必要がありました。それらはシステムが内部的にAjaxで呼び出すAPIなど、連続で叩かれることが元々想定されているものなどです。
根本的にはアクセス頻度の大きく異なるサービスが同一のサーバに同居していることが問題なんですが、レガシーなシステムも含むためあまり手を入れずに対処したいという事情もありました。
dosdetectorは検査対象がVirtualHost単位になるため、ディレクトリ毎に対象・除外の設定を行うことはできません。
mod_rewriteで除外したいパスを表面上素通しすることは可能ですがDoS検査自体は動作するため、対象外のページでもアクセスがカウントされ、そのまま検査対象のページに移動したらDoS検査に引っかかるなんてことがおきてしまいます。

課題4 社内IP
当然ですが弊社社内の人間は頻繁にSUPER DELIVERYにアクセスします。
そして社内ネットワークのグローバルIPは1つなので簡単にDoS検査に引っかかってしまうことが分かりました。mod_rewriteで省くことは簡単なのですが、mod_rewriteの設定がごちゃごちゃするのでどうせならこれも要件に入れちゃえ!と、ついでに入れました。


モジュールの変更ポイント
<DoSIgnoreIpRange>
※複数行で設定可能
DoS検査の対象から除外するIP範囲を指定可能で、下記3種類の指定方法を認識します。
・192.168.0.0/16
・10.0.0.0/255.0.0.0
・203.0.113.56

<IncludePath/ExcludePath>
※複数行で設定可能
DoS検査を行う対象をrequest_uriで絞り込みます。
1. ExcludePath優先、ExcludePathにマッチしたらDoS検査を行わない
2. IncludePathを次に検査、IncludePathが1つも設定されていない場合はIncludePathに / が指定されているのと同じ動作をする
3. IncludePathが1つ以上ある場合は明示的にIncludePathに指定されたパスのみDoS検査の対象とする

IncludePath/ExcludePathともに、複数設定した場合はOR結合です。

X-Forwarded-Forに複数IPを含む場合の処理を変更
X-Forwarded-Forヘッダの末尾からIP候補を探します。カンマ区切りで順に取得して、IPとして無効な文字列やDoSIgnoreIpRangeに含まれるIPを除外した最初(より末尾に近い)のIPをDoS検査対象とします。
これで X-Forwarded-Forヘッダを偽装されても正しくDoS検査を行うことができます。

DoSForwardedがOnでX-Forwarded-Forが存在しない場合の処理を変更
DoSForwardedがONの場合は、X-Forwarded-Forが存在しなければDoS検査を行わないよう変更しました。


導入
ダウンロードして適当なディレクトリに解凍

同じディレクトリに上記ファイルをダウンロード

apxsのパスが異なる場合はMakefileを修正します。
以下、パッケージ版のapacheを利用している前提で進めます。

$ patch < mod_dosdetector.patch
$ make
$ make install
$ vi /etc/conf/httpd.conf
モジュールが読み込まれていることを確認します。 
LoadModule dosdetector_module modules/mod_dosdetector.so
具体的な数値や設定を載せるといろいろと怒られるのであくまで設定例としてご紹介します。
下記は弊社で実際に運用している設定とは異なります。
 
DoSDetection On
DoS検査機能をOn
 
DoSForwarded On
接続元IPの代わりにX-Forwarded-Forを利用する場合はOn
 
DoSPeriod 60
DoSThreshold 200
DoSHardThreshold 300
DoSBanPeriod 30
DoSTableSize 100
DoSPeriodはDoS検出する時間単位で、指定した秒数の間にDoSThreshold回以上のアクセスがあるとSuspectDoS=1がセットされます。
さらに、DoSHardThreshold回以上のアクセスがあるとSuspectHardDoS=1もセットされます。
DoS判定されるとDoSBanPeriodで指定した秒数の間SuspectDoS=1がセットされ続け、その後いったん規制がクリアされます。
DoSTableSizeは保持するIP一覧の数で、この数だけ共有メモリに領域が確保されます。そのため多くすれば検出の幅は広がりますが、IP一覧からIPを検索する負荷が上昇するため効率が下がります。IP一覧はLRUで管理されるため頻繁にアクセスしてくるDoS攻撃のIPは残りやすため、よほど高負荷な環境でなければ100もあれば十分だと考えられます。

DoSIgnoreContentType ^(image/|application/|text/javascript|text/css)
Apacheがローカルで解決できるレベルでコンテンツタイプによる除外を行います。
しかし、mod_proxyなど外部リソースから動的にコンテンツタイプが返却される場合は除外できないため、この設定に頼ってしまうと危険です。動的コンテンツの場合は後述のmod_rewriteで除外することを検討します。

DoSIgnoreIpRange 192.168.0.0/16
DoSIgnoreIpRange 172.16.0.0/12
DoSIgnoreIpRange 10.0.0.0/8
DoSIgnoreIpRange 203.0.113.56
内部IPと特定のIP(社内ネットワークのグローバルIPを想定)を除外する例です。サーバー間でなんらか直接やりとりしている場合やLBからのheartbeatなどのために内部IPは除外しておいた方が無難です。必要に応じて社内ネットワークのグローバルIPなども除外します。

DoSIncludePath /path1/
DoSIncludePath /path2/
DoSExcludePath /path1/exclude1
DoSExcludePath /path1/exclude2
DoSIncludePathにはDoS検査対象のパスを前方一致で指定、未指定の場合はすべてのパスが対象となります。
DoSExcludePathにはDoS検査対象から除外するパスを前方一致で指定します。
DoSExcludePathがDoSIncludePathよりも優先され、両方とも複数のパスを設定した場合はOR結合となります。

RewriteCond %{ENV:SuspectDoS} =1
RewriteCond %{HTTP_USER_AGENT} !googlebot [NC]
RewriteRule .  - [E=DoS:1]
dosdetectorの結果を受け取ってmod_rewriteで除外する例です。
ここではSuspectDoS変数に1がセットされていて、かつユーザーエージェントにgooglebotを含まない場合を最終的なDoS対象としていて、新しい変数DoSに1をセットします。

ErrorDocument 500 /error503.html
RewriteCond %{ENV:DoS} =1
RewriteCond %{REQUEST_URI} !=/error503.html
RewriteRule . - [R=503,L]
CustomLog /var/log/httpd/sd-static-dosdetector.log combinedr env=DoS
最終的なアクションです。DoS=1がセットされている場合は503ページを表示しつつログも出力します。
503ページは /error503.html というパスに存在する前提です。


最後に
本モジュールを導入して2ヶ月以上経ちますが問題なく安定稼働しています。
大規模サイトでは当たり前のように行われているであろうDoS攻撃への対策ですが、中・小規模のサイトではなかなかノウハウを含め情報が行き渡っていないのではないでしょうか?
かく言うSUPER DELIVERYでもようやく必要性が出てきたところでした。この記事がそういったステージの方の一助になることを願います。
 
最後になりましたが、 非常に有用なモジュールを開発および公開してくださっているid:stanaka氏に感謝いたします。

RubyのPDFライブラリ "Prawn" のご紹介

こんにちは。開発チームのハッタです。
前職が業務系SIerだったこともあり、主にバックオフィス系のシステムを担当しています。
好きな言語はVB.NETとPL/SQLです。

業務系システムといえば必ず出てくるのが帳票類です。
.NETならGUIでサクサク作れるのですが、Webサービスメインの会社では雰囲気的にそうもいかないことが多いです。
(自分ひとりで保守を続けることになったり、、)

そこで、郷に入っては郷に従えの精神でWeb系言語による帳票開発をやってみました。

今回は、言語はRuby、ライブラリはPrawnを使ってPDFを作成します。
Prawnはググった限りでは数年前からあるようなのですが、日本語の情報があまりありません。
この記事が、リファレンスとしてみなさんのお役に立てれば幸いです。

導入
gemでインストールし、requireするだけです。

帳票レイアウトを決める
帳票のサイズや向き、上下左右の余白はコンストラクタで指定します。
  Prawn::Document.new(:page_size => 'A4',
                      :page_layout => :portrait,
                      :top_margin => 40,
                      :bottom_margin => 30,
                      :left_margin => 20,
                      :right_margin => 10)
または、
  Prawn::Document.generate("test.pdf",
                          :page_size => 'A4',
                          :page_layout => :portrait,
                          :top_margin => 40,
                          :bottom_margin => 30,
                          :left_margin => 20,
                          :right_margin => 10)
という書き方をします。
generate()はオブジェクトの生成と、指定したファイルパスへの保存を行います。

オプションの説明:
  :page_size      帳票のサイズです。'A4'、'B5'といった規格の他、[100, 200]という指定もできます。[横, 縦]、単位はポイント(pt)です。
  :page_layout    帳票の向きを縦 or 横で指定します。縦 = :portrait、横 = :landscape です。
  :top_margin     上余白をptで指定します。
  :bottom_margin  下余白をptで指定します。
  :left_margin    左余白をptで指定します。
  :right_margin   右余白をptで指定します。

上記のコードで作られるPDFの余白と印刷領域を塗り分けると、以下のようなイメージになります。
01_margin

































帳票のレイアウトを決めたら、文字や線を描いていきます。
それらの処理は以下のようにblockで渡す形で書きます。
  Prawn::Document.new(...) do |pdf|
    pdf.foo ...
  end

座標を把握する
PrawnではX座標、Y座標をptで指定して、文字や線の出力位置を決めます。

Xの値は、印刷領域の左端が起点です。
印刷領域の右端は、
 pdf.bounds.right
で取得できます。
Yの値は、印刷領域の下端が起点です。
印刷領域の上端は、
 pdf.bounds.top
で取得できます。

また、
 pdf.y
 pdf.cursor
で、現在のポインタのY座標が取得できます。
オブジェクト生成時の初期値は、印刷領域の上端 = pdf.bounds.topと同値が設定されています。

この値は、後述する文字出力などの処理を行うと、下方向へ移動していきます。(数値が減少)
y:bottom_marginを考慮せず帳票下端からの位置、
cursorは:bottom_marginを含めた下端からの位置になります。

以下のようなコードで、ポインタのY座標を動かすことができます。
  pdf.move_up 100           #現在位置から100pt上へ移動
  pdf.move_down 100         #現在位置から100pt下へ移動
  pdf.move_cursor_to(100)   #帳票下端から(100 + :bottom_margin)pt上の位置へ移動
上下左右に100ptの余白を設定したA4横帳票で、
座標移動処理を実行した後のyおよびcursorの位置は以下の図のようになります。
(枠線は印刷領域と余白の境界を表しています。)

02_point

















ページヘッダ、フッタを出力するには
ページヘッダ、フッタの出力など、毎ページ同じ処理を行いたい場合はrepeat()を使います。
  pdf.repeat :all do
    ...
  end
block内に文字や線の出力など、各種処理を書きます。
上記の例では、:allで全ページ対象、他に:oddで奇数ページ、:evenで偶数ページといった指定もできます。

後述するグリッド処理(table())で改ページが発生すると、
次のページは印刷領域の上端からグリッドの印字が始まるため、
repeat()内で行う出力に印刷領域内の座標を指定すると、
グリッドと重なってしまいます。
その場合は、座標にpdf.bounds.topを超える値を指定して、
上余白部分へ印字することで対応できます。

フォントを指定する
まず、文字を出力する際に必要となるのがフォントです。
Prawnでは何も指定しない場合、Helveticaが使われます。

日本語出力をしたい場合など、別のフォントを使いたい場合は以下のように書きます。
  pdf.font "/foo/bar.ttf", :size => 10
指定するファイルは.ttfファイルです。サイズの指定は省略できます。デフォルトでは12ptになります。
フォントファイルを指定後、サイズのみ変更したい場合は
  pdf.font_size = 10  #ptで指定
と書きます。

文字を出力する際、縦方向に必要となる長さは
  pdf.height_of(string)
で取得できます。
横方向は
  pdf.font.compute_width_of(string)
で取得できます。
これらを使うと、下図のように文字列と同じ長さの線を引くことなどが容易になります。
03_font










文字を出力する
Prawnには文字を出力するメソッドが3つあります。

1. text
  pdf.text 'foo'
  [0, pdf.cursor]の位置に指定された文字を出力します。
  文字の上端が指定されたY座標に当たる形で出力されます。
  このメソッドを実行すると、文字列1行分(= height_of(text))、Y座標が下に移動します。

2. draw_text
  pdf.draw_text 'foo', :at => [100, 200]
  :atに指定された座標に文字を出力します。
  文字の下端が指定されたY座標に当たる形で出力されます。
  このメソッドは、Y座標の移動をしません。

3. text_box
  pdf.text_box "foo", :at => [100, 200], :width => 200, :height => 100, :align => :center, :valign => :center
  :atに指定された座標を起点として、:width:heightで指定された枠内に文字を出力します。
  文字列が1行に収まらない場合、:heightの範囲内で折り返して出力されます。
  枠内に収まらない部分は切り捨てられ、戻り値として返します。
  文字列に半角スペースがある場合、それを区切り文字として単語ごとに分解されるため、想定外の箇所で折り返されることがあります。
  その場合は出力する文字列の半角スペースを以下のように置き換えることで、単語分解されずに出力されます。
    string.gsub(" ", Prawn::Text::NBSP)
  :alignは横の位置、:valignは縦の位置を指定できます。
  帳票タイトルを印字する場合に、:widthに印刷領域の最大幅を指定し、:align => :centerをするなどの活用方法があります。

  文字の上端が指定されたY座標に当たる形で出力されます。
  このメソッドは、Y座標の移動をしません。

各メソッドの座標の動きや、text_box()の折り返しをまとめると、以下の図のようになります。
04_text


















線を引く
線の始点と終点を指定するline()、線を描画するstroke()を実行します。
  pdf.line([0, 0], [0, 100])      #左辺
  pdf.line([0, 0], [100, 0])      #下辺
  pdf.line([100, 0], [100, 100])  #右辺
  pdf.line([0, 100], [100, 100])  #上辺
  pdf.stroke                      #描画
上記のコードで、帳票左下に一辺100ptの正方形が描画されます。

線の太さや色の変更、破線の出力もできます。
  pdf.line_width = 2          #太さ
  pdf.dash = 2                #破線間隔
  pdf.stroke_color("FF0000")  #色
太さ、破線はpt、色はRGB16進数で指定します。
太さ等はstroke()が実行される直前に指定された値が採用されます。
太さ、破線間隔ともに指定する数値の単位はptです。

破線を実線に戻す場合は、
  pdf.undash
と書きます。

line_width = 1..3dash = 1..3 をそれぞれ出力すると、以下のようになります。
06_line



















複数の出力項目をグルーピングする

bounding_box()というメソッドを使って枠を定義し、
その中の相対的な座標位置で文字出力などを行うことができます。

たとえば、
  pdf.bounding_box([0, 100], :width => 160, :height => 90) do
    pdf.draw_text "★", :at => [0, 0]
  end
上記のコードでは、
"★"は帳票全体の左下ではなく、
帳票下端から100pt上の位置に置かれた高さ:90pt、幅:160ptの枠の左下に出力されます。
block内でpdf.ypdf.cursorを実行した場合も、枠内の相対的な座標を取得します。

bounding_box()に渡す座標位置を変えることで、
枠内の文字や線の位置関係は変えずに、帳票全体の中での出力位置を変えることができます。
たとえば、
差出人の情報と宛先の情報がある帳票で、
それぞれの住所・氏名等をそれぞれのbounding_box内でまとめておけば、
位置の入替などが容易に行えます。

block内で
  pdf.stroke_bounds
と書くことで、枠線を出力することもできます。
2つbounding_boxを作成し、その中に同じ座標を指定した文字出力処理を書いた場合、
以下のように親のbounding_boxに依存して出力位置が変わります。
07_bounding_box


















データグリッドを出力する
今までにご紹介したtext()line()を駆使すれば、データグリッドを作成することは可能ですが、
Prawnにはtable()という簡単にグリッドを作成できるメソッドがあります。
  pdf.table(
    data,
    :column_widths => [70, 80, 90],
    :header => true,
    :row_colors => ["FF0000", "0000FF"]
  ) do |t|
    t.cells.border_width = 0.1
    t.columns(0).style :align => :left
    t.columns(1).style :align => :center
    t.columns(2).style :align => :right
    t.row(0).style :align => :center, :background_color => "CCCCCC"
  end
dataには二次元配列を渡します。
ヘッダ行を出力したい場合は、dataの先頭にヘッダ行の配列を格納します。

:column_widthsには各列の幅を指定します。
省略された場合は、最長データに合わせて幅が設定されます。
最長データよりも短い幅を指定した場合は、折り返して出力します。

:headerにはdata[0]の値をヘッダ行として扱うかを指定します。
trueが指定された場合は、上記のコード例でblockに書いた
  t.row(0).style :align => :center, :background_color => "CCCCCC"
などのような、ヘッダ行個別の設定が有効になります。
falseまたは指定なしの場合、ヘッダ行個別の設定は効きませんが、data[0]の値自体は明細行の先頭として出力されます。

:row_colorsには明細行の色を指定します。
明細行が交互に配列で指定された色で塗られます。
配列には3種類以上の色も指定可能で、その場合は3行、4行と指定された色の数ごとに明細行の色が分かれます。

blockでは、上記のコード例のようにグリッド全体や各列のスタイルなどを設定できます。

table()の出力位置は、[0, pdf.cursor]となります。
出力位置を調整する場合は、move_down()bounding_box()を使う必要があります。

データ件数が多く、明細行が改ページ位置にかかった場合は、自動的に改ページされます。

ページトップから出力した場合と、bounding_boxを使って特定の位置に出力する場合の例です。
08_table



















以上、一般的な帳票を作る上で基本となりそうな部分を紹介させていただきました。
これらを組み合わせるだけでも、以下のような簡単な請求書などが作れます。
99_sample



































上図作成時に実際に書いたコードです。
フォントはIPAフォントを使っています。
Prawn::Document.generate("99_sample.pdf",
                        :page_size => 'A4',
                        :page_layout => :portrait,
                        :top_margin => 170,
                        :bottom_margin => 20,
                        :left_margin => 20,
                        :right_margin => 20,
                        :compress => true
                        ) do |pdf|
  total = 0
  data = []
  data.push ["商品名", "単価", "数量", "金額"]
  w = (pdf.bounds.right / 10).floor
  ws = [w*5, w*2, w, w*2]
  for i in 1..20 do
    data.push [
      "商品 #{"%06d" % i}",
      (i * 100).to_s.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse,
      i,
      (i * i * 100).to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
      ]
    total += (i * i * 100)
  end

  page_head = 150
  pdf.font "ipam.ttf"
  pdf.repeat :all do
    pdf.font_size = 20
    text = "請   求   書"
    y = pdf.cursor + page_head
    pdf.text_box text, :at => [0, y], :width => pdf.bounds.right, :align => :center
    y -= 40

    pdf.font_size = 16
    pdf.text_box("発行日:#{Time.now.strftime("%Y年%m月%d日")}",
                :at => [0, y],
                :width => pdf.bounds.right,
                :align => :right)
    y -= 25

    w = pdf.bounds.right / 2
    h = pdf.height_of(text) * 3
    pdf.bounding_box [0, y], :width => w, :height => h do
      pdf.text "【宛先】"
      pdf.text "株式会社○○ 様"
    end
    pdf.bounding_box [w, y], :width => w, :height => h do
      pdf.text "【差出人】"
      pdf.text "株式会社□□"
    end

    y -= h
    pdf.font_size = 18
    text = "合計金額: \\#{total.to_s.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse}"
    pdf.text_box(text,
                :at => [0, y],
                :width => pdf.bounds.right,
                :align => :right)
    y -= pdf.height_of("a")
    x1 = pdf.bounds.right - pdf.font.compute_width_of(text)
    x2 = pdf.bounds.right
    pdf.line([x1, y], [x2, y])
    pdf.stroke

    pdf.move_cursor_to pdf.bounds.top
  end

  pdf.table(data,
          :header => true,
          :column_widths => ws,
          :row_colors => ["FFFFFF", "CCCCCC"]
    ) do |t|
    for i in 1..3 do
      t.columns(i).style :align => :right
    end
    t.rows(0).style :align => :center, :background_color => "CCCCCC"
  end
end

ところどころソースを読みながらこのブログを書きましたが、
他にも使えそうなオプションやメソッドがまだまだありそうです。

ソースはgithubに公開されているので、(https://github.com/prawnpdf/prawn)
もっと複雑な帳票を作りたい、という方は是非ソースコードも読んでみてはいかがでしょうか。

記事検索