Tech Blog

グローバルな家族アプリFammを運営するTimers inc (タイマーズ) の公式Tech Blogです。弊社のエンジニアリングを支える記事を随時公開。エンジニア絶賛採用中!→ https://timers-inc.com/engineering

Go 言語を使って既存のデータベース資産と向き合う

みなさん、こんにちは。サーバエンジニアの長南です。

最近私が所属するチームでは、モバイルアプリのバッチ処理部分の一部を 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/sqlScan() が 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時間で格納」という忖度(?)を表現したものです。

birthdaytimestamp 型を適用することで、忖度スキャンが有効になります。結果を表示するときには一旦 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

Timersでは各職種を積極採用中!

急成長スタートアップで、最高のものづくりをしよう。

募集の詳細をみる