every Tech Blog

株式会社エブリーのTech Blogです。

ネットスーパーアプリ GraphQL から REST へ移行始めました

はじめに

こんにちは、retail HUBで Software Engineer をしているほんだです。
今回は私が現在着手している事業譲渡されたアプリを社内で持続的なプロダクト開発を行える状態にするリプレイスプロジェクトをどのように行っているか紹介しようと思います。
この記事ではリプレイスを行うにあたってどのようなことを課題に感じてその課題に対してどのような解決策をとったか主にサーバーの実装について説明しています。

ネットスーパーアプリとは

現在弊社ではネットスーパーアプリとして Web アプリとスマホアプリの二つのシステムを提供しています。
Web アプリは販促コンテンツの設定や売り上げの管理・集計を行うことが可能な管理システムと受け取り方法に応じた価格変更や送料変更にも対応し、消費者の柔軟な買い物を実現するお客様向けアプリを 17 の小売り様に、スマホアプリでは Web アプリのお客様向けアプリと同等の機能を Android と iOS のアプリとして株式会社リウボウストア様にリウボウネットスーパーとして提供しています。
こちらのサービスは以前株式会社ベクトルワン様が開発・運用していたものを事業譲渡されたものです。

リプレイス前の実装

リプレイス前の実装は上記図のようになっていました。
ネットスーパーアプリは GraphQL Mesh で作成された GraphQL Gateway Server を呼びその裏では AppSync と Lambda を用いて GraphQL が実装されていました。
GraphQL のリゾルバーに当たる Lambda は Python で書かれていました。

リプレイスの背景

課題点

既存の実装では下記のような問題があったため今回リプレイスを行うに至りました。

  • 社内に知見が少ないインフラ構成や、言語で実装されている。
  • Appsync, Lambda を用いた GraphQL の実装がチューニング不足もあるかもしれないが遅かった。
  • 重複する場合やコアとなるロジックを切り出すのに Lambda レイヤーにする必要があり管理が大変だった。

リプレイスを進めるにあたり満たしたいこと

リプレイスを進めるにあたり満たしたいこととしては下記のようなことを意識しています。

  1. このプロダクトは現状は1小売様向けとなっていますが今後小売りの拡大やバグを見つけたときに早期対応、機能の追加をできるような持続的なプロダクト開発をできるようにする。
  2. DB は既存の Web アプリのものを用いるため大量の table 、小売りごとに特定の table の有無があるものを適切扱えるようにする。
  3. サーバーの実装と同時並行でアプリの実装もすすめられるようにする。

リプレイス後の技術スタック

リプレイス後のインフラ構成は上記図のようになる予定です。
リプレイス前に用いていた GraphQL Gateway Server は GraphQL を REST に移行また今後は REST に統一していく点から導入している必要がなくなったため廃止しました。
満たしたいこと 1 にあげた持続的なプロダクト開発をできるようにすることを満たすためになるべく社内に知見があるものを選定するようにしました。
インフラに関しては社内の他のサービスでも使われていて知見が豊富な ECS を、開発に用いる言語に関しても Python から社内の知見が豊富な Go、framework は echo を採用しました。
満たしたいこと 2 DB を適切に扱えるようにすることを満たすために Go の ORM は sqlboiler を採用しました。具体的な理由については後述します。
満たしたいこと 3 サーバーとアプリの実装の最適化を満たすために OpenAPI を用いたスキーマ駆動開発を実践しています。OpenAPI を用いてエンドポイントの仕様を事前に決めておくことでサーバーとクライアントが並列に実装を行えるようにしています。
OpenAPI 定義書の作成には Stoplight Studio を用いています。
次にリプレイスにあたり特筆する点について説明していきます。

oapi-codegen

oapi-codegen は Stoplight Studio で作成した OpenAPI 定義書から Go のコードを作成するために用いています。
oapi-codegen を用いて Go のコードを作成することで Request Header の値や Query Parameter の validation を自分で実装する必要がなくなります。
また、ルーティングも任せることは可能ですがその場合全てのエンドポイントに middleware を反映することになり個別に設定することができなくなってしまうため今回は用いていません。
main 関数の実装は下記のようになります。

func main() {
    e := echo.New()

  wrapper := openapi.ServerInterfaceWrapper{
        Handler: handler.NewHandler(chainSchemaMap),
    }

  g := e.Group("")
    g.Use(echomiddleware.Recover())

  // 認可なしエンドポイント
  {
    g.GET("/policy", wrapper.GetPolicy)
  }

  // 要認可エンドポイント
  g.Use(middleware.Authorize())

  {
        g.GET("/items", wrapper.GetItems)
        g.GET("/items/:id", wrapper.GetItem)
    }

  e.Logger.Fatal(e.Start(":1323"))
}

sqlboiler

sqlboiler は toml ファイルを記述し実際に DB に接続することでその table 定義を元に Go の struct を生成することができ、既存の table の数だけ stuct として書き直す手間が省けます。
今回、小売ごとの DB の差分は特定の table の有無なため local 環境に全ての table を持つ DB を作成し、それを参照することで小売ごとのカスタマイズを含む全ての table の struct を作成できます。
Go のサーバーと DB の接続には一つのユーザーを用いているため、どの小売のアプリがどの DB にアクセスできるかは Go で map を定義することで対応しています。

Stoplight Studio

スキーマ駆動開発のための OpenAPI 定義書は Stoplight Studio を用いて記述しています。
Stoplight Studio は GUI 形式で OpenAPI 定義書を編集できるツールとなっています。
また、ツール内から Mock Server や実際のサーバーを叩くことができるので OpenAPI を書く用途だけでなく、どのようなリクエストでどのようなレスポンスが返ってくるかも確認することも容易となっています。

まとめ

まだリプレイス作業が始まったばかりでレイテンシーの改善などは具体的に測れていないの結果として捉えることは今後やっていく必要があるなと感じました。
リプレイスを行っていくにあたっても最終系から逆算し何が必要かということをまとめられていなかった点もあり適切な工数見積もりができなかったり後手になることもあったので今後はそういった点も意識していきたいです。
今回私自身実務の Python のコードに触れるのが初めてでそれを慣れ親しんだ Go に書き換えるという経験は言語の長所などを改めて捉え直す貴重な機会になったなと思います。

DELISH KITCHENのレシピのレコメンドにTwo-stage Recommender Systemsを導入するまでの道のり

こんにちは。 開発本部のデータ&AIチームでデータサイエンティストをしている古濵です。

引き続き、私がフルコミットしているDELISH KITCHENのレシピレコメンドについてまとめていきます。 前回の投稿の続きのような位置づけです。

私自身の苦悩も含めた思考過程と実際に取り組んだことについてまとめていきます。

背景

DELISH KITCHENではユーザの嗜好に寄り添ったアプリのパーソナライズに向けた開発をしています。 大きく3つの課題を解決するために、アプリのパーソナライズに注力しています。

  1. 受動的に提示するレシピのパーソナライズ不足

    サービスの成長に伴い、ユーザ数もレシピ数も増えているのに対して、アプリのロジック部分は更新されていない状態が続いています。 そのため、ユーザが好みのレシピの発見の機会を増やすために、レシピのレコメンドの開発を進めています。

  2. ロジックの癒着

    DELISH KITCHENでは、一部の機能がサーバー側の簡易な集計ロジックをもとに提供しているため、サーバー側の実装と密結合となっている部分があり、データ&AIチームが継続的にロジックの改善に集中できない状態です。 そのため、データ&AIチームがオーナーシップを持ってロジックを開発し、サーバーエンジニアがロジック改善に伴う修正を対応せずとも運用できる状態を目指しています。

  3. ML活用が部分的にしか行われていない

    ユーザの行動データやレシピの栄養素データなど多くのデータが利活用できる状態なのに対して、MLをプロダクトに活用する動きが部分的にしかできていません。 そのため、MLをプロダクトに活用する事例づくりや、ML基盤の構築が必要となっており、データ&AIチーム総手で取り組んでいます。

    直近のML事例は以下をご覧ください

レシピレコメンド開発の道のり

構成

対象面は、最近見たレシピからおすすめの枠です。

レシピレコメンドの全体構成は以下のとおりです。

Data Sourceとなるdelta lakeからデータを読み込み、ロジック用の集計をdatabricksのnotebookで実装します。 実装した結果をレコメンド結果としてdelta lakeに保存し、そのデータと同じフォーマットのデータを推論結果のデータストアとして、Delish ServerのRedis(ElastiCache for Redis)内にデプロイします。
このパイプラインをデイリーのバッチで実行し、推論結果をサーバー側で取得して、DELISH KITCHENアプリで表示できるようにしています。

最初にやったこと

ルールベースのベースライン

まず、ルールベースのベースライン作成をしました。具体的には、ルールベースロジックnotebook内で、ユーザごとに検索経由で視聴した動画の中で長く再生したレシピ順にレコメンドする集計をしました。
集計データをrule base resultとしてdelta lakeに保存します。 Redisへデプロイするためにフォーマット整形も別のnotebookで実装し、recommend resultとして保存します。

最近見たレシピからおすすめ、というタイトルともシナジーもあり、ユーザにとってもわかりやすいレコメンドになったと思います。

数年間更新されていなかった既存ロジックと、ルールベースのベースラインを比較するためにA/Bテストしました。
結果として大きく改善しましたが、以下の2点のことがわかりました。

  1. 既存ロジックとルールベースのベースラインでは、レコメンド対象のユーザ数が異なることが判明し、ルールベースのベースラインが改善したというよりも、レコメンドを展開しているユーザ規模を増やすことができたことが改善の大きな要因だということ

  2. レコメンド対象に含まれていないユーザは、アプリ上で最近見たレシピからおすすめ枠が表示されていないということ

上記のような手探りの状態から始まりました。 とはいえ、ルールベースのベースラインを作って検証し、新たな課題を得ることができたと思います。

ルールベースのベースラインの限界

ルールベースのベースラインを作成した時点で、ルールベースの限界も感じていました。理由は2つあります。

1つ目は、動画をあまり再生しないユーザも一定数いることが見えてきたからです。
レコメンドをする順序を再生秒数の多い順にしていましたが、動画をあまり再生しないユーザにとっては、嗜好に添わないレシピが上位に表示されることになります。
それは、DELISH KITCHENのユーザは、動画を見るためにアプリを使っているわけではなく、レシピを探すためにアプリを使っているからではないかと推測しています。 あくまで動画はレシピを選定するための手段にすぎず、目的はレシピを探すことであり、Youtubeなどの動画サービスとは異なる思考が必要だと感じました。
そのため、動画を再生せず、材料などが見れるレシピ詳細を見てからレシピを選定しているユーザもいると考えました。

2つ目は、実装コストに対して、大きな改善は望めないと思ったからです。
多くのルール作成したり、レコメンド順序を細かくチューニングするなどすれば、より良いレコメンドができるかもしれませんが、その実装をするコストに対して改善の幅は小さいだろうと感じていました。
実際に、ルールベースのベースラインにレシピ詳細の表示ログを追加してA/Bテストしたところ、大きな改善はしませんでした。

MLロジックに向けての情報収集

ルールベースに限界を感じていたため、MLロジックに向けての情報収集を始めました。

まずは、ブログ記事を読み漁り、引用されている論文など目を通しました。 MLの導入やレコメンドのアルゴリズムに関して多くの知識があったわけではなかったため、基礎や体系的に学べる書籍を購入して読み進めました。

また、Kaggleなどコンペティションで実施された解法なども参考になりました。 コンペティションの場合、コードが公開されているケースが多くあり、コードを読んで理解する助けになりました。

情報収集した気づきとして、書籍で紹介される協調フィルタリング(行列分解など)のような手法は、コンペティションの解法ではメインで使われていないということです。
あくまで主軸となっていたのは、Two-stage Recommender Systemsと呼ばれる、候補生成とリランキングの2つからなる手法でした。 候補生成の一つとして協調フィルタリングなどが使われているケースはたくさんありましたが、メインとして使われている解法は多くなかった印象でした。

Two-stage Recommender Systemsは、大規模なユーザ x アイテムの組み合わせを全て扱うのではなく、ユーザ一人当たりに対して候補を生成し、その候補を並び替え(リランキング)するという手法です。 手法の肝としては、情報検索における検索クエリの結果が候補であり、検索結果をどの順序で並び替えるかがリランキングに該当するのかなという所感を受けています。
正しくは確認できていないですが、Covington Paul, Adams Jay, and Sargin Emre. 2016. Deep neural networks for Youtube recommendations. In RecSys. 191–198.で提案された手法が有名であり、その後Two-stage Recommender Systemsという言葉が広まったかなと思います。

Two-stage Recommender Systemsを実装するために、まず、どのような候補を生成できるかのアイデアを一覧化しました。 候補の肝となるeventログをデータソースとして、SQLで集計可能な候補を中心に整理しています。 後述する候補生成モジュールでは、このアイデア一覧をもとに、候補を生成するためのクエリを一元管理しています。

候補生成のアイデアを整理する中で、ルールベースのベースラインである検索経由で視聴したレシピを候補の一つとして扱えるのではという考えが浮かびました。 Two-stage Recommender Systemsであれば、検索経由で視聴したレシピレシピ詳細を表示したレシピの2種類をそれぞれ候補として扱い、ルールベースにおける並び替えの限界を、MLロジックでリランキングすることでユーザの嗜好にあった順にレコメンドできると考えました。

Two-stage Recommender Systemsの実装

候補生成

まずは、候補生成をするパイプラインの作成から始めました。 全体構成としては、ルールベースロジックのnotebookが候補生成notebookに置き換わります。 候補生成notebookでは、複数の候補を一括で生成するために、候補生成モジュールを用いています。

候補生成モジュールを作成した経緯として、レコメンド開発をしていく上で、今後多くの候補を作るだろうと予測していたためです。
DELISH KITCHENで全てのレシピからレコメンドする場合、ユーザ一人当たり5万レシピ強になります。 候補生成は、このレシピの数を減らす役割がありますが、特定の候補からだけでレコメンドした場合、特定の人気レシピや上位のポジションに位置するレシピばかりがレコメンド対象になる可能性があります。
本ブログ執筆時点では、検索経由で視聴したレシピレシピ詳細を表示したレシピを候補にしていますが、さらに複数候補からの組み合わせでレコメンドしたいケースも出てくると考えました。

そこで、候補生成モジュールを作成し、候補生成に関する集計ロジックを一元管理することにしました。 使い回しやすい 2-stage recommender systemの デザインパターンを考えて実装した話 を参考に、Candidate、QueryGererator、Evaluatorのクラスを作成し、これを候補生成モジュールと呼称します。

Candidateに対して、それぞれQueryGereratorとEvaluatorが依存しています。 メインとなるCandidateは、以下のメソッドを持っています。

  • generate
    • QueryGereratorからクエリ(=query)をstringを受け取り、spark.sql(query)を実行
    • QueryGereratorでは、候補を生成するためのクエリと、クエリがアウトプットするスキーマを保持
    • クエリはspark.sql()で実行可能なクエリであり、スキーマはpyspark.sql.typesのStructTypeで定義
  • evaluate
    • Evaluatorクラスで定義された評価関数を使って、生成した候補とground truthを比較
    • 評価関数には、precision@k, recall@k, map@k等
  • validate
    • generateで生成したデータや、evaluateで評価するデータに対して、簡単なバリデーションを実施
    • バリデーションにはdataframeの空チェック、カラム数チェック、カラム名チェック、カラムの型チェック等

候補生成モジュールを用いて候補生成notebookを実行し、候補を生成します。 サンプルコードとしては、以下のとおりです。

from src.recommend_system.candidate_generation.candidate import Candidate

ground_truth_table = spark.sql(...)
# example)
# user_id, recipe_id
# aaaaaaa, 111111111
# ...

candidate = Candidate(
    delta_schemas=delta_schemas,
    user_col="user_id",
    item_col="recipe_id",
    candidate_col="candidate_recipes",
    ground_truth_col="recipe_ids"
)
candidate_names = candidate.catalog_schema.keys()
# example)
# candidate_names = ["検索経由の視聴", "レシピ詳細の到達"]

for candidate_name in candidate_names:
    # 候補生成
    candidate.generate(
        candidate_name,
        date
    )

    # 評価
    candidate.evaluate(
        candidate_name,
        ground_truth_table,
        eval_topk=[3, 8, 10],
        mlflow_eval_cache_name=candidate_name
    )

    # 保存
    results = candidate.generated_candidates[candidate_name]
    results.write \
        .format("delta") \
        .mode("overwrite") \
        .option("mergeSchema", "true") \
        .save(f'path/{candidate_name}')

candidate_namesに生成する候補名となるkeyが格納されます。 これは、QueryGeneratorクラスで定義した、候補を生成するためのクエリとクエリがアウトプットするスキーマをもとに、Candidateクラスのcatalog_schemaに格納されます。

QueryGeneratorクラスの、候補を生成するためのクエリとクエリがアウトプットするスキーマは、以下のような定義をしています。
新規で候補を追加したい場合、以下のような実装を追加するだけでOKです。

# わかりやすくするために一部日本語にしています

class QueryGenerator:

    def __init__(self, delta_schemas: DeltaSchema):
        self.catalog = {
            "検索経由の視聴": {
                "query": {
                    "func": self.fetch_検索経由の視聴,
                    "params": { "days": 30 }
                },
                "schema": StructType([
                    StructField("user_id", StringType()),
                    StructField("recipe_id", LongType()),
                    StructField("seconds", DoubleType()),
                ])
            },
            ...
        }
        ...

    # Candidateクラスでgenerateメソッドが呼ばれたときに、このメソッドが呼ばれる
    def get_query(self, candidate_name: str, date: str) -> str:
        ...
        return query

    def fetch_検索経由の視聴(self, from_date: str, to_date: str) -> str:
        return f"""
            SELECT
                user_id,
                recipe_id,
                sum(seconds) AS seconds
            FROM
                ...
            WHERE
                event_date BETWEEN '{from_date}' AND '{to_date}'
                AND ...
            GROUP BY
                1,
                2
        """

リランキング

生成した候補から得られたuser_id x recipe_idの組み合わせを用いて、リランキングをします。
リランキングでは、教師あり機械学習を用いてリランキングモデルを作成し、予測結果を降順でソートして上位k個をレコメンド対象とします。 今回は、LightGBMを使ってリランキングモデルを作成しました。

候補群の作成

まず、各候補をfull outer joinして、user_id x recipe_idの組み合わせとなる候補群を作成します。 今回の場合は、検索経由で視聴したレシピレシピ詳細を表示したレシピの2種類の候補になります。こうして作成された候補群がリランキングの対象となります。

特徴量の作成

次に、リランキングモデルの学習をするための特徴量を作成します。 特徴量は、ユーザの行動データやレシピの栄養素データなどを使って作成します。
行動データは動画の表示及び視聴やレシピ詳細の表示、最後のアクセスからの経過日数、アプリ内の様々なタップログを用いています。
栄養素データは、DELISH KITCHENのレシピのメタデータにあるカロリー、たんぱく質、脂質、糖質などの栄養素を使っています。 栄養素データはDELISH KITCHENのWebサイトで公開されており、ユーザがレシピを選定する上で重要な指標になっていると考えています。

目的変数の設定

次に、正解ラベルを用意します。 これを目的変数とします。 今回の学習では、候補生成時点よりも未来の時間軸にユーザが視聴したレシピを正解ラベルとします。 そのため、正解ラベルは視聴した=1、視聴していない=0を持ちます。
正解ラベルは、最近見たレシピからおすすめの枠で視聴されたレシピのみに限定せず、アプリ上の全ての枠で視聴された動画を対象としました。 その理由は以下の3つです。

  1. 最近見たレシピからおすすめの枠は、ユーザにレシピを再度見てもらうための枠だと位置づけており、ユーザが興味を持ちそうなレシピを広く反映させたいため
  2. 最近見たレシピからおすすめ枠の視聴レシピだけでは、すべての枠で視聴されたレシピの数に比べて、正解ラベルの数が少なくなるため
  3. 最近見たレシピからおすすめ枠経由のレシピだけを用いると、正解ラベルが既存ロジックのバイアスの影響を受けるため

学習

次に、候補群に対して、特徴量と正解ラベルをleft joinして学習データを作成します。 学習データをもとに、再視聴の有無を予測するための二値分類問題として、LightGBMで学習します。 正解ラベルは視聴していない=0の方が圧倒的に多いため、0となる方をダウンサンプリングしています。 学習後、mlflowを使ってモデルを保存します。

予測

次に、学習データと同じ特徴量を使って、最新の候補群に対して予測します。 予測結果を降順でソートし、上位k個がリランキングモデルにおけるレコメンド対象になります。

評価

最後に、同じ候補群を用いて、ルールベースのベースラインとリランキングモデルの性能を評価します。 ルールベースのベースラインは再生秒数で降順にソートし、上位k個がルールベースのベースラインにおけるレコメンド対象になります。
リランキングモデルとルールベースのベースラインの各評価指標もmlflowで記録し、モデルの性能を比較できるようにしています。

A/Bテスト

controlをルールベースのベースライン、testをTwo-stage Recommender Systemsによるレコメンド、としてA/Bテストしました。 A/Bテストの結果、ある指標において、リランキングモデルによるレコメンドがルールベースのベースラインよりも改善されたことがわかりました。

まとめ

本ブログでは、DELISH KITCHENのレシピのレコメンドにTwo-stage Recommender Systemsを導入するまでの道のりについてまとめてきました。

現時点では検証段階であり、あくまでTwo-stage Recommender Systemsの一通りの実装をしただけに過ぎません。 リランキングモデルの目的変数の設定は深く検討できておらず、特徴量も既存のものを使いまわしているため、モデルの性能は十分とは言えません。 バイアスの考慮なども含めるとチューニングして改善する余地は多くあります。

そんな未だ手探りの状態とも言えますが、ユーザの嗜好に寄り添ったアプリを目指した改善が少しずつできていると思います。
CTOの今井が過去のブログでも記載している「事業を推進する開発組織になる」を目指して、データ&AIチームとして、引き続きプロダクトに寄り添った開発を進めていきたいと考えています。
私個人としても、MLをプロダクトに導入するという非常に挑戦的な取り組みをできており、裁量を持って開発をできていることに成長を実感しています。

データ&AIチームでは一緒に働く仲間を募集しています! 動画メディアでAI/MLプロダクトの推進にご興味のある方はぜひ、以下のURLからご応募ください。

corp.every.tv

DevEnableグループを 新設しました!

はじめに

エブリーでCTOをしている今井です。先日の池のブログ でも少し触れておりますが、2月にDevEnableグループ を設立したので、その紹介と設立した背景ついてお話しできればと思います。

tech.every.tv

DevEnableグループとは

DevEnableグループはCTO室に属しているグループで、開発本部を横断し、組織の活性化・成長環境の提供・発信・広報の強化・採用など、さまざまな課題解決を推進するグループです。

DevEnableという名前は Developer Enablement から取られており、「社内外から憧れる開発組織へ」というのをミッションに、エンジニア自身やエンジニア組織がより活性化し、成果を出し続けられる人・組織にすることを目標としています。

Developer Enablementは各社定義もかなり幅があるように感じておりますが、自分は、エンジニア自身の成長はもちろん、組織とのコラボレーション、これから迎えるメンバーの採用やその方の早期活躍に向けたオンボーディング、また自社だけでないエンジニアコミュニティの活性化など、かなり広義にとらえております。

DevEnableグループでは音頭を取ったり、活動がやりやすい場の提供をすることで推進し、活動自体は開発本部に所属する全員で行なっていきたいと考えております。

なぜ作ったのか

自分がCTOになった時から口酸っぱく言ってきたのが、「事業を推進する開発組織になる」ということでした。それが浸透してきたのもあり、各メンバーが技術だけじゃなく事業を考え開発に向き合ってくれるようになった一方で、相対的に技術に関する取り組みが減り、振り返ると技術的な挑戦ができてないと感じることも多くなりました。

また採用観点でも、まだまだエブリーを知っていただけてないことが多かったり、エブリーは知っているが具体的に今何をしてる会社かわからないなどの声をいただくことも多く、課題を感じていました。

それらの課題に向き合うために生まれたのがDevEnableグループの前身となる、組織活性化委員会でした。

前身: 組織活性化委員会

上記の課題に対して、特にDeveloper Experienceに興味がる有志で結成された組織活性化委員会です。この委員会では、TechTalkや社内勉強会の開催、挑戦WEEEKの実施、アドベントカレンダーの開催など、組織の活性化に向けた様々な活動を行ってきました。詳しくはいくつかブログにもなっているので、ぜひ一読ください。

tech.every.tv

これらの活動を通じて、組織内のコミュニケーションが活性化し、開発者同士の繋がりが強くなるなどの成果が見られました。一方で、有志で集まった非公式な組織であるが故の活動のやりにくさがあったり、より広い課題に取り組みたい、また今後も継続的な取り組みが必要だと感じていたので、正式な組織とすることにしました。

足元の取り組み

具体的には大きく3つの軸で活動する予定です。

細かい内容はまだまだ詰めている途中なものもあり、追加や変更あるとは思いますが、 一部詳細な内容も含めてご紹介できればと思います。

1. 社内活性化

こちらは組織活性化委員会時代からの引き継いだものが主になります。

「挑戦WEEK」、「TechTalk」、「勉強会」などがあります。 それぞれ、ブログにもなっておりますので、こちらも合わせて読んでいただけると嬉しいです!

tech.every.tv

2. 外部発信・コミュニティ貢献

昨年度よりテックブログの執筆推進を進め、半年で50~60本ほどの記事を上げることができる体制になってきました。今年はそれに加えて、技術だけじゃなく人や取り組みにフォーカスした記事の執筆なども増やしていきたいと考えています。

また、今年から国内カンファレンスへの協賛も積極的に行なっていくことで、国内の技術コミュニティへの貢献もしていきたいと考えております。さっそく6月のGoConferrenceへの協賛が決まりました!(これに関しては後日またきちんとご報告できればと思います。) このほか、勉強会の開催など、技術系のコミュニティへ積極的に貢献していきたいと考えておりますので、何か弊社で貢献できそうなことがあれば、ぜひ気軽に連絡いただけると嬉しいです。

3. 採用およびオンボーディング

課題にも書きましたが、エンジニアは全職能において絶賛採用中ではあるものの、あまり認知されてないという課題があります。上記の発信に加えて、採用面でも発信を強化するとともに、より会社の魅力が伝わるような会社説明資料の刷新から採用プロセスの見直し、リファラル採用のサポートなども進めています。

また、入社後早期に活躍できる仕組み作りにも取り組みたいと考えており、まずは4月に入社する新卒向けのオンボーディングプログラムを作成しています。

最後に

私たち DevEnable グループは、まだ発足したばかりですが、今後も「社内外から憧れる開発組織へ」というミッションの実現に向けて、様々な施策に取り組んでいきます。 何度も言いますが、弊社は全方位で積極採用中です!

DevEnableグループをおもしろうそうと思った方や、そんなグループが活躍してる組織で働きたいと思った方はぜひお話しましょう! corp.every.tv

開発組織のナレッジ共有とコミュニケーションを促進する社内イベント「TechTalk」の紹介

はじめに

こんにちは。DELISH KITCHEN 開発部 SERS グループ兼、CTO 室 DevEnable グループ所属の池です。

SERS グループでは主に小売向けプロダクトの開発を行なっており、DevEnable グループでは社内開発組織活性化に向けた活動を行なっています。

今回は DevEnable グループの活動の一つである、”TechTalk” という社内技術共有会の取り組みにスポットを当てたイベントレポートをお届けします。

DevEnable グループとは

2024 年 2 月に DevEnable グループが新設されました。有志が組織活性化委員会として行なっていた活動を正式な組織活動としてより広く深く取り組むためのグループです。

私たち DevEnable グループのミッションは「社内外から憧れる開発組織へ」です。そのミッションの実現に向けて採用・発信・成長環境などの課題を改善するため、施策の検討から実施まで推進しています。

様々な施策を推進している中で私は現在主にオンボーディングプロセスの改善や TechTalk の運営などの施策推進を行っています。

TechTalk とは

TechTalk とはエブリーが月次で開催している社内技術共有の場です。

エブリーでは DELISH KITCHEN、トモニテ、TIMELINE と各事業部に分かれており、普段はそれぞれが別チームとして動いているため、チーム横断でのコミュニケーションが取りづらいという組織体系による課題があります。

チームを超えたナレッジ共有や情報共有ができずに、チームごとに同じような技術検証や課題に取り組んでしまうと、無駄な労力に繋がってしまいます。

活性化組織委員会のリーダーを担っていた國吉さんが書いた 挑戦 Week の記事 にも上記課題への言及があるのでご参照ください。

TechTalk はこの課題を解決するための取り組みの一つであり、以下の目的を持っています。

  • 組織横断したナレッジ共有
  • エンジニアの技術的知見の共有
  • 開発部全体でのエンジニアの交流

TechTalk 実施内容

TechTalk のアジェンダは次のとおりです。

  • 新しく入社されたメンバーの自己紹介
  • 開発部 ALL HANDS
  • ポストモーテム共有会
  • ライトニングトーク(LT)
  • 懇親会

オフライン参加者はフリースペースに集まり、オンライン参加者は Zoom で繋ぎます。

自己紹介

新たに加わったメンバーの自己紹介を行います。 開発部全員が集まる場で自己紹介することによって、組織へのスムーズなオンボーディングを促します。 このように開発部全員が集まる機会は少ないので、貴重な機会となっています。

開発部 ALL HANDS

ALL HANDS では、各グループの OKR やプロジェクト進捗、課題やトピックスについて共有します。

これにより、開発部の各部門全体の動向について把握することができます。

ALL HANDSの様子

ポストモーテム共有会

ポストモーテムとはインシデントについてまとめた文書のことをいいます。 エブリーではインシデントが発生した際には、関係者全員で振り返りを行い、ポストモーテムを作成する文化が根付いています。

同じインシデントを組織内で繰り返さないため、ポストモーテムの内容を共有するセクションを設けています。

LT

続いて LT セクションです。今回の発表は以下の 3 つでした。

  • Vue 3.4 アップデート:開発者が知っておくべきこと
  • push 通知について勉強しました
  • DAP の概要の理解を目指して

最新アップデート内容の共有から、担当を超えた技術領域について学んだ話や、DAP(Delish App Platform)という社内プラットフォームの技術共有など、多岐にわたるトピックが発表されました。

ここからはケータリングのピザを食べながらワイワイと LT 会を行います。

ケータリング

LT1

LT2

LT3

懇親会

LT の後は懇親会に移ります。フリースペースでケータリングを食べながらエンジニア同士が交流を深めます。 普段の業務では会話する機会の無い他部署の方と交流を深めることができます。

懇親会の様子

運営に携わった所感

私は 2024 年 2 月度の TechTalk から運営に携わりました。 運営側の視点に立ってイベントの意義を考えると、単にイベントを運営するということではなく、開発組織の文化形成や、エンジニアの成長を支える重要な役割を担っていると実感しました。 運営側から参加することで、適切な時間配分や時間管理方法はあるか、より質疑応答が活発になるためにはどうすれば良いか、など今までとは異なる視点でイベントのあるべき姿を考えるようになったと思います。 発表者がスムーズに発表できる環境を整えると同時に、参加者にとって意義のあるイベントになるように努めることが重要だと認識しました。 今後も、参加者の声を大事にして、さらに意義のあるイベントにしていけるよう改善を続けていければと考えています。

おわりに

エブリーでは、TechTalk をはじめとする多くの取り組みを通じて、技術者が互いに刺激を受け、成長を続ける環境を大切にしています。 今後も新たな発見と交流の場となることを期待し、TechTalk を開催していく予定です。

また、これからも DevEnable グループとして「社内外から憧れる開発組織」を目指し、働きやすい開発組織作りを追求していきます。 他のイベントを開催した際には同様にイベントレポートをお届けできればと思うのでどうぞご期待ください!

TypeScriptのコードをBranded Primitiveでもう1歩型安全へ

お久しぶりです,トモニテ開発部でSoftware Engineer(SE)をしている鈴木です.
私が普段実装しているトモニテ相談室のフロントエンドはTypeScriptを採用しているのですが,トモニテ相談室の実装中にTypeScriptでは検出することが出来ないミスをしてしまい,原因解明までに時間を要した経験があります.
この経験からTypeScriptを普段より少し型安全にする手法を学んだので,本記事で具体例を交えながら紹介させていただこうと思います.

はじめに

TypeScriptは型を区別するための方式として構造的型付けを採用しています.
したがって,type宣言子による宣言は単に構造に対してエイリアスを張っているに過ぎず,トランスパイラはエイリアスの参照先の構造のみを検査しています.
この自由度は名前的型付けとは対称的であり,TypeScriptがJavaScriptに対してシームレスに型システムを導入することが出来た要因の一つとなっています.
一方で,この自由度ゆえにエンジニアがミスをしてしまった場合にもトランスパイラが見逃してしまう可能性があります.
どのようなミスを見逃してしまうのかを早速皆さんに共有させていただきたいところですが,逸る気持ちを抑え,まずは構造的型付けと名前的型付けの特徴を簡単に整理します.

構造的型付けと名前的型付け

型システムが型を区別するための方式には構造的型付け(Strucural Typing)と名前的型付け(Nominal Typing)の2種類があります.
前者は型の区別の際に型の"構造"に着目し,後者は型の区別の際に型に与えられた"名前"に着目します(両者とも読んで字の如くですね).
したがって,以下のような型TUがあったとき,構造的型付けでは型TUは等しいと見なされ,名前的型付けでは型TUは異なると判定されます.

type T = number;
type U = number;

以下のようなオブジェクト型の場合も同様です.

type User = {
    id: number;
    name: string;
}
type Counselor = {
    id: number;
    name: string;
}

構造的型付けが原因で見逃してしまうミス

以下のような,ユーザーIDを渡すと該当するIDを持つユーザーを返すTypeScriptの関数を考えます.

function getUserById(id: User['id']): User {
    return {
        id: 1,
        name: "鈴木",
    };
}

以下のようにUser['id']型の値を渡した場合にはもちろん想定通りの挙動をします.

const userId: User['id'] = 1;
const ret = getUserById(userId)

ここで,getUserByIdに対してCounselor['id']型の値を渡すことを考えてみます.
引数idUser['id']型であることから,これ以外の型の値を渡した場合にはトランスパイラが検出し,エンジニアにメッセージを出力して欲しいものです.
しかし,期待に反してトランスパイラは以下のようにCounselor['id']型の値を渡した場合も何もメッセージを出力すること無く,問題なくトランスパイルを終えます.

const counselorId: Counselor['id'] = 1;
const ret = getUserById(counselorId)

これはTypeScriptが型を区別するための方式として構造的型付けを採用していることが原因です.
先述の通り,type宣言子はあくまで構造に対してエイリアスを張るだけであるため,User['id']Counselor['id']number型にエイリアスを張っているに過ぎず,トランスパイラは両者を区別しないのです!
これは良し悪しではなく,単に言語仕様なので仕方のない事なのですが,サービス上の各モデルが共通で持つidのようなプロパティは区別出来るとエンジニアのミスが減り,開発速度の向上に繋がります.
つまり,TypeScriptをもう一歩型安全に近づけるために,TypeScriptで名前的型付けを再現し,idのような共通プロパティを区別出来るようにしたいのです.
構造的に型を区別するTypeScriptにそのような方法はあるのでしょうか?

Branded Primitive

Branded Primitiveという手法を用いることでTypeScriptで名前的型付けを再現することが可能です!
この手法はTypeScriptのgithubのwikiやオライリー・ジャパンから出版されている「プログラミングTypeScript―スケールするJavaScriptアプリケーション開発」(Boris Cherny 著、今村 謙士 監訳、原 隆文 訳)で紹介されており,弊社社内でエンジニア同士のコミュニケーションの際に用いる場合はBrand化と称しています.
number型をBrand化する際には以下のようにします.

type T = number & { readonly brand: unique symbol };
type U = number & { readonly brand: unique symbol };

上記のように,型TUを区別したい場合,それぞれnumber型と互いにプロパティを区別できるオブジェクト型の交差型を定義するのがBrand化です(※1).
このようにするとオブジェクト型の部分が異なることから構造も異なり,TUは互いに異なる型になります.
この時点で名前的型付けを再現出来ているのですが,更にnumber型とオブジェクト型の交差型はnumber型のサブタイプであるため,number型が持つtoStringなどのようなメソッドにも問題なくアクセス出来るのもBrand化のメリットの一つになります.
なお,型TまたはUを持つ値を生成する際には型アサーションが必要となります(※2).
上述のUser型やCounselor型をBrand化すると以下のようになります.

type User = {
    id: number & { readonly brand: unique symbol };
    name: string;
}
type Counselor = {
    id: number & { readonly brand: unique symbol };
    name: string;
}

このようにidの定義にBrand化を適用することにより,無事User['id']型とCounselor['id']型を区別できるようになりました!

Brand化を適用したnumber型の区別

※1
オブジェクト型の部分は互いに区別出来ればどのような形状になっていても構いません.
okunokentaroさんのZennの記事を学習の際に大いに活用させていただいたのですが,その記事で紹介されているジェネリクスを参考に以下のようなジェネリクスを定義するとBrand化の手間が少なくなるかと思います.
ただし,型パラメータTに同じリテラル型を渡してしまうと構造が一致し区別がつかなくなることには注意が必要です.

type BrandedNumber<T extends string> = number & { brand: T };

type User = {
    id: BrandedNumber<'User'>;
    name: string;
}
type Counselor = {
    id: BrandedNumber<'Counselor'>;
    name: string;
}

※2
各所で型アサーションをするのは手間やミスに繋がってしまうため,以下のような生成関数を定義すると良いです.

function UserId(id: number): User['id'] {
    return id as User['id']
}
const userId = UserId(1)

まとめ

本記事では私の実体験を元にTypeScriptをもう一歩型安全にする手法を紹介させていただきました.
TypeScriptは型を区別するための方式に構造的型付けを採用しており,この方式が持つ自由度ゆえに本来意図していない型を利用してしまった場合にもトランスパイラが検出出来ない可能性があります.
Branded Primitiveという手法がTypeScript公式wikiに掲載されており,この手法を区別したい型に対して適用することによって上述のようなミスをトランスパイラが検出出来るようになり,エンジニアのミスを仕組みで解決出来るようになります.
この記事が私と同じようなミスをしてしまった経験のある開発者の方々のお役に立てたら大変嬉しいです.
ここまでお読みいただきありがとうございました!