一休.com Developers Blog

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

テキストデータのかさましを実装する

はじめに

データサイエンス部の平田です。 ディープラーニングのモデルを作る際、学習データが少ないことが原因で精度が上がらない場合、データのかさまし(augmentation)を行うことがあります。

画像の場合は、オリジナルに対して回転させたりノイズを少し加えることで同じラベル付けがされている別の画像を作り出すことができ、それを学習データに加えることで頑健なモデルになります。

ただし、テキストの場合は回転させると意味不明になるのでどういう操作をしてかさましするかというのを考える必要があります。

そこで、EDA(Easy Data Augmentation)というものが考案されました。参考

  • Synonym Replacement:文中の単語の内n個、同義語に置き換える
  • Random Insertion:文中の単語をランダムに選んで同義語にしてランダムな場所にinsert、n回繰り返す
  • Random Swap:文中の二つを入れ替える n回繰り返す
  • Random Deletion:確率αでランダムにそれぞれの単語削除

上4つの操作の内どれか一つをランダムに行って一つの文から沢山の文を生成する手法です。n=α×テキストの単語数とします。αは自由に変えられるハイパーパラメータで、文章の内どのくらいの割合で単語を操作するかという意味になります。 論文中では英文のデータセットで行っていましたが今回は日本語の文で試してみました。また、同義語に置き換える方法をWordNetからとChive(Word2vec)からの2通り試しています。

実装

EDA

まず文中の単語を分けるために、分かち書きをする必要があるのですが、その前に単語を引数に取って同義語を返す関数を定義します。 Wordnetから「Japanese Wordnet and English WordNet in an sqlite3 database」をダウンロードしてきます。

conn_sqlite = sqlite3.connect("wnjpn.db")
re_alnum = re.compile(r'^[a-zA-Z0-9_]+$')

# 特定の単語を入力とした時に、類義語を検索する関数
def search_similar_words(word):
    # 問い合わせしたい単語がWordnetに存在するか確認する
    cur = conn_sqlite.execute("select wordid from word where lemma='%s'" % word)
    word_id = 99999999  #temp 
    for row in cur:
        word_id = row[0]

    # Wordnetに存在する語であるかの判定
    if word_id==99999999:
        return []

    # 入力された単語を含む概念を検索する
    cur = conn_sqlite.execute("select synset from sense where wordid='%s'" % word_id)
    synsets = []
    for row in cur:
        synsets.append(row[0])

    words = []
    for synset in synsets:
        cur3 = conn_sqlite.execute("select wordid from sense where (synset='%s' and wordid!=%s)" % (synset,word_id))
        for row3 in cur3:
            cur3_1 = conn_sqlite.execute("select lemma from word where wordid=%s" % row3[0])
            for row3_1 in cur3_1:
                words.append(row3_1[0])

        
    return list(set([w for w in words if not re.search(re_alnum, w)]))

例えば「美味しい」の同義語は、

print(search_similar_words('美味しい'))
# => ['快い', 'きれい', '素適', '善い', '好いたらしい', 'いい', '好い', 
'ナイス', '心地よい', '可愛い', '麗しい', '旨味しい', '好ましい', 
'すてき', '良い', '綺麗', 'よい']

こんな感じになります。美味しいと可愛いは同義語?というのは置いといて、ポジティブなワードが並んでいます。

実装は@pocket_kyotoさんの記事を参考にしました。英単語が混じっているので取り除く処理もしています。 のちほど比較のためにべつの同義語取得関数も作ります。

続いて文を単語に分解する分かち書きですが、使いやすいsudachiを利用することにします。

pip install sudachipy==0.5.2
pip install sudachidict_core
from sudachipy import tokenizer
from sudachipy import dictionary

tokenizer_obj = dictionary.Dictionary(dict_type="core").create()
mode = tokenizer.Tokenizer.SplitMode.A

以上でsudachiライブラリの導入ができました。上記4つの操作をランダムに行うEDAをごりごり実装していきます。同義語を抽出する単語は名詞と動詞、形容詞だけにします。また、単語を変換する際、動詞の活用は気にせず終止形だけ使うようにします。

alpha = 0.3 # 単語を操作する割合
N_AUG = 16 # 一文から増やす文章の数(倍率)、500: 16, 2000: 8, 5000: 4
def synonym_select(target_token):
    if target_token.part_of_speech()[0] == '動詞' or \
        (target_token.part_of_speech()[0] == '形容詞' and target_token.part_of_speech()[1] == '一般'):
        target_word = target_token.dictionary_form()
    else:
        target_word = target_token.surface()

    synonyms = search_similar_words(target_word)

    if len(synonyms) > 0:
        replacement = random.choice(synonyms)
    else:
        # 同義語が無かったら置換しない
        replacement = target_token.surface()
    
    return replacement

# Synonym Replacement 文中の単語の内n個、同義語に置き換える
def synonym_replacement(text_tokens, text_split, text_length):
    n = math.floor(alpha * text_length)
    indexes = [i for i in range(text_length) if text_tokens[i].part_of_speech()[0] in ['名詞', '動詞', '形容詞']]
    target_indexes = random.sample(indexes, min(n, len(indexes)))
    
    for i in target_indexes:
        target_token = text_tokens[i]
        replacement = synonym_select(target_token)
        text_split[i] = replacement
        
    return text_split

# Random Insertion 文中の単語をランダムに選んで同義語にしてランダムな場所にinsert、n回繰り返す
def random_insertion(text_tokens, text_split, text_length):
    n = math.floor(alpha * text_length)
    indexes = [i for i in range(text_length) if text_tokens[i].part_of_speech()[0] in ['名詞', '動詞', '形容詞']]
    target_indexes = random.sample(indexes, min(n, len(indexes)))
    
    for i in target_indexes:
        target_token = text_tokens[i]
        replacement = synonym_select(target_token)
        random_index = random.choice(range(text_length))
        text_split.insert(random_index, replacement)
        
    return text_split

# Random Swap 文中の二つを入れ替える n回繰り返す
def random_swap(text_split, text_length):
    if text_length < 2:
        return ''.join(text_split)
    
    n = math.floor(alpha * text_length)
    target_indexes = random.sample(range(text_length), 2)
    
    for i in range(n):
        swap = text_split[target_indexes[0]]
        text_split[target_indexes[0]] = text_split[target_indexes[1]]
        text_split[target_indexes[1]] = swap
        
    return text_split


# Random Deletion 確率pでランダムにそれぞれの単語削除
p = alpha
def random_deletion(text_split, text_length):
    return [t for t in text_split if random.random() > p]

# Easy Data Augumentation 
def eda(text_tokens, text_split, text_length):
    results = []
    for _ in range(N_AUG):
        text_tokens_c = copy.copy(text_tokens)
        text_split_c = copy.copy(text_split)
        
        random_number = random.random()
        if random_number < 1/4:
            result_arr = synonym_replacement(text_tokens_c, text_split_c, text_length)
        elif random_number < 2/4:
            result_arr = random_insertion(text_tokens_c, text_split_c, text_length)
        elif random_number < 3/4:
            result_arr = random_swap(text_split_c, text_length)
        else:
            result_arr = random_deletion(text_split_c, text_length)
            
        results.append(''.join(result_arr))
    return list(set(results))

実際に例文を入れていきましょう。

お肉がとても美味しかったです。食後のコーヒーが別料金(210円)なので、そこだけ注意です。
=>
おミートがとても美味しかったです。食後のカフェーが画然たる勘定(210円型)なので、そこだけ注目です。

「画然たる勘定」という新たな語彙が生まれました。

Chive(Word2Vec)

同義語を持ってくる別手法として、単語をベクトル化して類似度が高いものを選ぶというやり方も考えられます。今回は単語をベクトル化する手法として一番オーソドックスなWord2Vecを使い、WikipediaをコーパスとしたChiveを利用します。gensim用のv1.1 mc90 aunitをダウンロードして適切な場所に置きます。

vectors = gensim.models.KeyedVectors.load("chive-1.1-mc90-aunit.kv")
def search_similar_words(word):
    thre = 0.6
    try:
        result = vectors.most_similar(word, topn=20)
        return [r[0] for r in result if r[1] > thre]
    except:
        return []

search_similar_wordsを置き換えることでChiveバージョンの関数を定義できます。

ちなみに、同じく美味しいの同義語を見てみると

print(search_similar_words('美味しい'))
# => ['美味', '激旨', '味', '絶品', '食べる', '甘み', '焼き立て', 'サラダ', 
'うまうま', 'スープ', '食べ応え', '食感', '香ばしい', '熱々', 
'風味', 'ジューシー', '揚げ立て', '御馳走', 'あっさり味', '塩味']

こんな感じになります。WordNetよりカジュアルですし、後半は唐揚げ感あります。

差し替えたバージョンでもEDAの例を見てみます。

お肉がとても美味しかったです。食後のコーヒーが別料金(210円)なので、そこだけ注意です。
=>
1. お胸肉がとても美味しかったです。空腹の水出しが各料(210¥)なので、そこだけ呉々です。
2. お肉がとても美味しかったコーヒーショップです。
¥食後のコーヒーが別料金(210円)な違う間食のでジューシー金額、細心ソーセージそこだけ注意です。

美味しいお肉が出てくるコーヒーショップは行ってみたいですが、意味変わってる気がしますね。

評価モデル作成

実際に文を増やしたことによる効果を検証したいと思います。今回は、レストランの口コミ文だけから評価点(1~5)を予測するタスクを考え、正解データとどれだけ離れているかを調べることで定量的に効果を計算します。

BertJapaneseTokenizerと、LSTMを使用します。

# LSTMで検証
from transformers import BertJapaneseTokenizer
from keras.preprocessing.sequence import pad_sequences
tokenizer = BertJapaneseTokenizer.from_pretrained('cl-tohoku/bert-base-japanese-whole-word-masking')

MAX_WORD_COUNT = 256

x_train = pad_sequences(df_sample_train['text'].apply(lambda x: tokenizer.encode(x)), maxlen=MAX_WORD_COUNT)
y_train = df_sample_train['total_score']
x_test = pad_sequences(df_sample_test['text'].apply(lambda x: tokenizer.encode(x)), maxlen=MAX_WORD_COUNT)
y_test = df_sample_test['total_score']

以下でモデルを作成します、指標として平均二乗誤差を利用します。

# ベクトルを入力としてLSTMでregression
from keras.preprocessing.sequence import pad_sequences
from keras.layers import LSTM, Dense, Embedding, Dropout, Input
from keras.models import Model

vocabulary_size = len(tokenizer) + 1  # 学習データの語彙数+1

text_in = Input(shape=(MAX_WORD_COUNT, ), dtype='int32', name='text_in')
x = Embedding(input_dim=vocabulary_size, output_dim=32, mask_zero=True)(text_in)
x = LSTM(128, return_sequences=False)(x)
x = Dropout(0.1)(x)
out_score = Dense(units = 1, name='out_score')(x)
model = Model(inputs=text_in, outputs=out_score)

model.compile(optimizer = 'adam', loss = 'mean_squared_error')

model.summary()

モデルに実データを入れて実験です!

history = model.fit(
    x_train,
    y_train,
    batch_size=64, epochs=5,
    validation_data=(x_test, y_test)
)
min(history.history['val_loss'])

例えばval_lossが0.7だと実際の評価点と予測の評価点が平均±0.7だけ離れていることになります。小さい方がより性能が高いということになります。

結果

学習に使う文の数を500, 1000の二通りについて見てみます。 また、バッチサイズはEDAのときは64, なしのときは4にします。同義語の抽出はWordNetを使っています。

500文の場合

EDA val_loss
なし 0.795
α=0.05 0.771
α=0.1 0.783
α=0.2 0.734
α=0.3 0.730

EDA無しの時と比べてval_lossが低い(=性能が高い)ことが分かりました。また、αは大きい方が性能が良くなっています。

1000文の場合

EDA val_loss
なし 0.709
α=0.05 0.714
α=0.1 0.723
α=0.2 0.650
α=0.3 0.666

1000文の場合は学習データが増えているので全体的にval_lossは低くなっています。 また、α=0.2の場合はEDA無しと比べて性能が良くなっています。

αが低いときはEDA無しより性能が悪くなっているので、適切なαの設定は重要なことが分かります。

500文(Chive)の場合

今度はWord2Vecの事前学習済みデータChiveを使った同義語抽出に変更した場合の性能を検証してみます。

EDA val_loss
なし 0.774
α=0.05 0.766
α=0.1 0.727
α=0.2 0.725
α=0.3 0.707

こちらも性能が良くなっています(0.774 -> 0.707)。同性能が出るなら実装の手軽さからChiveを使う方が良いかもしれません。

結論

基本的にはEDAを行うことで性能が上がることが分かりました。特に文章数が少ないときほど効果的です。学習データが少ないときには試してみてはいかがでしょうか!

一休新入社員の在宅勤務記録

f:id:narumatt:20210707112858p:plain

はじめまして、システム本部CTO室の松村です。
私は去年の4月に一休に入社しましたが、当時は緊急事態宣言の真っ只中でした。
一休も感染拡大防止のために多くの人が在宅勤務になり、私もいきなり週5で在宅で働く事になりました。
それから1年以上働いた経験から、一休での在宅勤務はどんな感じだったのか、新人だった自分はどんな感じで業務を行っていたのかについてご紹介したいと思います。

概要

一休では10人以下のチームで1つのプロダクトの開発を行っていますが、 チームで開発をすすめる上で、重要な要素だと感じた以下の3つについて説明していきます。

  1. チーム内外とのコミュニケーション
  2. 会話によるコミュニケーション
  3. 開発のフロー

チーム内外とのコミュニケーション

一休は日常的なコミュニケーションの手段として、以前からSlackを利用しています。
Slack内には様々なチャンネルがあり、全社共通のチャンネルや部署・チームごとのチャンネル、 開発向けのデータやアラートが送られてくるチャンネル、 趣味のチャンネルなどがあります。
私は以前の会社で社内の連絡手段として主にメールしか使えなかったので、以下のような点でSlackのメリットを感じました。

  1. 短文で必要な内容だけ伝える事ができる
  2. 過去の報告や議論などを全体やチャンネル毎に検索することができる
  3. メンションにより、特定の人やグループに即座に呼びかける事ができる
  4. 申請のワークフローや、営業から開発への問い合わせなど部署間のやりとりをシステム的に行うことができる
  5. スタンプを使うと雰囲気が柔らかくなる 🎉

また、一休のエンジニアには 「times」という個人のチャンネルを持っている人が多いです。
一般的には「分報」と呼ばれるようなスタイルのチャンネルで、 個人の技術メモや興味を持ったニュース、今日食べたものなど様々な内容を投稿しています。
在宅勤務だと直接会話をする機会が減るので、こういったチャンネルを見れば個人のパーソナリティを知る事ができますし、 特定の誰かに相談したい事があれば、本人のtimesチャンネルに投稿すれば必ず見てもらう事ができます。
ダイレクトメッセージだと二者間の閉じた会話になってしまいますが、timesを使えば 気になった人が会話に割って入る事ができますし、後から検索する事ができます。

f:id:narumatt:20210706185438p:plain
timesイメージ1

f:id:narumatt:20210706185506p:plain
timesイメージ2

会話によるコミュニケーション

一休ではビデオ会議用ツールとしてZoomを使用しています。
オンラインでは普段は簡単に行っていた「隣の人と会話する」という行動もハードルが高くなるので、 ログインせずにすぐに使う事が出来るZoomのメリットは大きいです。

チームによって方針は異なりますが、以下の2点を行う事で 「何も分からない」や「全然違う」状態を防ぐ事ができていると思います。

  1. 朝会、夕会などを毎日行い、認識合わせや報告を密に行う
  2. 何かあったらすぐ相談する

チームごとのZoomのURLがあれば定期的なミーティングは毎回そこに入るだけで済みますし、 SlackとZoomが連携しているのでSlackのコマンド1つでミーティングを作成する事ができます。
Slack内のビデオ通話だと能動的に招待しないといけないので、そのあたりが気軽にできるのがとてもありがたいです。

f:id:narumatt:20210706185538p:plain
zoomミーティングをコマンドで作成する

開発のフロー

複数人が関わるプロダクトは開発中の各段階で確認や連絡などのコミュニケーションが必ず発生するので、 コミュニケーションのコストが大きくなる在宅勤務では、開発のフローも大事になってきます。

私は入社してすぐ小さなタスクをいくつか任されましたが、実際のプロダクトにデプロイするのが非常に楽だと感じました。 以下のように、開発のフローがきっちり定まっているのが要因だと思います。

  1. コードはGithubで管理、レビューする
  2. CIが整備されていて、自動テスト、テスト環境や実環境へのデプロイが簡単にできる
  3. 重要なアラートはSlackに通知が来る
  4. プロダクトのログやサーバのデータなどがDatadogに集約されている

それぞれの点について、詳しく説明していきます。

Githubによるコード管理、レビュー

コードの変更はGithubのプルリクエストを利用して管理します。
PR内で指摘や議論ができますし、SlackにURLを貼ればすぐに変更点を見に行けます。
新人でも 指摘→修正→確認 の流れがスムーズに行なえますし、常に他人にレビューしてもらう事を意識すれば、 自ずとコードやPRの内容も良くなる(はず)です。

f:id:narumatt:20210706185942p:plain
コードレビュー

CIによる自動テスト、デプロイ

CircleCIによるCIを導入しているため、PRに対して事前に自動テストが動いています。
また、特定のブランチにマージすれば、テスト環境や実環境に自動でデプロイするようになっています。
これにより、自動テストが通らないようなコードを早い段階で発見する事ができますし、 デプロイ時にやるべき作業が最小限になるので、本当に確認すべき内容に集中して、ボトルネックなく開発していくことができます。

f:id:narumatt:20210706191948p:plain
特定のブランチにマージするとデプロイ

重要なアラートはSlackに通知が来る

プロダクトや各種監視ツールがSlackと連携しているため、問題が生じた際には即座にキャッチする事ができます。
デプロイ完了通知なども投稿しているので、他の場所を見にいかずに済みます

f:id:narumatt:20210706200531p:plain
グラフ付きアラート

プロダクトのログやサーバのデータなどがDatadogに集約されている

サーバのログやステータスなどはDatadogに記録しています。
エラーや遅延など全て関連付けられて記録されているので、 エラーが起きた時や動作が遅い時などは、原因がDBなのかサーバなのか、どういったメッセージが表示されているのかを簡単に辿る事ができ、 原因の切り分けがスムーズに進みました。
一休のDatadog利用については詳しい記事があるので、こちらもご覧になってください。
Datadog Log Management でアプリケーション稼働モニタリング - 一休.com Developers Blog

おわりに

SIerだった前職とは作るものもスピードも開発手法も全然違う中での在宅勤務でしたが、
おかげさまで「在宅勤務だから辛い」と感じる事もなく仕事を続けていく事ができました。

今回紹介してきた内容に関わるシステムやツールは導入や開発記事が過去の記事にあったりするので、興味がある方はぜひ読んでみてください。

プロダクト開発で大事にしていること

こんにちは。宿泊事業本部 プロダクト開発部 UI/UXチーム の 岡崎です。
今回は、「個人的」に「プロダクト開発で大事にしていること」をテーマに話を進めます。

概要

大事にしている事は下記3つあります。
それぞれにフォーカスして話を進めます。

  • 1.「ユーザーファースト
  • 2.「チームワーク
  • 3.「アーキテクチャ

なぜ大事にしているのか?

  • 「ユーザーファースト」

    • ユーザーに価値を届けられないプロダクトは「無意味」である為
  • 「チームワーク」

    • 良いプロダクトを生み出す為に「自分が不得意な分野の知識を借りる」事が必要不可欠である為
  • 「アーキテクチャ」

    • 速いサイクルでプロダクトの改善をする為に必要不可欠である為

「ユーザーファースト」を大事にする

Q.「ユーザファースト」を大事にするとは?
A. ユーザが使い心地の良い機能かを考える事

私の場合は、これをまず最初に考えてプロダクト開発をします。
具体的には、以下のテクニックを利用しています。

  • 1.軽く機能を作成してフィードバックを得る
  • 2.最終的なUI/UXの決定を長けている人に任せる
  • 3.CVRを確認する

軽く機能を作成してフィードバックを得る

エンジニアにありがちなのが、手段と目的の逆転現象です。 例えば、モダンなUIフレームワークを利用して、イケているデザインを作ろう。 という風に考えると破綻します。
手段を考えるよりも先に「どうしたらユーザが困っている事を解決できるか?」を考えて プログラミングに臨むことが大事だと思います。
そのためにも、HTML/CSS/JavaScript だけで静的なコンポーネントを作ってみて「そもそも使い勝手良いんだっけ?」 と社内のメンバーにフィードバックを得るなどの行為は大事になってくると思います。

最終的なUI/UXの決定を長けている人に任せる

「デザインスプリント」「アジャイル開発」などのフレームワークでは、 「皆で議論して」「付箋」「ホワイトボード」... などのワードが目立つと思います。
「皆で議論する」... 事自体は、問題ないですが、最終的に「誰がUI/UXを決めるか?」は大事になります。 「民主主義」で決めたり「エンジニア」が決めてしまう場合は、「それぞれの欲しいデザイン」になりがちです。 UI/UXに関する内容の決定権は、「ユーザの行動分析が得意な人」や「デザイナー」に責任をもってもらう事が重要だと思います。

CVRを確認する

CVRを確認する理由は、「CVRが上昇≒ユーザが使いやすいと思っている」という方程式が成り立ちやすいからです。 そのためにも、以下は大事になってくると思います。

  • A/Bテストの仕組みを整えておくこと
  • カナリーリリースの仕組みを整えておくこと
  • データレイクにデータを送信する仕組みを整えておくこと
  • 分析基盤を整えておくこと

「チームワーク」を大事にする

Q.「チームワーク」を大事にするとは?
A. チームが「プロジェクトに対して上手く進んでいるか」かを考える事

私の場合は、具体的には、以下のテクニックを利用しています。

  • プロジェクトがうまく進んでいるかを客観視する

プロジェクトがうまく進んでいるかを客観視する

まずは、心理的安全性の確保などは考えず「プロジェクトがうまく進んでいるか?」を考えます。 理由は、うまく進んでいる場合はチームが上手く回っている事が多いからです。
チームが良くなってもプロジェクトが上手くいかなければ意味がありません。
プロジェクトを上手く進めるうえで結果的にチームが上手く連携がとれている状態を目指すのが良いと考えています。 私の場合は、以下を意識 / 実践しています。

  • マイルストーンが明確になっている事の確認

    • 大枠のスケジュール(いつまでに / 誰が / 何を ) が明確になっている事
  • タスク管理 / タスクの優先度付けがちゃんと行われている事の確認

    • 個々人の持ちタスクなどが把握できる状態になっている事
  • プロジェクトを進めるうえで出てくる課題をベースにチームメンバーと会話をする

    • 会話をする事で個々人の詳細な状況を把握 / 対策を考える
    • メンバーと会話を行う事で自分の頭の中の整理を行う
  • 「一緒に」プロダクト開発を行うという意識を持つ

    • チームメンバーが時間がかかっているタスクに対して積極的に介入する
  • 知っておくと開発においてスムーズになる情報を分りやすくドキュメント化する

    • アーキテクチャ や 実装指針
    • デプロイ/リリース手順
    • なぜ開発を行う必要があるのかの背景を説明したドキュメント

「アーキテクチャ」を大事にする

Q.「アーキテクチャ」を大事にするとは?
A. 開発者が「分りやすい設計 / 実装」を心がける 事

私の場合は、具体的には、以下のテクニックを利用しています。

  • 1.データフローを統一化する
  • 2.ビジネスルールをテストしやすいコードにする
  • 3.レイヤを責務毎に分けて実装する

データフローを統一化する

Redux や Vuex などの「一方向アーキテクチャ」や 「伝統的なレイヤードアーキテクチャ」 がなぜ分りやすいかというと 「処理が行われる順番が決まっている」という点です。
「処理が行われる順番」が決まっていない場合は、循環参照などの 危険性も出てきます。
以下を実践すると良いのかなと思っています。

  • ディレクトリ単位でレイヤ分けをする
  • レイヤがどの順番で処理を行うかを決める

ビジネスルールをテストしやすいコードにする

ビジネスルールをテストしやすいコードにしておくとメリットが多くあります。 そのためにも、ビジネスルールのレイヤ(=Domain)をデータベース通信などのI/Oに依存しないようにすることが 大切になってきます。
なぜなら、データベースに存在する情報は、日々変化するものである為テストが常に同じ結果になるとは限らないからです。
「ダミーのデータをテストコードで扱えるよう」に「常に同じ結果」を返せるような設計にすると良いと思います。
データベース通信などの実処理に依存するのではなく、「データベース通信などの実処理を行った結果、 どういうDomainのデータが欲しいか?」を書いたインタフェースに依存するようにした方が良いと思っています。

ビジネスルールを単体テストしやすくすると、以下のようなメリットがあります。

  • 以下が分かる事で開発速度・テスト速度が向上する
    • テストコードで仕様が分かる
    • テストコードがある事によって追加の修正 ...etc で、デグレが起きていない事を確認できる

レイヤを責務毎に分けて実装する

既に出ていますが、責務毎にディレクトリ(レイヤ)を分けてSOLIDな実装をすると良いと思います。 特に大事なのは、ビジネスルールを他のレイヤに依存させないプレーンな実装にすると良いかなと思います。

因みに弊社では、「オニオンアーキテクチャ」を採用している箇所があり、「ビジネスルール」/ 「外部とのI/O」/ 「プレゼンテーション」 にそれぞれ分かれています。
「ビジネスルール」が他の「外部とのI/O」や「プレゼンテーション」に依存していない為 以下のメリットを享受できています。

  • テストコードが書きやすい
  • 「同じビジネス文脈で利用されているビジネスのルール」の再利用がしやすい

最後に

この記事で「伝えしたいことを一つにしろ」と言われたら、 「手段と目的」を逆転させず「プロダクト開発」を成功させるように動く事が大事だという事を発信したいとおもっています。

ヘルプデスクに Halp を導入して改善した話

f:id:rotom:20210521184904p:plain

社内情報システム部 コーポレートエンジニアの大多和(id:rotom / tawapple)です。 最近はオフィスファシリティと、Jamf Pro や Dialpad や、情シスの採用をやっています。

今回は情シスの業務において外すことのできない、社内のヘルプデスクを改善した話をします。

一休のヘルプデスクについて

これまでのヘルプデスク

2018年の記事でも紹介している通り、一休では営業やコーポレート部門のメンバーを含めた全メンバーで Slack・Google Workspace を導入しています。

user-first.ikyu.co.jp

社内からのヘルプデスクについては、Google フォームに入力してもらった内容が Slack に自動投稿され、Slack のスレッドでやりとりを行い、問題を解決していました。

f:id:rotom:20210426174458p:plain

この方法を導入することで、口頭、電話、Slack など分散していた問い合わせ窓口を1つのチャンネルに集約することができました。

課題だったこと

一方で、この方法を使った運用にはいくつか課題点がありました。

対応状況のステータスが分からない

この問い合わせが対応待ちなのか、調査などの対応中なのか、すでに解決しているのか、忘れられているのか、といったステータスがひと目でわからず、スレッドでのやりとりや、絵文字でのリアクションでしか確認することができない状況でした。

これにより対応の抜け漏れが発生することがあり、改善点として挙げられていました。

スマートフォンから投稿しづらい

一休のメンバーは営業が6割を占めており、ホテル・旅館やレストランなどの取引先や移動中など、外出時に問い合わせを行うことも少なくありません。

Google フォームを使った問い合わせ方法は、情シスにとっては管理がしやすくなった一方で、ユーザーにとってはスマートフォンからの投稿に手間が多い状態でした。 ブログのドメインにもなっていますが、一休は全社を通して「ユーザーファースト」という、ユーザーにとっての価値を追求する文化が根付いています。

www.ikyu.co.jp

情シスにとってのユーザーは社員であり、この状態はユーザーファーストではありませんでした。 また、外出時の問い合わせは緊急を要することも多く、問い合わせから解決までをスピーディーに行う必要があります。

以上のことから、スマートフォンからも投稿しやすく、すばやく問い合わせができる仕組みをつくる必要がありました。

DM で問い合わせがきてしまう

上記の使い勝手の悪さもあり、Slack の DM で情シスメンバーに直接問い合わせがよくありました。

ヘルプデスクを DM で行ってしまうと他者からやりとりが見えないため、ナレッジが貯まらず同じ問い合わせが続いてしまう、対応が属人化し特定のメンバーに負荷がかかってしまう、対象のメンバーが離席していると対応が遅れてしまう、など多くの問題を抱えていました。

qiita.com

これらの課題からヘルプデスクにチケット管理ソリューションの導入を検討しました。

Halp について

ここで本題の Halp の登場です。ハルプと読みます。

www.atlassian.com

アメリカのスタートアップ企業が開発していたヘルプデスクソリューションで、2020年5月に Jira や Confluence などを開発する Atlassian が買収しました。

jp.techcrunch.com

一休では2020年7月から検証・評価を開始し、実用性の確認が取れたことから2020年10月に本導入しました。

Halp で改善できたこと

対応状況の見える化

f:id:rotom:20210513163114p:plain

Halp のコンソールより、チケットごとのリクエスター(ユーザー)、アサインエージェント、対応状況、最終更新日時が一覧で確認できます。 これにより、誰もアサインされていないチケットや、しばらく更新されずオープンのままのチケットなどを確認することができ、抜け漏れを防げるようになりました。

f:id:rotom:20210513163348p:plain

また、Halp のレポート機能により、チケットを拾うまでの応答時間(First Response Times)、解決までにかかった時間(Resolution Times)を表示することができます。 問い合わせの粒度がまばらなため数値は大きめになってはしまうのですが、ここの数値は少しでも小さくなるように意識し対応しています。

また、日ごとのチケット作成数や、アサインエージェントごとの担当チケット数もこちらから確認可能となっています。

Slack ネイティブな問い合わせと対応

Halp ではチケットの発行からクローズまで、Slack 上で完結することができます。

it-helpdesk のようなユーザー対応を行うヘルプデスク用チャンネルと itdept-triage のような情シスメンバー用のトリアージチャンネルの2つを用意します。

f:id:rotom:20210514182758p:plain

ユーザーはチケットについて意識せず、ただ Slack のヘルプデスクチャンネルに問い合わせるだけで、自動でチケットが発行されます。

f:id:rotom:20210514183910p:plain

Bot がチケットを発行した旨をスレッドに投稿します。このあとのユーザー対応はスレッドで行います。 このやりとりはすべてトリアージチャンネルと自動同期するため、情シスメンバーはトリアージチャンネルのみで対応可能です。

f:id:rotom:20210520170442p:plain

情シスメンバー内での相談や依頼などは :lock: 🔒 の絵文字を先頭につけることで、ヘルプデスクチャンネルには自動同期されず、やりとりをすることができます。

f:id:rotom:20210520174109p:plain

f:id:rotom:20210520180623p:plain

ステータスの更新、クローズまで、すべてチケット操作が Slack 上で完結し、他のシステムやページを開く必要もありません。

これにより、ユーザーはヘルプデスクチャンネルだけ、情シスはトリアージチャンネルだけで問い合わせが完結し、 スマートフォンからも操作がしやすいSlack ネイテイブな対応が可能となりました。

DM 問題への対応

Halp は DM に対しても機能します。DM で届いた問い合わせにも :ticket: 🎫 リアクションをつけることでチケットが発行されます。

f:id:rotom:20210521165913p:plain

発行されたチケットはトリアージチャンネルに自動投稿されるため、ナレッジを情シスメンバー内に共有することができます。 また、DM がチケット化されることで対応状況や対応件数も把握できるようになりました。

f:id:rotom:20210521182216p:plain

日頃より DM ではなくチャンネルで問い合わせていただくようにアナウンス・誘導することも大切ですが、 実際に DM で問い合わせが来たときにチャンネルと同じようにチケット化する、というアプローチが取れるようになりました。

自動応答 bot

現在はまだ β ではありますが、「Halp Ansers」という自動応答の機能も開発されています。 現時点(2021/5)では日本語非対応なため、「Zoom」「SmartHR」などアルファベットの SaaS 名などで利用ができます。

f:id:rotom:20210521183001p:plain

f:id:rotom:20210521183143p:plain

キーワードマッチで自動応答をすることで、適切な問い合わせ窓口へ誘導や、トラブルシューティングの URL やマニュアルを展開することができ、 かんたんな問い合わせであれば、bot で自己解決を促すこともできるようになりました。

終わりに

こうした業務の改善により、ユーザーにとっても使いやすく、情シスにとっても管理がしやすく、素早く問題が解決できる、 従業員体験を向上できるヘルプデスクを引き続き目指していきたいと思います。

さて、ここまで読んでいただいたあなたは、きっと一休の情シスに興味があると思います

一休では組織を IT の面で成長させる、情シス・コーポレートエンジニアを募集しています! 社内インフラ・ネットワーク系の方に限らず、SaaS などのシステムを活用して業務の改善に取り組める方は大歓迎です!

インターネット企業としては比較的歴史の長い成長した組織ではありますが、裁量を持ってシステムの選定・導入に携わることができ、 チーム一丸となって最新の技術・サービスや、エンタープライズ向け製品に触れることができる充実した環境です。

hrmos.co

note.com

ご興味のある方はぜひご応募、ご連絡をお願いします。一度お話しましょう!

追記

SmartHR yamashu さんの記事でご紹介いただきました。 Halp を含めたヘルプデスクソリューションとの比較がわかりやすくまとまっています!

tech.smarthr.jp

WebComponents でログインコンポーネントをつくってサービス横断で使えるようにした話

こんにちは。プロダクト開発部の渥美 id:atsumim です。
今回サービス横断で利用できるログインコンポーネントを WebComponents で実装したのでその紹介をします。

1. 背景

今年の2月に電話番号での会員登録及び認証機能をリリースしました。

これに伴って一休の会員基盤も刷新しました。
一休のサービスは主に、宿泊、レストラン、スパとあるのですが、 歴史的経緯により会員基盤が分散してしまっていたので、ひとつにまとめる狙いもありました。

会員基盤 Before/After

その一環として、一休のサービスで横断して使えるログインコンポーネントを WebComponents で実装しました。 このコンポーネントにログインや会員登録の処理を集約し、新会員基盤へのインターフェースとするようにしました。
また、電話番号認証や2段階認証設定のモーダルも実装しました。下記が実際の画面です。

ログインモーダル 電話番号認証モーダル 2段階認証モーダル

この記事ではログインモーダルに絞ってお話します。

2. 技術選定

技術選定するにあたって、条件は以下の通りでした。

  • ページ遷移を挟まずにログインができる
  • どのアプリケーションプラットフォームでも利用できる

1つ目の条件からモーダルコンポーネントを提供することはほぼ決まっていました。
予約入力をしている途中でログインページに遷移すると体験を損ねてしまうので、スムーズな予約を実現するためにはモーダルコンポーネントでの提供が必須でした。

2つ目の条件として、一休のサービスは主に Vue.js, Python テンプレート, ASP.NET 等のプラットフォームで 画面描画を行なっているのですが、どのプラットフォームでもログインができるようにする必要がありました。 そのためには Web 標準で使える WebComponents が適任でした。

WebComponents について詳しくはこちらの記事がよくまとまっています。

WebComponents の実装フレームワークには PolymerLitElement がありますが、 Vue CLI が標準で WebComponents をビルドできるのでこれを利用しました。 内部的には vue-web-component-wrapper が使われています。大変助かりました🙏

3. 実装

一部省略していますが、下記のインターフェースになるようにログインモーダルを実装しています。 実装したログインモーダルは <ikyu-login> という CustomElement で定義しました。 HTML に <ikyu-login> と書けば通常の HTML タグ同様に使えるようになります。

Attributes

Attribute Type Default Note
login-only Boolean false ログイン画面のみ表示するか
signup-only Boolean false 会員登録画面のみ表示するか
open Boolean false モーダルを表示するか

Events

Event Type Note
login Boolean ログイン及び会員登録成功
error Error ログイン及び会員登録失敗
close Boolean モーダルを閉じる

HTML への組み込み

実際に HTML への組み込みを見てみましょう。 CustomElement に属性を指定する場合は setAttribute 関数、イベントを取得する場合は addEventListener 関数を使います。

<html>
  <head>
    <meta charset="utf-8">
    <title>ログイン</title>
    <script src="https://unpkg.com/core-js-bundle@3.0.0-alpha.1"></script> // IE11 用
    <script src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script> // IE11, Edge 用
    <script src="./ikyu-login.js"></script>
  </head>
  <body>
    <button onclick="openIkyuLogin()">open</button>
    <ikyu-login show-signup></ikyu-login>

    <script>
      const ikyuLogin = document.querySelector('ikyu-login');

      function openIkyuLogin() {
        ikyuLogin.setAttribute('open', true);
      }

      ikyuLogin.addEventListener('close', () => {
        ikyuLogin.setAttribute('open', false);
      });

      ikyuLogin.addEventListener('login', (status) => {
         // リダイレクトしたりする
      });
    </script>
  </body>
</html>

Vue への組み込み

Vue に組み込むときは通常の Vue コンポーネントと同様に propsevent のやりとりができます。 setAttributeprops, addEventListenerevent に置き換わるイメージです。

下記は実際の利用例です。

<template>
 <div>
   <button @click="openIkyuSignupOnly()">モーダルを開く</button>
    <ikyu-login
      :open="ikyuLoggingin"
      :login-only="ikyuLoginOnly"
      :signup-only="ikyuSignupOnly"
      @close="ikyuLoggingin = false"
      @login="reload"
      @error="onError"
    >
 </div>
</template>
<script lang="ts">

export default Vue.extend({
  data() {
    return {
      ikyuLoggingin: false,
      ikyuLoginOnly: false,
      ikyuSignupOnly: false,
    }
  },
  methods: {
    openIkyuSignupOnly() {
      this.ikyuLoginOnly = false;
      this.ikyuSignupOnly = true;
      this.ikyuLoggingin = true;
    },
    reload(status) {
      window.location.reload()
    },
    onError(error: Error) {
      console.log(error);
    },
  }
});
</script>

注意点として、Vue 内で CustomElements を利用するときは Vue コンポーネントとして見なされてしまうため、明示的に Vue コンポーネントではないことを宣言する必要があります。

Vue.config.ignoredElements = ['ikyu-login'];

InternetExplorer の対応

案の定 InternetExplorer では WebComponents が動作しないので、憎しみと愛を持って対応します。
pollyfill の読み込み、スタイル崩れなどなどありましたが結果なんとかなってよかったです。IE 許すまじ。

まずは IE および Edge 用に core-jswebcomponents-loader を読み込みます。

<script src="https://unpkg.com/core-js-bundle@3.0.0-alpha.1"></script> // IE11 用
<script src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script> // IE11, Edge 用

vue-web-component-wrapper 内ではES6で実装しているのでそのままでは IE で動きません。許さない。 これに対処するため babel で pollyfill してやる必要があります。また、ES6 をトランスパイルするための prebuild を記述します。

package.json は以下のようになりました。

{
  "scripts": {
    "prebuild": "npm-run-all babel node_modules/@vue/web-component-wrapper/dist/vue-wc-wrapper.js -o node_modules/@vue/web-component-wrapper/dist/vue-wc-wrapper.js",
    "build": "cross-env vue-cli-service build --no-clean --target wc --inline-vue --name ikyu-login 'src/components/IkyuLoginAndSignup.vue'",
  },
  "dependencies": {
    "babel-loader": "8.1.0",
    "babel-plugin-transform-es2015-arrow-functions": "6.22.0",
    "core-js": "3",
  },
  "devDependencies": {
    "@babel/cli": "7.11.6",
    "@babel/core": "7.11.6",
    "@babel/plugin-syntax-async-generators": "7.8.4",
    "@babel/plugin-transform-arrow-functions": "7.10.4",
    "@babel/plugin-transform-regenerator": "7.10.4",
    "@babel/preset-env": "7.11.5",
    "babel-plugin-transform-async-to-generator": "6.24.1",
    "babel-plugin-transform-custom-element-classes": "0.1.0",
    "babel-plugin-transform-es2015-shorthand-properties": "6.24.1",
    "babel-plugin-transform-es2015-template-literals": "6.22.0",
  }
}

これで InternetExplorer でも WebComponents が使えるようになりました🎉
IE 特有のデザイン崩れ等も対応しつつ、モダンブラウザと遜色なく動作するようになっています。


余談ですが IE に悪態をつきながら対応していたら同僚から実績解除の称号を得ました😇

Legendary Hate Speech...

4. 所感

WebComponents を使ってみてよかった点と改善点を挙げます。

よかった点

コンポーネント指向であること

ログインモーダルの他にも認証モーダルなどを実装したのですが、共通コンポーネントを使い回せたので実装コストがかなり減りました。

Vue.js との親和性が高い

一休のアプリケーションプラットフォームは Vue.js が多いので、 Vue コンポーネントと同様のコンテキストスイッチで実装できたのはよかったです。

改善点

そのまま配信しようとすると重くなってしまった

当然ですが、実装を進めていくとどんどんファイルサイズが大きくなってしまいます。 そのまま配信するとコンポーネントを読み込んでいるページパフォーマンスが下がってしまう懸念があるので gzip での圧縮やブラウザキャッシュを付けて改善することが必要となってきます。


結果的に各サービスでのログイン実装が簡潔になり、ログイン処理が新会員基盤に集約できました。
Web 標準でお手軽に再利用できるコンポーネントが必要になった場合は、是非 WebComponents の選択肢を考えてみてください。

ヤフーのInternal Hack Dayに一休も参加しました

こんにちは。
宿泊事業本部のいがにんこと山口です。id:igatea

ヤフー社内で毎年開催されているハッカソンイベント「Internal Hack Day」が先日6/27~6/29に開催されました。
そのハッカソンにZ Holdingsのアスクル、一休、PayPay、ZOZOテクノロジーズが一緒に参加出来る運びになり、一休からも参加させていただきました。
この記事ではInternal Hack Dayに参加してきたレポートを書きます。

Internal Hack Day

Internal Hack Dayはヤフー社内で毎年行われている社内向けのハッカソンイベントです。
チームを組んでテーマに沿った新しい機能やサービスのアイデアを出し合い、短い期間で作り上げて競い合うイベントとなっています。
チームは自社だけで組んでもいいですし、他社の方と組むことも可能です。

Internal Hack Dayのルールは以下の通りです。

  • 開発時間は24時間、9:00~21:00の2日間
  • プレゼン時間は90秒

通常ルールは上記のみなのですが、新型コロナウイルスの流行に伴い、今回は上記ルールに加えて以下のルールも追加されました。

  • 「新しい生活様式での課題解決」をテーマに
  • 開発、発表は原則オンラインで行う

自分はハッカソンには初参加だったのですが、ハッカソンというと開催会場でみんなで集まって開発、開催会場で発表、というのが当たり前だと思っていたのですが、それが全てオンラインで行われるということで新しい試みでおもしろかったです。

開発

ハッカソン中はずっとオンラインのビデオ通話を繋げながらやっていました。
24時間なので、ずっと集中して出来るわけではないのでオンオフ切り替えるためにもご飯の時なんかは通話を切ってゆっくり過ごしたりしていました。

やっぱりオンラインコミュニケーションは大変だったりします。
オンラインで通話をしていると熱量とか空気感が伝わりづらいし感じにくい。
自分のチームは2人チームだったのでまだ問題ないのですが、これが人数が増えてくると収集つかなそうな印象を受けました。

ハッカソン中はずっと議論をしていて、手よりも口を動かすことのほうが多かったです。
最初の3時間は新しい生活様式の課題って何かをずっと議論していました。
仮説を建てて検証、さらに深堀して課題として正しいのか、課題にアプローチできているのか、今の自分たちに24時間で行えることか(発表時に成果物を見せなければいけないのでここは重要)をしっかり行ってから開発を始めました。

オンラインでの開催なので他チームの状況が全く分からなかったのもちょっとドキドキしました。
自分たちはまだ全然形に出来ていないけど他のチームはどんな感じなんだろう?と思いながら開発していました。
ここらへんはオンライン開催の課題ですね。

オンラインで複数人の声が混ざっても聞き取れるように

最終的に僕たちはオンラインでの会議や飲み会での会話がぎこちなくなりがち、というところに目をつけました。
原因の一つに複数人の声が混ざった時に聞き分けづらいことがあると考え、そんな問題を解決するためにオンラインで複数人の声が混ざっても聞き取れるように、そんなツールを作りました。

f:id:igatea:20200713002829p:plain

ZoomのURLを入れると、同じURLを入力した人同士を自動で音が被らないように音が聞こえる方向を調整してくれます。

課題の目の付け所、アプローチなどが評価されて、元々の賞にはなかった特別賞が急遽作られて表彰していただけたのはとても嬉しかったです。

結び

グループ内で他会社と一緒に何かイベントをやるというのは初めてだったので、別の会社のカルチャーに触れることが出来てとても刺激的でした。 また会社をまたいで何かやりたいですね。

他の受賞作品などはヤフーのテックブログにて。 https://techblog.yahoo.co.jp/entry/2020071430011124/

GraphQLのN+1問題を解決する DataLoaderの使い方

f:id:ryo-utsunomiya:20200128121121p:plain

こんにちは。宿泊事業本部の宇都宮です。この記事では、GraphQLサーバ実装時に遭遇するN+1問題と、その解決のために使えるライブラリを紹介します。

フィールド単位でresolverを用意する

GraphQLでは、クライアントのクエリに応じてオンデマンドに結果を取得できます。

たとえば、以下のクエリを投げると…

{
  accommodation(accommodationId: "00001050") {
    name
  }
}

以下のようなレスポンスが取得できます。

{
  "data": {
    "accommodation": {
      "name": "マンダリン オリエンタル 東京"
    }
  }
}

ここで、施設のクチコミレーティングを取得したい場合、以下のようなクエリを投げることができます。

{
  accommodation(accommodationId: "00001050") {
    name
    rating
  }
}

このとき、サーバサイドではクエリによって必要なカラム(場合によっては、JOINするテーブル)が動的に変わります。バックエンドで動的にSQLを組み立てるのも1つの方法でしょう。しかし、SQLの組み立てロジックが複雑になったり、生成されるSQLが巨大でパフォーマンスの悪いものになったりするといった懸念点があります。

別のアプローチとして、追加のJOINが必要になるフィールドには GraphQL resolverを別に用意して、GraphQLサーバにレスポンスの組み立てを任せる、というものもあります。このようにすると、各resolverの実装をシンプルに保ちつつ、複雑なクエリに応答することができます。

一休.comでも使用している gqlgen というGoのGraphQLライブラリでは、以下の手順でフィールド単位のresolverを用意できます。

(1) GraphQLのスキーマと、gqlgenの設定ファイルを用意する

# schema.graphql

type Accommodation {
    name: String!
    rating: Float!
}
# gqlgen.yml

models:
  Accommodation:
    fields:
      rating:
        resolver: true # この設定がキモ

(2) go generate して、インタフェースを満たす

Resolverのインタフェースは以下のようになります。

// generated.go
type AccommodationResolver interface {
    Rating(ctx context.Context, obj *Accommodation) (float64, error)
}

これを満たす実装は以下のように書けます。

// resolver.go

func (r *accommodationResolver) Rating(ctx context.Context, obj *Accommodation) (float64, error) {
    summary, err := appcontext.From(ctx).Loader.ReviewSummary.LoadByAccommodationID(ctx, obj.AccommodationID)
    if err != nil {
        return 0, err
    }
    return summary.Rating, nil
}

N+1問題

このようにすると、無駄なデータの取得を避けつつ、resolverの実装がシンプルに保つことができます。しかし、以下のようなクエリを処理する際には問題が発生します。

{
  accommodation(accommodationId: "00001050") {
    name
    rating
    neighborhoods {
      name
      rating
    }
  }
}

ここでは、ある施設の近隣施設を取得して、それらのratingを取得しています。仮に、クチコミのレーティング取得が select rating from review_summary where accommodation_id = ? のようなクエリで実装されていると、このクエリが近隣施設の数だけ実行されることになります。このように、関連レコードの件数の分、追加データ取得用のクエリが発生する状態をN+1問題と呼びます。

このときのSQLの流れは以下のようになります。

-- 親の accommodation と rating を取得
select name from accommodation where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;

-- 近隣施設を取得
select accommodation_id, name from neighborhood_accommodation where accommodation_id = ?;

-- 近隣施設の数だけ rating を取得するクエリが発行される。。。
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;
select rating from review_summary where accommodation_id = ?;

-- ↑ではなく、↓のように一括で取ってほしい
select rating, accommodation_id from review_summary where accommodation_id in (?, ?, ?, ?, ?);

このような場合、RailsなどではORMの 一括読み込み 機能を利用します。

一方、gqlgenでは、各resolverは平行に実行されるので、ORMによる一括読み込みは利用できません。このような場合に利用可能な、データ取得をバッチ化する仕組みが DataLoader です。DataLoaderのオリジナルはJavaScript実装の graphql/dataloader ですが、様々な言語のDataLoader実装が公開されています。また、DataLoaderはGraphQLサーバで使うために作られたライブラリですが、GraphQLとは関係なく、REST APIなどでも利用できます。

GoのDataLoaderライブラリ

Go製の有力なDataLoaderライブラリは、私が把握している範囲では以下の2つです。

前者は graph-gophers/graphql-go 、後者は gqlgen の作者によるライブラリです。

一休.comではgqlgenを使っているため、当初は dataloaden の方を試しました。dataloadenはgqlgenと同じくgo generateによるコード生成ライブラリとなっており、型安全なDataLoaderを生成できるという特長があります。しかし、モデルの配置方法などに制約が強く、私たちの用途には合いませんでした。

そこで、今は graph-gophers/dataloader を使っています。

DataLoaderの仕組み

サンプルコードに入る前に、DataLoaderの仕組みについて解説します。DataLoaderは前述したようにデータ取得をバッチ化するためのライブラリですが、そのための仕組みとしては「一定時間待って、その間に実行されたデータ取得リクエストをバッチ化する」というアプローチを取っています。

「一定時間」は、1msや16msなどといった値になります。この値が大きくなるとバッチ化できる範囲が広がりますが、その分レスポンスタイムが遅くなるおそれがあります。

graph-gophers/dataloader では、dataloader.Loader の Load() メソッドを呼び出すと、 Thunk という型の関数が返ってきます。この関数はJavaScriptのPromiseのようなもので、一定時間待った後で値が取得できます。

thunk := dataloader.Load(ctx, key)

実際のサーバでは、 Load() は平行して呼ばれるため、各goroutineが Thunk を受け取ります。

// goroutine A
thunk := dataloader.Load(ctx, key)

// goroutine B
thunk := dataloader.Load(ctx, key)

// goroutine C
thunk := dataloader.Load(ctx, key)

このthunkを呼び出すと、結果を取得できます。

thunk := dataloader.Load(ctx, key)
result, err := thunk()

一定の待ち時間の間に呼び出された Load() のkeyを覚えておいて、一括でデータ取得を行うのがDataLoaderの仕組みです。

// ここで 1ms のタイマースタート
s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000001")

// 0.5ms経過…

// この呼び出しは↑と一緒にバッチ化される
s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000002")

// 1ms 経過:↑の2件をまとめて、以下のクエリを発行し、結果を返す
// select accommodation_id, rating from review_summary where accommodation_id in ('00000001', '00000002')

// この呼び出しは別のバッチになる
s := loader.ReviewSummary.LoadByAccommodationID(ctx, "00000003")

DataLoaderのサンプルコード

完全な形のサンプルコードとしては、 hatena/go-Intern-Bookmark がオススメです。ここでは、このサンプルコードを題材に graph-gophers/dataloader の使い方を解説します。

DataLoaderを使うには、まず以下のようにバッチでデータ取得を行う関数を用意します(コードは簡略化しています)。

// loader/entry.go
func newEntryLoader(app service.BookmarkApp) dataloader.BatchFunc {
    return func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
        entryIDs := keysToEntryIDs(keys)
        entrys, _ := app.ListEntriesByIDs(entryIDs) // ここがキモ。 select * from entry where id in (...) を投げる
        return entrysToResults(entrys)
    }
}

次に、この関数を context に保持させます。なぜ context に保持させるのかというと、DataLoaderのキャッシュ機能はリクエスト単位のデータのキャッシュを意図しているからです。リクエスト毎に内容が空になる context は、DataLoaderを保持させる場所にぴったりです。これによって、バッチ化の対象は同一リクエスト内の Load() の呼び出しに限定されます。

contextへの追加はミドルウェアで行います。

// web/server.go

func (s *server) attatchLoaderMiddleware(next http.Handler) http.Handler {
    loaders := loader.New(s.app)
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r.WithContext(loaders.Attach(r.Context())))
    })
}

このようにしてcontextに登録したDataLoaderは、以下のようにして呼び出せます。

// resolver/bookmark_resolver.go

// hatena/go-Intern-Bookmark は graph-gophers/graphql-go を使っているため、
// resolverの書き方がgqlgenとは異なる
func (b *bookmarkResolver) Entry(ctx context.Context) (*entryResolver, error) {
    // LoadEntry は context から DataLoader を取得し、Load() を呼び出して、結果を Entry 構造体にして返す
    entry, err := loader.LoadEntry(ctx, b.bookmark.EntryID)
    if err != nil {
        return nil, err
    }
    return &entryResolver{entry: entry}, nil
}

DataLoaderとDataDog APM

一休で使っているDataDogのAPM(Application Performance Monitoring)だと、以下のようなトレースが見えます。resolverが平行に実行されている様子が分かりやすいです。

f:id:ryo-utsunomiya:20200128145750p:plain

紫色がDB問い合わせで、Ratingの取得は1回のDB問い合わせにまとめられていることがわかります。また、APMを活用すると、「待ち時間が短すぎて、複数のバッチに分かれてしまっていないか?」といった調査も簡単にできます 👍

むすび

今回はGoのDataLoaderライブラリの使い方を紹介しました。DataLoaderはややトリッキーですが、ハイパフォーマンスなGraphQLサーバの実装には欠かせないライブラリだと思います。

採用情報

一休では、GoやGraphQLに強みのあるエンジニアを募集しています! 一休.comのバックエンドは .NET Framework から Go への移行を進めていて、バックエンドでGoを書く割合が少しずつ増えているところです。

hrmos.co