TECH PLAY

タイミー

タイミー の技術ブログ

264

こちらは Timee Advent Calendar 2023 の13日目の記事です。 タイミーでバックエンドエンジニアをしている @Juju_62q です。 記事内でワーカーさんや事業者さんに関して敬称を省略させていただきます。 タイミーは雇用者である事業者に求人を投稿してもらい、労働者であるワーカーが求人を選ぶという形でマッチングを実現しています。ワーカーが求人を選ぶためにはなんらかの形でワーカーが自分にあった求人を見つけられる必要があります。検索はワーカーが求人を見つけるために最もよく使われる経路です。今回はそんな検索機能において今後の開発をスムーズにするための リファクタリング を実施した話を紹介します。 背景 リファクタリングの切り口 実施したリファクタリング before after 結果と所感 終わりに 背景 タイミーの検索は以下の機能です。 ワーカーの状況や希望に合わせて求人を表示しています。求人はサーバーサイドで取得、絞り込みを行っています。この絞り込みはFilterというクラスが一手に担っていました。Filterクラスでは具体的に以下の処理を行なっています。 該当日の全ての求人を取得する ワーカーや事業者の設定した絞り込み条件やタイミーのルールに合わせて絞り込みを行う 前述した通り検索はマッチングが最も生まれている 動線 です。そのために機能追加の圧力も非常に大きく、さまざまな機能が追加されていきました。結果としてFilterクラスは見通しが悪くなっていきました。最大では16の絞り込み処理が1つのクラスで行われていました。 また、このFilterクラスですが利用箇所が検索だけであればいいものの、タイミーのルールに合わせた絞り込みを行なっていたために検索以外にもお気に入り求人やお仕事リク エス トなどさまざまな機能に利用されています。 ただでさえ問題を起こした時に大きくなってしまう検索機能です。コードの見通しが悪く、影響範囲も不透明となると変更するのもどんどん億劫になってきます。大切な機能であるからこそチームとして改修をした方が今後も含めて開発が早くなるだろうと考えて リファクタリング に踏み切りました。 リファクタリング の切り口 見通しが悪いのは絞り込み処理を行なっているFilterクラスなので、Filterクラスに絞って リファクタリング を検討します。Filterクラスがどんな処理を担っているのかをチーム内で話しました。 取得部分を一旦置いておくと、Filterクラスの絞り込みには大きく以下の2種類があるとわかりました。 ワーカーが設定した検索条件による絞り込み(10処理) 良いマッチングを生むためにタイミーが実施している可視性の制御(7処理) また、Filterクラスを使っている箇所を眺めてみても検索では1, 2の両方を利用しているものの、それ以外の箇所では2の「良いマッチングを生むためにタイミーが実施している可視性の制御」しか利用していませんでした。 これを踏まえて3つの要素を満たすような リファクタリング をすることにしました。 「ワーカーが設定した検索条件による絞り込み」と「良いマッチングを生むためにタイミーが実施している可視性の制御」の2つに処理が分かれること 今後の開発で処理の要素を既存処理に影響を与えずに3つ以上にできること 利用するクラスが適用するロジックを選択できること なぜこれらの要素を選択したかを解説します。まず ”「ワーカーが設定した検索条件による絞り込み」と「良いマッチングを生むためにタイミーが実施している可視性の制御」の2つに処理が分かれること”です。まず、ロジックを精査する前にFilterクラスの見通しが悪いということは単一責任の原則に反していると考えていました。実際に話し合ったところ2つの責務が見えてきたので責務が単一になるように分割することを考えます。 次にその他2つに関してはオープンクローズドの原則を考えたものです。タイミーのフィルタリングロジックはまだまだ発展途上で、今後どんどん拡張される可能性があります。その時に簡単に処理を追加・削除できる状況を作ることを考えると重要であると考えました。 実施した リファクタリング 結論から行くと、以下のようなbefore-afterとなっています。 Offeringは求人を表現しているモデルで、Userはワーカーです。 before クラス図 Filterクラスの利用例 offerings = :: Offering :: Filter .new(current_user, search_params) .result after クラス図 Filterクラスの利用例 offerings = :: Offering :: Filter .new( conditions : [ :: Offering :: Filter :: SearchCondition .new(search_params), :: Offering :: Filter :: Viewable .new(current_user) ]) .result よくみるとわかるのですが、ストラテジパターンを用いて リファクタリング をすることとなりました。SearchConditionクラスが「ワーカーが設定した検索条件による絞り込み」を担い、Viewableクラスが「良いマッチングを生むためにタイミーが実施している可視性の制御」を担っています。 この変更によって元々考えていた要素は以下のように達成されました。 「ワーカーが設定した検索条件による絞り込み」と「良いマッチングを生むためにタイミーが実施している可視性の制御」の2つに処理が分かれること → SearchConditionとViewableの2つに分かれた 今後の開発で処理の要素を既存処理に影響を与えずに3つ以上にできること → Conditionのインターフェースを持ったクラスは追加可能、Conditionを追加しても他のクラスに影響はない 利用するクラスが適用するロジックを選択できること → 引数でConditionを選択することで制御が可能 クラスや省略したコードだけ見るとかなり複雑に見えますが、まとまりごとで制御できるので全体としてはすっきりしたと思います。 結果と所感 今回の リファクタリング によって検索にかなり変更を加えやすくなりました。実際に リファクタリング 後に検索を3度ほど触っていますがかなり機能の追加はしやすくなりました。早くロジックが大きくして、いろんなConditionが見つかると良いなと思っています。 ここまでの話で特に話していませんでしたが、この リファクタリング は スクラム 開発の中でプロダクトゴールを最速で達成する一環として実施しています。品質が開発速度に好影響を与えた例としてとても感慨深かったことを覚えています。 ところで、 Ruby でストラテジパターンを実現する時って皆さんどうしていますか?今回は以下のような抽象クラスのようなものを作成しました。ただ、今回の用途で継承を利用するのはToo muchであると思います。何かいい方法があれば教えていただけると嬉しいです。 # frozen_string_literal: true class Offering class Filter class Condition # 一定の検索条件で絞り込むために利用される。 # Offering::Filterクラスで利用するinterfaceとなる。 # # @param [Offering::ActiveRecord_Relation] offerings # @return [Offering::ActiveRecord_Relation] def apply (offerings) raise NotImplementedError , " You must implement #{ self .class } # #{ __method__ }" end end end end 終わりに 今回はタイミーの検索を司るFilterクラスを リファクタリング した話を紹介しました。重要な機能には仕様がどんどん追加されます。開発がしにくいと感じた際には責務や基本的な原則に立ち返って リファクタリング してみるのもよいかなと思います。今後もユーザーへの価値提供をしながら技術品質をどんどん高めていきたいと思います。
アバター
この記事は Timee Advent Calendar 2023 シリーズ 3 の12日目の記事です。 qiita.com はじめに DREグループでデータエンジニアをやっている 西山 です。 今回は、データ転送まわりの運用自動化について書きます。 タイミーのアプリログが分析できる状態になるまでのリードタイムが長く、効果検証や意思決定に遅れが出ていた問題に対して、dbtに関連する運用を自動化することで改善しました。 タイミーでのアプリログの転送について タイミーではS3に貯まったサーバーログを定期的にデータ基盤(GCPのBigQuery)へ転送しており、ログがLake層へ追加されるとdbt(data build tool)によって型変換等の加工処理がなされてStaging層へと転送されます。 アプリの機能追加によって新しいログテーブルが追加された場合、dbtの処理追加が必要になるため、GitHub Actionsで定期的にLake層とStaging層でログテーブルの差異をチェックし、Lake層に新しく追加されたテーブルがあれば処理追加を促す通知をslackへ送信していました。 通知を確認したら、以下の対応を行います。 新しく追加されたテーブルのスキーマを確認 dbtでmodelを作成 他のログテーブルのmodelをコピー カラム名やキャスト処理を修正 {{ config( alias={{ テーブル名を書く }} ) }} {% set column_lists = [ {{ カラム名を書く }}] %} {{ production_logs_template({{ テーブル名を書く }}, column_lists) }} , result_record AS ( SELECT {{ カラム名を書く }}, dre_transfer_at, ROW_NUMBER() OVER (PARTITION BY {{ カラム名を書く }}) AS row_number FROM records ), stg_record AS ( SELECT {{ 各カラムの変換処理を書く }}, FROM result_record WHERE row_number = 1 ) SELECT *, {{ set_record_exported_at(column_lists) }} AS dre__record_exported_at, FROM stg_record チーム内レビュー dbtのジョブを実行してStging層にもテーブルを追加 基本的には通知を確認次第、上記の対応を行う方針でしたが、他に優先度の高い障害対応や他チームからの依頼があると後回しになってしまうことも多く、1週間以内にStaging層へ新規ログテーブルを転送する取り決めとなっていました。 しかし、これだとログが分析できるようになるまでのリードタイムが長く、プロダクト開発チームの効果検証や意思決定に遅れが出てしまいます。 そこで、今回はこのリードタイムの短縮を目指すことにします。 実装案 アプリのログに関しては、dbtで実装する加工処理の内容がほぼ決まりきっているため、modelの追加が自動化できそうです。 (例) 末尾にidが付くカラムはINT型へキャスト 末尾にatが付くカラムはJSTのDATETIME型へキャスト これまで新しいログテーブルを検知していたGitHub Actionsのツール内で、dbtのmodel追加のPRを生成することにします。 実装してみた 大まかにやったこととしては以下の通りです。 GitHub Apps によるAPIリクエストの準備 pythonで書かれている監視ツールの修正 dbtのmodel(sqlファイル)とtest(ymlファイル)の生成処理追加 GitHubへのリクエスト処理追加 slackへの通知内容修正 GitHub AppsによるAPIリクエストの準備 今回はログテーブルの監視ツールとdbtプロジェクトが格納されているリポジトリが異なったため、GitHub Appsを作成してAPI操作ができるようにします。 以下の手順に沿ってAppを作成 https://docs.github.com/ja/apps/creating-github-apps/registering-a-github-app/registering-a-github-app Appの秘密鍵を取得 https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps 認証に必要な INSTALLATION_ID を生成 https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation#generating-an-installation-access-token 3.で生成した INSTALLATION_ID を使ってinstallation access tokenを発行 今回は監視ツールに以下のクラスを追加しました。 import json import os from datetime import datetime, timedelta import jwt import requests class GitHubAppTokenManager : def __init__ (self) -> None : self.GITHUB_APP_ID = os.environ.get( "APP_ID" ) self.GITHUB_APP_PRIVATE_KEY = os.environ.get( "APP_PRIVATE_KEY" ) self.GITHUB_APP_INSTALLATION_ID = os.environ.get( "APP_INSTALLATION_ID" ) def _generate_jwt (self) -> str : PEM = ( self.GITHUB_APP_PRIVATE_KEY.replace( " \\ n" , " \n " ) if self.GITHUB_APP_PRIVATE_KEY is not None else None ) utcnow = datetime.utcnow() alg = "RS256" payload = { "typ" : "JWT" , "alg" : alg, "iat" : utcnow, "exp" : utcnow + timedelta(seconds= 60 ), "iss" : self.GITHUB_APP_ID, } return jwt.encode(payload, PEM, algorithm=alg) def _generate_jwt_headers (self) -> dict : jwt_token = self._generate_jwt() return { "Authorization" : f "Bearer {jwt_token}" , "Accept" : "application/vnd.github.machine-man-preview+json" , } def _fetch_access_token (self) -> str : url = f "https://api.github.com/app/installations/{self.GITHUB_APP_INSTALLATION_ID}/access_tokens" response = requests.post(url, headers=self._generate_jwt_headers()) response.raise_for_status() return json.loads(response.text).get( "token" ) def generate_token_header (self) -> dict : token = self._fetch_access_token() return { "Authorization" : f "token {token}" , "Accept" : "application/vnd.github.inertia-preview+json" , } ここまでできればAPIのリクエスト準備完了です。 dbtのmodelとtestの生成処理追加 GitHub APIをリクエストする前にコミット対象となるファイル生成の処理を追加します。 まずはmodelのsqlファイルとtestのymlファイルのテンプレートを用意します。 ↓modelのテンプレートです。 dbtで使っているJinjaに反応して意図したところで値が置き換わらなくなるので、一部エスケープしています。 やっぱり多少見づらくなるので他に良い方法はないだろうか・・・ {{ ' {{ ' }} config( alias= ' {{ table_name }} ' , ) {{ ' }} ' }} {{ ' {% ' }} set column_lists = {{ column_list }} {{ ' %} ' }} {{ ' {{ ' }} production_logs_template( ' {{ table_name }} ' , column_lists) {{ ' }} ' }} , result_record AS ( SELECT {{ select_list }}, dre_transfer_at, ROW_NUMBER() OVER (PARTITION BY {{ partition_column_list }}) AS row_number FROM records ), stg_record AS ( SELECT {{ jst_converted_select_list }}, dre_transfer_at, {{ ' {{ ' }} jst_now() {{ ' }} ' }} AS dre_delivered_at, FROM result_record WHERE row_number = 1 ) SELECT *, {{ ' {{ ' }} set_record_exported_at(column_lists) {{ ' }} ' }} AS dre__record_exported_at, FROM stg_record ↓testのテンプレートです。ログの一意性を担保するテストを追加します。 version : 2 models : - name : TABLE_NAME tests : - dbt_utils.unique_combination_of_columns : combination_of_columns : COLUMN_LISTS 次に以下のクラスを追加します。 BQから追加対象テーブルのカラム名を取得して generate_dbt_file_info 関数に渡すことで、先ほど作成したテンプレートを元にファイルが生成されます。 import json import os import re import tempfile from typing import Any, Dict, List, Tuple from jinja2 import Environment, FileSystemLoader from ruamel.yaml import YAML class DBTModelFileGenerator : def _convert_column_with_at (self, column_name: str ) -> str : return f "TIMESTAMP({column_name})" def _convert_column_with_id (self, column_name: str ) -> str : return f "CAST({column_name} AS INT)" def _convert_utc_to_jst (self, column_name: str ) -> str : return f "{{{{ timestamp_utc2datetime_jst('{column_name}') }}}}" def _generate_select_list (self, column_list: List[ str ]) -> str : converted_columns = [] for col in column_list: if col.endswith( "_at" ): converted_columns.append( f "{self._convert_column_with_at(col)} as {col}" ) elif re.search( r"(_id$|^id$)" , col): converted_columns.append( f "{self._convert_column_with_id(col)} as {col}" ) else : converted_columns.append(col) return ", \n " .join(converted_columns) def _generate_jst_converted_select_list (self, column_list: List[ str ]) -> str : converted_list = [] for col in column_list: if col.endswith( "_at" ): converted_list.append(f "{self._convert_utc_to_jst(col)} as {col}" ) else : converted_list.append(col) return ", \n " .join(converted_list) def _generate_staging_sql (self, table_name: str , column_list: List[ str ]) -> str : select_list = self._generate_select_list(column_list) partition_column_list = ", " .join(column_list) jst_converted_select_list = self._generate_jst_converted_select_list( column_list ) # テンプレートのロード file_loader = FileSystemLoader( "テンプレートファイルのパス" ) env = Environment(loader=file_loader) template = env.get_template( "template_stg_production_logs.sql" ) # テンプレートのレンダリング sql = template.render( table_name=table_name, column_list=column_list, select_list=select_list, partition_column_list=partition_column_list, jst_converted_select_list=jst_converted_select_list, ) # tempdirにファイルを保存 output_path = os.path.join( tempfile.gettempdir(), f "{table_name}_stg_production_logs.sql" ) with open (output_path, "w" ) as tmp: tmp.write(sql) return output_path def _generate_staging_test (self, table_name: str , column_list: List[ str ]) -> str : yaml = YAML() yaml.indent(sequence= 4 , offset= 2 ) with open ( "テンプレートファイルのパス" , "r" , ) as template_file: template_content = yaml.load(template_file) template_content[ "models" ][ 0 ][ "name" ] = f "{table_name}_stg_production_logs" test_definition = template_content[ "models" ][ 0 ][ "tests" ][ 0 ] test_definition[ "dbt_utils.unique_combination_of_columns" ][ "combination_of_columns" ] = column_list output_path = os.path.join( tempfile.gettempdir(), f "{table_name}_stg_production_logs.yml" ) with open (output_path, "w" ) as tmp: yaml.dump(template_content, tmp) return output_path def generate_dbt_file_info ( self, new_table_and_column_names: List[Dict[ str , Any]] ) -> List[Tuple[ str , str , str ]]: files = [] for record in new_table_and_column_names: table_name = record[ "table_name" ] record_dict = json.loads(record[ "record" ]) column_list = list (record_dict.keys()) sql_file_path = self._generate_staging_sql(table_name, column_list) test_file_path = self._generate_staging_test(table_name, column_list) files.append( ( f "追加先のdbt modelのパス/{table_name}_stg_production_logs.sql" , sql_file_path, f "Add staging SQL for {table_name}" , ) ) files.append( ( f "追加先のdbt testのパス/{table_name}_stg_production_logs.yml" , test_file_path, f "Add staging test for {table_name}" , ) ) return files GitHubへのリクエスト処理追加 以下のクラスを追加し、 create_pr 関数に前段で生成したファイルの情報を渡すことでPRが生成されます。 認証にはAPIのリクエスト準備で発行したinstallation access tokenを使います。 import base64 import json import re from typing import Any, Dict, List, Tuple import jwt import requests from deleted_logs.adapter.output.github_app_token_manager import GitHubAppTokenManager class GitHubPRCreator : def __init__ (self, session_start_time: str ) -> None : self.OWNER = "XXX" self.REPO = "XXX" self.BASE_BRANCH_NAME = "main" numeric_only_session_start_time = re.sub( r"\D" , "" , session_start_time) self.NEW_BRANCH_NAME = f "feature/add_new_table_of_production_logs_to_staging_{numeric_only_session_start_time}" self.github_app_token_manager = GitHubAppTokenManager() def _is_branch_present (self) -> bool : headers = self.github_app_token_manager.generate_token_header() branch_url = f "https://api.github.com/repos/{self.OWNER}/{self.REPO}/git/ref/heads/{self.NEW_BRANCH_NAME}" response = requests.get(branch_url, headers=headers) if response.status_code == 200 : return True elif response.status_code == 404 : return False else : response.raise_for_status() return False def _create_branch (self) -> None : if self._is_branch_present(): return headers = self.github_app_token_manager.generate_token_header() # ベースブランチのSHAを取得 base_ref_url = f "https://api.github.com/repos/{self.OWNER}/{self.REPO}/git/ref/heads/{self.BASE_BRANCH_NAME}" response = requests.get(base_ref_url, headers=headers) response.raise_for_status() sha = response.json()[ "object" ][ "sha" ] # ブランチ作成 branch_ref_url = ( f "https://api.github.com/repos/{self.OWNER}/{self.REPO}/git/refs" ) data = { "ref" : f "refs/heads/{self.NEW_BRANCH_NAME}" , "sha" : sha} response = requests.post(branch_ref_url, headers=headers, json=data) response.raise_for_status() def _upload_file_to_repository ( self, git_file_path: str , local_file_path: str , message: str ) -> None : headers = self.github_app_token_manager.generate_token_header() with open (local_file_path, "r" ) as file : content = file .read() encoded_content = base64.b64encode(content.encode()).decode() url = f "https://api.github.com/repos/{self.OWNER}/{self.REPO}/contents/{git_file_path}" # 同様のファイルが既に存在するか確認 response = requests.get(url, headers=headers) sha = None if response.status_code == 200 : sha = response.json()[ "sha" ] data = { "message" : message, "content" : encoded_content, "branch" : self.NEW_BRANCH_NAME, } if sha: data[ "sha" ] = sha response = requests.put(url, headers=headers, json=data) response.raise_for_status() def _push_files (self, files: List[Tuple[ str , str , str ]]) -> None : self._create_branch() for git_file_path, local_file_path, message in files: self._upload_file_to_repository(git_file_path, local_file_path, message) def _generate_pr_title (self, table_names: List[ str ]) -> str : return f "production_logs新規テーブル({','.join(table_names)})追加" def _generate_pr_body (self, table_and_column_names: List[Dict[ str , Any]]) -> str : header = "このPRはdeleted_logsによって自動生成されたものです。 \n\n 以下のテーブルのステージング処理を追加しています: \n " lines = [] for record in table_and_column_names: table_name = record[ "table_name" ] record_dict = json.loads(record[ "record" ]) columns = ", " .join(record_dict.keys()) lines.append(f "**{table_name}** \n Columns: {columns} \n " ) return header + " \n " .join(lines) def create_pr ( self, table_names: List[ str ], column_names: List[Dict[ str , Any]], files: List[Tuple[ str , str , str ]], ) -> str : self._push_files(files) headers = self.github_app_token_manager.generate_token_header() url = f "https://api.github.com/repos/{self.OWNER}/{self.REPO}/pulls" title = self._generate_pr_title(table_names) body = self._generate_pr_body(column_names) data = { "title" : title, "body" : body, "head" : self.NEW_BRANCH_NAME, "base" : self.BASE_BRANCH_NAME, } response = requests.post(url, headers=headers, json=data) response.raise_for_status() return response.json()[ "html_url" ] こんな感じのPRが生成されました。 ※テストで作成したものなのでクローズしてます。 slackへの通知内容の修正 前段で生成したPRのURLを通知メッセージに追加します。 ↓テストで飛ばした通知はこんな感じです。 ここで通知されたPRのレビューとマージを行うことで、Staging層のdbt modelが作られるようになりました。 まとめ 今回dbtのmodel生成を自動化したことで、2つの効果が得られました。 1つ目は、トイルの削減です。 これまで以下のフローで対応していましたが、 既存のdbt modelをコピペしてPR作成 → レビュー依頼 → マージ 自動化したことで、 PRを確認してマージ だけででよくなりました。 元々大した工数はかかっていなかったものの、繰り返し発生する価値のない定型作業を減らすことができました。 そして2つ目は、Staging層へ新規ログテーブルが反映されるまでのラグ短縮です。 自動化したことで、他の対応でひっ迫している際に後回しにされることがなくなり、 これまではログ出力し始めてから分析可能になるまで最長7日かかっていたところ、導入後は大体1日以内で分析可能な状態になりました。 個人的には、タイミーにジョインするまでDevOps的なことがやれていなかったこともあり、運用は地味でつまらないイメージがあったのですが(すみません)、こうやって改善がまわせると運用も面白いなと最近思うようになりました。 We’re Hiring DREグループではまだまだやっていきたいことがたくさんあるのですが、まだまだ手が足りておらず、ともに働くメンバーを募集しています!! product-recruit.timee.co.jp
アバター
この記事は "Timee Advent Calendar 2023" の11日目の記事です。 qiita.com こんにちは、タイミーのデータ統括部データサイエンス(以下DS)グループ所属の菊地です。 今回は、定例会議の議事録をNotion DBで構造化して、いい感じにした話を紹介したいと思います! 前提 タイミーでは社内ドキュメントツールとしてNotionを採用しており、私が担当しているプロジェクトで週1回開催される定例では、議事録をNotion DBとして管理しています。当初は以下のような定例議事録用テンプレートを作成して運用していました。 定例の内容としては、プロジェクト進行上同期的に議論すべき アジェンダ を定例出席者が持ち寄って議論し、決定事項とToDoを記載していくような内容となっています。 旧定例議事録テンプレート 上記の定例議事録で感じていた課題 上記の定例議事録用テンプレートから定例の議事録を作成して運用していた際に、以下のような課題を感じていました。 「 アジェンダ 」パート アジェンダ が多いと一覧性が悪く、優先度の高い アジェンダ を先に話すなどの判断がしづらい アジェンダ に関する議論が複数回にわたる際に、議論内容が複数ページに渡り、情報が追いにくい アジェンダ が多く、その回の定例で話せず持ち越しになった場合に、次回の議事録に転記する必要がある 「決定事項」パート 何の アジェンダ について、どんな意思決定が行われたのかが追いにくい ある決定事項がどこに書かれていたかを調べたい際に、決定事項が書かれた会の議事録のページを探し出す必要がある 「ToDo」パート 前回の定例で決まったToDoを確認するために、前回の議事録を参照or次回の議事録に転記する必要がある 進行状況が分からない 期限が不明瞭 やったこと 上記で感じていた課題を解消するために、下記のように「構成要素」と「構成要素間の関係」をNotion DBで整理(構造化)し、合わせてNotion DBテンプレートページの作成を行いました。 「 アジェンダ 」パート アジェンダ を管理するNotion DB(Agendas)を作成し、議事録ページからはLinkded viewで参照する 「決定事項」パート 決定事項を管理するNotion DB(Decisions)を作成し、議事録ページからはLinkded viewで参照する Agendas DBとrelationsを持たせる。 「ToDo」パート プロジェクトのタスク管理で使用しているNotion DB(ここでは「Tasks」とします)をそのまま流用し、議事録ページからはLinkded viewで参照する 議事録から参照する際は、定例で作成されたToDoタスクだと判別できるようにtagをつけてフィルターを設定して絞り込む Agendas DBとリレーションを持たせる Notion DBのリレーション Notion DBのリレーションは以下のような構成になっています。 Meetings: 議事録を管理するNotion DBで、Agendas、Decisions、TasksをLinked Viewで参照する Agendas: 会議の アジェンダ を管理するNotion DB Decisions: 会議の決定事項を管理するNotion DB Tasks: プロジェクトのタスクを管理するNotion DB erDiagram Meetings Agendas ||--o{ Tasks: "1つのAgendaは0以上のTaskを持つ" Agendas ||--o{ Decisions: "1つのAgendaは0以上のDecisionsを持つ" Notion DBテンプレートページ 定例議事録と、定例議事録内の各 アジェンダ のNotion DBテンプレートページを以下のように作成しました。 「Meetings」DBの定例議事録テンプレート 「ToDo」パートの「Tasks」は、「ステータス」が「完了」以外を表示するようにフィルタを設定 「 アジェンダ 」パートの「Agendas」は、「解決済み」が「チェックなし」のみを表示するようにフィルタを設定 「決定事項」パートの「Decisions」は、決定事項が定例会議実施日のみに作成されるという想定の元、「作成日時」のフィルタ条件として、定例実施日に設定する運用にする 新定例議事録テンプレート 「Agendas」DBの アジェンダ テンプレート 「ToDo」パートの「Tasks」は、フィルタ条件「Agendas」をテンプレートページに設定しておくことで、テンプレートから作成されたページが自動で設定されるように 「決定事項」パートの「Decisions」は、フィルタ条件「Agendas」をテンプレートページに設定しておくことで、テンプレートから作成されたページが自動で設定されるように アジェンダ テンプレート 結果 定例議事録ページで感じていた課題は、議事録の「構成要素」と「構成要素間の関係」をNotion DBで整理(構造化)することで、以下のように解消することができました。 「 アジェンダ 」パート アジェンダ の一覧性が悪く、優先度の高い アジェンダ を先に話すなどの判断がしづらい → アジェンダ がDBのページとして表現されることで、一覧として表示することができ、一覧性が向上 → DBは一覧の並び替えができるので、優先度の高い アジェンダ を上に持ってくることが可能になった アジェンダ に関する議論が複数回にわたる際に、議論内容が複数ページに渡り、情報が追いにくい → アジェンダ に関する議論は、 アジェンダ ページに集約されるので、議論内容が追いやすくなった アジェンダ が多く、その回の定例で話せず持ち越しになった場合に、次回の議事録に転記する必要がある → 解決していない アジェンダ は自動的に次回に持ち越されるようになり、転記が不要になった 「決定事項」パート 何の アジェンダ について、どんな意思決定が行われたのかが追いにくい → アジェンダ に紐づけて決定事項をDBとして管理することで、意思決定が追いやすくなった ある決定事項がどこに書かれていたかを調べたい際に、決定事項が書かれた会の議事録のページを探し出す必要がある → 決定事項はDBにまとまっていて、 アジェンダ も紐づいているので、情報の検索性が向上し、素早く決定事項にたどり着けるようになった 「ToDo」パート 前回の定例で決まったToDoを確認するために、前回の議事録を参照or次回の議事録に転記する必要がある → ToDoはLinked viewとして次回定例に引き継がれるので、前回の議事録を参照する必要性も、次回の議事録に転記する必要性もなくなった 進行状況が分からない → DBとして表現することで進行状況をpropertyとして表現することが可能になり、進行状況を追えるようになった 期限が不明瞭 → DBとして表現することで、期限をpropertyとして表現することが可能になり、期限が明確になった まとめ 今回は、定例会議の議事録をNotion DBで構造化して、いい感じにした話を紹介しました。 タイミーではNotionをフルに活用しており、私が所属しているデータ統括部DSグループでも、 スクラム 運用や開発チケット管理など、さまざまな場面でNotionを活用して業務を効率化しています。 今後も定期的に、Notionの活用について発信していきたいと思います! We’re Hiring! タイミーのデータ統括部では、ともに働くメンバーを募集しています!! 現在募集中のポジションは こちら です! 「話を聞きたい」と思われた方は、是非一度 カジュアル面談 でお話ししましょう! mermaid.initialize({startOnLoad: true});
アバター
この記事は Timee Advent Calendar 2023 シリーズ 2 の10日目の記事です。 qiita.com こんにちは! @lucky_pool です。 タイミーでプロダクトマネージャーをしています。 はじめに 何らかのシステム障害が起こったとき、サービスを利用するあらゆる人に影響が出て、普段通りにサービスを利用できなくなってしまいます。そんな状況になった際、 “なんとかする” しかありません。 私はプロダクトマネージャーという役割で働いていますが、サービスのコード修正をすることや、データ変更のオペレーションをすることはありません。また、過去や新規に開発された機能や仕様をすべて熟知しているわけでもありません。ですが、障害対応においてインシデント コマンダー を担うことが何度かありました。 そこで、私がタイミーでインシデント コマンダー をやった経験から、一般的に役立ちそうな内容ををここでは紹介したいと思います。 インシデント コマンダー は誰でもなれる ここでは、障害対応をなんとかする人を「インシデント コマンダー 」と呼びます。 PagerDuty Incident Response *1 *2 を参照すれば、インシデント コマンダー の説明は以下のとおりです。※ この記事はとても良い内容でして、なんと邦訳版としても公開されております。 *3 *4 ありがたい! Keep the incident moving towards resolution. (意訳) インシデントを解決に向かわせ続ける人 そして次のようにも説明があります。 You don't need to be a senior team member to become an IC, anyone can do it providing you have the requisite knowledge (yes, even an intern!) (意訳) インシデント コマンダー になるには、シニ アメンバー になる必要はなく、 必要な知識があれば誰でもなれる (もちろん、 インターン でも) そうです、やり方さえ分かれば誰でもなれるとのことです。私も、タイミーでのインシデント コマンダー の経験上、これは一定「確かにそう」と思っています。 PagerDuty のドキュメントには Requisite knowledge (必要な知識, 予備知識) と説明がありますが、実際は所属するチームや会社によって、対応できる権限や関係者、関係するツールが異なります。方法論を知ることは必要ですが、それらの知識をベースにした “経験” が必須だと考えています。故に実行することができます。 実際に私がどんな経験をしたかをかいつまんで説明します。 事前に経験したこと 私が入社したのは2022年10月です。約1年ちょと前です。入社後から(今もですが)、システム障害等、何らか問題が起きたとき、その事象を “なんとかしたい” という気持ちだけは持っていました。 そんな気持ちからか、いつのまにか以下のことを経験していました。 ①障害対応に何度もオブザーブする タイミーでは、何らかのシステム障害が観測されたとき、Slackの WF にて報告されることになっています。そして @障害報_通知グループ なるグループに通知が飛ぶようになっています。 障害報WFで投稿された内容 この通知グループは、障害報告を知りたい人が多く入っており、例えば、プロダクト開発の関係者だけではなく、カスタマーサポート、広報、管掌役員なども入っています また、メンション対象にいなくとも、投稿先のチャンネルは、全社への情報発信をする場所であり 数100人以上が見ているチャンネルだったりします 私はこの通知を受け取る一人となりました。WFによって自動的に Google Meet のURLを提示してくれるため、作業担当者が会話しはじめます。この Google Meet に入ることで状況を知るようにしました。 その過程で大まかな障害対応の手順がわかるようになっていきました。 例えば以下の通り。 影響範囲の調査 誰に影響があるのか、何に影響があるのかを調べます システム的な影響(xxx のエラーが xxx 件ある等)だけでなく、問合せ状況(xxxに関する問合せが、通常よりxxx件多い)、また手元での確認状況(xxxxの操作をするとxxxxになる)を把握するのに努めます 暫定対応方針の検討と実行 顧客説明方針 広範囲な障害であれば、なんらか顧客周知をすることを検討しなければなりませんが、そうではなく、社内一部業務フローのみの影響であれば、関係者に対し周知する方針にします システム対応方針 できる限り早く、影響範囲を小さくできる方針を検討します revert するほうが手っ取り早ければそうしますが、そうはいかない場合は、なんらかコード差分を作りデプロイが必要になるかもしれません 定期的な情報共有 1および2の対応ステータスをリアルタイムに更新していきます、そうすることで、解消見込み時間などが分かり、安心する人たちもでてきます オンコール対応の収束 暫定対応を講じ、影響範囲を少なくすることに成功した場合、チームは解散します もし土日や深夜であれば、何らかの残対応があっても、翌営業日にすることがあるでしょう ポストモーテムの実施及び恒久対応の計画 恒久的な再発防止策だけでなく、プロセスの改善の検討と実施を計画します ②障害対応時のインシデント コマンダー の補佐をする 障害対応状況をオブザーブしていると、インシデント コマンダー を補佐できるようなことがいくつかあります。例えば以下の対応ができます。 対応状況の記録を取る タイミーの場合はドキュメントは Notion にまとめており、障害対応時においてもNotion ページに情報を集めていきます インシデント コマンダー は話をすること、情報を整理することに集中するため、文字列に整理することは難しいことがあり、故にこの補佐をすることはとても頼りになります また以下にも関連しますが、関係者にライブで状況を伝える役割にも一助になります 関係者にライブで状況を伝える WF で生成されたスレッドに、今の対応状況を箇条書きで投下しました 例えば 影響範囲がわからず、その調査を開始した、解消は未定 影響範囲は未確定だが規模として xxx が見込まれることがわかった 対応方針を検討しているが、大きく xxxx と xxxx の方針がでている xxx の方針を取ったため、 xxxx に解消が見込まれる これらのただの書き殴りだとしても、関係者にとっては有用な情報になります 障害対応時のタイムラインとして後で役に立ちます 関係者にメンションし呼び出してくる 対応に必要な人が Meet に来ていない場合は、容赦なくメンションします インシデント コマンダー や作業者が「xxxさん、xxxxチームに来て欲しい」という発言があれば、すぐに「じゃ、私が呼びますね」と対応します。 インシデント コマンダー や作業者が解決に向かうことに集中してもらうことに役立てます また、作業者が複数チームに分かれる場合もあるため、両チームのハブとなるような動きをしてインシデント コマンダー の補佐ができます 例: A: 障害原因の調査および対処方針を検討するチーム B: 顧客周知方針を検討するチーム ③ポストモーテムにオブザーブする タイミーにおいて、障害対応をしたあとは関係者でポストモーテムを実施しています。もし、障害対応をオブザーブしていなかったり何らかの補佐をしていなくても、どのような障害があり、対応がされていたのかが理解できるため、ポストモーテムの参加からでも良い経験になると思います。 ポストモーテムにおいては、再発防止だけでなく、障害対応のプロセスの改善についても会話がされます。例えば、私が参加したポストモーテムでは以下のような会話がありました。 xxx さん(xxxチーム) にて早期に報告があったのはいい動きだった! 以前に対応した xxx によって、今回の対応が早期に解決できてよかった! 今回 xxx の対応ができたことによって、顧客問い合せが 0件 で影響範囲を少なくできた! もちろん、このような良い会話だけではなく、 xxx をすることによって、もっと効果的にできるというような具体的なアクションにつながるものもあります。 システム障害、それは突然やってくる 誰もが対応できる時間帯にやってくるとは限りません。なんとかする間には、サービスのあらゆる利用者に対して真摯に説明する必要がありますし、可能な限りはやく解消するしかありません。故に、事前にこういった経験をしておくと焦らずにすむと思います。また、事前にある程度経験した上で、書籍 *5 も読むと体系だった知識としても理解しやすくなるかもしれません。 本稿が転ばぬ先の杖として、参考になれば幸いです。 *1 : https://response.pagerduty.com/ *2 : https://response.pagerduty.com/training/incident_commander/ *3 : https://i-beam.org/2020/09/22/pagerduty-incident-response/ *4 : https://ueokande.github.io/incident-response-docs-ja/ *5 : https://www.amazon.co.jp/dp/4297112655/
アバター
はじめに ※ Timee Product - Qiita Advent Calendar 2023 - Qiita の12月8日分の記事です。 okodooooooonです BigQueryの料金爆発。怖いですよね。 dbtでの開発が進んでたくさんのモデルを作るようになると、デイリーのビルドだけでも凄まじいお金が消えていったりします(僕はもう現職で数え切れないくらいやらかしてます)。 コストの対策として「パーティショニング」「クラスタリング」などが挙げられますが、今回は「増分更新」の観点で話せたらと思います。 「dbtのmaterialized=’incremental’って増分更新できておしゃれでかっこよくてコストもなんとなく軽くなりそう!」くらいの認識でさまざまな失敗を経てきた僕が、BigQueryにおけるincrementalの挙動を説明した上で、タイミーデータ基盤における増分更新の使い方についてまとめたいと思います。 ※この記事はdbt + BigQueryの構成に限定した内容となります。BigQuery以外のデータウェアハウス環境では今回紹介する2つ以外のincremental_strategyが用いられるので、そちらご注意ください! BigQueryの増分更新の挙動についておさらい dbtはincremental処理において、BigQuery向けに merge と insert_overwrite という2つのincremental_strategy(増分更新のやり方のオプション)を提供しています。 名前だけ見てもちんぷんかんぷんなのでそれぞれ解説していこうと思います。 MERGE戦略 MERGEはBigQueryにおけるデフォルトのincremental方針となります。 dbtのconfigブロックの定義は以下のようになります。 {{ config( materialized='incremental' , unique_key='xx_key' ) }} incremental_strategy = mergeの時に実際にBigQueryに発行されるMERGE文はこんな感じになります。 merge into `xxxx_project`.`xxx_dataset`.`atesaki_table` as DBT_INTERNAL_DEST using ( {{model_sql}} ) as DBT_INTERNAL_SOURCE on ( DBT_INTERNAL_SOURCE.ユニークキー 1 = DBT_INTERNAL_DEST.ユニークキー 1 AND DBT_INTERNAL_SOURCE.ユニークキー 2 = DBT_INTERNAL_DEST.ユニークキー 2 ... ... ) -- ユニーク指定キーがマッチングした場合 when matched then update set ソース側の行でマッチングした宛先テーブルの行を上書き -- 宛先テーブルにユニーク指定キーが存在しなかった場合 when not matched then insert ソース側の行を追加 この際のON句の中身が問題で on ( DBT_INTERNAL_SOURCE.ユニーク指定キー 1 = DBT_INTERNAL_DEST.ユニーク指定キー 1 AND DBT_INTERNAL_SOURCE.ユニーク指定キー 2 = DBT_INTERNAL_DEST.ユニーク指定キー 2 ... ) といった式になっているので、宛先テーブルの全件を走査して、マッチする列がないかを確認しています。宛先テーブルが巨大になればなるほど、クエリコストが問題となってきます。 概念図で示すとこんな感じ INSERT+OVERWRITE戦略 dbtのconfigブロックは以下のようになります。 {{ config( materialized='incremental', incremental_strategy='insert_overwrite', partitions=var('last_31_days'), # dbt_project.ymlで定義した日付のリストが格納された変数 partition_by= { 'field' : 'xx_datetime' , 'data_type' : 'datetime' , 'granularity' : 'day' } ) }} 発行されるMERGE文は以下のような形。参考: https://docs.getdbt.com/reference/resource-configs/bigquery-configs#the-insert_overwrite-strategy create temporary table {{ model_name }}__dbt_tmp as ( {{ model_sql }} ); declare dbt_partitions_for_replacement array< date >; -- ソーステーブルから上書きすべき日付の配列をdbt_partitions_for_replacementにセット set (dbt_partitions_for_replacement) = ( select as struct array_agg( distinct date (max_tstamp)) from {{ model_name }}__dbt_tmp ); merge into {{ destination_table }} AS DEST using {{ model_name }}__dbt_tmp AS SRC on FALSE -- on Falseにすることでキー同士のマッチングは発生しない -- 宛先テーブルの該当パーティションを削除する。参考:https://cloud.google.com/bigquery/docs/using-dml-with-partitioned-tables?hl=ja#using_a_filter_in_the_search_condition_of_a_when_clause when not matched by source and {{ partition_column }} in unnest(dbt_partitions_for_replacement) then delete -- ↑で削除した後にソースをinsertする when not matched then insert ... 指定したパーティションを削除した後に、ソーステーブルをinsertするような処理をしています。 ソースにも宛先にもfull scanが走らないので、クエリコストとしては軽くなります 概念図で示すとこんな感じ 増分更新ロジックの使い分けについて 上記のロジックを理解した上で増分更新についてまとめると以下のようになります。 incremental_strategy=mergeのユースケース ソーステーブルが巨大で宛先テーブルが小さい場合の増分更新 ソーステーブルにフルスキャンを走らせることなくコストを抑えた状態で、ユニークを保って増分更新ができます incremental_strategy=insert_overwriteのユースケース 宛先テーブルが巨大な場合の増分更新 ソーステーブル, 宛先テーブルともにフルスキャンを走らせることなくコストを抑えた状態で増分更新できます insert_overwriteの注意点 💡 パーティションを静的に指定しないといけない点 insert_overwriteではdbt_project.ymlにて以下のように静的なリストとしてパーティション指定のリスト変数を作成した後に last_2_days : [ current_date('Asia/Tokyo') , date_sub(current_date('Asia/Tokyo'), interval 1 day) ] 以下のように {{ config( materialized='incremental', incremental_strategy='insert_overwrite', partitions=var('last_2_days'), partitionsに渡す必要があります 上の直近2日間のpartitions指定だと、もし何かしらの要因でJobが3日間転んだりしていたら、3日目に復旧したとしても 障害1日目:レコード無。障害2日目:レコード有。障害3日目:レコード有 といった形の歯抜けテーブルになってしまいます。障害を踏まえたバッファ分を余裕を持って指定しておくことが大切です。 💡 ユニークが担保されない点 insert_overwriteはパーティションをごっそり入れ替えるようなロジックであるため、ユニークがこのロジックだけでは担保されません。これによって生じる想定される不具合を見てみましょう。 例えば「ユーザーごとの月毎と日毎の両方のステータスを保持するようなテーブル」を想定してみます。 月単位のステータスが12/9で petapeta ⇒ potepote に変更されたとします。 この時に直近1週間をpartitionsに指定するinsert_overwriteで増分更新をすると仮定すると12/9の更新で以下のようになります。 このように直近1週間のパーティションをそのまま置き換えただけなので、12/1, 12/2の月単位のステータスがpetapetaのままになってしまい、同じ月に2つの月単位ステータスを持つ形になってしまいます。このようなケースを防ぐには、partitionsに「直近1ヶ月分の日付のリスト」を渡してあげる必要があります。 過去の値が変わりうるテーブル(月末の締め処理で金額が変動しうる会計処理とか?)に対してinsert_overwriteを適用するには、「どの期間をpartitionsに渡せば安全なのか」を考えることがとても重要になります! タイミーの増分更新の対象について 挙動を正確に理解できたところでタイミーデータ基盤における増分更新のユースケースを見ていきましょう! mergeを使用して増分更新しているケース firebaseなどのログテーブルから一部種別のログだけを抽出するようなテーブル 巨大なソーステーブルから集約した値を蓄積するようなテーブル insert_overwriteを使用して増分更新しているケース 宛先がソースと比較して膨大になるsnapshot factテーブル その他ログテーブルなどから作られる巨大なfactテーブル こんな形で当社では現状は使い分けています。 実装難度がinsert_overwriteの方が高いので、mergeで済むケースはmergeにしちゃってます。 おわりに BigQueryにおけるincrementalの挙動と、それぞれのメリデメ、なぜコストが下がるのかあたりを解説しました。 incrementalに関する公式Docを見てもなんとなく言ってることわかりそうでわからない。 MERGE文などを通して公式Docなどで紹介されているが、普段SELECT文を書くことが主となってしまって、DMLにあたるMERGE文は読みづらいしわかりづらい。などといった方々の参考になれたら嬉しいです。 We’re Hiring タイミーのデータ統括部はやることがまだまだいっぱいで仲間を募集しています!興味のある募集があればこちらから是非是非ご応募ください。 product-recruit.timee.co.jp
アバター
こんにちは、データ統括部データサイエンス(以下DS)グループ所属の小関 ( @ozeshun )です。 本記事では、タイミーで取り組んでいるレコメンドに使用する アルゴリズム を検証する際に活用した、RecBoleでの実験方法について紹介したいと思います。 ※ Timee Advent Calendar2023 の12月8日分の記事です。 RecBoleとは RecBoleを活用したアルゴリズムの実験手順 0. ディレクトリ構成 1. 学習データの準備とRecBoleで使用するconfig fileの用意 2. 学習データをAtomic file *3 へ変換 3. モデルの学習 4. 学習したモデルの検証 おわりに We’re Hiring! RecBoleとは RecBole とは、レコメンド アルゴリズム を統一されたインターフェースで提供する事を目的としたプロジェクトであり、後述のように アルゴリズム 間の比較を簡単に実現出来ます。2023/12/8現在、91種類の アルゴリズム が実装されており、 Python のライブラリ *1 として公開されています。 実装されている アルゴリズム は、 Model Introduction から確認できます。 今回は、実装されている アルゴリズム の中でもexplicitなフィードバックを予測すること *2 を目的とした、 Context-aware Recommendation の アルゴリズム をRecBoleを使用して検証する一連の流れを紹介したいと思います。 RecBoleを活用した アルゴリズム の実験手順 0. ディレクト リ構成 今回は、以下のような ディレクト リ構成の元、ノートブック上で実験を進める手順を説明します。 ├── notebook.ipynb ├── artifact │ ├── saved # 学習したモデルの保存先 │ │ ├── AFM-%m-%d-%Y_%H-%M-%S.pth │ │ ├── DeepFM-%m-%d-%Y_%H-%M-%S.pth │ │ ├── FM-%m-%d-%Y_%H-%M-%S.pth │ │ ├── NFM-%m-%d-%Y_%H-%M-%S.pth │ └── train_data # 学習用のデータの保存先. pickle fileは、Atomic fileに変換する際のソース. │ ├── interact.pkl │ ├── items.pkl │ ├── users.pkl │ ├── train_dataset # RecBoleが学習で使用する、Atomic fileの保存先 │ ├── train_dataset.inter │ ├── train_dataset.item │ └── train_dataset.user ├── base_dataset.py # データセットのI/Oをコントロールするクラスのベース (詳細はStep.2に記述) ├── config ├── model.hyper # 探索したいハイパーパラメータと探索範囲を記述したファイル └── train.yaml # RecBoleで使用する、学習方法などを記述したconfig file 1. 学習データの準備とRecBoleで使用するconfig fileの用意 学習用データの準備 今回は、Context-awareなモデルを学習することを目的としているので、下記のように、user_id、item_id、explicitなフィードバックを表すカラム、ユーザー・アイテムの特徴量を含む pandas.DataFrame 形式のオリジナルデータを用意します。   user_id item_id target user_feature item_feature 1 1 0 0 -1.0 2000 2 1 2 1 0.5 3000 3 2 1 1 -0.8 4000 4 2 2 0 0.0 5000 RecBole用のconfig fileの用意 このファイルには、データの保存先などの環境の設定、使用するデータに関する情報、学習方法や評価方法の設定を記述します。 # config/train.yaml # 使用するモデル名とデータセット名を指定----------------------------------- model : FM dataset : train_dataset # Environment Settings----------------------------------------------- # https://recbole.io/docs/user_guide/config/environment_settings.html gpu_id : 0 use_gpu : False seed : 2023 state : INFO reproducibility : True data_path : 'artifact/train_data/' checkpoint_dir : 'artifact/saved/' show_progress : True save_dataset : True save_dataloaders : False # Data Settings------------------------------------------------------ # https://recbole.io/docs/user_guide/config/data_settings.html # Atomic File Format field_separator : " \t " seq_separator : "@" # Common Features USER_ID_FIELD : user_id ITEM_ID_FIELD : item_id LABEL_FIELD : target # Selectively Loading load_col : # interaction inter : [ user_id, item_id, target ] # ユーザー特徴量 user : [ user_id, user_feature, ] # アイテム特徴量 item : [ item_id, item_feature, ] # Preprocessing # 標準化する特徴量を指定 normalize_field : [ item_feature, ] # Training Setting--------------------------------------------------- # https://recbole.io/docs/user_guide/config/training_settings.html epochs : 100 train_batch_size : 1024 learner : 'adam' train_neg_sample_args : ~ eval_step : 1 stopping_step : 3 loss_decimal_place : 4 weight_decay : 0 # Evaluation Settings------------------------------------------------ # https://recbole.io/docs/user_guide/config/evaluation_settings.html eval_args : group_by : user split : { 'RS' : [ 0.8 , 0.1 , 0.1 ]} mode : labeled repeatable : True metrics : [ 'LogLoss' , 'AUC' ] topk : 20 valid_metric : LogLoss eval_batch_size : 1024 metric_decimal_place : 4 eval_neg_sample_args : ~ 次のステップでAtomic fileに変換する際のソースとなるように、用意したDataFrameをpkl形式で保存 下記のコードをノートブック上で実行すると、ユーザー×アイテムのインタ ラク ション、ユーザーの特徴量、アイテムの特徴量を抽出したデー タセット が ARTIFACT_PATH 配下に保存されます。 import os import yaml import pandas as pd # データの読み込み (データソースはなんでも良い) train_df = pd.read_csv( '/path/to/train.csv' ) # yaml形式で書かれたRecBoleのcofig fileを読み込む TRAIN_YAML_PATH = 'config/train.yaml' with open (TRAIN_YAML_PATH, 'r' ) as yaml_file: train_config = yaml.safe_load(yaml_file) # cofig fileから各データセットに使用するカラム名を抽出 INTERACTION_COLUMNS = train_config[ 'load_col' ][ 'inter' ] USER_COLUMNS = train_config[ 'load_col' ][ 'user' ] ITEM_COLUMNS = train_config[ 'load_col' ][ 'item' ] TOKEN_COLUMNS = [ 'item_id' , 'user_id' , ] # Atomic fileに変換する際のソースデータとしてpkl形式で保存 ARTIFACT_PATH = 'artifact/train_data/' train_df[INTERACTION_COLUMNS].to_pickle(os.path.join(ARTIFACT_PATH, 'interact.pkl' )) train_df[ITEM_COLUMNS].to_pickle(os.path.join(ARTIFACT_PATH, 'items.pkl' )) train_df[USER_COLUMNS].to_pickle(os.path.join(ARTIFACT_PATH, 'users.pkl' )) 2. 学習データをAtomic file *3 へ変換 変換時に使用する、デー タセット のI/Oをコン トロール するクラスをノートブック上に記述 このクラスには、インプット・アウトプット先や、使用するカラムの情報とそのデータ型を記述しておきます。 # https://github.com/RUCAIBox/RecSysDatasets/blob/master/conversion_tools/src/base_dataset.py をそのままimportして継承 from base_dataset import BaseDataset class TrainDataset (BaseDataset): def __init__ (self, input_path, output_path): super (TrainDataset, self).__init__(input_path, output_path) self.dataset_name = 'train_dataset' # input_path self.inter_file = os.path.join(self.input_path, 'interact.pkl' ) self.item_file = os.path.join(self.input_path, 'items.pkl' ) self.user_file = os.path.join(self.input_path, 'users.pkl' ) self.sep = ',' # output_path output_files = self.get_output_files() self.output_inter_file = output_files[ 0 ] self.output_item_file = output_files[ 1 ] self.output_user_file = output_files[ 2 ] # selected feature fields inter_fields = { i: f '{c}:token' if c in TOKEN_COLUMNS else f '{c}:float' for i, c in enumerate (INTERACTION_COLUMNS) } item_fields = {i: f '{c}:token' if c in TOKEN_COLUMNS else f '{c}:float' for i, c in enumerate (OFFER_COLUMNS)} user_fields = {i: f '{c}:token' if c in TOKEN_COLUMNS else f '{c}:float' for i, c in enumerate (USER_COLUMNS)} self.inter_fields = inter_fields self.item_fields = item_fields self.user_fields = user_fields def load_inter_data (self): return pd.read_pickle(self.inter_file) def load_item_data (self): return pd.read_pickle(self.item_file) def load_user_data (self): return pd.read_pickle(self.user_file) 下記コードをノートブック上で実行して、 ARTIFACT_PATH 配下に格納したデー タセット をAtomic file形式に変換 ARTIFACT_PATH = 'artifact/train_data/' # 前のステップで保存したデータセットの保存先 input_path = ARTIFACT_PATH # Atomic fileの書き出し先 output_path = os.path.join(ARTIFACT_PATH, 'train_dataset' ) i_o_args = [input_path, output_path] # 前のステップで作成したI/Oクラスにinput,outputの情報を渡す datasets = TrainDataset(*i_o_args) # DatasetをAtomic fileに変換 datasets.convert_inter() datasets.convert_item() datasets.convert_user() 3. モデルの学習 モデルを学習する関数をノートブック上に定義 パラメータチューニングの各種設定については、コメントで記述したページに詳しく書いてあります。 from recbole.quick_start import objective_function, run_recbole from recbole.trainer import HyperTuning # (再掲) 事前に準備したRecBoleのcofig fileへのパス TRAIN_YAML_PATH = 'config/train.yaml' # 探索したいハイパーパラメータと探索範囲を記述したファイルへのパス HYPER_PARAMS_PATH = 'config/model.hyper' def train_model (model_name: str , config_file_list: str = TRAIN_YAML_PATH, params_file: str = HYPER_PARAMS_PATH) -> None : # ハイパーパラメータチューニングの条件を設定 # 参考: https://recbole.io/docs/user_guide/usage/parameter_tuning.html hp = HyperTuning( objective_function=objective_function, algo= 'bayes' , early_stop= 3 , max_evals= 15 , params_file=params_file, fixed_config_file_list=config_file_list, ) # チューニングを実行 hp.run() # print best parameters print ( 'best params: ' , hp.best_params) # print best result print ( 'best result: ' ) print (hp.params2result[hp.params2str(hp.best_params)]) # bestなパラメータを取得 parameter_dict = { 'train_neg_sample_args' : None , } | hp.best_params # bestなパラメータでモデルを学習 run_recbole( model=model_name, dataset= 'train_dataset' , config_file_list=config_file_list, config_dict=parameter_dict, ) 探索したいハイパーパラメータとその探索範囲を model.hyper というファイルに記述して用意 # config/model.hyper (使用アルゴリズムがFMの場合) learning_rate choice [0.1, 0.05, 0.01] embedding_size choice [10, 16, 32] 定義した学習用の関数に試したい アルゴリズム 名を指定して、学習を実行すると train.yaml に記述した checkpoint_dir 配下に学習済のモデルが保存されます。 # FM train_model('FM') # NFM train_model('NFM') # AFM train_model('AFM') # DeepFM train_model('DeepFM') 4. 学習したモデルの検証 学習したモデルでテストデータに対する予測値を計算し、その評価指標を算出する関数をノートブック上に定義 import torch from recbole.data.interaction import Interaction from recbole.quick_start import load_data_and_model def eval_model ( model_file_name: str , model_saved_dir: str = train_config[ 'checkpoint_dir' ], target: str = train_config[ 'LABEL_FIELD' ], user_columns: list = USER_COLUMNS, item_columns: list = ITEM_COLUMNS, token_columns: list = TOKEN_COLUMNS, ): # 学習したモデルとテストデータを読み込み _, model, _, _, _, test_data = load_data_and_model(model_file=os.path.join(model_saved_dir, model_file_name)) columns = user_columns + item_columns + [target] # テストデータをモデルが予測出来る形式に変換 interactions = {} test_df = pd.DataFrame([]) for c in columns: test_features = torch.tensor([]) for data in test_data: test_features = torch.cat([test_features, data[ 0 ][c]]) if c in token_columns: test_features = test_features.to(torch.int) interactions[c] = test_features if c in [ 'user_id' ] + [target]: test_df[c] = test_features test_interaction_input = Interaction(interactions) # テストデータに対する予測結果を作成 model.eval() with torch.no_grad(): test_result = model.predict(test_interaction_input.to(model.device)) test_df[ 'pred' ] = test_result # テストデータに対するランキングメトリクス、AUC, Loglossを算出する関数を実行 (今回は実装は割愛) # 現状のRecBoleの仕様だとmode: labeledで学習した場合、ランキングメトリクスを指定できないので、自前で計算する必要があります return test_df 定義した検証用の関数に学習済のモデルのファイル名を入れて実行することで、テストデータに対する予測結果とメトリクスが計算されます。 # FM test_df = eval_model( 'FM-%m-%d-%Y_%H-%M-%S.pth' ) # NFM test_df = eval_model( 'NFM-%m-%d-%Y_%H-%M-%S.pth' ) # AFM test_df = eval_model( 'AFM-%m-%d-%Y_%H-%M-%S.pth' ) # DeepFM test_df = eval_model( 'DeepFM-%m-%d-%Y_%H-%M-%S.pth' ) 作成された test_df にはランキングメトリクスが計算出来るようにuser_id、真値、予測値が書き込まれています。   user_id target pred 1 1 1 0.9 2 1 1 0.7 3 1 0 0.3 4 1 0 0.1 test_df を元にテストデータに対するメトリクスを計算することで、 アルゴリズム 間の比較検証が出来ます。   ROC -AUC Logloss Recall@20 MAP@20 MRR@20 FM 0.865 0.105 0.491 0.489 0.577 NFM 0.843 0.110 0.502 0.470 0.540 AFM 0.865 0.103 0.507 0.487 0.568 DeepFM 0.862 0.101 0.523 0.522 0.598 おわりに 今回は、多様なレコメンド アルゴリズム を検証できるRecBoleを活用した実験の手順について紹介しました。 この記事が、レコメンド アルゴリズム 構築に関わる方々の助けに少しでもなれたら嬉しいです! We’re Hiring! タイミーのデータ統括部では、ともに働くメンバーを募集しています!! 現在募集中のポジションは こちら です! 「話を聞きたい」と思われた方は、是非一度 カジュアル面談 でお話ししましょう! *1 : PyPI : https://pypi.org/project/recbole/ *2 : 例: CTR予測など *3 : RecBoleが学習に用いる データ形式
アバター
この記事は Timee Advent Calendar 2023 の7日目の記事です。 qiita.com こんにちは、データ統括部データサイエンス(以下DS)グループ所属の小栗です。 本記事では、メンバーの相互理解を深めるためにDSグループで取り組んでいる施策を紹介します。 そもそもの課題感 以下の要素により、DSメンバー間の相互理解が今後難しくなりそう…という課題感が当時あり、諸々の施策をスタートさせました。 フルリモート前提の働き方をしている チームメンバーの数がすごい勢いで増えてきた(1年で2.5倍に) メンバーがそれぞれ担当する部署横断PJがいくつも並行に走っており、逆にチーム内での接点が少なくなってきた メンバーの相互理解を深めるためにやっていること データ統括部やDSグループで取り組んでいる取り組みは他にもたくさんありますが、今回は以下の2つに絞って紹介します。 スキルマップ共有会 ストレングスファインダー共有会 スキルマップ共有会 スキルマップとは、業務に関係あるハードスキルとそれに対する各メンバーの習熟度を可視化するツールです。 スキルマップの作成・共有をする目的は主に2つあります。 各メンバーが持つハードスキルを把握し、相互理解に繋げる 業務に関係あるスキルと、そのスキルに自信がある人を把握できるようにする 具体的には、下図のようなスキルマップを スプレッドシート で簡単に作成しました。 スキルマップの一例 社内や社外のスキルマップを参考にしつつも、タイミーのDSグループに強く関係するスキルや技術を独自にピックアップしています。 具体的には、「データサイエンス」「データエンジニアリング」「ビジネス」「アカデミック ドメイン 」「業界 ドメイン 」の軸でそれぞれ10~20個ほど要素(≒ スキル)を選定し、軸ごとにスキルマップを作成しています。 また、スキルの習熟度とは別に、「今後伸ばしたい」スキルも可視化するようにしています。 工夫した点(というか難しい点)として、各スキルの習熟度に対して厳密な基準を設けない形にしています。 厳密な基準を設けるのは難しいこと、そして、相互理解が目的なのでメンバーそれぞれの各スキルに対する「自信度」がザックリわかればいい、といった理由から、現在の形で運用しています。 定期的に各メンバーはスキルマップへの記入を行います。 その後に共有会を開催し、1人ずつ自分のスキルを発表して、それに対する感想や質問をする、という形で運用しています。 共有会で各メンバーの 保有 スキルや今後伸ばしたいスキルが分かるため、それを通して相互理解が深まり仕事がしやすくなる、という想いで運用しています。 ストレングスファインダー共有会 ストレングスファインダー は、米国のギャラップ社が開発した「強みの診断」ツールです。 WEB上で診断を受けると、34の資質の中から自分の強みや資質を知ることができます。 スキルマップはハードスキルに着目していましたが、ストレングスファインダーではソフトスキルに焦点が当たっており、DSグループでは棲み分けをする形でどちらも運用しています。 新メンバーがオンボーディングの過程で診断を受けるフローになっており、メンバーが増えるごとに都度共有会を開いています。 共有会では、1人ずつ自分の強みを発表して、それに対する感想や質問をする、という形で賑やかに開催しています。 また、診断結果を盲目的に信じて決めつけたコミュニケーションをするのではなく「この診断結果は的確/そうでもない」といった会話も挟むなど、あくまで診断結果を踏み台にして生まれる会話によって相互理解を深めています。 ストレングスファインダーの診断結果を通して、普段の業務では把握できない各人の「強み」や「考え」がわかるのが魅力だと思っています。 ※データ統括部BIチームのyuzukaさんが執筆した ストレングスファインダーに関するアドベントカレンダー記事 もあるため、そちらもぜひご一読ください! おわりに この記事では、DSグループが実施しているメンバー相互理解のための取り組みを紹介しました。 データ統括部およびDSグループは今後もメンバーを増員する予定なので、施策をさらにアップデートしていきたいと考えています! We’re Hiring! タイミーのデータ統括部では、ともに働くメンバーを募集しています!! 現在募集中のポジションは こちら です! 「話を聞きたい」と思われた方は、是非一度 カジュアル面談 でお話ししましょう!
アバター
この記事はTimee Advent Calendar 2023シリーズ 2の5日目の記事です。 はじめに DREグループの石井です。 今回はDREグループの管理するデータ基盤に関するインフラのterraformのテスト環境の話をしようと思います。 導入前の課題感 我々のチームではデータ基盤として複数の GCP Projectを管理していますが、その全てをterraformで管理しています。 この時点でGithubActionsによる自動テスト(validate, plan) 及び 自動デプロイは導入されていたため、レビューさえ通れば誰でもインフラの変更を反映できる状態になっています。 しかし、この時点でよく起こっていた問題として以下のようなものがありました。 validationが実装されていないリソースの 命名規則 などでデプロイ時に落ちる 実際にapplyして試してみたいけど、デプロイ先が本番しかない そこでPRごとに GCP Projectを作成しその中でapplyを実際に試せる仕組みを実装して、しばらく運用してみたので実装からその所感までをまとめてみようと思います。 やったこと PR時にテスト環境が作成するために、ざっくりいうと以下のようなことをやっていますので、それぞれ詳細に記載しようと思います。 リポジトリ 構成の変更 CIで実際にapplyされるときのリソース名の調整 GithubActionsの実装 リポジトリ 構成の変更 元々、1つの Github Repositoryで全ての GCP Projectを取り扱っていたのですが、元々は以下のような構造でした。 (必要なところのみ抜粋) envs/ GCP_ProjectA/ 各種terraform .. GCP_ProjectB/ modules/ module_A/ module_B/ global.tfvars ... ユーザの管理等 envs配下にproject単位で切られており、modulesは共通の部品だけ切り出しておくという構成です。 これを以下の様に変更しています。 envs/ GCP_ProjectA/ environment/ prod/ main.tf backend.tf ...            test/ main.tf backend.tf modules/ module_X/ 各種terraform .. GCP_ProjectB/ modules/ module_A/ module_B/ global.tfvars ... ユーザの管理等 プロジェクトを跨いでグローバルに使用していたモジュールはそのままとして、モジュール化されていなかったプロジェクト固有のterraformファイルをモジュールとしてまとめています。 そして、main.tf内でモジュールを呼び出すという一般的なモジュール構成に似た形になっています。 リソース名の修正 リソース名もvalidation対象ではあると思うので本来はそのまま適用していきたいのですが、GCSのようなグローバルに一意にしないといけないリソースはこのまま実行するとテスト環境を壊すのと本番環境を作るときのラグ等でリソース名が利用できなかったりして困るケースがあります。 そのため gcs_suffix というvariableを用意しておいて、module側で例えば以下のようにしています。 resource " google_storage_bucket " " gcs " { name = " test${var.gcs_suffix} " .. } default値を ""(空文字) としておくことで、本番環境には影響を与えないようにしています。 Github Actionsの実装 それでは上で整理したモジュールを使ってテスト環境を生成する部分の話に移ります。 基本的にはPJを作るテンプレートを別途用意しておき、それをコピーしてきてapplyしてプロジェクトを作成、その後、environment/test内をapplyする、という流れになっています。 project.tf (Projectの作成) resource " google_project " " test-environment " { billing_account = var.BILLING_ACCOUNT_ID name = local.project_id project_id = local.project_id org_id = var.ORGANIZATION_ID } resource " google_billing_budget " " budget " { depends_on = [ google_project.test-environment ] provider = google-beta billing_account = var.BILLING_ACCOUNT_ID # 消し忘れ対策にバジェットを指定 amount { specified_amount { currency_code = " JPY " units = 10000 } } budget_filter { projects = [ " projects/${google_project.test-environment.number} " ] } threshold_rules { threshold_percent = 0.5 ... } services.tf (サービスの有効化) resource " google_project_service " " service " { depends_on = [ google_project.test-environment ] for_each = local.services project = local.project_id service = each.value disable_dependent_services = true } (variable等は割愛します) これをapplyした後に実際のtest配下をapplyするのですが、backend設定だけ差し替えないといけないため、以下のようなテンプレートを用意して書き換えています。 backedn.tf.template terraform { backend \ " gcs\ " { bucket = \ " terraform-backend-bucket-name\ " prefix = \ " ci_projects/${PROJECT_ID}/projects.tfstate\ " } } これらを用いて、以下のようなActionsになりました。なお、PJがかなり多いため実際によく変更されるプロジェクトのみを対象としたかったのでそうなるようにしています。 name: terraform-test-apply on : pull_request env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GOOGLE_BACKEND_CREDENTIALS: ${{ secrets.GOOGLE_BACKEND_CREDENTIALS }} GOOGLE_CLOUD_KEYFILE_JSON: ${{ secrets.TIMEE_CORE__GOOGLE_CREDENTIALS }} jobs: set -matrix: runs- on : ubuntu-latest outputs: target_project: ${{ steps.get-diff.outputs.value }} steps: - uses: actions/checkout@v3 - name: Fetch changes run: git fetch origin ${{ github.base_ref }} - name: id: get-diff run: | diff=$( echo " $(git diff --name-only origin/master..HEAD | \ cut -d'/' -f2 | \ grep -e 'Project1' -e 'Project2' -e 'Project3\' | \ jq -R . | jq -s . | jq -c '.|unique') " ) echo $diff echo " value=${diff} " >> $GITHUB_OUTPUT apply: needs: set -matrix if : ${{ needs. set -matrix.outputs.target_project != ' [] ' }} strategy: fail-fast: false matrix: PROJECT_NAME: ${{fromJson(needs. set -matrix.outputs.target_project)}} runs- on : ubuntu-latest env: PROJECT_ID: " tf-test-${{ matrix.PROJECT_NAME }}-${{ github.event.number }} " PR_NUMBER: ${{ github.event. number }} steps: - uses: actions/checkout@v3 - name: Setup Terraform uses: hashicorp/setup-terraform@v2 - name: create project tf and apply run: | pushd tests/project eval " echo \ " $(cat ../templates/backend.tf.template )\ "" > backend.tf terraform init - lock = true - lock -timeout=60s terraform apply -var= " PROJECT_ID=${PROJECT_ID} " -auto-approve -var- file =../../global.tfvars - lock = true - lock -timeout=60s project_number=$(terraform output google_project_number | head -n 2 | tail -1 ) echo " project_number=$project_number " >> $GITHUB_ENV popd - name: apply `iam/` run: | pushd envs/${{ matrix.PROJECT_NAME }}/environment/test/ terraform init -backend-config= " prefix=${{ matrix.PROJECT_NAME }}/${PROJECT_ID}/test.tfstate " terraform apply -var= " project=${PROJECT_ID} " -auto-approve -var- file =../../../../../global.tfvars - lock = true - lock -timeout=60s -parallelism= 20 - name: Terraform apply Link uses: actions/github-script@v6 with : result-encoding: string script: | github.rest.issues.createComment({ issue_number: context.issue. number , owner: context.repo.owner, repo: context.repo.repo, body: " ### Test Apply of `${{ matrix.PROJECT_NAME }}` :rocket: " + " \n " + " Link: https://console.cloud.google.com/welcome?project= " + process.env.PROJECT_ID }); やってみた感想と課題 正直1PRごとに1GCP Projectを立てるのはやりすぎかなと思っていましたが、実際立っていると確認はしやすく、かつ他の影響も受けないため特に新しい機能を開発するようなタイミングでは大変良かったように思います。 ただ、現実問題としてapplyにかかる時間がだいぶ長いという問題はあり、環境によっては4,50分かかっていたこともありました。 検証する必要性の薄いリソースを対象外とするなど色々改善しましたが、それでも軽微な変更をするのにもこれを回さないといけないというのはやりすぎでは?という側面も正直あるかなと思っています。 このあたりは程度問題な気もするので、今後も見極めていければとは思っています。  個人的には初めて行う設定などでterraformの書き方にやや自信がないものをある程度自信を持ってレビューを出せるようになったことに最も価値を感じているところではあります。 We’re Hiring DREグループではまだまだやっていきたいことがたくさんあるのですが、まだまだ手が足りておら ず、ともに働くメンバーを募集しています!! データエンジニアのポジション データに係る他のポジションやプロダクト開発などのポジションも絶賛募集中なので こちら からご覧ください
アバター
こんにちは、CTO室グループでQA スペシャ リストを担っている依光です。 今年を振り返ってという視点から、施策として動き始めた「障害対応をFactから改善する取り組み」について紹介させてください。 今までの取り組みと課題 タイミーのプロダクト部ではFour Keysを活用して改善サイクルに取り組んでおり、 プロダクトの品質を改善するという側面から「変更失敗率」と「サービス復元時間」を 計測しています。 この「サービス復元時間」を短縮するに当たり、障害を時系列にまとめて事後検証として 振り返るポストモーテムだけでは、改善するポイントを客観的に判断することが難しい という課題がありました。 取り入れた施策 障害発生の時間を短縮するために発生時間の内訳を計測して、客観的に判断するようにしました。 まず計測する際に時間を分解する切り口は、O'Reilly社から出版されている「Seeking SRE(SREの探求)」に記載されているプロセスを参考に収集するようにしました。 図の 参照元 : https://www.oreilly.com/library/view/seeking-sre/9781491978856/ch04.html TTD:検出時間 TTE:エンゲージ時間( エス カレーションプロセスの時間) TTF:修正時間 TTM:軽減時間(障害発生から対応が完了するまでの時間) 続いて客観的に判断するために、各障害のレベルごとにTTD、TTE、TTF、TTMの目標指標を定義しました。 表:障害レベルと各プロセスの目標指標の例 施策を取り入れた効果 ポストモーテムの実施タイミングで、発生した障害のレベルに応じて TTD、TTE、TTF、TTMが目標指標内に収まっているかを確認する ステップが追加されました。 そして未達の指標に対して改善案を深ぼるアプローチができるようになり 障害発生時間の短縮に結びつく改善に繋がりやすくなりました。 指標例:TTD(検出時間)が未達だった 改善例:メトリクスの追加とアラートについて検討する 今後の取り組みに向けて 今回紹介した施策は、時系列にまとめて事後検証を行うポストモーテムだけでは 見つけられなかった改善ポイントが、障害発生時間の内訳時間というFactを基に、 客観的に把握して品質改善ができるようになった1例だと考えています。 今回の事例だけでなく、今後もタイミーのプロダクト本部ではFactを基にした 品質の維持、向上の取り組みを大切にしていきたいと考えています。 私達の取り組みにご興味がありましたら、 情報交換など気軽にご連絡下さい。 最後まで読んでいただき、ありがとうございました。
アバター
こちらは Timee Advent Calendar 2023 シリーズ1の5日目の記事になります。 昨日は @redshoga による Vercel REST APIを用いたステージング環境反映botについて で明日は @yama_sitter による フロントエンドアプリケーションの認知負荷とテスタビリティに立ち向かう です。 タイミーでバックエンドエンジニアをしている id:euglena1215 です。 タイミーはユーザー向け・企業向け・社内向けの機能を1つの Rails アプリケーション上で動かしています。 10/5に Rails 7.1 がリリースされ、タイミーも11/1に 7.1.1 に上げることができました。現在は Rails 7.1.2 が本番で元気に動いています。 Rails 7.1.1 へのアップグレードは比較的スムーズに行うことができたものの、アップグレードのプロセスにはまだ改善の余地があると感じました。今回はどこに改善の余地があると思ったのか、具体的な改善の取り組みについて紹介したいと思います。 背景・課題 指標その1:Code Line Coverage Code Line Coverage とは 指標その2:Endpoint Coverage 改善その1:index actionのテストめっちゃ書く 改善その2:使われていない batch_action の削除 まとめ 背景・課題 タイミーでは、 Rails アップグレードの動作確認は手動での動作確認をせずとも自動テストで動作を担保して問題ないという合意が得られています。これはテストの網羅性が高く(Code Line Coverage が 91%)十分に動作が担保できているだろうという前提があるためです。 しかしこのルールにもいくつかの例外があります。例えば、暗号化 アルゴリズム の変更やキャッシュフォーマットの変更、 Cookie の属性変更など普段書くアプリケーションロジックの自動テストでは検出が難しいと考えられるものです。これらの例外は毎アップグレードで確認すべき箇所が異なり自動テストで検出するのは現実的ではないと考えているため、自動テスト以外の方法で動作を担保しています。 一方、他の例外として「社内向け管理画面は手動でのチェックを行う」というものがあります。これは社内向け管理画面はテストコードの網羅性が低く、自動テストを信頼できないというのが理由です。理由としてはもっともだと思いますが、ユーザー向けの機能は手動でのチェックをしなくていいのに、社内向けの機能は手動でのチェックが必要なのはチグハグさを感じました。 今回はこの 社内向け管理画面はテストの網羅性が低く自動テストを信用できないため、 Rails アップグレードの手間が増えている ことが課題だと捉えました。 指標その1:Code Line Coverage テスト カバレッジ が低いのならテストを書けばいいじゃないということで、社内向け管理画面 ( /admin/* )の Code Line Coverage を指標としテストを書き始めました。 Code Line Coverage とは 「Code Line Coverage」は、 ソフトウェアテスト の際に使用される指標です。その目的は、自動テストによって実行された ソースコード の行数の割合を測定し、どの程度のコードがテストされているかを把握することにあります。計算方法は、テストで実行されたコード行数を全コード行数で割り、それを100倍してパーセンテージで表します。この指標はテストの カバレッジ を 定量 的に評価するために使われます。 タイミーの Rails アプリケーションでは codecov を使って以下のようにテストで実行されたコード、実行されていないコードを可視化しています。 この時点での app/admin/* ディレクト リの Code Line Coverage は 75% でした。 前提として、タイミーでは社内向け管理画面を実装するために ActiveAdmin gem を利用しています。 ActiveAdmin gem を使うこと自体には社内でも賛否両論ありますが、大いなるデメリットもあれば大いなるメリットもあるということで使い続けています(この辺りの話に興味があればぜひ話を聞きに来てください。カジュアル面談は こちら )。 問題とは、ActiveAdmin を使って管理画面を実装した場合 Code Line Coverage と体感のテスト カバレッジ が一致しないことです。 代表例を挙げます。下記は User モデルに対応した管理画面を実装するコードです。 この2行を書くだけでいくつのエンドポイントが定義されるかを確認してみましょう。 # app/admin/users.rb ActiveAdmin .register User do end 下記は上記2行の実装によって定義されたエンドポイントの一覧です。2行書くだけで9つものエンドポイントが定義されています。 root@ba8730884fed:/usr/src/app# bundle exec rails routes | grep admin/users batch_action_admin_users POST /admin/users/batch_action(.:format) admin/users#batch_action admin_users GET /admin/users(.:format) admin/users#index POST /admin/users(.:format) admin/users#create new_admin_user GET /admin/users/new(.:format) admin/users#new edit_admin_user GET /admin/users/:id/edit(.:format) admin/users#edit admin_user GET /admin/users/:id(.:format) admin/users#show PATCH /admin/users/:id(.:format) admin/users#update PUT /admin/users/:id(.:format) admin/users#update DELETE /admin/users/:id(.:format) admin/users#destroy また、ActiveAdmin は動的にルーティングを生成するため、 /app/admin/* ディレクト リ以下のファイルは Rails 起動時に読み込まれ評価されます。そのため、 app/admin/users.rb に対応するテストを1つも書かなくても app/admin/users.rb の Code Line Coverage は 100% になります。 もちろんこれは極端な例で、普段は一覧の要素を変更するなど実際にアクセスしないとカバーできないコードが生まれるため Code Line Coverage が 100% になるケースは少ないです。だとしても対応するテストがないのに 50%以上のファイルがいくつかあったりと全体的に高く出過ぎているように感じました。 ActiveAdmin は高機能な DSL によって数行でいくつもの画面を生成できます。そのため、コード量の機能量が比例しません。よって、ActiveAdmin による管理画面の実装において Code Line Coverage は指標として不適切だろうと判断しました。 指標その2:Endpoint Coverage タイミーでは一番外側のテストとして system spec は書かず request spec を書いています。エンドポイントに対応したテストが1つ以上あれば一定動作は保証されているだろうと考え、 /admin/* のエンドポイントに対して request spec が何割カバーできているかを指標として改善をしていくことにしました。 具体例 GET /admin/users テスト🙆‍♂️ GET /admin/users/:id テスト🙆‍♂️ POST /admin/users テスト🙅‍♂️ ↓ ↓ ↓ ↓ ↓ ↓ テストされたルート数: 2 テストされていないルート数: 1 全ルート数: 3 カバレッジ: 66.67% ここでは Endpoint Coverage と呼称することにします。(正式名称あれば教えてください。訂正します。) なるべくシンプルな方法で Endpoint Coverage を集計することにしました。ステップは以下です。 rails routes 相当の情報を取得 spec/requests/* ファイルを読み込みルーティングに対応する describe 句を抜き出す(describe 句があればテストケースは1つ以上あるだろうと判断) 1.と2.で得られたデータを組み合わせて、テストされているエンドポイント・テストされていないエンドポイントを分類し、割合を算出する 集計用の Rake タスクは以下になります。興味があれば見てみてください。 エンドポイントごとのテストカバレッジを計測するための rake タスク · GitHub 上記の Rake タスクをタイミーの Rails アプリケーションで実行すると以下の結果が得られました。 root@084b7fc12fd0:/usr/src/app# bundle exec rake endpoint_coverage:collect_for_admin テストされたルート数: 165 テストされていないルート数: 367 全ルート数: 504 カバレッジ: 32.74% root@084b7fc12fd0:/usr/src/app# bundle exec rake endpoint_coverage:collect テストされたルート数: 457 テストされていないルート数: 445 全ルート数: 827 カバレッジ: 55.26% 確かに admin は カバレッジ が 32.74% と高くないことが分かります。体感としてもそのくらいです。 次に全体の カバレッジ は 55.26% でした。こちらの結果には admin も含まれているため、admin を抜くと 78.9% です。 上記の結果より、admin とそれ以外とでは2倍以上の開きがあることが分かりました。これならテストの網羅性が低いという判断も納得できます。ということで、Endpoint Coverage を指標として用い社内向け管理画面のテストの網羅性を高めていくことにしました。 また、「社内管理画面の手動での動作確認では機能の60%もカバーできていないだろう」ということで Endpoint Coverage は 60% を目標として進めていくことにしました。 改善その1:index actionのテストめっちゃ書く Endpoint Coverage の集計 Rake タスクの副産物として、テストが書かれていないエンドポイント一覧を入手しました。これを元にテストの拡充を進めていきたいと思います。 まずは、index action に対応する request spec を書いていくことにしました。テストケースとしては 200 を返すことを検証します。 この判断をした理由は以下の通りです。 index action は一覧を取得するためのエンドポイントであり、多くの利用者は一覧画面を起点にして操作を行うため比較的重要度が高い index action の 200 を返すテストはある程度 機械的 に追加できるため、テストを追加するのが楽 spec ファイルが元々あるのとないのとではテストを追加する際の 心理的 ハードルが異なるため、とりあえずファイルだけでも作っておくことで他のエンドポイントのテスト追加がされやすくなるのではという期待 というわけでテストを書き始めていると嬉しい誤算に気付きました。それは GitHub Copilot がかなり補完してくれることです。 # frozen_string_literal: true require ' rails_helper ' RSpec .describe ' Admin::User ' do describe ' GET /admin/users ' do # ここまで書くと... end end # frozen_string_literal: true require ' rails_helper ' RSpec .describe ' Admin::User ' do describe ' GET /admin/users ' do # 以下を全て補完してくれる! subject { get admin_users_path } context ' 正常系 ' do before do create_list( :user , 2 ) end it ' 200を返す ' do subject expect(response).to have_http_status( :ok ) end end end end テストが書かれていないエンドポイント一覧は手元にあるため、ほとんどエンドポイントをコピペするだけで index action に対応する request spec を増やしていくことができました。 その結果、以下のように 32.74% → 41.47% まで カバレッジ を伸ばすことができました 🎉 root@084b7fc12fd0:/usr/src/app# bundle exec rake endpoint_coverage:collect_for_admin テストされたルート数: 209 テストされていないルート数: 326 全ルート数: 504 カバレッジ: 41.47% 改善その2:使われていない batch_action の削除 テストが書かれていないエンドポイント一覧を眺めていると、多くのリソースに対して batch_action エンドポイントが生えていることが分かりました。これは一覧画面で各リソースにチェックを入れて一括で削除するといった処理を行うために ActiveAdmin が用意しているものになります。 Active Admin | The administration framework for Ruby on Rails batch_action をデフォルトで有効にするかどうかは設定で変更可能なのですが、タイミーでは有効になっているようでした。 「一括操作ってそんなに行うことある?」と思い過去1年間のログを確認したところ、batch_action が使われているリソースは1つしかありませんでした。そのため、全体では無効化し使われているリソースにのみ batch_action を有効化しました。 これまで定義されていた batch_action は気付かず定義されていたのか、開発者による善意のものだったのかは判断できませんが Code Line Coverage では気付くことが難しく Endpoint Coverage を見ていたからこそ気付けたものかなと思っています。 その結果、以下のように 41.47% → 48.98% まで カバレッジ を伸ばすことができました 🎉 root@084b7fc12fd0:/usr/src/app# bundle exec rake endpoint_coverage:collect_for_admin テストされたルート数: 217 テストされていないルート数: 257 全ルート数: 443 カバレッジ: 48.98% 目標としていた Endpoint Coverage 60% にはまだ届いていませんが、元々が 32.74% だったことを考えるとかなり近づいたのではと思っています。 まとめ Rails アップグレードの手間を減らすために社内向け管理画面のテストを拡充させようと考え、Endpoint Coverage という指標を定義しました。Endpoint Coverage を改善するためにいくつかの改善を行い、約30%から約50%と目標の60%に近づけることができました。Endpoint Coverage を定義し、テストされていないエンドポイント一覧がわかったことで様々な改善ア イデア が思いついたように感じます。 これからも Endpoint Coverage を高めていき Rails 7.2 アップグレードでは手動での動作確認が必要なくなるよう頑張っていこうと思います。
アバター
こんにちは、データ統括部でデータアナリストをしているyuzukaです。 今回は、データ統括部のアナリストを対象に実施した「ストレングスファインダー共有会」について紹介します。 ストレングスファインダーとは? ストレングスファインダー とは、米国のギャラップ社が開発した「強みの診断」ツールです。 WEB上で177個の質問に答えると、34の資質の中から自分の強みや資質を知ることができます。 診断は有料ですが、弊社では会社の経費で受けられます。(ありがたい・・・!) 私の強みTOP10はこんな感じでした。 ストレングスファインダーに着目した背景 個人の強みに着目するコンセプト 職場での性格診断に適したツールとして、MBTIやDISCなど有名どころはいくつかありますが、中でもストレングスファインダーは個人の「強み」にフォーカスしているところが良いと感じました。 ただ相互理解を深めるだけでなく、それぞれの強みを仕事に活かしていこう、という前向きな気持ちになることができます。 実際にストレングスファインダー共有会で利用した資料 実際に取り組んだこと 事前準備 ストレングスファインダー共有会の開催にあたり、準備した内容は以下の通りです。 ストレングスファインダーのアクセスコードを、人数分まとめて購入して配布する 参加者にはあらかじめ診断を受けてもらい、診断結果とひとこと所感を記入しておいてもらう 参加者に負担がかからないかつ、当日の進行がスムーズになるようにロジ周りを意識しました。 ストレングスファインダー共有会の内容 オンラインで集まり、和気あいあいとした雰囲気で強みの共有会を行いました。 ルーレットでランダムに指名して、1人ずつ自分の強みを発表 事前に記入してもらった診断結果と所感を見ながら、4-5分話してもらう みんなでわいわい、感想や質問を言い合う これを人数分繰り返す形式で実施しました。 当日は想定していた以上に盛り上がり、時間があっという間に過ぎ去りました。 「新しいことを始めるのが得意」「学習欲が強い」「目標に向けて、人一倍努力できる」「ゲームのオリジナルルールを考案するのが得意」など、参加者それぞれのユニークな強みを知ることができました。 おわりに ストレングスファインダー共有会の開催からしばらく経ちますが、 通常業務のコミュニケーションの中でも、お互いの強みに言及する機会が増えた ように思います。 データ統括部は今後も積極的にメンバーを増やしていく予定なので、ストレングスファインダー共有会も、メンバーが増えたタイミングでの定期開催を考えています。 We’re Hiring! タイミーのデータ統括部では、私たちとともに働くメンバーを募集しています。 product-recruit.timee.co.jp
アバター
この記事は "Timee Advent Calendar 2023" の2日目の記事です。 qiita.com こんにちは、株式会社タイミーの土川 ( @tvtg_24 ) です。 先日、マネー フォワ ードさんとアナリティクスエンジニアリング周りの合同勉強会を開催しました! 自分が所属するデータ統括部では初めての試みだったんですが、とても良い会になったと思うので少しこの記事で紹介できたらと思います! きっかけ マネー フォワ ードの木宮さん ( @yuu_kimy ) と自分は定期的にご飯に行っていて、いつもお互いのデータ関連の話をたくさんさせていただいてました! そんな中、もっと社内のメンバー巻き込んで情報交換したいね、みたいなお話をしていたところ、ちょうどRettyさんとマネー フォワ ードさんで合同勉強会を開いたとのことでしたので、それを参考に合同勉強会を実施しました。 木宮さんには、当日の アジェンダ 作成など合同勉強会実施のための諸々準備をしていただいて、とても感謝してます! 当日の様子 当日の アジェンダ アジェンダ は以下のようになっていて、発表は各社2つずつ行い、最後に時間が許す限りフリーディスカッションでdbtについて話しました。 会自体はオンラインで行い、お互いの会社から計10名以上が参加する賑やかな会になりました! アジェンダ 発表振り返り 発表はタイミー側から 大越さんと、okodoon ( @miburo_data ) が、それぞれ「データアナリストがdbtを触ってみた」、「dbtジョブ分割実行について色々考えた話」というテーマで発表しました。 マネフォさん側からは、奥野さん ( @RossOkuno )と木宮さんが、それぞれ「マルチテナント分析基盤について」、「dbtをAirflowで動かす道のりは続く」というテーマで発表しました。 発表のダイジェストについてはマネフォさん側の記事 ( https://note.com/yuu_kimy/n/n236c5d5047ad ) で紹介されているので、詳しくはそちらをご覧ください。 マネフォさんとタイミーは使っているデータ基盤サービスは似ているんですが、プロダクトの数や、事業規模が違ったりするため、複数組織でのデータ分析環境の提供についてのノウハウや、苦労したところなどがたくさん聞けて、すごく勉強になりました。また、Cosmosといったタイミーが使っていない新しめの python パッケージについての利用感が聞けたのはとてもよかったです。タイミーでも今後の参考にしていきたいと思いました。 dbt活用に関するフリーディスカッションについて フリーディスカッション 会の最後には、dbt活用に関するフリーディスカッションを1時間ほどしました。 発表自体で消化できなかった質問や、dbt Coalesce で話題になっていたdbt Cloud CLI などを使った、dbt Core, dbt Cloudの使い分けについてなど、様々な議論をしました。 また普段あまりオープンな発表では公開しづらい、各々のデータ モデリング の詳細などについて話せたのはとてもよかったと思います。 終わりに 今回は、初めての合同勉強会でしたが、とても楽しく、有意義な時間になりました。 会社ぐるみで一つのテーマに対して、深く議論する機会は普段なかなかないのと、やはりオープンな場より、一歩踏み込んだ情報をもとに議論ができることが個人的にとても良いなと思いました。 また、より良いデータ分析環境を提供するという同じ目標を掲げて試行錯誤しているマネフォさんをみて、タイミーも追いつけるように頑張らないと!という気持ちになりました! これからも合同勉強会のような機会は増やしていけたらなと思っているので、興味のある会社さんはぜひX (旧Tiwtter) でお声かけください! 採用関連 タイミーは絶賛採用中です!ぜひお力を貸してください! hrmos.co
アバター
はじめに こんにちは、マッチング領域でバックエンドエンジニアをしているぽこひで (  @pokohide  ) です。 タイミーの アドベントカレンダー 2日目の記事です。 今回は、タイミーのプロダクト組織で毎週開催している 技術的な雑談を行うテック トーク の紹介をします。なぜ開催しようと考えたか、どのように運用をしているかなどをお話しします。 はじめに 開催の背景 毎週ゆるく開催するテックトークについて テックトークの仕組み化 会の説明や目的の共有 WINの共有 ポストモーテムの学び共有 雑談タイム やってみて さいごに 開催の背景 タイミーのプロダクト組織では、働き方の柔軟性を担保する観点などから フルリモートという働き方を選択 しています。また、タイミーではチーム トポロジー を採用しており、それに沿ってチーム構成などを考えています。 チーム トポロジー の変遷や取り組みについてはCTOとCPO(発表当時はVPoT)が過去に発表を行っているので詳しく知りたい方はこちらをご覧ください。 チームトポロジー Vol. 2 「組織をチームトポロジーで振り返るメリット」タイミー 亀田 彗 | ITエンジニア向けのトレンド情報 組織をスケールさせるための Four Keys とチームトポロジー - Speaker Deck チーム トポロジー によって、チームが 疎結合 になることでコミュニケーションの複雑性が抑えられたりチームの柔軟性が増すなど利点はありますが、インフォーマルコミュニケーション(業務外の日常的な会話や雑談)が減り仲間意識が薄れる可能性もあります。さらに、フルリモートはこの流れを助長する可能性があります。 疎結合 性を確保しつつも適度なコミュニケーションや協力を促進するバランスが重要と考え、テック トーク という会を開催 *1 しようと考えました。 2023年9月に発売された「GitLabに学ぶ 世界最先端のリモート組織のつくりかたドキュメントの活用でオフィスなしでも最大の成果を出すグローバル企業のしくみ」でもリモート組織を作る観点でですが、インフォーマルコミュニケーションが従業員のパフォーマンス向上や メンタルヘルス の問題解決に重要な役割を果たすという研究結果が紹介されています。 GitLabのカルチャーは、「GitLab Value 」「仲間意識(信頼と友情)」「ワークスタイル」という3つの要素で構築されており、仲間意識を醸成するためにインフォーマルコミュニケーション(業務外の日常的な会話、雑談、何気ないやり取り)が「意図的」に設計されています。 オフィスなしのオールリモートで成長するGitLab社、世界中の2,000人をつなぐカルチャーとは|CodeZine(コードジン) から引用 と、それっぽい理由を書いていますが、他のチームで起きた技術的に面白い事やその時々の興味深いテックニュースなどをゆるく駄弁りたいという個人的な欲求が発端というのもあります。 毎週ゆるく開催するテック トーク について 2023年2月から毎週金曜日に夕方から最長1時間枠で開催している技術的な雑談を行う会です。 SlackのHuddleを利用しており、会話から抜ける時の確認は不要で入退室は自由です。 テック トーク 技術的な話題である事のみが制約で、あとは雑談でもLTの練習場でも何でも自由に使って良い時間としています。 話題は持ち寄り制で、最近の技術ネタや面白いPRなどがあれば自由に書いてもらう形式で運用しています。何もなければその場の雰囲気で会話したり、早めに会を終わらせています。 とはいえ、持ち寄るのも大変なので会の冒頭10分に、1週間にあった技術的なWINやポストモーテムの学びを共有する時間を設けています。ここからさらに深掘りたいものがあれば話題に移して話しましょうといった流れです。 テック トーク の仕組み化 テック トーク を開催するにあたって、参加者に満足してもらいたいのは勿論ですが、それ以前に 継続的に開催する事 が大切と判断し、そちらを重視しました。 そこで運用コストを最小化するためにも内容をシンプルにし、仕組み化にもこだわっています。 開催するからには気軽に参加してもらいたいのでSlackbotのリマインダーを活用しつつ、Slack Huddle上で開催しています。当初は Google Meetを使っていましたが、カレンダーに招待されていないと暗黙的でクローズドな会と誤解されると思ったからです。 時間配分や会の内訳はシンプルです。 会の説明や目的の共有 WINの共有 ポストモーテムの学び共有 雑談タイム 以上です。前半の共有は経験則的に長くとも10分程度で終わります。タイムキーパーを用意していません。あとは適当に駄弁るだけです。 会の説明や目的の共有 会によっては、初めて参加してくれる方もいるので「この会は、テックな話題である事以外は特に制約はなく、入退室も自由なゆるい会ですよ」とお伝えしています。 WINの共有 タイミーのプロダクト組織では、技術的に面白い取り組みやドキュメントの更新、エラー対応、パフォーマンス改善など文字通り WIN! と感じたものに :win: スタンプをつける文化があります。そこで、 :win: スタンプが付いた投稿を特定のチャンネルに集約して、テック トーク で触れやすくしています。 ここでは、WINを共有するだけに留め、パフォーマンス改善などその背景にあったツラミをもう少し深掘りたい場合は、後続の雑談タイムで話すようにしています。 余談ですが、 :win: というスタンプはあまり汎用的すぎる名前で他の部署がつけたWINも収集してしまい収集チャンネルがノイ ジー になっていました。そこで、 メンショングループXに所属するユーザーがスタンプYをつけた時にチャンネルZに通知するSlack BOT を作りました。 この BOT については、またどこかで紹介できればと思います。 この辺も今後はNotionに自動で転記するなどより自動化していきたいですね。 ポストモーテムの学び共有 ポストモーテムをNotionのDBで管理しているので、その週に開催されたポストモーテムだけを表示するようにフィルタリング条件を調整し、準備不要で触れられるようにしています。 ここでも時系列を振り返るのは時間をとりすぎるので、影響範囲や原因、学びのまとめのみを共有しています。 雑談タイム この時間に話す話題をDBの1レコードとして管理しています。最近は参加者も話題も増えたため、気になる人は投票してもらう制度を導入し、どの話題から触れていくかを決めています。 議事録は話題レコードの中に雑に記載しています。話題をDB管理しているのは、テック トーク での雑談からTechBlogや登壇のネタになると嬉しいな考えているからです。これも特に目的として明文化してませんが、この仕組みが機能していく事に乞うご期待です。 Rails 7.1に上げるための残作業 論理削除のつかいどころ テク ノロ ジー レーダー9月号を眺めてみる モジュラ モノリス におけるパッケージ間のDB トランザクション の扱い 技術検証を活性化するためにはどうすればいいか 例ですが、過去にはこういった話題が話されていました やってみて 2023年2月から開催し、開催回数は30回を超えました。 準備も特に必要なく、自分もただの一参加者気分で参加できているので気負わず継続できているのも良いことです。当初考えていた技術的な話題を駄弁りたい欲求も満たされ、他のチームメンバーとも交流ができています。 自分だけ述べても公平性に欠けるので、参加してくださっているみなさんのコメントも欲しかったのですが、 アドベントカレンダー の締切間近すぎて間に合いませんでした。なので、これは自分一人の勝手な妄想かもしれないですが、最近は常に6人以上は集まってくれているので客観的に見て満足はしてくれているのでしょう…(そう信じたい笑) さいごに 今回は技術的な雑談を行うテック トーク の紹介や、継続的に開催するためにどのように仕組み化しているかをお話ししました。 テック トーク の参加者は増えてきています。嬉しい悲鳴ですが、参加者が増えすぎるとこの会の主目的である 雑談 が難しくなります。それは今後の課題なので、テック トーク の フランチャイズ 化やランダムに参加者を分割して ブレイクアウト ルームのようにするなど色々試していければと思います。 また、ここで話された話題からテックブログのネタなどが生まれる事を個人的に期待しているのでそちらも乞うご期待ください。 この活動について、より詳しく知りたいと思った方はカジュアル面談受け付けておりますので、是非お話ししましょう!過去の話題や議事録は残っています笑 product-recruit.timee.co.jp *1 : 定例は増やせばいいという訳ではなく適切に開催することが大切です。過去には、技術改善の文化を根付かせるためにプロダクトミライ会議という技術的負債を扱い、どのように解消していくか話し合う会議体を設けていましたが、技術改善の文化が根付いてきたこともあり、テック トーク 開催と並行して現在はクローズしています。
アバター
この記事は Timee Advent Calendar 2023 シリーズ 1の1日目の記事です。 はじめに こんにちは、タイミーでバックエンドエンジニアをしている須貝( @sugaishun )です。昨年は弊社で アドベントカレンダー に取り組んだか覚えていないのですが、今年はなぜかいきなり3トラックで臨むということで、非常に勢いがあるなと思いました。量と勢いで攻めていくところが弊社らしいなと感じています。全て完走できると良いですね。 さて私はその中のひとつのトップバッターということで、タイミーの Rails アプリケーションについて弊社のシニアなエンジニアたちと雑談した内容を座談会風にお伝えできればと思います。事の発端は弊社Slackのバックエンドエンジニアが集まるチャンネルで「タイミーの Rails アプリケーションの健康度はどのくらいなのか?」という会話をしたことでした。その時の私の感想は「人によってけっこう基準が違うなあ」といったものでしたが、改めて話を聞いてみると自分の中で発見がありました。参加者は以下のとおりです。 参加者プロフィール 難波さん( @kyo_nanba ) シニアバックエンドエンジニア ファッションEC系スタートアップの開発責任者やメドピア株式会社の GM などを経て2022年にタイミーに入社。プラットフォームチームの立ち上げやフィーチャーチームとのコミュニケーション設計に取り組み、直近では会社の技術戦略と現場の技術的ロードマップを接続する役割を担う傍ら Rails アプリケーションの設計やリ アーキテクチャ に関わっている。 神速さん( @sinsoku_listy ) バックエンドエンジニア 2022年11月入社。CTO室に所属し、 Rails アプリケーションの開発効率を向上するために型( RBS )の導入やCIの実行速度の改善などの施策に取り組んでいる。 須貝( @sugaishun ) バックエンドエンジニア 2022年1月入社。スポットワークシステム領域でエンジニアとして活動。 Ruby on Rails ChapterのChapter Leadも務める。Leadとついているが偉いわけではない。好きな言葉は「容赦ない リファクタリング 」 では、ここからが本編です。 タイミーの Rails アプリは60点 須貝:以前Slackでタイミーの Rails アプリケーションが健康かどうかという話をしたと思うんですけど、ここでは健康 = アプリケーションの保守性が高い状態ということにして話をしていきたいと考えています。難波さんはけっこう厳し目の評価をしていたと思うんですけど、主観で構わないので今のタイミーの Rails アプリケーションを100点満点で点数をつけるなら何点くらいなんですか? 難波: 60点 くらいですかね。 須貝:やっぱりけっこう厳しい。 神速: 高専 だと 赤点ギリギリ ですね。 難波:不可ではない、という感じです。入った当初の印象は60点くらいでこの1年で色々取り組んできて65点になったかな、というイメージです。タイミーはプロダクトとしては成長できているし、頻繁に障害が起きているわけでもない。世の中に価値を提供できているのでその点では合格だとは思います。 ただ、ここから上を目指していくためには、まだ我々は合格ラインのギリギリ上くらいなんだぞ、と。そういう想いも込めてこの点数にしています。 須貝:具体的にどの辺りが伸びしろだと考えているんですか? 難波:ひとつは Sentryのアラート ですね。僕が考えるヘルシーなプロダクト組織だったら基本的にはSentryのアラートは鳴らないし、鳴ったらみんながそれを直すべき対象だと思ってできるだけ直す。で、直したらちゃんとresolveして、というのができるようになったらもっと点数が上がるかなと思います。 もうひとつは スロークエリが管理できていない 。スロークエリが出ているということはユーザー体験に直結することなので、 バッチ処理 などであればまた話は別ですけど、現在はきちんと管理できていないのは課題ですね。 最後は 自動テストの信頼性 です。テストが全部パスしているということは本番にデプロイしても大丈夫ですよねという状態を期待しているんですが、テストがパスしているから大丈夫とはあまり思えない。 須貝:たしかにテストの信頼性は僕も課題に感じています。テスト カバレッジ 自体は低くはない *1 んですけど、仕様の カバレッジ が高いかというとちょっと不安なんですよね。 反対に良い点は何でしょうか? 難波:システムが モノリス でやっていけているところは良いところだと思います。マイクロサービスと比べると モノリス *2 のほうが保守性が高いと思っているので。 あとはアプリケーションの保守性に直結するわけではないんですが、CIが速いことでしょうか。CIが速いイコール何かあったらすぐわかるので色々なトライもしやすい。結果的に保守性に寄与する良いところだと思います。 須貝:CIは今何分くらいでしたっけ? 神速:デプロイまで含めると12〜13分くらいですかね。CIだけで見ると8、9分くらいだと思います。 難波:最後にもうひとつ、 github -flowで運用できていることも良い点だと思います。いわゆるgit-flowのようにreleaseブランチ的なものを用意して、QAしてmainブランチにマージしてデプロイというのもそれはそれで良いやり方だとは思います。ただ、mainブランチにマージしたら即デプロイという github -flowはコードの品質をみんなが信頼していないとできないことだと思います。 カナリア リリースが普通、みたいなチームを目指したい 須貝:神速さんは100点満点で点数をつけるなら何点くらいですか? 神速:45とか50点ですかね。 須貝:だいぶ低いですね(笑) 神速:自分の中での100点となると GitHub 社みたいにバンバン本番にデプロイして、 カナリア リリースも当たり前で、みたいな組織です。さらにテストコードも Rails 本体の リポジトリ と同じレベルのクオリティで書く。それが私の中の理想で、それと比べたら今は45点くらいかなと。 須貝:デプロイの話が出てくるあたりが面白いですね。神速さんらしいといいますか。 神速:これは私がインフラもやっているからとかではなく、 顧客へ価値をいかに早く届けるか ですとか、障害が起きた時もいかに早く直すかというのを大事にしていて、デプロイが大事だと思っているのが根底にあります。 須貝:神速さんはどういう状態が健康と考えていますか? 神速:健康さでいうとまず、 Ruby と Rails の最新のバージョンを使うことです。あとはこれに付随して、 Ruby と Rails を最新のバージョンにするためにgemのバージョンを上げ続ける運用があるか。dependabotの整備などもこれにあたります。 あとはテストコードがあるとかレビューをしている、とかでしょうか。難波さんに比べてだいぶ基準が低いんですけど(笑) 須貝:これは過去に経験してきた現場によって基準は変わってきそうですよね。テストを書いているとかレビューしているとか、自分は当たり前だろうと思うんですけど勉強会などで他社のエンジニアと話すと「テストないです」「レビューもないです」というのは普通に耳にします。 神速:ただそこを基準にするよりは もっと上を見たい ですね。 カナリア リリースが普通、みたいなチームを目指したい。 須貝:タイミーの良いところは? 神速:難波さんが挙げてくださった以外だと、 CIでRailsのedgeを使ってテストを走らせている のはレアなのでそこは良いところです。それに付随して弊社のエンジニアが Rails の最新機能を教えてくれる、みたいな福利厚生がある。 後は RBS のような最新機能を導入することにみんなポジティブで、止める人がいない。 やってみようという空気がある のは良い点です。 須貝:やっていきのある人が多いですよね。だから一回やってみましょうという文化がある。逆に伸びしろは? 神速:さっきデプロイ時間の話がありましたけど、私はもっと速くしたい。今はデプロイまでの時間が長いと思っています。 Rails アプリの話でいうと、難波さんと同じでテストの書き方とか保守性はもうちょっと考えたいですね。バグがあったらバグを再現するテストを書くとか。機能を追加する時も既存のテストに手を入れるのではなく、テストを追加して カバレッジ を高めるような考え方は広まってほしいとは思います。 須貝:パフォーマンスの自動テストはちょっと難しそうですね。 神速:でもたとえばコミットログに残すとかはできそうですけどね。パフォーマンスのテストは増やさないにしても、 Rails 本体でもパフォーマンスを計測してコミットメッセージに残したり、 SQL のEXPLAINの内容を貼ったりとできることはあります。「私が速くなったと思うから」ではなくて根拠はほしい。 他の伸びしろを挙げるならレビューの仕方ですかね。「良さそう」でapproveではなく、パフォーマンスや保守性まで考慮したレビューがほしい。それを気にする人が増えてほしいです。ただ良いレビューをする方法を私が教育したいかというとそれは難しいな、という気持ちがあります。 難波:その気持ちはよくわかります(笑)。 須貝:課題感はあるけど自分が率先してやりたいわけではないという。そういったものは Rails に限らず社内にはいくらでもありますよね。 ここまででタイミーでの開発の良いところや伸びしろについて語ってきましたが将来的にこうなっていてほしいというイメージを伺えますか? 難波:なかなかすぐに実現するのは難しいと思うんですけど、 良い設計のシステムにしていきたい ですね。例えば RSpec のcontextがわかりやすいんですけど、contextが多重に 入れ子 になっている時はテストコードの問題というよりはテスト対象クラスの複雑性の問題なんですよね。責務が適切に分解されていないのがテストコードに表出してしまうという。そういうことが起きないようなアプリケーション設計になっていってほしいなと思っています。 須貝:頑張ります。だいたいChapter Leadの僕に返ってくる話ですね。 神速:ただジュニアなエンジニアが頑張っても難しいところはあるので、できる人が教えてあげる環境は何かしら作らないといけないですよね。 難波:そうしたほうが良いというのはすごくわかります。それでいうと今タイミーでは各Squad(チーム)が独立して開発できることを良しとしていて、そこと先輩が教えて知識を伝搬していくことを両方満たそうとすると各Squadに最低一人はミドル〜シニアレベルのエンジニアがいる必要が出てくる。それはけっこう難しいですよね。 須貝:なのでシニアなエンジニアの方、ぜひ弊社に来てくださいという。ちょっとオチがついたっぽい感じになったので今回はこの辺にしておきましょうか。 おわりに いかがでしたでしょうか。弊社のエンジニアは基準が高いなと思いましたね。やっていくしかない。タイミーでの開発についてもっと知りたいという方、ぜひカジュアル面談でお話しましょう。 product-recruit.timee.co.jp 最後になりますが、執筆にご協力くださった難波さん、神速さん本当にありがとうございました。 *1 : 2023/11/27現在、ライン カバレッジ で91.5% *2 : タイミーでは現在モジュラ モノリス 化を進めている
アバター
こんにちは! shun です。私はタイミーのデータ統括部でデータ分析やLooker開発を担当しています。今回は、社内のLooker利用者へのユーザーインタビューを実施し、得られた知見についてお話しします。 背景と目的 データ統括部では、各組織がデータを元に意思決定の質やスピードを向上させビジネス インパク トを生むことを目指して、BIツールとしてLooker上のデータ探索環境を開発、提供しています。 Looker開発依頼の相談窓口の設置や利用者向けの講習会の実施、利用者数のモニタリング等をしていますが、Lookerの使い方や課題感やデータを使った意思決定へのハードルについて、実際の声を聞く機会がなかったため今回のユーザーインタビューを実施しました。 取り組みの全体の流れ 具体事例 ユーザーインタビューを通じて最も顕著な課題としてあがったのが、探索環境内のディメンジョンやメジャーがわかりづらく、意図したデータをスムーズに抽出することが難しいというものでした。 あらゆるディメンジョンやメジャーを開発してニーズへの網羅性を高めることの トレードオフ として、全体のわかりやすさを阻害している現状がわかりました。データ統括部のメンバーはデータへの知見が深く、わかりづらさに気付きづらいという側面もありました。 こちらの課題に対しては開発チームとして、文言を社内 ユビキタス に揃え検索性を上げる、よく使うであろうディメンジョンをまとめる、 ユースケース に沿ったテンプレの ダッシュ ボードを作成するなどの解決を進めている段階です。 工夫したこと インタビュー対象者の選定 Lookerの利用ログを活用して、平均的な利用頻度の利用者を見繕ってアポを取りました。今回の目的としては ボリュームゾーン の方の動向を知りたかったためです。バリバリ使いこなしていたり、ほとんど使っていない利用者だと想定していたインタビューができなかったはずなので、 定量 的な観点から意図した対象者を選定することは大切だと感じました。 実際にデータ抽出をしてもらう 画面共有してもらいながら実際に作業する様子を見せてもらうことで、利用者が詰まる ボトルネック が明確になりました。まさに百聞は一見に如かずな体験でした。 個々のインタビュー結果を持ち寄り、共有知をつくる インタビューをやって終わりではなく複数回実施した後、結果を持ち寄り課題の共通認識を作り、解決のア イデア をブレストする会を実施しました。そして、この場で出たア イデア を開発タスクとして起案し改善するというサイクルを回せました。 今後に向けて タイミーではPMMを中心にユーザー ヒアリ ングを行う風土があり、仕組みやナレッジが蓄積されていたおかげで、横展開を比較的スムーズにできLookerチームでもユーザーインタビューが実施できました。もっと詳しく知りたい方は下記ブログもご覧ください。今後も定期的なユーザーインタビューを実施し、利用者に寄り添ったLookerの環境の開発を進めていければと思っています。 tech.timee.co.jp We’re Hiring もっと具体的に話を聞いてみたい方、データアナリストやLooker開発について、興味を持って頂けた方がいらっしゃればお気軽にお話ししましょう!カジュアル面談からご連絡いただけるとうれしいです product-recruit.timee.co.jp
アバター
はじめに こんにちは! タイミーのデータアナリストのYoです。 今回は、社内で実施した「Looker講習会」を紹介させていただきます。 ご紹介する内容は以下になります。 Looker講習会について なぜLookerか Lookerとは、Google社が提供するBusiness Intelligenceツールです。 弊社では以下の観点から、社内のデータ利活用ツールとしてLookerを採用しています。 SQLを書くことなく、データが確認できる。 閲覧する人によって、利用するデータの定義に相違が生まれない。 一つのBIツールで様々な観点から、データの活用ができる。 なぜ講習会を実施したか 社内のデータ利活用ツールとしてLookerを採用した一方、Lookerを実際に利用できる人が少なく、またどのようなことができるかもわからないという状態になっていました。 そのため、主に以下を目的とし、講習会を実施しました。 新規Looker利用者の増加 既存Looker利用者のスキル底上げ 講習会の内容について 先述の通り、タイミーでは、Looker自体を実際に利用できる人が少なく、どのようなことができるかもわからないというメンバーが多い状態でした。 そのため、Lookerの編集権限を持つ営業組織のメンバーを対象として、以下のような内容で講習会を実施しました。 Lookerを利用する前に、テーブルや、Lookerで利用できるデータの構造について共有しました。 また、上記をベースとしてLookerの利用方法の説明と、ハンズオン形式での演習を実施しました。 実施後の反響について 50名弱の方に講習会へ参加をしていただき、アンケートを通して以下のような声をいただきました。 残課題 社内のデータ利活用に向けて、一定の効果があった一方で、残っている課題もあります。 1回限りの講習会の実施だったため、今後新しく入社した方へLookerが浸透しない。 基礎的な講習会だったため、応用的なLookerの使い方を網羅できていない。 上記の課題の解決に向けて、継続的な講習会の実施や新入社員のオンボーディングへの組み込み、レベル別の講習会実施などを検討していきたいと思います。 まとめ Looker講習会の紹介はいかがでしたでしょうか。 一般的に社内でのデータ利活用が進むと、データを元にした意思決定が広まっていく一方で、意思決定の基礎となるデータのガバナンスが取れないなどの問題が発生します。 その中で、データアナリスト側がコントロールできるBIツールの講習会を実施することで、データアナリストと社内の他のメンバーの両者にメリットがあります。 データアナリストとしては、単純なデータ抽出依頼ではなく、より高度な分析に注力できます。 また、社内メンバーとしては、データアナリストに依頼をすることなく、自身の意思決定に正確なデータを利用することが可能となります。 タイミーは事業、組織ともに拡大傾向にありまして、データアナリストが活躍する機会が多く存在しています。 これからもデータアナリスト発信で社内のデータ利活用に貢献できる仕組みを展開することで、事業の成長に貢献したいと考えています。 We’re Hiring! 私たち、データアナリストと一緒に働く仲間を募集しています。 まだ転職を考えていない方も、ぜひ一度、お話ししましょう! product-recruit.timee.co.jp 最後に、少しでもこの記事を「いいな」と思っていただけた方は、 SNSへシェアをお願いします!
アバター
タイミーの新谷、江田、酒井、正徳です。 Kaigi on Rails 2023 が10月27日、28日の2日間で開催されました。タイミーは去年に引き続きKaigi on Railsのスポンサーをさせていただきました。 また、タイミーには世界中で開催されている全ての技術カンファレンスに無制限で参加できる「Kaigi Pass」という制度があります。詳しくは以下をご覧ください。 productpr.timee.co.jp この制度を使ってオフラインでは4名のエンジニアが参加しました。 参加して聞いたセッションのうち印象に残ったいくつかをピックアップしてご紹介します。 生きた Rails アプリケーションへの Delegated Types の導入 docs.google.com Rails 6.1から導入されたDelegated Typeに関する発表でした。 デプロイ履歴を管理する機能において、AWSのECSやLambdaなどのサービスごとに固有で設定したいデータと、各デプロイ共通のデータ管理をする上で単一テーブル継承(Single Table Inheritance: STI)ではなくDelegated Typeが活用されていました。 タイミーでは一部のテーブル設計でSTIが活用されています。ただ、すべてのサブクラスのすべての属性を持つ単一のテーブルが作られ、nullが避けられない等のデメリットはよくある話だと思いまして、発表の中でも触れられていました。 一方Delegated Typeではスーパークラスの共通データ、サブクラスの固有データを格納するテーブルがそれぞれ作成されます。ポリモーフィック関連をベースにした委譲による実装がなされ、個別データを持つ側のクラスからアソシエーション(今回の発表では has_one)で辿れる仕組みでRails開発者としても直感的に使える仕組みだと感じました。 また各種Gem側での対応も順調に進められており、特にFactoryBotで詰まることなくデータ生成が出来るのは開発・運用の観点でも心理的ハードルが下がる話でした。 そもそもRails 6.1からActiveRecordに導入された機能で、まだ実際に稼働中のプロダクトでの実例もあまり聞いたことが無かったので具体例と共にその特徴を知ることができ、有益な時間になりました。実際に導入する際にはまたこの発表を参考にさせていただこうと思います。 (@edy2xx) 生きた Rails アプリケーションへの delegated types の導入 by mozamimy - Kaigi on Rails 2023 やさしいActiveRecordのDB接続のしくみ speakerdeck.com MySQLへの接続確立するまでのActiveRecord内部の動きを、findメソッドをデバッグ実行した時のスタックトレースを参考に、接続に関する主要なクラスの役割を紹介しながら、内部処理を学ぶセッションでした。 ActiveRecordを用いるとdatabase.ymlに必要な情報を載せるだけでDBにアクセスしデータ取得や操作を行うことができるためあまり意識をすることがありませんでした。しかし、このセッションを通して改めてどのようにDBへの接続を確立・管理するかや、クラスごとの責務を理解できた気がしました。 後半の知見でも触れられていた事ですが、この内部処理を理解することで、接続プールはDBの接続情報を保持したインスタンスを持つため(端折っています)、フェイルオーバーなどでDBホストの変更を行っても、変更以前の接続情報を用いてしまう可能性があります。そのような可能性があることを知っておく事は運用において大事ですし、そのような事態になった時や、そうならないための打ち手を常に持っておく事も重要です。 最後に軽く触れられていたバリデーションクエリの他にも、Railsの再起動等を通して再接続を試みたり、一時的にコネクションプールをやめて都度接続にしたり、クエリ実行時にエラーが発生した場合にエラー内容によってリトライをかけるなど色々あるので改めて理解を深めようと思いました。 (@pokohide) やさしいActiveRecordのDB接続のしくみ by kubo - Kaigi on Rails 2023 Simplicity on Rails - RDB, REST and Ruby speakerdeck.com 偶発的な複雑性が少ない状態をシンプルと定義した上でテーブル設計・API設計・クラス設計の観点でどんなことを意識すべきかを紹介するセッションでした。 テーブル設計:リソースエンティティだけでなくイベントエンティティを見出してhas_many through 関連を作ること。 API設計:テーブルとリソースは似ていることもあるが同じではないので、テーブルに対応しない Controller を作ることは問題ないので気にしなくていいこと。 クラス設計:Controller の create アクションは INSERT INTO 以上のことをしても良いこと。Controller が複雑になってきたと感じたら FormObject の利用を検討すること。 事業としてはイベントエンティティがコンバージョンポイント(お金に変換できるデータという理解をしました)であるという話は確かになと思いました。タイミーのコンバージョンポイントは働くワーカーさんが求人に申し込むマッチングなのですが、ここに対応するモデルは UserOffering という User モデルと Offering モデルの多対多を管理するリソースエンティティとしての命名になっています。様々なものに紐づいているモデルなので変更を加えるのは大変ですが、長期的には手を加えていきたいと思いました。 イベントエンティティをモデルとして表現するという話は texta.fm でも耳にしていて、今回の発表を聞いて更に理解を深めたいと思ったので texta.fm での話の元になっていたパーフェクト Ruby on Rails を読んでみようと思います。 (@euglena1215) Simplicity on Rails - RDB, REST and Ruby by MOROHASHI Kyosuke - Kaigi on Rails 2023 Railsの型ファイル自動生成における課題と解決 speakerdeck.com Railsアプリに型を導入するときに起きるいくつかの課題と、それらの課題に対する解決策を紹介するセッションでした。 課題: Railsアプリに手軽に型を導入したい -> 解決: orthoses-rails 課題: アプリケーションコードの型が無い -> 解決: rb prototype 課題: YARDを活用したい -> 解決: orthoses-yard 課題: YARDのシンタックスをチェックしたい -> 解決: rubocop-yard 弊社でも型(RBS)を活用しているため、課題に共感しながら聞いていました。 Orthoses は使用していませんが、いくつかの解決策は弊社の開発環境でも活用できそうなので、Orthoses への乗り換えも含めて検討・参考にしたいと思いました。 また、発表の後に3Fの休憩部屋(ROOM 0)で今後の型に関する話をすることができて、個人的に型に対するモチベーションが上がる1日になりました。 今後は型(RBS)に関するOSSにも少しずつコントリビュートしていきたいと思います。 (@sinsoku) Railsの型ファイル自動生成における課題と解決 by Yuki Kurihara - Kaigi on Rails 2023 管理機能アーキテクチャパターンの考察と実践 speakerdeck.com 後追いで作ることになりがちな管理機能を Frontend, Backend のソースコードを置き場を元にいくつかのアーキテクチャパターンに分類しそれぞれのpros/consをまとめたのちに、B/43ではどのアーキテクチャを選択したのかと選択した理由・振り返りを共有するセッションでした。 タイミーは ActiveAdmin を用いてモノリシックコードベースに管理機能を密結合させています。ActiveAdmin を使う上で色々と大変な面はあるのですが、なんだかんだ ActiveAdmin は便利ということで使い続けています。とはいえ、過去・現在に最適なアーキテクチャが未来も最適である保証はないので、今後の管理機能の在り方を考える上で非常に参考になりました。 また、管理機能のバックエンドとエンドユーザー向け機能のバックエンドを同じにするのも同意です。管理機能の中には本来は機能として提供すべきなものが提供されていないことから運用に負がたまって社内管理機能に機能追加の力学が働き、結果として管理機能が肥大化していくというのはあるあるだと思っています。バックエンドを分けると管理機能に閉じた変更の方が容易になり、管理機能の肥大化を加速させる要因になりうると感じました。 (@euglena1215) 管理機能アーキテクチャパターンの考察と実践 by ohbarye - Kaigi on Rails 2023 オフライン参加楽しかったですね。2日間で多くの企業のエンジニアとも交流が出来ました。 今年は抽選に外れたのでブース出展できなかったのですが、来年はブース出展したいと思います。来年はブースでまた会いましょう!
アバター
はじめに こんにちはokodoonです タイミーのデータ基盤に対してデータモデリングを始めてしばらく経ったので、現状の全体構成を紹介したいと思います 全体構成 弊社のBigQueryは以下の4層にレイヤリングされています それぞれの役割は以下のような切り分けになっています レイヤー名 役割 データレイク層 複数ソースシステムのデータを未加工の状態でBigQueryにロードする宛先 dbt snapshotによるソースの履歴化 ステージング層 複数ソースシステムのデータを共通した処理でクレンジングする層 DWH層 ソースシステムのデータ形式を分析に適した形に変換する層 ディメンショナルモデリング/ログテーブルをイベント単位に分割/その他便利テーブル作成 データマート層 特定用途に対して1:1で作成されたテーブル群を格納する層 ダッシュボード用テーブル/Looker用テーブル/GoogleSheets用テーブル/外部サービス連携用テーブル 図で表すとこんな感じです DWH層, データマート層の切り分けについて 「DWHとデータマートとは?どう切り分けるか?」と100人に聞いたら100通りの答えが返ってきそうなこちらの定義 弊社内ではデータ基盤チーム内で議論した結果、以下のような切り分けをしています - DWH: ステージングデータを分析ニーズに合わせて加工したもの - データマート: 特定ニーズに対して1:1で作成されたテーブル なので ディメンショナルモデリングなどの分析用データへ変換したテーブル クエリを発行する際に、結果の出力が重くなってしまうテーブルを軽量化したもの などがDWH層に格納されているテーブル群で 特定ダッシュボード用に作られたテーブル Looker探索環境用に作られたwide_table 外部サービス連携用に成形されたテーブル などが現在データマート層に格納されているテーブル群になります ※「ダッシュボードに1:1で対応するマートを作る方針なのかあ」と思われるかもですが、こちらは現在推進中のdbt exposureを用いたアウトプット管理を見据えているためです(またこの辺りは別の機会にアウトプットします!) 各層の説明を少しだけ深掘り データレイク層 この層でdbt snapshotを活用した履歴化を実行しています 下流のステージング層/DWH層などで履歴化をしていない理由としては、何かしらの加工がされたあとのデータを履歴化する場合、加工処理に何かしらのバグや想定漏れがあった場合のロールバックが大変なためです 分析に使用する場合は以降の層でクレンジング/モデリングされたデータのみでいいため、基本的に分析ユーザーはデータレイク層へのアクセス権限を保持していません ステージング層 以下のような処理を加えて、生データをクレンジングしています データ型の変更と統一 パーティショニング/クラスタリング データマスキング 品質担保のテスト uniqueテスト Nullテスト 外部キーテスト ユーザーが3NF形式のデータに対してクエリを書く場合は、品質が保証されたステージング層のテーブルに対してクエリを実行してもらうような運用にしています DWH層 DWH層内は以下のような区分が作られています DWH層内区分 役割 dbtのフォルダパス events ログテーブルを分析要件にあるイベントごとに分割整理して軽くしたテーブル群 models/dwh/events component ディメンショナルモデリングのデザインパターンに当てはまらないが便利な部品のようなテーブル群 models/dwh/component pre_dimension 同一属性単位にドメイン情報を結合 ドメイン要件に従ったフラグ生成などの複雑な事前処理 models/dwh/dimension_modeling/pre_dimension dimension SCD Type2カラムの生成( https://en.wikipedia.org/wiki/Slowly_changing_dimension#Type_2:_add_new_row ) サロゲートキーの生成 models/dwh/dimension_modeling/dimension fact ビジネス指標の作成 関連Dimensionのサロゲートキーの反映 models/dwh/dimension_modeling/fact conformed_fact 複数プロセスを跨いだよく使われるfactの組み合わせをconformed dimensionで結合 複数プロセスを跨いだビジネス指標の作成 models/dwh/dimension_modeling/conformed_fact 弊社Dimensional Modelingについて 弊社Dimensional Modelingについてざっくり解説します SCD Type 6の採用 dimensionテーブルは「履歴化されたテーブル(Type2)」と「最新情報を持つテーブル(Type1)」に分けることなく、SCD Type1~3の全てを一つのテーブルで管理する方針としています こうすることでFactに登録するサロゲートキーが少なくなり、分析時のクエリと思考がスリムになると考えています 採用しているFactテーブルのパターン Transaction Fact + Factless Factを接合したFactテーブル Transaction FactテーブルとFactless Factテーブルを別々に作るのではなく、ビジネスプロセス単位でまとめてくっつけています 例えば「買い物プロセス」を仮定した場合に、Transaction Factにあたる 合計売上 とFactless Factにあたる 決済回数 が同一の買い物Factテーブルにあるイメージです Snapshot Factテーブル Transaction FactやFactless Factではうまく表現できないような指標(etc. ユーザー数全体の推移, 銀行残高の推移, )を日,月単位の粒度でスナップショットして保持します 例えば日毎のユーザーdimensionのサロゲートキーを保持するようなSnapshot Factテーブルを作成することで、日単位のユーザー数の推移をDimensionテーブルで絞り込み可能な状態で追えるようになります FatなFactテーブル Factテーブルは分析ニーズのあるビジネスプロセス単位で作られると思いますが、そのビジネスプロセスで発生する指標を全て一つのFactテーブルで完結して出力できるような方針をとっています 買い物の決済プロセスのFactテーブルを仮定すると、 決済Factの決済金額 * 決済Dimensionの消費税率 = 消費税金額 みたいにFactとDimensionを組み合わせて新しい指標を出力するのではなく、決済Factテーブルに 決済金額 , 消費税金額 を二つとも持っておくような形です こうすることで「Factテーブルに定義されている指標をDimensionテーブルで絞り込むだけでいい」「Factテーブルにない指標があればFactテーブルに足せばいい」とクエリ作成時の思考が軽くなると考えています Factテーブル上に社内の指標を全て整理して保持している状態です データマート層 データマート層内区分 役割 dbtのフォルダパス wide_table ディメンショナルモデリング済みテーブルをJOIN済みの状態にしたテーブル群 Lookerの探索環境に1:1で対応している models/marts/wide_table dashboard LookerStudioダッシュボード, Spreadsheetのコネクテッドシートに1:1で対応しているテーブル群 models/marts/dashboard その他 他サービスに連携していたり、特定プロジェクトのために一時的に作られるテーブル群 models/marts/{{その他}} Lookerとの接続について LookerのUIを見てみると、dimensionとmeasureが選択できるような、ディメンショナルモデリングを意識したものになっています そこで弊社ではこの画像のようにLookerのデザインに乗っかる形で、Lookerの接続先をディメンショナルモデリング済みのwide_tableとしており、スター内のFactテーブルの指標をmeasureとして、Factに接続するDimensionテーブル内の要素をDimensionテーブルごとに分割して表示しています こうすることで 商品の生産国が中国の決済金額を決済方法ごとに出力したい というユーザーの思考があった時に 商品の生産国だから => 商品Dimensionの生産国を選択 決済方法だから => 決済Dimensionの決済方法を選択 出力したい指標は決済金額だから => Factの決済金額を選択 とLooker上のユーザーの行動に繋がると思っており、 ユーザーの思考 , 実際に走るクエリ , Lookerの探索環境のUI ができるだけ一致するようなデザインになっています こうすることでLookerへのフィードバックがモデリングをより便利にすることに繋がり モデリングへのフィードバックがLookerをより便利にすることに繋がります ダッシュボード用 / スプレッドシート用のデータマートについて 基本的にマートはView形式で作成します ( 補足:Viewテーブルとは ) Viewとして作成することで無駄なビルドやコスト増加を避ける方針です マート化する判断基準は「正確性と安全性を保守すべき対象かどうか」であり、チーム内で定めた基準に従ってマート化を実施してアウトプットをコード管理します ダッシュボード用 / スプレッドシート用のデータマートをdbtで作成するのでdbt上でリネージュ管理が行えるようになり、破壊的な変更が上流で発生した場合に保守対象のダッシュボードへの影響を抑えることができます 作成したマートに対応するdbt exposureを宣言することでデータオーナー管理やアウトプット先管理も行えるようにする予定です 将来構想 数ヶ月以内に実現したい将来 dbt exposureによるオーナーを含めたアウトプットの管理 主要ビジネスプロセスの全モデル化 1年以内とかにはやりたい将来 サンドボックス層の作成 ポリシータグを用いた、メンバーのアクセス可能情報レベルに応じたクエリ範囲の制限 まとめ・今後の課題 弊社は組織拡大のスピードがとても早く、データ活用者数もとても多いので クエリ作成者間で出力される指標値がブレる。ダッシュボードやLookerの値とアドホッククエリの値がブレる。そもそも定義がブレている などの問題を防ぎつつ 簡便にあらゆる指標値がクエリできる ように、ディメンショナルモデリングを主軸としたデータモデリングを推進してきました 現在は作成したモデル数はかなりのものになり、それに従ってLooker環境もリッチになってきたのですが、アナリストが作るアドホッククエリやダッシュボード作成クエリにまだまだ採用してもらえていない課題感があります モデリング済みテーブルを使ってクエリを書くのはDWH開発に参加してくれているアナリストメンバーに集中していて、他アナリストメンバーにモデリングなどの技術的内容とDWHの開発内容などをうまく同期できていない状態です モデリング済みテーブルを使用することで 指標が全社定義と常に一致する Dimensionの履歴を考慮した指標出力が可能になる クエリ文量が1/5とかになる クエリパフォーマンスが向上するケースもある などメリットも大きいと思うので、モデリング手法の理解を助けるアウトプットやワークショップなど様々なHowを通して、アドホックなクエリに採用してもらってもっとフィードバックのサイクルが高速化するようにしていきたいです! We’re Hiring タイミーのデータ統括部では、ともに働くメンバーを募集しています!! product-recruit.timee.co.jp
アバター
イベント概要 2023年8月2日に「What’s “Next” JS Meetup」と題してバベル社のuhyoさんをお招きしてNext.jsに関する勉強会を開催しました。 その中でタイミーフロントエンドエンジニアのいーふとさん(@redshoga)の講演をイベントレポートにまとめてお届けします。 今回のスピーカー Server Actionsの概要 ※ 2023年8月2日のイベント開催時点での情報であり、将来的に変わる、変わっている可能性があります。 Server Actionsはざっくり説明するとサーバーで動作するコードをあたかもクライアント上に記述できる機能です。コード下部のLikeボタンがクリックされると、propsに渡されているincreateが呼び出され、その中でPrismaのclientが動作します。 クライアントで動作 レンダリング結果 サーバーで動作 送信データ 一つのファイルだけでなく別ファイルで書くことも可能で左側がクライアントで動作、右側がサーバーで動作します。それぞれに”use client” “use server”というディレクティブを書く必要があります。 レンダリング結果を見ると普通のフォームとして動作することがわかります。右側の”createPost” というのが普通のTypeScriptをインポートしているにも関わらず通常のformとして動いてくれる不思議な機能です。 送信データとしては普通のフォームデータです。内部ではACTION_IDがよしなに付与され、良い感じに動作します。 既存のフルスタックTSのアプローチ ここまでがServer Actionsを踏まえた未来のTypeScriptをイメージした内容だったのですが、現状のフルスタックTypeScriptにはどんなアプローチがあるのかを見ていきます。 よくあるのがスキーマの活用です。概念としてはOpen APIやGraphQLとも一緒だと思います。スキーマAPIの定義ファイルからTypeScriptの型生成を行う。よく見る代表例としてはopenapi-typescriptなどですね。タイミーではopenapi2aspidaというライブラリを使っています。aspidaというライブラリがあってそれに変換してくれるものですね。当然、定義ファイルが必要になりますが、自動生成される仕組みにしてしまえば楽なのかなと思います。 他の例としてはtRPCが今、流行っているのかなと思っています。TypeScriptに特化したRPCでインターフェースを提供するライブラリという解釈です。Next.js、Express、FastifyにインターフェースとしてつけてあげるとあたかもRPCのような感じで書くことが可能になります。フロント側の対応もしっかりしてReactのhookを勝手に提供してくれる優秀なライブラリだったりします。 シリアライズもsuperjsonが用いられており、MapやSetといったオブジェクトをそのまま送ることが出来ます。 クライアントサイド レンダリング結果 サーバーサイド 送信データ こちらが実際に書いた例です。 クライアントサイド側にapi.example.hello.useMutationというのがありますが、これがサーバーサイド側のコードを書くと勝手に生成されているような感覚で書くことができる開発者体験のよいライブラリになっています。 シリアライザとしてはsuperjsonが用いられており、内部ではよしなにwrapされているので使用感としてRPCっぽいといった感じのライブラリになります。 ここまでServer Actionsについてと今までのフルスタックTypeScriptをどうやって書いていたのか、という話をしてきましたが、比較するとServer Actionsは圧倒的に手軽です。”use server”と書くだけで動くので手軽です。 シリアライズの方式も微妙に違っていて、レスポンスで返ってくるやつを色々実験しているのですが、JSXとかも実は送れたりします。 Next.js特化で共通のインターフェースみたいな概念ではないので今のところはServer Actionsを書いて、それをNativeAppから呼ぶみたいなことは想定されていないと思います。revalidatePathの様な所謂、Next.jsのキャッシュサービスと併用して使ってねみたいなサンプルコードとかも散見されるので、インターフェースを提供するというよりはやはりNext.jsに特化したRPCといった印象です。 Server Actionsの活用例 Server Actionsを知っている人は似たイメージを持っていると思いますが、Vercel盛り盛りのフルスタック構成ですね。 サーバーのデータベースを触る際はServer ActionsとかAPI Routesとかで書いて、クライアントコードは普通にReactで書くと。Vercelは最近様々なサーバーレスストレージサービスを出しているのでそれらを活用して永続化していきます。 あとはKey-Valueストアとかもあるのでそれらでキャッシングをしてあげるといった感じになります。 もう一つはBFF as Server Actionsのようなイメージですね。BFF as Server Actionsという言葉自体は私が作った造語なのですが、先程の構成ではネイティブアプリケーションからAPIを叩けないですよね。 それをAPIだけVercelから外に出して、VercelのServer Actionsから叩いてあげる、といったことも可能ではないかなと思っています。あとは単純にServer ActionsにすることでJavaScriptが動かない環境でもフォームとタグが動くのでそういったプログレッシブエンハンスメントを主とした活用もあるのではないかなと思います。 まとめ 今回はSever Actionsこんな感じですよ、ということをお伝えしました。とはいえまだアルファ版なのでServer ActionsちゃんとGAされてほしいなと期待を持っています。便利なので使っていきたいという気持ちですね。最近のNext.jsはフルスタック寄りに様々な機能を提供してくれているんですけど、Railsのような規約(レール)がある訳ではないのでその議論は活発になっていくのでないかなというのを個人的に思っています。 ご清聴ありがとうございました。 uhyoさんの発表や弊社のもう一人の西浦さんの発表が気になる方は以下の動画を是非ご覧ください。 www.youtube.com 少しでも興味を持っていただいた方は是非こちらからカジュアルにお話しましょう! product-recruit.timee.co.jp
アバター
内容 こんにちは! スクラム マスターのShinoP ( @marupopu )です! この記事は、 スクラムフェス仙台2023 で登壇した事によるふりかえり記事になります。 実際の発表内容はこちら speakerdeck.com ※後で動画も公開されると思いますので、ぜひ見てください! なぜ登壇したか? 初めに、なぜ登壇する事となったか?ですが、時は今年の1月に行われた RSGT でコンフォートゾーンから抜け出す体験をしたのがきっかけでした。 具体的なコンフォートゾーンから抜け出す体験というのは、RSGT初参加で、初 OST のホストを務めた事です。 今までは外部の勉強会に参加しても、話を聞くだけで自分の中で解釈をして納得をして、わかった気になって終わっていました。 また、自分は参加する人で、登壇している人たちはすごい人というような印象を持っていました。 そんな中、RSGTならではの空気感や、 OST のホストをやる人も多く存在し、遠方から参加していることもあり、参加するからには効果を最大限にしたいという思いがあり、勢いのまま OST のホストに申し込んだ結果、多くの方が集まって頂き5チーム程度に分裂して話し合う規模になりました。 その時に、自分が思っているような疑問でも共感や気になってくれる人は案外多いのかもしれない…と認識できました。 その後、 タイミー主催の勉強会 での登壇経験をして、共同で発表してくださった Coincheck の方々や、参加者の方とScrumについて語り合う体験が楽しいと感じました。 www.youtube.com 自社主催の勉強会での登壇が楽しいと感じたので、さらに課外活動などにも興味が湧きました。 なぜ、 スクラム フェス仙台か? さて、なぜ スクラム フェス仙台を選択したのか?というと、私自身が 岩手県 盛岡在住ということもあり、仙台も割と近いので現地参加しやすかったのと、4年前に東京から岩手に移住してくるほど東北が好きで、東北という括りで盛り上げたかったのが理由になります! あと、去年も参加したかったのですが、現地チケット取れずに断念した記憶があるのでリベンジも込めて。 雰囲気はこんな感じ スクフェス 仙台間もなく始まるサメー🦈 pic.twitter.com/nVx7xLKmtK — スクラム フェス仙台 (@scrumsendai) 2023年8月25日 初プロポーザルと文豪制度 もちろん、初プロポーザルでした! Scrum Fest Sendai 2023 - SMって兼任?専任?結局どうなの? 〜持続可能なチームになる為の鍵〜 | ConfEngine - Conference Platform どのように書いたら良いかわからなかったので、他の方の書き方などを参考に、自分の伝えたいことを書いてみました。 また、採択頂けたタイミングで資料作成などを行うにあたり、弊社のTDE10の中の人つの”文豪制度”を利用しました。 productpr.timee.co.jp 壁打ちをしてもらいながら資料を作成し、本当に自分が伝えたいことを 言語化 するのを手伝って頂き、本当に助かりました。 また、壁打ちを行なっていく過程で、最初に出したプロポーザルのアウトラインがずれていたりもしたのに気が付いたので、何を伝えたいのか?から正しく伝えられるような 言語化 、資料作成までお世話になりました。 登壇者での参加は何がよかったか? RSGTなどもそうでしたが、登壇者でない時の参加と登壇者としての参加の違いを感じたので書いていこうと思います。 まず、気持ちの問題かもしれないのですが、”圧倒的喋りかけやすさ”があるような気がしました。 何人かに、タイミーのしのぴーさんですよね?って話しかけられて嬉しかったです😆 めちゃめちゃ目立つ登壇者用TシャツもGOOD! また、認定 スクラム マスター研修やクローズドなイベントでの交流のあった方々にも声をかけて頂いたりして、登壇者 = 参加することが確定しているなどのイメージなので、話しかけて頂いたのかな?と推測しています。 あとは、同じ登壇者同士で話せたことも良かったと思います。 初めての登壇者同士で話したりして、緊張しますね…とかその時の心境をシェアできたのはとても良かったです。 その他よかった点 こちらは登壇者でなくとも スクラム フェス仙台2023の体験がよかった点を書いていきます。 まず、なんと言っても” クラフトビール ”が美味しかった🍻🍻 (ビールを飲みながらの OST 最高でした!!!) Day1で少し飲みすぎて、Day2に酒焼けで声が少し枯れてしまったのは反省😂 ノベルティ のシャークレくんグラス 来週8/25,26は スクフェス 仙台! ノベルティ を製サメしてます🦈オンサイトの参加の方はぜひ現地で乾杯🍻しましょ〜 #scrumsendai pic.twitter.com/s7BZjrHJBZ — スクラム フェス仙台 (@scrumsendai) 2023年8月15日 また、その場でしか体験できないワークショップ体験や飲み会、昼ご飯時に話されるディープな悩みの相談会なども現地参加ならではの楽しみ方だなぁと思いました。 あとは、会場が複数の階に分かれていたこともあって、ギャザリング感がめっちゃ味わえました。 1階では、メイントラックと品川トラックとオンライントラックがモニターで映されていたのですが、その空間で話している人もいればモニターを見て観戦している人もいたり自由な感じでとても雰囲気が良かったです。 この場作りという観点で、 スクラム フェス仙台2023の運営メンバーが考え抜いたレイアウトだと思うと、すごいと感じておりました。 さらに、スタッフの方々も楽しんでいる&参加者も全員協力的だったと思うので、やはり場を作り上げていくのは全員でやるのだなぁと再認識できました。 反省点 めちゃめちゃ楽しかったのですが、ふりかえっていて、いくつか反省点が見えてきました。 それは、登壇を意識するあまり、他のセッションが頭に入ってこなかった…という点です。 Day1でお酒をたくさん飲んだのも緊張を紛らわすためかもしれません…(酒焼けの言い訳) そして、お気付きでしょうか…現地でのオリジナル写真がほとんどない事に… 精神的に一杯一杯で写真を撮るのも忘れていました… また、Day1ではしゃぎ過ぎて、登壇が終わった後の無気力感と 疲労 が半端なかったです。 (まさに スクラム フェス仙台2022のシャークレくんのツイートみたいなイメージ🤣) それだけ、この登壇にかけてきたという事にしておきましょう😅 初めての社外カンファレンスの登壇終わって、めっちゃ脱力してる 燃え尽きた… — ShinoP/しのぴー@ スクラム マスター (@marupopu) 2023年8月26日 #scrumsendai スクラム フェス仙台のすべての日程を終了しました。 2日間本当にありがとうございました! pic.twitter.com/RPuVsa3NRo — スクラム フェス仙台 (@scrumsendai) 2022年8月27日 今後について 今回、 スクラム フェス仙台に登壇者として参加して、色々な気付きが得られました。 その中で、 カイゼン したい点としては、登壇経験を積み重ねてさらに楽しみたい!ということです。 今回は初参加、初プロポーザル、外部イベントでの初登壇という事でめちゃめちゃ緊張したのですが、楽しみの方が上回る体験となりました。 しかし、もっと楽しむためには登壇に慣れる事により心に余裕を持ってさらに楽しめるのでは?と感じました! (あと、全力で楽しむために体力もつけたい!) 他には、今回の スクラム フェス2023の運営側の方々が光る場面がいくつも目撃したので、自分でもこのような場作りができるようになりたいと強く思いました。 元々、なぜ スクラム フェス仙台か?にも書いた通り、東北を盛り上げたいという気持ちがあります。現在、北東北(青森・岩手・秋田)に スクラム や アジャイル の勉強会はほとんど開催されていないですし、コミュニティも私の観測範囲では見当たらないように見えます。 (コンパスで見ると「 アジャイル ・ スクラム 」のワードで勉強会が一つもない) ですので、個人的なNextActionとして岩手で アジャイル / スクラム コミュニティを生み出す活動なども今後やっていきたいと考えています!! 最後に、こんな素晴らしい場作りをして頂いた運営の方々を初め、関わってくださった全員に改めて感謝をお伝えして、ふりかえりの記事とさせて頂きます!! ありがとうございました!そして、おつサメでした!! スクフェス 仙台2023!おつサメ〜🦈最高に楽しい2日を皆さんと作れて最高嬉しいシャー  #scrumsendai pic.twitter.com/jtNhSak5sz — スクラム フェス仙台 (@scrumsendai) 2023年8月27日
アバター