みなさん、こんにちは。サーバエンジニアの長南です。
最近私が所属するチームでは、モバイルアプリのバッチ処理部分の一部を Go 言語を使って書きかえる取り組みを行っています。
バッチ処理を書くときには、必ずといっていいほどデータベースに格納された情報を取り出す処理を準備する必要があります。そこで問題になるのは既存のデータベース資産をどう活用するのかという点です。
既存のテーブルの構造(スキーマ)が理想的なものでない場合にどうするのかというのは大きな課題ですが、Go 言語でどう向き合えばよいのかという点を考えてみます。
理想的な例
まずは非常に理想的な例を紹介します。特別な環境を準備せずにそのまま実行できるように、データベースは SQLite3 のインメモリデータベースを使い、クエリビルダーや ORマッパーは使わずに書いてみました(見やすさを優先させるためにエラーチェックやSQL分の記述は手抜きをしています)。
package main import ( "database/sql" "fmt" "time" _ "github.com/mattn/go-sqlite3" ) func prepareTable() sql.DB { db, _ := sql.Open("sqlite3", ":memory:") db.Exec(` CREATE TABLE sample1 ( id INTEGER, memo TEXT, birthday TIMESTAMP ); INSERT INTO sample1 (id, memo, birthday) VALUES (1, "foo", "2018-04-01 12:00:00.000+09:00") , (2, "bar", "2018-05-01 12:00:00.000+09:00") `) return *db } func main() { var id int var memo string var birthday time.Time db := prepareTable() row := db.QueryRow(` SELECT id, memo, birthday FROM sample1 WHERE id = 2 `) row.Scan(&id, &memo, &birthday) fmt.Printf("id: %d\n", id) fmt.Printf("memo: %s\n", memo) fmt.Printf( "birthday: %s\n", birthday.Format("2006-01-02 15:04:05 -07:00")) }
実行結果
id: 2 memo: bar birthday: 2018-05-01 12:00:00 +09:00
これは database/sql
を使い、 QueryRow()
を実行した結果をそれぞれの変数に格納する例で、解説書にかかれていそうなオーソドックスな例です。 SQLite3 の TIMESTAMP 型のデータが Go言語の time.Time 型の変数にうまく格納されています。
工夫が必要な場合
しかし、実際には一筋縄ではいかない場合があります。
誕生日を格納する birthday
が実は TIMESTAMP 型ではなく、 誕生日のUnix時間を INTEGER で表現されている
CREATE TABLE sample2 ( id INTEGER, memo TEXT, birthday INTEGER -- 誕生日のUNIX時間 )
というときはどうしたらよいでしょうか。何も考えずにSQL部分を
func prepareTable() sql.DB { ... db.Exec(` CREATE TABLE sample2 ( id INTEGER, memo TEXT, birthday INTEGER ); INSERT INTO sample2 (id, memo, birthday) VALUES (1, "foo", 1522551600) , (2, "bar", 1525143600) `) ... } func main() { ... row := db.QueryRow(` SELECT id, memo, birthday FROM sample2 WHERE id = 2 `) ... }
と書き換え実行してみると
id: 2 memo: bar birthday: 0001-01-01 00:00:00 +00:00
といったように、意図しない誕生日が表示されてしまいます。この例では意図しないデータが格納されましたが、場合によっては Scan Error が発生してプログラムが停止してしまうこともあります。
このようなことが起きるのは database/sql
の Scan()
が INTEGER を time.Time に変換することができないということですが、少し考えると「数値」を「時刻」に「いいかんじに忖度して」変換するのは難しいことに気がつくかと思います。
Scan() 部分を自前で実装する
ではどうすれば良いのかということになるのですが、「いいかんじに忖度」する機構を実装するのがスマートです。 time.Time 型のエイリアスを timestamp
という名前で作って、そこに Scan()
を実装します。
type timestamp time.Time func (t *timestamp) Scan(src interface{}) (err error) { switch src.(type) { case time.Time: *t = timestamp(src.(time.Time)) case int64: *t = timestamp(time.Unix(src.(int64), 0)) default: srcType := fmt.Sprintf("%s", reflect.TypeOf(src)) return errors.New("Incompatible type for timestamp from " + srcType) } return nil }
この Scan()
では row.Scan()
が実行されたときに、クエリの結果の実際の型に応じて自身に適切な数値を格納する処理を行っています。この例では「time.Time ならそのまま、 int64 なら Unix時間で格納」という忖度(?)を表現したものです。
birthday
に timestamp
型を適用することで、忖度スキャンが有効になります。結果を表示するときには一旦 time.Time にキャストした上で Format() をかけることで今まで通りの出力を得ることができます。
func main() { ... var birthday timestamp ... fmt.Printf( "birthday: %+v\n", time.Time(birthday).Format("2006-01-02 15:04:05 -07:00")) }
気を取り直して実行してみると
id: 2 memo: bar birthday: 2018-05-01 12:00:00 +09:00
と、期待する結果を得ることができるようになりました。
Unix時間版のコード
ここで誕生日が Unix時間で格納されているときのコード全体を掲載します。
package main import ( "database/sql" "errors" "fmt" "time" "reflect" _ "github.com/mattn/go-sqlite3" ) type timestamp time.Time func (t *timestamp) Scan(src interface{}) (err error) { switch src.(type) { case time.Time: *t = timestamp(src.(time.Time)) case int64: *t = timestamp(time.Unix(src.(int64), 0)) default: srcType := fmt.Sprintf("%s", reflect.TypeOf(src)) return errors.New("Incompatible type for timestamp from " + srcType) } return nil } func prepareTable() sql.DB { db, _ := sql.Open("sqlite3", ":memory:") db.Exec(` CREATE TABLE sample2 ( id INTEGER, memo TEXT, birthday INTEGER ); INSERT INTO sample2 (id, memo, birthday) VALUES (1, "foo", 1522551600) , (2, "bar", 1525143600) `) return *db } func main() { var id int var memo string var birthday timestamp db := prepareTable() row := db.QueryRow(` SELECT id, memo, birthday FROM sample2 WHERE id = 2 `) row.Scan(&id, &memo, &birthday) fmt.Printf("id: %d\n", id) fmt.Printf("memo: %s\n", memo) fmt.Printf( "birthday: %+v\n", time.Time(birthday).Format("2006-01-02 15:04:05 -07:00")) }
既存のデータベースは負債なのか資産なのか
長い期間運用しているデータベースを見返してみるとカラムの型定義やデータの格納状況、インデックスの有無などいろいろな不満を感じるものです。しかしそこに入っているデータは(矛盾をはらんだデータでもないかぎり)見た目よりも価値があるものです。
Go 言語に標準で準備されている database/sql
にはこのような強力な機構が備えられています。巷では「DSN(データソース名) の末尾に ?parseTime=true
を入れる」という MySQL に特化したソリューションが出回っていますが、深い部分の理解を深めることで、より便利で柔軟なコードを書くことができます。
どのようなプログラミング言語を採用しても、そこで実現したいものにフォーカスをして考察を深めることが大事だと思っています。
積極採用中!!
子育て家族アプリFamm、カップル専用アプリPairyを運営するTimers inc. では、現在エンジニアを積極採用中! 急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください! 採用HP : http://timers-inc.com/engineerings