クラシル開発ブログ

クラシル開発ブログ

国内初?マルチリービングでランキングを勝手に自動改善!

はじめに

こんにちは。 機械学習エンジニアの辻です。

さて本日は、「国内初?マルチリービングでランキングを勝手に自動改善!」ということで、マルチリービングという手法と、その手法を使ったランキングの自動最適化の方法についてご紹介したいと思います。なお、今回の取り組みは、筑波大学・図書館情報メディア系・准教授の加藤誠先生*1に大変ご助力賜りました。この場を借りてお礼申し上げます。

f:id:long10:20200629081915j:plain

目次

アローの不可能性定理

さて、マルチリービングのご紹介に入る前に、みなさんアローの不可能性定理*2なるものをご存知でしょうか?ちょっとWikipediaで調べてみたところによると、「投票者に3つ以上の独立した選択肢が存在する場合、如何なる選好投票制度であっても、個々人の選好順位を共同体全体の(完備かつ推移的な)順位に変換する際に、特定の評価基準(定義域の非限定性、非独裁性、パレート効率性、無関係な選択肢からの独立性)を同時に満たすことは出来ない。」とありました。

ちょっと何言ってるかわからないですね。

f:id:long10:20200625222933p:plain

要するに、この定理は次の5つの「公正さ」の基準を、常に同時に複数の人に対して満たすようなランキングなんて作れないよ、という主張です。

  • 人々の選好の順序は自由だ
  • 満場一致
  • 独裁者はいない
  • 他の選択肢から影響を受けない
  • 堂々巡りの矛盾にならない(a>b , b>cなら必ずa>c)

つまり、いつの世もみんなの心が一つなることは絶対にないというわけです、悲しいかな。

クラシルのランキングについて

前置きはこれくらいにして、クラシルには様々なランキング機能*3があります。

クラシルのランキング機能(ある日のランキング)

f:id:long10:20200625224611p:plain

ランキングの課題

クラシルのランキングは、現在利用していただいている多くのユーザさんの声によって作成されています。 しかしながら、先ほどのアローの定理が主張するように、ユーザさん全員に喜んでもらえるただ一つのランキングを作成することは無理なので、 この例の場合は、申し訳ないことにサバが嫌いな人にとって、今日のランキングは最低!ということになってしまうのです。(美味しいのにね。)

では、どうしたらいいか?

先ほどのアローの定理を再び思い出してください。ここで主張されているのは、列挙した5つの「公正さ」の基準を常に同時に複数人では満たせないということでした。

でも逆に言えば、

  1. 常に→適宜
  2. 同時→変化する
  3. 複数人→少人数

このような条件なら、完璧ではないにせよ、少しはユーザさんに喜んでもらえるランキングに近づけるんじゃないか? それが今回の取り組みのモチベーションになります。

マルチリービングって何なん?

マルチリービング(Multileaving)というのは、インターリービング(Interleaving)の複数版のことです。 それでは、インターリービングが何かというと、それはA/Bテストを手っ取り早く行う手法の一つです。

A/Bテストとはシステムの機能やデザインの良し悪しを判定するための取り組みのことで、 仮説検定やベイズ最適化を用いて評価するのが一般的です。(FirebaseのA/B Testing機能などを使うと便利ですね。)

f:id:long10:20200629082823p:plain

ただ、こういった方法だと、サンプル数を十分に確保できるまで評価できなかったり、評価方法が複雑になり良し悪しの判断が難しいという状況が起こったりします。 そうなると手っ取り早くAとBのどっちがいいか知りたいというニーズを満たすことができません。

そこで、このマルチリービング(インターリービングも同じ)という手法は、非常に高感度なランキング評価手法であって、 結果判定が可能になるまでの時間やサンプル数が比較的少なくても、AとBのどっちがいいか判断したいというニーズを満たすことができるという、 そんな非常に画期的な手法になります。*4 *5

さて、詳しく知りたい方は、脚注4の加藤先生のQiitaをご参考頂きたいと思うのですが、 この手法をめちゃくちゃ大雑把に説明すると、

  1. いくつか(たとえば4つ)の条件でランキングを作る
  2. いい感じにランキングをごちゃ混ぜにするアルゴリズムを使って、ごちゃ混ぜランキングを作る(初期は1/4ずつ均等に配分するなど)
  3. ユーザさんが、このごちゃ混ぜランキングをクリックした結果(暗黙フィードバック)を集計する
  4. 3で得られた結果から、最初に作った4つのランキングのうち一番強いランキングを評価して、その強いランキング(好まれた)の混ぜる割合をちょっと増やす
  5. 混ぜる割合を変えた状態で2に戻り、再びごちゃ混ぜランキングを作る

(以降、繰り返し)

このとき、このいい感じにごちゃ混ぜにするアルゴリズムの種類にはこのようなものがあります。

  • Balanced interleaving(均衡交互配置) (Joachims 2002a, Joachims 2003)
  • Team draft interleaving(チームドラフト交互配置) (Radlinski et al. 2008)
  • Probabilistic interleaving(確率的交互配置) (Hofmann et al. 2011)
  • Optimized interleaving(最適化交互配置) (Radlinski and Craswell 2013)

そして、今回使用したのは、これらをさらにいい感じにした版のPairwise Preference Multileaving(略してPPM)になります。*6

こちらは加藤先生ご自身がPythonモジュールとしてオープンソースとして公開されているのでご指導いただきつつ利用させて頂きました。*7

以下、加藤先生のモジュールを使用したPPMのデモ実装になります。 少しだけ詳しくみていきましょう。

まずは、ランキング対象となるダミーレシピデータとそのデータに合わせたスキーマクラスを用意します。 レシピ動画の素性を持つVideoクラス、ランキングを生成するRankerクラス、そしてユーザ情報を保持するUserクラスです。 Rankerクラスのrank関数では、与えられたランキングの条件ごとの割合とRankerの重みを線形結合しています。

class Video(object):
    def __init__(self, id, genre_ids, features):
        self.id = id
        self.genre_ids = genre_ids # レシピのジャンル
        self.features = features # 素性(ランキングの条件)

    def __hash__(self):
        return self.id


class Ranker(object):
    def __init__(self, keys, w):
        self.keys = keys # 素性(ランキングの条件)
        self.w = w # 重み

    def rank(self, documents):
        result = sorted(
            documents,
            key=lambda x: np.array([x.features[k] for k in self.keys]) @ self.w.T,
            reverse=True
        )
        return result


class User(object):
    def __init__(self, id, favorite_feature_id):
        self.favorite_feature_id = favorite_feature_id
        self.id = id

    def click(self, videos, click_cnt=3):
        # ユーザはfavorite_feature_idが高いものをクリックする
        return to_id(sorted(videos, key=lambda x: -x.features[self.favorite_feature_id])[:click_cnt])

videos = []

# ダミーレシピデータの作成
def create_videos():
    for i in range(10000):
        video = Video(
            id = i + 1,
            genre_ids = random.sample(genre_ids, 2),
            features = {fid: random.random()  for fid in feature_ids}
        )
        videos.append(video)


def gen_rand(size):
    m = np.random.uniform(low=-1.0, high=1.0, size=size)
    m /= (np.linalg.norm(m))
    return m

つぎに、generate_fluctuated_weight関数として、重みをちょっとだけ変更する(これを摂動と呼んでいます。)関数を定義します。 つまり、初期の重みは単位ベクトルに保存していますが、これを微変化させることで、混合するランキングの割合を調整しています。

# 閉区間[0,1]に収まるように制限する
def generate_fluctuated_weight(original_weight):

    unit_vec = gen_rand(len(original_weight))
    diff_vec = unit_vec * delta

    # 元ベクトルに加算、閉区間[0,1]からはみ出す場合は、はみ出る分を他に付け替え
    tmp = original_weight + diff_vec
    tmp2 = sorted([(i, _) for i, _ in enumerate(tmp)], key=lambda x: x[1])

    for i, v in tmp2:
        if v < 0.0:
            tmp[i] = 0.0
            tmp[-(i+1)] += v

    tmp /= np.linalg.norm(tmp) # 正規化
    diff_vec = tmp - original_weight
    unit_vec = diff_vec / delta

    return (original_weight + diff_vec, unit_vec)

get_superior_rankers関数は、ユーザさんのクリック状況から、一番強かったランキングを判定する関数です。

def get_superior_rankers(il_result):

    prefs = defaultdict(int)
    for res in il_result:
        for r in res:
            prefs[r] += 1

    winner_indexes = []

    for i in range(1, ranker_size):
        wins = prefs[(i, 0)] - prefs[(0, i)]
        if wins > 0:
            winner_indexes.append(i)

    return winner_indexes

そして、このupdate_ranking関数が実際にランキングを生成する関数になります。 現在の重みを摂動させて混合ランキングを作成します。 作成後に、摂動した結果の重みも更新します。

def update_ranking(user, genre_id):

    # メイン重みを取得
    res = get_parameter()
    w = np.array([_[1] for _ in res])

    target_keys_count = len(feature_ids)
    # unit_vecs = np.array([np.zeros(target_keys_count)] + [generate_fluctuated_weight(w) for i in range(ranker_size - 1)])

    weight_vecs = [w] + [generate_fluctuated_weight(w)[0] for i in range(ranker_size - 1)]
    rankers = [Ranker(feature_ids, _w) for _w in weight_vecs]
    top_videos = get_top_videos_per_genre(genre_id)
    rankings = [to_id(ranker.rank(top_videos)[:ranking_count_per_genre]) for ranker in  rankers]

    ppm = PairwisePreference(rankings)
    mixed_ranking = ppm.interleave()

    #  ランキング保存
    # 摂動後の重み更新

こちらのevaluate関数がランキング結果を評価する関数になります。 ユーザさんのクリックした結果を受けてランキング優劣を評価し、重みを更新します。

def evaluate(user, genre_id, click_video_ids):

    # 保存済み摂動重み群をもとに、競わせたランキングを復元
    res = get_ranking()

    weight_vecs = []
    for i in range(ranker_size):
        ranker_id = i + 1
        w = np.array([_[-1] for _ in res if _[0] == ranker_id])
        weight_vecs.append(w)

    main_w = weight_vecs[0]
    # print("main weight: %s" % str(main_w))

    rankers = [Ranker(feature_ids, _w) for _w in weight_vecs]
    top_videos = get_top_videos_per_genre(conn, genre_id)
    rankings = [to_id(ranker.rank(top_videos)[:ranking_count_per_genre]) for ranker in  rankers]

    origin_rankings = get_origin_ranking()
    mixed_ranking = [_[1] for _ in origin_rankings]

    # 復元ランキング達とクリック結果からマルチリービングで優劣判断
    # rankingオブジェクトも復旧する必要がある
    ranking = PairwisePreferenceRanking(rankings, contents=mixed_ranking)

    # クリック対象IDを混合ランキングのインデックスに変換
    click_indexes = [mixed_ranking.index(vid) for vid in click_video_ids]

    result = PairwisePreference.evaluate(ranking, click_indexes)

    # 勝者ランカーを決定
    winner_indexes = get_superior_rankers([result])
    main_w = weight_vecs[0]

    # 重み更新(勝者ランキングに用いた単位ベクトルの平均値を加算)
    if winner_indexes:
        diff_vecs = np.array([w - main_w for w in weight_vecs])
        unit_vecs = diff_vecs / delta
        main_w += alpha * np.average(unit_vecs[winner_indexes], axis=0)

        # メイン重み更新

    return main_w

ここまで定義した関数を、後は順番に呼び出すだけという形になります。

if __name__ == "__main__":
    update_ranking(user, genre_id)
    clicked_video_ids = get_click_data(user, genre_id) #クリック結果を取得
    evaluate(user, genre_id, clicked_video_ids)

こちらのデモ実装におけるシミュレーションでは、

  • 素性数(ランキングの条件)は4つ
  • 生成するランキングのダミーアイテム数は50に固定
  • ダミーアイテムは、4つの素性値をランダム生成したものを事前に大量生成

という条件に基づいてランカー数をnに決定しています。

また、1回のイテレーションで、ユーザさんは提示されたランキング(50アイテム)の中から、 1つめの条件が大きいものを順に3つクリックしたと仮定しました。 それを50イテレーション(=1セット)繰り返した後、第一素性の重みを記録したうえで、さらに20セット繰り返して、最終的に1個目の素性重みの平均を取得し、 それをランカー数:nの場合の「最終第一条件の重み」として評価しました。

その結果、このシミュレーションでは、このようにランカー数:2~20に変化させて行ったところ、次のような結果が得られ、 十分な収束を確認することができました。

f:id:long10:20200626001413p:plain

ランキング評価エコシステム

それでは、このマルチリービングをどのようにクラシルのランキングに反映したかについてご紹介したいと思います。 この仕組み全体をランキング評価エコシステムと呼ぶことにします。 導入したクラシルの機能は、特定の検索キーワードに紐づいて表示されるテーマ別ランキング*8という機能になります。

f:id:long10:20200626112356p:plain

マルチリービングを用いたランキングを自動改善する流れは、このような流れになります。

  1. こちらのテーマ(殿堂入り、時短、子供が喜ぶなど)一つ一つについて、複数のランキング生成条件(視聴数、新しい順など)に基づくランキングをいくつか生成します。
  2. ユーザさんを行動に基づいていくつかのグループ(クラスタ)に分類します。
  3. 各クラスタごとに1で生成したランキングからごちゃ混ぜランキングを生成します。
  4. クラスタごとにそれぞれごちゃ混ぜランキングを表示します。
  5. 1週間経って、ユーザさんがクリックした結果を集計します。
  6. 集計結果から、ユーザさんがこの1週間でもっとも好んだランキング生成条件を評価します。
  7. ユーザさんが好んだランキングを考慮して、ごちゃ混ぜにする割合をちょっと変化させます。
  8. 変化した割合が反映された状態で、再びごちゃ混ぜランキングを生成します。

つまり、ランキングを複数作ってABテストを行い、ABテストの結果を反映したランキングを毎週自動で作ることで、 ほんのちょっとずつランキングを最適化していこうという作戦になります。

なお、この機能実装については、こちらの先行研究を参考に行いました。*9

先行研究でのアルゴリズム

f:id:long10:20200626114737p:plain

こちらの研究では、オンラインでの評価が対象となっていますが、これをそのままクラシルに適応してしまうと処理コストが高すぎるし、 食コンテンツというのは、ニュースコンテンツなどとは異なり即時性をそこまで求められないことから、 今回は1週間に1回更新するバッチ処理として導入することにしました。 また、先行研究のアルゴリズムはTDM(Team draft multileaving)が採用されていますので、PPMを用いている部分も異なっていると言えます。

それでは、ランキング自動改善エコシステムをより具体的にご紹介したいと思います。

全体概要

f:id:long10:20200626102810p:plain

ランキング自動改善エコシステムは、以下の5つのSTEPで構成されています。

1. aggregate ステップ

Athenaを経由して以下のデータ収集・集計を行う ユーザさんの行動から1週間分(起動当日-7日)の特徴量を収集・集計する 1週間分のクリック数をユーザごとジャンルごとに集計し保存する

2. user clustering ステップ

ユーザさんの行動状況から、ユーザさんをk個のクラスタに分類します。 このクラスタごとにランカー(ランキング生成器)を生成していきます。

3. create ranking ステップ

aggregate ステップで収集したデータに基づいて、複数のランキングを生成します。 ランキングを生成したのち、現状のランカーパラメータの保存を行います。 ランカーパラメータの状況に基づき混合ランキングを生成します。

4. evaluate ステップ

ユーザクラスタごとジャンルごとにランカーの評価を行い更新します。 勝者ランカーのパラメータに対し割合を調整して保存します。

5. load ステップ

生成した混合ランキングをDynamoDBに格納します。

難しかった点、課題について

こちらの実装期間については、加藤先生のご指導とモジュールのおかげで、5日ほどでサービスリリースすることができました。 リリースしたばかりなので適合率の評価などは実際まだこれからですが、 導入までの難しかった点としては、マルチリービングがいかに少数のサンプル数で収束効果があるとはいえ、 今回非常にニッチな機能なので、摂動の影響は非常に小さく、それほど大きな効果を得ることはできなそうという点があります。 また、当初ユーザごとにパラメタを調整する設計だったのですが、やはり処理コストが肥大化してしまうので、 今回は、初回導入ということでユーザクラスタ*10 に対するランカーパラメータにすることで処理コストを抑えました。 今後の課題としては、このエコシステムを改善していくことで、ランキングに限らず他機能のA/Bテストにも活用できるようにしていきたいと思っています。 また、ランカー数を増やしたり、摂動するハイパーパラメタをチューニングすることによってさらに最適なランキングを生成し、 可能な限りアローの不可能性定理に立ち向かっていきたいと考えています。

まとめ

いかがでしたでしょうか?
今回は、Pairwise Preference Multileavingという手法を用いてランキングのA/Bテストを行い、その結果を反映してランキングを勝手にどんどん最適化させるという手法について ご紹介させて頂きました。 実際、ユーザさんに満足してもらえるような適合率にしていくためには、まだまだこれから改善が必要になると思っていますが、 アローの不可能性定理に人海戦術で対応していくのは、その名の通り不可能なので、 こういった技術や工夫を用いてクラシルを利用してくださるユーザさんにもっともっと満足してもらえる機能を提供・改善していきたいと考えています。

GASで作ったBotを負債化させないようにやってきた管理の仕方

f:id:kenzo_aiue:20200622170356p:plain:w200

こんにちは。androidエンジニアと兼任でスクラムマスターをしているkenzoです。
スクラムマスターの業務において、メンバーや自分へのリマインド、バックログ整理の自動化、タスク状況の可視化などをGAS(Google Apps Script)を使って実施しています。
また、それ以外にもプロジェクトの進行管理やいろいろなことをGASでやってきました。(自分の健康管理とかも

GASを使うとちょっとしたスクリプトを書くだけで簡単にG Suiteのサービス(ドキュメント、スプレッドシート、カレンダー等)と連携することができます。
HTTP通信もできるので、APIを通じて他のサービスと連携させることも可能です。
そのため、うまく使えばかなりの業務を効率化させることができます。

簡単なことをやらせているうちは良いのですが、次第にいろんなことをさせたくなり、スクリプトは複雑化していきます。
永遠に自分だけで管理するのならそのままでも良いでしょう。
しかし、会社で業務としてやる以上は役割が変わったり退職したりと他の人がそのbotの面倒を見るようになることが考えられます。
その後任の方に辛い思いをさせないよう、きちんと整理しておく必要があります。

本記事では筆者が初めてGASを触ってから今まで作ってきたbotの管理方法の変遷について書いています。
コードの中身の話はありません。

GASのコードエディタ直書き + トリガーで定期実行

初めはスプレッドシートからGASのコードエディタを開き、そこにスプレッドシートの内容の操作やSlack等の外部サービスと連携するコードを直に書いていました。
そして、その処理を定期実行させるために作成した関数をトリガーに設定して運用していました。

f:id:kenzo_aiue:20200527181417p:plain

おそらく誰しも初めはここから始めたことと思います。

Chromeの拡張機能を使いGitHubでコードを管理

運用を続け、コードが増えてくるとGitHub等で管理したい気持ちが強くなってきます。
初めのうちはGASのコードエディタに書いたコードをローカルのファイルにコピペし、それをGitHubに上げていました。
なにか良い方法はないかと思い、見つけたのがChromeの拡張機能「Google Apps Script GitHub アシスタント」でした。

これを使うとコードエディタ上にGitHubでのコード管理に使うボタンが表示されるようになり、その場でpushやpullをすることができるようになります。

f:id:kenzo_aiue:20200618114812p:plain

当時はこれでずいぶん楽になったのを覚えています。

claspを使ったローカル開発

コード管理は楽になりましたが、当時は依然としてGASのコードエディタ上でコードを書いていました。
また、コードをコピペしてきてIDE上で変更してそれをまたコードエディタに戻したりというきつい運用をしていたこともありました。
その頃知ったのがGoogleが作ったCLIツール「clasp」でした。

このツールを使うとコードエディタのコードをコマンドでローカルに持ってきて開発することができます。
そして、ローカルで開発したコードは別のコマンドでコードエディタにアップロードして動かすことができます。(他にもできることはあります)
これを使うことで泥臭くやっていたローカル開発を手軽に行えるようになりました。

そして、いつからかTypeScriptに対応していたので、ローカルではTypeScriptで書けるようになりました。

トリガーをコードとスプレッドシートで管理

これまで多くのトリガーを作成して運用してきましたが、トリガーには若干扱いづらい部分もいくつかありました。

  • トリガーが作成したユーザーに紐付くため他のメンバーから見られない
  • 定期的に走るトリガーは○時〜○時の間としか指定できず、その間のいつ発火するかわからない
  • どのトリガーがいつ発火するのものなのか、コンソールで一つずつ開いてみなければわからない

GASにはトリガーを作る関数もあるのでそれを使うことにしました。
スプレッドシートにこのように実行したい関数と曜日、時間を指定します。

f:id:kenzo_aiue:20200618084818p:plain
(イメージです)

毎日0時を過ぎたくらいにこのスプレッドシートを元にトリガーを作成する処理を走らせることで、上記の問題も下記のように解決できました。
ちなみにこの処理のトリガーだけはコンソールで管理していました。*1

  • スプレッドシート(+コードも)の権限さえ付与すれば誰でもトリガーを管理できる
  • 指定した曜日、時間にトリガーを発火させられる
  • スプレッドシートを見ればどのトリガーいつ発火するのかひと目で分かる

Slackに送るメッセージをスプレッドシートで管理

リマインドすべきものが増えてくると、中には毎度同じメッセージを送っているものもあることに気が付きました。
そこで、上記のトリガーと同様にスプレッドシートで送りたいメッセージを送信タイミングを指定して管理することにしました。

f:id:kenzo_aiue:20200618111643p:plain (イメージです)

SlackのユーザーやグループのIDを用意しておけば、メンション付きのメッセージを送ることもできます。
こちらも毎朝早い時間にその日のメッセージを送るトリガーを作成して*2指定した時間に実行しています。

ちなみにこれはslackのremindでもそこそこ近いことができます。

スクリプト毎にclaspの設定を持たせて管理

スクラムに関するBotが多くの機能を持つようになってきて下記の理由のためにスクリプトエディタを分ける必要が出てきました。

  • 1日にトリガーの最大設定数20を超える処理を実行する場合がある
  • シートに設置したオブジェクトのクリックで処理の実行をさせるためそのシートのスクリプトエディタ上に処理を書く必要がある
  • それぞれが異なる役割を持っている(リマインド機能、タスクの可視化機能など、、)

スクリプトを分けてもスクラムに関するBotとして共通の処理もあるため、GitHub上では1つのリポジトリで扱い、ディレクトリ毎にclaspの設定ファイルを置いてアップロード先を分けて運用する形にしました。

各スクリプト共通で使う処理の括り出し

上記でスクリプト毎にアップロードする単位を分けましたが、Slackへの投稿をする機能のようにそれぞれのスクリプトで同じ機能を使う箇所があります。
分けた当初はその機能をファイルとして切り出してはいたものの、それを各ディレクトリにコピペして置いていました。
そのため、処理に変更を加えたければそれぞれのディレクトリ以下にあるファイル同じファイルに同様の変更を加える必要がある状態でした。

こちらへの対応としては、共通処理を書いたファイルをディレクトリに括り出し、アップロードの直前でそれらを移動して対象のファイルと一緒にするかたちをとりました。

|--common
|  |--slack.ts
|  |--github.ts
|--script1
|  |--.clasp.json
|  |--src
|  |  |--script1.ts
|--script2
|  |--.clasp.json
|  |--src
|  |  |--script2.ts
|--script1アップロード用(スクリプトで作成)
|  |--.clasp.json
|  |--common
|  |  |--slack.ts
|  |  |--github.ts
|  |--script1
|  |  |--src
|  |  |  |--script1.ts

package.jsonにファイル移動・アップロードをするスクリプトを作成し、それを使って運用しています。 (webpackでまとめてからアップロードすることもできそうでしたが、こちらの方法に比べて学習・作業コストが高そうだったため一旦見送っています。いずれ時間のあるときに試してみようと思います。)

まとめ

以上が今日に至るまでに実施してきたGASで作ったBotの管理の仕方でした。
どこまで管理してどのような運用をすべきかはBotを使う環境や扱う方の職種等によって様々かと思います。
こちらはあくまで一例ですが、Botの管理や運用の仕方で困っている方の一助になれば幸いです。


delyはエンジニア大募集中です。クラシルのエンジニアとざっくばらんに話をするお茶会・ランチ会なども実施していますので、興味のある方は是非ご応募ください!

*1:V8ランタイムで実行する際にバグっぽい挙動があったので注意してください(コンソールで作成したトリガーで実行する関数でさらにトリガーを作成するとそのトリガーが無効になる?ような挙動でした。あんまりやらないとは思いますが。)

*2:トリガー毎にメッセージを渡すことができなかったので、別シートに送信予定のメッセージを置いています。

クラシルAndroidプロジェクトの開発者体験の向上を頑張ってます!

こんにちは、そしてはじめまして、今年2月にAndroidエンジニアとして入社したばかりのMeilCliです。先日プロジェクトマネージャーのtummyがユーザー視点でのクラシルAndroidアプリの改善を紹介しましたが、今回は自分が入社してから改善されてきた開発者視点でのクラシルAndroidアプリについてご紹介できればと思います

Detektの導入

クラシルAndroidアプリの開発にはKotlinコードを静的解析するツールとして今までktlintが使用されてきました。類似ツールとしてdetektというものがあり、このdetektにはktlintの解析ルールが収録されているため、解析ルールベースで考えると上位互換にあたります。そのため、ktlintからdetektへ移行することにしました

detektにはGradle Pluginが用意されているためAndroidプロジェクトへの導入はスムーズにできますが、クラシルAndroidのプロジェクトは現在マルチモジュールプロジェクトへの移行をしている最中なのでモジュールが複数存在しています。ktlintを運用しているときはマルチモジュールに対応した設定をしていなかったため、アプリケーションモジュールとなるappモジュールのみにktlintを実行している形になっていたのでdetektへの移行と同時にマルチモジュールに対応した設定を行うことにしました

各モジュールへの設定をいちいち書くのは面倒であり管理コストが増えてしまうので、今回はbuildSrcにGradle Pluginを作成し、各モジュールのbuild.gradleで作成したGradle Pluginを適用するだけでよい形にしました

まずプロジェクトのルート直下にbuildSrc/build.gradle.ktsを作成し、buildSrcのための設定を記載します

plugins {
    `kotlin-dsl`
}

repositories {
    jcenter()
    google()
    maven("https://plugins.gradle.org/m2/")
}

dependencies {
    implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.6.0")
}

そうしたらbuildSrcがモジュールのように識別されるのでDetekt用のGradle Pluginのソースコードを追加していきます

buildSrc/src/main/kotlin/com/kurashiru/gradle/plugins/DetektConfigPlugin.kt:

package com.kurashiru.gradle.plugins

import io.gitlab.arturbosch.detekt.extensions.DetektExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import java.io.File

class DetektConfigPlugin : Plugin<Project> {

    override fun apply(project: Project) {
        project.extensions.findByType(DetektExtension::class.java)?.apply {
            toolVersion = "1.6.0"
            buildUponDefaultConfig = true
            parallel = true
            config = project.files("${project.rootProject.rootDir}/detekt.yml")
            reports.apply {
                xml.enabled = true
                val name = project.projectDir.toRelativeString(project.rootProject.rootDir).replace("/", "_")
                xml.destination = File("${project.rootProject.rootDir}/reports/detekt/${name}.xml")
            }
        }
        project.dependencies {
            add("detektPlugins", "io.gitlab.arturbosch.detekt:detekt-formatting:1.6.0")
        }
    }
}

buildSrc/src/main/resources/META-INF/gradle-plugins/DetektConfigPlugin.properties:

implementation-class=com.kurashiru.gradle.plugins.DetektConfigPlugin

このGradle Pluginではルート直下のdetekt.ymlをconfigとして指定し、CI用にルート直下のreports/detektフォルダーにレポートを書き出すようにしています

あとは通常と同じようにdetektのGradle Pluginの依存関係をルート直下のbuild.gradleに書いて、detektを適用するモジュールでは

apply plugin: 'io.gitlab.arturbosch.detekt'
apply plugin: 'DetektConfigPlugin'

この2行を追加すれば良いという形になりました

detektはホームページでたくさんのRuleSetsが紹介されるほど細かいところまで静的解析し、問題を検知・報告してくれるツールです。便利な反面、RuleSetsの設定をチューニングしなければ不要な警告を出してしまいます。そのため今後はクラシルAndroidアプリ開発に適したRuleSetsのチューニングをしていこうと考えています、あと最新版のdetektにバージョンアップしないとですね笑

GitHub Actionsの導入

クラシルAndroidアプリの開発では以前からAmazonのCodeBuildを使用してきました。CodeBuildではPullRequestでのビルドチェックやストアリリース時のリリースビルドを行っていましたが、それ以外でもCIで自動化してみたいよねということで、OSSでは無料でありプライベートリポジトリーでも無料枠の多いGitHub Actionsを部分的にですが導入していくことになりました

クラシルAndroidでは様々なGitHub Actionsのワークフローを運用していますが、そのうちの何点かご紹介します

PullRequestのタイトルチェック

クラシルAndroidでは社内的なリリースノート作成のためにPullRequestのタイトルに対して一定の規則を設けています。具体的には[improvement] タイトルのようにタグをprefixとして付けるだけですが、開発者がこの規則通りにPullRequestを作成できているかを人の手でチェックするのは大変なため、GitHub Actionsで自動化しました

name: プルリクのタイトルチェック

on:
  pull_request:
    types: [opened, edited, reopened]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: octokit/request-action@v2.x
        # 条件式は長いので省略しています
        if: startsWith(github.event.pull_request.title, '[improvement]') == false
        with:
          route: POST /repos/:repository/pulls/:pull_number/reviews
          repository: ${{ github.repository }}
          pull_number: ${{ github.event.pull_request.number }}
          event: "COMMENT"
          body: "PRのタイトルは`[タグ] .+`という形式にしてください"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

このワークフローではoctokit/request-actionを使用しています。これを使うことによってワークフローのステップにてGitHubのREST APIを楽に呼び出すことができるので、「こういう場合にこういうコメントを付ける」といったワークフローを作るときに便利です

PullRequestのマイルストーンチェック

タイトルと同じように、マイルストーンも社内的なリリースノート作成のために設定をルール化しています

name: プルリクのマイルストーンチェック

on:
  issues:
    types: [demilestoned]
  pull_request:
    types: [opened]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: octokit/request-action@v2.x
        name: Check PR Demilestoned
        if: github.event_name == 'issues' && github.event.action == 'demilestoned' && github.event.issue.pull_request != null
        with:
          route: POST /repos/:repository/pulls/:pull_number/reviews
          repository: ${{ github.repository }}
          pull_number: ${{ github.event.issue.number }}
          event: "COMMENT"
          body: "PRには必ずマイルストーンを付けてください"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - uses: octokit/request-action@v2.x
        name: Check PR Opened
        if: github.event_name == 'pull_request' && github.event.action == 'opened' && github.event.pull_request.milestone == null
        with:
          route: POST /repos/:repository/pulls/:pull_number/reviews
          repository: ${{ github.repository }}
          pull_number: ${{ github.event.pull_request.number }}
          event: "COMMENT"
          body: "PRには必ずマイルストーンを付けてください"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

先にワークフローファイルをお見せするとこのような形で運用しています。octokit/request-actionを使ってコメントで通知するのはタイトルチェックと同じですね

さて、このワークフローではトリガーとなるイベントにissuesdemilestonedを使用しています。デフォルトブランチに直Pushする開発体系ならばこういったワークフローの作成を難なくできるかと思いますが、クラシルAndroidプロジェクトではPullRequestを介してデフォルトブランチにpushする形式を取っています
このワークフローを開発していたときのGitHub Actionsの挙動では、PullRequestで変更されたワークフローファイルはPullRequest上のCheckではpull_requestのイベントしかトリガーを引いてくれないという仕様になっている様子でした。そのため最初このワークフローを作成するときにissuesdemilestonedに対する動作を確認できなくてつまづきました。「マイルストーンが外された」や「マイルストーンが付いていない状態」をトリガーにするワークフローを作成したいときは是非参考にしていただければと思います

PullRequestに限らずIssueについても動作するサンプルをMeilCli/actionsに置いていますのでそちらもよければどうぞ

ちなみにですが、GitHub上でマイルストーンを貼り替える作業をするとdemilestonedのイベントが発火するらしく、このワークフローではそのままコメントを付けてしまうのでdelayをかけてAPIから情報を取ってくるなどの改善点があります。そのうち直そうと考えていますm(__)m

Detekt

さて、最初に紹介したDetektですが、PullRequestで自動でチェックしてくれなければ意味がありません。そこでGitHub ActionsでDetektを実行し、Dangerを使ってDetektの結果をコメントで通知するようにしました

クラシルAndroidではCodeBuildのほうでもDangerを運用しているためすでにGemfileDangerfileが存在していました。GitHub Actionsではそれらのファイルと共用するのは避けたかったため、.github配下にそれらのファイルを配置することにしました。Gemの解決にBundlerを使用している都合上、そのような配置をすると依存関係を解決できなくなるという状態に陥ったため、過去に自分が勉強がてらに作成したMeilCli/danger-actionGemfileの位置にとらわれないように改修して使用しています

.github/Gemfile:

# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

# gem "rails"
gem 'danger'
gem 'danger-checkstyle_format', '~> 0.0.1'

.github/Dangerfile:

github.dismiss_out_of_range_messages

checkstyle_format.base_path = Dir.pwd
report_files = Dir.glob("reports/detekt/**")
for report_file in report_files do
    checkstyle_format.report report_file
end

.github/workflows/ci.yml:

name: CI

on: [pull_request]

jobs:
  detekt:
    runs-on: ubuntu-latest
    env:
      GITHUB_USER: "github-bot"
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review'
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-ruby@v1
        with:
          ruby-version: '2.6'
      - uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - uses: actions/cache@v1
        name: Cache Gradle
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('buildSrc/build.gradle.kts') }}-${{ hashFiles('buildSrc/src/**') }}
          restore-keys: |
            ${{ runner.os }}-gradle-${{ hashFiles('buildSrc/build.gradle.kts') }}-
            ${{ runner.os }}-gradle-
      - uses: actions/cache@v1
        name: Cache Bundler
        with:
          path: vendor/bundle
          key: ${{ runner.os }}-gems-${{ hashFiles('.github/Gemfile') }}
          restore-keys: |
            ${{ runner.os }}-gems-
      - name: Grant permission
        run: chmod +x gradlew
      - name: Run Detekt
        run: ./gradlew detekt
      - uses: MeilCli/danger-action@v4
        name: Run Danger
        with:
          plugins_file: '.github/Gemfile'
          install_path: 'vendor/bundle'
          danger_file: '.github/Dangerfile'
          danger_id: 'danger-detekt'
        env:
          DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}

さて、GitHub ActionsやDangerを運用されている方々にとってはあまり目新しいワークフローではないかもしれませんが、Detektを導入するにいたって、開発者がPullRequestに変更を加えてから静的解析のコードレビューがされるまでの時間を減らす点でこだわることにしました

具体的にはactions/cacheを使って依存関係やタスクの実行結果などをキャッシュすることによって、キャッシュが存在する場合にキャッシュを利用することで早く動作させるようにしました

f:id:meilcli:20200617170748p:plain
GitHub ActionsのCIワークフローの実行一覧
結果としては遅ければ5分程度、早ければ2分程度、平均3~4分で実行が完了するようになったためキャッシュを利用してワークフローを高速化する試みは成功だったかなと思います
(参考数値としてktlintの頃はいろいろな処理を待機していたために約8分ほどかかっていました)

今後について

開発者体験という視点で言えばクラシルAndroidプロジェクトはたくさんの課題があります。その中で直近でやろうと考えてるものをご紹介し、これを次回予告にします

  • 長年放置し続けてきたlintの対応、不要になったリソースの削除
  • 使用しているOSSなどのライセンス表記のための集計ツールをMeilCli/Librarianに置き換える
    • これによってライブラリーやライセンス通知の集計精度が向上する見込みです
  • ライブラリーアップデートの自動検出・通知
  • モジュールのbuild.gradleの共通な宣言部分をbuildSrcへまとめる

note.com

最後にdelyではエンジニアを大募集しています。ランチ会などを定期的に開催してるので、中の人と話してみたい!という風に興味がある方は是非お声がけください

クラシル Android アプリの改善をがんばっています!

こんにちは、クラシル Android のプロダクトマネージャーをしている tummy です。dely では今年 4 月から本格的にチームが立ち上がり、Android アプリの改善に取り組み始めました。スタートから 2 ヶ月弱経ち、徐々にアプリ内も変化してきたのでスクショを交えつつ取り組みについてご紹介できればと思います。

まずはデザインのお話から。

検索バーが変わりました

f:id:rnitame:20200611210917p:plain

ホーム上部にある検索バーですが、パッと見ここから検索できるように見えないのではないかという仮説のもと、人気キーワードを定期的に変える形で表示してみたり、検索ボタンを明示的に表示するようにするなど様々な UI を試しました。結果、検索バーを固定したパターンでわずかなタップ率の向上が見られたため現状はこのパターンになっています。あまり数字は変わりませんでしたが、こういった地道な改善も行っていっています。

お気に入り検索結果画面からフォルダ追加できるようになりました

f:id:rnitame:20200611210437p:plain

今までお気に入りの検索結果画面からフォルダに追加することはできませんでした。しかし、ユーザーさんからのご要望が多かったため機能を実装することにしました。少しずつですが使われている数が増えているので嬉しいです。ぜひご活用ください! 改善ブログに使い方も載っているので良ければこちらも御覧ください 💁‍♀️

note.com

レシピ詳細の材料部分にアイコンがつきました

f:id:rnitame:20200611211314p:plain

以前からここはタップできる領域で、材料名で検索できるようになっていて使ってくれているユーザーさんも多いことがわかりました。そこで、上2つの材料名に虫眼鏡のアイコンをつけることで「ここはタップできて、タップすると検索に飛べる」という認知を持ってもらえるのではないかと考えました。結果、認知を持ってもらえたのか、以前より材料検索を使う方が増えてくるようになりました。

通知の設定が変わりました

f:id:rnitame:20200611212953p:plain f:id:rnitame:20200611213037p:plain

今までは通知をオフにするとすべての通知が届かないようになってしまっていました。 しかし最近のアップデートで、以下の項目ごとに通知のオン・オフを設定できるようになりました。

  • お得な情報(キャンペーンなど)
  • チラシ
  • 質問への返信
  • 公式アカウントからのメッセージ
  • おすすめレシピ
  • その他(以上の項目に属さないものすべて)

そのため、チラシの通知はいらないけどおすすめレシピの通知はほしい!といった場合にも対応できるようになります。


内容変わって、技術的なお話もさせてください。

minSdkVersion をアップデートしました

minSdkVersion を 16 から 19 に引き上げました。クラシルでは一度もあげたことがなかったので、ついに!といった感じです。今回はあまりコードを削除できた部分はありませんでしたが、今後徐々にあげていって古い OS 向けに書いているコードも消せるといいなぁと思います。

f:id:rnitame:20200609135208p:plain
minSdkVersion を上げるリリースをした際のリリースノート

改善がんばってます!

分析したデータ、ユーザーインタビューやアンケートの結果など、たくさんの情報を活用して改善を進めています。(インタビューやアンケートにご協力いただいたユーザーの皆さまありがとうございます!)

今後も検索機能を中心に改善を進めていくほか、プッシュ通知の改善にも着手していく予定です。また内容についてはブログ等でお知らせできればと思います。

最後に、dely ではエンジニアを全方面で絶賛募集中です。 興味がある方は是非お声がけください!

speakerdeck.com

note.com

CSエンジニアになって3ヶ月経ちました

f:id:sakura818uuu:20200402165707p:plain

はじめまして。開発部の sakura818uuu です。 CS(カスタマーサクセス)チームのサポートエンジニアを始めて3ヶ月が 経過しました。

3ヶ月が経ったので振り返ってみようと思います。

主な業務内容

主な業務内容はCSチームの技術的サポートや施策の計画や管理をしています。
また、CS業務のドキュメントの拡充や整理を行いました。

やってること(一部)
・お問合せの技術的サポート
・CSに関する施策の計画・実行管理
・CS業務の言語化

お問合せの技術的サポートはブログも書かせていただきました。 tech.dely.jp

求められるスキル

3ヶ月働いてみて実際に感じた求められスキルを3つ記載します。
データ分析(SQL) 、プロダクトの仕様把握 、情報共有です。

1.データ分析(SQL)

データ分析は言わずもがな必要になります。
SQLはCSエンジニアになる前もデータサイエンスチームに所属していたため書いていました。

ただ、データサイエンスチームでは「全体の何%が」と森を見ることが多かったのですが、CSは逆で個別のデータログなど木を見ることが多いです。
どちらも経験したからこそデータの仕様についての理解が一段と深まったように思います。

2.プロダクトの仕様把握

ユーザーさんから多種多様なお問合せをいただくため、プロダクトの仕様把握は最重要です。
運がいいことに私にはかなり合っている仕事でした。なぜなら入社してから2年間テスター(のようなこと)をしていたから仕様把握が既に細かなところまで出来ていたからです。

新機能やバージョンアップに伴う仕様把握も重要です。
この3ヶ月はリリースのサイクルが非常に速かったため、開発状況から常に目を離さないようにしていました。
新機能でわからないところや不明点があると時折エンジニアに確認したりして仕様認識の齟齬を起こさないよう気をつけました。

3.情報共有

情報の共有はどんな仕事でも必ず必要ですが、特にCSではこまめな情報共有が必要となります。

・主にユーザーさんから届いたバグや不具合の情報を開発者に伝える ・新機能や不具合修正などの開発状況をCSチームに伝える

この2つの情報共有は欠かせないです。

また、全体的なお問合せの傾向を把握できるよう
お問合せ内容をカテゴリ別に分類し月別にどんなお問合せが何件届いているのかを可視化するようにしました。

情報共有をまめに行うことのできる人がCSエンジニアには向いているんだな、と思いました。

他にもあったら良さそうなスキル

他にもあったら良さそうだと感じたものです。
今はテクニカルライティングを書籍やネットを通して学んでいます。

・テクニカルライティング
・原因を突き止めるために問題の切り分けを上手くできること
・少ない情報からあらゆることを想像できること

キャリアとして

3ヶ月経ってみてキャリアとして考え方が変わりました。
CSエンジニアの前は検索エンジニアをやっていて、「これからもずっと検索に携わっていきたいな。いくんだろうな」と思いきっていたからです。

CSエンジニアになってみて、CSエンジニアとして歩んでいくのもいいなと思いました。

さいごに

CSエンジニアになって3ヶ月経ち大分慣れてきました。
まだ土台が出来た状態にすぎないので、これからCSエンジニアとしてどんなことができるか模索していきたいと思います。


delyではエンジニアを絶賛募集中です。
興味があるかたは是非お声がけください!

【Rails】 ActiveHash gemのクラシルでの事例とハマりポイント

こんにちは、開発部の高橋です。f:id:jity:20200225114534p:plain

最近弊社のRailsプロジェクトでactive_hashというgemが使われ始めました。

個人的にも結構重宝しているgemでとても便利なのですが、一方で特性を理解せずに使うとハマりやすいgemでもあると思っています。

今回は、ActiveHashのクラシルでの事例と自分の過去の知見に基づくハマりポイントなどを書いていきます。

目次

ActiveHashとは

データをRDBではなくHashやYAMLで定義し、それをActiveRecordライクに利用できるGem。

コード上にデータを持つためmigrationやseedなどの考慮が不要で、テーブルを持つほどでもない・ほとんど変更のないようなちょっとした静的データを保持する際にとても便利です。

詳しい使い方はREADMEを見てほしいですが、基本的には以下のようにActiveHashに用意されているクラスを継承し、データを設定して使います。

class Foo < ActiveHash::Base
  self.data = [
    { id: 1, name: 'a' },
    { id: 2, name: 'b' },
    { id: 3, name: 'c' },
  ]
end

上記のようにdata属性にデータを追加していく方法以外にも、json,yamlなどのファイルにデータを設定してを読み込ませることもできます。

クラシルでの事例

導入経緯

以前もブログにて紹介された「サーバーサイド&SRE改善MTG」での議題として自分が発案し、導入に至りました。

ちなみに、この改善MTGは1月で1周年を迎えたようです 🎉

tech.dely.jp

もともとクラシルのプロジェクトにはactive_hashが使われるようなユースケースが存在しており、すでにレポジトリ内にYAMLなどで定義された静的データが散在してました。

それらがバラバラに管理され、使われ方も統一されていなかったところを一元化したいというところが主な導入意図です。

また、今後何かしらのマスターデータを管理していく際にも便利なので、今のうちに入れておきたいということで入れました。

使われ方

データはyamlで管理し、app/models配下にActiveRecord継承なモデルと一緒に置くというおそらくはスタンダードな使い方で利用してます。

ActiveYamlRecordという親クラスをつくり、各クラスで継承していきます。

# app/models/active_yaml_record.rb
class ActiveYamlRecord < ActiveYaml::Base
  set_root_path Rails.root.join('config', 'masters')
end

# app/models/foo.rb
class Foo < ActiveYamlRecord
end
- id: 1
  name: 'aaa'
- id: 2
  name: 'bbb'
# ...

2020年2月時点ではまだ3モデル程ですが、これから要所要所でガンガン使っていきたいと思っています。

ハマりポイント

ActiveHash導入に関して、自分の過去の経験からハマりやすい(ハマった)ポイントをいくつかご紹介しようと思います。

インスタンス変数がクラスインスタンス変数相当

ActiveRecordでは取得されるごとに生成されるオブジェクトは異なりますが、ActiveHashでは常に同じオブジェクトが返ります。

これはデータが読み込まれる際に各レコードのインスタンスが作成され、クラスインスタンス変数に保持され使い回されるからです。

以下はActiveRecordとの挙動の差です。

class Foo < ActiveHash::Base
  self.data = [
    { id: 1, name: 'a' },
    { id: 2, name: 'b' }
  ]
end

require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.define do
  create_table :bars, force: true do |t|
  end
end

class Bar < ActiveRecord::Base
end
Bar.insert_all([{id: 1}, {id: 2}])

b1 = Bar.first
b2 = Bar.first
p b1
#=> #<Bar id: 1>
p b2
#=> #<Bar id: 1>
p b1.equal? b2
#=> false

f1 = Foo.first
f2 = Foo.first
p f1
#=> #<Foo:0x00007ff31791e738 @attributes={:id=>1, :name=>"a"}>
p f2
#=> #<Foo:0x00007ff31791e738 @attributes={:id=>1, :name=>"a"}>
p f1.equal? f2
#=> true

このような挙動になるため、ActiveRecordと同じ気分でインスタンスの状態を変更するとハマるので注意が必要です。

class Foo < ActiveHash::Base
  self.data = [
    {id: 1, name: 'a'},
    {id: 2, name: 'b'},
  ]

  attr_accessor :foo
end

f1 = Foo.first
f1.foo = 'bar'
p f1.foo
#=> "bar"

f2 = Foo.first
p f2.foo
#=> "bar"
# nilではなくf1で代入した値が返る

データがロードされるタイミング(特にActiveYaml, ActiveJsonの場合)

これはActiveYamlやActiveJsonなど、データをファイルで持ってる場合に特に気をつけたいことです。

これらのクラスのデータロードのデフォルトの挙動は、取得メソッドが実行されたタイミングになります。

具体的には以下のメソッドが実行された際にファイルのパースが実行され各クラスにデータが格納されます。

  • find
  • find_by_id
  • all
  • where
  • method_missing

github.com

class Foo < ActiveYaml::Base
  set_root_path File.expand_path(__dir__, './')
end
# この時点ではまだYAMLは未ロード

Foo.find(params[:id]) # ここで初めてYAMLが読み込まれる

小さいデータであれば問題はないようにも思えますが、大きなデータの場合は初回リクエストだけロードに時間がかかるようになってしまいます。

また、スレッドセーフな作りになってるわけではなさそうなので、複数スレッドで実行される際にも注意が必要です。

以下のように、意図しない挙動になってしまう可能性があります。

class Foo < ActiveYaml::Base
  set_root_path File.expand_path(__dir__, './')
end

t1 = Thread.new do
  p Foo.first
  p 't1 done'
end

t2 = Thread.new do
  p Foo.first
  p 't2 done'
end

t1.join
t2.join
$ ruby sample.rb
nil
"t2 done"
#<Foo:0x00007fe224c622e0 @attributes={:id=>1, :name=>"name1"}>
"t1 done"

これはおそらく以下のような状況になってるものと思われます。

  • t1
    • ActiveFile::Base.firstが呼び出される
    • データ読み込みフラグ(data_loaded)のチェック
    • .reloadが呼び出される
    • data_loadedフラグがtrueになる
    • YAMLのロード処理が走る
  • t2
    • ActiveFile::Base.firstが呼び出される
    • データ読み込みフラグ(data_loaded)のチェック
    • データ読み込み済み(data_loaded=true)と判定される
    • ActiveHash::Base.firstが呼び出される
    • t1のロードが終わっておらずデータが空なので[].firstが処理として実行される
    • nilが返る
  • t1
    • YAMLのロード完了
    • ActiveHash::Base.firstが呼び出される
    • レコードが返る

アプリケーションサーバーなどでマルチスレッドな環境を利用している場合は、意図しない挙動が引き起こされやすい状況と言えそうです。

対応策

この対応策として思いつくものとしては、クラスロード時にデータを読み込んでしまうことです。

class Foo < ActiveYaml::Base
  self.reload
end

上記のようにクラス定義で.reloadを呼び出せばクラスが読み込まれる起動時などにデータを読み込ませることができるため、メソッドの初回呼び出し時でもロードが行われません。

あるいは、config/initializers配下で呼び出す方法もあるかと思います。

#  https://github.com/zilkey/active_hash#defining-data の応用
# config/initializers/data.rb
Rails.application.config.to_prepare do
  Country.reload
end

ただしどちらも起動が遅くなるというデメリットがあるため、各々のアプリケーションに適用できるかどうかは要確認です。

いずれにせよ、利用前にある程度意識しておく必要はあるかと思います。

最後に

delyではRailsエンジニアを絶賛大募集中です。

  • 大量のトラフィックをさばきたい
  • 食の課題を解決したい
  • ユーザーファーストな開発がしたい

といったことに少しでも興味があるかたは、是非お声がけください!

www.wantedly.com

Rails Girls Tokyo 13thにスポンサー協賛&コーチ協力をしました!

f:id:mochizuki_pg:20200226140555j:plain

こんにちは! サーバーサイドエンジニアの望月です!

先日開催された「Rails Girls Tokyo 13th」に、
delyはスポンサーとして協賛してきました!

また今回は、私とサーバーサイドエンジニアの安尾が
コーチとして参加者のサポートもさせていただきました。

今日はその様子をかんたんにご紹介したいと思います。

Rails Girlsとは?

f:id:mochizuki_pg:20200125090537p:plain

railsgirls.com

Rails Girlsとはプログラミングに興味のある女性が
コーチである現役のエンジニアのサポートのもと、
全2日間でWebアプリケーションを1から作ってみるイベントです。

13回目の開催となる今回はプログラミング初挑戦の方や、
デザイナーの方、会社のプロダクトがRailsを使用している人事の方など、
合計で25人のガールズが参加してくださいました。


Rails Girlsのスポンサーになりました&LTをしました!

delyでは運営しているレシピ動画サービス「クラシル」の
web・アプリでサーバーサイドとして
Ruby、そしてフレームワークとしてRailsを使用しています。

www.kurashiru.com

RailsはRubyのフレームワークであり、
Rubyの開発者のMatz(まつもとゆきひろ) さんいわく
enjoy programming がコンセプトになっているため
可読性が高く、わかりやすいです。

またRailsは DRYやCoCなどRails Wayや、豊富なgemなど
高速に開発が進めることができる、という特徴から
delyでもクラシルの開発にRailsを採択しています。


今回クラシルを運営しているdelyとしても
Ruby、Railsコミュニティに対してなにか貢献したいと思い、
GitHubさんやSansanさん、SmartHRさんなどとともに
今回スポンサーとして協賛させていただきました。



当日はスポンサー枠でサーバーサイドエンジニア 安尾による
会社紹介LTもさせていただき、
“delyの魅力を 「4P」でお伝えします!”というテーマのもと、
delyやクラシルについてご紹介しました。

企業の魅力因子とも言われる「4P」とは

・Philosophy(理念・目的)
・Profession(仕事・事業)
・People(人材・風土)
・Privilege(特権・待遇)

のこと。

delyがこれら4Pに対して、
どのような取り組みをしているかをお話させていただきました。


参加者のみなさんに、
「あなたにとって一番大切なPはなんですか?」という質問をして、
挙手をしてもらったところ、それぞれのPにわかれる結果に。

若干ではありましたが、Philosophy(理念・目的) が多かった印象を受けました。

f:id:mochizuki_pg:20200218114214j:plain f:id:mochizuki_pg:20200216135008j:plain

また、「クラシルを知っている方〜」という質問にも多くの手が挙がりました!🙌


LT後にもたくさんの方から、
「4Pって始めて知りました!」「LT良かったです!」と、
声をかけてもらって嬉しかったです!!

クラシルでは、アプリ、WebともにRailsで開発をおこなっています

(笑いもあり、みなさん積極的に参加してくれて感謝です🙂)

コーチとしても参加しました


今回はスポンサーとしての協賛だけでなく、
プログラミングに興味のある参加者の方のサポート役として
サーバーサイドエンジニアの安尾と、コーチもしてきました。

f:id:mochizuki_pg:20200127095909p:plain

プログラミング自体が初挑戦の方もいたので、
数時間で多くの知識を学ぶのは、なかなかハードではありましたが
皆さん真剣に取り組んでいました。

コーチとしてもいかにわかりやすく伝えられるかを考え
質問などにも丁寧に納得のいくまで回答するように心がけました。

当日の様子

ザッハトルテチーム


マカロンチーム

無事デプロイができました〜〜🎉🎉


参加者の方のなかにはこんな嬉しいツイートをしてくださっている方たちも。

今回参加して/コーチをしての感想


安尾:
私が担当させていただいたガールズさんで、
普段はQA担当としてエンジニアと一緒に業務をしている方からは
「普段エンジニアと話していて理解できなかったことが今日一日で色々と分かるようになって嬉しかった!」
というフィードバックを頂くことができました。
プログラミングを学びたいという気持ちに
少し応えられたかなととても嬉しかったです。
今後もまたコーチとして参加できればと思っています!

望月 :
マカロンチームのコーチをしましたが、
もう既にプログラミングを学習している方や
普段はデザイナーとしてフロントエンドのコーディングをしている方だったので、
皆さんの理解がはやく、特に問題が起きることもなくデプロイすることができました。
プログラミングは楽しいので、これからも続けていってくれたらなと思います。

最後に

今回私たち2人ともコーチとして初めての参加でしたが、
プログラミング初心者の方にどうしたらわかりやすく伝えることができるか、
どうやったらプログラミングを楽しんでもらえるかを
改めて考える良いきっかけになりました!


これからもdelyではRuby、Railsコミュニティへの貢献をしていき
Railsを活用してより良いプロダクトを世の中に届けていきたいと思います。

"80億人に1日3回の幸せを届ける" というミッションのもと
一緒に挑戦してくれるRailsエンジニアを募集しています!

www.wantedly.com