TECH PLAY

dely株式会社

dely株式会社 の技術ブログ

236

はじめに SQLFluffとは? 導入の背景 SQLFluffの導入 SQLFluffをインストールする SQLFluffを試してみる .sqlfluffを作成する dbt templaterをインストールする SQLFluffの使用 CLIでの使用 dbt Cloud IDEでの使用 さいごに はじめに こんにちは、データエンジニアの加藤です。 クラシルのデータ基盤ではdbt(data build tool)を使ってデータを変換しデータウエアハウス・データマートを構築しています。 今回はdbtプロジェクトにSQLFuffを導入したので紹介します。 SQLFluffとは? 様々な種類のSQLに対応するリンターです。 Jinjaやdbtにも対応しておりコーディング規約に違反した記述を自動で修正してくれます。 SQLFluff is a dialect-flexible and configurable SQL linter. Designed with ELT applications in mind, SQLFluff also works with Jinja templating and dbt. SQLFluff will auto-fix most linting errors, allowing you to focus your time on what matters. github.com 導入の背景 これまでdbtプロジェクト内で作成されたSQLにはコーディング規約がなくSQLを書く各人の経験や暗黙的な規約で書かれていました。 そこに新メンバーがジョインしたことで暗黙的に守られていた規約に対するコードレビューに時間がかかったり、修正する手間が発生しました。 そこで以下の価値を期待してSQLFluffを導入することを決めました。 暗黙的なコーディング規約の明確化 規約違反コードのレビューコストの軽減・削減 統一されたコーディングによる認知・キャッチアップコストの軽減 SQLFluffの導入 SQLFluffをインストールする SQLFluffでは Python 3が必要 です。 $ pip install sqlfluff 以下のコマンドでインストールが成功していることを確認しましょう。 バージョンが表示されればOKです。 $ sqlfluff version 2.1.2 SQLFluffを試してみる テスト用に test.sql を用意 $ cat test.sql sELECt a, b , c + d, e FRoM hoge; lintコマンドでコーディング規約に違反している箇所を確認できます。 --dialectには 使用したいSQL を指定します。( $ sqlfluff dialects でも確認できます) $ sqlfluff lint test.sql --dialect snowflake 以下のように結果が表示されます。 Lは違反のある行数をPは何文字目であるかを表しています。 CP01などはSQLFluffのルールのコードを表します。 以降は違反の説明とルール名が表示されます。 L: 1 | P: 1 | CP01 | Keywords must be consistently upper case. | [capitalisation.keywords] L: 1 | P: 1 | LT09 | Select targets should be on a new line unless there is | only one select target. | [layout.select_targets] L: 1 | P: 1 | ST06 | Select wildcards then simple targets before calculations | and aggregates. [structure.column_order] L: 1 | P: 7 | LT02 | Expected line break and indent of 4 spaces before 'a'. | [layout.indent] L: 2 | P: 1 | LT02 | Expected indent of 4 spaces. | [layout.indent] L: 2 | P: 8 | LT04 | Found leading comma ','. Expected only trailing near | line breaks. [layout.commas] L: 2 | P: 10 | AL03 | Column expression without alias. Use explicit `AS` | clause. [aliasing.expression] L: 3 | P: 1 | LT02 | Expected indent of 4 spaces. | [layout.indent] L: 4 | P: 1 | CP01 | Keywords must be consistently upper case. | [capitalisation.keywords] fixコマンドでコーディング規約に合わせて自動修正できます。 $ sqlfluff fix test.sql --dialect snowflake 違反箇所の修正を試みるか聞かれるのでyを入力します。 ==== finding fixable violations ==== == [test.sql] FAIL L: 1 | P: 1 | CP01 | Keywords must be consistently upper case. | [capitalisation.keywords] L: 1 | P: 1 | LT09 | Select targets should be on a new line unless there is | only one select target. | [layout.select_targets] L: 1 | P: 1 | ST06 | Select wildcards then simple targets before calculations | and aggregates. [structure.column_order] L: 1 | P: 7 | LT02 | Expected line break and indent of 4 spaces before 'a'. | [layout.indent] L: 2 | P: 1 | LT02 | Expected indent of 4 spaces. | [layout.indent] L: 2 | P: 8 | LT04 | Found leading comma ','. Expected only trailing near | line breaks. [layout.commas] L: 3 | P: 1 | LT02 | Expected indent of 4 spaces. | [layout.indent] L: 4 | P: 1 | CP01 | Keywords must be consistently upper case. | [capitalisation.keywords] ==== fixing violations ==== 8 fixable linting violations found Are you sure you wish to attempt to fix these? [Y/n] ... Attempting fixes... Persisting Changes... == [test.sql] FIXED Done. Please check your files to confirm. All Finished 📜 🎉! [1 unfixable linting violations found] 成功すると test.sql が修正されます。 $ cat test.sql SELECT a, b, e, c + d FROM hoge; .sqlfluff を作成する 独自のコーディング規約を定義するためにプロジェクトのルートディレクトリに .sqlfluff を作成しましょう。 .sqlfluff を作成しなくても デフォルトの設定 で使用することが可能です。 今回は dbt Style Guide を参考にコーディング規約を整備していくことにしました。 基本的にはデフォルトと異なる設定のみを定義するようにしています。 [sqlfluff] dialect = snowflake # templaterにdbtを指定する場合はプラグインのインストールが必要です(後述) templater = dbt # SQLFluffで定義されているcoreルールのみを使用する # https://docs.sqlfluff.com/en/stable/rules.html#core-rules rules = core # dbt templaterの設定 # https://docs.sqlfluff.com/en/stable/configuration.html#installation-configuration [sqlfluff:templater:dbt] # 環境に合った設定をする project_dir = ./ profiles_dir = ~/.dbt/ profile = default target = dev # 各種ruleについては以下を参照 # https://docs.sqlfluff.com/en/stable/rules.html:title [sqlfluff:indentation] allow_implicit_indents = true [sqlfluff:rules:aliasing.table] aliasing = explicit [sqlfluff:rules:aliasing.column] aliasing = explicit [sqlfluff:rules:capitalisation.keywords] capitalisation_policy = lower [sqlfluff:rules:capitalisation.identifiers] capitalisation_policy = lower [sqlfluff:rules:capitalisation.functions] extended_capitalisation_policy = lower [sqlfluff:rules:capitalisation.literals] capitalisation_policy = lower [sqlfluff:rules:capitalisation.types] extended_capitalisation_policy = lower [sqlfluff:rules:ambiguous.column_references] group_by_and_order_by_style = implicit dbt templaterをインストールする dbt templaterはSQLFluffのデフォルトテンプレートでないためプラグインのインストールが必要です。 dialectに対応する dbt adapter と sqlfluff-templater-dbt をインストールします。 $ pip install dbt-snowflake sqlfluff-templater-dbt 今回はdbt templaterを使用しますが用途によってはjinja templaterを使用する方が有効な場合もあるため こちら を確認して状況に応じて適切なtemplaterを使用しましょう。 SQLFluffの使用 CLIでの使用 こちら と同じように実行します。 .sqlfluff があるディレクトリでコマンドを実行することで自動でコーディング規約を読み込んでくれます。 $ sqlfluff lint models/test.sql $ sqlfluff fix models/test.sql dbt Cloud IDEでの使用 dbt Cloud IDEではデフォルトで sqlfmt を使ったフォーマットができます。 プロジェクトのルートディレクトリに .sqlfluff を作成することでSQLFluffでLintとFixを行うことができます。(注意: mainブランチや読み取り専用ブランチでは使用できません) docs.getdbt.com さいごに dbtプロジェクトへSQLFuffの導入を紹介しました。 現在はSQLFluffを手動で実行する必要があるのでpre-commitやGitHub Actionsを使って必ずSQLFluffが実行されるようにCI/CDパイプラインを改善しコーディング規約の徹底を目指していきたいと考えています。加えて、今回はSQLFluffのcoreルールのみを適用しているのでチームで議論しながらより効果的なコーディング規約を育てていきたいと考えています。 careers.dely.jp
アバター
はじめに 移行が必要となった背景 Elastic Cloudへの移行およびv7へのバージョンアップ 旧構成について 構成図 なぜElastic Cloudか なぜ移行と同時にアップグレードを行ったか なぜ最新のv8ではなくv7か サーバサイドの修正内容 新構成について 構成図 Traffic Filter経由での接続 監視 Datadog Elastic Status ログ deprecation slowlog audit 権限管理 S3バックアップ Kibana Spaceのロゴ調整 辞書・同義語の運用 補足(unassigned shardの調査) 移行後に起きた問題 CPUクレジット枯渇 原因 対応 今後の展望 さいごに はじめに クラシルSREのkashと申します。 クラシルでは検索エンジンとしてElasticsearchを様々な用途で使用しています。 ElasticsearchのクラスタはAWSのEC2上で構築・運用されていましたが、多くの課題が溜まっていたことから、バージョンアップおよびElastic Cloudへの移行を行いました。 本記事では新構成や移行後に起きた問題についてご紹介します。 移行が必要となった背景 AWSのEC2上で構築されていたクラスタは久しくバージョンアップが行われておらず、インスタンスタイプも旧世代が使われていました。また、インデックスごとにS3へのバックアップ用にLambdaが存在していたり、そのLambdaがEOLになっているなど、運用維持が困難な状態でした。 このような状況があったため、インフラ面をSREが担当しつつ、Ruby側の修正はサーバサイドチームの協力ももらいながら、1ヶ月程度かけて新しい構成への移行を行いました。 Elastic Cloudへの移行およびv7へのバージョンアップ 旧構成について 構成図 旧構成 大幅に簡略化していますが、アプリケーションはいくつかのサービスとして分割されており、ECS上で動いています。 またElasticsearchのクラスタはマスタ3台と、数台のデータノードで構成されていました。 なぜElastic Cloudか AWSのOpenSearchも候補の一つでしたが、最終的にElastic Cloudを選定しました。 他にも Elastic Cloud on Kubernetes などが候補としてありますが、クラシルのアプリケーションはECSで稼動しているため対象外としました。 elasticsearch-ruby および elasticsearch-rails gemへの依存があるため AWSにはOpenSearchがありますが、クラシルが依存しているこれらのgemがv7そしてv8以降で使えなくなる可能性を考慮した結果です *1 クラシル以外の自社サービス(TRILL)で既にElastic Cloudの採用実績があるため 運用負荷を軽減するため Elastic Cloudであればハードウェアプロファイルの変更やサイズを容易に変えることができます クラスタのバックアップも自動で行われており、任意のタイミングの状態を復元することができます また、Kibanaの構築やプロキシ設定を自前で行う必要はないのも大きいです 最新機能を活用するため Elastic Cloudユーザであれば、Elastic社が独自に開発した機械学習モデルである ELSER のような新しい機能を使えるメリットがあります なぜ移行と同時にアップグレードを行ったか 本来であれば、Elastic Cloudへの移行という大きい変更と、バージョンアップは同時に行いたくありません。 同時に行ったのはElastic Cloudで起動できる最低バージョンがv7だったことが理由の一つです。 移行を何段階かのフェーズに分けて、Opensearch上で今と同じバージョンを起動したりEC2上でアップグレード作業をしてからElastic Cloudへ移行を行う段階的な方法も検討しましたが、大幅に時間がかかることになります。 新環境に加えて旧環境へのインデックスもしばらく動かし続け、いつでも切り戻せる状態にしておくことで、このリスクを許容することにしました。 なぜ最新のv8ではなくv7か 移行時点で elasticsearch-railsのv8対応 が進行中という状況でした。 積極的にメンテナンスされているわけではなさそうなため、今後の新しいバージョンでも同様の問題が起きる可能性があり、 elasticsearch-rails への依存を剥がすことの検討が必要になるかもしれません。 サーバサイドの修正内容 サーバサイドチームに協力いただき、 Breaking Changes と照らし合わせながら、アプリケーションの改修を行ってもらいました。以下は修正が必要だった変更内容の一部です。 typeの廃止 function_scoreクエリのweightに負の値が使えなくなった hits.totalの型が変更 ElasticsearchのClient初期化方法も変わりました。基本的には下記のような cloud_id を指定する方法で問題ないのですが、クラシルでは Traffic Filter (後述)を使用しているため、下の elastic-cloud.com をホストとして指定した初期化方法にしています。Traffic Filterを使用していない場合はデフォルトの found.io ドメインになります。このあたりは最初戸惑いがありました。 client = Elasticsearch :: Client .new( cloud_id : ' <deployment name>:<cloud id> ' , api_key : ' ****** ' ) client = Elasticsearch :: Client .new( host : " https://*****.es.vpce.ap-northeast-1.aws.elastic-cloud.com " , # host: https://*****.es.ap-northeast-1.aws.found.io api_key : ' ***** ' ) 辞書や同義語はElastic Cloudの Extension としてアップロードします。 インデックスのマッピング(kuromoji_tokenizerのuser_dictionary等)もパスを /app/config に変更する必要がありました。ZIPで圧縮する際は dictionaries ディレクトリを作る必要がある点もご注意ください。 The entire content of a bundle is made available to the node by extracting to the Elasticsearch container’s /app/config directory. This is useful to make custom dictionaries available. Dictionaries should be placed in a /dictionaries folder in the root path of your ZIP file. GET /<index name>/_settings?filter_path=**.kuromoji_tokenizer { "<index name>" : { "settings" : { "index" : { "analysis" : { "tokenizer" : { "kuromoji_tokenizer" : { "type" : "kuromoji_tokenizer", "user_dictionary" : "/app/config/<dic>.csv" } } } } } } } 新構成について 構成図 新構成 こちらも大幅に簡略化しており、実際にはElastic Cloud上に環境ごとのクラスタを構築しています。 Traffic Filter経由での接続 Traffic Filter for AWS によってクラシルのAWSアカウントとElastic Cloudをプライベート接続しています。インターネット経由での接続に比べて、パフォーマンスが安定しました。AWSの場合、実態としてはPrivateLinkになっています。 パフォーマンス測定は esrally で行いました。 esrally --pipeline=benchmark-only --target-hosts="https://*****.es.ap-northeast-1.aws.found.io" --client-options="basic_auth_user:'elastic',basic_auth_password:'*****'" --track=<track> esrally --pipeline=benchmark-only --target-hosts="https://*****.es.vpce.ap-northeast-1.aws.elastic-cloud.com" --client-options="basic_auth_user:'elastic',basic_auth_password:'*****'" --track=<track> esrally compare --baseline=<before> --contender=<after> 以下の記事ではTraffic Filterの仕組みが図解されており、わかりやすいのでおすすめです。 www.creationline.com 監視 Datadog Elasticsearchクラスタの各種メトリクスは DatadogのElastic Cloud連携 を使用し、必要に応じて監視を入れています。 ただ、注意点があります。Traffic Filterを有効化すると許可したネットワーク以外は接続できなくなり、Datadogのような外部から監視を行う場合や、更にはエンジニアがKibanaにアクセスするときにも影響します。 タイプがIPアドレスのTraffic Filterを追加して DatadogのIP やエンジニアのIPを適宜追加するか、VPNなど何らかの方法でIPを固定する必要があります。 クラシルでは現時点ではオフィスのIPとVPNからのアクセスを許可し、必要に応じてエンジニアのIPを追加しています。 CloudflareやTailscale等を活用して elastic-cloud.com へのアクセスをPrivateLink経由にする方法もできそうですが、まだ検証できておらず現時点では手動で管理しています。 このような仕様のため、本番稼動中にTraffic Filterを有効化するのは意図せずアクセスを遮断してしまう可能性があるため注意が必要です。これからElastic Cloudへの移行を考えている場合、可能であれば初期からTraffic Filterを使用するかを決めたほうがよいかもしれません。 Elastic Status Elastic Cloud Status が公開されているので、メール購読およびDatadogへのRSS登録を行なっています ログ メインとなるクラスタのログとメトリクスは他のクラスタに転送しています。以下の画像は例です。 メトリクスおよびログの転送 転送先のクラスタはv7にする必要はないため、最新バージョンであるv8にしており、これによって Stack Monitoring が使えるようになりました。インデックスごとの検索レートがリアルタイムで分かるため非常に便利です。 ゆくゆくは WatcherのSlack通知 を活用したいと思っています。 deprecation バージョンを上げたことで非推奨の設定もあります。ログは elastic-cloud-logs-* インデックスに格納されており、deprecationに関するログは event.dataset: "elasticsearch.deprecation" という条件でKibanaのDiscoverで可視化するか、以下のようなクエリで抽出できます。 www.elastic.co GET elastic-cloud-logs/_search?filter_path=**.message { "query": { "term": { "event.dataset": { "value": "elasticsearch.deprecation" } } }, "sort": [ { "@timestamp": { "order": "desc" } } ] } クラシルでは以下のようなログが発生しており、次のバージョンにアップグレードする前に対応する必要があります。 The [edgeNGram] token filter name is deprecated and will be removed in a future version. Please change the filter name to [edge_ngram] instead slowlog インデックスごとに slowlog の設定をしています。 以下の例では直接インデックスの設定を更新していますが、実際には インデックステンプレート でインデックスパターンに対して指定しています。閾値を 0s にすると全てのクエリをログに出すことができるため、開発環境のクラスタでどのようなクエリが実行されているかを把握するために活用していたりします。 PUT /<index name>/_settings { "index.search.slowlog.threshold.query.trace": "<threshold>" "index.search.slowlog.threshold.query.debug": "<threshold>" "index.search.slowlog.threshold.query.info": "<threshold>" "index.search.slowlog.threshold.query.warn": "<threshold>" } ログはdeprecationと同様で、 event.dataset: "elasticsearch.slowlog" で抽出できます。 このログにはクエリの全文が格納されており、KibanaのProfilerにコピペすることでクエリが遅い原因を調査することが可能です。 audit xpack.security.audit.enabled を指定にすることで、 監査ログ を有効化でき、ログは event.dataset: "elasticsearch.audit" として出力されます。記録するイベントの種類やインデックスを限定することもできます。 xpack.security.audit.enabled: true xpack.security.audit.logfile.events.include: access_denied, authentication_failed xpack.security.audit.logfile.events.emit_request_body: true 以下のようにUser Settingsから elasticsearch.yml を指定しますが、ハマりどころとしては、 kibana.yml にも同様の設定が必要だったということでした。 ElasticsearchのUser Settings 権限管理 各クラスタの権限管理はElastic Cloudに最近導入された ロール機能 を使っています。 カスタムロールなどは現状使えませんが、特定クラスタのadmin、editor、viewer権限を特定の人に対して割り当てることができます。 権限を割り当てることにより、Kibanaで Login with Elastic Cloud を選択することでログインできます。 実はこの機能がリリースされる前は Oktaログイン を使用することを想定し、検証していました。手順に従って有効化すると、以下のようにログイン画面に Login with Okta が表示されます。 Kibanaへのログイン このやり方であれば権限を細かく調整できるメリットがあるのですが、クラスタごとにロールを定義する必要があります。現時点ではTerraformで管理しておらず、クラスタが増えると大変になるため、どうしたものかと考えていました。そのような状況でロール機能がリリースされたため、そちらを使うことにしました。 今後は二つを組み合わせることも想定しており、基本的にはElastic Cloudのロール機能でviewer権限を付与し、本番クラスタのみOktaで細かく制御するハイブリッド方式も良さそうと思っています。 S3バックアップ Elastic Cloudはデフォルトで定期的にバックアップをとってくれています。そのバックアップを使ってクラスタ全体や特定のインデックスのみを復元できるのが非常に便利です。 ただ、クラスタを消すとそのバックアップも当然消えてしまいます。なにかしらの問題があったときのことを考えて、以下の手順に従ってクラシルのAWSアカウント側のS3にもバックアップすることにしました。 Configure a snapshot repository using AWS S3 | Elasticsearch Service Documentation | Elastic Kibana Spaceのロゴ調整 本番用のクラスタ以外にも開発用のクラスタを起動しています。Kibanaを使っているときに、どの環境のクラスタに接続しているかはURLでしかわかりません。 そのため、開発環境だと勘違いして本番に変更を加えてしまう恐れがあります。 ミスが起きることを完全になくすことは難しいですが、可能性を減らすためにKibanaのデフォルトスペースのロゴを変えて環境が本番( Pr oduction)であることに気づきやすくしました。 なお、8.8系では Custom Branding という機能が導入され、FaviconやKibanaのロゴも変えられるようです。うまいこと活用できれば本番環境であることを強調できるかもしれません。 www.elastic.co 辞書・同義語の運用 Elastic Cloudでは辞書や同義語ファイルをzipで圧縮して前述したExtensionの仕組みを使って、クラスタに紐づけます。 既存のインデックスで既に辞書を使っている状態で、シンタックスやディレクトリ構造が間違った辞書をアップロードするとローリングアップデートが行われますが、その結果、unassigned shard状態になってしまうため注意が必要です。 慌てて前の辞書を使うようにクラスタの状態を戻しても解消しないことがありました。その場合は Restart Elasticsearch で明示的に再起動することで元に戻りました。 本番環境で直面すると非常に焦る事象のため、辞書の更新は慎重にやる必要があり、開発クラスタで事前に辞書が問題ないことを試すことに加えて、あえて不正なファイルをアップロードしてエラー対応をする流れを検証することをお勧めします。 補足(unassigned shardの調査) Elasticsearchの運用をしていると、unassigned shardという状態になることは避けて通れません。 Datadogでは elastic_cloud.unassigned_shards でメトリクスを確認できますが、Dev Toolsなどからは以下のクエリで確認できます。 GET /_cat/shards?v=true&h=index,shard,prirep,state,node,unassigned.reason&s=state index shard prirep state node unassigned.reason <index name> 0 p UNASSIGNED ALLOCATION_FAILED 原因調査には /_cluster/allocation/explain のAPIが使えます。 GET /_cluster/allocation/explain { "index": "<index name>", "shard": 0, "primary": true } failed shard on node [xSxiF3YWS1yeShDqWQohbg]: failed to create index, failure IllegalArgumentException[Failed to resolve file: system_core.dic\nTried roots: [Filesystem{base=/app/config/sudachi}, Classpath{prefix=}]] 移行後に起きた問題 移行直後は大きな問題は起きず、めでたしめでたし、で終わるかと思いきや5月前半にアクセス数が突発的に跳ねたタイミングで検索リクエストも急増し、それによって障害を起こしてしまいました。 CPUクレジット枯渇 Sentryから以下のような 429: Too Many Requests エラーの通知が来ました。 rejected execution of ** on QueueResizingEsThreadPoolExecutor[name = instance-00000000 /search, queue capacity = 1000, ... queue_sizeはデフォルトの1000のままになっており、何らかの理由で捌ききれずに検索リクエストがキューから溢れてしまったようです。 www.elastic.co 原因 突発的なクラシルへのアクセス増によって検索リクエストも増え、その結果クラスタにCPU負荷がかかり、データノードのCPUクレジットが枯渇しました。それによって、パフォーマンスの悪化が発生しました。 事象としてはこちらと同じです。 www.elastic.co 残念ながら、現時点ではCPUクレジットの情報をメトリクスとして取得することはできないとサポートの方から聞きました。 Nodes Stats API では cfs_quota_micros が取得できますが、これはCPUクレジットが「枯渇した後」に変化が起きるため、CPUクレジットが「枯渇し始めた」という兆候を検知することはできないようです。 兆候を検知する方法はいまだに未解決で、CPUクレジットの減少が発生しないような余裕のあるハードウェアプロファイルとサイズで構築するしかないという認識です。 対応 どの ハードウェアプロファイル を使用するか、どのサイズ(ストレージ、メモリ、vCPU)を使用するかをワークロードに応じて決定する必要があります。 結果的に、ハードウェアプロファイルを Storage Optimized から、 CPU optimized (ARM) に変えることにしました。指定できるサイズは倍々になっていくため、 Storage Optimized のまま次のサイズにすると予算を大幅に超えてしまうため、多少のコスト増でおさまり、CPUにも余裕の出る CPU optimized (ARM) にしました。 基本的にElastic Cloudに変更を加える時はローリングアップデートになり、1台ずつ順にアップデート処理されます。 ただ、落とし穴として、ハードウェアプロファイルの変更はローリングアップデートにはならず、全ノードが一斉にダウンするようです(2023/07時点)。 以下は例ですが、全データノードが Not Routing Requests になっています。 ハードウェアプロファイルの更新例 クラシルのあらゆるところでElasticsearchが使われているため、このままではサービスが全体的に止まってしまいます。 どうしたものか、と悩みましたが、ちょうど直近でメンテナンスを行う予定があったため、そのときにハードウェアプロファイルの変更も行うことにしました。 ちなみに、これはAurora MySQL v2からv3にアップグレードするメンテナンスでした。こちらも別の機会で紹介したいと思います。 問題はメンテナンスまでの数日間をどう凌ぐかです。 Elastic Cloudはデータノードを任意の数にすることはできません。EC2で自前で構築していたときのように、今3台だとして気軽に5台にすることはできません。起動しようとするAZ(availability zone)数でノード数が決まり、例えば3AZを指定すると3ノードになります。 ではデータノード数の上限は3なのか?と疑問に思って試したところ、一定サイズ以上(最低でもメモリが116GB以上)のクラスタを起動しようとするとノード数が増える仕組みのようです。例えば、116GBのメモリを搭載したサイズを指定した場合は58GBメモリのノードが2台、174 GBの場合は3台になる仕組みのようでした。 ノード数の増減 幸い本番クラスタでは Warmノード を2台起動していため、苦肉の策としてこれらを活用することにしました。 Elasticsearchには data tier という概念があり、デフォルトでは data_content が割り当てられています。以下のようなクエリで確認できます。 GET /<index name>/_settings?filter_path=**._tier_preference { "<index name>" : { "settings" : { "index" : { "routing" : { "allocation" : { "include" : { "_tier_preference" : "data_content" } } } } } } } 通常はライフサイクルポリシーでインデックス作成後、指定日数経過したら data_warm 、 data_cold などに遷移させるようにできますが、手動で変えることができます。メンテナンス日までは負荷のピーク時にCPUクレジットを確認するようにし、0になるまえにCPU負荷の高いインデックスの _tier_preference を data_warm にすることで、強制的にノードを変える対応をとりました。ピークを過ぎたら元の値に戻します。 本来のWarmノードの用途ではないので、おすすめはできません。例えば、Warmノードが存在してない状態で指定するとunassinged状態になるため、手動で変えるとリスクがあります。あくまで緊急対応という形です。 結果的に、 _tier_preference を変える必要があったのは1日のみでしたが、仮に長期間の対応となる場合は厳しいため、その際は一時的なコスト増を許容してハードウェアプロファイルを維持したまま深夜にサイズを上げる対応をとったと思います。 ただ、ローリングアップデートになるか否かについての条件がドキュメントで見当たらなかったため、この仕様が永続的とは限りません。事前に別クラスタ等で検証は必要になりそうです。 結果的に CPU optimized (ARM) にしたあとは、CPUクレジットが問題となることはなくなりました。 今後の展望 v8へのアップグレードはできるかぎり早く行いたいと思っています。 また、バージョンが上がったことで、今までは不可能だったことが可能になりました。以下については実現がいつになるかは分かりませんが、導入できたらいいなと思っています。 大きな変更前後の差異を定量的に評価できる仕組みづくり v7になったことで、 Ranking evaluation API が使えるようになりました v8へのアップグレード等が控えていることもあり、同一クエリにおける検索結果の順位変動を定量的に測る仕組みを整えたいです 新バージョンの機能を活用 Elasticsearchの進化は凄まじく、全てを追えているわけではないですが、魅力的な機能が多く導入されています 特に、同義語運用で楽をできる可能性のある 検索アナライザーのリロード 、Lookup Runtime Field *2 、サジェスト機能の改善が見込めるkuromojiのsuggester *3 などは活用できそうでした また、少し試した程度で分からないことは多いですがELSERを活用することで、よりよい検索体験にできそうです sudachiの検討 クラシルは辞書や同義語の数が膨れ上がっており、管理に課題がある状況です メンテナンスが行われている sudachi を活用させてもらうことで辞書や同義語の管理を簡素化できないかと思っています さいごに クラシルにおけるElasticsearch v7へのアップグレードおよびElastic Cloudへ移行した結果を振り返りました。 Elatic Cloud移行を検討している方にとって何か参考になることがあれば幸いです。 careers.dely.jp *1 : ElasticSearchClientを利用する際の注意点 *2 : Lookup Runtime Field 〜Elasticsearch 8.2 新機能〜 - Qiita *3 : 日本語用オートコンプリートのためのAnalyzer | @johtaniの日記 3rd | @johtani's blog 3rd edition
アバター
こんにちは、クラシルiOSエンジニアの uetyo です! クラシル では、2022年12月に アプリリニューアル を含む、クラシル史上最大規模のブランドリニューアルを実施しました。iOSアプリでは、「ダークモード対応」、「タイポグラフィの再定義・統一」、「アイコン変更」、「カラー定義の全変更」など、大幅なリニューアルを行いました! この記事は、2022年4月に新卒でdelyに入社し、iOS未経験から数ヶ月の研修を受けた後、アプリリニューアルのためにクラシルのほぼ全ての画面を改修していった裏側についてのお話です 🛠️ この記事の読者対象: アプリリニューアルを控えているデザイナーやエンジニア ダークモード対応時のポイントやハウツーを知りたい方 アプリ内のタイポグラフィを再定義して既存画面に適用したい方 アプリ内で利用するアイコンを一括変更したい方 カラーの再定義・適用とダークモード対応 🎨🌙 カラー対応のポイント カラーの定義 カラーの命名 カラーの管理方法 カラーの適用 CGColor で指定されたコンポーネントのダークモード対応 UIButtonにBackgroundImageで指定した際のダークモード対応 新タイポグラフィの統一とサイズ変更 🔠 タイポグラフィ変更のポイント タイポグラフィの定義と命名 新タイポグラフィの格納 タイポグラフィの変更 アイコン変更 🏷️ アイコン変更のポイント 画像系リソースの格納方法を設計する AssetChangerを用意する アプリリニューアルを振り返って 💭 関連リンク カラーの再定義・適用とダークモード対応 🎨🌙 対応期間:4ヶ月 カラー対応のポイント 気合で乗り切る 🔥 カラーの管理は Asset Catalog で行う カラーの命名は最重要 CGColorを利用する場合は TraitCollection を監視する ダークモード対応のデバッグも TraitCollection で実現できる カラーの定義 アプリのリニューアル以前は、カラーを多用した華やかなデザインでしたが、それらがあらゆるボタンやテキストに対して使用されていたため、ユーザに認知・行動して欲しいものにまとまりがありませんでした。その結果、ユーザに行って欲しいアクションの学習(メンタルモデルの構築)を促すことができませんでした。 この問題を解決するために、リニューアル時にデザイナーチームがゼロからカラーの定義を再考し、定義外の使用を原則として禁止することになりました。こららの定義は、よりモダンで今後クラシルが目指す食のプラットフォームとしての基盤となるUIを目指して設計されています。 実装を始めたところ、思っていたように機能しないカラー定義が発見されました。しかし、後述する カラーの管理方法 を採用していたため、途中で何度か定義変更を行っても、最小限のコード変更で対応することができました。 カラーの命名 クラシルの新しいアプリデザインではメインカラーとして13種類を利用します。それぞれの命名はエンジニア・デザイナー双方にフレンドリーな命名になっています。 Content - 最も利用率の高いカラー郡です。文字やアイコンなどの要素に対して利用するため Content という命名にしています - 優先度が高いほうから Primary, Secondary, Tertiary, Quaternary と定義しています - PrimaryColor のボタン内に表示するテキストなどは PrimaryInverseColor を利用します Theme - ユーザに最もアクションして欲しい場合に利用するブランドカラーです Background / Elevated - 背景色です。BaseViewの上にホバーするようなコンポーネントを表示する場合は Elevated を利用します。これはすべて Backgound に統一してしまうと著しく視認性が悪いことがあるためです Fixed - クラシル内に投稿されたコンテンツ上や常に同じ色を表示する場合に利用します Overlay - コンテンツにマスク的なものをつける際に利用します 以前は Color.base, Color.state のような何を基準としてベースなのか、ステータスなのか不明な命名となっていたため、エンジニア↔デザイナー間で認識がずれることがありました。しかし、再定義によりで抽象度を高く保ちつつ、より明確な指定ができ、ダークモード・ライトモードのステータスに関係しない命名で設定できるようになりました。 カラーの管理方法 これまでは、UIColorを拡張して独自のブランドカラーを定義していましたが、Xcode 9 (iOS 11)以降では、カラーの管理もAsset Catalog(Color)で行えるようになりました。さらに、Xcode 11 (iOS 13)以降では、ダークモード対応もXcode側で自動的に行ってくれるため、Asset Catalogで管理することにしました。 ブランドカラー及びアプリカラーの管理はすべてAsset Catalogで行っています。Asset CatalogはHexColorStyleだけでなく、RGBやOpacityなど、色に関係するプロパティであれば基本的に設定可能です *1 。 クラシルでは、 SwiftGen を使用して、Asset Catalogに定義されたカラーを静的に参照できるようにしています。また、Asset Catalog側でColorの名前空間を設定することで、カラーを指定する際に他のリソース(例えばアイコン)のサジェストが表示されないようにしました。 例:ライトモード( #FFFFFF ), ダークモード( #000000 ) のカラーを Colors.Primary として定義 SwiftGenで静的プロパティを生成後、実際のコードで利用する際 // UIKit Asset.Colors.PrimaryColor.color // SwiftUI Asset.Colors.PrimaryColor.swiftUIColor 名前空間はネストさせることも可能なので、普段は利用しないブランドカラー等を Colors.Brand.blueRegular として定義することも可能です // UIKit Asset.Colors.Brand.BaseColor.color // SwiftUI Asset.Colors.Brand.BaseColor.swiftUIColor カラーの適用 ※ 気合です Git Branch 戦略 クラシルのiOSアプリはサービス開始から約7年経過しており、現在では100枚以上の画面があります。アプリをリニューアルする際には、すべての画面が最新でメンテナンスが行き届いていることが望ましいですが、難しい状況でした。また、日々大量の変更が発生するため、数ヶ月間リニューアル用のブランチを運用することは不可能だと判断し、 画面ごとに分割してリリース することになりました。しかし、上記で紹介したようにアプリリニューアルに合わせてダークモード対応も行っているため、開発者のみ切り替えれるようにしておく必要がありました。 そこで クラシル iOS ではリリース版はライトモード固定、デバッグ版では設定からライトモードとダークモードを切り替えれるようにすることで開発やQAの効率を向上しました。 アプリのディストリビューション毎に画面モードを固定するには AppDelegate にて UIWindow.overrideUserInterfaceStyle に対して状態を上書きすることでダークモードを無効にすることが可能です。 // For debug version #if DEBUG if isEnabledDebugDarkmode { // 端末外観設定に準拠 UIWindow.overrideUserInterfaceStyle = UIUserInterfaceStyle.unspecified } else { // ライトモード固定 UIWindow.overrideUserInterfaceStyle = UIUserInterfaceStyle.light } #endIf // For public app version UIWindow.overrideUserInterfaceStyle = UIUserInterfaceStyle.light // UIUserInterfaceStyle.unspecified = 端末設定に準拠 // UIUserInterfaceStyle.light = ライトモード固定 // UIUserInterfaceStyle.dark = ダークモード固定 カラーの適用は後述する タイポグラフィの変更 と同時進行で進めました。以前のデザインシステムカラーが多種多用な設定方法(RGB/Hex直書き、Colorの拡張を指定、独自ColorEnumを指定)だったことと、タイポグラフィの変更した画面かどうかをダークモードに対応しているかどうかで判別するためです。 基本的にマッピング等は用いず、一つ一つの画面を調査して色を変更してはビルド→シミュレータで確認を繰り返しました。 変更を始めた当初は、iOSの実務経験が全くなかったこともあり、意図しないコンポーネントに影響を与えたり、他の画面からカラーの上書きをしていることに気が付かず文字が背景色と混同して読めなくなるなどの問題が発生しました。カラー変更が半分ほど終わると、ドメイン知識や実装経験が身についたため、リファクタリングにも積極的に取り組みました。かなり古い画面になると、カラーの適用だけでも影響範囲が膨大で、どこで状態が変更されているのか不透明で、リーディング力も向上しました。 結果的にこの戦略は膨大な時間を利用することになりましたが、iOSエンジニアとして技術力が幼かった私にとって膨大な画面の実装を読む良い経験になりました。 CGColor で指定されたコンポーネントのダークモード対応 iOS と Xcode Asset Catalog の機能を利用したダークモード対応ではUIColorで指定されているものであれば良しなにOS側が変更してくれますが CGColor を利用しているコンポーネントでは端末の外観設定を切り替えてもアプリを再起動するまで適用されない問題に遭遇しました。調査した結果、ライト・ダークモードが切り替わったことを判定して再適用することで解決することができました! CGColor を指定している場合は traitCollectionDidChange をオーバーライドすることで外観設定が切り替わったことを判定することができます。このタイミングでCGClorで設定するプロパティを再指定すれば意図した表示にできます。特に layer 系の場合は関係するプロパティも一緒に再指定する必要があります。 // ダーク<->ライトモード切り替え時に適用されないため再設定する override public func traitCollectionDidChange (_ previousTraitCollection : UITraitCollection? ) { super .traitCollectionDidChange(previousTraitCollection) layer.borderColor = Const.borderColor.cgColor // もし borderWidth も変更する必要がある場合は再指定する layer.borderWidth = Const.borderWidth } UIButtonにBackgroundImageで指定した際のダークモード対応 AppDelegateでライトモードに固定しているにもかかわらず、端末側の外観設定をダークモードに切り替えると UIButton に image を設定しているコンポーネントのカラーが切り替わる、という問題に遭遇しました。こちらも調査した結果、 Color.resolvedColor(traitCollection) は何も指定していなければ UITraitCollection.current を参照してしまい、起動時(AppDelegate)に overrideUserInterfaceStyle を上書きして、アプリ外観設定を固定しても端末の設定に基づいた色を適用してしまうようです。以下のようにbackgroundImageを現在のアプリ外観設定で上書きすることで解決することができました。 public var containerColor : UIColor = .clear { didSet { setBackgroundImage( containerColor.resolvedColor(with : traitCollection ).image(), for : .normal ) } } 新タイポグラフィの統一とサイズ変更 🔠 対応期間:4ヶ月 タイポグラフィ変更のポイント 気合で乗り切る 🔥 タイポグラフィの定義と命名がとても大事 タイポグラフィの定義と命名 カラーと同様にアプリリニューアルに合わせてタイポグラフィも変更することになったため、こちらも変更します。以前はよくある段階式サイズ指定(title1, subtitle, body, button, caption)と独自指定でした。そのため利用したい場所がタイトルなのに button サイズが利用したい…ニーズに対応できず個別で独自指定することがありました。 新しいタイポグラフィの定義では、この問題を解決するためデザイナーチームがサイズに基づく設計してくれました。 この設計により、利用範囲が制限されない指定をすることが可能になりました。また命名がAndroidと共通になっているため、例えばAndroidで先行している機能を確認する際にiOS版が作成されていなくても、ほぼ実装できるようになりました。 iOS アプリでは、タイポグラフィの定義を直に指定しています。 public struct NewTypography { public static let size36w6 = NewTypography(.w6, fontSize : 36 ) public static let size36w3 = NewTypography(.w3, fontSize : 36 ) public static let size32w6 = NewTypography(.w6, fontSize : 32 ) public static let size32w3 = NewTypography(.w3, fontSize : 32 ) //... } // 利用時 let textStyle : TextStyle = NewTypography.size36w6.style 新タイポグラフィの格納 クラシルはアプリリニューアルに付随してアプリ内のタイポグラフィを統一する方針となりました。日本語向けは Hiragino-sans(ヒラギノ角ゴシック) 数字向けは外部フォントを利用することになりました。 Hiragino-sans はiOS標準フォントになるので、別途外部からインポートする必要もないですが、数字向けはiOSには存在しない外部フォントとなるためResourceの一部としてアプリ内に配置、 SwiftGen を用いて静的に参照できるようにしています。 public struct NumberTypography { public var style : TextStyle { let size = Typography.addFontSizeIfNeeded(size : fontSize.value ) return TextStyle( weight : fontType.weight , size : size , lineHeightValue : fontSize.lineHeight , kerning : fontSize.kerning , font : fontType.fontFamily.font (size : size ) ) } // ... // 利用時 let textStyle : TextStyle = NumberTypography.size32w6.style タイポグラフィの変更 ※ 気合です こちらもカラー変更と同様に画面毎に変更してはリリースする戦略で進めました。幸い、日本語向けタイポグラフィは以前同様にApple標準 Hiragino-sans だったため、特にフラグなどは用いることなく、差し替えとサイズ調整のみ行いながら進めました。 基本的に定義したTextStyleに置き換えるだけだったので非常にスムーズに進めることができましたが、 UITextView と UISearchBar のダークモード対応+タイポグラフィ変更に関しては、悩む日々を過ごしました… もしタイポグラフィを変更される際は最新のサポートバージョンに合わせて新規でコンポーネントを作成することを強くおすすめします。 アイコン変更 🏷️ 対応期間:1ヶ月 アイコン変更のポイント アイコンを含む画像系リソースの格納方法を設計する AssetChangerを用意する 本番リリースしながら確認できるようにする QAチームと特に協力する やっぱり気合 📛 画像系リソースの格納方法を設計する クラシルでは約300個ほどのアイコンを含む画像系リソースを利用していました。これらのリソースはアプリリリース時から特に格納方法など設計されずに積み上げられていたため似通ったアイコンが重複登録されていることや、ベクター画像(svg)とラスター画像(jpeg, png)がごちゃごちゃに登録されている状態でした。 アプリリニューアルに合わせてアイコンはすべてクラシル独自のものに差し替えることになったため、このタイミングで格納方法についてもゼロから再設計しました。設計の際に気をつけたポイントは以下です。 利用する画像にView側で着色する必要があるのか分かりやすくする RenderingModeを Asset Catalog 格納時に指定する View側で着色する画像に関してはダーク・ライトモードで確認が抜け落ちないようにする (1)利用する画像にView側で着色する必要があるのか分かりやすくする こちらは名前空間を用いて画像を指定する際に理解できるようにしました。Colorのとき同様に、AssetCatalog にて名前空間を有効化して登録→SwiftGenを用いて静的に参照できるようにしています。 また、着色不要であってもベクター画像の場合は Scales=Single Scale , Resizing=True , SVG 形式で格納することにしました。これは PDF 形式に比べて約50%ファイルサイズを削減することができるためです。さらに、極稀に存在する5MBを超える画像を格納する必要がある場合は PNG 形式で格納します。 以上のように画像を格納することで、どんな画面サイズでも高解像度かつ低容量に扱うことができました。 (2)RenderingModeを Asset Catalog 格納時に指定する アイコンを格納する際に Image set プロパティから Render As = Template Image を明示的に指定するようにしました。明示的に指定することでView側で色をつける際に .withRenderingMode(.alwaysTemplate) を呼ぶ必要がなくなります。 // in UIKit // 🙅🏻 Wrong Asset.Image.hogehoge.image.withRenderingMode(.alwaysTemplate) // 🙆🏻 Correct Asset.Image.fugafuga.image // in SwiftUI // 🙅🏻 Wrong Asset.Image.hogehoge.swiftUIImage.renderingMode(.template) // 🙆🏻 Correct Asset.Image.fugafuga.swiftUIImage (3)View側で着色する画像に関してはダーク・ライトモードで確認が抜け落ちないようにする 少しSwift / Xcodeから離れてデザイナーとのやり取りが必要です。 アプリリニューアルに付随してクラシルではダークモード対応を行いました。これまではアイコンを格納する際は黒色で格納していましたが、作業を進める中で以下の問題が発覚しました。 Xcode ダークモードだと Asset catalog に登録したアイコンの識別が難しい View側で着色を忘れてしまい、QA時にダークモード化した際に着色されていないことが発覚する Apple純正アイコンのSFSymbolではカラーを指定しない限り 青色( #007AFF ) が適用されます。独自で作成したアイコンもデフォルトカラーとして、この青色を利用することで上記の問題を解決しました AssetChangerを用意する ※ 造語です 本番リリースしながらアイコンを変更するために、AssetChangerというアイコン切り替えシステムを導入しました。これは、本番バージョンでは常に古いアイコンを表示しますが、開発者の場合は新旧アイコンを切り替えることができるものです。システムといっても複雑ではなく、300個の画像系リソースを 手動でマッピング すると本番に影響させることなく新アイコンを利用できる、という代物です。 // in Resources module import SwiftUI import UIKit public enum AssetChanger { case homeIcon case playIcon case stopIcon // ... public var image : UIImage { if UserDefaults.standard.bool(forKey : "debug_asset_change" ) { return newAssetImage.image } else { return oldAssetImage.image } } public var swiftUIImage : SwiftUI.Image { if UserDefaults.standard.bool(forKey : "debug_asset_change" ) { return newAssetImage.swiftUIImage } else { return oldAssetImage.swiftUIImage } } /// リニューアルまで利用するアイコン private var oldAssetImage : ImageAsset { switch self { case .homeIcon : return Asset.iconHome case .playIcon : return Asset.iconPlay case .stopIcon : return Asset.iconStop24 // ... } } /// リニューアル後に利用するアイコン private var newAssetImage : ImageAsset { switch self { case .homeIcon : return Asset.Icon.iconHome case .playIcon : return Asset.Icon.iconStart case .stopIcon : return Asset.Icon.iconPause // ... } } } 画面数と画像数がとても多いため、それなりに時間がかかりましたが、マッピングと新アイコン適用した際のアイコンサイズ調整以外は基本的に一発置換することで変更できました。SwiftGenで画像を管理していたおかげでかなりスムーズに行うことができました! アプリリニューアルを振り返って 💭 研修後すぐにアサインされたリニューアルプロジェクトですが、無事に期日に間に合わせて完了することができました! 総プロジェクト期間:5ヶ月 GitHubPR数:250+ 扱った画面数:100+(クラシル内のほぼすべてのVC、コンポーネント) とても作業量の多いプロジェクトでしたが、今後のクラシルの基盤となるUIへアップデートできたこと、これまで乱立していたデザインシステムを最新のものへ統一できたこと、ほぼすべての画面を入社&実務1年以内で経験できたことはとても大きな学びとなりました! iOS未経験だった私がこのプロジェクトをやり切れたのは、アイコン・クリエイティブ制作、カラー定義などでとてもお世話になったデザイナーの方やレビュー・技術的サポートしてくれたiOSチームメンバー、毎週山のようなチェック項目を確認してくれたQAチームのおかげです! このプロジェクトが完了したあと約半年経過した現在では、クラシルが目指す新しい方針に向けて新規の機能開発をガシガシと進めつつ、スクラムマスターとしてチームビルディングを行っています。実務2年目も楽しみながらプロダクト・事業に貢献していきます 🔥 関連リンク クラシルブランドリニューアルサイト: https://www.kurashiru.com/rebrand クラシルブランドガイドライン: https://speakerdeck.com/delyinc/kurashiru-brand-guideline *1 : https://developer.apple.com/documentation/uikit/appearance_customization/supporting_dark_mode_in_your_interface
アバター
こんにちは。 クラシル開発部、バックエンドエンジニアの松嶋です。 delyに入社してから約3年間、私はSREチームに所属していましたが、昨年10月にバックエンドに転向しました。バックエンドに転向してからは、主にクラシルアプリの公式レシピおよびCGMコンテンツの検索機能に関する開発・改善に取り組んでいます。 クラシルは、2016年2月にサービスを開始してから、管理栄養士監修の「誰でも安全に・おいしい料理を作ることができるレシピ動画」を5万件以上提供してきました。 昨年12月には、クラシルのブランドリニューアルを行い、今後はシェフや料理研究家を中心としたクリエイターとともに多様化したユーザーの食の好みや課題解決に応えられるよう、幅広い食のコンテンツを提供するプラットフォームを目指しています。 ブランドリニューアルの詳細に関しては、こちらを御覧ください。 www.kurashiru.com このような背景から、私たちはクリエイターのコンテンツもクラシルアプリで検索でき、お気に入りのレシピをストックできるように、UGC検索エンジンのMVP開発に着手することになりました。 自分たちが運営するサービスに検索機能を導入する場合、DBのLIKE検索で簡易的な検索機能を実現する、またはElasticsearchのような全文検索エンジンを導入する方法を思いつく方が多いと思います。 しかし、今回のMVP開発において私たちが選択したのはMySQLの全文検索機能です。 MySQL InnoDBのFULLTEXTインデックスは、MySQL 5.6の段階では様々な制約があり、日本語環境では使うことが難しい言われていましたが、MySQL 5.7ではそのような事情が大幅に改善され、日本語環境でも適用できるようにパーサーが変更できるようになりました。 *1 昨年、クラシルが使用しているAurora MySQLのバージョンを5.6から5.7にアップグレードしたことがきっかけで、「今回のUGC検索のMVPを実施する上で使えないか?」という話が出たため、開発環境での検証を経て導入することに決定しました。(2023年6月現在、クラシルが使用しているAurora MySQLのバージョンは、既に8.0までアップグレードされています。) この記事では、MySQL全文検索を導入・運用した経験から得られたTipsやメリット及びデメリットについて紹介したいと思います。 MySQLの全文検索とは MySQLの全文検索は、検索対象のカラムにFULLTEXTタイプのインデックスを貼るだけで簡単に実現することが可能です。また、パーサーは、デフォルトの ngram とインストール可能な MeCab のどちらかを選択できます。しかし、AWS環境でAurora MySQLやRDS MySQLを使用している場合は、ngramパーサーしか選択できないことに注意してください。 以下にngramパーサーを用いてフルテキストインデックスを貼る例を示します。 # 単一カラムにインデックス貼る場合 alter table videos add FULLTEXT index ngram_idx (title) with parser ngram; # 複数カラムにインデックス貼る場合 alter table videos add FULLTEXT index ngram_idx (title, introduction) with parser ngram; 全文検索は以下の3種類があり、これらの中から1つをmodifierとして指定できます。 自然言語検索:検索文字列を人間の自然な単語のフレーズとして扱う(デフォルト) boolean検索:完全一致検索(大文字、小文字、ひらがな、カタカナ完全区別する)、特殊演算子(+,-,*)を使ってAND検索やOR検索が可能。 クエリ拡張検索:自然言語検索の拡張版。自然言語検索が最初に実行され、その結果、最も関連のあるレコードの単語が検索文字列に追加され、再検索を行う。 全文検索を実行する際には、以下のように MATCH() AGAINST() シンタックスを使用します。 # title, introductionで複合インデックスを貼り、自然言語検索を実行する場合 select title from recipes where match (title, introduction) against ( " フレンチトースト " in natural language mode ); 全文検索の種類の選択 上述の通り、MySQLの全文検索には3種類のモードが存在します。完全一致させたいのであれば、boolean検索一択になるかと思いますが、自然言語検索も検索文字列をダブルクォートで囲むことで、検索対象カラムにその文字列が含まれているレコードをマッチさせることが可能と言われています。そのため、自然言語検索もboolean検索と同等の検索結果が得られるのはないかと考え、検索結果を比較してみました。 MATCH()  関数は、返り値として適合度(relevance value)を数値として返します。この適合度は、行(ドキュメント)内の単語数、行内のユニーク単語数、コレクション内の単語の総数、及び特定の単語を含む行数に基づいて計算されます。すなわち、適合度が高いほど検索文字列と類似性の高いレコードであることが分かります。 この適合度をscoreとして扱い、まずは自然言語検索で関連性が高いと判断されたレシピTOP10を見ていきましょう。 「ロールキャベツ」、「ピーマン 肉詰め」を検索してみましたが、検索意図に合ったレシピが上位10件に含まれており、検索結果として良さそうです。 # 「ロールキャベツ」を検索 mysql> select title, match (title, introduction) against ("ロールキャベツ" in natural language mode) as score from recipes where match (title, introduction) against ("ロールキャベツ" in natural language mode) order by score desc limit 10; +------------------------------------------------------------------------------+-------------------+ | title | score | +------------------------------------------------------------------------------+-------------------+ | とっても簡単!逆ロールキャベツ | 76.14885711669922 | | ロールキャベツの巻き方 | 70.85739135742188 | | 旨味ぎっしり リゾット風ロールキャベツ | 56.6859130859375 | | のせるだけ ロールキャベツ | 47.8058967590332 | | 巻かない コーンクリームのミルフィーユロールキャベツ | 47.8058967590332 | | 基本のロールキャベツ | 47.8058967590332 | | ウインナーとチーズの変わり種ロールキャベツ | 47.8058967590332 | | コンソメ味のシンプルロールキャベツ | 47.8058967590332 | | ロールキャベツ | 47.8058967590332 | | トマトクリームの巻かないミルフィーユロールキャベツ | 47.8058967590332 | +------------------------------------------------------------------------------+-------------------+ # 「ピーマン 肉詰め」を検索 mysql> select title, match (title, introduction) against ("ピーマン 肉詰め" in natural language mode) as score from videos where match (title, introduction) against ("ピーマン 肉詰め" in natural language mode) order by score desc limit 10; +-----------------------------------------------------------------------------------------------------------------+--------------------+ | title | score | +-----------------------------------------------------------------------------------------------------------------+--------------------+ | 【後藤シェフ】ピーマンの肉詰め&かぼちゃのポタージュ&ペペロンチーノライス | 66.87279510498047 | | 【後藤シェフ】ピーマンの肉詰め | 66.87279510498047 | | ピーマンの肉詰め | 56.90451431274414 | | 種ごとピーマンの肉詰め | 56.90451431274414 | | ひとくちピーマンの肉詰め | 53.58710479736328 | | 五目春雨のピーマンカップ詰め | 50.7970085144043 | | とろーりチーズがたまらない!まるごとピーマンの肉詰め | 50.368736267089844 | | 大豆ミートでピーマンの肉詰め | 50.154598236083984 | | ピーマンの肉詰め カレー風味 | 50.154598236083984 | | ピーマンのご飯入り肉詰め | 50.154598236083984 | +-----------------------------------------------------------------------------------------------------------------+--------------------+ 続いて、私の好きな料理でもある「キッシュ」で検索したところ、約半数はキッシュのレシピが表示されましたが、下位4つはキッシュと関係のないレシピがヒットしてしまいました。おそらく、マッシュルームやラディッシュがキッシュと近しい言葉であると判断されているのでしょう。 mysql> select title, match (title, introduction) against ("キッシュ" in natural language mode) as score from videos where match (title, introduction) against ("キッシュ" in natural language mode) order by score desc limit 10; +--------------------------------------------------------------------------------------------------------+--------------------+ | title | score | +--------------------------------------------------------------------------------------------------------+--------------------+ | 豆乳で和風キッシュ | 28.9677734375 | | かぼちゃとカリフラワーのパンキッシュ | 28.9677734375 | | 北海道アスパラガスとベーコンの簡単キッシュ | 28.9677734375 | | 彩り夏野菜の パンキッシュ | 28.9677734375 | | アスパラベーコンのバゲットキッシュ | 28.9677734375 | | イングリッシュマフィンで ズッキーニのフラワーパンキッシュ | 27.980960845947266 | | 【マッシュルームトーキョー】マッシュルームとチキンのハニーマスタード | 21.8929500579834 | | 【マッシュルームトーキョー】マッシュルームあんかけ和風ハンバーグ | 21.8929500579834 | | 【マッシュルームトーキョー】マッシュルームの炊き込みご飯と味噌汁 | 21.8929500579834 | | ラディッシュとマッシュルームのバター醤油炒め | 21.8929500579834 | +--------------------------------------------------------------------------------------------------------+--------------------+ boolean検索で同じく「キッシュ」を検索したところ、TOP10は全てキッシュのレシピであること確認できました。 mysql> select title, match (title, introduction) against ("キッシュ" in boolean mode) as score from videos where match (title, introduction) against ("キッシュ" in boolean mode) order by score desc limit 10; +---------------------------------------------------------------------------------------+--------------------+ | title | score | +---------------------------------------------------------------------------------------+--------------------+ | 冷凍パスタの簡単おかず ハムカップdeパスタキッシュ | 36.209716796875 | | 豆乳で和風キッシュ | 28.9677734375 | | かぼちゃとカリフラワーのパンキッシュ | 28.9677734375 | | 北海道アスパラガスとベーコンの簡単キッシュ | 28.9677734375 | | 彩り夏野菜の パンキッシュ | 28.9677734375 | | アスパラベーコンのバゲットキッシュ | 28.9677734375 | | チーズたっぷり じゃがいもとベーコンのキッシュ | 28.9677734375 | | イングリッシュマフィンで ズッキーニのフラワーパンキッシュ | 27.980960845947266 | | キャベツの食パンキッシュ | 21.725830078125 | | たっぷりきのこのキッシュ | 21.725830078125 | +---------------------------------------------------------------------------------------+--------------------+ 自然言語検索は、完全一致しなくとも検索文字列に近しいレコードを返してくれるため、boolean検索のデメリットを補うことができると感じました。しかし、検索文字列によっては検索意図から外れる結果になることがあるため、今回は意図通りの検索結果が得られやすいboolean検索を選択しました。 検索ヒット率を考慮する場合、最初にboolean検索を実行し、ヒットしなかった場合には自然言語検索で再検索を実行すると良いかもしれません。ただし、2回検索を実行する点でパフォーマンスが悪化してしまう懸念があります。 クエリ拡張検索でも、同じく「キッシュ」で検索してみましたが、自然言語検索及びboolean検索は0.01-0.02秒程度で検索結果が返ってくるのに対して、クエリ拡張検索では2-3分程度かかってしまい、加えて検索意図と全く異なるレコードが返ってきたため、却下しました。 mysql> select title, match (title, introduction) against ( "キッシュ" in natural language mode with query expansion ) as score from videos where match (title, introduction) against ( "キッシュ" in natural language mode with query expansion ) order by score desc limit 10; +--------------------------------------------------------------------------------------------+-------------------+ | title | score | +--------------------------------------------------------------------------------------------+-------------------+ | 豆乳で作ったヨーグルトで和風アボカド冷製パスタ | 4640.31787109375 | | 紅茶が香る りんごがのったふわふわマフィン | 4060.578369140625 | | フライパン1つで完成!こってりたっぷり豚の角煮風 | 3647.45849609375 | | とろうま ハーブシュリンプとナスのとろたまチリソース | 3316.906494140625 | | さわやかな酸味 北海道の秋鮭とポテトのレモンクリーム煮 | 3312.131103515625 | | 食べ応え抜群!ハーブシュリンプとニラのカリカリもっちりチヂミ | 3130.677490234375 | | 【名古屋】焦がしバターでやみつき! ガリバタ肉野菜正麺 | 3061.77734375 | | 【仙台】焦がしバターでやみつき! ガリバタ肉野菜正麺 | 3059.89990234375 | | 【北海道】焦がしバターでやみつき! ガリバタ肉野菜正麺 | 3059.4404296875 | | 【長野】焦がしバターでやみつき! ガリバタ肉野菜正麺 | 3058.764892578125 | +--------------------------------------------------------------------------------------------+-------------------+ 10 rows in set (2 min 35.11 sec) FULLTEXTインデックス設計と 照合順序の選択 今回のMVP開発の要件は、コンテンツのタイトル、キャプション、およびクリエイター名を検索対象のカラムとし、ひらがな、カタカナ、大文字、小文字などの表記ゆれに対応することでした。 使用するboolean検索は、大文字、小文字、ひらがな、カタカナを完全区別するため、検索ヒット率が低下する可能性があります。そのため、デフォルトの照合順序である utf8mb4_general_ci から変更する必要がありました。既存のカラムの照合順序を変更するよりも、全文検索用のカラムを新設し、そのカラムの照合順序を変更する方がリスクが少ないと考えました。全文検索用カラムの照合順序には utf8mb4_unicode_ci を選択しました。 utf8mb4_unicode_ci は、濁音・破裂音の区別などもなくなりますが、許容範囲としました。 全文検索用のカラムは、タイトル、キャプション、及びクリエイター名のそれぞれに別々のカラムを作成して、それらをマルチインデックスにする予定でした。しかし、検索対象のカラムは増減する可能性があること、また、FULLTEXTのマルチインデックスを貼るよりも、1つのカラムにインデックスを貼った方がレスポンスが早いことが検証により判明しました。従って、新設する全文検索用のカラムは1つにし、既存のタイトル、キャプション、及びクリエイター名の各カラムの中身を CONCAT したものをコピーすることにしました。 回数 2カラムに対してインデックスを貼った場合 (s) 1カラムに対してインデックスを貼った場合(s) 1 1.30 0.35 2 0.55 0.27 3 0.55 0.27 4 0.39 0.28 5 0.39 0.25 Railsで照合順序の指定、FULLTEXTインデックスを貼る場合は以下のようにマイグレーションファイルを記載します。 add_index を使用してFULLTEXTタイプのインデックスを作成することもできますが、パーサーの指定ができません。そのため、直接SQL文をマイグレーションファイルに書く必要があります。 class AddColumnsToVideos < ActiveRecord :: Migration [ 6.1 ] def change add_column :videos , :full_text_search , :text , collation : ' utf8mb4_unicode_ci ' execute ' alter table videos add FULLTEXT index index_recipes_on_full_text_search (full_text_search) with parser ngram ' end end Railsで全文検索を実現するための実装例 ここからは、Railsで全文検索機能を実装するために考慮した点について説明していきます。 まず、全文検索に使用するモデルのインスタンスメソッドは、以下のように定義しました。 def self . full_text_search (keyword) ngram_words = generate_ngram_words(keyword) boolean_mode = ' match (search_full_text) against (? in boolean mode) ' search_text = ngram_words.map { |key| " + #{ key }" }.join( ' ' ) sanitize_sql = sanitize_sql_array([ " *, #{ boolean_mode } as score " , search_text]) where( "#{ boolean_mode } and #{ boolean_mode } > 10 " , search_text, search_text).select(sanitize_sql) end def self . generate_ngram_words (keyword) keywords = keyword.split ngram_words = [] keywords.each do |item| words = item.chars words.each.with_index( 1 ) do |word, i| ngram_words << (word + words[i]) unless words.size == i end end ngram_words end 少し複雑になっていますが、検索文字列を ngram_token_size に合わせた文字数に分割し、AND 検索をするために + 演算子を使用しています。今回は、 ngram_token_size=2 であるため2文字ごとに検索文字列を区切っています。 実際のMySQLのクエリに置き換えると以下のようになります。 SELECT *, MATCH (search_full_text) AGAINST ( ' +作り +り置 +置き +お弁 +弁当 ' IN BOOLEAN MODE ) AS score FROM cards WHERE publish_status = ' published ' AND MATCH (search_full_text) AGAINST ( ' +作り +り置 +置き +お弁 +弁当 ' IN BOOLEAN MODE ) AND MATCH (search_full_text) AGAINST ( ' +作り +り置 +置き +お弁 +弁当 ' IN BOOLEAN MODE ) > 10 このように検索文字列を分割して渡す理由は、MySQLのInnoDBにおけるFULLTEXTインデックスが転置インデックスの設計に基づいているためです。 ngram_token_size で指定した文字数以外の検索文字列が渡ってきた場合、パフォーマンスが低下することが検証中に判明したため、 ngram_token_size にあわせて分割するようにしました。その結果、2秒かかっていたクエリが0.6s程度に短縮されました。 Rspecで全文検索のテストを行うときの注意点 通常通り、Rspecでテストを書き実行すると、全文検索に関連するテストは軒並み失敗します。これは、Railsがngramに対応しておらず、マイグレーションを実行しても、schemaファイルにパーサーの記述が反映されないためです。私たちのRspecは、schemaファイルを元にしてテストDBが作成されているため、パーサーの指定が反映されず、全文検索を実行しても何のコンテンツも返ってきませんでした。 テストDBを作成する際に、schemaファイルではなく、マイグレーションファイルを元にすることもできますが、全体のSpecにも影響があるため、この方法は見送りました。 代わりに、 DatabaseCleaner を使用して、全文検索のテスト前に FULLTEXT インデックスを再構築するようにしました。通常は、テスト実行前にインデックスを再構築すると、トランザクションの外側でDBデータが作成され、残ってしまうことがありますが、DatabaseCleanerを使用すると、残ってしまったデータを綺麗に削除してくれます。 実装例は以下の通りです。 clean_database=true の場合のみ truncation が実行されます。 # rails_helperの設定 config.before(:each, clean_database: true ) do DatabaseCleaner.strategy = :truncation DatabaseCleaner. start end config.after(:each, clean_database: true ) do DatabaseCleaner.clean end # 実際のspec context ' 公式レシピが存在しないとき ' , clean_database: true do before do recreate_indexes_with_ngram_parser end let(:params) { { query: ' 麻薬卵 ' , page_size: 3 , next_page_key: nil } } it ' UGCコンテンツが返ること ' do subject expect(response). to have_http_status( 200 ) expect(response_data.map { |obj| obj[ ' type ' ] }.uniq.sort). to eq([ " videos " , " cards " ]) expect(response_json[ ' meta ' ][ ' total-count ' ]). to be >= 3 end def recreate_indexes_with_ngram_parser ActiveRecord::Base.connection. execute ( ' drop index index_cards_on_full_text_search on cards ' ) ActiveRecord::Base.connection. execute ( ' drop index index_videos_on_full_text_search on videos ' ) ActiveRecord::Base.connection. execute ( " alter table cards add FULLTEXT index index_cards_on_full_text_search (full_text_search) with parser ngram " ) ActiveRecord::Base.connection. execute ( " alter table videos add FULLTEXT index index_videos_on_full_text_search (full_text_search) with parser ngram " ) end end 運用上で発覚した課題 Auroraはinnodb_ft_result_cache_limitを変更できない MySQL innoDBは、各全文検索クエリ、またはスレッドごとに検索結果のキャッシュ上限値 ( innodb_ft_result_cache_limit ) を設定しています。つまり、テーブルのレコード数が増えると、全文検索のクエリ結果も比例して大きくなり、必要なキャッシュサイズが増えていくため、メモリを過剰消費しないように制限されています。しかし、 Bug#86036 に記載されているように、このパラメータは上限の最大値が4GBしかないため、大規模なテーブルの場合、この上限値を超える可能性があります。 *2 Auroraの場合、このパラメータのデフォルト値は2GBであり *3 、変更可能なパラメータのように見えますが、Aurora MySQL 5.7のパラメータグループには存在していません。(*)AWSサポートに問い合わせたところ、Auroraでは innodb_ft_result_cache_limit を変更できないとのことでした。ただし、AuroraではなくRDS MySQLを使用している場合は、このパラメータを変更することができます。 調査段階で4GBまでの上限があることは認識していましたが、Auroraでこの値を変更できないことは把握しておらず、2GBのままだと想定より早くキャッシュエラーが返ってくるようになりました。テーブルレコード数やデータ量にも依存しますが、私たちの場合、約74万レコードに達したタイミングでこのキャッシュエラーが発生するようになりました。 ただし、エラーが発生する頻度は稀であったため、まずは暫定的な対応を実施しました。Rails側では、キャッシュエラーが発生した場合には例外処理で空の配列を返すよう修正し、エラー発生頻度は検知できる状態を維持しました。 def fetch_contents (model, keyword) begin model.display_on_public.full_text_search(keyword).map do |record| { score : record.score.to_i, record : record } end rescue ActiveRecord :: StatementInvalid => e Sentry .capture_exception(e) [] end end また、長い検索クエリが渡ってきた場合には、キャッシュサイズが大きくなり、エラーが発生しやすいことが検証によって判明したため、検索クエリの長さにも制限を追加しました。 今振り返ってみると、このキャッシュエラーをできる限り防ぐ方法として、FULLTEXTインデックスに不要な文字列をパターンマッチさせて除外する仕組みも検討すればよかったと感じています。 MySQLのFULLTEXTインデックスの実装では、true word(文字、数字、アンダースコア)のみを文字として扱うため、記号等はFULLTEXTインデックスに追加されません。 *4 しかし、UGCコンテンツには、クリエイターが何かしらのURLを記載している場合など全文検索に不要な文字列もFULLTEXTインデックスに含まれてしまいます。そのため、レコードが追加・更新されるタイミングでパターンマッチで不要な文字列を取り除けば、FULLTEXTインデックスの肥大化の速度を落とすことが可能だったのではと思います。 ALTER TABLEするときにINPLACE方式が使えない MySQLの ALTER TABLE によるカラム追加や削除などのDDL操作は、通常INPLACE方式で変更できます。ただし、 FULLTEXT インデックスを持つテーブルに関しては、INPLACE方式による変更はサポートされておらず、COPY方式しか利用できないため、テーブルの更新時にロックがかかってしまいます。 *5 # fulltextインデックスを持つテーブルにてINPLACE方式でカラム追加しようとした場合 mysql> alter table videos add test varchar(255), LOCK=NONE, ALGORITHM=INPLACE; ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. Reason: InnoDB presently supports one FULLTEXT index creation at a time. Try ALGORITHM=COPY. MySQLの本番稼働中にロックされると、DBの負荷が高まり、障害につながる可能性があります。クラシル規模のサービスでは、サービスダウンが大きな損失につながるため、FULLTEXTインデックスを持つテーブルにカラムを追加・削除する必要が生じたタイミングで、MySQLからElasticsearchの全文検索に移行しました。Elasticsearchの導入実績が既にあったため、移行自体はスムーズに行えたと思います。 まとめ テーブルのレコード数が想定よりも早く増加したこと、及びMySQL側の制約によって、MySQLの全文検索を使用する期間は想定よりも短くなりました。しかし、MySQLの全文検索を運用する上で必要な知識を得ることができたため、私自身とても勉強になりました。 MySQLの全文検索は、簡単に導入でき、数十万程度までのレコード数が多くないテーブルに対しては、LIKE検索よりもレスポンスが早く返ってくるので、初期フェーズの検索機能を実装する場合には有用だと思いました。ただし、現状はMySQLの全文検索を長期間運用するには向いていないと思います。そのため、Elasticsearchなどへの移行を前提に、MySQLの全文検索を導入することがベストだと感じました。 この記事が誰かの役に立てれば幸いです。 *1 : 詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド(奥野 幹也)|翔泳社の本 p.168 *2 : MySQL8.0でもinnodb_ft_result_cache_limitの上限の最大値は 4GB から変更されていません。 *3 : Amazon Aurora MySQL データベース設定のベストプラクティス | Amazon Web Services ブログ , パラメータの分類 *4 : MySQL :: MySQL 5.7 Reference Manual :: 12.9.1 Natural Language Full-Text Searches *5 : MySQL :: MySQL 5.7 Reference Manual :: 14.13.1 Online DDL Operations
アバター
こんにちは、クラシルPOの小川です。 この界隈でプロダクト開発をしていると、 「MVPで開発しよう」という発言をしたり聞いたりすると思います。 このMVPに対する認識が、おそらく会社や組織、個人間で様々だと感じています。 現在はクラシルアプリの開発を管掌していますが、以前は新規事業の開発を管掌していました。 新規事業と既存事業の開発をする中で、MVPのニュアンスが自分の中で違うなと感じることがありました。 MVPってなんだろうと考えていたのですが、そんな時にテックブログという機会があったので吐き出していこうと思います。 あたらめて、MVPとは? 一般的な答えをgoogleさんで検索すると、下記のような結果を得られました。 MVP(Minimum Viable Product)とは、顧客に価値を提供できる最小限のプロダクトのことを指します。 完璧な製品・サービスを目指すのではなく、顧客が抱える課題を解決できる最小限の状態で提供します。 提供後は、顧客からのフィードバックなどを参考にし、新機能の追加や改善点の見直しを図ります。 顧客に価値を提供できる最小限のプロダクトのことらしいです。 プロダクトは機能にも置き換えられそうですね。 では、何を持って「最小限の状態」と言えるのか。 「どのようにMinimizeされるべきなのか」が、この問いの解にあたります。 僕個人の経験として、理想的なプロダクトの状態・機能があるが、 実装が難しそうだから簡易的なものを作ってみよう 全部やると工数がかかりそうだから、工数少なくできる方法で開発してみよう 競合の提供している機能のコア部分だけ最小限で開発しよう などいろいろなMVPを開発してきました。 これらは難易度や工数、機能そのものなどのMinimizeをしていますね。 これらは、 結果的に正しいMinimize になることはありますが、 もう少し踏み込んで考えることで、より良い最小限の状態を実現できるのではないかと考えています。 どのようにMinimizeされるべきなのか 本題です。 結論として、MVPは 「問題に対しての課題設定と、その課題に対する解決策が適切だったかを検証できて、その後学習できる」 ようになるまでMinimizeされるべきだと思っています。 長いですね。もう少し端的に言えたらよかったですが、 次の項目で、良いと考えるMinimizeについて説明していきます。 ※ここで定義されている「課題」や「解決策」は、ユーザーの持つ課題や、ユーザーに提供する解決策ではありません。下記に定義を記載しておきます。 自分は仮説を整理するときに、「問題・課題・解決策」に分けて考えます。よくあるフレームワークなので、気になる方は検索してみてください。 簡単に説明すると以下になります。 問題 ・・・ 理想的な状態と現状のギャップ 課題 ・・・ 問題を取り除くために達成すべきもの 解決策 ・・・ 課題を解決するために行う策 惜しいMinimizeの例 プロダクト開発には不確実性がつきものです。 そんな不確実性の中で様々な仮説を持って開発を行っていきます。 例えば、「ユーザーに適したコンテンツを届けたいから、機械学習アプローチで推薦システムを作ろう」となった時、どのように開発を開始しますか。 いきなり推薦システムを作り込む意思決定はできないと思います。作り込むには膨大なコストがかかり、たとえ作り込んだとしてもウケるかわからないからです。 そこで、「市場に早く出して反応みよう」「不確実性が高いものを作り込まず試すそう」と言って、推薦システムをMVPで作ります。 とりあえず初手で協調フィルタリングで推薦アルゴリズム試してみる ユーザーが閲覧したコンテンツのカテゴリと同じコンテンツを推薦する パッと最初に考えつくのはこのあたりでしょうか。 ここから深く考えていくこともあると思いますが、例のために上記を要件としてMVPを作成するとします。 それって本当に良いMVPと言えるでしょうか。 「ユーザーに適したコンテンツ届けられていない」という問題に対して、課題が曖昧になっており、解決策ファーストでMVPが作られてしまっています。 課題が精査されないままリリースを行うと、結果「うまくいかないことが分かった」という意味のない結果が起こりえます。検証・学習のサイクルが回りません。 もう少し踏み込んだMinimize 不確実性が高い問題解決のための適切なアプローチを探すために、我々は「開発・検証・学習」のサイクルを回せる状態にする必要があります。 このサイクルを回すために、 課題設定が必要不可欠 です。 下記に例を示しました。 課題設定をするのとしないのでは、解決策でやることの解像度も違うことがわかります。 「よし!じゃあ、課題設定もできたし趣向をクラスタリングして推薦する事にとりかかろう!」と言いたいところですが、この解決策は「趣向に合わせて配信できるようにする」という課題設定が正しい場合に有効な解決策です。 それなりに大きな実装なので、課題設定が正しいことを担保してから取り掛かりたいです。 この課題設定が正しいことを検証するために、MVPを用いましょう 。 この解決策を実装し反応を見ることで、課題設定の確らしさを検証します。 たとえ反応が悪かったとしても、得られた結果から次の課題設定の糧にします。 違う課題が設定できるかもしれないし、既存の課題の解像度が上がるかもしれないです。もしかしたら、課題に対する解決策の方がアップデートできるかもしれません。 いずれにしろ次につながるので、「開発・検証・学習」のサイクルが回り、「うまくいかないことが分かった」などという曖昧な形で終わることはありません。 余談 機械学習という専門的なテーマを挙げましたが、実際はより高度な検証をおこなうかと思います。 この時、機械学習知見があるかつPdMのようなロールもできると、自身の頭の中で高度なやりとりができるので、かなり効果的で早いサイクルを回せるようになります。 この領域はスキルの掛け合わせのレバレッジがかけやすいですね。 最後に 再度結論になりますが、 MVPは「問題に対しての課題設定と、その課題に対する解決策が適切だったかを検証できて、その後学習できる」ようになるまでMinimizeすると良いです。 決して、工数や機能そのもののMinimizeではありません。結果としてそうなっているだけです。 MVPについて話すつもりが、課題設定の話になってしまいました。 ですが、ある種本質のテーマだと思っています。 巷では、PdMなどに求められるスキルとして、課題設定能力と言われることが多いかと思います。(細かくいうと問題設定の確らしさもありますが割愛) 課題設定を行うには、自社・他社のサービス触ってユーザーを想像して考え抜く。 レビューなどを見るのも手です。様々な手段でプロダクトへの所感を集めて、それらで帰納的に所感を抽象化し課題設定をしていきます。 数値を見たり、ユーザーインタビューしたりするような業務全ては、確度の高い課題設定するためと割り切ってもいいかもしれません。 課題設定能力はかなり応用がきく能力だと思います。 課題設定をプロダクト方面に向けるのがPdMやPOですが、これを事業や会社方面に向けられると、さらに視野や視座が上がった状態と言えるでしょう。 この辺りも泥臭く頑張っていこうと思います。
アバター
はじめに こんにちは!クラシルでQAを担当しているumepiです! 今回のブログでは、「他人軸ではなく自分軸で行うQA業務」についてを書いていきます。 個人的な社会人2年目の自身の課題として、内観を私生活で進めています。そんな中で、仕事においてももっと自分軸で進められるのでは?と考えたことが今回のテーマのきっかけです。 自分軸とは 他人がどう思うかに左右されず、「自分はどうしたいか」を基準に行動する ことです。 自分がこの挑戦をすると相手がネガティブに受け取るかもしれないから、その挑戦はやめておこうというような経験はありませんか? 本来は「他人から嫌われないようにしよう」という考えが他人軸で、「自分はどうしたいか」が自分軸だと思うのですが、QAの業務においても近い部分はあるのかなと思っています。 品質保証という、リリース予定の機能をリリースされる前に品質に問題がないか検証するというQAの仕事を行っていると、締め切りや開発の事情などがあるが故に追われていると感じやすく、「他人の行動に影響されて自身の行動が変わってくる」という他人軸があるように私は感じました。 そのような中でも、私たちQAチームはなるべく自分軸で仕事ができるような工夫をいくつか行っているのでご紹介します。 先に情報を取りに行く姿勢 機能の追加や変更に対するQA依頼を受けて、テスト計画からテストの完了までを進めるという流れが、ざっくりとした従来のテストの流れです。 私たちの従来の方法では依頼が多かったり確認に時間がかかってしまうと、フレックスであるにも関わらず、プライベートやライフスタイルに影響が出てしまう状態でした。 「想定外の急な依頼によりタスクの進め方を左右される」という他人軸で、急な依頼があるかもしれないという不安もあり、ヘルシーな心ではありませんでした。 事前に情報を得て設計、レビューを行い、QA依頼を受けたタイミングで実行から進めるという流れが現在の新しい方法です。 従来の方法との最大の違いは、QA依頼を受ける前に新規機能開発を行う各スクラムの開発状況の情報をJIRAで確認しているという点です。(JIRAの機能の活用についてはshiominさんが以前の記事で紹介しています。) tech.dely.jp この新しい方法はQAチームリーダーのshiominさんによる提案なのですが、事前に変更内容や意図、進捗を知ることができます。そのため、どういう変更の依頼に対しどのような実行が必要で、いつ頃依頼を受けるかがわかるというメリットがあります。 仕様確認、設計、レビューを自分たちのタイミングで行うことができ、テストタスクの全量のイメージができるようになりました。それにより、「他人の行動に影響されて自身の行動が変わってくる」「想定外の急な依頼によりタスクの進め方を左右される」という他人軸の部分が減ったように思います。 素直に伝える 私たちQAチームでは素直に自身の状況を伝えるようにしています。挑戦したいこと、取り組みたい課題、プライベートなどをお互い伝え合うようにしています。 自分軸のデメリットとして、周囲の人が離れてしまったり、対立関係になってしまうことがあります。よって「他人から嫌われないようにしよう」という他人軸の考えを持ってしまい、自分の本来望んでいる行動ではない行動をとってしまうかもしれません。 ですが、相手の感情が自分の想像する相手の感情と必ずしも同じとは限りません。もしかすると、相手は気にしているだろうと自分が思っていても、相手は全く気にしていないかもしれません。自分の思っていることを伝え、どう進めていくことが業務を達成する上でベストなのかを一緒に決めていくことが、なるべく自分軸で業務を行おうとする上で重要なのではないかと考えています。 しかし、素直に伝えることは心理的安全性が必要な場合もあるかもしれません。そのために私たちは毎朝の朝会でGood&Newに取り組みお互いを知ることで、心理的安全性を得ています。 また、できないことはできないと言う気持ちも必要かと思います。急ぎでなければ来週でも構わないかどうかを確認し、タスクに対し自分軸で取り組めるよう心がけています。 さいごに 自分軸で業務に取り組むことで心もヘルシーに、キャリアや自身の取り組みたいことに対してつながっていくのではないかと考えています。 この内容が少しでも見ていただいた方の参考になればと思います!
アバター
はじめに こんにちは!クラシルでプロダクトデザイナーをしているkashikoです! 今回のブログでは、「Zapierを活用したデザインチームの業務改善ナレッジ」を書いていきます。 Zapierとは操作の自動化を非エンジニアでも簡単に行えるツールで、私たちの場合は Slackで特定のスタンプを使うとNotionにリスト形式で自動でストックする のに使用しています。クラシルのデザインチームでこの機能をどのように活用し、チームとしてのレベルアップを図っているのかご紹介します。 ユースケース クラシルデザインチームがZapierを利用しているのは以下の3つのシーン。 ①実機を触っていて見つけたデザイン改善タスクのストック ②デザインの過程でリサーチしたナレッジのストック ③相互に行うデザインレビューのストック です。 それぞれ具体的にどのような運用になっているのか、詳しく見ていきます! ①デザイン改善タスク デザイン改善ストック 「デザイン改善」では、 特定のチームに紐づきにくいUI/UX上の改善点 や デザインシステムを運用していて出た課題 、更には閲覧頻度の低い箇所に残っていた旧ロゴや旧カラーの掲出箇所をストックしています。 誰もアサインされなかったものはNo Assignとして一番左に、担当者が決まったものは担当者ごとの列に表示されるようにし、担当が明確でないことで長期間放置されてしまう、ということを防ぐために仕組み化しています! ②デザインの過程でリサーチしたナレッジの蓄積 クラシル開発部には現在6名のデザイナーが在籍しており、それぞれ別のsquadに配属しています。 自分が現在取り組んでいることが、別の時期に他のデザイナーの参考になることがある ため、デザインリサーチDBではデザインワークの過程で調べた他社の参考事例などを簡単にストックすることで、 似たリサーチを何度も0からしなくてもいいようになりました。 また、ジャンルをタグづけすることで、膨大な情報量の中からも必要な時に必要な参考例を取り出せるように工夫しています。 ③レビューストック クラシルでは、デザインが実装される前に、 デザインの品質を高めることを目的としてsquadを跨いでデザイナー間でレビュー を行っています。 この「レビューストック」は、Slack上でやりとりされたレビューそのものをストックしていくことで、 「レビューそのもの」を振り返り、どんな形式でレビューしたらより品質を高めることができるのか、改善していく ことを目的に運用しています。 レビューをする側・される側のちょっとした工夫がレビュー及び最終的な成果物の品質に影響していくので、このストックをもとに、より良くなるようフォーマット化していく予定です! 安定的な運用のためにおすすめしたいこと ●スタンプは直感的なものにする スタンプをわかりやすく変更 どのスタンプで何がストックされるのか、直感的に分かるもの を設定することをおすすめします! デザインチームでも当初、「デザスト」というスタンプでデザインレビューをストックして行っていたので、ナレッジシェアと間違えてしまう事態が頻発していました。。。 正しいページに正しく情報をストックするために、地味ですが大事なポイントです◎   ●見返す仕組みも同時に作る また、 手軽にストックするだけではなく、それを定期的に取り出せる仕組みも同時に作る ことを強くおすすめします。 ただ溜め続ける運用では、肝心な時に見返し忘れたり、一人だけ情報ストックにコミットし続ける・・といった状況になりかねません。 そこでクラシルデザインチームでは、月曜日を改善タスク確認デーとし、朝会前にbotを設定することで週に一回必ず進捗や状況を確認できるようにしています。 また、デザイナー・PdMのNotionワークスペ ースのトップにもリンクを貼ることで「あれ、どこ行ったっけ・・?」を防いでいます◎     そもそも、 ナレッジシェアや改善タスクは全員がコミットしなければ、仕組みとして成立しづらい ですし、貯め続けた情報は 適切なタイミングで取り出して活用しなければ貯めている意味を見出しづらく なります。 クラシルでは、情報を貯める・取り出すを同時に仕組み化することでこの問題を解決し、チームとしてデザイン改善に取り組める体制を少しずつ構築しています。 終わりに 今回は、「Zapierを活用したデザインチームの業務改善ナレッジ」を紹介しました! チームとしてデザイン品質を上げていきたい!という方の参考になれば幸いです◎
アバター
はじめに クラシルリワードについて クラシルリワードのiOSアプリについて 技術スタック Project Management Swift Package Managerのモジュール粒度 Package.swiftの例 Screen Architecture Screen Structure Builder Controller(UIHostingController) ScreenView(SwiftUI) ViewModel(ObservableObject) BaseViewModel Screen Navigation ConcurrencyのTask管理について DemoApp その他の取り組み 自動生成 Development Flow 最後に はじめに こんにちは!クラシルリワードiOSエンジニアのfunzinです。 この記事ではクラシルリワードのiOSアプリの構成について紹介していきます。 クラシルリワードについて クラシルリワードは「 日常のお買い物体験をお得に変える 」アプリです。 買い物のためにお店に行く(移動する)、チラシを見る、商品を買う、レシートを受け取る......。これら日常の行動がポイントに変わり、そのポイントを使って様々な特典と交換することができます。 詳しくはこちらのnoteをご確認ください。 delyは次の領域へ。「クラシルリワード」が切り拓く、新たな買い物体験と小売業界のDX クラシルリワードのiOSアプリについて 技術スタック クラシルリワードは昨年から開発を着手した新規サービスのため、最新の技術を積極的に取り入れています。 Project Management: Swift Package Managerを利用したマルチモジュール Core: SwiftUI, Swift Concurrency Screen Architecture: MVVM CI/CD: Xcode Cloud, GitHub Actions Library Management:CocoaPods, Swift Package Manager それぞれの詳細については、下記セクションで紹介していきます。 Project Management クラシルリワードのプロジェクトの構成はSwift Package Managerを利用したマルチモジュール構成です。広告SDKなど一部はCocoapodsで管理していますが、それ以外は基本的にSwift Package Managerで管理しています。 Swift Package Managerを利用したプロジェクト管理方法はd_dateさんのスライドに詳しく書かれていますのでそちらをご参考ください。 Swift Package中心のプロジェクト構成とその実践 クラシルリワードでのxcworkspaceでの構成は下記のようになっています。 xcworkspace swiftpm DemoApp Swift Package Manager RetailAppPackage(ローカルのSwiftファイル管理Package) xcodeproj RetailApp Debug Staging Production アプリの都合上複数の広告SDKに依存していますが、開発時には全ての広告接続先を確認する必要がないため、接続先数を最小限にしたRetailAppを普段の開発では利用しています。 Podfile上で abstract_target を利用することで特定のターゲットのみに広告SDKをインストールすることを実現しています。 workspace ' RetailApp.xcworkspace ' abstract_target ' Abstract ' do use_frameworks! :linkage => :static pod ' AdsSDK ' target ' RetailApp ' do project ' RetailApp.xcodeproj ' end abstract_target ' Abstract ' do use_frameworks! :linkage => :static pod ' AdsAdaper1 ' pod ' AdsAdaper2 ' pod ' AdsAdaper3 ' pod ' AdsAdaper4 ' target ' Debug ' do project ' Environment/Debug/Debug.xcodeproj ' end target ' Staging ' do project ' Environment/Staging/Staging.xcodeproj ' end target ' Production ' do project ' Environment/Production/Production.xcodeproj ' end end end (※ライブラリ名は仮です) ターゲット別にインストールされるCocoaPodsライブラリは下記になります。 RetailApp AdsSDK Debug, Staging, Production AdsSDK AdsAdaper1 AdsAdaper2 AdsAdaper3 AdsAdaper4 Swift Package Managerのモジュール粒度 Swift Package ManagerモジュールはApp, Feature, Coreレイヤーをベースとして分割しています。 module App 具体実装を各FeatureにDI 画面遷移先の解決(後述) Feature チラシやマイページなど機能単位で分かれている Featureモジュール間での依存は禁止 Core Featureレイヤーから利用される共通のロジック群 それぞれのモジュールを細かく分けているため、1モジュールあたりに含まれるSwiftファイルは数ファイルです。 そのため各モジュールでのUnitTestの実行時間も短縮することができ、開発効率が上がっています。 Package.swiftの例 RetailAppPacakgeで管理しているPackage.swiftは下記のようになっています。 // swift-tools-version:5.6 import PackageDescription // XCFramework let debug = Target.binaryTarget(name : "Debug" , path : "XCFrameworks/Debug.xcframework" ) // SPM Library let nuke = Target.Dependency.product(name : "Nuke" , package : "Nuke" ) let nukeUI = Target.Dependency.product(name : "NukeUI" , package : "Nuke" ) let nukeExtensions = Target.Dependency.product(name : "NukeExtensions" , package : "Nuke" ) // 後に説明 func targetsForDebug () -> [ Target ] { let isDebug = true if isDebug { return [debug] } else { return [] } } // Core let apiClient = Target.target( name : "APIClient" ) let apiClientTests = Target.testTarget( name : "APIClientTests" , dependencies : [ apiClient ] ) // Feature let leaflet = Target.target( name : "LeafletFeature" , dependencies : [ apiClient ] + targetsForDebug() ) let leafletTests = Target.testTarget( name : "LeafletFeatureTests" , dependencies : [ leaflet ] ) // App let app = Target.target( name : "App" , dependencies : [ leaflet ] , dependencyLibraries : [ nuke, nukeUI ] ) let package = Package.package( name : "RetailAppPackage" , platforms : [ .iOS ( .v15 ) ] , dependencies : [ .package ( url: "https://github.com/kean/Nuke" , from: "12.1.0" ) ] , targets : [ // App app, // Feature leaflet, // Core apiClient, // Library debug ], testTargets : [ leafletTests, apiClientTests ] ) extension Target { private var dependency : Target.Dependency { .target(name : name , condition : nil ) } fileprivate func library (targets : [ Target ] = []) -> Product { .library(name : name , targets : [ name ] + targets.map(\.name)) } static func target ( name : String , dependencies : [ Target ] = [], dependencyLibraries : [ Target.Dependency ] = [], resources : [ Resource ] = [] ) -> Target { .target( name : name , dependencies : dependencies.map (\.dependency) + dependencyLibraries, resources : resources ) } static func testTarget (name : String , dependencies : [ Target ] ) -> Target { .testTarget( name : name , dependencies : dependencies.map (\.dependency) ) } } extension Package { static func package ( name : String , platforms : [ SupportedPlatform ] , dependencies : [ Dependency ] = [], targets : [ Target ] , testTargets : [ Target ] ) -> Package { Package( name : name , platforms : platforms , products : targets.map { $0 .library() }, dependencies : dependencies , targets : targets + testTargets ) } } String Literalではなく変数化しておくことで、Package.swift内でも補完が効くようにしています。 また開発環境のみで利用するライブラリは、XcodeCloudのpost_clone.shのタイミングでPackage.swiftを上書きすることで本番には含めないようにしています。 sed -i "" -E " s/let isDebug = true$/let isDebug = false/g " ./RetailAppPackage/Package.swift Screen Architecture 基本的な画面構成はSwiftUI + UIHostingControllerを採用しています。 フルSwiftUIにするかどうか議論はありますが、下記の理由でUIKit Navigation + SwiftUIを採用しました。 開発着手時はiOS15でありSwiftUIのNavigationに比べてUIKitのNavigationが安定しており、従来のUIKitを利用したiOS開発に近しい状態で開発が行える SwiftUI + UIHostingControllerで実現が難しいUIでも、UIViewControllerを利用したUIKitベースでの実装リプレイスが可能になる 非同期処理に関しては Swift Concurrency をメインで利用しています。 Screen Structure 1画面は以下の構成で構築されています。 それぞれの役割をサンプルコードを交えて説明します。 screen Builder 依存を解決してViewControllerを返却するのが責務 (※HomeTabScreenRequest.ViewControllerはUIViewController) public struct HomeTabBuilder : FeatureBuilderProtocol { private let userDefaultsClient : UserDefaultsClient private let apiClient : APIClient private let routerService : RouterService public init ( userDefaultsClient : UserDefaultsClient , apiClient : APIClient , routerService : RouterServiceProtocol ) { self .userDefaultsStore = userDefaultsStore self .apiClient = apiClient self .routerService = routerService } public func buildViewController (request : HomeTabScreenRequest ) -> HomeTabScreenRequest.ViewController { let viewModel = HomeTabScreenViewModel(state : . init (), dependency : . init ( userDefaultsClient : userDefaultsClient , apiClient : apiClient )) let vc = HomeTabViewController(rootView : HomeTabScreenView (viewModel : viewModel ), viewModel : viewModel , routerService : routerService ) return vc } } Controller(UIHostingController) 画面遷移の制御を主に行います。 Combineで画面遷移を実現していますが、AsyncStreamでも置き換え可能です。 final class HomeTabViewController : UIHostingController < CoinTabScreenView > { private var cancellables = [AnyCancellable]() private let viewModel : HomeTabScreenViewModel private let routerService : RouterServiceProtocol init (rootView : HomeTabScreenView , viewModel : HomeTabScreenViewModel , routerService : RouterServiceProtocol ) { self .viewModel = viewModel self .routerService = routerService super . init (rootView : rootView ) } override func viewDidLoad () { super .viewDidLoad() viewModel.output .receive(on : DispatchQueue.main ) .sink( receiveValue : { [ weak self ] output in guard let self else { return } switch output { case .coin : let vc = self .routerService.buildViewController(request : CoinScreenRequest ()) self .navigationController?.pushViewController(vc, animated : true ) } }) .store( in : & cancellables) } } ScreenView(SwiftUI) 画面の見た目を表現するためのView 画面遷移はUIKitに依存しているため、SwiftUIのView上では画面遷移を行わない struct HomeTabScreenView : View { @StateObject var viewModel : HomeTabScreenViewModel var body : some View { Text( "Hello, World!" ) } } ViewModel(ObservableObject) BaseViewModelのサブクラスとして定義(後述) 画面遷移をControllerでハンドリングできるようにOutputを定義 final class HomeTabScreenViewModel : BaseViewModel < HomeTabScreenViewModel > { required init (state : State , dependency : Dependency ) { super . init (state : state , dependency : dependency ) }   func didTapCoinButton () { send(.coin) } } extension HomeTabScreenViewModel { struct State { fileprivate ( set ) var title : String = "title" } struct Dependency : Sendable { let userDefaultsClient : UserDefaultsClient let apiClient : APIClient } enum Output { case coin } } BaseViewModel State, Dependency, Outputを定義しているViewModelのBaseClass sendメソッド経由でViewControllerにoutputを伝搬する BaseViewModelを導入している意図としては、複数人で開発する上で書き方を統一したいためです 現状はBaseViewModelで統一していますが、別のアーキテクチャ(e.g. TCA , Actomaton )に置き換えたいとなった時に備えて、なるべく依存度が低いように設計しています。 public protocol LogicProtocol { associatedtype State associatedtype Dependency = Void associatedtype Output = Void } public typealias BaseViewModel< Logic : LogicProtocol > = ViewModel < Logic > & LogicProtocol @MainActor open class ViewModel < Logic : LogicProtocol >: ObservableObject { public typealias Dependency = Logic.Dependency public typealias State = Logic.State public typealias Output = Logic.Output @Published public var state : State public let dependency : Dependency public var cancellables : Set < AnyCancellable > = [] public let output : AnyPublisher < Output , Never > private let outputSubject = PassthroughSubject < Logic.Output, Never > () public required init (state : Logic.State , dependency : Logic.Dependency ) { self .state = state self .dependency = dependency self .output = outputSubject.eraseToAnyPublisher() } public convenience init (state : Logic.State ) where Logic.Dependency == Void { self . init (state : state , dependency : () ) } public func send (_ output : Logic.Output ) { outputSubject.send(output) } } Screen Navigation UIHostingControllerを利用しているため、画面遷移はUIKitのNavigationを利用しています。 画面と1対1の対応関係であるStruct(XXXScreenRequest)をキーとして、画面遷移を実現しています。 e.g. AFeatureでBScreenに遷移する場合 import BScreenRequest let request = BScreenRequest() let vc = routerService.buildViewController(request : request ) present(vc, animated : true ) buildViewControllerで返却する画面はAppモジュール内にあるRouterServiceが解決しています。 extension RouterService { public func buildViewController < ScreenRequest > (request : ScreenRequest ) -> ScreenRequest.ViewController where ScreenRequest : ScreenRequestProtocol { switch request { case let request as AScreenRequest : return build(request) as! ScreenRequest.ViewController case let request as BScreenRequest : return build(request) as! ScreenRequest.ViewController default : fatalError ( "should not reach here" ) } } } extension RouterService { func build (_ request : AScreenRequest ) -> AScreenRequest.ViewController { let builder = AScreenBuilder() return builder.buildViewController(request : request ) } func build (_ request : BScreenRequest ) -> BScreenRequest.ViewController { let builder = BScreenBuilder() return builder.buildViewController(request : request ) } } こちらはCookpadさんのresolverの仕組みと近しいです。 コード生成を用いたiOSアプリマルチモジュール化のための依存解決 ConcurrencyのTask管理について Swift Concurrencyで利用しているTask管理については、TaskManagerというクラスを用意して管理しています。 TaskManagerはdeinitされたタイミングで、保持しているtaskをキャンセルするので、 RxSwift でいう Disposable , Combine でいう AnyCancellable のような役割を果たしています。 TaskManagerの実装例 public typealias TaskID = AnyHashable public protocol TaskIDProtocol : Hashable & Sendable { var identifier : String { get } } extension TaskIDProtocol { public static func createDefaultIdentifier () -> String { String(describing : type (of : Self.self )) } } public struct DefaultTaskID : TaskIDProtocol { public let identifier : String = createDefaultIdentifier() public init () {} } public final class TaskManager { private ( set ) var taskDict : [ TaskID : [ AnyCancellable ]] = [ : ] public init () {} public func addTask ( priority : TaskPriority? = nil , operation : @Sendable @escaping () async -> Void ) { _addTask(id : DefaultTaskID (), task : Task (priority : priority , operation : operation )) } public func addTask ( id : some TaskIDProtocol, priority : TaskPriority? = nil , operation : @Sendable @escaping () async -> Void ) { _addTask(id : id , task : Task (priority : priority , operation : operation )) } public func cancelTask (id : some TaskIDProtocol) { taskDict[id] = nil } public func cancelAndThenAddTask ( id : some TaskIDProtocol, priority : TaskPriority? = nil , operation : @Sendable @escaping () async -> Void ) { cancelTask(id : id ) _addTask(id : id , task : Task (priority : priority , operation : operation )) } public func cancelAll () { taskDict = [ : ] } private func _addTask ( id : some TaskIDProtocol, task : Task < Void , Never > ) { let cancellable = AnyCancellable { task.cancel() } taskDict[id, default : [] ].append(cancellable) } } SwiftUIのViewがonAppearした時には .task は使わずに、TaskManagerに責務を集約しています。 理由としては下記です。 ボタンを押した時にもAPI通信などの非同期処理が発生するため、Taskを一元管理したいため 前回実行中の同一IDのTaskをキャンセルしたい場合があるため TaskManagerの実用例 struct HomeTabView : View { @StateObject var viewModel : HomeTabViewModel @State private var taskManager = TaskManager() var body : some View { VStack { Button( "Button" ) {       // ExampleButtonTaskIDと同一IDの実行中Taskをキャンセルして、Taskを実行する taskManager.cancelAndThenAddTask(id : ExampleButtonTaskID ()) { [ weak viewModel] in await viewModel?.didTapButton() } } } .onAppear { taskManager.addTask { [ weak viewModel] in await viewModel?.onAppear() } } } } struct ExampleButtonTaskID : TaskIDProtocol { let identifier : String = createDefaultIdentifier() } DemoApp マルチモジュール化やM2チップの登場によってビルド時間は改善傾向にありますが、単体機能開発をする際にはビルド時間が長いことが開発効率を下げる要因になります。 クラシルリワードではSwift Package Managerでモジュール化していることで、各機能のDemoAppが作りやすい環境になっています。 DemoAppのために新規でxcodeprojやtargetを作成する場合、xcodeprojの差分に付き合うことになるため、それらを避けるためにPlayground(swiftpmファイル)を利用しています。 ReceiptDemoAppの例 ReceiptDemoApp demo DemoAppを他のメンバーも確認できるようにgit管理に含めていますが、PRレビューはしないことにしています。 あくまでいつでも捨てられるかつ開発用のアプリという位置付けで利用しています。 その他の取り組み 自動生成 Genesis , Sourcery を利用していることで下記のフローを自動化しています。 機能モジュールの作成 DemoAppの作成 画面遷移のType解決 genesisコマンドをラップしたmakeコマンドを利用することで、機能開発に必要なテンプレートを自動生成します。 $ make create-feature-module FEATURE_NAME =Leaflet 機能モジュール生成物 LeafletFeature ├── Generated ├── Leaflet │   ├── LeafletBuilder.swift │   ├── LeafletScreenView.swift │   ├── LeafletScreenViewModel.swift │   └── LeafletViewController.swift └── Resources └── Localizable.strings LeafletScreenRequests └── LeafletScreenRequest.swift DemoApp生成物 LeafletDemoApp.swiftpm ├── Assets.xcassets │   ├── AccentColor.colorset │   ├── AppIcon.appiconset │   └── Contents.json ├── Package.resolved ├── Package.swift └── Sources ├── MyApp.swift └── ViewControllerWrappper.swift 定型作業が多いものを自動化することで、開発者の負担を減らしています。 Development Flow ブランチ戦略としては GitHub Flow で開発しています。 新規機能開発が複数走っているため、巨大なFeatureブランチを作成すると、都度コンフリクト修正をする必要があるため、FeatureFlagをベースに機能開発しています。 FeatureFlagの仕組みはUserDefaults、FirebaseRemoteConfigを利用して実現しています。 FeatureFlagの実現例 public struct FeatureFlagManager : Sendable { public enum BoolKey { case isNewDesign case isHeaderHidden } public var getBoolValue : @Sendable ( BoolKey ) -> Bool } extension FeatureFlagManager { public static func live (userDefaultsClient : UserDefaultsClient , remoteConfig : RemoteConfig ) -> FeatureFlagManager { . init ( getBoolValue : { key in switch key { case .isNewDesign : // UserDefaultsClientはUserDefaultsのラッパーなのでここでは説明を割愛 return userDefaultsClient.isNewDesign case .isHeaderHidden :   return remoteConfig.bool( "isHeaderHidden" ).boolValue } } ) } } // 利用例 let isNewDesign = featureFlagManager.getBoolValue(.isNewDesign) let isHeaderHidden = featureFlagManager.getBoolValue(.isHeaderHidden) 開発版のみFeatureFlagの機能フラグを有効にできるため、本番環境には影響せずに機能を確認することができます。 QA終了後にFeatureFlagを切り替える or フラグを削除することで、本番環境に機能をリリースしています。 それぞれの機能の差分が小さい状態でmainブランチに取り組むことができるため、リリースも頻度高く行えています。(2023/5/29時点: 週平均2回リリース) 最後に クラシルリワードのiOSアプリの構成について紹介しました。 2023/1まで1人で開発していたためこの構成がうまくいくかどうかはわかりませんでしたが、チームメンバーが増えても現状は特に問題なく運用できています。 引き続き機能開発でクラシルリワードアプリを改善していきたいと思いますので、よろしくお願いします。
アバター
はじめに こんにちは!クラシルで検索チームのPdMを担当してる四柳です。 検索チームは日本以外の国籍の方が所属してるのが一つ特徴で検索に深く知見を持ったメンバーがチームに在籍しています。 メンバーと話していて僕自身初めて知った検索ロジックの評価基準"GSB Score"について今回紹介しようと思います。 GSB Scoreについて GSBそれぞれの意味はGはGood, SはSame, BはBadになります。 ではどういう時にGSBそれぞれが評価されるのか?についてAが既存のロジック、Bが検証したいロジックとして以下表にまとめました。 パターン GSB AよりもBが優れている G(Good) AとBは同じ S(Same) BよりもAが優れている B(Bad) 次により具体的な比較方法について説明します。 既存、検証ロジックそれぞれカレーというQueryを投げた結果が以下です。 既存ロジック 検証ロジック 既存、検証で得た結果に記された番号同士を比較してGSB評価を行います。 評価後のイメージが以下です。 既存ロジック 検証ロジック 次にスコア付けになります。 前提として番号が小さいコンテンツほど検索ロジックとしては評価が高いコンテンツになるので1番目の比重が大きくなっています。 query: カレー 既存ロジックのスコア: 1 + 0.6(-1- 1 + 1) + 0.3(1 - 1) = 0.4 検証ロジックのスコア: 1 + 0.6(1 + 1 -1) + 0.3(1 + 1) = 2.2 番号 比重 既存ロジック 検証ロジック 1 1 +1 + 1 2 0.6 -1 + 1 3 0.6 -1 + 1 4 0.6 +1 - 1 5 0.3 +1 + 1 6 0.3 -1 + 1 これをまとめると以下になります。 Query 既存ロジック 検証ロジック GSB カレー 0.4 2.2 G 今回はカレーの1Queryでのスコア付けになりますがこれをカレーとは異なる別のQueryで行いその全体での結果、検証ロジックの方が優れているケースが既存ロジックより多い場合GSB Scoreを用いての評価としては検証ロジックが優れていることを示します。 以下はカレー以外のQueryも含めた場合の例です。 この場合検証ロジックの方がG評価が多いので既存より検証ロジックが優れていることを意味します。 Query 既存ロジック 検証ロジック GSB カレー 0.4 2.2 G 本格そば 0.6 1.2 G うどん 1.2 1.0 B 0歳離乳食 1.0 1.0 S 暑い時に食べたいさっぱりご飯 0.2 2.2 G 検証ロジックの評価をGSB Scoreのみで行うの? ここまでの説明だけだと評価はGSBだけで行うの?と不安視があるように思えます。 この不安の原因の一つとしてはユーザのログをもとに定量で評価をしていないところにあるかなと思います。 定量と定性の両軸でロジックを検証することを重要視しており、定量の場合はコンテンツタップ率などを指標としておきます。 その一方で推薦したいコンテンツの正しい答えであったり期待値というのはチーム内で把握しています。 この期待値通りに表示されているか?を軸にGSB Scoreを用いて評価します。 定量と定性の両方で検証ロジックの方が優れている場合は全ユーザへのdeployになるイメージです。 運用初期フェーズで起きるイメージ 導入初期フェーズで発生するイメージのことです。 定量指標では検証が負けてるが、定性(GSB Score)では検証が勝ってる場合です。 言い換えるとチーム内の良い検索とユーザが求める良い検索がずれている可能性があるのかなと思ってます。 この場合はなんでこういう結果になるのか?について深くユーザを理解するために分析などの行動を行いその結果GSB Scoreを評価するときの軸をupdateする必要があるかもしれません。 さいごに どうだったでしょうか?各社様々な手法で検索ロジックの評価を行なっているかと思いますが今回の記事が少しでも何かの参考になれば幸いです。 現在クラシルでは検索に力を入れている状況です。 この記事をきっかけにご興味を持たれた方がいらっしゃいましたらぜひカジュアル面談などでお話できれば嬉しいです!
アバター
こんにちは! dely株式会社でクラシルリワードのバックエンド開発を担当しているおぺんです。 今回はサーバーサイド仕様書をGitHubで管理してよかったことを書いていきます💪 ※ Railsアプリケーションリポジトリとは別のサーバーサイド仕様書専用のリポジトリで管理しています。 ※ GitHub管理しているのはサーバーサイドの仕様書のみで、機能仕様に関しては別のドキュメントツールで管理しています。 背景 クラシルリワードは新規事業として、ここ1年で注力・成長してきたサービスになります。 立ち上げからリリース後しばらくの間まで、一人でサーバーサイドを担当していたということもあり、各機能の開発の目的・経緯や処理の流れなどを、今後新しく入ったエンジニアが理解しやすいようにドキュメントで管理しようという話になりました。 当初は社内で既に使われていたドキュメントツールを使おうと思っていたのですが、以下のような課題がありました。 レビューしづらい 各ツールのコメント機能を使って指摘していた 指摘箇所のスクショを撮ってSlackやGitHub上で指摘していた 検索性が悪い 他の部署のドキュメントも一緒に管理されていたので埋もれてしまう ドキュメントツールの移行が行われた場合に、資料が複数のツールに散乱する or 失われる ドキュメントツールによって対応していない記法がある 以上を踏まえた上で、サーバーサイド仕様書として求められている要件としては以下になりました。 レビューのしやすさ 検索性の良さ 仕様書を書く上で必要な記法がサポートされているか 基本的なマークダウン記法 Mermaid / PlantUMLなどのシーケンス図法 JSONやRuby、シェルスクリプトなどの言語のシンタックスハイライトに対応しているか 今後ドキュメントツールが移行されたとしても影響を受けないもの これらの全ての要件を満たすツールとしてGitHubに白羽の矢が立ったという次第です。 管理・運用方法 まずはテンプレートを用意します(PULL_REQUEST_TEMPLATE.mdやREADME.mdなどで管理するのが良さそうです)。 以下は簡易的な例です! # 目的・経緯 <!-- この機能開発が必要な目的・経緯を簡潔に書く --> # 仕様 <!-- この機能が満たすべき仕様やUIなどを簡潔に書く --> # DB設計 <!-- テーブル・カラムの追加があればその詳細をここに書く --> # ER図 <!-- MermaidのerDiagramで記述 --> # シーケンス <!-- MermaidのsequenceDiagramで記述 --> また、全体のフォルダ構成としては以下のようにしました(Twitterのようなサービスを例にしてみました)。 運用していく中で、Railsアプリケーションのフォルダ構成と同じようにすると管理しやすく、知りたい仕様がどこにあるのか見つけ易いということがわかってきました(Railsに限らず、他のフレームワークにおいても同じことが言えそうです)。 . ├── api │ ├── tweets │ │ ├── create │ │ │ ├── images │ │ │ │ └── UI.png │ │ │ └── serverside_architecture.md │ │ └── show │ │ ├── images │ │ │ └── UI.png │ │ └── serverside_architecture.md │ └── users │ └── create │ ├── images │ │ └── UI.png │ └── serverside_architecture.md ├── batches(バッチ処理に関わる仕様書) ├── rake_task(rake taskに関わる仕様書) 基本的には、複数のAPI / 機能の依存関係が強い時などを除き、1API / 1機能につき1仕様書という運用で進めています。 そうすることで、機能修正が入っても特定の仕様書のみを変更すれば良くなり、レビューもしやすく、抜け漏れも少なくなります。 「その機能の仕様書を読めば、常に最新のサーバーサイドの仕様がわかる」という状態が理想です。 実際に仕様書を作成してPRを出してみるとこんな感じになります! また、以下のように特定の行に対してreviewできるので、スクショ等の共有も不要です。 所感 そもそも仕様書レビュー自体やっていないというところも多いのではないかと思うのですが、 仕様漏れを防ぐ PRレビュー時にどんな機能を実装しているかが仕組みで共有されるのである程度バックグラウンドも含めナレッジシェアされる 設計時に気をつけるべき観点がメンバー内でsyncさせていける → 一人一人の設計力の向上につながる 他の機能に影響がないか、インフラ周りで気をつけるべきことがないかが事前に議論できる といった大きなメリットがあります。 実際に仕様書をGitHubで管理するようになってから、他のエンジニアからも どこに何が書いてあるのか分かりやすくて良い レビューしやすい といった好意的な意見を頂いております。 逆に、今抱えている課題感としては 最新の状態を保つことが意外と難しい というところです。ちょっとしたUI変更や細かな仕様修正などが入ると、仕様書の更新を忘れてしまうこともあります。 また、 実装者に直接聞く方が手軽で早い メンテコストが意外と高い という意見もあるとは思うのですが、これに関しては 実装者がずっとチームに残ってくれるとは限らない その当時のPRでのやりとりから実装背景を確認できる という理由から、多少コストがかかったとしても導入するメリットの方が大きいと感じました! GitHubはほとんどのWeb系企業で導入されていると思いますし、仕様書を管理するための導入コストがほぼ0なのも推しポイントの一つです。 もし、仕様書管理で同じようなお悩みがあれば、ぜひこの機会にGitHubでの仕様書管理を導入してみてはいかがでしょうか!
アバター
はじめに こんにちは! クラシルのQAを担当しています。shiominです。 今回のブログでは私が2人目のQAとして取り組んできたチーム体制の整備とそれに伴っての成果を紹介していければと思います。 はじめに 現状のQAチームはどんな感じ? 私が入社した当時、QAチームにあった課題 取り組んだこと さいごに 参考文献 現状のQAチームはどんな感じ? delyのQAチームの現状をまずお話しすると、 現在は3人体制で日々QAの業務を行っています。 メイン業務はクラシルAppのiOS、Androidの検証業務(たまにWebも)になりますが、よりクラシルプロダクトの品質を向上させるための取り組みなども行っています。 しかし、QAチームはまだ2年にも満たないくらいの新しいチーム&人数も3人しかいないので現在はQAチーム体制の強化・整備に重きをおいています。 その中で私は通常の検証業務とチーム体制の整備をメインにここ10ヶ月くらい取り組んできました。 ちなみに現在のQAチームは以下のツールをメインに使っています。 Notion テストツールなどは導入されていない為、テスト設計はNotionで行っています JIRA (2023/02頃~) 私が入社した当時、QAチームにあった課題 私がdelyに入社した当初、QAチームには主に以下のような課題がありました。 業務を効率よくまわせるシステムになっていなかった 過去のテストケースは残されてはいるもののナレッジとして蓄積されていなかった QA中に発生した不具合や本番で発生した不具合がチケットとしてしっかり残っていなかった(Slackでのやりとりを探さないといけない状況) これに伴い、 不具合を分析しプロダクトの健康状態が可視化できていなかった 取り組んだこと ここからは上記の3つの課題に対して、私がどう取り組んでいったかをご紹介できればと思います。 1.業務を効率よくまわせるシステムになっていなかった この課題を解決するために入社当初、まず2人以上で回していく上で必要なステータスの追加とタスクボードの整備から行いました。 ステータス管理をより自然な流れにするために、元々あったステータスの中に レビュー中 と 実行前 を追加しました。 レビュー中 :2人体制となったことからテストケースのレビューを行う工程を追加 実行前 :設計が終わっていること。まだ実行は開始できないことを把握するための工程を追加 実行前を追加したことに伴い、QAチームが設計や実行にどのくらいの工数がかかっているのかを測定することが可能となりました ステータスの流れのイメージ図(黄色が追加箇所) JIRAが導入されるまではNotionで管理をしていましたが、JIRA導入後はこの流れをベースにしつつJIRAの機能を活かしてQA対応予定のチケットを事前に把握し積んでおけるようにBacklogを活用し、タスク管理する上で状況を把握しやすいカンバンボードの作成を目指しました。 JIRAはカードの色や、カードに表示させる項目を別途設定できるので、JIRAだからこそできる機能を駆使してチケットの詳細を見に行かずともボード上である程度情報を拾えるようにしました。 このJIRA整備の作業を行う際、JIRAの一番編集権限の高い権限を一時的に借り、以下の改修を行いました。 カンバンボードの編集 チケットステータスの編集 チケットプロパティの編集・追加 元々JIRAは結構触ったことがあったのですが、ステータスの編集やチケットのプロパティ追加などは初めての経験だったので非常に勉強になりましたし、JIRAで触ったことがある範囲が広がったのは私個人としてもスキルアップにつながったと思います。 JIRA導入後のタスクボードのステータス流れのイメージ図 QAタスクの管理についてはこれで一区切りしましたが他にも効率化の一環として、 Notionのデータベースを作成すると、テンプレートが作成できるようになるので、それを活かし以下のテンプレートを作成しました。 テスト設計時の項目テンプレート それまでテスト設計をする際に毎回項目を都度書いていたので1クリックで項目セットを呼び出せるようにしました テストケースに変動がないケースのテンプレート App全体のリグレッションテストケース 定常チェック用のテストケース これらのテンプレートを作成したことで、上記画像のように作成したページ上に各テンプレートが表示され、任意のテンプレートを1クリックで呼び出せるようにしました。 この仕組みをQA設計に適用したことによって以下のような効果を得ることができ、より効率化を進めることができました。 まっさらなページから項目を追加する手間の削減(約10分の削減)←小さい削減でもこういう手間を効率化していくのはアリだと思っています チーム内でのテスト設計時の記載項目の統一化 リグレッションケースを毎回1から作成する手間の削減(約1時間の削減) 2.過去のテストケースは残されてはいるもののナレッジとして蓄積されていなかった この課題を解決するため、Notionのカスタム性の高さをどううまく活用し管理しやすいようにするかを考えました。(前提としてテストケースはNotionに作成する運用となっています。) そこでベースとする管理方法の考え方を検討し、同じくカスタム性の高いテストツール TestRail のスタイルを採用することにしました。 ここではNotionを活かしたテストケースの管理方法を紹介できればと思います。 このTestRailの構造をNotionで再現していく上で、私は以下の手順でテストケースのカテゴリの細分化を行いました。 各OS or チーム単位にNotionの子ページを作成 TestRailでいうとプロジェクト単位のイメージ クラシルはUIパターンが現在2パターンあるため、Appの場合は各ページの中でテーブルを各UIパターン毎に作成 TestRailでいうとTest Caseのセクション単位のイメージ UIパターン毎に分けた上でさらに各テーブルにて機能毎にタブを作成 TestRailでいうとTest Caseのセクション下のサブセクション単位のイメージ ※上記画像参照※ 例)TestRailのTestCaseのセクション構造イメージ *1 これにより、過去のテストケースを確認したいと思った際に、 どのOS or チームか どのUIパターンか どの機能か この3点が把握できていればテストケースが探しやすくなるようになりました。 またこの仕組みを導入したことにより、テストケースを作成する時にかかる工数を少しですが削減することができています。 Notionのテーブルはフィルターをかけていればタブ内の『新規』ボタンを押下してページを作成した際に、作成したタブにかかっているフィルターの項目がページのプロパティ上に自動的に入力されるようになっているからです。 作成されたページのプロパティ これにより、少しですがプロパティを設定する手間を省くことができました。 3.QA中に発生した不具合や本番で発生した不具合がチケットとしてしっかり残っていなかった(Slackでのやりとりを探さないといけない状況)  a. これに伴い、 不具合を分析しプロダクトの健康状態が可視化できていなかった この課題に対しては以下の流れで不具合分析をある程度自動で集計できるよう整備を進めていきました。 不具合チケットを起票していたNotionページをテンプレート化し、入力すべき項目を統一化 クラシルにおける重篤度を定義 重篤度としてはCritical、Major、Normal、Minorの4つを用意し、以下の基準に沿ってエンジニアサイドと調整をしながらクラシルに特化した定義付けを行いました(詳細な定義はここでは割愛します) 重篤度 基準 Critical 発覚から即時の対応が必要 Major 発覚から1週間での対応が必要 Normal 発覚してから1週間〜2週間くらいでの対応が必要 Minor 直近で対応予定のない軽微なもの JIRA導入に伴って、Notion→JIRAへの不具合チケットの完全移行 QAのプロジェクトを整備した時と同様、こちらもJIRAの権限を一時的に借りて以下の整備を行いました カンバンボードの編集 チケットステータスの編集 チケットプロパティの編集・追加 不具合記載項目のテンプレートの作成 それまでNotion側に起票していたこれまでの不具合チケット全てをJIRAに移行しました JIRAダッシュボードを使用した、不具合の集計と健康状態の可視化 ダッシュボードのガジェット *2 は以下を使用しました 最近作成された課題グラフ 月単位で作成された課題件数の推移を見れるようにするために使用 2 次元フィルター統計 重篤度と機能の掛け合わせによる集計結果を確認するために使用 課題の統計 件数と割合を項目毎に確認するために使用 検索結果の絞り込み 不具合を一覧で確認するために使用 JIRAのダッシュボードで不具合チケットを自動的に集計できるようになったことにより、以下が可視化できるようになりました。(やはりJIRAは便利ですね) 各月毎の発生件数の推移(過去3年分) 各OS毎の発生件数と解決状況 重篤度毎の発生件数 重篤度×機能毎の発生件数 不具合要因毎の発生件数 ここまでで不具合を分析するための基盤を完成することが出来ました。 さいごに 10ヶ月でようやくQAチームの基盤となる仕組みがある程度システム的に回るレベルまでになりましたが、 まだまだQAチーム自体やプロダクト品質を底上げしていくには課題が多くあります。 エンドユーザーにより品質の高いサービスを提供していけるよう、引き続きQAのチーム体制の効率化&強化と、QAとして品質という面からアプローチを多くの方面にしていければと思います。 参考文献 *1 : TestRail 入門 - TestRail ドキュメント (v7.0.2) *2 : ダッシュボード ガジェットを使用する | アトラシアン サポート
アバター
はじめに こんにちは!クラシルiOSエンジニアの中川です。 今回はGoogle Mobile Ads SDKを使用した広告の実装において役立つTipsをいくつかご紹介しようと思います。 テストモード テストモードは、本番環境の広告を表示する代わりに、テスト用の広告を表示する機能です。 Googleからテスト用の広告を簡単に表示できるようにデモ広告のUnit IDが提供されていますが、テストモードを有効にすると、本番環境のUnit IDを使用してテスト用の広告を表示することができるため、Unit IDを差し替える手間が必要なく便利です。 また開発中はこのテストモードの使用が推奨されています。 公式ドキュメントより 開発中はテスト広告を使用して、Google 広告主に料金を請求することなくクリックできるようにすることが重要です。テストモードを使わずに広告をクリックしすぎると、無効なアクティビティとしてアカウントが警告を受ける恐れがあります。 テストモードを有効にするには以下のコードを追加します。 ※ iOS シミュレータは自動的にテストデバイスとして扱われるため実装は不要です。 GADMobileAds.sharedInstance().requestConfiguration.testDeviceIdentifiers = [ "device_id" ] device_id は広告のリクエスト時にXcodeコンソールに出力されるログから取得することができます。 <Google> To get test ads on this device, set: GADMobileAds.sharedInstance.requestConfiguration.testDeviceIdentifiers = @[ @"2077ef9a63d2b398840261c8221a0c9b" ]; ログからデバイスIDをコピーし、以下のように指定します。 GADMobileAds.sharedInstance().requestConfiguration.testDeviceIdentifiers = [ "2077ef9a63d2b398840261c8221a0c9b" ] テストモードを有効にすると、バナー広告の場合はTest modeラベルが表示され、 ネイティブ広告の場合はタイトル(headline)にTest modeの表記が含まれるようになります。 developers.google.com この手順でテストモードを有効にすることができますが、以下の問題があり少し扱いづらさを感じます。 端末ごとにデバイスIDを直ビルドで確認し、コードを修正する必要がある デバイスIDを含む状態でコミットしたくないので、毎回コードを追加・削除しないといけない deploygate等で配布した際、柔軟にテストモードを使用できない そこでクラシルでは以下の方法でこの問題を解決しています。 1. デバイスIDをコードで取得、生成する 公式ドキュメントでの記載は見つけられなかったのですが、デバイスIDをコードで取得することができます。 ここでポイントになるのがIDFAの許諾状態によってデバイスIDが変わることです。 IDFA が有効になっている場合 → IDFA IDFA が無効になっている場合 → IDFV IDFAの許諾状態に応じて上記の値を取得し、これらをMD5でハッシュ化した値がデバイスIDとなります。 private func getTestDeviceId () -> String { let idfa = ASIdentifierManager.shared().advertisingId if ! idfa.isEmpty { return generateMD5(from : idfa ) } else { let idfv = UIDevice.current.identifierForVendor?.uuidString ?? "" return generateMD5(from : idfv ) } } private func generateMD5 (from idfv : String ) -> String { let computed = Insecure.MD5.hash(data : idfv.data (using : .utf8) ! ) return computed.map { String(format : "%02hhx" , $0 ) }.joined() } 2. デバッグビルドでテストモードを有効にする #if DEBUG let testDeviceId = getTestDeviceId() GADMobileAds.sharedInstance.requestConfiguration.testDeviceIdentifiers = [testDeviceId] #endif これによってテストモードを柔軟に扱うことができるようになります。 広告インスペクタ 広告インスペクタは、アプリ内で表示される広告に関する情報を確認することができるGoogle Mobile Ads SDKが提供している広告のデバッグ機能です。 広告インスペクタを使用すると、アプリ内で読み込まれている広告のタイプ、サイズ、表示される場所、その他の詳細を確認することができます。 これにより、広告が正しく表示されているか、または問題がある場合は何が原因であるかを特定することができます。 ※ 広告インスペクタはGoogle Mobile Ads SDK バージョン 7.68.0以上で使用可能です。 実際の広告インスペクタの画面を見てみましょう。 左の画面が広告インスペクタを表示したときの最初の画面です。 アプリ内で読み込まれている広告がUnitIDごとに表示され、まだリクエストがない広告の場合は薄く表示されます。 広告のタイプやリクエスト時間、どの事業者から取得された広告なのかの情報を確認することができます。 また、広告レスポンスが返ってきている場合は Fill 、 エラー等で広告が取得できなかった場合は No fill という表記でラベルが表示されます。 右の画面は各UnitIDを選択すると表示される詳細画面です。 広告リクエストで広告が配信された(または配信できなかった)時点までのメディエーション、ウォーターフォールの詳細を確認することができます。 詳細画面内の三点リーダーから View all bidders をタップして表示される画面では、Open Bidding オークションによる落札状況も確認することができます。 他の広告SDKをAdapterとして導入している場合は、Ad Unitsの隣にAdaptersの項目が表示され、接続状況が確認できます。 広告インスペクタを表示するには以下のコードを実行します。 ※ 広告インスペクタを表示するにはテストモードを有効にする必要があります。 GADMobileAds.sharedInstance().presentAdInspector(from : viewController ) この1行でデバッグ機能が使用できるので前述のテストモードと併せて実装しておくと便利です! 端末をGoogle Ad Managerと連携済みの場合は、後述で記載しているデバッグオプションのアラートで Ad inspector settings をタップし、 Set gesture: Flick を選択することで端末シェイクで広告インスペクタを表示できるようになります。 developers.google.com 配信ツールを使用したデバッグ方法 配信ツールはGoogle Ad Managerの管理画面上で使用できるデバッグ用の機能です。 Google Ad Managerと端末を連携し、手元でアプリを操作することで広告に関する以下のような情報を得ることができます。 どの広告がページに配信されたか これらの広告が配信され、他の広告が配信されなかったのはなぜか 広告申込情報とクリエイティブは正しく設定されているか 広告申込情報が予定より遅いペースで配信されている、またはまったく配信されていないのはなぜか まずは端末を連携する手順から見ていきましょう。 1. デバッグオプションのアラートを表示する デバッグオプションのアラートを表示する方法は2通りあります。 アプリ内で表示されている広告を2本指でロングタップ コードを実行して表示 デバッグオプションのアラートを表示するコードは以下です。 let vc = GADDebugOptionsViewController(adUnitID : "unitID" ) present(vc, animated : true ) initializerでadUnitIDを指定する形になっていますが、"/{Google Ad ManagerのプロジェクトID}" の指定だけで良さそうです。 このようなアラートが表示されます 2. Troubleshooting をタップ 3. Googleログインの画面に遷移するので、Google Ad Managerへのアクセス権限があるGoogleアカウントでログイン 4. デバイスの登録画面が表示されるのでデバイス名を入力し、VERIFY ボタンをタップ 以上でGoogle Ad Managerと端末の連携は完了です。 連携した端末の一覧はGoogle Ad Managerにログインし、[管理者] > [アクセスと認証] > [リンク済みデバイス] で確認できます。 次はGoogle Ad Managerにログインして実際の配信ツールを見ていきましょう。 左側のメニューから [配信] > [配信ツール] で以下の画面が表示されます。 今回はアプリに表示されている広告についての情報を見たいので[アプリ広告]を選択します。 アプリ広告を選択すると以下の画面が表示されます。 Google Ad Manager連携済みの端末が一覧表示されるのでデバッグに使用する端末を選択します。 端末を選択すると「広告情報を取得しています」の画面に切り替わります。 ここで再度アプリ側でアラートを表示し、 Troubleshooting をタップします。 以下の表示になったら準備完了です。 広告リクエストをしている画面を表示すると以下のように読み込まれた広告の情報が一覧で表示されます。 ※ もし、しばらく経っても読み込み中のままの場合は再度Troubleshootingをタップすることで読み込まれるようになると思います。 ※ アプリキルするとトラブルシュートのセッションが切れるため、都度Troubleshootingをタップしてリンクする必要があります。 詳細画面では広告ユニットの設定に応じて様々な情報を確認することができます。 リクエストされたKey-Valueの値も確認できるため、ターゲティング配信などのデバッグ時にとても便利です。 また、配信ツールを使うことでエンジニア以外でもある程度広告のデバッグができるのも良いですね! support.google.com おわりに 今回はGoogle Mobile Ads SDKを使用した広告の実装において役立つTipsをご紹介しました。 クラシルでも広告を実装する際に重宝しているTipsなので少しでも参考になれば幸いです! careers.dely.jp
アバター
はじめに こんにちは、クラシル開発チームでエンジニアリングマネージャーをしているtakaoです。この記事を書いているタイミングは4月なのですが、ちょうどクラシルの開発チームでは年度の変わり目ということもあり組織体制のアップデートをしたタイミングでした。私自身はマネジメント経験が特に長いわけでもなく組織全体のマネジメントをするようになってからは日も浅いのですが、プロダクト開発をするチームにとってどのように組織をデザインするかというのは重要な課題の一つだと感じています。 今回はその過程で情報を整理していくにあたり役に立った考え方や実際に組織体制を考える際に考慮したポイントなどについてご紹介します。 組織形態のパターン 組織形態には基本型と呼ばれている3つのタイプが存在しています。本来これらのパターンは開発組織というよりは一つの会社の括りで見たときの設計パターンを指すことが多いのですが、大小あれど開発組織も「組織」に変わりないので、開発組織を考える際にも同じ考え方が役に立つと思い参考にしています。 機能別(職能別)組織 機能別組織は一つの開発組織に当てはめるとフロントエンド、バックエンド、インフラなど業務内容の意味での機能(職種)で分けたパターンのことです。 基本的に同じ職種のメンバーが同じチームに属しており、レポートラインの上長なども同じ職種の人を想定します。 この設計をした場合のメリットとしては各チームが専門性を高めやすくなり、効率的な業務遂行が可能になると思います。 一方デメリットとしては、部門間のコミュニケーションや連携が不足しがちになり、開発全体のスピードが落ちてしまったり、余計な手戻りが発生しやすくなるリスクが大きくなります。 また、事業会社の場合はこの体制に寄せ過ぎると社内受託感が強くなってしまう可能性もあります。 事業部制組織 事業部制は一般的には会社の事業(製品など)ごとに組織を分割するような形態を表しています。 一方プロダクト開発組織に当てはめてみると、大きい組織の場合は各プロダクトごと、小さい組織の場合は一つのプロジェクトやドメイン、機能単位などで分けるようなパターンに該当すると思います。 このパターンの場合は、一つのチームに複数の職種のエンジニアやデザイナーが存在している点が特徴です。メリットとしては各エンジニアやデザイナーが担当する領域やドメインに詳しくなるため、コミュニケーションコストが低く議論や意思決定のスピードが早くなることが多いです。また、要件定義の段階から各職種のメンバーで議論が進められるので手戻りが少なかったり懸念の洗い出しなども容易になることが予測されます。 一方でデメリットとしては横の連携をうまくしないと独立したそれぞれのチームで同じ課題に取り組んでしまったり、各チームが独自の方針や戦略を持つことで組織全体の一貫性が欠けてしまうことが考えられます。 マトリクス組織 マトリクス型の組織は、機能別組織と事業部制組織を組み合わせたもので、一人のメンバーが複数のチームに所属するパターンです。これにより、機能別組織の専門性の高さと、事業部制組織の柔軟性やチーム間の連携を同時に享受できることが特徴です。 マトリクス組織のメリットとして、リソースの最適化が図れることが挙げられます。各プロジェクトで必要とされるスキルセットや専門性が変わるため、メンバーが複数のチームに所属することで適切なリソースを割り振ることができます。また、組織全体での知識共有が容易になり、組織の柔軟性が向上します。 一方でデメリットも存在します。上長に当たる存在が複数になるため、指揮命令が複雑になりメンバーが適切な優先順位を判断しづらくなることがあります。また、メンバーが複数のチームに所属するため、スケジュール管理やタスクの調整が困難になることがあります。これにより、効率が低下するリスクが生じます。 実際に組織形態・チーム体制を検討したときのポイント では実際に開発チームの組織形態・チーム体制を考える際にどのようなことを考えたり議論したりしているかという点ですが、個人的には以下のようなポイントを考慮して適切な形を設計するように意識しています。 事業・組織の規模 事業のサイズやフェーズを考慮します。初期段階(0→1)であればスピードが重要で、成長段階(1→10)ではスケーリングと効率化が求められ、安定期(10→100)では最適化やイノベーションが重要になるなどがあると思います。 組織の人数や各職種の人数も考慮に入れて、適切な組織構造を選びます。小規模ならばフラットな組織が適していることが多く、大規模になると権限移譲が必要になるので階層型の組織が適切になることが多いです。 チームの専門性とスキルセット チームメンバーが特定の領域に特化しているか、幅広いスキルを持っているかも重要です。特化型の場合は機能別組織が適していることが多く、幅広いスキルを持つ場合はプロジェクト型組織が適していることが多いでしょう。 組織として何を重要視しているか 組織のミッションやビジョンに基づいて、組織形態を選択しています。例えば、開発スピードや自律性を重視する場合はアジャイルな組織が適していることがありますし、効率性や組織自体のスケーラビリティを重視する場合は階層構造が必要になってきます。 チームとしての機動性 コミュニケーションの複雑性を考慮します。例えば、連携が必要なメンバーがいろいろなチームに散らばったりすると会議が多くなり効率が落ちるため、適度なコミュニケーションができる組織が望ましいです。 また、delyでは組織のグローバル化を行っており、チームによっては英語を使う機会も発生するのでその点も考慮すべき点になります。 組織の変更を行ったときに、どういう役割とどういうコミュニケーションが必要になるか予測を立てるのが良いと思います。 1チームの人数は、多くても8人までの範囲になるようにします。これにより、チーム内のコミュニケーションが円滑に行われ、効率的な意思決定が可能になります。 成長とキャリアパス(目標設定、評価) 適切な目標設定・評価がしやすいレポートラインになっているかも重要視しています。ここを考慮することで、社適切な評価やキャリアアップの機会を提供できると考えています。 成長やキャリアアップのための支援やフィードバック、マネジメントができる体制になっているかを確認します。 おわりに 組織形態やチーム体制の選択は、その組織の事業規模や目的、チームの専門性、スキルセット、機動性、成長やキャリアパスなど様々な要素を考慮して慎重に行われるべきだと考えています。機能別(職能別)組織、事業部制組織、マトリクス組織という3つの基本的な組織形態がありますが、それぞれにメリットとデメリットが存在します。 そんな中でどの組織形態を選択するかは、組織の目的やニーズに応じて決定することが重要です。また、組織形態だけでなく、適切な目標設定や評価、成長支援などのマネジメント体制も整えることで、より効果的な組織運営が可能になるでしょう。 クラシルの開発チームでは共に組織・プロダクトの成長を描いていける仲間を募集しています!特にSREのポジションを積極採用中ですので、興味がある方は以下のリンクをご覧いただければ幸いです! careers.dely.jp
アバター
こんにちは!ウェブ版クラシルの開発を担当しているサーバサイドエンジニアの福島と申します。 今回は、ElasticsearchのMultiModel検索を使って、多様なフォーマットのレシピコンテンツ検索を実現したことについて書こうと思います。 MultiModel検索とはどのようなものか、なぜこの機能が必要だったか、また開発を通しての経験についてご紹介します。 MultiModel検索ってなに? まずは、ElasticsearchのMultiModel検索機能について簡単に説明します。 従来の単一インデックスに対する検索機能とは違い、 複数のインデックスからデータを検索することができる 機能です。 (※Elasticsearchでは"Index"が、DBテーブルに相当する概念です。) 例えばユーザーが「鶏肉 レシピ」と検索した時に、Index A, B, Cそれぞれに格納されている異なる構造のデータに対して、任意の条件で検索を行い、並べ替え、データを返すような仕組みです。 ただ単に、「IndexAに対してクエリ検索を行い、その次にIndexBに対して検索して、その次IndexCに.... 」みたいな処理をしていては、パフォーマンスも良くないしコードの可読性も落ちてしまいそうですよね。 それを一発のクエリで取得できて、しかも異なる構造のデータに対して横断的にスコアリングして並べ替えられるのがMultiModel検索の便利なところだと思います。 なぜこの機能が必要だったのか? クラシルでは、2年ほど前からCGM機能の開発を行なっており、クリエイターさんの素敵なコンテンツが投稿されています。 クラシルで内製している公式レシピは、Elasticsearchを活用した検索機能が既に実装されてましたが、 CGMコンテンツの検索機能の開発にはまだ着手できておらず、検索結果に表示されていないのが現状でした。 「クリエイターさんの素敵なコンテンツを検索できる状態にしたい」 「公式レシピではカバーできない検索クエリに対してもコンテンツを提供できるようにしたい」 という思いがあり、今回の機能開発に乗り出しました。 そして、この機能の実現方法として、ElasticsearchのMultiModel検索を採用したのですが、その理由は以下の点です。 膨大な数のCGMコンテンツに対して、高パフォーマンスの全文検索を行う必要がある 細かなスコアリングで適切なコンテンツが上位に表示されるようにしたい すでに公式レシピの検索にElasticsearchのアーキテクチャが構築されており、それを拡張する形で比較的簡単に実現できそう MultiModel検索の実装 ここでは、MultiModel検索を実現するための大まかな流れのみ紹介するので、細かい設定などは公式ドキュメントを読んでいただくのが良いかと思います。 https://www.elastic.co/guide/en/elasticsearch/reference/master/search-multiple-indices.html また、クラシルのサーバサイドはRailsを使っており、Elasticsearchの利用には"elasticsearch-rails"というgemを使っています。 https://github.com/elastic/elasticsearch-rails この上で、既に実装されていた単一テーブルの検索機能から、どうやってMultiModel検索に拡張するのかを説明します。 まず前提として、Elasticsearch + Railsでの検索機能の実現には、 ①データマッピングの定義、②データの格納、③データ検索、④実データの取得 の4つの要素が必要です(実際はもう少し複雑ですが。。。) データのマッピング定義 ... Elasticsearchに新たにインデックスを作成し、それはどのようなデータ構造なのかを定義する データの格納 ... ①で作成したマッピングに基づき、データ(ドキュメント)を格納していく データ検索 ... Elasticsearchに対して、独自で定義したクエリをリクエストし、適切なデータ(ドキュメント)一覧を取得する 実データの取得 ... Elasticsearchから取得したデータ(ドキュメント)をもとに、ActiveRecordでマッピングされたDBに格納されているレコードを取得する (※Elasticsearchでは"ドキュメント"が、DBレコードに相当する概念です) これによって、高速で正確な検索が実現されています。 このステップに従い、今回に開発で行ったのは、 新たにCGMコンテンツのインデックス作成、およびそれぞれのマッピング定義 Elasticsearchにドキュメントをインポート(格納)する処理の実装 MultiModel検索のロジック作成 MultiModel検索によって取得したドキュメント一覧をもとに、レコード一覧を取得してくる処理の実装 です。 ①②に関してはそこまで難しいことはやってなくて、それぞれテーブルごとに個別に定義すれば済みます。 ③MultiModel検索のロジック作成、に関しては、それぞれ異なるフォーマットに対して、 「どうやって適切なスコアリングを行うか?」 が難しいです。 フォーマットが違えば、それぞれの指標となるデータフィールドに対する重みづけも考慮しなければなりません。 この部分はウェブ開発チームで検証を行い、フィードバックをもらいながら精度の向上に努めました。 ▼MultiModel検索のコードのサンプルを紹介します。 def search_merged_contents (query = '' , options = {}) # 検索対象のデータフィールドを定義 # インデックスによっては存在しないデータフィールドを指定しても問題ないです fields = [ ' title ' , ' introduction ' , : : ] # スコアリングロジックを定義 search_functions = merged_contents_search_basic_function(query) # 検索対象のインデックスを配列で指定 model = [ IndexA , IndexB , IndexC ] # 実際に発行する検索クエリを生成 query = build_search_query( query : query, functions : search_functions, fields : fields ) # MultiModel検索を実行(第二引数に検索対象のインデックス一覧を渡す) search_with_timeout do Elasticsearch :: Model .search( query, model, size : options[ :size ] || DEFAULT_SIZE ) end end ④の実レコード取得に関しては、実はgemに搭載されている機能で簡単にできます。 詳しくはgemのソースコードを参照ください。 https://github.com/elastic/elasticsearch-rails/blob/main/elasticsearch-model/lib/elasticsearch/model/adapters/multiple.rb しかしクラシルでは、以下の条件からこの機能を使えず、自前で実装する必要がありました。 Elasticsearchのインデックスをダウンタイムなしで再構築するような仕組みにしている そのため、それぞれユニークなインデックス名を定義している これが原因で、gemに実装されている処理では、うまく該当のDBレコードを取得できない ここは将来的に改善したい...!! 「ダウンタイムなしでインデックスを再構築」に関してご存知ない方は、こちらの記事が分かりやすかったです。 https://qiita.com/ainame/items/5ef2c2aa3c204cb23733 MultiModel検索の機能開発を通して 今回の機能開発は、ウェブチームにとっても重要な施策でした。 自分はこれまでElasticsearchをほぼ触ったことがなかったので、開発を進める上で色々と調べて試行錯誤して、時には他のエンジニアメンバーの力も借りながら開発を行いました。 結果的に、まだカバーできてなかった検索クエリに対してもコンテンツを表示できるようになり、ユーザーにより良い検索体験を提供できるようになったと思います。 ▼「ルーローハン」のレシピ検索 最後に 今回は、ElasticsearchのMultiModel検索機能についてご紹介しました。 ウェブチームでは、この他にもまだまだやりたいことがたくさんあり、日々様々な開発を行っています。 これからもっと強い開発組織を作っていくためにも、一緒に働いてくれるメンバーをお待ちしてます:)
アバター
はじめに こんにちは。クラシルのAndroidアプリチームのテックリードのうめもりです。 今回は、クラシルのAndroidチームで新しく作っているSlack Botをご紹介します。Command Line Application BasedなSlack Botになっている、というところが大きな特徴で、今までクラシルのSlack Botが抱えていた課題を解決するためにそのような構成のSlack Botを新しく作ることになりました。 本稿では、新しいSlack Botを作るに至った経緯や、Slack Botのどのような課題を解決するためにCommand Line Application Basedにしたのか、Command Line Application BasedなSlack Botを作るコツ、どういったところがハッピーなのかについてご紹介します。 経緯 クラシルでは、rebeccaというSlack Botを5年くらい運用してきました。 チームメンバーが増えていくにあたって、誰でも手軽にデバッグ版アプリをビルド出来るようにしたり、リリース作業をミスなく正しい手順で行えるようにしたり、デバッグ会をスムーズに進められるようにしたり、チームの要望をかなえるために多機能に進化してきました。 作った最初はとてもシンプルなコードでしたが、増築に次ぐ増築で今ではかなりコードベースも大きく、コード自体としても読みにくいものになっているというそもそもの課題はありますが、コード品質以前にSlack Botの特性から来るコードの複雑性があり、機能改善にもなかなか手を付けにくいような状態になってきていました。 分岐地獄 rebeccaはユーザーのメッセージを正規表現でパターンマッチングすることによって機能を提供するようにしていたのですが、 機能が増えるにしたがって分岐のif文がどんどん増えていき、結果的に分岐部分のコードはかなりカオスな状態になっていました。(そこに機能自体のコードも書いてあったから魔窟に…。) 自然言語による機能の呼び出しは呼び出す側もどういうメッセージを送ればいいか混乱しがちで、ちょっとした表記の揺れでうまく機能が呼び出せていない、というケースがちょこちょこ発生していました。 (本来はサンドボックスの 用意 と言わないと必要な機能が使えなかった図) Interactive Components対応 さらに、rebeccaはSlackのInteractive Componentsの機能に対応しており、これもまたコードの複雑化に拍車をかけていました。実装側はメッセージ呼び出しの分岐と、Interactive Componentsの分岐の両方に対応する必要があったのです。 英語化の波 最近クラシルでは英語でのコミュニケーションがメインになるメンバーが参加してきており、そもそも日本語でしか使えないrebeccaはメッセージの内容やパターンマッチング用のコードを翻訳する必要があり、コードに大きく手を入れる必要が出てきていました。 そういった経緯もあり、今回Slack Botを作り直し、それらの課題に対応できる形に直すことになったのです。 新しいSlack Botの名前はチームと相談してkdroidにしました!シンプルで分かりやすい! Slack BotはほぼCommand Line Application Slack Botを作り直すにあたって、今後同じ轍を踏まないように大きな方針を決める必要がありました。その上で、5年間のbotの運用を経て、一つ気が付いたことがありました。 それは、 Slack BotはほぼCommand Line Application ということです。 標準入力と標準出力 Command Line Applicationの基本的なインターフェースは、標準入力と標準出力です。標準入力経由でユーザーからの要求を受け取り、結果は標準出力で返すのが基本になります。Slack Botもほぼ同様の構造で、botを呼び出すためのSlackのメッセージが標準入力、botからのレスポンスが標準出力と考えることが出来ます。 エラーがあった時は標準出力 Command Line Applicationでエラーが発生した際は、標準出力にエラーを出力し、ユーザーにエラーを伝えます。Slack Botも、標準出力(Slackのメッセージ)経由でユーザーにエラーを伝えるのが好ましいです。 コマンドによる呼び出し Command Line Applicationは、基本的にコマンドによって機能を呼び出すという構造になっています。コマンドによって細かな違いはあれど、基本的には次のような構造です。 $ (コマンド名) (機能名) (--オプション 値)* (パス)* 基本的なコマンド構造が決まっているので、ヘルプをちょっと見ればCommand Line Applicationの使い方はなんとなくわかるようになっています。Slack Botも標準入力(Slackのメッセージ)経由でコマンドを受け取りますが、コマンドの構造は特に決まっていないところが大きな違いです。(rebeccaは自然言語によるパターンマッチングで実現しています。) 呼んでみないと使い方がわからない Command Line Applicationにしろ、Slack Botにしろ、呼んでみないと使い方がわからないという特徴があります。GUIのように機能を予めユーザーに明示するということがその特性上できないので、Command Line Applicationには基本的にヘルプ機能が付いており、どのように呼び出せばいいかわかるようになっています。同じようにSlack Botでもヘルプ機能を提供することが出来ます。Slack BotはInteractive Componentsを使ってGUIを実現する、というやり方があるという点はCommand Line Applicationとは違うポイントです。 ヘルプ機能にしろ、Interactive ComponentsによるGUIの実現にしろ、それぞれつらいポイントがあります。前者は標準化された仕組みが無いので、実装者がヘルプのフォーマットを構築するところからすべて作る必要があります。後者は一度に出来る選択肢を全て表示してしまうと表示が冗長になってしまう、という問題があったり、ステップバイステップで表示するにせよ、実装側が複雑になりがちという問題があります。 Command Line Application Basedに作り直してます 以上のような共通点があるということもあり、Slack BotをCommand Line Applicationという偉大な先人の肩に乗る形で作ることには大きなメリットがありそうだと判断し、現在Slack Botを作り直しています。元々クラシルのSlack BotはGo言語で作られていたのですが、Go言語にはCommand Line Applicationの作成を支援するライブラリが多い、ということもその理由の一つになりました。 今回はライブラリに Cobra を採用することにしました。Cobraにした理由は、 Go言語でCommand Line Applicationを作る際にかなりポピュラーな選択肢であること 標準入力や標準出力を別の出力先に置き換えることが出来ること(Slack Botとして作るので必須でした) ヘルプ機能を簡単に提供できること が決め手になりました。 Command Line Application BasedなSlack Botを作るコツ Command Line Application BasedなSlack Botを作るにあたって、いくつかコツを発見したのでご紹介します。 標準入力と標準出力をSlackとつなぎこむ コマンドライン作成支援ライブラリのメリットを最大限生かすには、標準入力と標準出力をSlackにつなぎこむ必要があります。 標準入力 標準入力についてはSlackのメッセージから、コマンドを抽出して入力値として使うのが一番手っ取り早いです。今回はこのような実装になりました。(サンプル用にメソッドの内容を展開したりしてます) pattern := regexp.MustCompile(fmt.Sprintf( `(?s)<@%s>\s*(.+?)\s*$` , botID)) matches := pattern.FindAllStringSubmatch(bodyText, - 1 ) var commandLine string for _, groups := range matches { commandLine = groups[ 1 ] break } rootCmd := &cobra.Command{ Use: "kdroid" , Short: "A bot for kurashiru-android ci" , Long: "kdroid is A bot for kurashiru-android ci." , } ... args, err := shellwords.Parse(commandLine) if err != nil { return err } command := rootCmd.Root() command.SetArgs(args) ... command.ExecuteContext(ctx) 実際にSlackのメッセージをコマンドを抽出する過程は、2つの過程に分けられます。 正規表現でメッセージの不要な部分を除去してコマンドが含まれている全文を抽出 シェルと同じように入力文字列をスペースで分割された配列に変換 入力文字列をスペースで分割する際には、実際にはエスケープ等を考慮したほうがよいでしょう。(文字列に含まれるスペース等で分割しないため)今回は go-shellwords を使わせていただきました。 標準出力 標準出力についてはちょっと工夫が必要です。cobraはio.Writer経由での出力を行うため、出力をリダイレクトしてSlackにメッセージとして投稿する必要があります。 この要件に対応するために、次のような関数を作りました。 func ProcessEachParagraph(reader io.Reader , doOnParagraph func ( string )) *sync.WaitGroup { wg := sync.WaitGroup{} wg.Add( 1 ) go func () { _ = readEachParagraph(reader, doOnParagraph) wg.Done() }() return &wg } func readEachParagraph(reader io.Reader , doOnParagraph func ( string )) error { bufReader := bufio.NewReader(reader) var lines [] string for { bytes, err := bufReader.ReadBytes( ' \n ' ) if err != nil { if len (bytes) > 0 { lines = append (lines, strings.TrimRight( string (bytes), " \n " )) } doOnParagraph(strings.Join(lines, " \n " )) if err == io.EOF || err == io.ErrClosedPipe { break } return err } line := string (bytes) if line == " \n " { doOnParagraph(strings.Join(lines, " \n " )) lines = [] string {} } else { lines = append (lines, strings.TrimRight(line, " \n " )) } } return nil } この関数は、io.Readerから一行ずつ読み出し、空の行かEOFがあったらそれまでの出力をまとめてコールバックする仕組みになっています。sync.WaitGroupを返すことで、呼び出し先で読み出しの終了を待つことが出来ます。 reader, writer := io.Pipe() ... aWg := asyncreader.ProcessEachParagraph(reader, func (text string ) { if strings.TrimSpace(text) != "" { _, _, err := slackClient.PostMessage(ctx, slackChannelID, slack.WithMessage(text), slack.WithThreadTs(threadTs)) if err != nil { return err } } }) ... rootCmd := &cobra.Command{ Use: "kdroid" , Short: "A bot for kurashiru-android ci" , Long: "kdroid is A bot for kurashiru-android ci." , } ... command := rootCmd.Root() ... command.SetOut(writer) command.SetErr(write) ... command.ExecuteContext(ctx) io.Writerとio.Readerを繋ぐのにPipeを使います。こうすることによって、cobraのヘルプやエラー出力を含め、標準出力をslackにリダイレクトすることが出来るようになりました。 デフォルトの出力先は元メッセージのスレッドに これは好みの問題もありますが、デフォルトのSlack Botのメッセージの出力先は元メッセージのスレッドにしています。 特にリプライなど付けなくてもタスク完了通知やエラー完了通知をコマンド送信者に送れますし、一連の操作をスレッドにすることで操作の文脈を分かりやすくすることもできます。 Interactive ComponentsからのActionも、コマンドとして解釈する 新Slack Botでは、Interactive ComponentsからのActionもコマンドとして解釈する仕組みを入れています。 var callback slackLib.InteractionCallback if err := json.Unmarshal([] byte (payload), &callback); err != nil { return err } switch callback.Type { case slackLib.InteractionTypeBlockActions: if len (callback.ActionCallback.BlockActions) < 1 { return xerrors.Errorf( "found no action. just ignore." ) } commandText := callback.ActionCallback.BlockActions[ 0 ].Value ... if err != nil { return err } command := rootCmd.Root() command.SetArgs(args) } Slack Blocksの生成側では、単純にvalueに実行するコマンドを設定するだけです。これにより、コマンドを追加するだけでSlack Botに機能追加できる仕組みを実現しています。 // 実際のコードでは簡略化するためにwrapperを介していますが、基本的にはSlack Block Kitのオブジェクトを組み立てているだけです。 messageSender.SendToThread( slack.WithBlocks( slack.ActionBlock( "block1" , slack.BasicButton( "action1" , "Regular" , "confirm_pull_requests --dry-run" ), slack.BasicButton( "action2" , "Hot fix" , "start_hotfix --dry-run" ), ), ), ) 異常時のリトライも簡単に Interactive ComponentsからのActionとメッセージによる呼び出しのやり方を同一のフォーマットのコマンド呼び出しに統一することで、異常発生時のリトライも簡単になります。例えば、一連のフローのActionの処理後にコマンドによる手動呼び出しのやり方を返しておけば、任意の場所からの再開も容易になります。 Command Line Application BasedなSlack Botここがハッピー ヘルプを作るのが簡単 func New( awsClient aws.Client, githubClient github.Client, ) *cobra.Command { var buildTypeName string var debugApiURL string var debugApiHosts string cmd := &cobra.Command{ Use: "build [branch | ref]" , Short: "Build android app" , RunE: func (cmd *cobra.Command, args [] string ) error { if len (args) == 0 { _ = cmd.Help() return nil } // do command ... return nil }, } cmd.Flags().StringVarP(&buildTypeName, "type" , "t" , "sandbox" , "build type [sandbox | googleplay]" ) cmd.Flags().StringVarP(&debugApiURL, "apiurl" , "u" , "" , "override api url for debug" ) cmd.Flags().StringVarP(&debugApiHosts, "apihosts" , "o" , "" , "override api hosts for debug" ) return cmd } このコードはアプリをビルドするためのAWS CodeBuildを呼び出すためのコードの一部ですが、cobraを使うとこれだけで次のようなヘルプテキストが生成されます。 Build android app Usage: kdroid build [branch | ref] [flags] Flags: -o, --apihosts string override api hosts for debug -u, --apiurl string override api url for debug -h, --help help for build -t, --type string build type [sandbox | googleplay] (default "sandbox") 実装に沿う形でのヘルプを保守し続けるのは面倒なことですが、実際のコードにいくつかヘルプ用の実装を追加するだけで、このようなヘルプが生成されるのは非常に便利で、これだけでも Command Line Application Based にする価値があります。 機能拡張したい時はコマンドを増やすだけ 現在は原則としてcobraの ユーザーガイド に沿う形でコマンドを作っています。 ユーザーガイドのパッケージ構成の例 ├── cmd │ ├── root.go │ └── sub1 │ ├── sub1.go │ └── sub2 │ ├── leafA.go │ ├── leafB.go │ └── sub2.go └── main.go このような作り方をしておくメリットは、機能を増やすときにパッケージやインターフェースに明確なルールがあるため、コードが散らかりにくくなることです。 とかく開発用のツールは無計画に機能を増やしがちでコードが散らかりがちですが、このような整理の仕方をしておけば機能を追加するときも削除するときも、非常にやりやすいです。Interactive Componentsを使う場合も、同様にコマンドを呼び出すように作るだけなので、コードが散らかりにくいというメリットが享受できます。 Interactive Componentsも活用しやすい! 通常Interactive Componentsを使うと、実際にBlock Kitを使ってボタン等をSlackに表示しないとデバッグやトラブルシューティングがやりにくくなってしまいがちです。ですが、Command Line Application Basedで作ったことにより同様の機能がいつでも手動で呼び出せるため、Interactive Componentsの活用もしやすくなっています。 終わりに クラシルのAndroidチームではこのような技術も活用しながら、生産性高くアプリを開発できるような努力をしています。 今回の記事を読んで興味を持った方いらっしゃいましたら是非一度お話ししましょう!以下のリンクから、いつでもカジュアル面談の申し込みをお待ちしてます! dely.jp
アバター
はじめに こんにちは!ウェブ版クラシルの開発を担当しているフロントエンドエンジニアのしらりんと申します。 4月になり、この記事を読まれている方の中にも社会人になられた方もいらっしゃると思います。 2年前の記事ですが、当ブログにも新社会人の方へ向けたメッセージがあるので、ぜひこちらも読んでいただけたら嬉しいです🌸 tech.dely.jp さて、今回はウェブ版クラシルとその開発事情について、これまでの変化や課題、これから何に力を入れて取り組んでいくかなどお伝えできればと思います。 これまでのウェブ版クラシル サービス面 開発面の話の前に、ここ数年のウェブ版クラシルの成長について軽く触れておきます。ウェブ版クラシルはエンジニアやデザイナー、SEOなど各ビジネス領域の担当者が1つのチームとなってプロダクトづくりをしています。現在はコンテンツをより多くの人に適切に届けるための外部流入最大(最適)化やウェブを接点としたアプリ版クラシルのインストール・起動想起、広告収益などいくつかの領域に注力しています。 直近3年/四半期毎のウェブ版クラシルのMAU 上記の画像はウェブ版クラシルの直近3年間のユーザー数の変遷です。 2020年の4月、1回目の緊急事態宣言時には内食需要が高まったことによって多くの方に利用いただきました。一旦はそこがピークとなりましたが、2021年末にはアプリ版からユーザーがコンテンツを投稿できる機能が解放され、それによって社内で制作するコンテンツだけでは実現できないより多く、より個性的なコンテンツが集まるようになりました。 それらUGCを含むグロース施策や機能開発やを積み重ね、そのピークを安定して超えるユーザー数まで成長しました。 www.similarweb.com 開発面 機能開発に力を入れて取り組むためにフロントエンド・サーバーサイド共にエンジニアリソースの多くを機能開発に充ててきたため、これまで継続的な技術負債解消には力を入れておらず、徐々に蓄積してきた技術負債が起因となったバグやパフォーマンスの低下がみられるようになってきました。 開発における複雑さを定期的に下げて健全な状態を保ち、より早くより良いものを提供するサービスにしていくために、CTO含め話し合った上で機能開発と技術改善の2軸の開発体制を作る意思決定をしました。 まだ直近数ヶ月で変え始めたばかりですが少しずつ良い方向に向かっているので、その取り組んでいる/これから取り組む内容を以下にまとめてみました。 直近取り組んでいる/これから取り組むこと エンジニア採用 技術改善を行いつつ、機能開発も両立させるには今のチーム体制では実現が難しいのでまずはフロントエンドエンジニアを採用することが必要と考え、最初に採用活動を行いました。こちらは無事に採用することができ、直近で新しいメンバーがチームに加わる予定となっています。 ここからは機能開発と技術改善がそれぞれに注力しつつ互いにケアできる体制づくりを目指していきます。 ウェブ版クラシルの開発体制 止まってしまった移行プロジェクトを完遂し、あるべき姿に作り替える ウェブ版クラシルは、当初Rails + Slim + jQuery + CoffeeScriptで開発されていました。その後、RailsからNode.js + Vue.jsに移行するプロジェクトが進んでいたのですが、そのプロジェクトが機能開発とのバランスを取りきれないまま完了せず、現在もVue.js・Node.js・Railsが複雑に入り組んだ上に新しい機能を作り続けています。例えばheadタグの中をみてもRails側で生成しているタグとVue.js側で生成しているタグが混合していたり、本番で動いているページはVue.jsで動いているのに、プレビューページはSlimでビューを返しているため本番ページとUIや挙動に差異があったりと多くの辛みを抱えています。 また、フレームワークはVue.jsの2系をvue-property-decoratorやvuexなどと組み合わせて開発していますが、Vue.js 2系のサポートが年末に迫り、バージョンを3系へ上げることや他のフレームワーク・ライブラリへの移行を考える必要があります。Vue.jsへの移行を始めた当時と比べてサービスの規模が大きく変わっているにも関わらず、設計方針は当時からアップデートできておらず、propsリレーやミックスインの多用による意図せぬ上書きなどフロントエンド単体としてみても多くの負債が散見されます。 そういった開発体験の改善も合わせ、まずは元々のNode.js + Vue.jsへの移行プロジェクトを完遂して当時理想としていた形にし、そこから2023年時点でウェブ版クラシルにとってあるべき設計を再考し、機能開発と並行してリアーキテクチャを進められる状態を作っていきます。 Node.js バージョンアップ ウェブ版クラシルではNode.jsとvue-server-rendererを利用して自前SSRを行なっています。ここで使用しているNode.jsのバージョンはRails→Vue.jsへの移行プロジェクトを進めていた当時から適切に上げられておらず、以下のような課題を抱えています。 使いたいライブラリを入れられない、バージョンを上げられない ブラウザ上で動く構文がNode.jsのバージョン制約で動かないものもあるため、縛られてしまっている セキュリティやパフォーマンス面の課題 直近では変更ログを見ながら影響反映を確認し、一旦14系まで上げ切ることを目標にアップグレードを進めており、現在ステージングで動作を確認できている状態です。 14系ももう時期サポート終了となるためまた16系へのアップグレードを考える必要がありますが、そこからは定期的なアップグレードを続けられるように改善していきます。 CWVに基づくパフォーマンス改善 ウェブ版クラシルではCore Web Vitalsのスコアを計測し、ディレクトリやページの単位など細かい条件を指定して各スコアをエンジニア以外のメンバーも含めredashから確認できるようにしています。 ユーザー体験としてはもちろん、このスコアがSEOにも影響を与えるため、ビジネス的な側面でもウェブにおけるパフォーマンスを重要視していくべきと考えています。 残念ながら現状のウェブ版クラシルは技術的に取り組めていないことが多く、優れたパフォーマンスを提供できてはいません。 Vue.js 2系からの脱却を兼ねたフロントエンド周りの刷新に合わせてサーバーサイド・インフラ含め見直しを行う予定です。 また、短期的に改善が見込めてかつアクセス数が多く改善する意義の強いページは、根本改善とは別軸でボトルネックの調査と改善対応を差し込みで行っています。 以下は実際に改善に着手したとあるディレクトリの改善前後のLCPのスコアの推移です。 改善前後のLCPの変動 調査の結果、このディレクトリに関しては不適切なサイズの画像や不要なjsファイルの読み込み、SSR時点で取得するデータが不十分でページの一部しかSSRできていなかったことが原因だったため、それらを修正しました。 改善着手前はgood・needs improvemet・poorがそれぞれ30%前後くらいのスコアだったものが、着手後はgoodが90%近くを占め、needs improvemet・poorがそれぞれ5%前後で推移するようになりました。 さいごに サービスとして順調に成長を続けるウェブ版クラシルですが、技術的にはやりたいこと・やらなければならないことが山積みとなっています。ポジティブに捉えるならば技術的挑戦機会がたくさんある環境なので、技術改善を通じて学んだことを定期的に発信していければと思います。 careers.dely.jp
アバター
こんにちは、クラシルのバックエンドを担当しております鈴木と申します。 今回は「非エンジニアとスクラムを組んでプロジェクトを推進した事例」についてお話したいと思います。 下記の様な課題を持っている方 に読んで頂けると嬉しいです! 課題に対する不確実性が高い。 チームメンバー同士でも今誰が何をやっているかわからない。 チームメンバーの活躍や成果が見えにくい。 ※本記事は主に非エンジニアとスクラムをする際に意識した点やトピックを記載しております。基本的なスクラム構築についての説明は触れておりませんのでご容赦ください。 ▼非エンジニアスクラムの発足 2022年11月頃にクラシル開発部全体でチームの再編成とスクラムの導入があり、その中で開発を必要としないクラシルのUGCコンテンツの拡充を推進するチームが発足しました。 そこで私(スクラムマスター)を含めた合計6名でプロジェクトを進めることになりました。メンバーはCS業務や、料理レシピのコンテストの開催などをしている「非エンジニア」の方たちです。(+アルバイトさんも含む) 結成時のチームは下記のような状態でした。 スクラムは全員未経験。 定常的な業務が多く、不確実性の高い業務の経験は少ない。 ▼(スクラム導入期)どんなことからはじめたか ・定常業務の棚卸しと可視化 まずはメンバー全員の業務スケジュールを曜日ごとに書き出してもらい、それをもとに1つずつどんな作業をやっているか教えてもらいました。 ヒアリングの結果をもとに下記の分類に分けることができました。 自動化できる業務 アルバイトさんにお願いできる業務 その人の対応が必ず必要な業務 1.は自分が自動化をおこない、2.はアルバイトさんにお願いをして業務の時間を可能な限り削減しました。 3.は毎スプリントタスクボードに[定常業務]として各メンバー分のチケットを作成しました。 これにより課題解決に対して取り組める時間が増え、同時に業務の可視化を行なうことが出来ました。 ・アクティブなコミュニケーションによるフォロー体制 スクラム体制になり、タスクボードにチケット化された業務を「はいどーぞ。」と言われてもなかなか上手くいかないケースが発生しました。 自分の伝え方が悪かったり、それぞれの認識にズレが生じていたり様々なケースがありますが、いずれもコミュニケーションをすることによって未然に防げた内容でした。 そこで積極的な声かけを心がけることで、メンバーが言い出せなかった課題・困り事、早い段階で認識のズレを修正することが出来たと思います。 ・時間を掛けて[目的]や[背景]を共有していく チームにはメンバーだけでなく、アルバイトさんへお願いする業務が多く存在します。コミュニケーションをする際に意識していることは業務的な指示のみに留めてしまうのではなく少し時間を掛けてでも目的や背景をしっかりと説明することです。単純な業務であっても最初に時間を掛けて共有しておくことによって、 結果的により短い時間で成果を出す ことに繋がりました。 これを繰り返すことによって、徐々に「もっとこうした方が良さそう!」「ここを改善出来そう!」など自分以外の視点でも意見を頂けるようになり良い循環が生まれました。 ▼(スクラム成長期) 自立への働きかけ ・スクラムイベントの分散 プロジェクトがある程度進行しスクラムに慣れて余裕が出てきたタイミングで、スクラムイベントのファシリテーションをメンバーにお願いするようにしました。 特に効果的だったのはタスクボードの進捗管理を週替りで当番制に変更したことです。担当したメンバーは自分以外のタスクにも関心を持って積極的にフォローし合う環境が少しずつ出来始めました。 チーム全体の課題解決に対する自主性が生まれ、チームの成長につながったと思います。 ・ティーチングからコーチングへ 結成時から目的や背景の共有を続けてたことにより、チームメンバーの課題に対する不透明性が下がり、自律的に課題に取り組めるようになってきました。 このタイミングで自分からの積極的なフォローを徐々に少なくしていき、助けを求められた場合でも直接的な解答を控えてるようにしてその時必要な最低限のサポートのみをするように心がけました。 自分は心配性なのですぐに手助けをしたい性格なのですがそれがチームとメンバーの成長を阻害してしまうため、グッと堪えてメンバーの成長につながるような振る舞いをしていきました。 1つずつ壁を乗り越えていくことによって、ハードルの高い課題に対しても挑戦出来るようになっていきました。 ▼初めてのメンバーとスクラムをする上で特に気をつけた部分 タスクボードで進捗状況(=成果)が可視化されます。進捗が良い場合はなにも問題ありませんが、遅れている場合ストレスや焦りで心理的負荷が高まります。 (この部分はスクラムの良い点でもあり、悪い点とも言えるかもしれません...。) 進捗が遅れる原因が何だったのかを レトロスペクティブ(≒ふりかえり) で明確にしてチーム全員で改善案を検討・実施。メンバー全員で改善案を検討することによって、一人ひとりが自分ごととして問題を捉え、問題を当事者一人で背負い込む状態にならないように気をつけました。 またコミュニケーションハードルを下げるために、 スプリントレビュー ではお菓子を用意したり、些細な質問でも気軽にできるような雰囲気作りを心がけていました。 具体的には、メンバーからの質問には即レス対応をしていました。他にも、話しかけやすい雰囲気を作るために冗談を言ってみたり...笑 ▼まとめ 現在のチームで約5ヶ月間一緒になってスクラムをまわしてきました。 チームの状態に合わせてスクラムマスターの振る舞いを柔軟に変えていくことが大切なのだと、実践しながら学ぶことが出来ました。 これからももっと良いチームになるようにがんばります! この記事がシステム開発以外でスクラムを実践される際の参考になりましたら幸いです。 スクラム体制にして良かった点 タスクの見える化によって、メンバーの成果が見えやすくなった 見える化によってチーム内外とも連携がしやすくなった 不確実性の高い業務に立ち向かう勇気と実行力が培えた
アバター
こんにちは!クラシルバックエンドエンジニアの高松 @takarotoooooo です。 今回はクラシルの推薦システムにおけるSnowparkの活用事例を経緯とともに紹介しようと思います。 Snowparkとは DataFrame式のプログラミングを可能にする開発者向けツールで、現在はJava, Python, Scalaで利用することができます。 Snowparkを利用することで、SQLでは対応できなかったタスクがSnowflakeからデータの移動なしで実現できるようにデザインされています。 www.snowflake.com 推薦システムの概要 まずは、我々がどのように推薦システムのパイプラインを既に構築していたかをご紹介します。 下記のフローでデータの収集、加工し、ユーザーさん毎に推薦アイテムを提供しています。 推薦システムのパイプライン クライアントアプリケーションからユーザーさんの行動データをログとしてKinesis Data Firehose、S3を経由し、Snowflakeに収集される 収集されたデータを用いてユーザーさん毎に推薦アイテムのリストを生成する 生成された推薦アイテムをReverse ETLとして外部関数を経由してサーバーアプリケーションが利用するDBへ格納する サーバーアプリケーションはDBに格納された推薦アイテムをAPIのレスポンスとしてクライアントアプリケーションへ提供し、ユーザーさんへ推薦アイテムを届ける 詳しくは、こちらでもご紹介しているのでご覧ください。 tech.dely.jp なぜSnowparkを利用するに至ったか 初期の推薦ロジックは視聴履歴を利用し、「このコンテンツを見ている人にはこのコンテンツを推薦する」といった、所謂ルールベースによる推薦を行なっていました。 「ユーザーさん毎の推薦アイテムリストを作成する」というSQLが存在し、Snowflakeのtaskが実行するとユーザーさん毎の推薦アイテムリストが抽出され、後続のtaskがそのリストを外部関数に対してリクエストすると言った具合です。 推薦ロジック初期状態 しかしながら、日々ルールベースロジックを更新していく中でルールは複雑になり、更新・デバッグの難易度が上がってきたことと、将来的に機械学習の導入を見据えるとSQLから何かしらのプログラミング言語に移行する必要性を感じてきました。 どのような構成にするかを考える上で、要件は下記で考えました。 構築済みのニアリアルタイムなパイプラインは引き続き活かせること データの移動は極力なしにすること 機械学習を導入した際に学習、推論も同じ構成で実現可能であること webアプリケーションエンジニアも理解・開発できるようにRubyまたはPythonで実装できること この結果Snowpark for Pythonを利用してみることにしました。 導入時にしたこと 大きく二つのことを行いました。 SQLで書かれた既存のルールベースロジックと同じ結果を出力するプログラムをPythonで実装する PythonのコードをSnowflakeのtaskから実行する方法を検討する まずは既存のルールベースロジックをSnowpark for PythonのクライアントAPIを利用して再現できることを目指しました。 2つ目に「構築済みのニアリアルタイムなデータパイプラインは引き続き活かせること」を要件としていたので、コードで書き換えたルールベースロジックをSnowflakeのtaskから実行できる方法を検討しました。 こちらは結果として、Pythonで書かれたルールベースロジックをstored procedureとして登録し、taskから呼び出すことでデータパイプラインの構成は変えず、SQL -> Pythonへの書き換えを達成しました。 docs.snowflake.com 機械学習の導入 しばらくルールベースロジックの開発を続けた後、機械学習導入の検討を始めました。 機械学習の導入における要件は下記で考えました。 引き続き構築済みのニアリアルタイムなデータパイプラインは引き続き活かせること ルールベースロジックと機械学習ロジックのABテストを実現できること 学習もSnowflakeのtaskによって実現できること 既存のデータパイプライン構成は引き続き変えず、ルールベースロジックとの比較を行うためにtaskから実行されるstored procedureは引き続き利用することにしました。 stored procedureで登録した処理内部にABテストの仕組みも導入し、既存のルールベースロジックと機械学習ロジックが振り分けられるようにしました。 機械学習の導入にはルールベースとは別に学習の仕組みが必要になるので、学習を実行するstored procedureを作成しモデルを学習させ、学習済みモデルをimportする形で推論のstored procedureを更新することで、次回から再学習されたモデルが推論で利用されるようにしました。 推薦ロジックのシミュレータとしての利用 ルールベース、機械学習モデルともに、推薦システムにおいて誰にどんなコンテンツが推薦されるのかを確認できるようにするため、シミュレータをSnowparkとStreamlitを利用して作りました。 Streamlitは簡単にwebアプリケーションを作成できるPython用のフレームワークです。 webのフロントエンドの知識がなくても、webアプリケーションが作れるようになっているので、分析結果の可視化などでもとても役に立つフレームワークとなっています。 streamlit.io 我々がここで作ったシミュレータはstored procedureと同様の方法でアプリケーションが推薦ロジックを呼び出す形で作りました。 シミュレータのおかげで 新しい推薦ロジックを作り シミュレータで確認し 問題なければstored procedureを更新する というサイクルで、事故なく開発を続けることができました。 まとめ Snowparkを利用することで、現行のデータパイプラインを極力変更せずに推薦システムにおいて機械学習の導入をデータの入力(収集、加工)から学習、推論を通して出力までE2EでSnowflake内で完結させることができました。 つまり最初のご紹介の通り、Snowparkを利用することで、SQLでは実現できないタスクをデータの移動なく実現することができたわけです。 今後も推薦システムに限らず、いろいろな場面で活用していきたいなと思っています。 https://careers.dely.jp/ careers.dely.jp
アバター
こんにちは。Kurashiru Androidエンジニアのもとはしです。 最近は暖かくなってきましたね。なんなら暑い。そろそろ半袖を着始めてもいいかもしれません。 さて、今回はタイトル通りGoogle Ad Managerより提供されているカスタムネイティブフォーマットを使って、広告接触ユーザーを特定する方法をご紹介しようと思います。 背景 Kurashiruでは食品メーカーを中心とするクライアントさんを多数抱えており、アプリ内に純広告としてクライアントさんの広告を配信することがあります。 リターゲティングや広告効果計測を行うため、それらの純広告への接触ユーザーを特定する仕組みを社内で用意していましたが、今回ビジネスサイドよりもっと正確に接触ユーザーを特定したいとの要望がありました。 そこで、何か使えそうな機能がないか調査してみたところカスタムネイティブフォーマットを使えば実現できそうだということが分かったのです。 実際にやってみる 下準備 カスタムネイティブフォーマットは、主に純広告を配信する際に通常のネイティブ広告に含まれる情報(headline / body / クリエイティブ情報など)とは別に表示情報を付加したい場合に有用となります。 主な用途は表示情報のカスタムになるわけですが、表示に関係ない情報を含めることも可能です。 例として、キャンペーン名を設定できるようにしてみます。 フォーマット作成後、IDが振られます。これは後ほどアプリ内の実装にて使用します。 そしてこのフォーマットを使い申込情報を作成します。 カスタムフォーマットを使用すると、クリエイティブ作成時にユーザー定義の変数という項目が表示されます。 フォーマット作成時に設定したcampaign_nameが表示されているので、そこに適当な値を設定します。 アプリの実装 ネイティブ広告のロード時はforNativeAdを使用しますが、カスタムネイティブフォーマットの場合はforCustomFormatAdを使います。 AdLoader.Builder(context, adUnitId) .forNativeAd { ad -> ~~~~ } .forCustomFormatAd( customFormatId, { customFormatAd -> ~~~~ }, null ).build() このメソッドの第一引数に先ほどのフォーマットIDを指定することでカスタムネイティブ広告を取得できるようになります。 取得できた広告に対して、getString("campaign_name")とすると申込情報作成時に指定したキャンペーン名が取得できます。 customFormatAd.getString( "campaign_name" ) // テストキャンペーン あとはview imp / clickに応じて接触ユーザーのIDと一緒に、キャンペーン名をパラメータに入れてFirebase Analyticsなどのアナリティクスツールにイベントを飛ばしてあげるだけです。 adView.setOnImpressionListener { sendAdImpEvent(userId, campaignName) } adView.setOnClickListener { sendAdClickEvent(userId, campaignName) } アナリティクスツールに送るの?と思った方もいるかもしれません。 本来は管理画面上でデータを確認したいところですが、我々は現時点でその方法は見つけられていません…。もし他の方法をご存知の方いましたらぜひコメント等で教えてくださると助かります🙏 カスタムネイティブフォーマットの実装時の注意点 さて、ここからは実装時の注意点をいくつか残しておこうと思います。詳細については ドキュメント が詳しいのでこちらをご参照ください。 headline / body等の表示について headlineやbodyといった通常のネイティブ広告で返ってくる値もフォーマット上で指定しない限り、アプリ側で受け取ることができません。 先ほどは例示用としてキャンペーン名のみフォーマット上で指定しましたが、本来はheadlineやbodyも指定して受け取れるようにする必要があります。 customFormatAd.getText( "headline" ) customFormatAd.getText( "body" ) クリエイティブの表示について クリエイティブの表示方法も通常のネイティブ広告と差異があります。 まず、広告クリエイティブが動画なのか以下のように判定する必要があります。 if (mediaContent != null && mediaContent.hasVideoContent()) 動画の場合は通常のネイティブ広告と同様にMediaView.mediaContentにmediaContentを紐づけるだけですが、静止画の場合は静止画表示用のImageViewを置いてsetImageDrawableを呼び出します。 if (mediaContent != null && mediaContent.hasVideoContent()) { layout.media.mediaContent = customFormatAd.mediaContent } else { layout.image.setImageDrawable(customFormatAd.getImage( "creative" )?.drawable) } imp / clickの集計について カスタムネイティブの場合imp / clickは自動で集計されないため、自分でimp / click処理を呼び出さないといけません。 impにはrecordImpression / clickにはperformClickが使用できます。 // imp customFormatAd.recordImpression() // click customFormatAd.performClick( "view_name" ) performClickにはどのview経由で広告がクリックされたかを含めることができます。 自前でclickイベントを送ってはいるので、あくまで管理画面でも数値を確認できるようにするためという意味合いが強いです。不要であれば実装は省いてもよさそうではありますね。 終わりに カスタムネイティブフォーマットを使って広告接触ユーザーを特定する方法をご紹介しました。 広告接触ユーザーの特定には色々な方法があるかと思いますが、Google Ad Managerを使用した事例はなかったので参考になると幸いです。 https://careers.dely.jp/ careers.dely.jp
アバター
こんにちは!クラシルでサーバーサイドエンジニアをやっています @_kobuuukata です!👩🏻‍💻 私は、現在クラシルサーバーサイドの技術改善チームに所属し、技術的負債の解消に取り組んでいます! 今回の記事では、技術改善チームでどんなことに取り組んでいるかについて紹介したいと思います💁‍♀‍ 技術改善チームについて クラシルサーバーサイドの技術改善チームは2021年1月より発足しました。 それまではスピード重視での開発を進めてきたのですが、使っているミドルウェアやライブラリがEOLを迎え、サービス全体に影響のあるものに対して向き合っていく必要が出てきました。 当初はRails, Rubyバージョンアップが大きなミッションでしたが、 現在は「開発スピードとシステムの品質を長期的に維持していくために、開発メンバーとシステムを根本からサポートする」ことを目的としたチームとして、日々の業務に取り組んでいます。 これまでに技術改善チームが取り組んできた主な課題 Rails, Rubyのバージョンアップ サービス開始当時からRails,Rubyのバージョンアップは行っておらずEOLを迎えていました。 2021年1月時点で利用していたバージョンは以下の通りです😨 Rails v5.0(EOL 2018年4月) Ruby v2.3(EOL 2019年3月) バージョンアップ対応を行うにあたっては、テストコードのカバレッジ率が78%と低く品質の担保が出来なかったため、最初にテストコードを追加してカバレッジ率の向上を行いました。 現在は97.7%まで維持できるようになりました↓ また、リリース時は全てのサービスのサーバーに対しデプロイするのではなく、一部のサーバーのみに適用して1日問題がなければ翌日全サーバに反映するなど、できる限りサービスに影響が出ないように工夫しました。 2021年から約1年半かけてサポート範囲内までバージョンアップが完了しました🎉 Rails: v5.0 → v5.1 → v5.2 → v6.0 → v6.1 Ruby: v2.3 → v2.4 → v2.5 → v2.6 → v2.7 バージョンアップ対応の詳細は以下の記事をご覧ください↓ tech.dely.jp 動画変換基盤の改善 クラシルでは内製レシピの動画変換基盤がhlsとwebmで異なっており、複雑な構成になっていました。 before こちらをAWS Elemental MediaConvertに移行し、動画変換基盤を1本化しました。 MediaConvertに移行するにあたっては、ElasticTranscoderやECSで実行されていたffmpegでの変換パラメータをMediaConvertに読み替える作業が一番大変でした。。 after 動画変換基盤がシンプルになったことで、AWSの不要なリソースも削除することができたり、チーム全員が理解しやすいものになりました🎉 アプリケーションログ量の調整 アプリケーションが吐き出すログが多すぎて、インフラ費用が多くかかってしまっていたり、ログ基盤への負荷がかかっていました。 デフォルトで吐き出されるログや、意図的に吐き出しているが閲覧しないものを整理しました。 具体的には以下の対応を行いました ActiveModelSerializerのログを削除 log levelをwarnに変更 Dynamodbの操作ログを削除 debug logに変更 Sentryのログを削除 sentry sdkのlog levelをwarnに変更 対応後はログ量が最大で1/70まで減りました🎉 技術改善チームではこの他にも gemのバージョンアップ bundlerのバージョンアップ CIの速度改善 クエリチューニング 脆弱性対応 使われていないコードの削除 などなど、の負債解消を行ってきました🙌 最後に クラシルの開発部では、業務の30%を負債解消に当ててもよいルールや、隔週の金曜日に負債解消に取り組むといった様々な技術負債解消に向けた仕組みを試してきました。しかし、日々の業務でなかなか時間が取れなかったり、工数のかかる負債解消には取り組みづらかったりといった課題がありました。 サーバーサイドでは技術改善チームを作ったことで負債解消のスピードを加速させられたのはとてもよかったと思います! クラシルでは機能開発はもちろん、こういった負債解消にも積極的に取り組んでいることが伝われば幸いです🌸 クラシルに少しでも興味を持ってくれた方がいれば、以下の採用ページをのぞいてみてくださいね💁‍♀‍ careers.dely.jp
アバター