一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

あえてテクニカルなコーディングをしないという選択肢

この記事は一休.comアドベントカレンダー2017の17日目です。 残すところ一週間とすこしですね

一休.com スパ を運用・開発しているid:kichion0526です。

テクニカルな話や一休の苦労話etcは諸先輩方がたくさん書いてくれているので

最近、何を意識して実装しているかを書き残したいと…

ギークなテッキーになりたかった故に小難しいことをしていた

前職からC#で書くことも多く、リフレクションなどを使い倒しメタプログラミングがすらすらできるようになるのが当時の目標でした

一例として
~ゲームにおける様々なアイテムを生成するファクトリ~

public abstract class ItemBase
{
    protected ItemBase(int id) {
        Id = id
    }

    /// アイテムカテゴリを取得します。
    public abstract ItemCategory Category { get; }

    /// アイテムIDを取得します。
    public int Id { get; }

    /// アイテム名を取得します。
    public abstract string Name { get; }
}

public enum ItemCategory
{
    Weapon = 0, // みんな大好き武器
    Armor = 1, // みんな大好き防具 
    GachaTicket = 2, // みんな大好(ry
    ...(などなどいっぱい)...
}

public class Weapon : ItemBase
{
    public override ItemCategory Category => ItemCategory.Weapon;
    ....(コンストラクタ等でid指定etc)...
}

public class Armor : ItemBase
{
    public override ItemCategory Category => ItemCategory.Armor ;
    ....(コンストラクタ等でid指定etc)...
}

public class GachaTicket : ItemBase
{
    public override ItemCategory Category => ItemCategory.GachaTicket;
    ....(コンストラクタ等でid指定etc)...
}

アイテムクラスを定義したら端的にswitch文でファクトリ作ると下記のイメージ

public static class ItemFactory
{
    public static T Create<T>(int id, ItemCategory category) where T : ItemBase
    {
         switch (category)
         {
             case ItemCategory.Weapon:
                 return new Weapon(id);
             case ItemCategory.Armor:
                 return new Armor(id);
             case ItemCategory.GachaTicket:
                 return new GachaTicket(id);
                ...(その他もろもろ)...
         }
    }
}

「新しいアイテムが追加されるたびにここをいじるのはめんどくさいなぁ」
と思うとリフレクションの出番です

public static class ItemFactory
{
    private static readonly Dictionary<ItemCategory, Func<int, ItemBase>> Items;

    static ItemFactory()
    {
        var constructors = typeof (ItemBase).Assembly.GetTypes()
            .Where(x => x.IsSubclassOf(typeof (ItemBase)))
            .Where(x => !x.IsAbstract)
            .Select(x =>
            {
                // コンストラクタの引数の型
                var argumentType = typeof (int);

                // コンストラクタ
                var constructor = x.GetConstructor(
                    BindingFlags.Instance | BindingFlags.Public,
                    null,
                    CallingConventions.HasThis,
                    new[] {argumentType},
                    new ParameterModifier[0]
                );

                if (constructor == null)
                    return null;

                // コンストラクタの引数
                var id = Expression.Parameter(argumentType, "id");

                // コンストラクタをデリゲート化
                return Expression.Lambda<Func<int, ItemBase>>(
                    Expression.New(constructor, id), id
                    ).Compile();
            })
            .Where(x => x != null)
            .ToArray();

            Items = constructors.ToDictionary(x => x(0).Category);  // Keyを生成するためなので引数は何でもいい
    }

    public static ItemBase Create(ItemCategory category, int id)
    {
        return Items[category](id);
    }
}

これでItemBaseを継承するクラスのコンストラクタを辞書化しておけるのでCreateメソッドがこんなシンプルに!
しかも、辞書はstatic変数で保持しているのでアイテム生成のコストも気にしなくていい!
(こんな書き方できるのかっこいい!)

何が得られたの?

メリット

  • 新しいカテゴリのアイテムが追加されるたびにファクトリを修正しなくて良くなった
  • 生成メソッドの行数の少なさ
  • (かっこいいという満足感)

デメリット

  • switch文の実装より初期実装に時間がかかる
  • リフレクション知っているとしても初見は黒魔術
  • Dictionaryなので2つ以上のクラスで同じカテゴリを使うと一律エラーで死ぬ
  • アイテム種類数が少ないと単純に行数が増えてる
  • 後任の人が困る材料になりかねない

もともとは
「新しいアイテムが追加されるたびにここをいじるのはめんどくさいなぁ」
というモチベーションから始まった改修でした

今でこそ振り返ると複雑な概念を導入した割には解決したことが薄すぎると思っています

こうなるとswitch文を書き換えないという選択肢の方が良さそうです

結局何が言いたいの?

ホントの目的がないとテクニカルなコーディングはただの自己満足になると感じています

私自身、本当にテクニカルな手法が必要なのかを意識するきっかけになったのは一休に転職するようになってからです
極端な例では「この改修でどれくらい儲かるか」「今のチームの人が全員入れ替わったら扱えるか」なんてことを考えたりします

一休はエンジニアでもビジネス視点が求められているのでより「事業目標」に向かってコーディングできていると思います

より目的を意識してプログラミングと付き合うと
どうコーディングしたらいいか?の問いに答えが出しやすいのかなと実感してます

まとめ

目的を見失ったプログラミングは自己満足になりがち という話でした

この考えに至ったのも一休にいる優秀な方々のおかげだと思っています これからも社内のエンジニアの方から学びを得て全能感を高めていけたらと…

つらつらと書きましたが先人たちがいい言葉を残してくれていますので、それで締めたいと思います

プログラマが学ぶべき最も大切な技能というのは、コードを書かないときを知ることなのかもしれない。


最も読みやすいコードは、何も書かれていないコードだ。

(「リーダブルコード」和訳版 第13章「短いコードを書く」P.168 より)


明日は sagisakat さんの
「アプリのローンチと2度のメジャーアップデート、何を考えてデザインしたか」 です

レストランアプリのアイコンをクリスマス仕様にした話

この記事は 一休.com アドベントカレンダー 2017 の 15日目です。

一休レストランiOSアプリの開発ディレクターをしています、id:tsuchidah です。

クリスマスまであと10日となりました。 今回は、ふとした思いつきでアプリのアイコンをクリスマス仕様に変更して、どんなことが起きたのか、数字的な面や得られた知見などをご紹介したいと思います。

きっかけ

一休.comレストランにとってクリスマスはとても大事な時期です。クリスマスに大事な人と素敵なひとときを過ごすため、レストラン選びは外せない…。多くのユーザーさんが一休.comレストランに訪れます。ですが、他の予約サービスなどを使うユーザーさんもたくさんいるはず。そこで、少しでも弊社サービスを候補に入れてもらおう、と考えた時にアプリをクリスマスアイコンにすることを決めました。

狙う効果は2点

  • Storeで目を引くこと → 新規インストール数の増加
  • ホーム画面で目を引くこと → 起動率の増加、そこからのコンバージョン

クリスマスを意識していない人に早いうちからアイコンを目にしてもらい、「あ、そういえばそろそろか」「今年は一休で探してみようかな」と意識してもらえればいいな、という割りと軽い気持ちでスタートしました。

リリース

11月初旬に次のようなかたちでリリース。

f:id:tsuchidah:20171215120437p:plain

背景画像に雪を降らせる…といった案もありましたが、11月上旬という時期は逃したくなく、このかたちに落ち着きました。クリスマスまで一月半ほど期間がありますが、実はクリスマス予約は10月初頭から始まっています。ハロウィンが終わり、11月は一般的にもクリスマスに向かって盛り上がっていく時期。いいタイミングでリリースできたのではないかと思っています。

結果

数字的な変化

新規インストール数

特に伸びませんでした。

これはサービス内容によりけりで結果は異なると思います。弊社サービスはレストラン予約サービス。レストランを予約する時、「まずStoreへ…」となる方は少ないと思います。とは言え、インストール数が増える時期なので多少拍車を掛けられるかもと期待をしたのですが、残念な結果となりました。

起動率

起動セッション数、DAUが伸びました!

セッション数、DAUどちらもおよそ+20%ほど。アプリをフォルダーに入れる方も多く、なかなか目に触れないのでは…と懸念していたので、予想以上の数字でした。 こういった数字の成果が出たことは大変よい知見になりました。

予約数

明確な成果はわかっていません…。

予約数は上昇したものの、この時期は予約数が増える時期なので、クリスマス仕様にした成果とは断言できません。ただしセッションが増えた分CVRが下がらなかったので、悪い結果ではないと判断しています。

その他

別チームへの影響

効果を社内に共有したところ、PCブラウザ、スマホブラウザでも取り入れようという話になり、間もなくリリースされました。

f:id:tsuchidah:20171215123328p:plain

クリスマスの日付をクリックすることで、すぐに検索結果に遷移することが出来ます。そのアイデアは思いつかなかったので、やられた!という気持ちになりました(笑)

社内からの声

とてもポジティブな意見をたくさんいただきました。また、「友達からいいね!と言われたよ」など、間接的にユーザーさんのフィードバックを受け取ることができました。アイコンやUIを季節に合わせて変えるという一見効果がわかりにくい施策で、ユーザーさんにポジティブな印象をもっていただけると思うと、やってよかったと心底思います。

まとめ

ふとした思いつきだった割には、得られたものが多かったため、とても満足しています(笑)また、他社のレストラン予約アプリがクリスマスアイコンに変わっているのを見つけては、「弊社サービスが影響しているんだったらいいなぁ」と妄想をしています。 次もアイコンを変えるチャンスがあれば試してみよう、とチーム内で話していますので、次回乞うご期待です。

一点、iTunesConnectが12/22〜12/27までお休みのため、良いタイミングでアイコンを通常仕様に戻せるかが気がかりです…。

最後に、

クリスマスまであと少し、クリスマス仕様なのは今だけ!(笑)是非アプリやサイトに足を運んでみてください。 まだクリスマスのご予約がお済みでない方は、クリスマス直前割という企画も行っていますので、是非。

みなさま、素敵なクリスマスをお過ごしください。

明日は @hirosawak さんの「一休.comレストランアプリでのfastlane使用例」です。お楽しみに。

データの民主化とオープンソースソフトウェアと SQL Server

この記事は 一休.com アドベントカレンダー 2017 の 14 日目です。

昨日に引き続き、一休データサイエンス部の id:kitsuyui です。 13 日目のエントリでは Embulk, Redash, DatabaseMEMO の導入の経緯について解説しました。 とても素晴らしいツールを導入できましたが、実はそのままでは一休に導入することができない箇所がいくつもありました。

GitHub 上でどんなアクションをしたかを振り返りたいとおもいます。 その後、自分なりに見出したコントリビューションのコツと反省点について説明します。

私の英語力が不足していますので、つたない英語のプルリクエストを送ってしまっています。 この点についてはご容赦ください。

コントリビューション例

Re:dash + SQL Server が日本語が含まれていると正しく動作しない問題

Re:dash には Google のユーザ認証でログインする機能があるのですが、 このユーザ名と SQL Server の Query Runner がバッティングする問題がありました。

Re:dash から発行したクエリには SQL のコメントでユーザ名を埋め込むのですが、 そこが文字化けを引き起こしていました。

Re:dash のような Python 2.7 系ではありがちなこと (オープンソースソフトウェアの世界ではありがちなこと) ですが、文字コードを正しく扱っていないという基本的な点でした。

データソースの設定で文字コードを指定出来るようにし、クエリもエンコードするようにしてプルリクエストを出し、マージしていただきました。

https://github.com/getredash/redash/pull/1104

f:id:kitsuyui:20171213161119p:plain

Re:dash の Azure SQL Data Warehouse 対応

Azure SQL Data Warehouse という Microsoft 公式の SQL Server 互換の DWH 向けソリューションがあるのですが、 こちらが Re:dash に対応していない、という問題がありました。

そもそも Re:dash は SQL Server に対して pymssql (+ FreeTDS) を利用したクエリランナーを持っていたのですが、 こちらが Azure SQL Data Warehouse には対応していませんでした。

Python を使用して Azure SQL Database に照会する の解説にもある通り、 Microsoft としては pyodbc での実行ができることは検証されているため、 こちらを利用してアクセスできるようにし、プルリクエストを出し、マージしていただきました。

https://github.com/getredash/redash/pull/1906

embulk-output-jdbc で SQL Server Native Client (BCP) 対応

embulk は embulk-output-jdbc というプラグインをインストールすることで、各種の JDBC 互換のデータソースを出力先に追加することができます。 この embulk-output-jdbc には embulk-output-sqlserver が含まれています。

embulk-output-sqlserver には Windows 独自の機能として、 Native Client という Windows の DLL を利用した (非 JDBC) ロード方法があります。 これが SQL Server の最速の Bulk Insert 機能となっていますが、前述の通り Windows にしか対応していないという欠点がありました。

しかし、近年 Microsoft は Linux, macOS 環境にも力をいれており、公式で ODBC ドライバを配布しています。 こちらの ODBC のライブラリの中に Native Client (BCP) 周りの実装も含まれているため、 Linux, macOS でも実行可能であることがわかりました。

embulk-output-sqlserver の Windows 専用となっていた箇所にメスを入れ、 Linux, macOS にも対応し、 プルリクエストを出し、マージしていただきました。

具体的には、 Windows で読んでいる sqlncli11.dll というライブラリの代わりに Linux や macOS では msodbcsql.so, msodbcsql.dylib を使うように書き換えました。

途中 Shift_JIS (CP932) を前提とした箇所があったのを UTF-8 に対応する際にミスをし、一時的にバグを追加してしまいました。 こちらもすぐに修正をプルリクエストし、マージしていただきました。現在ではどの OS でも高速なロードができるようになりました。

一休内では予約情報のトランザクションや会員の行動履歴といった件数の非常に多いデータをロードする際にこのモードを活用しています。 該当の箇所で 5 倍以上の高速化が実現できました。

https://github.com/embulk/embulk-output-jdbc/pull/209 https://github.com/embulk/embulk-output-jdbc/pull/214

embulk-input-bigquery のバージョン変化に伴うプルリクエスト

embulk-input-bigquery という BigQuery を入力データソースとして使えるようにする embulk のプラグインがあるのですが、 こちらが依存しているライブラリ google-cloud-ruby が動作しなくなっていました。

バージョンアップによりインターフェースが変わり、クエリ結果のカラム名などを渡すインターフェースが、文字列ではなく Ruby のシンボルに変化していたためです。 embulk-input-bigquery にもこの変更を加え、プルリクエストを出し、マージしていただきました。

https://github.com/medjed/embulk-input-bigquery/pull/7

DatabaseMEMO (dmemo) の SQL Server 対応について

dmemo はテーブル情報を取得するデータソースとして、 MySQL, PostgreSQL, Redshift に対応しているのですが、 未だ SQL Server には対応していませんでした。

内部の実装をみたところ、テーブル情報を取得する際に、 Rails の Active Record を使っているようでした。

activerecord-sqlserver-adapter という、 SQL Server 用のアダプタを見つけたので、こちらを利用できるように実装しました。

なんとなく勘所はつかめましたので、 Active Record 用のアダプターさえあれば、ほかの種類のデータベースも移植できるかもしれないと考えています。一休ですと、他に BigQuery, Presto などが候補としてあります。

こちらは私 kitsuyui の方の実装がまだこなれていない点があるので、 GitHub 上でやりとりさせていただいている最中です。 https://github.com/hogelog/dmemo/pull/91

DatabaseMEMO (dmemo) を閲覧のみログインせずできるようにする

DatabaseMEMO は Google のアカウントでログインできるのですが、ログインせずに社内からの閲覧のみは許可したいケースがありました。全員が Wiki の編集者とならずとも、利用はできるようにしたいのです。 こちらは crowi-plus などにも 「ログインしていないユーザーにも閲覧のみ許可するオプション」があるのと同様の意図です。

こちらも GitHub 上でやりとりさせていただいている最中です。 https://github.com/hogelog/dmemo/pull/93

まとめ

SQL Server について

基本的には「SQL Server に対応してない」のケースは多いです。 しかし、実際には社会的な土台はほぼ整っていて、ソフトウェア的には少しの修正で対応できてしまう、というケースが多いです。

以前であれば、 SQL Server はエンタープライズ製品・導入が難しい・検証には Windows 機が必要、といういろいろな壁があったかと思います。 しかし、今ではありがたいことに SQL Server には Docker 版 が存在し、 Linux や macOS でも充分に検証できるようになりました。

SQL Server 自体はオープンソースソフトウェアではないですが、実行環境自体がオープンソースソフトウェアに近い構造で用意されていることで、 検証にかかるコストがグッと減りました。

この点は大きな転換点になると思います。

コントリビューションのコツ

期待以上の動作をさせてみる

私なりのコントリビューションのコツは、オープンソースソフトウェアを使い始めたときに、期待以上の使い方をすることです。

  • あえて Linux で embulk-output-sqlserver を Native Client で動作させようとしてみる
  • あえて DatabaseMEMO (dmemo) を SQL Server で動かしてみる
  • あえて Re:dash で Azure SQL Data Warehouse を使ってみる

こういうときに、最初から期待以上の動作をすることはありません。しかし、そっと閉じるのではなく、 想像力を働かせて「似た環境では動いてるはずなのに、なぜ?」「誰かの環境では動いているのに、なぜ?」といった問いからスタートして、あとは地道に帳尻を合わせていくことです。

できそうな材料をしらべる

ここまでわかれば、まずは一点だけが突破するような状態を作ることができます。

  • embulk-output-sqlserver で呼んでいる DLL の関数を調べ、全てが ODBC ドライバにもあるかを調べる
  • DatabaseMEMO で使っているフレームワーク (Rails) のドライバを調べ、 SQL Server 版が使えないか調べる
  • Re:dash の実装言語である Python で Azure SQL Data Warehouse に接続する方法を調べる

道具を揃える

材料があっても、見知らぬ環境ではうまく戦えません。道具を調べます。 ビルド方法・テスト方法・デバッグ方法・インタプリタ・パッケージマネージャあたりをおさえておけば大丈夫です。

  • embulk の場合は ./gradlew を叩けばビルドができます。 embulk irb でインタプリタに入れます。
  • DatabaseMEMO は Dockerfile があるので Docker で環境をつくれます。 rails console コマンドで Rails の irb に入れます。
  • Re:dash は (Python なので私はあまり困りませんでしたが) ./manage.py shell でインタプリタに入れます

また、未知のオープンソースソフトウェアの場合だと、これらを調べるのに時間がかかってしまうことがあります。

その場合には

  • strace (Linux のプロセスが実行するシステムコールを見ることができるコマンドです)
  • netcat (ポートの開閉をしらべたり、ポートを転送したり、サーバをでっちあげたりするのに使います)
  • print デバッグ (あまり上品ではありませんが、どこでも使えます)

などの、より低レベルな道具を使えば問題ありません。 (どこでも使える、というメリットもあります。)

また、利用するソフトウェアに CONTRIBUTION.md やライセンス等がある場合、それらも読み込んでおきます。

一点突破

後先考えずにあちこちに手を入れて、一点でいいので動くようにします。 このレベルでは 100% の状態である必要はありません。ボロボロでも疎通までができれば最高です。

  • DLL のパスだけ .so や .dylib に変えてコンパイルすると、 Linux でも文字化けした状態でなら文字列が挿入できる状態
  • SQL の実行の大部分は失敗するが、 DatabaseMEMO から SQL Server への接続まではできる状態
  • 幾つかのデータベースが見えないが、 Re:dash で SELECT 1 クエリが実行できる状態

きれいにする

一点突破したら、あとはそこを中心に綺麗に整地していきます。 一点突破したときにソースコードを散らかしてしまった場合も、このタイミングできれいにしていきます。

なるべく git diff が最小になるようにします。場合によっては複数のプルリクエストに分けます。

  • ドライバのパスや名称を指定出来るようにする ・文字化けしないように修正
  • SQL Server 用にソースコードをちまちま修正 (SELECT ... FROM ... LIMIT nSELECT TOP n ... FROM ... に、といった翻訳)
  • データベース一覧のクエリなどを修正する

あとはコミッタの方と相談

ここまでできたら、プルリクエストが作れます。 当たり前ですがコミッタやメンテナのほうが、自身のソフトウェアに対して深い洞察とモチベーションをもってます。

できるさえわかったら、深追いする前に Issue などで挙げて、具体的な実装については相談しつつ進めるのが良いと思います。

反省点

自分の場合、原因がわかると修正も同時にできてしまうことが多いので、 Issue とプルリクエストを同時に送ってしまう、 またはプルリクエストのみを送ってしまうというということが多かったです。だいぶ失礼なことをしてしまったと今は反省しています。

また、 Issue でのディスカッションを踏まえて実装に入るほうが、より問題の本質を捉えやすかったかな?とも思います。 この点は 2018 年は改善していきたいです。

最後に

日頃いろいろなオープンソースソフトウェアにお世話になることが多いので、来年もどんどんオープンソースソフトウェアを利用し、ガンガン還元していきたいです。

明日 (15 日目) は tsuchidah さんによる「アプリアイコンをクリスマス仕様にしたらどうなった」です。

データエンジニアとデータの民主化 〜脱・神 Excel 〜

この記事は 一休.com アドベントカレンダー 2017 の 13 日目です。

一休データサイエンス部の id:kitsuyui です。データエンジニア兼データサイエンティストをやっています。 この記事はもともとアドベントカレンダー上では「脱・神 Excel (仮)」という名前で枠で取っていたのですが、 少し主語が大きすぎたかな?と反省しています。 書いているうちに全く主旨が変わってきましたので、副題とさせていただきました。

今回は一休社内でのデータエンジニアリングにまつわる負担、それらを解決する Redash, Embulk, DatabaseMEMO の導入の流れを書こうと思います。

また、その過程で副次的に発生した FLOSS へのコントリビューションなどなどについては、 14 日目のエントリで説明したいと思います。

一休とデータ活用

一休は今日まで上質な宿・レストランの予約サービスを運営してきて、今年の 7 月で創業 20 年目になりました。 Web でのサービスを提供する企業としては比較的に古参プレーヤーの方だと思います。会員数も 500 万会員を突破しています。

蓄積されてきた膨大なユーザの行動・予約データがあり、データの持つ価値が非常に大きい企業です。

  • 2012 年ごろから、一休ではデータ分析を重視して施策を決定することが増えてきました。
  • 2014 年ごろから、 BigQuery などの巨大データ向けのソリューションを併用することが増えてきました。
  • 2015 年ごろから、基幹データベースとは独立したデータ分析のための専用のデータベースとして、データウェアハウスの構築が進んできました。

今では一休社内の様々な施策が、データドリブンまたはデータ分析にもとづいて行われるようになっています。

1. Re:dash 導入によるデータの民主化

Before

社員にデータ分析の習慣が定着していき、こぞってデータを見るようになると、データの抽出業務が大量に発生していました。 エンジニアでなくとも CSV ファイルさえあれば Excel で勝手に分析することができるのですが、その CSV ファイルこそがデータエンジニアなしでは用意できないものだったのです。

データ活用者からすると分析をしたいのにデータが手に入るまで時間がかかり、データエンジニアからすると「自分は右から左にデータを渡すだけで手一杯になってしまう」というところに負担やフラストレーションを感じるという、お互いに嬉しくない状態になっていました。

f:id:kitsuyui:20171211175520p:plain

Do

そこで一休では 2016 年にデータウェアハウスと共に Re:dash を社内に導入しました。

Re:dash はデータベースへの接続機能を持っており、登録した SQL を実行することで、きれいなグラフや表を生成することができます。 また CSV や Excel ファイルを生成することができます。 Re:dash は Web アプリケーションであり URL を持つので、 URL を Slack で共有することができます

f:id:kitsuyui:20171211180137p:plain

f:id:kitsuyui:20171211183015p:plain

After

定型的なデータ抽出作業は全て Re:dash のボタンを押すだけで実行できるようになったため、データエンジニアがボトルネックとなることは減っていきました。

また、一休では Re:dash 以外にも Tableau を営業ツールとして導入しており、これもデータエンジニアの負荷を低減しています。

f:id:kitsuyui:20171211175534p:plain

2. データウェアハウスと Embulk

Before

Re:dash によってデータ抽出業務の負荷は大きく下がったのですが、データを観る人が増えたことによって、ますます社内のデータ活用のモチベーションは上がりました。 そのため、基幹データベースからデータウェアハウスにロードしたり、その過程で正規化するタスクしたり、また別のデータベースに移し替えたりという、いわゆる ETL 業務の効率化と安定性が急務になっていました。

しかしながら、個々の ETL のコンフィギュレーションが分散して存在していたため、著しく可搬性・メンテナンス性が損なわれていました。

Do

そこで Embulk の出番です。 embulk では .yml ファイルで ETL (Extract, Transform, Load) の組を記述することができます。

in:
  type: sqlserver
  table: some_table
  ...

out:
  type: sqlserver
  table: some_table
  ...

素晴らしい特徴として、 input と output のインターフェースが完全に独立しているということがあります。 そのため、「データウェアハウスに入れているあのテーブルを BigQuery にもロードしたい」というようなケースでは、単に output だけ変えればそのまま動作します。

After

embulk の導入によって、いろいろなサーバに分散していた ETL 処理とそのコンフィギュレーションを 1 つのシステムとして統合することができました。 また、これらの .yml ファイルは GitHub で管理しているため、 ETL の過程がどのように変化したかを時系列で追うことができます。

3. 肥大化するテーブル定義の山と DatabaseMEMO の導入

社内でデータ分析がいよいよ活性化して、道具が整ってくると、今度はデータの文脈や名称に対しての理解が重要になってきます。

今日まで何度も改良を加えながら受け継いできた基幹のデータベースは 宿泊予約 のサービスで 500 テーブル以上レストラン予約のサービス200 テーブル以上 、その他全社の基幹システムのテーブルは合計で 1,000 テーブルを超え ます。 また、基幹システム以外にもデータ分析用に使うテーブル (500 テーブル以上) や、社内システム用のテーブル定義なども含めると、 2,000 テーブル以上 にもなります。 カラム数はさらにその 10 倍程度 でしょうか。

Before

これら基幹データベースのテーブル定義は、全て SVN 上の Excel ファイルに記述する運用をしていました。 2016 年に SVN から GitHub への切り替えを行ったのですが、 Excel によるテーブル定義は Git での差分検出・マージがしづらい という問題がありました。 また、 Excel ファイル自体の行数・列数が大きくなりすぎ、検索性が下がり開発速度が非常に低下 していました。 この Excel ファイルが含んでいた職人的なマクロはメンテナンス不可能になっていました。

一方で Redash の導入により 営業やマーケターの中にも SQL を書ける方が増え てきました。 しかし、基幹 DB の定義は GitHub に置かれていたため、正しい定義はエンジニアにしか公開されていません でした。 また、 データウェアハウスのテーブル定義がどうなっているのかは、そのテーブルを作った本人以外には誰にもわかりません でした。

Do

そこで DatabaseMEMO (dmemo) を導入しました。 DatabaseMEMO は Cookpad がつくった データベース・テーブル・カラムを Markdown 書式で記述・検索・閲覧できるデータベース専用の Wiki のようなものです。

サーバを設置し、簡単なスクリプトを書いて旧来の Excel によるテーブルの定義をインポートしました。 f:id:kitsuyui:20171211181949p:plain

また、 一休独自のカスタマイズとして、テーブル説明文から GitHub へリンクも付与し、ログインなしでも閲覧までは可能に しました。

After

旧来のテーブル定義の歴史を維持しながらも、より高速に検索したり、気軽に Tips を追記したり ということができるようになりました。 テーブルが ソースコードのどこで使われているかも、瞬時に検索 することができます。

さきほど挙げた Re:dash のメリットと同じですが、 DatabaseMEMO は URL を持つので Slack 上で定義を簡単に受け渡しすることができるようになりました

まとめ

このようにして、データ分析業務に関わる様々なタスクの効率化・高速化・明文化を推し進めています。

世界的にデータ活用が叫ばれている時代の中で、データエンジニアは単にデータ活用の召し使いになるのではなく、だれもがデータ活用できるような風土づくりを率先して行い、社内のデータ活用のハードルを引き下げていく事が重要だと、私は考えています。

一休.com で 1 年半の間に取り組んできた改善内容について

この記事は一休.comアドベントカレンダー2017の 8 日目です。

一休.com の宿泊開発基盤のお手伝いをしている id:shiba-yanです。

はてなインターン時代の縁で naoya さんから声をかけていただき、基本フリーランスですが一休で週に 3 日ほどの作業を 2016 年 4 月から行っています。

最近は shibayan とも一緒に改善を進めている

4ヶ月の間に一休.comで起きた変化 - zimathon blog

2016 年 4 月末から現在までに、一休社内でどのようなことに取り組んできたか、公開できる範囲で思うままに書いていきます。長いです。

ユニットテスト基盤

これまではテストが書ける状態ではなかったのですが、xUnit.net を利用してユニットテストをコンポーネント単位で書けるような仕組みを用意しました。

しかし、残念ながら上手く回っていません。例外的ですが、複雑化した URL Rewrite ルールに対するユニットテストに関しては、上手く回すことができています。チームメンバーの id:minato128 が LT で話したスライドもあります。

URL Rewrite のテストは勢いでライブラリを作り、それをすぐに実践投入しました。クラウド移行のタイミングではこのテストのおかげで不具合が発覚し、非常に助けられています。

新しいメールテンプレート

長く開発されてきた結果、一つのシステム内に複数のメールテンプレートが実装されていて、それがアプリケーション内に分散している状態でした。

さらに条件によって項目が変わるメールは非常に複雑なテンプレートとして定義されていて、メンテナンスが非常に難しい状態になっていたため、ASP.NET MVC で使われている Razor ベースの新しいテンプレートを作成しました。

しかし、残念ながら利用は広がっていません。既にあるテンプレートを移行することを考えると非常に高コストになり、最近では新しくメールを増やすといったことがほぼないのが理由でした。このあたりはヒアリング不足で作業を進めてしまったのが反省点です。

メール配信基盤

これまで一休ではオンプレミスのメールサーバーを利用して来ましたが、一斉メール送信に時間がかかる問題が顕著になり、さらに保守にかかる手間とコストやクラウド移行という課題が出てきたため、AWS 上に新しく実装することになりました。

実装したメール配信基盤の概要は以下のようになります。

  • SendGrid を利用
  • SQS と Elastic Beanstalk を使った非同期処理
  • メールの配信結果は DynamoDB に格納
  • Webhook は API Gateway と Lambda で処理
  • ASP.NET Core を利用して実装

去年から段階的に一休.com のサービスで利用を始め、今ではほぼ全てのメールが AWS 上のメール配信基盤から送信されるようになっています。メール配信基盤に関しても id:minato128 がセッションを行っているので、そちらも参考にしてください。

今回のアドベントカレンダーでも書かれているので、こちらもどうぞ。

初期は多少 DynamoDB の予約スループットを使い切ってしまうことがありましたが、実装の改善により今では全く発生しなくなっています。SendGrid の障害が一時頻繁に発生した時には悩まされましたが、現在は対策を行い安定した運用を行えています。

宿泊クラウド移行

直近の大きな作業として、一休.com サービス全体のクラウド移行がありました。既に一休.com のサービスは全て AWS に移行が完了しているので、皆さんが見ている www.ikyu.com は AWS の東京リージョンから提供されています。

AWS への移行を行うことでサービス自体の可用性が高まったり、柔軟なスケーリングが可能になったりとクラウドのメリットを享受することが出来ていますが、移行完了までには様々な問題が山積みでした。

単純に宿泊のアプリケーションと言っても、正しく動かすためには関係するアプリケーションも同時に移行する必要があります。例えば、実際に宿泊のアプリケーションでは 4 つのアプリケーションを移行する必要がありました。

  • 宿泊サイト本体(www.ikyu.com)
  • マイページ(my.ikyu.com)
  • 管理画面
  • 外部連携用 API

その他にも内部から利用している API が複数あり、宿泊アプリケーションが AWS へ移行するためには、それらを先に AWS へ持っていく必要があったのです。他にもいろいろあり、担当している宿泊基盤チームだけで合計 8 アプリケーションを AWS に移行することになりました。

いきなり巨大な宿泊サイトから行うのは非効率なので、まずは規模の小さいアプリケーションから地道にノウハウを溜めつつ進めることにしました。

移行方法の調査・検証

まずはオンプレミスで動作しているアプリケーションを、どのように AWS 上で実行するかを検討しました。一番単純かつ時間がかからないのはオンプレと同じ構成を EC2 で作成して、今と同じアプリケーションをデプロイする方法でしたが、開発の現場では日々デプロイの問題に悩まされていて、今の状態のまま AWS に持って行っても悪くなるだけなのは、火を見るよりも明らかでした。

当時のデプロイでの辛さは、これもまた id:minato128 が話したスライドがあります。

デプロイ完全自動化から1年で起きたこと /ikyu-deploy // Speaker Deck

さらに当時採用していた Jenkins を利用したデプロイは、リポジトリ内の変更されたファイルのみを対象としていたため、EC2 に持って行った場合にインスタンスごとにファイルの整合性を担保することが非常に難しいことが分かりました。

その他にもたくさんの課題がありましたが、調査と検証を重ねていった結果として以下のように方針を決定しました。

  • オンプレと同じ仕組みは一切持ち込まない
  • EC2 インスタンスは何時でも破棄が出来るように
    • 差分ではなくアプリケーションに必要なファイル全てをデプロイする
  • ビルドには Jenkins を止めて AppVeyor を利用する
    • 一般的な方法でアプリケーションのビルドが行えるように構造を変える

実際に実行する EC2 インスタンスは Elastic Beanstalk を利用して、インスタンスの生成と破棄やデプロイなどを全て任せることにしました。最初は多少デプロイが非効率になったとしても、まずはオンプレ構成からの脱却が重要だと考えました。

実行環境の調査・検証

アプリケーションは Elastic Beanstalk を使って実行する方向に舵を切りましたが、宿泊アプリケーションでは一部に特殊なコンポーネントを利用しているため、事前に開発環境でデプロイ用のパッケージをビルドして、問題なく動作が可能かを検証しました。

当初は ebextensions を利用してインストールを試しましたが、Cloud Formation の実行権限が特殊で上手くいかなかったため、カスタム AMI を作成して運用する方向に決めました。

  • Windows Server 2012 R2 を利用(当時は 2016 がリリースされていなかったため)
  • CircleCI を利用して AMI を自動で生成
    • Windows Update は新しい AMI を作成して対応

出来るだけ手動で行う部分を減らして、運用時の負荷を下げるように工夫しました。CircleCI を使った新しい AMI のビルドは 20 分以内で完了するので、Beanstalk に設定すれば EC2 が自動的に作り直されて作業は完了します。

f:id:shiba-yan:20171206164320p:plain

今回はカスタム AMI を使わざるを得ない状況でしたが、結果的にプロビジョニング時間の短縮に繋がりました。

アプリケーションの分離と整理

一休.com のアプリケーションは ASP.NET で実装されていますが、歴史的経緯から先ほど述べた 4 つのアプリケーションが 1 つのアプリケーションとしてマージされた形で実装されていました。しかし、プロジェクトファイルは別々に存在していたので、このままでは MSBuild を使ってデプロイ用の資材をビルドすることも難しい状態でした。

まずは一つになっているアプリケーションを、役割ごとに分離する作業は必須だと考えました。実際のアプリケーション単位で分離することができれば、後は普通の ASP.NET アプリケーションと同様の方法が使えるからです。

当時は分離が本当に必要なのかという意見が何回か出てきましたが、移行を担当したチーム内では必要な作業だという認識があり、理由の周知を行い対応しました。

f:id:shiba-yan:20171206170722p:plain

まずは Qiita に DesignDoc を作成してフィードバックを貰い、内容に問題が無ければ周知用のエントリとして仕上げるという作業を繰り返し行いました。

実際の作業を行ったのは私なので、その時に方向性を以下のように定めました。要するに普通の ASP.NET アプリケーションに組み替える作業です。

  • 1 つの巨大なアプリケーションを 4 つに分ける
    • 適切な粒度で分離
    • アプリケーション毎にソリューションを用意してフルビルドが簡単に行えるように
  • 参照がアセンブリ直指定になっていた部分をすべて修正
    • アプリケーションの一部であればプロジェクト参照
    • NuGet で配布されている場合はパッケージ参照

実際に開発が日々行われているリポジトリに対して、非常に大きな変更を行うことは開発チーム全体への影響が大きいため、事前のリハーサルを何度も行いビルドやデプロイに影響が出ないように努力しました。

どのくらいの規模だったかは、GitHub の Insights が物語ってくれています。

f:id:shiba-yan:20171207145456p:plain:w400

各アプリケーションを順番に分離していき、作業が完了するまでに 2,3 ヵ月が必要でした。しかしこの分離作業が完了すれば、クラウド移行に必要な作業の 9 割は完了すると考えていたので、あえて時間をかけて丁寧に作業しました。

AppVeyor での CI / CD

アプリケーションの分離を行った結果、フルビルドやデプロイ用パッケージの作成は MSBuild を 1 回実行するだけで完了するようになり、大幅に処理をシンプルにすることが出来ました。

これまでの Jenkins を利用したデプロイでは、環境に依存する Web.config や App.config などの設定ファイルをスクリプトでコピーするような形になっていましたが、その方法を止めて全て Xml Document Transform を使ったビルド時の自動切り替えを利用するようにしました。

Web.config Transformation Syntax for Web Project Deployment Using Visual Studio

よくある Web.Release.config などと同じ方法で、ステージングや本番といった単位で変換ファイルを用意しました。環境ごとにデプロイパッケージを作成する場合も、環境名*1を MSBuild のパラメータに指定するだけで済みます。

ここまでの変更で AppVeyor でも簡単にデプロイパッケージが作れるようになりました。

f:id:shiba-yan:20171206164933p:plain

実際に今では 1 日に数多くのビルドを行い、Elastic Beanstalk へのデプロイを実行しています。

少しビルドとデプロイに時間がかかってしまっているのが課題としてありますが、今後のアプリケーション改善によって短縮を見込んでいます。

本番環境の移行

本番は規模の小さいアプリケーションから順に移行していきましたが、メインとなる宿泊アプリケーションに関してはリクエストが多く、移行作業での障害が発生した場合には多大な影響が発生します。そのため、アプリケーションの移行準備が完了してからの 1 ヵ月ほどは動作検証と負荷テストに時間を割り当てました。

基本的な動作検証には、既に利用している Selenium ベースの E2E テストを利用して実行しました。他にも運用担当者にお願いをして、AWS 上のアプリケーションで普段と同じ業務を試してもらうといった方法で進めていきました。

負荷テストはインスタンスサイズの大きな EC2 を用意し、そこから JMeter を使って特に重要なページと API に関して負荷をかけ、必要なインスタンスサイズと台数を決定しました。この辺りはテストの鬼 id:akasakas がほぼやってくれました。

www.ikyu.com は AWS 移行の前に Fastly への全面的な移行を完了していたため、本番環境を AWS 上に用意してしまえば、後は Fastly 側でバックエンドを切り替えるだけで移行が完了します。

f:id:shiba-yan:20171207122035p:plain

何か意図しない挙動が発生した場合には即座にオンプレに戻すと決めていましたが、そういったことは発生することなく、極めて平和に終わった本番移行でした。

その後の運用

準備と検証をしっかりと行ったため、クラウド移行は特に大きな問題もなく、スムーズにほぼ予定通りに完了することが出来ました。事前の負荷テストによって決定したサイジングもほぼ想定通りでした。

今回上げた内容以外にも、クラウド向けにアプリケーション側で最適な形となるように作業を行った結果、サービス提供に必要なマシン台数も大幅に減らしつつ、可用性を高めることが出来ています。ぱっと思いつくだけでも以下のようなメリットが、AWS への移行で得られました。

  • オートスケーリンググループによる柔軟なリソースの割り当て
  • 不良インスタンスは自動的に破棄、再生成
  • Web サーバーのメンテナンスがほぼ不要に
    • Datadog と New Relic のメトリックを見るぐらいに
  • ホスト OS に対する更新をイミュータブルに実行
    • ローリングアップデートを自動で行い、ヘルスチェックが通らない場合は自動的に元に戻される
  • デプロイが原因となる障害発生なし

半分は Elastic Beanstalk を利用したことによって得られています。一休社内では Elastic Beanstalk を多用していて、今 naoya さんが中心になって進めている、新レストランサービスでも Elastic Beanstalk と Docker が利用されています。

オンプレ時代にデプロイの課題を解決するために作られた Slack チャンネルがアーカイブされたのも、クラウド移行に伴ってデプロイに起因する問題が解消されたことの表れでもあります。

f:id:shiba-yan:20171206165825p:plain

今回、移行には非常に長い時間がかかってしまいましたが、単純に移行するのではなく最適化した形で持って行ったことで、数多くの課題が同時に解決されたと考えています。

移行が完了して 1 ヵ月後に打ち上げを行い、クラウド移行での思い出を語り合いました。

f:id:shiba-yan:20171207144934g:plain

内容と全く関係はありませんが、赤坂には良いレストランが数多くあり、一休.comレストランを使えば簡単に予約することができます。実際に打ち上げ会場は一休レストランで予約しました。

まだまだ積み残した課題は多いですが、なかなか経験できない重要な作業に参加することが出来て、非常に勉強になりましたし楽しかったです。

宿泊アーキテクチャの改善

元々は宿泊アプリケーションのアーキテクチャ改善のために誘われていたのですが、クラウド移行が完了したので最近になってようやく本格的に取り掛かれるようになりました。

クラウド移行のタイミングでプロジェクト間の依存関係を徹底的に整理した結果、Visual Studio と ReSharper を使った機械的な解析が行えるようになりました。現在は naoya さんからの助言もありコードリーディングを深くまで行い、問題点をしっかりと理解してまとめる作業をしています。

まだ始まったばかりですが、アーキテクチャを改善し開発効率だけではなく、それに伴ってパフォーマンスの向上までを目標としています。

終わりに

f:id:shiba-yan:20171207145258p:plain

明日は juri-t さんによる「最近流行りのword2vecをLDAと比較してみた」です。

*1:Production / Staging など

GoとSQL Server

この記事は、[一休.comアドベントカレンダー2017]の7日目です。

qiita.com

こんにちは、データサイエンス部・大西 id:ohke です。
ユーザの行動収集基盤や、マーケティング施策の実行を支援するシステムの開発・メンテナンスを担当しています。

7日目の本投稿では、GoでSQL Serverを使う方法について、紹介したいと思います。

なぜGoとSQL Serverなのか

メジャーじゃない組み合わせだと思いますので、なぜGoからSQL Serverを使うことになったのか、背景を補足します。

今年に入り、一休ではデータウェアハウス基盤をクラウド環境に構築しました。 この基盤では、リアルタイムな行動ログを含む、マーケティングに必要なデータを全てSQL Sever(Amazon RDS)に集約しています。

この基盤を使った施策の一貫として、ユーザのリアルタイムな行動を分析し、今一休に訪れているユーザへ1 to 1マーケティングを検討しています。 具体的には、サイトやアプリでのメッセージの通知などです。

ブラウザ、フロントエンドサーバ、アプリなど、様々なアプリケーションからアクセスされるため、Web APIで共通したインタフェースを提供するのがセオリーですが、今いるユーザを対象とした施策においてはPV数に比例したアクセスが予想されます。 こうした高負荷に耐えるための環境として、行動ログの収集でも実績があるGoをAPIサーバとして構えることになりました。
行動ログ収集の取り組みについて興味のある方は、こちらもご覧ください(ちなみに、当時はSQL Serverではなく、Azure SQL Data Warehouseをデータウェアハウスとして採用してました。変更された経緯については、 12/20投稿予定の「データ分析基盤、その後 id:sisijumi 」で触れます)。

2017-08-17-_DataAnalyticsPlatform.pdf // Speaker Deck

また、可能な限りリアルタイムな行動ログを使う(目標タイムラグは1分以内)ため、データマートなどを介在させずに、Goで動くAPIサーバからSQL Serverに直接アクセスする必要があり、今回の調査に至りました。

生のSQLを実行する

Goでは、database/sqlという標準ライブラリがSQL(-like)なインタフェースを提供しています。 DB製品ごとのドライバと組み合わせることで、DBへ直接SQLを実行できるようになります。

SQL Serverのドライバとして、go-mssqldbgofreetdsの2つがあります。

  • go-mssqldbはGo単体で実装されていますが、gofreetdsはcgo(GoからCのコードをコンパイルしたり、CのライブラリをリンクできるようになるGoのビルド機能)を使ってます
  • メジャーなのは、go-mssqldbのようです(GitHubスター比較)

今回はgo-mssqldbについて解説していきます。

https://github.com/denisenkom/go-mssqldb

go-mssqldb

パッケージをダウンロードしておきます。

> go get github.com/denisenkom/go-mssqldb

まずは接続です。ポイントは3点です。

  • go-mssqldbをブランクインポート(_)して、SQL Serverのドライバで初期化します
    • これでdatabase/sqlのインタフェースでSQL Serverにアクセスできるようになります
  • 接続文字列は3パターンで記述できますが、パスワードに;(セミコロン)を含む場合は2番目か3番目を選択する必要があります
    • server=testdb.ikyu.jp;user id=ohke;password=p@ssw0rd;database=TestDB
    • odbc:server=testdb.ikyu.jp;user id=ohke;password=p@ssw0rd;database=TestDB
      • パスワードに;を含む場合は、password={p@ss;word}のように{}で括る
    • sqlserver://ohke:p@ssw0rd@testdb.ikyu.jp?database=TestDB
      • パスワードに;を含む場合は、p@ss%3Bw0rdのようにURLエンコードする
  • sql.Open()の第1引数に"sqlserver"または"mssql"を指定して接続します
    • 2つは基本的に同じですが、クエリパラメータの渡し方に違いがあります(後述)
package main

import (
    "database/sql"
    
    _ "github.com/denisenkom/go-mssqldb"
)

func main() {
    connectionString := "sqlserver://ohke:p@ssw0rd@testdb.ikyu.jp?database=TestDB"

    // 接続
    // "sqlserver"の代わりに"mssql"でもOK
    connection, err := sql.Open("sqlserver", connectionString)
    if err != nil {
        return nil, err
    }
    
    // 切断
    defer connection.Close()

    // CRUD処理を記述
    // ...
}

接続すれば、database/sqlのインタフェースに則って、SQLを実行できます。

まずはselectです。

  • 1行のselectならQueryRow、複数行のselectならQueryと使い分けます
  • ドライバに"sqlserver"を指定した場合、@で始まるパラメータ名をSQLに埋め込み、sql.NamedArg構造体でパラメータに渡す値を設定します
    • "mssql"で指定した場合、?nをSQLに埋め込み、2つ目以降の引数で?nに渡す値を設定します(?1ならば2番目の引数、?2なら3番目の引数の値が渡されます)
   // select(1行)
    row := connection.QueryRow(`
      select name, registered_at, valid from members
          where member_id = @member_id`,
        sql.NamedArg{Name: "member_id", Value: 1})
    // ドライバに"mssql"を指定した場合
    // row := connection.QueryRow(`select name, registered_at, valid from members where member_id = ?1`, 1)

    var name string
    var registeredAt time.Time
    var valid bool
    if err := row.Scan(&name, &registeredAt, &valid); err != nil {
        return
    }
    fmt.Println(name, registeredAt, valid)

    // select(複数行)
    rows, err := connection.Query(`select name from members`)
    if err != nil {
        return
    }

    defer rows.Close()
    for rows.Next() {
        if err := rows.Scan(&name); err != nil {
            return
        }
        fmt.Println(name)
    }

続いて更新処理です。

  • 更新(insert、update、delete)はExecメソッドで実行します
  • Resultオブジェクトが返されます
    • RowsAffected()で、処理された行数を取得できます
    • LastInsertId()で、挿入時のidentityの主キー値が取得できます
      • 設定されていない場合は-1
   // insert
    if result, err := connection.Exec(`
      insert into members (member_id, name, registered_at, valid)
          values (@member_id, @name, @registered_at, @valid)`,
        sql.NamedArg{Name: "member_id", Value: 1},
        sql.NamedArg{Name: "name", Value: "onishik"},
        sql.NamedArg{Name: "registered_at", Value: time.Now()},
        sql.NamedArg{Name: "valid", Value: true}); err == nil {
        insertedNumber, _ := result.RowsAffected()
        insertedId, _ := result.LastInsertId()
        fmt.Println(insertedNumber, insertedId)
    }
 
    // update
    connection.Exec(`
      update members set valid = @valid
          where member_id = @member_id`,
        sql.NamedArg{Name: "valid", Value: false},
        sql.NamedArg{Name: "member_id", Value: 1})

    // delete
    connection.Exec(`
      delete members where member_id = @member_id`,
        sql.NamedArg{Name: "member_id", Value: 1})

ORMでアクセスする

ORMを使う方法もあります。

Goでは、gormxormgorpなど幾つか選択肢がありますが、一番メジャー(GitHubスター比較)で、かつ、SQL Serverにも対応しているgormに触れていきます。

https://github.com/jinzhu/gorm

gorm

パッケージをダウンロードしておきます。

> go get github.com/jinzhu/gorm

まずは接続です。

  • gormに加えて、gorm/dialects/mssqlをブランクインポートします
    • gorm/dialects/mssqlでSQL Server独自の型(bit型など)や処理(SET IDENTITY_INSERTなど)が吸収しています
    • 内部的にはgo-mssqldbをドライバとして使っています
  • 構造体とテーブルレコードがマッピングされます
    • デフォルトではActiveRecordやEntityFrameworkと類似した名前のマッピングが行われます(もちろん変更できます)
      • "構造体名+s"がテーブル名にマッピングされます(Member→members)
      • キャメルケースはスネークケースに変換してマッピングされます(MemberID→member_id)
  • gorm.Open()で接続しますが、第1引数は"mssql"とします
    • ちなみに"sqlserver"は不可です
package main

import (
    "fmt"
    "time"

    "github.com/jinzhu/gorm"

    _ "github.com/jinzhu/gorm/dialects/mssql"
)

// レコードの定義
type Member struct {
    MemberID     int       `gorm:"primary_key"`
    Name         string    `gorm:"type:nvarchar(256);name:name"`
    RegisteredAt time.Time `gorm:"type:datetime2;name:registered_at"`
    Valid        bool      `gorm:"type:bit;name:valid"`
}

func main() {
    connectionString := "sqlserver://ohke:p@ssw0rd@testdb.ikyu.jp?database=TestDB"

    // 接続
    db, err := gorm.Open("mssql", connectionString)
    if err != nil {
        panic(err.Error())
    }

    // 切断
    defer db.Close()

    // CRUD処理を記述
    // ...
}

CRUDも概観してみましょう。 いずれも上で取得したDBオブジェクトを使います。

  • 1行のselectであればFirst()、複数行のselectではFind()を使います
  • DBオブジェクトを返すので、FindしてDelete、といった処理も書きやすいです
   // select(1件)
    var member Member
    db.First(&member, 1)

    fmt.Println(member.Name, member.RegisteredAt, member.Valid)

    // select(複数行)
    members := []Member{}
    db.Find(&members, "valid=?", true)

    for _, m := range members {
        fmt.Println(m.Name, m.RegisteredAt, m.Valid)
    }

    // insert
    insertedMember := Member{MemberID: 2, Name: "akasakas", RegisteredAt: time.Now(), Valid: true}
    db.Create(&insertedMember)

    // update
    member.Valid = false
    db.Save(&member)

    // delete
    db.Delete(&member)

    // DBオブジェクトを返すのでメソッドチェーンで繋げることもできます
    db.Find(&members, "valid=?", true).Delete(&members)

ここでは紹介しませんでしたが、リレーション定義やマイグレーション等の一般的な機能も提供されています。

おわりに

本投稿では、GoからSQL Serverにアクセスする方法として、生のSQLを実行する方法(go-mssqldb)とORMを使う方法(gorm)を紹介しました。

明日は id:shiba-yan さんによる「一休.com で 1 年半の間に取り組んできた改善内容について」です。

Solr JSON Facetのススメ

この記事は一休.comアドベントカレンダー2017の6日目です。

一休.comレストラン 検索・集客担当のにがうりです。

一休.com、一休.comレストランともに、検索には主にSolrを利用しています。 (一部、RDBで検索しているところもあります)
RDB(SQL)ベースでの検索と比べると色々とメリットがありますが、その中でもファセットナビゲーションに必要な機能が揃っているのは大きな魅力と言えるでしょう。

ファセット例

f:id:ikyu_com:20171204014502p:plain:w300 f:id:ikyu_com:20171204014901p:plain:w250

Solr5.xからは、旧来のファセットとは異なるJSON Facetという機能が新たに提供されており、特に問題(後述の注意点を参照)が無いのであれば、こちらのほうが利用しやすいでしょう。
しかし、JSON FacetはSolrのサイト上では言及がなく、開発者のサイトがドキュメントになっている状況のためか、いまいちマイナーな存在に留まっているように感じます。
このエントリでは JSON Facetについて、旧来のファセットとの比較を混ぜながら、基本的な使い方、応用例、注意点について紹介します。
なお、本エントリで利用しているバージョンはエントリ作成時点の最新版、7.1.0を前提としています。

基本的な使い方

レストランを登録した ikyu-advent-2017-restaurant コアに対し、以下のようなデータが入っているとします

レストランID
(restaurant_id)
レストラン名
(restaurant_name)
ジャンル1
(genres)
サブジャンル1
(sub_genres)
ジャンル2
(genres)
サブジャンル2
(sub_genres)
都道府県
(prefecture)
市区町村
(city)
11AAAA洋食洋食-フレンチ東京都銀座
12BBBB和食和食-京料理和食和食-懐石料理東京都赤坂
13CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座
14DDDDその他その他-ラウンジその他その他-ブッフェ東京都品川
15EEEE和食和食-寿司東京都銀座
16FFFF和食和食-寿司和食和食-天ぷら東京都銀座
17GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜

※ 以下3点に留意

  • 都道府県 - 市区町村は親子関係であること
  • ジャンル - サブジャンルも親子関係であること
  • さらに、ジャンル-サブジャンルはそれぞれ2つ登録可能であること (MultiValueにしている)

試しに、このデータが入った状態のクエリを実行してみましょう http://localhost:8983/solr/ikyu-advent-2017-restaurant/select?echoParams=none&q=*:*&fl=restaurant_id,restaurant_name,genres,sub_genres,prefecture,city&rows=2

結果

{
  "responseHeader": {
    "status": 0,
    "QTime": 0
  },
  "response": {
    "numFound": 7,
    "start": 0,
    "docs": [{
      "restaurant_id": "11",
      "restaurant_name": "AAAA",
      "genres": ["洋食"],
      "sub_genres": ["洋食-フレンチ"],
      "prefecture": "東京都",
      "city": "銀座"
    }, {
      "restaurant_id": "12",
      "restaurant_name": "BBBB",
      "genres": ["和食", "和食"],
      "sub_genres": ["和食-京料理", "和食-懐石料理"],
      "prefecture": "東京都",
      "city": "赤坂"
    }]
  }
}

データが取得できました。ジャンル、サブジャンルは配列で返却されています。

従来のファセットを実行

このデータに対して、従来の方法でファセットを取得してみましょう。
取得対象はジャンル、サブジャンル、都道府県、市区町村の4つです。 (冗長になるためレストラン一覧の取得は抑制)

クエリ

http://localhost:8983/solr/ikyu-advent-2017-restaurant/select?echoParams=none&q=*:*&rows=0&facet=true&facet.field=prefecture&facet.field=city&facet.field=genres&facet.field=sub_genres

結果

{
  "responseHeader": {
    "status": 0,
    "QTime": 0
  },
  "response": {
    "numFound": 7,
    "start": 0,
    "docs": []
  },
  "facet_counts": {
    "facet_queries": {},
    "facet_fields": {
      "prefecture": [
        "東京都", 6,
        "神奈川県", 1
      ],
      "city": [
        "銀座", 4,
        "品川", 1,
        "横浜", 1,
        "赤坂", 1
      ],
      "genres": [
        "和食", 4,
        "その他", 2,
        "洋食", 2
      ],
      "sub_genres": [
        "和食-寿司", 3,
        "その他-ラウンジ", 2,
        "その他-ブッフェ", 1,
        "和食-京料理", 1,
        "和食-天ぷら", 1,
        "和食-懐石料理", 1,
        "洋食-イタリア料理", 1,
        "洋食-ステーキ・グリル料理", 1,
        "洋食-フレンチ", 1
      ]
    },
    "facet_ranges": {},
    "facet_intervals": {},
    "facet_heatmaps": {}
  }
}

取得できているのは良いのですが、大きく2つの問題があります。

  1. "prefecture":["東京都", 6,"神奈川県", 1] のように、1つの配列に対して key1, value1, key2, value2 ... という入り方をしているため、処理がしにくい
  2. 本来親子関係であるべき、都道府県と市区町村の親子関係が判断できない

このうち2についてはジャンル-サブジャンルのように子階層に親階層の情報を付与してあげることで回避可能ですが、1については我慢するしかありません。
しかし、JSON Facetならこの両方が解決できます。

JSON Facetを実行

クエリ

http://localhost:8983/solr/ikyu-advent-2017-restaurant/select?echoParams=none&q=*:*&rows=0&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{city:{type:terms,field:city,limit: -1}}}}&json.facet={genres:{type:terms,field:genres,limit:-1,facet:{sub_genres:{type:terms,field:sub_genres}}}}

※ 都道府県/市区町村のファセット指定を見やすく加工すると以下の通り

json.facet={ 
  prefecture: {           /* レスポンス時の項目名(任意) */
    type: terms,        /* ファセットの単位を値に */
    field: prefecture,  /* ファセットの対象となる項目(都道府県) */
    limit: -1,           /* 全件取得 */
    facet: {
      city: {         /* ここから子階層  */
        type: terms,
        field: city,        /* ファセットの対象となる項目(市区町村) */
        limit: -1
      }
    }
  }
}

結果

{
  "responseHeader":{
    "status":0,
    "QTime":9},
  "response":{"numFound":7,"start":0,"docs":[]
  },
  "facets":{
    "count":7,
    "prefecture":{
      "buckets":[{
          "val":"東京都",
          "count":6,
          "city":{
            "buckets":[
              {"val":"銀座", "count":4},
              {"val":"品川", "count":1},
              {"val":"赤坂", "count":1}]}},
        {
          "val":"神奈川県", "count":1,
          "city":{
            "buckets":[{
                "val":"横浜",
                "count":1}]}}]},
    "genres":{
      "buckets":[{
          "val":"和食",
          "count":4,
          "sub_genres":{
            "buckets":[
              {"val":"和食-寿司",      "count":3},
              {"val":"その他-ラウンジ", "count":1},
              {"val":"和食-京料理",    "count":1},
              {"val":"和食-天ぷら",    "count":1},
              {"val":"和食-懐石料理",  "count":1}]}}
              /**** 〜 以下略 〜 ****/
              ]}}}

ご覧の通り、

  1. {"val":key, "count":value}の組み合わせで表現されているため処理がしやすい
  2. 都道府県と市区町村の親子関係が表現できている

と、見事に前述の問題が解決できています。

ただし、ジャンル - サブジャンルの親子関係については、「和食」の下に「その他」が混在するという、期待とは裏腹な状態になっています。
残念ながら、こちらは親子関係の親がMultiValueになっている限り回避はできません。
従来のファセット同様個別にファセットの指定を行い、アプリケーション側で親子関係を処理する他無さそうです。

応用例

ところで、一休.comレストランはレストランの「プラン」を予約するサイトです。
つまり、予約検索で表示される一覧は「レストラン」単位ですが、実際に検索しているデータはプラン単位です。
そのため、データもレストランではなくプランが軸になります。 (実際には更に日付、時間、人数、席の有無といった軸も考慮する必要がありますが、複雑になるためここでは割愛します)

ikyu-advent-2017-plan コアのデータ

idレストランID
(restaurant_id)
レストラン名
(restaurant_name)
ジャンル1
(genre)
サブジャンル1
(sub_genre)
ジャンル2
(genre)
サブジャンル2
(sub_genre)
都道府県
(prefecture)
市区町村
(city)
プランID
(plan_id)
プラン名
(plan_name)
時間帯
(time)
価格
(price)
個室
(private_room)
夜景確定
(nightview)
飲み放題
(free_flow)
11-110111AAAA洋食洋食-フレンチ東京都銀座1101クリスマスディナーディナー8000110
11-110211AAAA洋食洋食-フレンチ東京都銀座1102クリスマスランチランチ4000100
11-110311AAAA洋食洋食-フレンチ東京都銀座1103アフタヌーンティーランチ2500000
11-110411AAAA洋食洋食-フレンチ東京都銀座1104平日限定スパークリング飲み放題!ディナー4000001
12-120112BBBB和食和食-京料理和食和食-懐石料理東京都赤坂1201おばんざいのセットランチ3000000
12-120212BBBB和食和食-京料理和食和食-懐石料理東京都赤坂1202おまかせコースディナー7000100
12-120312BBBB和食和食-京料理和食和食-懐石料理東京都赤坂1203おまかせコース飲み放題付ディナー9000101
13-130113CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1301【ワンドリンク付】プリフィクスランチランチ3000000
13-130213CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1302極上の短角牛ステーキ300グラム!ランチ4000000
13-130313CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1303【飲み放題付き】選べるパスタ・ステーキを含む6種のディナーディナー8000100
13-130413CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1304【Xmas】乾杯シャンパン付!上州牛の極上ステーキとデザートのセットディナー3000000
14-140114DDDDその他その他-ラウンジその他その他-ブッフェ東京都品川1401ブッフェランチランチ2000000
14-140214DDDDその他その他-ラウンジその他その他-ブッフェ東京都品川1402【忘年会におすすめ!!】50種類から好きに選べるディナーブッフェ!ディナー5000011
15-150115EEEE和食和食-寿司東京都銀座1501握り10貫ディナー7000100
15-150215EEEE和食和食-寿司東京都銀座1502握り8貫。お造り、焼き物付きディナー8500100
16-160116FFFF和食和食-寿司和食和食-天ぷら東京都銀座1601握りと天ぷらのコースディナー5000001
16-160216FFFF和食和食-寿司和食和食-天ぷら東京都銀座1602握りのコースディナー4500001
17-170117GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜1701【夜景確定】クリスマスディナーディナー9000010
17-170217GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜1702クリスマスディナーディナー7000100
17-170317GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜1703平日限定ディナーディナー5000001

このデータに対して、都道府県、市区町村のJSON Facetを実行してみましょう

JSON Facetを実行

クエリ

http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{city:{type:terms,field:city}}}}

結果

{
  "responseHeader":{
    "status":0,
    "QTime":0},
  "response":{"numFound":20,"start":0,"docs":[]
  },
  "facets":{
    "count":20,
    "prefecture":{
      "buckets":[{
          "val":"東京都",
          "count":17,
          "city":{
            "buckets":[{
                "val":"銀座",
                "count":12},
              {
                "val":"赤坂",
                "count":3},
              {
                "val":"品川",
                "count":2}]}},
        {
          "val":"神奈川県",
          "count":3,
          "city":{
            "buckets":[{
                "val":"横浜",
                "count":3}]}}]}}}

これはいけません。1行の単位がプランになった関係で、ファセットの数も「プランの数」になってしまいました。
Result Groupingを使いデータをレストラン単位で表現するようにしましょう

&group=true&group.field=restaurant_id&group.ngroups=true&group.truncate=true

※ Result Groupingについては本稿の主旨とは異なるため説明は割愛します。 エメラルドアオキロックさんのエントリ がオススメ

Result Grouping + JSON Facetを実行

クエリ

http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{city:{type:terms,field:city}}}}&group=true&group.field=restaurant_id&group.truncate=true&group.ngroups=true

group.truncate=trueでファセットもグルーピングの単位で返却、group.ngroups=true でグループ単位の検索件数も返却になります。

結果

{
  "responseHeader":{
    "status":0,
    "QTime":1},
  "grouped":{
    "restaurant_id":{
      "matches":20,
      "ngroups":7,
      "groups":[]}},
  "facets":{
    "count":7,
    "prefecture":{
      "buckets":[{
          "val":"東京都",
          "count":6,
          "city":{
            "buckets":[{
                "val":"銀座",
                "count":4},
              {
                "val":"品川",
                "count":1},
              {
                "val":"赤坂",
                "count":1}]}},
        {
          "val":"神奈川県",
          "count":1,
          "city":{
            "buckets":[{
                "val":"横浜",
                "count":1}]}}]}}}

無事、ファセットの件数がレストラン単位になりました。

プランの情報をJSON Facetで取得

グルーピングはそのままに、プランの情報である夜景確定もファセットで取得してみます

クエリ

http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&json.facet={nightview:{type:terms,field:nightview,limit:-1}}}&group=true&group.field=restaurant_id&group.truncate=true&group.ngroups=true

結果

{
  "responseHeader":{
    "status":0,
    "QTime":5},
  "grouped":{
    "restaurant_id":{
      "matches":20,
      "ngroups":7,
      "groups":[]}},
  "facets":{
    "count":7,
    "private_room":{
      "buckets":[{
          "val":false,
          "count":5},
        {
          "val":true,
          "count":2}]}}}

夜景確定はプラン毎に異なる情報であるにも関わらず、レストランの数が返ってしまいました。このようなケースでは &group.truncate=true では無理があるようです。

レストラン単位のResult Groupingにプランのファセットも思惑どおり追加する方法

クエリ

http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&group=true&group.field=restaurant_id&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{restaurant_count:"unique(restaurant_id)"},city:{type:terms,field:city,facet:{restaurant_count:"unique(restaurant_id)"}}}}}&json.facet={nightview:{type:terms,field:nightview,limit:-1,facet:{restaurant_count:"unique(restaurant_id)"}}}}&group.ngroups=true

&group.truncate=trueを外し、restaurant_count: "unique(restaurant_id)" を追加しています。restaurant_id でユニークを取った数がrestaurant_countとして返却される、という理屈です。

結果

{
  "responseHeader":{
    "status":0,
    "QTime":3},
  "grouped":{
    "restaurant_id":{
      "matches":20,
      "ngroups":7,
      "groups":[]}},
  "facets":{
    "count":20,
    "prefecture":{
      "buckets":[{
          "val":"東京都",
          "count":17,
          "restaurant_count":6},
        {
          "val":"神奈川県",
          "count":3,
          "restaurant_count":1}]},
    "nightview":{
      "buckets":[{
          "val":false,
          "count":17,
          "restaurant_count":7},
        {
          "val":true,
          "count":3,
          "restaurant_count":3}]}}}

これで、都道府県 / 市区町村はレストランの数、夜景確定はtrue / falseそれぞれに「該当するプランを持っているレストランの数」が返却されました。

注意点

値の信頼性について

場合によっては大きな問題を招く可能性があります。

SolrのJSON Facetは必ずしも正確なカウント数を返さない

ただし、Shardingをしていないかぎりは問題ないはずです。

機能の安定性について

公式に言及が無い機能のため安定性が気になるところでしたが、幸い、導入してから1年ほど安定稼働しています

おわりに

以上、いまいちマイナー?なJSON Facetについての紹介でした。

最後に宣伝です。
クリスマスのお店を決め兼ねている方、唐突に忘年会の幹事に指名されてしまった方、是非、一休.comレストランで予約してください。まだ間に合います!

restaurant.ikyu.com

restaurant.ikyu.com

明日は ohke さんによる GoとSQL Server です。

メール配信基盤のモニタリングと障害リカバリーについて

この記事は一休.comアドベントカレンダー2017の5日目です。

宿泊事業部 Platformチーム*1id:minato128 です。今年一休ではクラウド移行に伴い、メール配信の仕組みを大きく変えました。詳しくは一休✕bitFlyer C#をつかったサービス開発の裏側でお話したスライドがこちらにありますので、興味のある方はご覧ください。

新メール配信基盤への移行 /ikyu-mail-platform // Speaker Deck

さて、宿泊予約やレストラン予約のサービスを提供している一休では、メールをユーザーに届けることはとても大切です。特に予約完了メールが届かなかった場合、(メール以外の確認方法もあるとはいえ)予約が取れたことに気づかず、最悪ユーザーが2重に予約をしてしまう可能性もあります。*2 そこでメールを届けるために、どのようにメール配信基盤のモニタリングや障害が起きたときのリカバリーを行っているかを紹介したいと思います。

f:id:minato128:20171204145554p:plain

前提として

現在このようにメール配信を行っています。 また、日次のトランザクションメールは 13~15万通ほどです。

  1. 各アプリケーションから Cloud Queue (SQS) に Message (JSON) を Enqueue する
  2. Worker (Elastic Beanstalk) で Message を処理して SendGrid Web API で送信する
    • 成功したら Message を メールログ(DynamoDB) に保存する
    • n 回エラーになったら Dead Letter Queue (以下 DLQ とします) に入るように設定
  3. SendGrid Event Webhook を AWS Lambda で受けてメールログに送信ステータスを反映する

f:id:minato128:20171201172336p:plain

モニタリング

このようなアラートを設定しています。大抵の場合、Datadog のアラートで何かが起きていることがわかり、Logentries のエラーログで原因がわかります。

  • Datadog
    • Worker の状態異常アラート
    • Queue の Message 遅延アラート
    • DLQ の存在アラート
  • Logentries
    • Worker のアプリケーションエラーアラート

f:id:minato128:20171201172621p:plain

f:id:minato128:20171201172611p:plain

障害リカバリー

SendGrid だけでなくアプリのバグや AWS など障害によってメールが送れなかったとき、復旧時にメールを再送してあげる必要があるので、このような仕組みを用意しています。

Worker で Message が処理できず、DLQ に入ってしまったとき

これはそもそも SendGrid まで届いていないケースで、DLQ から送信用の Queue へ Message を移せるようにしています。Message の移動だけなのでわざわざ用意するほどでもないう気もしますが、障害はいつ起こるかわからないし誰が対応するかもわからないので簡単にできるようにしておいたほうがいいでしょう。

メール DLQ 管理画面

f:id:minato128:20171201172637p:plain

特定条件の Message を再送するとき

これは SendGrid まで届いているけれど送れていないかもしれないケース、もしくは単純に再送したいケースで、送信状態や時間帯で DynamoDB からログを抽出して送信用の Queue へ移せるようにしています。

個別再送

  • 送信履歴検索から本文を参照し、再送ボタンを押すとそのメールだけ再送できる
  • 主にCSチームで使用

f:id:minato128:20171201172712p:plain

一括再送

  • ログを検索して件数とプレビューを表示し、再送ボタンを押すと一括再送できる
  • 大きめの障害のときに使用

f:id:minato128:20171201172729p:plain

その他

送信ステータスが更新されない送信ログの検知

なぜか送信ステータス更新ができていないことが稀にあるため、AWS Lambda で日次実行してログが存在したら通知するようにしています。(SendGrid が不調のときにも発生しますが、正確な原因はまだわかっていません)

f:id:minato128:20171204143658p:plain

施設からユーザー宛のメールがバウンスしたときに、送信失敗お知らせメールを自動送信

※こちらはモニタリングや障害リカバリーとは関係ありませんが、新しいメール配信基盤の運用開始後にわかったこと*3として書きます。おまけと思ってください。

前提として、施設さまや店舗さま(ホテルやレストランなど。以下施設とします。)からユーザーへメールを送信する機能があります。*4

施設管理のメール送信画面 f:id:minato128:20171201172809p:plain

オンプレ時代は Return-Path が施設のアドレスに設定されていたため、施設側がユーザーへのメールが届かなかったときはバウンスメールが返ってきて気づけたのですが、バウンス管理を SendGrid に委譲したためそれができなくなってしまいました。そこで、

  • SendGrid Event Webhook を AWS Lambda で受けてメールログに送信ステータスを反映する

という既存処理のなかに「施設からユーザー宛のメール」かどうかを判定し、送信用の Queue に バウンス通知メッセージを Enqueue するという処理を追加しました。これで送信失敗お知らせメール(バウンスメール相当)を自動送信できるようになりトラブルが減少しました。

f:id:minato128:20171201172823p:plain


明日は @nigauryyy さんの「JSON Facetのススメ」です。

qiita.com

*1:開発効率を上げるための改善やクラウドインフラ構築、全社共通のAPI開発などを行っています。

*2:実際にメールの配信遅延が起こったときにたまに発生します

*3:考慮漏れとも言えますが

*4:ちなみにユーザーのメールアドレスは施設にはわからないようになっています。(ユーザーがメールを返信しない限りは)