TECH PLAY

株式会社スタメン

株式会社スタメン の技術ブログ

237

これはなに? こんにちは、リファクタリング大好きな ミノ駆動 です。2023年7月より 株式会社スタメン にジョインしました。 コミュニケーションには会議体やテキストベースなど様々な手段があります。 その中で雑談がなぜ重要であるかについて、私の考えを記したものです。 大事な前提 〜目的と手段の関係〜 人々の活動には 目的 があります。そして目的を満たすための 手段 を追い求めています(ここでいう手段とはシステムであったり情報であったり、「目的の役に立つもの」と考えてください)。 目的と手段の関係性を次の図で表現します。目的と手段それぞれの円の重なりが大きいほど、目的に対して相応しい手段である、ということをここでは表します。 この図を使った例を出します。 今の時期、だんだん暑くなってきましたね。「暑さを解消したい」という目的に対して、「扇風機を点ける」「エアコンを点ける」「かき氷を食べる」「南極に送り込む」などの手段が考えられます。 おや? 上図では「暑さを解消したい」という目的に対して「かき氷」という手段はあまり重なっていません。どうも相応しくない手段のようです。暑いと感じている人に詳しい事情を尋ねてみると「最近冷たいものを食べ過ぎちゃってさ、お腹の調子が良くないんだよ。食べ物以外で涼めるものがあればなぁ」という回答でした。 この人にとっては「胃腸に負担をかけずに暑さを解消したい」が 真の目的 だったのです。この目的に沿うようエアコンを提供したところ、目的が満たされました。 この関係性はソフトウェア開発も同じです。 例えば弊社スタメンの主要サービス TUNAG (ツナグ)は、企業のエンゲージメントの構築、つまりお互いを知って理解し、信頼し合う組織を作るための社内コミュニケーションを活性化させるプロダクトです。「 エンゲージメントを向上させる 」目的を満たす手段としてプロダクトを開発しています。よりこの円の重なりが大きくなるよう機能開発や機能改善が図られています。 ここまでが前提です。 ここから先が本題です。 会議体だと重要な情報を得にくい 私の生業はソフトウェア設計ですが、仕事柄、正確な設計には顧客課題や顧客要求など、ビジネス面の様々な情報を必要とします。そのため、PdMのほかビジネスサイドの皆さんへの相談やインタビューを必要とします。 こうした「何らかの重大な判断に必要な情報を収集する活動」は、別に私に限らず皆さん普段から当たり前のように実施されていると思います。 しかし私の経験上、 会議体での 相談やインタビューで「本当にほしい重要な情報」を得られた経験があまりありません。 そうではなく、 雑談でふとした拍子にポロッと出てきたセリフに重大な情報が含まれていた経験が圧倒的に多いです 。それはなぜでしょう。 意図せず重要な情報が削ぎ落とされてしまう 会議には必ず目標を設定します。「◯◯について意思決定する」「◯◯について認識を合わせる」などです。 ここでは「目的Aに関して意思決定する」を会議の目標とします。さて、このとき何が起きるでしょうか。 会議の目標を効率的に達成できるよう、会議の参加者は、目的Aに沿った準備をします。また、目的Aに則った会話が交わされます。つまり、 様々な情報の内、目的Aに沿ったものだけが話題に上がり、それ以外の情報は削ぎ落とされます。 一見効率的で正しいアプローチのように思えます。ところがそうではありません。 顧客が本当に必要だったもの 有名な「顧客が本当に必要だったもの」の図( Tree Swing Cartoon Pictures (early versions) より引用)。 この図に揶揄されるように、顧客の本当の要求が何であるのか、プロダクト開発において常に考え悩ませるものです。そのため我々は仮説検証やABテストなどを実施して 顧客の真の目的 を探りに行きます。 そして真の目的が本当は目的Bなのにも関わらず、偽の目的Aが真の目的だと勘違いしていることが往々にしてあります(そもそも顧客の真の目的がはじめから分かっていたら誰も苦労しない)。 偽の目的Aをテーマに据えて会議を進めた場合、目的Aに関する情報は話題に上がりますが、真の目的Bに関する話題は削ぎ落とされてしまいます。 会議の効率化が仇になって、本当に必要なものが手に入らない、または入りにくくなってしまうのです。 雑談では「情報の悪しき削ぎ落とし」が生じにくい 一方で雑談は特定の目的がありません。そのため、さまざまな情報が話題に上がりやすくなります。会議体で陥りがちな「意図しない情報の削ぎ落とし」が生じにくくなります。 このような特性があるため「 話者にとって重要だとは認識していないが実は重要な情報 」がポロッと話題に上がり、判断精度の劇的な向上に貢献することになるのです。 おわりに 以上解説しましたように、弊社スタメンでは雑談の効能を重要視しています。そしてサービスの改善革新や質の向上に役立てています。 弊社スタメンはTUNAGを中心に右肩上がりの成長を続けています。 そしてこの成長をさらに促進させるためにサービスの設計戦略を策定し、推進していく計画です( 詳しい戦略はこちら、 『スタメンの技術的負債解消戦略』 )。 2023年12月期 第1四半期決算説明資料 より この勢いで弊社スタメンはさらなる事業拡大を目指し、採用活動をより活性化していきます。現在従業員数は約80名。今の規模の2倍、3倍、5倍とスケールさせていきます。 エンジニア絶賛募集中です。私と一緒に働いてみたい、興味がある方は、ぜひ下記にアクセスしてみてください。 herp.careers
1. これはなに こんにちは、リファクタリング大好きな ミノ駆動 です。2023年7月より 株式会社スタメン にジョインしました。 この記事は、今後スタメンにおいてサービスの技術的負債を解消する設計戦略についてまとめたものです。 2. 背景、課題 株式会社スタメンは2016年創業。主要サービスである TUNAG (ツナグ)は、企業のエンゲージメントの構築、つまりお互いを知って理解し、信頼し合う組織を作るための社内コミュニケーションを活性化させるプロダクトです。TUNAGのバックエンドはRuby on Railsで開発され、ローンチから7年をむかえつつあります。 これまでTUNAGは、プロダクトをいかに伸ばすかに注力してきた一方、内部品質や開発効率など「開発者体験」に関する課題が後手に回っていました。本来プロダクトチームはユーザーにとっての本質的な価値にのみフォーカスできる状況が理想ですし、開発者体験が悪いと良いユーザー体験を提供することができなくなっていきます。 開発者体験を悪化させる大きな要因のひとつが技術的負債です。具体的には 変更容易性 の低下です。変更容易性とは、なるべくバグを埋め込まず、素早く正確にコード変更できる度合いです。変更容易性が低いと開発生産性が低下してしまいます。 3. 設計戦略解説 サービスの変更容易性を向上し、開発生産性の継続的向上を果たすため、以下の図に示す設計推進活動を私主導のもとこれから実施していきます。 %%{ init: { 'theme': 'base', 'themeVariables': { 'background': '#000000' } } }%% graph TD A[開発生産性の<br>継続的向上] --- B[変更容易性の<br>継続的向上] B --- リファクタリング B --- C[変更容易性<br>設計スキルの向上] リファクタリング --- D[費用対効果の<br>高い箇所] D --- コアドメイン D --- H[負債レベルの高い箇所] コアドメイン --- 中長期事業計画 コアドメイン --- E[有識者への<br>インタビュー] コアドメイン --- コンテキストマップ作成 H --- K[ツールによる<br>負債計測] H --- G[変更失敗率の<br>高い箇所] H --- I[変更の苦しい箇所] コンテキストマップ作成 --- F[ドメインエキスパートへの<br>インタビュー] コンテキストマップ作成 --- ソースコード解析 F --- 目的 F --- 登場概念 F --- アクター F --- プロセスサイクル C --- 設計ガイドライン C --- ハンズオン勉強会 設計ガイドライン --- データモデル 設計ガイドライン --- アーキテクチャ かなり多岐にわたるため説明が長くなりますが、ぜひ最後までお読みいただければと思います。 変更容易性の向上には大きく2軸の活動があります。既存の技術的負債を解消するリファクタリングと、今後の新たな負債を抑止するための変更容易性設計スキルの向上活動があります。 3.1 リファクタリング リファクタリングとは、外部から見た挙動を変えずに、プログラム構造を整理することです。変更容易性の高い構造に整理することがリファクタリングのゴールです。そのため、変更容易性の高いあるべき構造を設計したり、設計のためにドメインを分析したりと、多岐にわたるさまざまな活動が必要です。 3.1.1 費用対効果の高い箇所の特定 リファクタリングはただ実施すればいいというわけではありません。例えば粗悪なコードであっても、今後ほとんど仕様変更の見込みがない箇所を一生懸命リファクタリングして意味があるものでしょうか?リファクタリングは将来の変更コストを低減する活動です。従って、将来あまり変更されない箇所をリファクタリングしてもコストがかかるだけで役に立ちません。開発リソースは有限なので、限られたリソースの中でリファクタリングの費用対効果の高い箇所を狙う必要があります。 費用対効果の高い箇所の特定には、主に以下2つを複合的に考慮して判断します。 コアドメイン 負債レベルの高い箇所 3.1.1.1 コアドメイン どんな商品やサービスにも、「これがウリだ!」と呼べるような中心的価値があります。中心的価値を発揮する事業領域を、ドメイン駆動設計では コアドメイン と呼びます。コアドメインは差別化が図られ、企業の競争優位性を発揮する領域です。サブ的な領域よりも投資優先度が高いです。当然設計コストも同様です。競争優位性をより高めるために技術的負債を解消し、開発生産性を高めることが肝要です。 ただし、サービスが大きくなると何が中心的価値なのかだんだん分からなくなっていきます。また、時代によって人々の関心事は移り変わっていきます。特にコロナ禍前後でドラスティックに世の中の価値観が変化したのは皆さんの記憶に新しいところだと思います。何が中心的価値なのかを見定める必要があります。そのためには以下の調査や分析が必要になります。 中長期事業計画 有識者へのインタビュー コンテキストマップ作成 3.1.1.1.1 中長期事業計画 中長期事業計画とは、中長期的な経営ビジョンを実現するために事業としてやるべきことを計画したものです。中長期的にどのように事業価値を伸長していくのか、進むべき方向性が定義されます。そしてソフトウェアの変更容易性は、中長期にわたって開発生産性に影響を及ぼします。コアドメインを見定めるため、また変更容易性設計の投資価値を見定めるためにも中長期事業計画のinputは重要です。 3.1.1.1.2 有識者へのインタビュー 中長期事業計画だけでなく、有識者から生の声を聞くことも大事です。プロダクトマネージャーの他、経営レベルに近いメンバーに対し、コアドメインに相当する事業領域が何であるのかインタビューします。 3.1.1.1.3 コンテキストマップ作成 ソフトウェアサービスには、中心的存在となるモデルが必ずといっていいほど登場します。例えばECサイトにおける商品モデルです。このようなモデルはさまざまな状況、さまざまなユースケースで用いられます。しかし、全ての状況に対応可能な、一枚岩の万能モデルとして作り上げようとすると、ロジックが混乱する、変更が困難になるなど多くの弊害を招きます(以下は前職所属時のイベント登壇動画と資料)。 www.youtube.com speakerdeck.com そこで 万能モデルとは異なる設計アプローチ が必要です。背景、状況、目的が大きく異なる事業ドメインの単位でサービスをサブシステムに分解し、各サブシステムに 特化したモデル として設計することの重要性をドメイン駆動設計では説いています。状況別の各特化型モデルそれぞれが適用可能な範囲を「境界づけられたコンテキスト」と呼びます。 このようにしてコンテキストごとに分解したサブシステムの内、コアドメインに対応するサブシステムを特定し、コアにコア以外のロジックが浸潤しないよう明確に区分付けることの重要性をドメイン駆動設計では説いています。ここまでやって初めてリファクタリングの「選択と集中」を実施可能な、費用対効果の高い箇所の特定が可能になります。 コンテキスト境界がどこにあるのかはさまざまな観点での分析が必要です。例えば以下です。 ドメイン分析 ソースコード解析 3.1.1.1.3.1 ドメイン分析 コンテキストごとにどんな違いがあるのか観点はさまざまですが、例えば以下のような点で違いがあると私は考えます。 目的 アクター 登場概念 プロセスサイクル コンテキストの違い この図はECサイトを例にしたコンテキストの差異を説明した図です。 まず、大きな粒度で目的が異なります。コンテキストをまたぐような巨大な一枚岩モデルは、多目的に使われてしまうので単一責任原則違反と考えることができます。コンテキスト特化型モデルの設計は、単一目的に対応できるように設計することです。つまり、目的が大きく異なる境界がコンテキスト境界と考えることができます。 また、目的が異なると、アクターや登場概念が大幅に違ってきます。例えば、 在庫管理 アクター : 出品者 登場概念 : 入庫、出庫、安全在庫量 配送 アクター : 配送業者 登場概念 : 配送元、配送先、配送状況、配送料 このように周辺概念を整理することもコンテキスト境界の発見につながります。 また、コンテキストごとにプロセスサイクルが異なります。在庫管理では出品者が入出庫のサイクルを回します。一方で配送では配送業者が梱包し、配送先まで配送するサイクルを回します。お互いのサイクルは交わることがありません。 このような違いを探るため、ドメインエキスパートへのインタビューやイベントストーミングなどでドメイン分析します。 3.1.1.1.3.2 ソースコード解析 コンテキストの違いは当然ソースコードにも現れます。 これはある程度目処をつけて調査することが可能です。例えば、以下のようなモデルは複数のコンテキストにまたがっていることがとても多いです。 巨大なモデル サービスのワークフローの始めから終わりまでいるようなモデル このようなモデルは、状態によって以下が大きく異なる特徴があります。 更新対象のデータ 振る舞い これらの差異がコンテキスト境界の手がかりとなります。 3.1.1.2 負債レベルの高い箇所 リファクタリング優先度の高いのはコアドメインのロジックですが、その中でも負債レベルの高い箇所がより優先度が高まります。負債レベルの高い箇所の割り出しには、例えば以下を実施します。 ツールによる負債計測 変更失敗率の高い箇所の特定 変更の苦しい箇所の特定 3.1.1.2.1 ツールによる負債計測 技術的負債の分析はツールにより計測可能です。弊社では Code Climate Quality を利用しております。Code Climate QualityはGitHubと連携し、負債やファイルの更新頻度を自動でスコアリングしてくれます。技術的負債と変更容易性は未来の変更コストに影響しますから、負債レベルと更新頻度の双方が高いものほどリファクタリングの効果が高いと考えられます。 3.1.1.2.2 変更失敗率の高い箇所の特定 本番デプロイ後に修復を要するコードの割合を変更失敗率といいます。変更失敗率を計測し、失敗に関与したソースコードを割り出します。 3.1.1.2.3 変更の苦しい箇所の特定 エンジニアにとって変更の苦しい箇所を特定します。どのあたりのコードが厄介なのか、いつも変更に苦慮しているのかをアンケートなどで聞き出します。 3.2 変更容易性設計スキルの向上 負債の増加を抑止するには、エンジニアの設計スキル向上が大事です。 ガイドラインなどドキュメントを揃えたり、勉強会を開催するなどテコ入れを要します。 3.2.1 設計ガイドライン 設計にはゴールが必要です。ゴール方針となる設計ガイドラインを策定します。ゴールを据えることで、そのギャップとして技術的負債を認知できるようになります。 少なくとも以下2点のガイドラインをまず用意します。 データモデル設計 アーキテクチャ設計 3.2.1.1 データモデル設計 DBのテーブル構造が負債化しないよう、データモデルの設計方法を取りまとめておく必要があります。Railsの場合、テーブルと1:1になるActiveRecordがさまざまなレイヤと密結合になりやすく、テーブル構造が全体にダイレクトに影響を及ぼすため、特に設計に注意を払う必要があります。 設計にはTM(T字形ER手法)を用います。 イミュータブルデータモデル(入門編) from Yoshitaka Kawashima www.slideshare.net 3.2.1.2 アーキテクチャ設計 アプリケーションアーキテクチャ全体の再設計も必要です。 RailsはMVCフレームワークであり、普通はRails-wayと呼ばれるお作法に則って開発が進められます。しかしRailsは、スタートアップ時の加速力を得るため結合度を犠牲する「犠牲的アーキテクチャ」と呼ばれています。そのため開発が進み数年もするとモデルが複数の意味を持ち始めたり、最新のドメイン理解に対してテーブル構造が陳腐化して乖離が激しくなったりします。Rails-wayでは負債の解消や抑止が困難になっていきます。 この解決のため、モジュラーモノリス化することを目標に、以下のようなDDDベースのアーキテクチャへの移行を検討しています。このアーキテクチャの意図や方針、各責務の役割や設計方法をエンジニアに説明するため、アーキテクチャ設計のガイドラインを策定します。 %%{ init: { 'theme': 'base', 'themeVariables': { 'background': '#000000', 'primaryTextColor': '#6360DC' } } }%% classDiagram namespace Views { class View } namespace Models { class ActiveRecord } namespace Controllers { class Controller } namespace UseCases { class UseCase class QueryService { <<interface>> } class Dto } namespace Infras { class RepositoryImpl class QueryServiceImpl } namespace Domains { class Repository { <<interface>> } class Aggregate class Entity class ValueObject } ActiveRecord <.. RepositoryImpl ActiveRecord <.. QueryServiceImpl View ..> Controller Controller ..> UseCase Controller ..> QueryService UseCase ..> Aggregate UseCase ..> Repository Repository ..> Aggregate Aggregate --> Entity Aggregate --> ValueObject Entity --> ValueObject RepositoryImpl ..|> Repository QueryServiceImpl ..|> QueryService QueryService ..> Dto (※1:全体に対して適用するのではなく、コアドメインなど負債に対して特に注意を払う必要がある箇所についてDDDベースに移行します。負債が気にならない箇所、設計コストかける旨味があまりない箇所はRails-wayでいきます。) (※2:Rubyではinterfaceは心の中にしかないので、上図におけるRepositoryやQueryServiceのinterfaceは実際には存在しません。状況によってはマーカーinterfaceの代用としてmoduleを実装する場合があります。) 3.2.2 ハンズオン勉強会 設計スキルの大幅な向上には、なんといっても実際に手を動かすハンズオン勉強会が欠かせません。 プログラミングはただプログラミング入門書を読んだだけでは、実務に使えるプログラミングスキルは身につきません。仕様を満たすロジック構造を考えて実装する経験が必要です。設計スキルも同様で本を読めば身につくものではなく、泥臭く混乱したロジックをどのように整理すれば変更が容易な構造になるか、自分の頭で考えて設計する経験が必要です。 勉強会ではプロダクションコードを使ってリファクタリングの練習をします。泥臭いロジックほど設計の旨味があるためです。例えば拙著 『良いコード/悪いコードで学ぶ設計入門』 gihyo.jp に記載の設計パターンを学ぶ場合、そのパターンを適用できそうなロジックをプロダクションコードから探します。普段エンジニアが触り慣れている箇所が望ましいです。該当するロジックが見つかったら、挙動が同じで変更容易性の高い構造を設計し、コードを実装します。それぞれの成果をみんなの前で発表し、どんな工夫をしたかなどについて議論や質疑応答し、フィードバックを得ます。 私は数社でこの勉強方法を実施した結果、どこでも設計スキルの向上を果たせました。効果に再現性があると自負します。オススメです。 4. おわりに 以上説明した戦略のもと、変更容易性を向上し開発生産性を継続的に高めるべく邁進していきます。 弊社スタメンはTUNAGを中心に右肩上がりの成長を続けています。この成長をより高めるため、私に課せられた責務は非常に重要なものになっております。 2023年12月期 第1四半期決算説明資料 より この勢いで弊社スタメンはさらなる事業拡大を目指し、採用活動をより活性化していきます。現在従業員数は約80名。今の規模の2倍、3倍、5倍とスケールさせていきます。 エンジニア絶賛募集中です。私と一緒に働いてみたい、興味がある方は、ぜひ下記にアクセスしてみてください。 herp.careers
こんにちは、株式会社スタメンで TUNAG のiOSアプリエンジニアをしている青木 ( @38Punkd )です。 何気に今回の記事がこの Tech Blog への初投稿で、ワクワクしています。 TUNAGのiOSアプリは、これまでリアクティブプログラミングの手法として、 RxSwift を導入してきました。 そして今年度から、アプリがサポートするOSバージョンの下限を13.0に引き上げたため、Apple公式の非同期フレームワーク Combine が使えるようになりました。 アプリに対してサードパーティ製のライブラリであるRxSwiftへの依存度を下げたかったことと、純粋に新しい技術を試してみたいという好奇心も相まって、アプリにCombineを導入することを試みました。 実際にCombineを導入してみた感想と、導入する際の注意点をお伝えできれば思います。 SingleからFutureへの移行 通信は大別すると、結果の受け取りを継続して監視する必要のあるものと、結果の受け取りが一回きりで良いものの2種類がありますが、 今回は、Combineの中でも一回きりの非同期処理を監視する Future 型を使って、一回きりのAPI通信の結果を取得する実装をしました。 これまでは、一回きりのAPI通信部分は、RxSwiftのSingleを用いて以下のようなコードを書いていました。 結果を発行する側 Singleを用いて、非同期処理の結果を通知できるオブジェクトを返すメソッドを用意します。 // データレイヤー(APIと通信をする) class Repository { func fetchTaskList () -> Single <[ TaskEntity ]> { Single < [TaskEntity] > .create(subscribe : { observer in APIClient.request { response in observer(.success(`TaskEntity型`))) } return Disposables.create() }) } } 結果を受け取る側 上記のSingleを、subscribeメソッドを用いて購読し、結果を非同期に受け取れるようにします。 // ドメインレイヤー(ビジネスロジックを扱う) class Interactor { func requestTaskList () { Repository().fetchTaskList() .subscribe( onSuccess : { taskList in // API通信の結果取得した taskList を出力します } onFailure : { error in // errorを出力します }) .disposed(by : disposeBag ) } let disposeBag = DisposeBag() } これを、CombineのFuture型を用いて以下のように書きました。 結果を発行する側 Futureを用いて、非同期処理の結果を通知できるオブジェクトを返すメソッドを用意します。 // データレイヤー(APIと通信をする) class Repository { func fetchTaskList () -> Future <[ TaskEntity ], Error > { Future < [TaskEntity], Error > { [unowned self ] promise in APIClient.request .sink { completion in switch completion in case .failure( let error ) : promise (.failure(error)) case .finished : () } receiveValue : { response in promise(.success(`TaskEntity型`)) } .store( in : & cancellables) } } var cancellables = Set < AnyCancellables > () } 結果を受け取る側 上記のFutureを、sinkメソッドを用いて購読し、結果を非同期に受け取れるようにします。 // ドメインレイヤー(ビジネスロジックを扱う) class Interactor { func requestTaskList () { Repository().fetchTaskList() .sink { completion in switch completion in case .failure( let error ) : // errorを出力します case .finished : () } receiveValue : { taskList in // API通信の結果取得した taskList を出力します } .store( in : & cancellables) } var cancellables = Set < AnyCancellables > () } Futureは、非同期の結果や値を表すための型であり、RxSwiftでいう Single と同じ役割を担います。 sink メソッドは、監視可能なイベントを実際に監視するためのメソッドです。 イベントの監視状況は、sinkメソッドのトレイリングクロージャに入ってくる値(上記のコードでは completion という変数名で表しています)から確認できます。 この変数は、 failure と finished の二つのケースを持つenum型です。 監視中にイベントが発生(=この場合、タスク一覧の取得が完了する)すると、クロージャ型の引数 receiveValue に値が入ってきます。 最後に store メソッドによって、sinkメソッドの AnyCancellable 型の戻り値をcancellablesプロパティに保存します。 こうする事で、Future型をインスタンス生成した後も、イベントを監視できる仕組みが出来上がりました。 Combineを使う上で気を付けるべきポイントは2つありました。 sink メソッドの戻り値AnyCancellableは保持する必要がある Future はインスタンス生成直後に監視を開始する これらのポイントについて、詳細に解説していきます。 sink メソッドの戻り値AnyCancellableは保持する必要がある sinkメソッドはAnyCancellableを返却値として返します。 func sink (receiveValue : @escaping (( Self.Output ) -> [ Void ]( https://developer.apple.com/documentation/Swift/Void )) ) -> [ AnyCancellable ] (https : //developer.apple.com/documentation/combine/anycancellable) AnyCancellableは、非同期処理の中断を表すために使用される型です。 非同期処理が完了した後も監視を続ける場合、AnyCancellableのインスタンスを保持し、必要なときに中断することができます。 しかし、AnyCancellable型の戻り値をプロパティとして保存せずに捨ててしまうと、そのインスタンスは参照されず、すぐに解放されてしまいます。 その結果、監視が中断されます。 AnyCancellableの非同期を中断する方法は以下の2パターンがあります。 AnyCancellableのインスタンスが破棄される。 AnyCancellableに対して、 cancel メソッドを呼び出す。 AnyCancellableのインスタンスが破棄された場合に、AnyCancellableの非同期が中断されるのは、破棄されたタイミングでAnyCancellable自身がcancelメソッドを呼び出す仕様になっているからです。 なので、AnyCancellableインスタンスに対してstoreメソッドを実行して、プロパティとして保存しない場合、AnyCancellableインスタンスは解放され、監視が終了してしまいます。 class Interactor { // × // requestTaskListメソッドのスコープを抜けると、 // sinkメソッドの戻り値はすぐに解放されてしまう、良くない例です。 func requestTaskList () { _ = Repository().fetchTaskList().sink { ... } } } (おまけ) storeメソッドを呼ばない場合は、以下のようにも書けます。 class Interactor { func requestTaskList () { cancellable = Repository().fetchTaskList().sink { ... } } var cancellable : AnyCancellables? } なおこのAnyCancellables型のプロパティは、保持しているクラスが破棄されると、もちろん同時に破棄されますが、同時に監視も終了します。 なので、明示的に監視の中断処理を、デイニシャライザの中に書く必要はありません。 class Repository { // Repositoryのインスタンスが破棄されたら、cancellableも破棄されるので、 // このdeinitメソッドは不要です。 deinit { cancellable.cancel() } ... var cancellable : AnyCancellables? } Future はインスタンス生成直後に監視を開始する RxSwiftのSingleは、subscribeメソッドを実行するまで、監視を開始しません。この性質は Cold Observable と呼ばれます。 しかし、Futureはインスタンス生成された直後に監視を開始します。この性質は Hot Observable と呼ばれます。 Singleと違い、FutureはHotであることを意識しないと、以下のように意図しないタイミングで処理が走ってしまいます。 class Repository { let taskList : Future <[ TaskEntity ], Error > = Future < [TaskEntity], Error > { in cancellable = APIClient.request.sink { ... } } var cancellable : AnyCancellables? } class Interactor { ... } 上記のコードでは、 Repository がインスタンス生成された瞬間に、taskListを取得するAPI通信が走ってしまいます。 Future型を使う際は、意図しないメモリ消費を避けるために、ストアドプロパティとして定義するのではなく、メソッドの戻り値や計算プロパティとしてあげるのが良さそうです。 // ⚪︎ // fetchTaskListメソッドを呼び出して、初めてFuture内部が実行されます。 func fetchTaskList () -> Future <[ TaskEntity ], Error > { ... } // ⚪︎ // fetchTaskListプロパティを参照して初めて、初めてFuture内部が実行されます。 var fetchTaskList : Future <[ TaskEntity ], Error > { ... } // △ // fetchTaskListを保持するクラスがインスタンス生成された瞬間に、Future内部が実行されてしまいます。 // 意図しない挙動につながる怖い実装です。 var fetchTaskList = Future < [TaskEntity], Error > { ... } Combineに触れてみて 実際にCombineの機能の一部を導入してみた結果、書き方はRxSwiftと非常に似ていて、かつ処理の流れがより一層直感的なコードになったと感じました。 プロダクト保守性の観点からも、アプリの採用技術をApple公式のフレームワークに徐々に寄せていきたいと思っていますし、これを機に、Combineの導入をより推進できればと思いました。 弊社では、このようにして新技術を積極的に導入検討し、より良いプロダクトを追い求めて開発をしています。 人と組織を強くする HR Tech SaaSプロダクトを作りながら、技術でワクワクしたいソフトウェアエンジニアを、全技術領域で募集しています。 お得意の技術領域を問わず、ぜひカジュアルにお話ししましょう! herp.careers
アーキテクチャ図(完了後) こんにちは。当社が スポンサー参加したRubyKaigi 2023 が終わって1ヶ月以上経ち、6月は海外カンファレンスも多く忙しい日々を過ごしています。 最近はまたTUNAG全般をいじっています。 TUNAGのメインアプリ(Ruby on Railsベース)は 4月にRuby 3.0へのアップグレードした のち、5月前半にはRuby 3.1へのアップグレードが完了していました(ブログ記事なし)。執筆時点では、Ruby 3.2へのアップグレードは進行中との噂です。 2019年6月開催の 名古屋Ruby会議04 でもご紹介した通り、コンテナ基盤で動くRubyベースサービスとは別に、AWS Lambda上で動かしているバックエンドサービスがあります。これらのサービスでは、AWS Lambda公式提供runtimeの都合上もあり、最新版であり、AWS独自のサポートによるruby2.7 (コミュニティから出ているRuby 2.7) を利用していました。(注:一部AWS Lambda ruby2.5 runtimeも動いていましたが、こちらも2023年5月までにAWS Lambda ruby2.7 runtimeへの移行が完了しています。) 当社のプロダクトでのAWS Lambdaの利用状況については以下をご覧ください。(記述は執筆当時のものであり、既にリアーキテクティングされたなどにより、他のアーキテクチャで置き換えられた可能性もあります) tech.stmn.co.jp 我々のチームが技術検討した時点では、AWS Lambda ruby2.7 runtimeのサポート期日は、少なくとも2023年7月以降であると理解したため、急いでCustom runtimeを利用した基盤への移行を見送ることを決定していました。 アーキテクチャ図(当初) (注: Ruby 3.2ベースのruntimeがGA公開されてから6ヶ月と定められていた、というのが当時記述されていたと思うのですが、現在の公式ドキュメントからは削除されているようで、また、履歴として、最近 deprecated 扱いとされてしまった awsdocs も調査しましたが、見つけられることができませんでした。完全に蛇足ですが、今回の調査の過程でも困った、 https://aws.amazon.com/blogs/aws/retiring-the-aws-documentation-on-github/ の公式の報告(Retiring the AWS Documentation on GitHub, on 17 MAY 2023, by Jeff Barr)にもある通り、AWS DocumentationがGitHub上で管理できなくなったのは残念な限りです。) そんな中、Ruby 3.2 runtime now available in AWS Lambda by James Beswick | on 07 JUN 2023という朗報が先週入ってきました。 aws.amazon.com それを眺めた時点では、追加のruntime移行タスクを2023年11月末(より詳細な日付としては12月7日ですが)までに積んでおけばいいかくらいの温度感で手元の仕事に戻っていました。 次の週になって、RubyKaigi 2023関連の文脈で1つのツイートが社内チャットに上記の記事をリツイートされてきたことにより、急遽ruby3.2 runtime移行を推進することになりました。AWS Serverless Application Model (SAM) を利用しているものの、包括的なDevOpsが不十分であったこともあり、一手間かけなくてはなりませんでしたが、2.xから3.xとメジャーバージョンアップであったにもかからず、半日とかからず、productionリリースに漕ぎ着けることができました。 Lambda(Management Console) 効果 Ruby 3.1, 3.2あたりの注目ポイントとしてはYet Another Ruby JITであるYJITが導入され、production-readyであることが挙げられます。しかし、デフォルトでは無効であるため、今回はYJITを有効にしない形で、アップデートリリースのみを優先しました。 移行したLambda Functionは外部APIとの接続が最も占める割合が多いのですが、それが含まれないInvocationのDuration(Lambdaプラットフォームで計測され提供される値)では10-20%の高速化が観測できました。これだけの高速化はコミュニティリリースのRuby 2.7 MRI→Ruby 3.2 MRI (YJITなし)のみで経験したことがない次元であり、Lambdaで活用しているCompute基盤で利用しているCPUアーキテクチャが最新版になったのかLambda内部の何らかの改善が寄与しているのではないかと感じました。ユーザー体験、コスト面的にはよくなっているのでそれでいいかという気持ちです。それ以上プラットフォーム内部に詳しくなってもしょうがないですし。 10-20%の高速化を実現したLambda Functionでは、正規表現が割と多用されており、Ruby 3.2での同機能の改善によるものかもしれません。 まとめ Ruby 3.2.0がリリースされてから約半年した時点でAWS Lambda ruby3.2 runtimeがリリースされ、TUNAGプロダクトの一部で利用しているLambda functionで、ruby2.7→ruby3.2 runtimeの刷新を実施しました。特に問題が起きることなく、速度面、コスト面で若干の改善が見られました。 TUNAGへのYJITの本格導入や残りの半分ほどのruby2.7 runtimeのruby3.2 runtime完全移行、メインRailsアプリケーションのRuby 3.2移行、Ruby 3.3移行準備など、やりたいことがたくさんあるのですが、エンジニアが全く足りていません。 株式会社スタメンはRubyKaigi 2023ゴールドスポンサーです。Rubyを使う使わないに限らず、人と組織を強くする HR Tech SaaSプロダクトを作りながら技術でワクワクしたいソフトウェアエンジニアを全技術領域で募集しています。お得意の技術領域を問わずぜひカジュアルにお話ししましょう! herp.careers クレジット/おまけ 画像の一部にはAWS Architecture Icons (Release 16-2023.04.28)が利用されています。 aws.amazon.com awsdocs/aws-lambda-developer-guide@192b0ef7 の doc_source/lambda-runtimes.md : github.com なお、今日の朝ご飯はシンガポールチキンライスでしたが、写真はありません。
はじめまして、stmnで働いている@natsuokawaiと@starmiya_miyukiです。 stmnはRubyKaigi 2023にゴールドスポンサーとして協賛させていただいたので、エンジニア2人でRubyKaigi 2023にオフラインで参加してきました! スポンサーブースの様子 どのセッションも興味深かったのですが、本レポートでは特に社内でも話題に上がることの多い型関係の5つのセッションについてまとめてみます。 Day1 Generating RBIs for dynamic mixins with Sorbet and Tapioca rubykaigi.org Slide: https://drive.google.com/file/d/1W4bmAePVOSbUVe-JUzVcGMbro1HBcWhO/view tapiocaはRubyのソースコードを読み込み、クラスやモジュール内で定義された定数やメソッド定義の情報を元に型定義ファイルを生成するgemです。 基本的にはうまく動作していたが、dynamic mixins(クラスやモジュール定義の外で呼ばれる prepend、include 、extend)があるコードに関しては、静的な解析だけでは正しく型情報を生成できないという問題がありました。 そこでtapiocaでは、ソースコードのロード時に実際にmixinを実行する機構(これ自体もdynamic mixinである)を備えることにより、mixinされる定数やmixinの種類などの情報を取得できるようになりました。 コメント メタプログラミングが使われているコードの型情報の生成が難しいという話は色んなところで目にする話題ですが、それに対して同じくメタプログラミングを使って情報を動的に取得するというのはRubyらしくて面白いアプローチだなと思いました。 RBS meets LLMs - Type inference using LLM slides.com LLM(gpt-3.5-turbo)に実装コードから型定義(RBS)を生成してもらおうというお話。 .nameや.buildという自然言語からしっかりname: Stringのような推論をすることに成功していました。 一方で、たまにRBS構文がおかしかったり、不要な出力が混じっていたり、RBSがクラスごとに分割されていなかったりと課題もあったのですが、最終的にはFewShotで模範となるOutput Formatを入力してから推論させることにより、これらの課題を解決していました。 コメント 弊社でもすでに実装コードからSpecの生成にLLMに問い合わせを行っているエンジニアはいるので、RBSの生成タスクはLLMと相性が良さそうだと思いました。 また、明示的に型宣言をしない態度を取るRubyにおいて、自動で型推論してくれるLLM等の組み込みはかなり将来性があるのではないかと感じます。 Day2 Revisiting TypeProf - IDE support as a primary feature rubykaigi.org TypeProfは静的型定義のないメソッドやオブジェクトの型を推論するツールです。 すでにRubyに同梱されてはいますが、現状あまり使われていないようです。その原因として、ユーザーのニーズとTypeProf v1が前提としていたことが噛み合っていなかったことが挙げられます。 具体的には、v1ではパフォーマンスを重視しておらず、サポートするコードも完全なコードのみを対象としていたのですが、実際のユーザーのニーズはIDE上でのDX向上なのでそれらはいずれもマストで満たすべき要件であることが分かりました。 そこで、前提を改めたv2をRuby3.3がリリースされるであろう2023年末を目指してリリースしたいということでした。 TypeProf: https://github.com/ruby/typeprof コメント TypeProfのデモではIDE(VSCode)上でメソッドの型が補完されている様子を確認できました。TypeProfは明示的に型宣言しないRubyにおいて、とても有力なツールになりえるので年末の高速化されたv2系のリリースが楽しみです。 Day3 Gradual typing for Ruby: comparing RBS and RBI/Sorbet rubykaigi.org Rubyの静的型定義のツールとしてRBS/Steep、RBI/Sorbetの2つ存在している。前者はRuby公式であり、後者はStripeやShopifyといったRuby/Railsを使う会社によって開発・利用されている。 このセッションでは両者の型検査の実行速度や表現力の差、RBSをRBIに変換する際の課題などについて紹介していました。 RBI/Sorbet高速である一方でRubyの文法を使うので記法に制約が多い、RBS/Steepは独自の言語なので表現力が豊かである一方で現状Steepによる型検査は遅いのとRuby向けのツールが使えない、というのがざっくりとした比較になります。 コメント スライドの中ではShopify社員が回答したアンケートの結果もあり、実際に業務でSorbetを使っているエンジニアがどう感じているのかを知れたのは良かったです。どちらのエコシステムもメリットとデメリットがあり、これからRubyに静的型を導入していこうと思うとどちらを使うのが良いかというのは悩ましいですね。 Let's write RBS rubykaigi.org Slide: https://speakerdeck.com/pocke/lets-write-rbs RBS3.1後のrbs subtractとrbs parseの機能について、それらがなぜ必要なのか、実際にどう使うのかがデモを通して解説されていました。 特にrbs subtractはrbs prototypeでRubyから生成された不完全なRBSを整形するために必要とのことでした。 コメント 実際のアプリケーションコードでRBSの修正がされていたので非常に見応えのある内容でした。また、RBS Railsというライブラリも開発されていたのでそちらも気になりました。 Parsing RBS rubykaigi.org Slide: https://speakerdeck.com/soutaro/parsing-rbs RBS(≠Ruby)のコードを書く際のエディタ支援のためにRBSのパーサーをどのように改良したかという話。 引数と返り値の型として同じものを予期している時に適切にサジェストできなかったり、クラス定義のendが閉じられていないと途中のメソッドの候補が表示されなかったりと言った問題があったが、パーサーを改良することにより解決した。 コメント エディタ支援のために途中で解析を止めてしまわないパーサーをどう作るかという話を実例を交えて知ることができて面白かったです。 感想 RubyKaigiは初参加でしたが、セッション内容に大いに刺激を受け、新たな視点や知識を得ることができました。Ruby自体の実装の話が多かったものの、普段の業務に活かせそうな話題もあったので、社内でも動いていきたいです。 また、Rubyを作っている方々の思いを直接聞けたり、Twitterなどで繋がっていた方と実際にお話しできたりとコミュニティとの交流もでき、とても有意義な3日間になりました。 ありがとうRubyKaigi 最後に 株式会社スタメンでは一緒にプロダクトを作っていくRubyistを募集しています。RubyKaigiに参加した方もそうでない方も、ぜひカジュアルにお話ししましょう!
はじめまして。2023年4月ごろよりスタメン・TUNAGプロダクト開発にジョインしました、 @trowems23 です。 つい先日Ruby 3.0がリリースされました。ジョイン初日から数えて829日前のことでした。 Rubyは開発生産性が高いのですが、ブレイキングチェンジの多さに対してライフサイクルがとても短く、積極的なアップデートが求められます。 一方で、スタメン主要プロダクトのTUNAGのコアアプリでのRubyバージョンは2.2から始まり、2.3、2.5と推移して、最後のマイナーアップデートは2021年3月の2.7.2でした(その日時点でのRuby最新バージョンは3.0.0, 2.7.2)。 Ruby 2.7はサポートが2023年3月31日に終了したということもあり、Ruby 3.0以上へのアップデートが早急に求められる状況でした。 原因の分析 既に多種多様なRuby on Railsアプリケーションにおいて、筆者個人は複数プロダクトにおいて何度もRuby 2.x→3.0を実施した経験があったので、迅速に作業だけすることもできましたが、少し背景についても考えたいと思います。 当該のRuby on Railsアプリでは多くのgemを使っていることもあり、2021年時点では依存しているgemのアップデートも難しいため、同年(2021年)中のRuby 3.0へのアップデートは難しかったのではないか、と推測されます。 一方で、スタメンは2022年初より スクラムチームへの移行 を組織レベルで実施したため、プラットフォームである足回りの整備をするリソースは限定的になっていました。 当時の組織開発については、2022年2月にCTOの松谷が書いた、以下の記事をご覧ください。 TUNAG(ツナグ)の技術と開発体制のすべてを紹介します! ゴールの設定 Rubyの最新バージョンは当時3.2.1でした。一気にそこを目指す戦術を取ることもできますが、ゴールは「1秒でも早いタイミングからRuby 3.0.xで動いていること」になります。「1秒でも早い」というのを焦りに変えてもしょうがないので、「次期セキュリティ脆弱性対応パッチがリリースされる『Ruby 3.0.7』のリリースがされるまでの間」と置き換えることにしました。 筆者はRubyにあまり興味がないため、Ruby 3.0.7がいつ出るかの感覚は肌感覚でしか持っていなかったので、このプロジェクトが完了するまでの間(実際には13営業日でした)、プロダクト開発部内でのコンセンサスが形成されることはありませんでした。 手順 RubyのバージョンをRuby 2.7系の最新版 (2.7.8) にする Rubyのバージョンを3.0.6に設定し、CIを流してみる 必要なgemのみアップデートする 自前で書いたコードの修正を行う ワーニングを確認する Rubyのバージョンを3.0.6にする RubyのバージョンをRuby 2.7系の最新版 (2.7.8) にする これは簡単ですね。2.7.4を2.7.8にしただけです。その差分のチェンジログは大体覚えています。 Rubyのバージョンを3.0.6に設定し、CIを流してみる Ruby 3.0.6にした状態でCIを流してみたところ、想定通り全く動きませんでした。依存するgemのアップデートを継続的に実施してこなかったため、いくつかのgemをアップデートしないとRSpecなどのテストが開始されない状況でした。 必要なgemのみアップデートする 巷に全てのgemをアップデートするとよいと訴えるRubyプロフェッショナルの方もいらっしゃるようですが、2-3年のブランクを埋めながら、当初のミッションを実現するのは適切ではありません。 手間はかかるのですが、10-15個くらいに留めることで想定外の変更を最小限に抑えることができる、必要なgemのみアップデートを逐一行うことにしました。 自前で書いたコードの修正 1件を除き、全ての修正はkeyword argumentsの修正となりました。当社TUNAGで実装した6コンポーネントにわたり存在していたので、6コンポーネントに対し、1コンポーネント1PRという形で6個のPRにまとめられました。 例外だった1件も URI.escape ( encode ) の修正でしたので、システマチックに実施することができました。が、引数の形式により移行先のメソッドの切り分けをしなくてはならず、オートメーションはできませんでした。ここで、当社 業務改善推進プロジェクト「スタラボ」 の一環で、GPT-4や GitHub Copilot for Business の力を援用しようと思ったのですが、該当コードは5箇所しかなく、力づくで直してしまいました。 こういう姿勢はよくないと感じているので、ちゃんとGitHub Copilot for Businessに指示ができるようになりたいなと思ったので、今年の七夕の短冊🎋にはそのように書いておきます。 ワーニングを確認する gemがアップデートされた状態でstagingでの人間による検証を終えてリリースできるかとおもったのですが、その認識は甘かったようです。 約7年開発されているRails製プロダクトのワーニングを把握するために、マネーフォワードクラウド開発者の方が書かれた「 約8年開発されている Rails 製プロダクトを Ruby 3 にバージョンアップするために keyword parameters is deprecated を「網羅的に」検知する方法 」を参考にしてワーニングを検知するようにしました。 手順としては config/boot.rb に以下を記載することで対処しました。 Warning [ :deprecated ] = true 既に多くのgemがアップデートされた状態であったため、環境による出し分けをすることはなく、開発環境を含め全環境でワーニング出力するようにしました。 リリース 2021年の Ruby on Rails 6.1へのアップデート時に利用したcanary環境 は手軽に利用できる状態になっていませんでした。手間をかけてしまうとプロジェクト完了までの時間が長くなってしまう(=GW9連休を跨いでしまう)ことが予想されたため、リスクを評価し、通常リリースのワークフローを活用することにしました。ユーザー影響を最小化しつつ、通常営業時間帯を選び、実施することにしました。そのため、今回のリリースにおいて、深夜・早朝や土日休日の作業とはなりませんでした。 Ruby 3.0ベースのイメージのリリースは 東京支社 内でリモートで行いました。Slack Huddleを活用して、想定外の障害が発生した場合に備えて切り戻しに備えていましたが、特に大きな問題は起きず数時間が経過したため、祝杯を挙げたのち、オフィスそばの辛い麻婆豆腐をおいしく食すことができました。 東京オフィスそばの麻婆麺 浮き彫りになった問題 このアップデート作業の中で、いくつかの開発ワークフローに関して問題が浮き彫りとなりました。 これまでのCIのパイプラインでは効率が悪く、開発者体験として悪いことが分かりました。 TUNAGではCI基盤にCircleCIを利用しており、変更内容のみを適切にテストできるようにパイプライン・ジョブを最適化したいと思っています。 また、CDにおいては、CDの途中経過が見えづらくどこで詰まっているのか分かりにくい状況がありました。 TUNAGのCD TUNAGではAWS Systems Managerを起点にCodeBuild、CodeDeployを利用して、CDパイプラインを構築していました。CircleCIからCDのトリガーしているため、AWS側のCodeBuild、CodeDeployの状況の見通しが悪くなっています。 加えて、図には書かれていませんが、エンジニアの承認のワークフローがあるため、承認が遅れてしまうと、リリースが滞ってしまうことがありました。 これらは今後のCD領域における課題です。 Day 2オペレーション リリース(CD)したあとの、運用についても改善すべき課題があります。 2016年にプロダクトをリリースしたのち、複数のモニタリングツールを導入してきた経緯もあり、モニタリング・オブザーバビリティにおいて重複・抜け漏れが生まれてしまっていました。 まずはこの状況の整理を行い、TUNAG顧客に適切に価値を提供できる状態を維持しつつ、新機能リリースのスピードアップ・システム運用業務最適化・コスト最適化に繋げていければと思います。 TUNAG開発組織の今後 上に挙げた課題群やこの記事で触れなかった多くの課題を解決するため、スタメンでは開発組織の強化を行なっています。 Site Reliability Engineer Software Engineer, Developer Experience その他の開発系職種 技術コミュニティ支援の一環として、スタメンは5月に開催される RubyKaigi 2023 にGold Sponsorとして参加します。 また、 スタメンnote でGW明けからリレー形式で毎日記事を公開していく予定ですので、そちらもご期待ください。 参考 約8年開発されている Rails 製プロダクトを Ruby 3 にバージョンアップするために keyword parameters is deprecated を「網羅的に」検知する方法 (Money Forward Developers Blog)
スタメンエンジニアの井本です。 漏洩チェッカーのWebアプリケーションをフルスタックに領域問わず開発しています。得意な領域はインフラ含むバックエンドです。 本記事では、先月2022年12月にリリースしたサービス、 漏洩チェッカー について、プロダクトと開発体制の紹介をします。 漏洩チェッカーについて 技術について 技術スタック アーキテクチャ 組織について 組織体制 開発体制 まとめ 漏洩チェッカーについて 漏洩チェッカーは情報漏洩を未然に防ぐセキュリティ管理サービスです。 企業にとってセキュリティ事故の影響は甚大であり、近年、情報の漏洩事故の件数は年々、増加の傾向にあります。 情報資産に関するセキュリティ事故には、外部からのサイバー攻撃のほか、従業員などにより会社の内部で引き起こされる、不正行為や悪意のない不注意などによる情報漏洩があります。 漏洩チェッカーは後者、内部で引き起こされる情報漏洩を予防・検知するためのサービスです。 IT部門が存在しない企業においては、IT資産のリスク管理を始めるにしても、どこから始めれば良いのかが分からなかったり、またIT資産に優先順位をつけて部分的に管理を始めようとしても、世の中のほとんどのセキュリティツールは重厚なオールインワン形式のものしかなかったりと、費用面や工数面においても小さく始めることが難しい状況にあります。 漏洩チェッカーはモジュールと呼ばれる機能単位で構成されたサービスで、フォルダー操作の監視やUSBの脱着の検知などといった機能をユーザーが自由に組み合わせて利用することができます。 モジュールというコンセプトにより各企業の事情に合わせて、機能・コストを最適化したリスク管理を実施できます。 またクラウド型のサービスなので、保守・管理の負担を抑えられることも特徴です。 セキュリティ向上を目的としたツールの多くが、セキュリティやITのリテラシーが高いユーザーを前提としており、リスク管理の障壁となっています。 そこで漏洩チェッカーは、セキュリティやITを得意としていないユーザーでも簡単に利用できる、シンプルさを追求したサービスとして開発されています。 管理画面は、自社のセキュリティ管理状況を直感的に理解できるUIにデザインされています。 また監視対象の端末にインストールするエージェントアプリケーションは、煩雑さのない簡単なセットアップで動作を開始するようになっています。 技術について 技術スタック 漏洩チェッカーの技術スタックは下図の通りです。 漏洩チェッカーは大きく2つのアプリケーションで構成されています。 一つは監視対象のPCにインストールして情報を吸い上げるWindowsデスクトップアプリケーション、もう一つは吸い上げた情報を集約・閲覧するWebアプリケーションです。 ログ収集アプリケーション ログ収集用のWindowsアプリケーションは、.NET 6 による WPF(Windows Presentation Foundation) として実装され、MVVM フレームワークとして Prism を利用しています。 モジュールによって機能を組み合わせられる漏洩チェッカーのWindowsアプリケーションでは、Windows の各種情報にアクセスする必要があるため、新旧様々な API を利用することが特徴です。例えば USBドライブの接続状況については WMI(Windows Management Infrastructure) を利用し、Windows ごみ箱 の容量取得にはWin32 API の SHEmptyRecycleBin を利用しています。取得されたログは Entity Framework によってローカルPCに保存され、ネットワークがオンラインのタイミングでサーバーに送信されます。 また、機能追加が頻繁に行われることを想定し、 MSIX でパッケージングし Microsoft Store で配布することで、アプリケーションの簡易インストールと自動更新 (サイレントアップデート)を実現しています。 ログ集約・閲覧アプリケーション ログの保存・閲覧用のWebアプリケーションの開発には、バックエンドフレームワークとしてNest.js、フロントエンドフレームワークとしてNext.jsを使用しています。SPA構成となっており、フロントエンドとバックエンドの間のインターフェースにはGraphQLを採用しました。 クラウドはAWSをメインとし、一部基盤としてGCPやその他SaaSを利用しています。AWSの開発・管理にはAWS CDK in TypeScriptを使用しています。 漏洩チェッカー開発チームは、ユーザーに最速で価値を届けるために、職能や言語にとらわれない開発体制を目指しています。 そこで、言語の違いによるハードルをなくすため、フロントエンドからインフラまでフルTypeScriptで開発できるようにしました。 アーキテクチャ 漏洩チェッカーのアーキテクチャの概要は以下になります。 アーキテクチャは構成アプリケーションと同様に、大きく2つに分けることができます。 一つは、顧客企業の社員のPCの状態を監視し、操作ログなどの情報を吸い上げ、それを弊社ストレージに蓄積するログ収集アーキテクチャです。 もう一つは、蓄積したログ情報を管理者が閲覧・検索する管理画面アーキテクチャです。 ログ収集アーキテクチャ 顧客企業の社員のPC上では監視エージェントとしてWindowsデスクトップアプリケーションが稼働しており、ユーザーの操作やスケジューラをトリガーとして情報を収集します 収集データは一定量ごとにAWS上に構築されたログ収集エンドポイントに送信されます。 受信したログはAWS Lambda経由で一度Amazon SQSにキューイングされ、さらに後段に位置するログ処理サーバーが、Amazon SQSに対してポーリングすることで順に処理されます。 ログ処理サーバーはAmazon ECS(Fargate)にコンテナとしてデプロイされており、ログ種別ごとの仕分けの他、監視対象ユーザーの不審な行動ログを検知してアラートを発報します。 キューイングにより、ログ受信とログ処理を非同期に実行することで、監視対象のPCの負荷の上昇や処理サーバーの負荷変動を抑制しています。 ログはAmazon S3に蓄積され、監視対象のPCのオンライン状態やアラートなどの情報はAmazon RDSに保存されます。 管理画面アーキテクチャ 主に顧客企業の管理者が利用する管理画面アプリケーションのためのアーキテクチャです。 こちらは先程のログ収集アーキテクチャと比較して、シンプルな構成です。 Webアプリケーション自体は前述の通り、SPA構成となっており、フロントエンドはNext.jsと相性の良いVercelに、バックエンドはAmazon ECS上にデプロイされています。 Amazon S3上のログデータはAWS Glueを用いてテーブル定義されており、WebアプリケーションからはAmazon Athenaを経由することで検索・取得が行われます。 また、漏洩チェッカーはマルチテナント方式のサービスです。 マルチテナントは同じDBやサーバーを複数のユーザー(企業など)が共用する方式を指し、サービステナント同士のデータ分離が必須です。 データベースにPostgreSQL互換のAmazon Auroraを採用し、PostgreSQLの RLS(Row Level Security) を活用することでテナント分離を行っています。 アプリケーションレイヤーではなく、より低レイヤーの機能によってテナント分離を実現することで、テナントIDによる絞り込みのwhere条件を書き忘れる、といった情報の漏洩リスクを低減することができます。 その他 認証機能や決済機能など、プロダクトのコアバリューではないが、事業上クリティカルなシステムについては、SaaSを利用することで、開発・運用コストの低減および品質の向上に努めています。 またCI/CDパイプラインとして、CircleCIにおけるテスト実行後、AWS上でコンテナビルド〜各種ECSタスクの更新といった一連の処理を自動化しています。 組織について 組織体制 次のような体制で開発しています。 管理画面などWebアプリケーション全般の開発 2名 Windowsアプリケーションの開発 1名 デザイナー 1名 Webアプリケーションの開発担当者は、フルスタックに開発することでフロントエンドやバックエンドといった職能に左右されず、事業上の優先度に従って次の開発アイテムを選ぶことが可能です。 またマーケ・営業担当のメンバー1名を含めた事業推進チーム全体としても5名の少数精鋭であり、ビジネスサイドのメンバーと密にコミュニケーションを取りながら事業を進めています。 開発体制 1チームのスクラム体制で開発をしています。 日々の進捗共有の場であるデイリースクラムには、ビジネスサイドのメンバーも参加しており日々、共通のKPIと現在値を確認しています。 売り上げやユーザー数などを共通の目標値として追うことで、同じ危機感を持ち、同じ達成の喜びを共有することができます。 顧客の声を直に受けているメンバーから常にフィードバックを受けることにもなり、「本当に顧客にとって価値に繋がるのか」常に向き合い続けています。 漏洩チェッカー事業に関わることで得られる、少数精鋭チームゆえの貴重な経験の一つになっています。 まとめ 漏洩チェッカーは、管理対象のPCにインストールされた.NET製のWindowsアプリケーションによって、ログ情報を収集しています。 収集したログはAWS上のストレージに集約され、SPA構成のWebアプリケーションで閲覧が可能です。 このWebアプリケーションは、ユーザーへの価値提供のスピード向上のために、フルTypeScript&スクラム体制で開発を進めています。 弊社スタメンでは、今後さらに加速してユーザーに価値提供するために、共に漏洩チェッカーの開発に携わる仲間を募集しています。 漏洩チェッカーの技術スタックや開発体制に興味を持っていただけましたら、ぜひ下記のページから採用エントリーいただけると幸いです。 Webエンジニア(Forkwell) Windowsエンジニア(Forkwell) Windowsエンジニア(Wantedly)
2023年1月11日(水)〜1月13日(金)に開催された、スクラムの国内最大のイベントであるRegional Scrum Gathering Tokyo 2023(以下: RSGT)にスポンサーとして参加してきたのでその報告です。 2023.scrumgatheringtokyo.org 今回はCTO+エンジニア兼スクラムマスター×3人の4人で参加してきました。 会場の雰囲気 会場に入るとスポンサーブースが出展しているエリアがあるのですが、ブースの周りやセッション会場の外など、会場の至るところで話し合いをしているのが印象的でした。 初参加だと入場パスに"First Timer"というシールを付けられるので、そこから「初めてなんですね〜」と会話が広がることも多いですし、セッション中でも「〇〇について周りの人と話し合ってみましょう」という機会があり、初参加で知り合いが少なくても話しやすい環境になっていると思います。 あと、ランチのすき焼き弁当が大人気ですぐになくなります。 スポンサー活動報告 スポンサーセッション枠にて、CTO松谷が『スタメンのLeSSの導入と 事業部全体を巻き込んだ アウトカム文化への道のり』というタイトルで発表をしました。 スタメンとしての取り組みを発信できたほか、LeSSを実践している方とお話できたり、実際にTUNAGを使ってくださっているユーザーの声を聞けたりと良い機会になりました。 RSGT2023 スタメンスポンサーセッション スライドはこちら↓ ブースの出展もしました スタメンスポンサーブース (トートバッグが公式グッズとかぶってしまったのは反省点😣) 参加した感想 河井 OST(Open Space Technology) が強烈に記憶に残っています。 最初に参加者が話したいテーマを皆の前で発表し、興味を持った他の参加者がそれぞれ集まってディスカッションをする、というのを繰り返すセッションでした。 色んなディスカッションをふらふらと渡り歩いてましたが、途中からは「デザイナーがスクラムとどう関わるのか」というテーマのテーブルでじっくりと議論に参加して、参加者の方々と交流できたのが良かったです。 初対面の人も含めて次々に会話が生まれる場を目の当たりにして、自社でもこんな活発な場を作りたいなと思いました。 神尾 スクラムマスター兼エンジニアとしてTUNAGの開発をしている神尾です。 RSGTに参加して一番感じたことは、アジャイルに関わるみなさんがとても暖かいということです。 普段からスクラムマスターとして組織やチームのために活動されている方が多いこともあって、ワークショップに参加した際はスクラムの経験が浅い自分の話も「うんうん!わかる!そういうこと本当にありますよね!」といった感じでとても親身に聞いてくれる方が沢山いらっしゃいました。 また、セッションの時間もとても有意義なものになったので、ここで得た経験を会社に持ち帰って自チームだけでなく他チームにも還元します。 来年のRSGT2024も参加したいと思うので、この記事を読んでくださった方ともお会いできるのを楽しみにしています。 澤田 TUNAGプロダクト部門のモバイルアプリGでスクラムマスターをしているカーキ( @khaki_ngy )です。 今回が初めてのRSGTへの参加になりました。 昨年の10月にいちエンジニアからチームのスクラムマスターとなり、そんな自分が参加して大丈夫なのだろうか?とはじめは不安を感じていました。 ただ今回参加してみたことで、参加者の多くが同じような問題で悩み行動をしていることを知ることができました。 そして、自分自身もスクラムマスターとしての役割を今の組織で発揮していきたい、自分も一歩進んでいこうという勇気をもらいました。 またRGSTはスクラムに関する カンファレンス ではありますが、それ以上に ギャザリング であることを実感しました。 セッションはもちろんですが、ワークショップやイベントの合間や終了後での参加者同士の会話を非常に大切にされており、多くの参加者の方とお話しすることができました。 今回参加して得られた学びを行動に活かして、来年参加する際にはその差分や変化について参加者の方とお話しできるようになりたいと思いました。 まとめ 今回の参加で得られた知見を各自チームに持ち帰り、日々の開発に活かしていきます。 最後に、スタメンでは一緒にプロダクトを作っていく仲間を募集しています。 アジャイルな開発をやっていきたい方、ぜひお話だけでもできたら嬉しいので応募お待ちしております!
こんにちは!株式会社スタメン/TUNAG事業部プロダクト開発部の手嶋/西川/若園です。 弊社のプロダクト部では2022年から 大規模スクラム LeSS (以下: LeSS)を導入しています。 その中で私たち3人は、 チームプロダクトオーナー (以下:チームPOと呼ぶ)という通常ではLeSSに定義されていない役割を担って様々なことにチャレンジしてきました。 今回のブログでは、実際にチームPOとしてどのように活動をしてきたのかを紹介しながら、 チームPOがスクラムにいるメリットや苦労した点 を書いていきたいと思います。 LeSSとは 前提として、LeSSとは何かご紹介します。 (引用元:大規模スクラム Large-Scale Scrum(LeSS) アジャイルとスクラムを大規模に実装する方法) LeSSは、1つのプロダクトを複数チームで協働するために考えられたスクラムです。 LeSSはスクラムの原理・原則、目的、要素、洗練された状態を大規模な状況にできるだけシンプルに適用したものです。 基本的にLeSSで定義されている役割は以下4つです。 プロダクトオーナー(1人) スクラムマスター(複数人) チーム(複数) チーム代表者 上記の通り、LeSSにおけるPOは1人だけで、このPOが各チームに仕事を与えることができる唯一の役割だと定義されています。 (引用元:大規模スクラム Large-Scale Scrum(LeSS) アジャイルとスクラムを大規模に実装する方法) プロダクトオーナーは1人で、顧客にとって素晴らしいプロダクトのビジョンに責任があって、その影響(ROIなど)を最適化します。プロダクトオーナーとして、あなたはプロダクトバックログを継続的に育て、学習および変化に対する適応にもとづいてアイテムを追加、削除、最優先順位付け(並び替え)します。また、上位マネジメント、チーム、顧客に対して、プロダクトバックログが見える状態にしておくことで、透明性を保ちます。あなたはアイテムが明確になるように、チームや顧客と協働します。新しいスプリントに提示するアイテムは、プロダクトオーナーが適応的に決めますが、どのくらい選択するかはチームが決めます。そして、プロダクトオーナーだけがチームに仕事を依頼することができます。 弊社でもスクラム導入直後は、上記4つのロールでスクラムを回していました。 スタメンでのLeSSの紹介 本来のLeSSの体制を踏まえた上で、弊社のLeSSの体制をご紹介します。 弊社のLeSSは、各チームがメンバーの技術領域ではなく職能で横断され、以下のように編成されています。また各チームには、プロダクトオーナーの役割が移譲された「チームPO」が1人配置されています。 冒頭で述べた通り、チームPOはLeSSでは定義されていないため、スクラム体制に移行した直後にはない役割でしたが、今では基本的に各チームに1人(開発者と兼任)配置することを前提として、以下のような組織体制を目指しています。 以降、「チームPO制」導入の背景やスクラムでの実体験をご紹介します。 プロダクト開発部の組織構成(※一部簡略化しています) 「チームPO制」導入の背景 「チームPO制」導入の目的は主に以下の2点でした。 ① POのボトルネック化の防止 ②チームの「意思決定機能」を強め、自己組織化を一層深める 「チームPO制」導入前は、基本的には各チームのスクラム活動において、受入条件や詳細仕様を都度POに確認し、また同席の上でバックログアイテムを作成していましたが、 その結果、チームの意思決定に関してPOへの依存度が高くなり「待ちのチーム」が発生し、POがボトルネックになることが増えてしまいました。 このボトルネックを解消するために、チームの意思決定を率先していく役割として「チームPO制」を導入することを決めました。 元々は1開発者だった中でチームPOと兼任になったため、苦労した点も非常に多かったですが、スクラムにおける様々なイベントでチームが自立的に動ける場面が増えたと感じています。 スクラムの中での動き 弊社のスクラムでは、基本的に以下スケジュールで活動を行っています。 その中で、実際にチームPOとしてどのような動きをしているのかご紹介します。 【1週間スプリントのスケジュール】 月曜 スプリントレビュー(15:00 ~ 16:00) スプリントレトロスペクティブ(16:00 ~ 17:00) スプリントプランニング1(17:00 ~ 17:30) スプリントプランニング2(17:30 ~ 18:30) 火曜 スクラムマスター会議(11:00~12:00) オーバーオールリファインメント(16:00 ~ 17:00) 水曜 (チーム内)リファインメント 木曜 PO会議(17:30 ~ 18:00) スプリントプランニング スプリントプランニングでは主に、自チームのスプリントゴールの決定とチーム間でのアイテムの調整を主導して行っています。 開発部全体のスプリントゴールに関してPOから共有があった後、各チームPOを中心にゴール達成に向けてどのように1週間活動していくのか議論と決定をします。 スプリントゴールに直接関与しないアイテム(弊社ではSRE関連、臨時の顧客対応、ルーティン業務など)もあり、また各チームのベロシティが違うので、自チームの状況と全体の優先順位の両方を考慮して意思決定を行っています。 以前のスプリントプラニングでは、各チーム毎に発言するメンバーや内容に偏りがあったため、タイムボックス内にスプリントゴールを決めることに非常に苦労していましたが、 今では意思決定をリードするチームPOとPOを中心に優先順位にフォーカスした議論することでスムーズに決定できるようになってきました。 オーバーオールリファインメント オーバーオールリファインメントでは、数ヶ月先を見据えた優先順位付けとリファインメントの担当チームの割り振りについて議論しています。 以下のようにリファイメントの担当や方法に関しては、チームが意思決定を行うことが推奨されています。 (引用元:大規模スクラム Large-Scale Scrum(LeSS) アジャイルとスクラムを大規模に実装する方法) オーバーオールPBRでは、より詳細なリファインメントを「複数チームPBR」で行うか、「単一チームPBR」で行うかを決める。これはPOではなくチームに決めてもらうことで、自己組織化が促されPOの作業を減らします。 以前のオーバオールリファイメントは、基本的にPOからビジネス上の優先順位について共有を受けた上で、アサインチームがなんとなく決まっていきチームが意思決定を行うことが少ない状態でした。 「チームPO制」導入後チームの自己組織化が少しずつ進み、POからの共有内容以外にもチームから開発に関する要望(ex.技術的負債の解消など)がボトムアップで出ることが増えました。 今ではそれらを含め全ての優先順位を検討した上で、各チームがリファイメント対象のアイテムに着手できそうなタイミングを共有し、担当する可能性が高いチームがリファインメントを行えるようにチーム間で調整しています。 同時に、POからは開発のWHY(なぜ今必要なのか、マネタイズ、期日)とWHAT(ユースケース、ストーリー、要件)について共有を受け、後のチーム内のリファイメントで活かしています。 リファインメント リファインメントの前に、オーバーオールなどで共有を受けた内容をもとに、チームPOが受入条件を明記してバックログアイテムを作成しています。 見積もり時はチームPOがファシリテーションを行いながらポイントを算出しています。 開発者の疑問点に対しては、オーバーオールリファインメント等で共有を受けた情報を元にチームPOが解消することで、チーム独力でアイテム作成できることが増えました。 まだまだ改善点はありますが、以前と比べるとPOを介した意思決定が少なくなり、チームが主体的に受入条件や詳細仕様に関して意思決定できるようになりました。 また、以前はHOW(どのように実装をするか)に意識が向きがちでしたが、意思決定の回数や責任が増したことで、チーム全体としてプロダクトのWHYやWHATにフォーカスした議論が活発になり、開発への向き合い方も変わり始めています。 チームPOが開発者と兼任なので、技術的に専門性が高いアイテムに関しても意思決定をスムーズに行える場面が増えたこともメリットの1つでした。 PO会議 PO会議は弊社が独自で行っているスクラムのイベントです。 POと各チームPOが参加者で、チームPOが動きやすくするために情報を同期する場として開催し始めました。 具体的には、直近数スプリントの計画やスプリントレビューの設計について話しています。 直近数スプリントの計画に関しては各チームの情報を把握するために始めました。 スプリントプランニングの場で1週間毎の動きはわかりますが、比較的長いプロジェクトにアサインされると、プロジェクトの終了時期が見えづらく、各チームが中期的にどのように連携していくべきなのか把握し辛かったためです。 スプリントレビューの設計に関しては、スプリントの状況を共有しつつ次のスプリントレビューでどのようなデモを行うのか事前にすり合わせを行っています。 デモの時間が限られているので、各チームが使用する時間や優先順位について議論します。 また、社内のどのステークホルダー(営業部やカスタマーサクセス部のメンバー)をデモに招待するのかも決めています。結果として、スプリントレビューで有益なフィードバックを貰える場面が徐々に増えてきました。 最近では、PO主導でチームPOのレトロスペクティブも始めました。 普段のレトロスペクティブは開発者として参加しているため、なかなか振り返りをする機会がありませんでしたが、この振り返りを通して開発部全体としてPOスキルの向上を図っています。 以上がスクラムでの活動の紹介になります。 その他にも比較的大きめのプロジェクトにアサインされている時は、リリーススケジュールマネジメント/やQA項目の作成などを行っています。 チームPOを経験して感じたこと 良かったこととしては、チーム内で意思決定を完結できることが増えチームとして迅速に開発を行えるようになったことや、チームPOだけでなく開発メンバーも自分たちでプロダクトを作る意識が高まったことが挙げられます。 加えてプロダクトの開発方法よりも、その前段にあるWHYやWHATに意識が向くようになり、よりプロダクトの提供価値にこだわりを持つメンバーが増えたと感じています。 実際に直近では、プロダクトのKPIに開発部全体でフォーカスするために、チームPOが主体となり開発のKPIを可視化するダッシュボードを作成するプロジェクトも始めました。 このような開発の成果を可視化する動きは、チームメンバーの関心も高いため、今後のスクラム活動においてより注力していきたいポイントの1つです。 一方で、開発者と兼任なので開発との時間配分を誤るとチームPOの業務に十分な時間が割けず、チームの動きを停滞させてしまうことがありました。 特にバックログアイテムの受入条件やストーリーが明確になっていないと、リファイメントで時間が押してしまうので、バックログアイテム作成の時間を確保する必要がありますが、スプリント中に開発とのバランスを調整することに難しさを感じています。 また、プロジェクトマネジメントのスキルが不足しているという大きな課題があるので、今後はファシリテーションや仕様の意思決定に関してPOからスキルを学んだり、ステークホルダーとの連携を強めるために社内の連携フローを整えていきたいと思っています。 おわりに 実際にチームPOという役割を経験してみて、意思決定の手法や開発とのバランスの取り方に今でも難しさを感じています。 一方でチーム内で意思決定を完結できることが増え、当初の目的であったPOのボトルネックも解消できたことで、チームとして迅速に開発できるというメリットがありました。 今後はよりチームの自己組織化を進めていくために、これまでPOが担っていた役割を巻き取り さらにチームPOの活躍の幅を広げていきたいと思っています。 最後まで読んでいただき、ありがとうございました。 Lessにおけるチームのあり方として、少しでも参考になれば幸いです。  株式会社スタメンは2023年1月に東京に新たに開発拠点を設立することになり、立ち上げメンバーを募集しています!幅広いポジションで募集していますので、ご興味ある方はお気軽にご応募ください! 東京へ開発の拠点拡大!急成長する大規模SaaSプロダクトのエンジニア募集! また スタメン Engineering Handbook として、スタメンの開発体制について体系的にまとめられたページも公開していますので、こちらも是非ご覧ください。
目次 はじめに スケルトンスクリーンとは react-content-loaderによる実装 プリセットを使用する方法 Create React Content Loaderで独自のスケルトンスクリーンを作成し使用する方法 おわりに はじめに はじめまして、株式会社スタメンの神尾です。 普段はスクラムマスター兼エンジニアとして弊社が運営しているエンゲージメントプラットフォーム TUNAG を開発しています。 2022年10月末にTUNAG内のチャットに『タスク管理』機能を追加リリースし、タスク一覧のローディングUIとしてTUNAGで初めてスケルトンスクリーンを導入しました。 そこで今回はスケルトンスクリーンがUXにもたらすメリットや実装方法などを紹介したいと思います。 スケルトンスクリーンとは スケルトンスクリーンとは、下の画像のようにローディング中に表示される骨組みのようなデザインのことです。 例) youtube スピナーやインジケータといったローディングUIと比較して、ユーザーがローディング完了後のコンテンツを予測できるというメリットがあります。 その結果、ユーザーはスピナーやインジケータよりも多くの情報を得ることができるため、結果として体感時間が短くなると言われています。 また、スケルトンスクリーンとローディング完了後のコンテンツのサイズや表示位置を可能な限り近づけることでGoogleがWebサイトの健全性を示す指標として定義しているCore Web Vitalsの一つの CLS の向上にも繋がり、SEOにも効果があります。 react-content-loaderによる実装 今回は react-content-loader を使ってスケルトンスクリーンを実装しました。具体的な実装方法を説明する前にreact-content-loaderを採用した理由を簡単に話したいと思います。 cssで実装することも可能でしたが、下記の理由からreact-content-loaderを採用しました。 本格的にTUNAG全体で導入する前に新機能の一部でコスト低く試したかったため チャットのタスク一覧は固定幅のため Create React Content Loader を使用することで、簡単に レイアウトシフト がないスケルトンスクリーンを実装することができるため(Create React Content Loaderの使用方法については後述します) Create React Content Loader を使用することで簡単にスケルトンスクリーンを実装することが可能になりコストやハードルが低いと考えたため ライブラリが非常に軽量であるため ここからは、具体的な実装方法を見ていきたいと思います。 react-content-loaderは、以下の2種類の方法でスケルトンスクリーンを実装できます。 1. プリセットを使用する方法 デフォルトでFacebookやInstagramで使われているものと同じデザインのものが用意されています。それらをパッケージからimportするだけで簡単にスケルトンスクリーンを表示することができます。 import React from "react" ; import { Facebook } from "react-content-loader" ; export const MyFacebookLoader = () => < Facebook / >; 例 ) Facebookのプリセット この他にもプリセットがいくつか用意されているので、気になる方は こちら をご覧ください。 ただこれらのプリセットをそのまま使っても、ローディング完了後に表示されるコンテンツとは見た目が異なることが多いと思います。 スケルトンの中身がコンテンツとは異なっていてもサイズが同じであればUXやCLSを向上させることができますが、実際のコンテンツと同じにすることでよりUX向上や誤タップを防ぐことに繋がります。 そのため、react-content-loaderは次の方法で独自のスケルトンスクリーンを作成することができます。TUNAGでも次の方法を使用しました。 2. Create React Content Loaderで独自のスケルトンスクリーンを作成し使用する方法 react-content-loaderには Create React Content Loader というツールがあり、画面右側でデザインを作成すると、画面左側のコードに反映されるので、それを使用すると簡単に独自のスケルトンスクリーンを作成することができます。 Create React Content Loader 実際には以下の手順で行いました。 Figmaからスケルトンスクリーンを作成したいコンテンツの画像を保存 Create React Content Loader右側の「ファイルを選択」に1で保存した画像をアップロード 背景に表示されるコンテンツの画像に合わせてスケルトンスクリーンを作成 左側の「Copy to clipboard」でコードをコピーし、Reactのコンポーネントに貼り付け、ローディングUIとして呼び出す。 手順をひとつひとつ見ていきます。 1. Figmaからスケルトンスクリーンを作成したいコンテンツの画像を保存 TUNAGはデザイン作成にFigmaを使用しているので、Figma上で、スケルトンスクリーンを作成したいコンテンツを選択しエクスポートして保存します。 ここで保存する画像はスケルトンスクリーンを作成する際にサイズ(width, height)などを参考とするためスクリーンショットではなく実際に表示されるサイズの画像を保存してください。 2. Create React Content Loader右側の「ファイルを選択」に1で保存した画像をアップロード 3. 背景に表示されるコンテンツの画像に合わせてスケルトンスクリーンを作成 4. 左側の「Copy to clipboard」でコードをコピーし、Reactのコンポーネントに貼り付け、ローディングUIとして呼び出す( SkeltonTaskItem としてコンポーネントを作成) import React from 'react' import ContentLoader from 'react-content-loader' export const SkeletonTaskItem = () => ( < ContentLoader speed = { 2 } // グラデーションのスピード width = { 320 } height = { 98 } viewBox = '0 0 320 98' backgroundColor = '#F2F6F9' // スケルトンの背景色 foregroundColor = '#E1E8EE' // グラデーションの色 > < rect x = "248" y = "25" rx = "0" ry = "0" width = "5" height = "0" / > < rect x = "244" y = "28" rx = "0" ry = "0" width = "3" height = "2" / > < rect x = "215" y = "24" rx = "0" ry = "0" width = "1" height = "0" / > < rect x = "16" y = "16" rx = "0" ry = "0" width = "24" height = "24" / > < rect x = "56" y = "21" rx = "4" ry = "4" width = "210" height = "11" / > < rect x = "56" y = "48" rx = "4" ry = "4" width = "136" height = "8" / > < rect x = "55" y = "73" rx = "4" ry = "4" width = "158" height = "8" / > < rect x = "146" y = "23" rx = "0" ry = "0" width = "0" height = "1" / > < /ContentLoader > ) タスク一覧のローディングUIとして使用したかったので SkeltonTaskItem を複数回呼びだし上下に border を当てて表示しています。 おわりに 今回はチャットの一部にスケルトンスクリーンを導入しましたが、いずれはタイムラインや制度といった他の機能にも導入していくことで、TUNAG全体のユーザー体験の向上に繋げていきたいと思います。 バックエンドやフロントエンドのパフォーマンス改善で実際の待ち時間を短くすることも大事ですが、スケルトンスクリーンのように実際の待ち時間よりも短く感じてもらえるようなユーザー体験の改善も積極的にしていきたいと思います。 最後まで読んでいただき、ありがとうございました。 この記事が少しでも皆様の参考になれば幸いです。 株式会社スタメンは2023年1月に東京に新たに開発拠点を設立することとなり、立ち上げメンバーを募集します!幅広いポジションで募集していますので、ご興味ある方はお気軽にご応募ください! 東京へ開発の拠点拡大!急成長する大規模SaaSプロダクトのエンジニア募集! www.wantedly.com また スタメン Engineering Handbook として、スタメンの開発体制について体系的にまとめられたページも公開していますので、こちらも是非ご覧ください。 engineering-handbook.stmn.co.jp
目次 はじめに インペディメントリストとは 運用方法 インペディメントリストの効果 運用する上で課題となったこと インペディメントリストをもっと活かすために今後やりたいこと さいごに はじめに はじめまして、株式会社スタメン TUNAG事業部 プロダクト開発部 西川、神尾、小松、村中です。 スタメンでは2022年の2月からスクラムを導入しており、私たちはスクラムチームの「チームおでん🍢」として、1週間のスプリントで開発しています。 先日、私たちは、社内で開かれたベスプラ会という業務における改善策を共有するイベントで登壇をしました。発表内容は「チームの一番重要な問題を改善し続ける方法」です。 私たちは4ヶ月ほど前からチームの継続的な課題解決のためにインペディメントリストを導入し運用をしていますが、今ではうまく活用できているものの、導入当初は多くの問題がありました。 そこで今回は導入から現在に至るまでどのように問題を改善してきたか、現在はどのように運用しているか等を紹介していきます。この記事によって、現在スクラムを導入しておりインペディメントリストをうまく活用したいと思っているチームや、具体的な運用事例を知りたい方の参考になれば幸いです。 インペディメントリストとは インペディメントとはプロジェクトのゴールを妨げるものであり、その一覧がインペディメントリストです。 具体的には下記のようなものが含まれます。 ミーティングの参加者が事前に決まっていないので、準備が十分に出来ていない 属人化してしまっている作業があるので、休暇時の対応に困る オフィスが暑い 3つ目の「オフィスが暑い」というのも、チームの課題と一見関係なさそうに見えますが「オフィスが暑い」ことが原因で「集中力が続かない」と考えると十分にインペディメントだと考えられます。 このように明らかにチームの課題と考えられることから一見関係なさそうなことまでインペディメントリストには含まれます。 運用方法 次に私たちが、どのようにインペディメントリストを運用しているかをStepに沿って紹介していきたいと思います。 前提として、私たちは「いつでも誰でも」インペディメントをリストに追加するようにしています。 Step1 レトロスペクティブの前に、インペディメントリストの中から一番重要なインペディメントをチームで話し合って決定します。 Step2 レトロスペクティブの際に、そこで挙がった問題とStep1で決めたインペディメントを比較して1番重要な問題(レトロアイテム)を決定します。 そうすることで、そのスプリントで出た問題だけでなく、過去も含めた全ての期間で一番改善すべき問題をレトロアイテムとして決定することが出来ます。 Step3 レトロスペクティブで改善策を決定し、次のスプリントで改善を行います。 Step4 次のスプリントのレトロスペクティブの際に、本当に改善できたか、改善策は正しかったかなど、再度話すことで確実に1つずつ改善していきます。 インペディメントリストの効果 このようにインペディメントを運用した結果、毎スプリント改善を積み重ねることができるようになり、チームの雰囲気にも良い影響がありました。感覚的なことにはなりますが、以下のような意見がメンバーから出てきました。 インペディメントをポジティブに捉えられるようになった リストに追加すればチームで改善できるという安心感が生まれた 本心で話せるようになった これらはチームが問題に向き合う上でとても重要な考え方だと思います。 毎スプリント、チームの問題に正面から向き合っていると改善が難しいことも出てきます。そのような時に、本心で話すことが根本解決に繋がるということを、経験から学ぶことができました。 運用する上で課題になったこと 現在はチームでインペディメントリストを上手く活用することで継続的に改善を積み重ねていくことができていますが、導入当初はいくつか課題がありました。 今回はその中から下記の2つの課題と具体的に改善した方法を紹介します。 ただリストが並ぶだけ チームの改善よりも開発チケットを優先してしまう 1. ただリストが並ぶだけ これはインペディメントリストが「優先度順」ではなく「記載された順」に並んでいることにより、何が重要なインペディメントなのかがわかりにくいという課題です。 これは「重要度×緊急度のマトリクス」を参考に優先順位を割り振ることで改善しました。 定期的にチームでインペディメントリストを確認する時間をとり、優先順位の「割り振り」と「更新」を行っています。具体的には次のようなことです。 割り振り スプリント中に各チームメンバーが追加した優先順位が割り振られていないインペディメントについて「重要度×緊急度のマトリクス」を使い優先順位を割り振る 更新 過去に優先順位を決めたインペディメントの優先度が変わっていないかを確認し、変わっている場合は再度優先順位を話し合う このようにすることで、レトロスペクティブで一番改善したいアイテムを簡単に選ぶことができるようになっただけでなく、チームのインペディメントについてチームメンバー全員が共通認識を持つことができるようになったため、日々の話し合いなどの活性化にも繋がりました。 2. チームの改善よりも開発チケットを優先してしまう これは期限のある開発チケットをチームの改善よりも優先したくなってしまい、チームの改善が上手く進まないという課題です。 インペディメントリストを運用し始めたばかりの頃は、スプリントの後半に話し合いの時間を設けており、スプリントの進捗が悪いとゴールを優先し話し合いがスキップされることが多くありました。 このような課題は、スプリントの冒頭であらかじめ話し合いの時間を確保しておくことで改善しました。 「チームの改善が何よりも重要」という共通認識をもって、スプリントの初めに話し合う時間を必ず取るようにしています。 具体的には、レトロスペクティブの最後にメンバーのカレンダーを用意し、全員が参加できる時間にスケジュールを確保するようにしています。 その話し合いの中で完全な改善策が見つからない場合もありますが、軽減策やその課題に対する共通認識を持つことが出来るというメリットだけでも毎スプリント欠かさず行うことに大きな意味があると感じています。 インペディメントリストをもっと活かすために今後やりたいこと レトロスペクティブの改善 チームのレトロスペクティブは KPT を使用しています。 これはTUNAGでスクラム開発を導入してから継続的に取り入れている手法ですが、KPT以外にも振り返りの手法は数多くあります。 それらを取り入れるなどしてチームに合ったレトロスペクティブを模索し、インペディメントリストをより上手く活用できるようにしていきたいと思います。 さいごに 最後まで読んでいただきありがとうございます。 インペディメントリストを導入したことで、チームの問題を継続的に解決できるようになっただけでなく、本心で話し合いができるようになり、改めて導入して良かったと感じています。 この記事を読んでインペディメントリストに興味のあるチームや既に導入しているチームの参考になれば幸いです。 そして、株式会社スタメンは2023年1月10日-13日に開催される Regional Scrum Gathering Tokyo 2023 (通称:RSGT)にシルバースポンサーとして参加することが決まりました🎉👏🎈 スポンサーブースの出展やDay2(12日)にスポンサーセッションもありますので是非お越しください👏👏👏 2023.scrumgatheringtokyo.org また、2023年1月に東京に新たに開発拠点を設立することとなり、立ち上げメンバーを募集します! 幅広いポジションで募集していますので、ご興味ある方はお気軽にご応募ください! www.wantedly.com
こんにちは!TUNAG事業部モバイルアプリGのカーキです。 2022年ももう残りわずかになってきましたね。 最近は、社内のポケモンマスターズトーナメントに向けて、ポケモンの育成に勤しんでいます。 スタメン TUNAG事業部のプロダクト部では今年から 大規模スクラム(通称:LeSS) を導入しています。 今回のブログでは、 大規模スクラムを導入した今年、モバイルアプリGがどのような体制で開発してきたのか 、その変遷をご紹介できればと思います。 スタメンでの大規模スクラムの紹介 スクラム中でのモバイルアプリGの動きを紹介するのに、そもそもスタメンでのスクラムがどのように導入されているかについての前提が必要なので、先に紹介します。 大規模スクラムでは複数の機能横断のチーム(以下:フィーチャーチームと呼ぶ)に分かれています。 フィーチャーチームでは、メンバーの技術領域ではなく、機能で横断されたチームとして編成されます。 ただモバイルアプリの開発領域に関しては、技術の特殊性と元々開発していたメンバーが2人しかいなかったこともあり、現状は技術横断なグループとして存在しています。 そのため、Web領域では機能横断なものの、モバイル領域では技術横断のチームとなっています。 スクラムの導入前までは、こちらのように完全技術横断の組織構造になっていました。 開発一部 アプリケーショングループ SREグループ 開発二部 モバイルアプリグループ フロントエンドグループ そして現在はこのような組織構成になっています。 TUNAG事業部のプロダクト組織図 技術領域に依らないチームになるため、モバイルアプリG以外はユニークな名前がついています。 実際にネーミングの場に立ち会ったわけではないので想像になりますが、メンバーが共通して好きな食べ物から来ているのだと思います(?) 元々、スタメンのモバイルアプリGはモバイルアプリのみを専属で開発しており、開発の途中で発生するAPIの実装に関してはWebのチームが開発していました。 そのため新しい機能の開発がモバイルで発生した場合は、基本的にはAPIが必要になるため、Webのチームと並走して実装する必要がありました。 本格導入前 モバイルアプリのチームへのスクラムの導入は事例が少なく、社内でも手探りの状態から始まりました。 またWebのフィーチャーチームの立ち上げを確実に行いたいという考えから、フィーチャーチームへの移行が優先的に進められました。 その間、モバイルアプリGはある種 外注的 に動いてプロジェクトに関わっていくことになりました。 もちろんモバイルアプリ開発の専任チームとしてモバイルアプリの体験に関しての提案は行っていましたが、一つのプロジェクトの仕様面に関しては担当しているチームが決めていました。 与えられた仕様を実装するだけなので、技術的な挑戦にはトライしやすい環境ではありました。 ただそのような関係では、プロジェクトに入って一緒に進めているという実感が薄く、メンバーのモチベーションの低下につながっていました。 スクラムの本格導入 ただモバイルアプリエンジニアからはその旨みを享受できていないこともあり、モバイルアプリGにもスクラムを導入していった方が良いのではという声は徐々に大きくなっていきました。 そこで今年の夏頃から徐々にチームにスクラムのプラクティスを適応していき、アジャイルな開発体制を目指していきました。 スクラムイベント 8月ごろからモバイルアプリGを一つのスクラムのチームとして扱ってスクラムイベントに参加するようになりました。 この時開発していたチャットのタスク機能はWeb、モバイル両方に新たに追加される機能になり、スクラムを本格導入するには絶好のタイミングでした。 モバイルアプリGの特色として、iOS/Androidの両OSを開発する必要があったり、Webのチームとの連携が必須なため、どのようにスクラムイベントを導入するのかは手探りの状態から、少しずつ改良を加えていきました。 プロジェクト内でスクラムイベントをどのように行ってきたのかを紹介します。 スプリントレビュー スプリントレビューではモバイルアプリGの成果物としてデモとレビューを行なっています。 今まではモバイルアプリGのレビューの時間はなく、開発した機能に関しては一緒に連携して動いていたフィーチャーチームの成果物の延長としてデモを実施していました。 モバイルアプリGも一つのチームとして、独立したことでなぜデモを行うのかや、どのような観点でレビューを行うのかなど、より高い次元でスプリントレビューを行えるようになってきました。 スプリントレビューではプロダクト部全体でデモアプリの配信に利用している、App Distribution 経由で行っており、実際に自身の端末に入れて体験してもらうことを重視しています。 スプリントプランニング スプリントプランニングはフィーチャーチーム全体で実施しているスプリントイベントで他のチームと一緒に行うようにしました。 今まではモバイルアプリGのみ、プロダクト全体とは別のプロダクトバックログで管理を行い、別日にスプリントプランニングを行なっていました。 そのため他のチームからモバイルアプリGがこのスプリントで何を行なっているのかが見えづらい状況でした。 以前まで使用していたモバイルのプロダクトバックログ(Githubのプロジェクトボードで管理) この時のプロジェクトではお互いのチームが何をしているのかに応じて取れるアイテムが変わる可能性があり、全体のスプリントプランニングの場でモバイルアプリGのスプリントプランニングを実施しました。 リファインメント チャットタスク機能全体のリファインメントはWebのメンバーと合同で実施をしました。 リファインメントはアイテムを 分割 し・それを 見積もる 工程になりますが、見積もりの中でそのアイテムの 仕様について議論 する必要があります。また大きなプロジェクトのリファインメントには PO や PdM が同席していることが多く、プロジェクトに関わるメンバーとして参加したことで、プロジェクトの背景やモバイルアプリでの見せ方などの議論も早い段階から行うことができました。 またリファインメントでのアイテム分割もWebのチームと一緒に行い、同名のアイテムとしてWebとモバイルでそれぞれを作成しました。これにより、Webのチームと同じストーリーポイントでアイテムを切ることができ、お互いの進捗を把握しやすくなるという利点がありました。 Webとモバイルとで同じストーリーでアイテムを作成する ただその後の工数見積もりに関してはWebのチームとは分かれて実施をしました。 同じストーリーで作られたアイテムであっても実装する手段がWebとモバイルで異なっているため、見積もるポイントも当然異なるためです。 これらのように合同でリファインメントを実施したことで、一緒に仕様を固めることができ、モバイルアプリGからプロジェクトに参加している実感や開発へのモチベーションも高く行うことができました。 また当時はプロジェクトの中でiOS/Androidで同一の機能を提供するために、モバイルアプリに関するアイテムは iOS/Androidで分けない 方針を取っていました。 この時はプロジェクトで開発をしていたのがiOS/Androidで2名しかおらず、実装できるOSにも隔たりがあったためです(自分は両OS実装できるが、片方はiOSしか実装できない)。 あえて同じアイテムで分けたことで、自分がAndroidの実装を先に終えたら相方のiOSの実装にサポートできるという体制に必然的になり、チームでゴールに向かっている実感がありました。 デイリースクラム チームでのデイリースクラムは朝会として以前から実施しましたが、いくつか改良も加えてました。 朝会では以下の内容をチームメンバーで共有しています。 - 一人ずつ - 昨日やったこと - 今日やること - 困っていることや共有したいこと - 全体で - レトロアイテムの振り返り - 今日のスケジュールの確認 また以前までモバイルアプのプロダクトバックログとして管理していたGithubのプロジェクトボードをスプリントバックログとしてデイリースクラムに利用しています。 GithubのPRの状態と連携させることができるので、現在の作業の進捗状態が分かりスプリントバックログとしては優秀だと感じています。 他には、デイリースクラムの司会を順番に回すなどの工夫を行なっています。 司会が固定されてしまうと、慣れてくると受け身の姿勢で参加してしまうので、チーム全体で主体的に参加できるようにそうしています。 またそれに加えてプロジェクト全体でのデイリースクラムを実施しました。 プロジェクト全体で毎朝進捗の同期が取ることができるため、モバイルアプリGとしても動きやすくなりました。 特にモバイルアプリGではAPIの開発をWebのチームに依存しているので、その辺りの開発の優先度を上げてもらうなど、相互のコミュニケーションを取ることができるようになりました。 また開発をしていて感じた細かい仕様の相談などを早期に共有し、解決できるようになりました。 今後の展望 今後は、今のモバイルアプリGのフィーチャーチーム化を進めていくことを予定しています。 モバイルアプリ開発が主軸のフィーチャーチームでは、現状のチームにWebのエンジニアが数人参画することをイメージをしています。 このようなチーム構成にすることでAPIの改修や追加が必要なタスクであってもチーム単独でWebも含めたインクリメントを生み出すことができるようになります。 最終的にはスクラムの効果を最大限発揮した上でモバイル開発をより加速させることができると考えています。 また来年1月からTUNAG事業部は事業をより加速させていくために、 東京での新規開発拠点の設立 を予定しています。 東京支社の設立により、TUNAGのモバイルアプリエンジニア増え、モバイルアプリを主軸としたチームも近いうちに複数になってくることも想定しています。 そのような状況で、地理的な条件を超えて、モバイルアプリ開発を主軸としたチーム同士がどう自立して・連携をとっていくのかに関しては、まだまだ分からないことだらけです。 ただ一つわかっているのは、スタメンのモバイルアプリ開発はこれから さらに加速して面白くなっていく ということです。 フィーチャーチーム化やチームの増加は新たな課題にはなりますが、アジャイルな組織はそれを乗り越えて、 進化 することができると確信しています。 株式会社スタメンでは、これから面白くなるモバイルアプリ開発組織を一緒に作り上げていくメンバーを募集しています。 詳細は以下のページをご覧ください 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア TUNAGの開発体制、使用技術については下記ブログ記事を参照ください。 TUNAGの技術と開発体制のすべてを紹介します! また弊社では、オンラインサロン用プラットホーム FANTS というサービスも開発・運営しております。 ご興味がある方はぜひ下記のリンクをご覧ください。 FANTSの開発技術・開発組織を紹介します! また スタメン Engineering Handbook として体系的にまとめられたページも公開しています。 こちらも是非ご覧ください。
こんにちは! スタメン TUNAG 事業部 モバイルアプリグループのカーキです。 最近では主として Android アプリの開発に携わっています。 株式会社スタメンでは7月の初めに『TUNAG 受付』という、TUNAGのチャット機能を利用したオフィスの受付アプリをリリースしました(Androidのみ対応) ( ストアリンク ) 『TUNAG 受付』はUIの部分を全て Jetpack Compose で記述しており、アプリ全体の構成もJetpack Composeに合わせたものになっています。 (過去のJetpack Compose導入の経緯などは こちらのブログ をご参照ください) 今回は『TUNAG 受付』のアーキテクチャ及び、その構成について紹介します。 TUNAG 受付について まず初めに『TUNAG 受付』がどのようなサービスかについて紹介します。 「TUNAG 受付」では、主に以下の2つの機能を提供しています。 オフィスに訪れた来訪者が担当の社員を呼び出す 社員はTUNAGチャットでその呼び出しを確認できる TUNAG受付とTUNAGのアプリケーション間の関係 「TUNAG 受付」のアプリとしてはTUNAGのチャットの仕組みを使って、「オフィスの来訪者が担当の社員を呼び出せる」機能を提供しています。 受付アプリを自社で開発をしたきっかけとしては、 今年4月のオフィス移転 があります。 新オフィスでは執務スペースが2階にあり、出入り口のある1階に来訪の方が来られた時の対応を検討していました。 他社のサービスも検討したのですが、TUNAGのユーザー情報とチャットの通知機能を使うことで、スタメンらしさを出すことができるのでは?という話があり、自社で受付アプリの開発を行うことになりました。 アーキテクチャ TUNAG受付は、Androidタブレット端末向けに既にリリースされています。 開発時にはちょうど Jetck Compose を用いた開発が社内でも盛り上がっており、UIの部分に関しては全て Jetpack Compose で開発を行いました。 また今後iOSアプリへの拡張性を考慮し、データ及びドメイン層には Kotlin Multiplatform Mobile (通称:KMM)を採用しています。 今回は JetpackCompose をどのように活用しているかにフォーカスを絞って紹介しますので、KMMに関しては詳しくは触れません。 全体的なアーキテクチャとしてはMVVMを採用しています。 TUNAG受付全体の構成はこのようになっています。 アプリ全体のアーキテクチャ 社内では既に新規のコンポーネントの作成には Jetpack Compose を使っていましたが、Activityをほとんど用いないフル Jetpack Compose の開発は初めてだったので、Google が公開している Jetpack Compose の公式サンプルアプリ のアーキテクチャと Android アプリアーキテクチャガイド を参考にしています。 UI層 Googleのアプリアーキテクチャガイドでは、UIレイヤは「Viewを構成する UI elements 」と「Viewの状態を管理する State holders 」によって構成されています。 Google のアーキテクチャガイドにある UI 層の構成 一般的に UI elements では、View や Compose が、 State holders は ViewModel が該当します。 UI State の不変性 この View の状態管理の方法について、アプリアーキテクチャでは UI State という概念が登場しています。 UI State は UI の表示を一意に決定することのできる状態を持っています。 UI State の使用が推奨されている大きな理由は、その 不変性 にあります。UIレイヤーにおける不変性とは、データの状態により UI が一意に決まることを指します。 ここで UI elements は直接 UI の状態を書き換えることはできず、必ず State holders から受け取った状態のみを UI へ反映します。このようにすることで State holders は確実に UI の状態を把握することができ、UI elements は受け取った状態のみを UI に反映するだけで良いので、関心の分離を実現することができます。 受付アプリのUserListScreenでのUI State は以下のように記述されています。 /** * UserList - UIState */ sealed interface UserListUiState { val isLoading: Boolean val errorMessage: String ? val currentRoom: RoomDetailDto? /** * ルームにユーザーが存在しない */ data class EmptyUser( override val isLoading: Boolean , override val errorMessage: String ?, override val currentRoom: RoomDetailDto?, ) : UserListUiState /** * ユーザーが存在する場合 */ data class HasUsers( override val isLoading: Boolean , override val errorMessage: String ?, override val currentRoom: RoomDetailDto, val userOnCall: UserStateHolder, val isOpenCallDialog: Boolean , ) : UserListUiState } 受付アプリのUserListScreenでは以下の2つの状態があり、 sealed interface で定義することにより、状態を完全に切り分けています。 - EmptyUser - ルームにユーザーが存在しない場合は「ルームにメンバーが存在しません。」のように空の状態を表示 - HasUsers - ルームにユーザーが存在する場合は、メンバー一覧を表示 UI elements における UI State の扱い UI State は sealed class にて状態がただ一つに決定されるため、 UI State は状態に合わせて適切な UI を構成することができます。 UserListScreen のような UI elements を担うコンポーザブル関数では以下のように状態に合わせて UI の状態を変えて表示しています。 @Composable fun UserListScreen( uiState: UserListUiState, ... ) { when (uiState) { UserListUiState.EmptyUser -> EmptyUserList() UserListUiState.HasUsers -> UserList() } } ViewModel内におけるUI Stateの扱い ViewModel内では表示するデータ全般を扱う別のデータクラスの操作を行い、UI Stateの操作はViewModelでは直接は行いません。 ViewModelStateがViewModelでのデータ全般を扱い、ViewModelStateの変更がそのままUI Stateに通知されるようにしています。 このような ViewModelState を使用した ViewModel 内での状態管理は Google が公開している Jetpack Compose のサンプルアプリである JetNews の実装を参考にしています。 ViewModel内における状態管理を全てViewModelStateが担うメリットとしては、 UIState の状態を個別の値の変化によっていちいち変更する必要がないことが挙げられます。 これはアーキテクチャガイドにも 考慮事項 として記述されています。 private data class UserListViewModelState( val currentRoom: RoomStateHolder = RoomStateHolder(state = State.Initial), val userOnCall: UserStateHolder = UserStateHolder(state = State.Initial), val errorMessage: String ? = null , val isOpenCallDialog: Boolean = false , ) { /** * UiStateへの変換 */ fun toUiState(): UserListUiState { return when (currentRoom.state) { is State.Data -> { if (currentRoom.state.data?.users?.isNotEmpty() == true ) { UserListUiState.HasUsers( isLoading = false , errorMessage = errorMessage, currentRoom = currentRoom.state.data !! , userOnCall = userOnCall, isOpenCallDialog = isOpenCallDialog, ) } else { // メンバーが存在しない場合 UserListUiState.EmptyUser( isLoading = false , errorMessage = "呼び出し先がありません。設定してください。" , currentRoom = currentRoom.state.data, ) } } else -> { // 読み込み中・エラーなどでメンバーが表示されていない場合 UserListUiState.EmptyUser( isLoading = currentRoom.state is State.Loading, errorMessage = errorMessage, currentRoom = null , ) } } } } この UserListViewModelState はViewModelの内部での全ての状態と、UI elements へ公開される UI State (ここでは UserListUiState )へマップするメソッドを持っています。 ViewModelの内部では下記のように MutableStateFlow でラップされています。 MutableStateFlowとして扱えることで、StateFlowの update メソッドから、状態の更新を行うことができます。 class UserListViewModel() { private val viewModelState = MutableStateFlow(UserListViewModelState()) fun hoge() { viewModelState.update { it.copy(isOpenCallDialog = true ) } } } またViewModelから外部に公開される UI State はStateFlowの持つメソッドにより、 viewModelState に更新がかかるたびに順次更新されるように実装を行なっています。 /** * 外部に公開されるUiState */ val uiState = viewModelState .map { it.toUiState() } .stateIn( viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState() ) 画面遷移 画面遷移に関しては、 NavHost を利用しています。 こちらに関してはDvelopersのドキュメント通りの実装になるので、軽く紹介します。 NavHost ではコンポーザブルの目的地を指定することで対象となるコンポーザブルを呼び出すことができます 以下は公式ドキュメントのサンプルコードになります。 NavHost(navController = navController, startDestination = "profile" ) { composable( "profile" ) { Profile( /*...*/ ) } composable( "friendslist" ) { FriendsList( /*...*/ ) } /*...*/ } 実際には、画面間の値の受け渡しなどが発生するので以下のように実装を行なっています NavHost( navController = navController, startDestination = startDestination, modifier = modifier, ) { composable(ReceptionDestination.GroupList.route) { val viewModel: GroupListViewModel = viewModel( factory = GroupListViewModel.provideFactory(container) ) GroupListRoute( groupListViewModel = viewModel, navigateToUserList = { navigationActions.navigationToUserList(it) }, ) } composable( ReceptionDestination.UserList.route, arguments = listOf(navArgument( "roomId" ) { type = NavType.LongType }) ) { val viewModel: UserListViewModel = viewModel( factory = UserListViewModel.provideFactory( container, it.arguments?.getLong( "roomId" ) ?: 0L ) ) UserListRoute( userListViewModel = viewModel, back = navigationActions.back, ) } } 遷移先のコンポーザブルが呼び出されるタイミングで ViewModel の注入を行なっています。 まとめ 「TUNAG受付」がどのようにフルJetpack Compose の構成で実装を行なっているのかを紹介しました。 現状「TUNAG受付」は小さなアプリケーションとなるので、複雑さは少なく、Google が提供している公式のツール、方法でやりくりすることができています。 スモールスタートで開発を進める上で、JetpackCompose や Google のアーキテクチャガイドは良い指針となりました。 弊社では、JetpackCompose に興味のある Android エンジニアを募集しています。 気になる方、興味のある方は、ぜひ弊社採用窓口までご連絡ください。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア TUNAGの開発体制、使用技術については下記ブログ記事を参照ください。 TUNAGの技術と開発体制のすべてを紹介します! また弊社では、オンラインサロン用プラットフォーム FANTS というサービスも開発・運営しております。 ご興味がある方はぜひ下記のリンクをご覧ください。 FANTSの開発技術・開発組織を紹介します! また スタメン Engineering Handbook として体系的にまとめられたページも公開しています。 こちらも是非ご覧ください。
目次 はじめに 共通UIコンポーネント とは 共通UIコンポーネント を作り、運用することのメリット デザインの一貫性やクオリティが保たれる 開発コスト&デザインコストを削減できる 実装時に考慮すると良いこと スタイルを適切なpropsで操作可能か コンポーネントのトップの要素にmarginをつけない 親や子の要素をお互いが知っている前提の実装をしない おわりに はじめに はじめまして、株式会社スタメンの神尾です。 普段はフロントエンドにReact、バックエンドにRuby on Railsを用いて、弊社が運営しているエンゲージメントプラットフォーム TUNAG の開発をしています。 TUNAGには多種多様な機能 がありますが、普段のReactを用いたフロントエンド開発において、それら特定の機能に紐づかず共通で使用できるコンポーネントを実装したい時が多々あります。 今回は、そのようなReactコンポーネントを 共通UIコンポーネント と定義し、 共通UIコンポーネント の説明、メリット、実装時に考慮すると良いことを紹介していきたいと思います。 共通UIコンポーネントとは 「 共通UIコンポーネント 」とは、ドメインの知識を持たないコンポーネントのことで、具体的には Button, Modal, Pulldown, Tab, Checkbox 等があります。 これらを提供するライブラリとして有名なものは、以下のようなライブラリがあります。 MUI Ant Design Chakra UI 基本的には、これらのライブラリが提供しているReactコンポーネントが、この記事で言う 共通UIコンポーネント です。 例 ) 共通UIコンポーネント として汎用的なボタンを作成し、様々な機能で使用できるようにしている(投稿、制度、組織、チャットはTUNAGの機能名です) 共通UIコンポーネントを作り、運用することのメリット デザインの一貫性やクオリティを保つことができる 開発コストとデザインコストを削減できる デザインの一貫性やクオリティを保つことができる 共通UIコンポーネント があることで、特定の機能のコンポーネント内で当てるスタイルを減らすことが出来ます。 そうすることで、デザインの軽微な差異を無くすことが出来るので、一貫性やクオリティを保つことができます。 次に、 共通UIコンポーネント がない場合を考えることでメリットが伝わりやすいと思うので、具体例を挙げて考えてみます。 共通UIコンポーネントがない場合 共通UIコンポーネント としてボタンが無い場合、各機能のコンポーネント内でボタンを実装することになります。 上の画像で言うと「投稿」のコンポーネントの中でボタンを実装し、また別の「制度」でもボタンを実装する等、機能毎にボタンを実装することになります。 そのように色々な機能でUIコンポーネントが実装されると次のようなことが起こります。 ボタンにはborder-radiusが当てられることが多くあると思いますが、各機能ごとにボタンを実装する場合border-radiusもそれぞれのボタンで実装されます。 それらの実装は、それぞれ時期も実装者もデザイナーも異なる可能性が高いので、その都度デザイナーがスタイルを決め、実装することになります。 そのため、この機能の時のボタンのborder-radiusは4px、また別のボタンのborder-radiusは6pxなど、微妙な違いが出てしまう可能性があります。 この問題は、デザインルールがあり、かつ浸透していれば、避けられる問題ではありますが、エンジニアが実装する際に誤ったスタイルを当ててしてしまうことや、毎回実装やデザインの確認をするコストを考えると、同じ機能を持ったボタンを複数回実装することは避けた方が良いと考えられます。 さらに、色々な場所で同じボタンが実装されていると共通でスタイルを変更したくなった時にとても厄介なことになります。例えば「ボタンのbackground-colorを変更したい」となった時、各機能のコンポーネント内でボタンを実装している場合、どのようにbackground-colorを変更すれば良いでしょうか。 それは「ひとつひとつ変更していく」しか方法はありません。 「ボタンは投稿で使ってたから変更して、制度でも使ってたから、これも変更して、、」という風にひとつひとつ変更をしていたら「組織」でボタンを使っていたことを失念する可能性は十分あります。 そのようなことが続いていくと「デザインの一貫性」は崩れていきます。 例 ) 各機能で実装していると変更漏れなどでデザインの一貫性は崩れてしまう そこで 共通UIコンポーネント で、これらの問題を解決します。 どの機能でも共通で使用できるボタンがあることで、ボタンのbackground-colorを変更したくなった時は、 共通UIコンポーネント のbackground-colorだけを変更すれば、投稿、制度、組織、チャットの全てのボタンも一緒に変更されるため、変更漏れが起きることはありません。 このように 共通UIコンポーネント を上手く使うことで「デザインの一貫性」を高めることに繋がります。 例 ) 共通UIコンポーネントとしてのボタンがあれば、変更漏れを無くすことができる。 開発コストとデザインコストを削減できる 共通UIコンポーネント があることによって、開発コストやデザインコストが削減できます。 理由は以下のようなものです。どれも大きなメリットだと思います。 CSSを書く量が減る。 共通UIコンポーネント に既にスタイルが当たっているため デザインレビューする箇所が減る。 共通UIコンポーネント はスタイルも共通のため 「ここのpaddingどうしますか」のような細かいスタイルのコミュニケーションが減る。 改めて実装する必要がないので確認する必要がないため 使いまわせるコンポーネントがあるので、一からデザインを作成する必要がない。 同じ機能を持った似たようなデザインを考える必要がないため 以上が自分が感じている 共通UIコンポーネント のメリットになります。 ここからは、実装について見ていきたいと思います。 実装時に考慮すると良いこと ここからは 共通UIコンポーネント を実装する時に考慮した方が良いことについて書いていきます。 共通UIコンポーネント は実装時に考慮するべきことが多いので、気づいたら汎用的でないコンポーネントになってしまうことが多くあると感じています。 言われれば「そりゃそうだ」と思うこともあると思いますが、今回は3点紹介させていただきます。 スタイルを適切なpropsで操作可能か 共通UIコンポーネント と言っても「どこで何のために表示するか」が異なれば、当てるスタイル全てが共通になるわけではありません。 そのような時に備えて、柔軟にスタイルを変更できるようにしておくことは、より良い 共通UIコンポーネント の実装には重要なことです。 もちろん、そのような違いが多すぎると共通のスタイルが少なくなってしまい、 共通UIコンポーネント の意味も薄れ、上記のメリットで上げた「デザインの一貫性」が減ってしまうので気をつけなければならないと思います。 その点に関しては、デザイナーとエンジニアで「どこまでスタイルの違いを許容するか」を話し合って決める必要があると思いますが、一旦ここでは「使う場所によって、ここのスタイルは変えたい」となった場合を考えます。 例えば、以下の画像のようなタブコンポーネントを実装したとします。タブ全体には灰色のborder-bottomがあり、選択されているタブには水色のborder-bottomがあるデザインとなっています。 このコンポーネントを作成した時点では、このスタイルでしたが、別の機能で使用する際にデザインの観点からborder-bottomを表示しないようになったとします。 このような時は isDisplayBorderBottom といったボーダーを表示するかどうかのフラグのpropsで表示・非表示を分けられるように実装しておき、呼び出し側から、このフラグを操作できるようにしておけば、より汎用的な 共通UIコンポーネント を作ることができます。 この時に is機能名 のようなフラグにしてしまうと、また別の機能で使用する時に、 is機能名2 のようなフラグを増やさなければいけないので、避けた方が良いです。 また、例として挙げた isDisplayBorderBottom でも「ボーダーの表示・非表示」の対応はできますが、ボーダーの色を変更したいとなった時に対応できません。 そのような変更の可能性に備えて、以下のようなpropsにすると、さらに汎用的なコンポーネントになります。 borderBottom: 'mainColor' | 'subColor' | 'none' このように、propsひとつで対応できる幅が大きく変わるので、適切なpropsは何かということを意識することが大事になります。 コンポーネントのトップの要素にmarginをつけない コンポーネントのトップの要素にmarginをつけると、使い回しづらいコンポーネントになります。なぜ使い回しづらくなるのかを具体例で考えます。 下記の①では入力フォームとボタンの間にmarginが16px必要だったのでボタンコンポーネントにmargin-leftを16pxつけることにしました。 今回は入力フォームの右にflexboxを使って配置したらデザインが実装できました。 次に②のデザインを実装することになりました。プルダウンとボタンの間は8pxです。 ボタンは同じなので、①で作ったボタンコンポーネントを使い回そうとしましたが、margin-left: 16pxが付いているのでプルダウンとボタンの間が8pxに出来ません。 どうしよう、、、 => このようにコンポーネント自身にmarginが付いていると、他で使う時に邪魔になってしまうことがあります。そもそもmarginはボタンの要素ではなくボタンと他のコンポーネントの間にある空間なのでボタンコンポーネントが持つべきスタイルではありません。 marginはボタンを呼び出しているところで当てるようにすると、使い回しやすいコンポーネントになります。 // もろもろ省略 return ( < Wrapper > < Input / > < PrimaryButtonContainer > < PrimaryButton onClick = { handleOnClick } / > < /PrimaryButtonContainer > < /Wrapper > ) // styled-components const Wrapper = styled.div ` display: flex; ` const PrimaryButtonContainer = styled.div ` margin-left: 16px; ` 親や子の要素をお互いが知っている前提の実装をしないこと コンポーネント自身が「どのようなレイアウトで表示されるか」「親要素が何か」などを知っている前提の実装になっていると使い回しづらいコンポーネントになります。 以下は、UserCardList(親)とUserCard(子)がお互いの要素を知っている例です。 「親や子の要素をお互いが知っている」とはどういうことなのかを説明します。 UserCardListはUlなので、その子はLiでなければなりませんが、以下のUserCardListコンポーネント内だけでは子要素のUserCardがLiで作られたコンポーネントかどうかを判別できません。 今回の例ではUserCardはLiなので問題はありませんが、その「問題ない」かどうかを判別するには、親(UserCardList)が子(UserCard)がLiで作られたコンポーネントであると知っている必要があります。 反対に、Liの親はUlでなければならないのでUserCardもUlの子要素として呼ばれることを知っているとも言えます。 これが「親や子の要素をお互いが知っている」ということです。 const UserCardList = ( { users } : Props ) => { return ( < ul > { users.map (( user ) => ( < UserCard user = { user } / > )) } < /ul > ) } const UserCard = ( { user } : Props ) => { return ( < li key = { user.id } > < p > { user.id } < /p > < p > { user.name } < /p > < /li > ) } このような実装になっていると以下のような理由で使い回しづらいコンポーネントになります。 呼び出し側も呼び出される側も、そのコンポーネントがどのように作られているかを気にする必要があること。 呼び出される親が条件に合っていなければ使いまわせないこと。 上の例のUserCardの場合、Ul以外の親から呼び出したい時に使えません。 これらの問題は以下のように実装すると解決します。 const UserCardList = ( { users } : Props ) => { return ( < ul > { users.map (( user ) => ( < li key = { user.id } > < UserCard user = { user } / > < /li > )) } < /ul > ) } const UserCard = ( { user } : Props ) => { return ( < div > < p > { user.id } < /p > < p > { user.name } < /p > < /div > ) } このように実装すれば、UserCardListは子要素をLiタグでくくっているので、UserCardがどんな要素でも問題なく、UserCardも親がUlでもdivでも問題ありません。 このように「親や子の要素をお互いが知っている」という状況を減らすことができれば、より汎用的なコンポーネントとなります。 おわりに 今回は 共通UIコンポーネント について書かせていただきました。 この内容は 共通UIコンポーネント の実装以外にも活かせる考えだと思います。 コンポーネントが、どこでどうやって使われるのか、今後どのように拡張する可能性があるのか、今の実装だと今後こういう時に困るのではないか?など、実装時点では分からないことは多いですが、デザイナーとコミュニケーションを多く取るなど、出来るだけ多くの情報を集め、対応できる幅が増えるように実装することを自分も意識し続けていきたいと思います。 エンジニアとして、やりがいのある仕事がしたい!わくわくする仕事をしたい!という方がいらっしゃいましたらぜひ、弊社採用窓口までご連絡ください。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア TUNAGの開発体制、使用技術については下記ブログ記事を参照ください。 TUNAGの技術と開発体制のすべてを紹介します! 株式会社スタメンでは、オンラインサロン用プラットホームFANTSというサービスも開発・運営しております。 FANTSの開発技術・開発組織を紹介します! また スタメン Engineering Handbook として体系的にまとめられたページも公開していますので、こちらも是非ご覧ください。
目次 はじめに 使用量監視の課題 LambdaとCloudWatchを用いた監視方法 名前空間別にSPICE使用量を集計してCloudWatchにメトリクスを送信するLambda関数 CloudWatchアラート、ダッシュボードの作成 まとめ はじめに こんにちは、スタメンの田中、若園です。 こちらの記事 でカスタムダッシュボード機能の全体像を紹介しました。🎉 この記事では、続編その2としてカスタムダッシュボード機能におけるLambdaとCloudWatchを活用したマルチテナント別のSPICEデータ使用量の監視方法と実装について紹介していきます。 使用量監視の課題 名前空間を用いたQuickSightのマルチテナント環境を運用するに当たり、SPICEの使用量に関して以下の課題がありました。 ①名前空間別にSPICEの使用量を把握したい。 1つのAWSアカウントのQuickSightでは、SPICEは全名前空間で共通して使用されるため、QuickSightコンソールや既存のAPIでは、名前空間別のSPICE使用量を取得することができません。 ②意図せず大容量のデータセットが作成され、特定の名前空間でSPICE容量を大幅に専有された際に気づくことができるようにしたい。 そこで、上記の課題が解決でき、SPICE使用量が無料枠内に収まっているかを監視できる仕組みを実装しました。 (ちなみに、QuickSightでは、AUTHORアカウント1人につき、SPICEの10GB分が無料枠 *1 として付与されます。) LambdaとCloudWatchを用いた監視方法 前述の課題の解決策として、下図の構成で監視の仕組みを実装しました。 EventBridgeによる定期実行 ルールを設定し、毎日6時間毎にLambda関数を起動しています。 名前空間別にSPICE使用量を集計してCloudWatchにメトリクスを送信するLambda関数の実装 ListNamespaces APIとListDataSets APIを用いて、全名前空間および全データセットの情報を取得します。 DescribeDataSet APIとDescribeDataSetPermissions APIを使用し各データセット毎のSPICE容量と属している名前空間を確認します。 ListUsers APIを用いてAUTHORアカウント数を取得します。 上記で取得した情報から名前空間別にSPICE使用量を集計、AUTHOR1人あたりのSPICE使用量を算出し、CloudWatchにメトリクスを送信します。 CloudWatchアラートの設定・ダッシュボードの作成 閾値を超えた場合のアラートの設定とダッシュボードでの見える化を行っています。 名前空間別にSPICE使用量を集計してCloudWatchにメトリクスを送信するLambda関数 Lambda関数のコードを記載しておきます。 require ' json ' require ' aws-sdk ' def handler ( event : nil , context : nil ) @quicksight_client = Aws :: QuickSight :: Client .new( region : ENV [ ' REGION ' ]) @cloudwatch_client = Aws :: CloudWatch :: Client .new( region : ENV [ ' REGION ' ]) # 名前空間の一覧を取得 list_namespaces = get_list_namespaces # データセット一覧を取得 list_data_sets = get_list_data_sets data_sets_per_company = {} list_namespaces.each do |namespace| # 名前空間は company-#{company_id}の形式で作成している company_id = match[ 0 ].match( /\d+/ )[ 0 ] data_sets_per_company[company_id] = [] end # company_idをkeyとして、企業毎のデータセットの一覧を配列で持つ、Hashを作成する # DescribeDataSet APIでデータセット毎の使用量を取得することが出来る data_sets_per_company = classification_per_company(list_data_sets, data_sets_per_company) data_sets_per_company.keys.each do |company_id| total_bytes = data_sets_per_company[company_id].map do |data_set| data_set[ :consumed_spice_capacity_in_bytes ] end .sum total_gigabytes = convert_gigabytes(total_bytes) author_count = get_author_count( " company- #{ company_id }" ) put_metric_data(company_id, total_gigabytes, author_count) end end def classification_per_company (list_data_sets, data_sets_per_company) list_data_sets.each do |data_set| response_data_set = @quicksight_client .describe_data_set( aws_account_id : ENV [ ' AWS_ACCOUNT_ID ' ], data_set_id : data_set.data_set_id ).data_set response_data_set_permissions = @quicksight_client .describe_data_set_permissions( aws_account_id : ENV [ ' AWS_ACCOUNT_ID ' ], data_set_id : data_set.data_set_id ).permissions next if response_data_set_permissions.empty? # principal: arn:aws:quicksight:{region}:{aws_account_id}:user/#{namespace}/user_nameの形式で取得できる namespace = response_data_set_permissions[ 0 ].principal.split( ' / ' )[ 1 ] data_set_hash = { data_set_id : response_data_set.data_set_id, data_set_name : response_data_set.name, consumed_spice_capacity_in_bytes : response_data_set.consumed_spice_capacity_in_bytes } company_id = namespace.match( /\d+/ )[ 0 ] data_sets_per_company[company_id] << data_set_hash end data_sets_per_company end def get_author_count (namespace) response_list_data_sets = @quicksight_client .list_users( aws_account_id : ENV [ ' AWS_ACCOUNT_ID ' ], namespace : namespace ) response_list_data_sets.user_list.count { |user| user.role == ' AUTHOR ' || user.role == ' ADMIN ' } end def convert_gigabytes (bytes) bytes.fdiv( 1024 * 1024 * 1024 ) end def put_metric_data (company_id, total_gigabytes, author_count) data = { namespace : ' tunag/quicksight ' , metric_data : [ { metric_name : ' spice_usage ' , value : total_gigabytes, unit : ' Gigabytes ' , dimensions : [ { name : ' company_id ' , value : company_id } ] } ] } data[ :metric_data ] << { metric_name : ' spice_usage_per_author ' , value : total_gigabytes.fdiv(author_count), unit : ' Gigabytes ' , dimensions : [ { name : ' company_id ' , value : company_id } ] } @cloudwatch_client .put_metric_data(data) end CloudWatchアラート、ダッシュボードの作成 下図のようにアラートとダッシュボードを作成しました。 アラート ダッシュボード まとめ 以上が、LambdaとCloudWatchを活用したマルチテナント別にSPICEデータ使用量の監視方法の内容となります。 QuickSightを利用していて運用を効率化したいと考えている方にとって、少しでも参考になれば幸いです。 エンジニアとして、やりがいのある仕事がしたい!わくわくする仕事をしたい!という方がいらっしゃいましたらぜひ、弊社採用窓口までご連絡ください。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア 弊社開発部門では、開発体制や開発の流れ、採用している技術をエンジニアリングハンドブックにまとめております。ご興味がある方は下記のリンクからぜひご覧ください。 stmn, inc. Engineering Handbook *1 : Amazon QuickSight の料金
目次 はじめに データ更新のニーズと課題について API経由でのSPICEデータの更新方法 自動更新のアーキテクチャ Lambda関数のコード まとめ はじめに こんにちは、スタメンの田中、若園です。 こちらの記事 でカスタムダッシュボード機能の全体像を紹介しました。🎉 この記事では、続編その1として、カスタムダッシュボード機能におけるLambda関数を活用したQuickSightのSPICEデータ更新の方法と実装について紹介していきます。 データ更新のニーズと課題について 分析の元となるデータは、 処理速度やコストのメリット を考慮しSPICEにデータを格納しています。 データ更新においては下記のニーズがありました。 ① 日次の定期更新 毎日 TUNAG 上に利用データが蓄積されるため、最新の利用データがSPICEデータに毎日反映されるようにし、分析できるようにしたい。 ③「更新ボタン」からの即時更新 ユーザー情報、部署の所属情報、 セグメント機能 で設定したセグメントの対象の変更などが発生した場合に即時に分析に反映したい。 即時の更新をTUNAGの管理画面からユーザー操作で行えるようにしたい。 これらのニーズがある中で、QuickSightコンソールで提供されている機能を利用してデータ更新が行えないか検討しましたが、下記のような課題がありました。 ① 日時の定期更新 スケジュール更新機能 は、QuickSightコンソールから都度設定を行う必要があり、工数がかかる。 データセット同士の結合が頻繁に発生するため、スケジュール設定の漏れが懸念される。 ②「更新ボタン」からの即時更新 即時で変更を反映するためには、QuickSightコンソールで手動更新を行う必要があり、工数がかかる。 今後導入の拡大を狙う上で、QuickSightコンソール上での作業による工数がボトルネックになることが懸念されました。 そのためQuickSightから提供されているAPIを使用し、独自で自動更新の仕組みを実装することとしました。 API経由でのSPICEデータの更新方法 API経由でのSPICEデータの更新方法は下記の2通りあります。 ① CreateIngestion API SPICEデータの更新処理を開始する。 24時間あたり32回までの サービスクォータ が設定されている。 ② UpdateDataSet API データセット更新処理後に、SPICEデータの更新が実行される。 サービスクォータを考慮して、 UpdateDataSet API を使用して自動更新を実行するLambda関数を実装しました。 (サービスクォータを引き上げて欲しい。。。) 自動更新のアーキテクチャ 自動更新の実現に向けた課題 自動更新の実現に向けて下記の課題がありました。 ① 親子関係を持つデータセット群の更新において、親データセットを更新した後に子データセットを更新するようにしたい。 カスタムダッシュボード機能では、例えばユーザー情報などのマスターデータと投稿などのトランザクションデータを結合して、ユーザー別の投稿数などといったデータセットを作成しています。 結合済みのデータセットとマスター/トランザクションデータとの間に下図のような親子関係が発生します。データの中身を最新にするに当たり、親データセットを更新した後に、子データセットを更新する必要がありました。 ② SPICEデータ更新時にAuroraもしくはAthenaへの多重リクエストを最小限にしたい。 更新時にAuroraもしくはAthenaに対してデータをクエリするため、全てのデータセット更新を実行した場合の負荷や同時接続数が懸念されました。 Lambda関数を活用したアーキテクチャの概要 前述の課題解決にあたり、下図のように2つのLambda関数を組み合わせた構成で自動更新フローを構築しました。 2種類の更新方法について、それぞれ流れを紹介します。 日時の定期更新 日時の定期更新はEventBridgeでルールを設定し、毎朝早朝に更新が実行されるようにしています。 EventBridgeから、ECSタスクを起動し、一部データをQuickSightで扱う形式に変換する処理の実行を行います。 その後、QuickSight上のデータセット同士の依存関係の解決を行う関数(以下、関数その1)が起動されます。 関数その1では、データセット同士の依存関係を解決し、データセットIDを更新順に配列化します。 その後、データセット更新用のLambda関数(以下、関数その2)を起動し、更新順の情報を渡します。 関数その2は、1番目に更新するデータセットの更新をUpdateDataSet APIで実行し、更新が完了するまで待ちます。 更新が完了したら、更新順の配列情報と次の更新対象を示すインデックス情報を渡し、次のデータセット用の関数その2を起動します。 そして、最後のデータセットの更新が終わると、SNS経由でTUNAGに更新完了を通知します。 「更新ボタン」からの即時更新 即時更新は、TUNAG上の更新ボタンを押下することで実行されます。その後の流れは定期更新と同じとなります。 即時更新の場合では、更新対象がマスターデータと結合済みのデータセットの2種類のみとなるように絞り込みをしており、データ量が多いトランザクションデータは、日時で定期更新される前日分までの利用データを分析で使用するようにしています。 このように関数その1, その2を組み合わせて、データセット同士の依存関係の解決と更新順に順次実行することで、前述の課題を解消しました。 Lambda関数のコード 最後にLambda関数のコードを記載しておきます。 関数その1 require ' json ' require ' aws-sdk ' def handler ( event : nil , context : nil ) @quicksight_client = Aws :: QuickSight :: Client .new( region : ENV [ ' REGION ' ]) # 企業IDを元にデータセットをフィルタリングする company_id = event[ ' company_id ' ] # 日時の定期更新なのか、「更新ボタン」からの即時更新なのかを判定するため # daily: 日時の定期更新 # manually: 「更新ボタン」からの即時更新 update_type = event[ ' update_type ' ] # ListDataSets APIから企業毎にデータセットを絞り込む list_data_sets = list_data_sets_for(company_id) # 依存関係をDescribeDataSet APIから取得して、Hashを生成 unresolved_hash = generate_unresolved_hash(list_data_sets) # 依存関係を解決する resolved_data_set_ids = resolve_recursively(unresolved_hash) # トランザクションデータはdata_set_idの末尾に、transactionを付与している # 即時更新の場合はマスターデータと結合済みのデータセットに絞り込む if update_type == ' manually ' resolved_data_set_ids = resolved_data_set_ids.reject { |data_set_id| data_set_id.end_with?( ' transaction ' ) } end # 更新処理を行う関数その2を呼び、配列の0番地から開始する invoke_lambda_function(resolved_data_set_ids, company_id, update_type) end private def generate_unresolved_hash (list_data_sets) unresolved_hash = {} list_data_sets.each do |data_set| resp = @quicksight_client .describe_data_set( aws_account_id : ENV [ ' AWS_ACCOUNT_ID ' ], data_set_id : data_set.data_set_id ) dependent_ids = resp.logical_table_map.map do |_, v| # 未結合のデータセットはdata_set_arnがnilが返る next if v.source.data_set_arn.nil? # 結合で生成されたデータセットは、arnを取得することが出来るため、id値のみ取得する v.source.data_set_arn.split( ' / ' ).last end .compact! unresolved_hash[data_set.data_set_id] = dependent_ids end unresolved_hash end def resolve_recursively (unresolved_hash) resolved_array = [] unresolved_hash.each do |data_set_id, dependent_ids| resolve(data_set_id, dependent_ids, resolved_array, unresolved_hash) end resolved_array end def resolve (data_set_id, dependent_ids, resolved_array, unresolved_hash) return if resolved_array.include?(data_set_id) # 更新順序の依存関係を持たないデータセットの場合 return resolved_array.unshift(data_set_id) if dependent_ids.empty? # 更新順序の依存関係を持つデータセットの場合、依存先のデータセットを依存配列に含めるように再帰処理を行う dependent_ids.each do |dependent_id| if resolved_array.include?(dependent_id) && !resolved_array.include?(data_set_id) resolved_array.push(data_set_id) next else resolve(dependent_id, unresolved_hash[dependent_id], resolved_array, unresolved_hash) end end resolved_array.push(data_set_id) unless resolved_array.include?(data_set_id) end def invoke_lambda_for_start_to_update_datasets (data_set_ids, company_id, update_type) lambda_client = Aws :: Lambda :: Client .new( region : ENV [ ' REGION ' ]) lambda_client.invoke( function_name : ' 関数その2 ' , invocation_type : ' Event ' , log_type : ' None ' , payload : JSON .generate( index : 0 , # 最初は0番地を指定 data_set_ids : data_set_ids, company_id : company_id, update_type : update_type ) ) end 関数その2 require ' json ' require ' aws-sdk ' def handler ( event : nil , context : nil ) company_id = event[ ' company_id ' ] # データセットの更新順の配列 data_set_ids = event[ ' data_set_ids ' ] # 更新対象を示すインデックス情報 index = event[ ' index ' ] # 日時の定期更新なのか、「更新ボタン」からの即時更新なのかを判定するため # daily: 日時の定期更新 # manually: 「更新ボタン」からの即時更新 update_type = event[ ' update_type ' ] # 更新対象のデータセット data_set_id = data_set_ids[index] @quicksight_client = Aws :: QuickSight :: Client .new( region : ENV [ ' REGION ' ]) # データセットの存在確認 response_describe_data_set = @quicksight_client .describe_data_set( aws_account_id : ENV [ ' AWS_ACCOUNT_ID ' ], data_set_id : data_set_id ).data_set # データセットの更新 resp_update_data_set = @quicksight_client .update_data_set( aws_account_id : ENV [ ' AWS_ACCOUNT_ID ' ], data_set_id : data_set_id, name : response_describe_data_set.name, import_mode : ' SPICE ' , physical_table_map : response_describe_data_set.physical_table_map, logical_table_map : response_describe_data_set.logical_table_map ) # SPICEインポートの進捗確認 ingestion_status = wait_ingestion( resp_update_data_set.data_set_id, resp_update_data_set.ingestion_id ) if ingestion_status == ' COMPLETED ' success_update( index, data_set_ids, company_id, update_type, { data_set_id : data_set_id, name : response_describe_data_set.name, company_id : company_id, lambda_function_name : context.function_name, lambda_request_id : context.aws_request_id, lambda_log_stream_name : context.log_stream_name } ) else # SNS経由でエラー通知 end end def wait_ingestion (data_set_id, ingestion_id) waiting = true while waiting resp_describe_ingestion = @quicksight_client .describe_ingestion( aws_account_id : ENV [ ' AWS_ACCOUNT_ID ' ], data_set_id : data_set_id, ingestion_id : ingestion_id ) ingestion_status = resp_describe_ingestion.ingestion.ingestion_status case ingestion_status when ' FAILED ' , ' COMPLETED ' waiting = false else sleep 3 end end ingestion_status end # 最後のデータセット更新の場合は、SNS経由で更新完了を通知 # それ以外は、次のデータセット更新を実行する def success_update (index, data_set_ids, company_id, update_type, message) if last_update?(data_set_ids, index) # SNS経由で更新完了を通知 else invoke_lambda_for_next_dataset(index, data_set_ids, company_id, update_type) end end def last_update? (data_set_ids, index) data_set_ids.length == index + 1 end def invoke_lambda_for_next_dataset (index, data_set_ids, company_id, update_type) lambda_client = Aws :: Lambda :: Client .new( region : ENV [ ' REGION ' ]) lambda_client.invoke( function_name : ' 関数その2 ' , invocation_type : ' Event ' , log_type : ' None ' , payload : JSON .generate( index : index + 1 , data_set_ids : data_set_ids, company_id : company_id, update_type : update_type ) ) end まとめ 以上が、QuickSight SPICEデータのLambda関数を用いた自動更新処理の内容となります。 現在QuickSightの利用していて運用を効率化したいと考えている方にとって、少しでも参考になれば幸いです。 エンジニアとして、やりがいのある仕事がしたい!わくわくする仕事をしたい!という方がいらっしゃいましたらぜひ、弊社採用窓口までご連絡ください。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア 弊社開発部門では、開発体制や開発の流れ、採用している技術をエンジニアリングハンドブックにまとめております。ご興味がある方は下記のリンクからぜひご覧ください。 stmn, inc. Engineering Handbook
目次 はじめに カスタムダッシュボードの概要 カスタムダッシュボードのアーキテクチャ データセットの結合 アプリケーションへの埋め込み セグメント機能 まとめ はじめに こんにちは、スタメンのチームねぎまの近藤、滿本です。 以前、「 名前空間を用いたQuickSight上でのマルチテナントの実現 」というブログでご紹介した技術を用い、カスタムダッシュボード機能を開発・リリースしました 🎉( TUNAG「カスタムダッシュボード」の提供を開始!!利用データを最大限活用し、エンゲージメント経営の実践を支援! ) 今回はその概要とアーキテクチャをお伝えさせていただきます。 カスタムダッシュボードの概要 カスタムダッシュボードの背景 弊社では TUNAG という組織向けのSNSを提供しており、投稿やコメントなどの利用データを扱っています。 導入企業様の利用年数が長くなるに連れて利用データは蓄積され、利用状況として可視化することでその価値はより一層高まります。 TUNAGでは管理者様向けにそれらの利用状況をグラフ化したダッシュボード機能を提供していますが、全ての企業で同じ指標となっており、各社で異なる「○○の指標を△△の対象者で絞り込んでグラフ化したい」というニーズに応えられていない状況でした。 そのような背景から、企業ごとに分析したい指標・対象者をカスタマイズできるカスタムダッシュボード機能の開発に着手しました。 カスタムダッシュボードで出来ること 前述のとおり、カスタムダッシュボードでは会社ごとに異なる指標を特定の対象者に絞り込んでグラフにできます。 また、QuickSightはグラフの種類が豊富でフィルタリングもできるため、利用状況を最適な形で表現できます。 カスタムダッシュボードのアーキテクチャ カスタムダッシュボードをどう実現するか議論する中で、出来る限り早く機能提供したいという思いからBIツールを利用することにしました。 いくつかのツールを比較検討する中で、コスト面に加えて、TUNAGが既にAthenaやAuroraを利用しており親和性が高かったため QuickSight を採用することに決めました。 構成は以下のとおりです。 Athena、Auroraから各データ(投稿、コメントなど)のデータセットを作成 グラフ用にデータセットを結合(部署 ✕ 投稿 👉 部署別の投稿数) それらのデータセットを元に分析、ダッシュボードを作成 TUNAGの管理画面に、QuickSightのコンソールを権限別で埋め込み データセットを最新にするため、更新をEventBridgeにより日次で定期実行 or 手動で実行(Lambdaにリクエスト) Lambdaによりデータセットを更新 この構成は、カスタムダッシュボード機能を数社で検証する中で見えてきた以下のニーズを基に、徐々に固めていきました。 出来る限り柔軟に指標と対象者を組み合わせたい 👉 データセットの結合機能を利用 TUNAG上でグラフを編集したい 👉 アプリケーションへの埋め込み 対象者を細かく絞って分析したい 👉 セグメント機能(後述します)の開発 順番に説明します。 データセットの結合 検証では、「ユーザー別の投稿数」といった指標を1データセットとして用意していました。しかし、カスタムダッシュボードを運用する中で、新しい指標の要望が常に挙がってきます。それらの要望に答えるために、QuickSightのデータセットの結合機能を用いることにしました。これにより自由にデータセットを組み合わせて、グラフの基になるデータセットを作成できるようになりました。データセットは2つに分類して運用しています。 マスターデータ トランザクションデータ マスターデータ 一般的に、マスターデータとは基礎情報となるデータを指します。ユーザー、部署、役職といったデータが該当します。データソースはAuroraを使用し、SPICEにデータを保存しています。 トランザクションデータ 発生した出来事の詳細を記録したデータは、トランザクションデータと呼ばれます。トランザクションデータの例は、「投稿数」や「投稿の既読数」などです。トランザクションデータは、データベースの負荷を考慮し、AuroraではなくAthenaをデータソースとして使用しています。 マスタデータとトランザクションデータをQuickSight上で結合させることで、より多くの指標の組み合わせを表現できるようになりました。 アプリケーションへの埋め込み TUNAGアプリケーションへのQuickSightの埋め込みは AWS SDK for Ruby、Amazon QuickSight Embedding SDK を利用しました。 埋め込み用のURLを取得 埋め込み用のURLは、 generate_embed_url_for_registered_user を用いて取得します。 def generate_embed_url_for_registered_user () resp = @client .generate_embed_url_for_registered_user({ aws_account_id : AWS_ACCOUNT_ID , user_arn : user_arn, session_lifetime_in_minutes : 600 }) resp[ :embed_url ] end 取得したURLをAmazon QuickSight Embedding SDKを用いて埋め込む 取得したURLは、 Amazon QuickSight Embedding SDK を用いて埋め込みます。 export const QuickSightEmbed = () => { useEffect(() => { const containerDiv = document .getElementById( 'embeddedCustomDashBoardContainer' ) QuickSightEmbedding.embedDashboard( { url: "取得したURL" , container: containerDiv, scrolling: "yes" , height: "700px" , width: "1000px" , locale: "ja-JP" , } ) } , [] ) return ( <EmbedContainer id= "embeddedCustomDashBoardContainer" /> ) } ) カスタムダッシュボードでは、2種類のアカウントを提供しています。 READER:ダッシュボードの閲覧が可能 AUTHOR:ダッシュボードの閲覧及び新規グラフの作成・編集が可能 ユーザーは権限に応じて、TUNAGアプリケーションに埋め込まれたQuickSightを利用できます。 セグメント機能 セグメント機能とは、データを特定の対象者に絞り込んで分析できる機能です。 例えば「投稿数」というトランザクションデータを「新卒者」というセグメントと掛け合わせると、「新卒者の投稿数」がグラフとして表現できます。 アプリケーションで任意のセグメントをマスターデータとして作成 セグメントのマスターデータを基にQuickSight上でデータセットを作成 投稿数などのトランザクションデータと結合して利用 TUNAGでは各社ごとに利用状況から課題を抽出し、新たなTUNAGの利活用の方針を立てるため、こうした任意のセグメントによる利用状況の分析が重要になります。 まとめ 以上が、カスタムダッシュボード機能の概要とアーキテクチャになります。最後までご覧いただきありがとうございました。この記事がQuickSightとアプリケーションの連携を検討されている方にとって、少しでも参考になれば幸いです。 弊社ではエンジニアを募集しています。 やりがいのある仕事がしたい!わくわくする仕事をしたい!という方がいらっしゃいましたらぜひ、採用窓口までご連絡ください。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア 開発体制や事業内容をまとめた Engineering Handbook も併せてご覧ください。 参考 データの結合 Amazon QuickSight コンソールへのアクセスのカスタマイズ 登録済みユーザー向けの Amazon QuickSight コンソールの全機能の埋め込み
こんにちは、TUNAG事業部のカーキ ( @khaki_ngy )です。 普段は TUNAG の iOS/Android などのモバイルアプリの開発を行なっています。 今回は TUNAG の Android アプリ開発における Android View の開発での Jetpack Compose を使ったレイアウトプレビューの活用について、Jetpack Composeの導入理由と併せて紹介していきます。 Jetpack Compose の導入 TUNAG の Android アプリでは Jetpack Compose の安定版がリリースされた2021年末から Jetpack Compose の導入を開始し、新規の View に関しては極力 Jetpack Compose を利用して UI の構築を行なっていました。 当時の背景としては、下記の3点を狙いとしていました。 よりスピーディーな新機能の提供 レイアウトプレビューによる開発体験の向上 今後のUI開発のバラダイムが変わっていくことを見越して よりスピーディーな新機能の提供 また Jetpack Compose により宣言的にUIを作成することができるため、直感的に記述することができるようになります。 TUNAG アプリは以前から画面の部品単位でコンポーネントに分けて開発を進めていましたが、Android View を使った開発では一つのコンポーネントを作成するのにレイアウトファイルをいくつも作成しないといけないなど、煩雑さを感じていました。 レイアウトプレビューによる開発体験の向上 Jetpack Compose を導入したことにより作成したコンポーネントをプレビューすることができるようになりました。 以前までの Android View を使ったコンポーネント開発では、作成したコンポーネントの振る舞いが意図した通りになっているかどうかの担保は、テスト用の Activity を作成して、そこに配置してテストをおこなっていました。そのためコンポーネントの開発にプラスして、コンポーネントの確認のために時間と工数を費やしていました。 Jetpack Compose のプレビューには、インタラクティブプレビューやアニメーションプレビューなどの動的な動きを確認する仕組みもあり、かつておこなっていたような検証方法は不要になりました。 今後のAndroidのUI開発のパラダイムが変わっていくことを見越して 宣言型UIフレームワークはモバイルでは Flutter や SwiftUI 、Webフロントエンドの領域では React など、現代のフロントエンド開発における技術トレンドになってきています。 ここ数年での技術の成長や技術コミュニティの成熟などを見るに、この傾向は不可逆なものだと考えています。 導入と現状 現在、新しい画面や新しいコンポーネントの開発には積極的に Jetpack Compose を採用しています。 ただ TUNAG のモバイルグループの開発メンバーのリソースは限られ、既存の View を Jetpack Compose に全て置き換えることはできていません。 本来であれば、既存機能の改修のタイミングで既存コンポーネントのComposable 関数への移行もおこなっていきたいのですが、現状そこまでは取り組むことはできていません。 そんな中で Android View を使用した開発でもJetpack Composeによるプレビューによる恩恵を得たいと考えたのが今回の事例になります。 次項では、ブログタイトルにもある Jetpack Compose のプレビューで AndroidView で作成したコンポーネントをプレビューした事例に関して、背景を踏まえて紹介していきます。 チャット返信機能プロジェクト 直近で私達のチームで Android View のプレビュー化を行ったのは、春ごろにリリースをおこなった TUNAG のチャットの返信機能プロジェクトでした。 TUNAG では制度の利用やその閲覧を行うタイムラインの他にビジネス用のチャット機能も提供しています。この春に追加機能としてチャットでの引用返信や動画送信の機能の開発を行いました。 TUNAGチャットのデモ画像 このプロジェクトでメッセージの表示パターンに引用返信が増えるのに当たって、既存のメッセージのレイアウト自体を改修することになりました。 ここでの改修の際に全て Composable 関数で書き換えるという方法もありましたが、開発期間が短く、着手当時は自分たち自身もそこまで Jetpack Compose に詳しくなかったため、現状動いている Android View をベースに改修を行うことになりました。 Jetpack Compose のプレビューを使用した開発 チャットのメッセージコンポーネントにはそのメッセージの状態や選択された時、引用元のメッセージを含む場合とで数十通りのパターンが存在していました。 今までは実行環境でその状態を作り、意図した通りに表示できているかを確認していましたが、これ以上パターンが増える場合は対応できないと判断しました。 そこで目をつけたのが以前から試験的に導入していた Jetpack Compose のプレビュー機能でした。 Android View で作成したものを Composable 関数でラップしプレビューを可能にすれば、現状の Android View で作成したものを活かしながら、プレビューにより網羅的に状態を把握することができます。 Android Viewで作成されたコンポーネントの Composable 関数として利用するのは、Jetpack Compose の公式ドキュメントとして公開されているこちらの Android Developers ドキュメントの相互運用 API を参考にして進めました。 TUNAGチャットで使用されるテキストメッセージのプレビュー 上の画像はチャット返信機能着手前の時点で36通りですが、これが返信機能によって倍の72通りに増えています。 プレビューの機能を使用したことで、これだけの状態のパターンを全て網羅的に把握することができるようになりました。 このメリットとしては、「View の網羅的な状態の確認ができる」に尽きると思います。 今回、Composable 関数でのプレビューが導入できたことで、デグレを発生させることなく、安心してリリースを行うことができました。 Compose のプレビューのみを使用するデメリット これまで良い面を紹介してきましたが、開発時や運用してしばらく経ってみて感じたデメリットがいくつかあるので紹介します。 Android View での状態の変化を随時 Composable 関数の方にも反映させないといけない 今回私たちが行なった手法では、実際にコンポーネントとして使用しているのでは AndroidView であり、プレビューしている Composable 関数自身ではありません。 そのため、Android View で状態が増えた場合に、随時 Composable 関数の方にも反映して同期を取っていかないと正しい状態をプレビューできているとはいえなくなってしまいます。 今後基本的にコンポーネントに関わる変更は、プレビューでの正しい表示により確認をしていくので、問題にはなっていないのですが、二重で管理する必要はあります。 ビルド時間が長い そもそも今回のプロジェクトで進めていたモジュール自体がかなり大きいということにも原因があると思いますが、今回の対応によりビルド時間が長くなりました。 正確に測っているわけではないですが、体感で感じるレベルで長くなりました。 これは純な Composable 関数ではなく、Android View をラップしたものを扱っているところにも原因があると感じています。 上記のようなデメリットはありますが、それを踏まえた上でも Android View で開発中のコンポーネントの確認ができる今回の手法は有用だと思っています。 開発中にこのプレビュー機能を使用したことで、何度も View の不整合に気付くことができ、即座に修正に取り掛かることができました。 それは今回のような改修プロジェクトで、 以前までの表示状態を担保しなければいけない という状況にも合っていました。 今後の展望 今後の展望としては、チャットメッセージに関わる全てのコンポーネントを Composable 関数に置き換えたいと考えています。 もちろん開発プロジェクトとの調整を取りながらにはなりますが、コンポーネント自体を Composable 関数にすることで上で紹介したデメリットもある程度解消されます。 また最初に Jetpack Compose をプロジェクトに導入した経緯でも紹介したように、Composable 関数として扱えることで管理が楽になり、より早く新機能・改善を行うことができると確信しています。 TUNAG のチャット機能はまだまだ機能追加される余地が残っているため、早期に Composable 関数への置き換えができれば、今後の開発をより効率的進めることができます。 今回は Android View で作成したコンポーネントを Composable 化してプレビュー可能にすることで開発を加速させた事例の紹介しました。 途中でも述べましたが、新規作成するコンポーネントに関しては Jetpack Compose を使用して作成しています! Jetpack Compose を使ってガシガシ開発をしたいという方がいらっしゃいましたらぜひ、弊社採用窓口までご連絡ください。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア TUNAGの開発体制、使用技術については下記ブログ記事を参照ください。 TUNAGの技術と開発体制のすべてを紹介します! また弊社では、オンラインサロン用プラットホーム FANTS というサービスも開発・運営しております。 ご興味がある方はぜひ下記のリンクをご覧ください。 FANTSの開発技術・開発組織を紹介します! また スタメン Engineering Handbook として体系的にまとめられたページも公開しています。 こちらも是非ご覧ください。
はじめに こんにちは。株式会社スタメンで FANTS のエンジニアをしている @0906koki です。 今回の記事では、以前社内で実装した、デシリアライズする前の JSON:API フォーマットの型を、デシリアライズ後の型に変換する方法について書きたいと思います。 目次 はじめに 目次 TL;DR (概要) 👇 この JSON:APIフォーマットの型を... 👇 こう デシリアライズした型に変換する! JSON:API jsonapi-serializer でJSON:APIフォーマットのJSONを返却 返却されるレスポンスの例 なぜ実装したか 1. デシリアライズ後のフォーマットを考えて、型を実装する必要がある 2. MSW でモックデータを作成する際、型安全でないモックデータになる 最終的な型 型の説明 リレーションがない場合 リレーションがある場合 実際に運用してみて 最後に TL;DR (概要) 👇 この JSON:APIフォーマットの型を... type RawResponseType = { data: { id: string ; type : "user" ; attributes: { name: string ; } ; relationships: { drinks: { data: { id: string ; type : "drink" ; }[] ; } ; books: { data: { id: string ; type : "book" ; }[] ; } ; } ; included: | { id: string ; type : "drink" ; attributes: { name: string ; image_url: string ; created_at: string ; } ; }[] | { id: string ; type : "book" ; attributes: { title: string ; image_url: string ; published_at: string ; } ; }[] ; } ; } ; 👇 こう デシリアライズした型に変換する! type ResponseType = JsonApiRelationshipDeserializedType < RelationShipsApiType >; const Sample3: ResponseType = { id: '1' , name: "Nagai Koki" , drinks: [ { id: '1' , name: "Coke" , imageUrl: "https://avatars.githubusercontent.com/u/50698194?v=4" , createdAt: "2021/01/01" , } , ] , books: [ { id: '1' , title: "TDD" , imageUrl: "https://avatars.githubusercontent.com/u/50698194?v=4" , publishedAt: "2021/01/01" , } , { id: '2' , title: "DDD" , imageUrl: "https://avatars.githubusercontent.com/u/50698194?v=4" , publishedAt: "2021/01/02" , } , ] , } ; JSON:API FANTS ではサーバーサイドを Rails で実装しており、その API のシリアライザとして jsonapi-serializer を使用しています。 例えば、Movie モデルが has_many として Actor モデルを持ち、Movie の title と year、Movie のリレーション先である Actor の name を JSON で返したい場合、以下のようなコードを記述することで、 JSON:API のフォーマットで JSON 返却することができます。 jsonapi-serializer でJSON:APIフォーマットのJSONを返却 render json : MovieSerializer .new(movie).serializable_hash.to_json 返却されるレスポンスの例 { " data ": { " id ": " 1 ", " type ": " movie ", " attributes ": { " title ": " アナ雪 ", " year ": 2013 } , " relationships ": { " actors ": { " data ": [ { " id ": " 1 ", " type ": " actor " } , { " id ": " 2 ", " type ": " actor " } ] } } , " included ": [ { " id ": " 1 ", " type ": " actor ", " attributes ": { " name ": " クリステン・ベル " } } , { " id ": " 2 ", " type ": " actor ", " attributes ": { " name ": " イディナ・メンゼル " } } ] } } https://jsonapi.org/ に詳しく書かれていますが、JSON:API フォーマットでは、 type にリソース名、 attributes にそのリソースのデータ、 relationships と included はリレーション先のリソースデータを返します。 API のレスポンスが上記のような JSON の形で シリアライズされて返却されるため、クライアントサイドではこのフォーマットをデシリアライズして扱いやすい形に変換する必要があります。JavaScript であれば jsonapi-serializer 、swift であれば、 Japx など、簡単にデシリアライズできるライブラリが各クライアントに存在するので、基本的にはそれらを通してデシリアライズします。 ちなみに上記の JSON:API フォーマットの JSON データを jsonapi-serializer を使ってデシリアライズすると、以下のようなオブジェクトに変換されます。 const movie = { id: "1" , title: "アナ雪" , year: 2013 , actors: [ { id: "1" , name: "クリステン・ベル" , } , { id: "2" , name: "イディナ・メンゼル" , } , ] , } ; なぜ実装したか FANTS のフロントエンドでは、上記のように、 jsonapi-serializer を通して、JSON:API フォーマットのレスポンスデータをデシリアライズしていますが、主に 2 つの観点で、型の安全性の問題がありました。 デシリアライズした後のフォーマットを考えて、型を実装する必要がある MSW でモックデータを作成する際、型安全ではないモックデータになる 1. デシリアライズ後のフォーマットを考えて、型を実装する必要がある サーバーサイドが Rails で実装されており、API スキーマの型の自動生成、クライアントとの共有が出来ないので、フロントエンドエンジニアはサーバーサイドエンジニアと連携して、API スキーマを自前で実装する必要があります。この実装においてハブとしているのが、 stoplight という OpenAPI 定義ファイルを GUI で作成できるツールです。 stoplightのイメージ サーバーサイドエンジニアは、この stoplight 上で API スキーマを定義することで、フロントエンドエンジニアは stoplight を確認してフロントエンドの API レスポンスの型を実装します。 しかし、あくまで stoplight 上で定義されるのはデシリアライズされる前の JSON:API フォーマットであり、フロントエンドエンジニアが実際に使用するのはデシリアライズした型であるため、stoplight 上のスキーマからデシリアライズした型を以下のように自前で実装する必要があります。 type DeserializedResponseMovieType = { id: string ; title: string ; year: number ; actors: { id: string ; name: string ; }[] ; } ; 具体的に言うと、以下は axios の例ですが、axios の interceptors を使って、レスポンスとして返されるデータを先程上げた jsonapi-serializer を通してデシリアライズしており、そのデシリアライズされたデータをアプリケーション内で使います。 axiosInstance.interceptors.response.use (async ( response ) => { // ... const deserializedData = await new Deserializer ( { keyForAttribute: "camelCase" , } ) .deserialize ( response.data ); return { ...response , data: deserializedData } ; } ); jsonapi-serializer のデシリアライズ処理の返却データの値に型が付けば良いのですが、以下の通り Promise<any> で返ってきます。 export interface Deserializer { deserialize ( data: any , callback: Callback ) : void ; deserialize ( data: any ) : Promise < any >; } このように、デシリアライズした型を自前で実装するので、その変換した型にミスがある可能性があったり、実装自体にコストもかかるなど、安全性や実装コストの面でデメリットが存在します。 2. MSW でモックデータを作成する際、型安全でないモックデータになる FANTS では、フロントエンドのモックサーバーとして MSW を使用しています。MSW とは Service Worker を立てて、実際に API リクエストがあった際に、それに合致するモックデータをネットワークレベルでインタセプトして返却するライブラリです。 MSW ではテストを書く場合や、実際に API がない場合でもフロントエンドだけ先行して開発したい場合など、様々なシーンで使用していますが、MSW のモックデータは「ネットワークレベルでのモック = デシリアライズする前の JSON:API フォーマットの JSON データ」であるので、以下のように記述しており、型安全ではない状態になっていました。 const MOVIE_MOCK_DATA = { data: { id: "1" , type : "movie" , attributes: { title: "アナ雪" , year: 2013 , } , relationships: { actors: { data: [ { id: "1" , type : "actor" , } , { id: "2" , type : "actor" , } , ] , } , } , included: [ { id: "1" , type : "actor" , attributes: { name: "クリステン・ベル" , } , } , { id: "2" , type : "actor" , attributes: { name: "イディナ・メンゼル" , } , } , ] , } , } as const ; export const fetchMovie200Handler = rest. get( `/api/v1/movies/:id` , ( _ , res , ctx ) => res ( ctx. status( 200 ), ctx.json ( MOVIE_MOCK_DATA )) ); 例えば、レスポンスの型に変更があり、1 で書いたデシリアライズした後の型に変更を加えても、上記のモックデータには型が当たっていないので、静的チェックが出来ずコンパイルが通ってしまいます。 デシリアライズする前の型にも型注釈を書けば良いですが、レスポンスのデータに変更があった場合に、デシリアライズした後の型にも変更を加える必要があり 2 重メンテになるため避けたいです。 このように、デシリアライズする前と後でそれぞれ型安全なコードを書く必要があり、またレスポンスの変更に静的チェックで気付けるように、それぞれの型が相互に結びついている必要があります。 最終的な型 それらの問題点を解消するために、デシリアライズする前のレスポンスの型を、デシリアライズした型に変換する型を実装しました。 以下が最終的なコードです。 import { SnakeObjectToCamelType , ExtractArrayType , NarrowUnionObjectType , } from "@/types/utils" ; import { BaseJsonApiArrayType , BaseJsonApiType , BaseRelationshipApiType , BaseRelationshipApiArrayType , } from "./base" ; // JSON:API(Relationなし / Metaデータなし)の型変換 export type JsonApiDeserializedType < T extends BaseJsonApiType | BaseJsonApiArrayType > = T extends BaseJsonApiType ? SnakeObjectToCamelType < T [ "data" ][ "attributes" ] > & { id: string } : T extends BaseJsonApiArrayType ? SnakeObjectToCamelType < ExtractArrayType < T [ "data" ] > [ "attributes" ] & { id: string } > [] : never ; // JSON:API(Relationなし / Metaデータあり)の型変換 export type JsonApiDeserializedWithMetaType < T extends BaseJsonApiType | BaseJsonApiArrayType , MetaType extends Record < string , unknown > > = { data: JsonApiDeserializedType < T >; meta: SnakeObjectToCamelType < MetaType >; } ; // JSON:API(Relationあり / Metaデータなし)の型変換 export type JsonApiRelationshipDeserializedType < T extends BaseRelationshipApiType | BaseRelationshipApiArrayType > = JsonApiDeserializedType < T > & ( T extends BaseRelationshipApiType ? SnakeObjectToCamelType < { [ K in keyof T [ "data" ][ "relationships" ]] : SnakeObjectToCamelType < NarrowUnionObjectType < ExtractArrayType < T [ "data" ][ "included" ] >, "type" , ExtractArrayType < T [ "data" ][ "relationships" ][ K ][ "data" ] > [ "type" ] > [ "attributes" ] & { id: string } > [] ; } > : T extends BaseRelationshipApiArrayType ? SnakeObjectToCamelType < { [ K in keyof ExtractArrayType < T [ "data" ] > [ "relationships" ]] : SnakeObjectToCamelType < NarrowUnionObjectType < ExtractArrayType < ExtractArrayType < T [ "data" ] > [ "included" ] >, "type" , ExtractArrayType < ExtractArrayType < T [ "data" ] > [ "relationships" ][ K ][ "data" ] > [ "type" ] > [ "attributes" ] & { id: string } > [] ; } > [] : never ); // JSON:API(Relationあり / Metaデータあり)の型変換 export type JsonApiRelationshipDeserializedWithMetaType < T extends BaseRelationshipApiType | BaseRelationshipApiArrayType , MetaType extends Record < string , unknown > > = { data: JsonApiRelationshipDeserializedType < T >; meta: SnakeObjectToCamelType < MetaType >; } ; // JSON:APIのベース(Relationなし / dataがObject)の型 export type BaseJsonApiType = { data: { id: string ; type : string ; attributes: Record < string , unknown >; } ; } ; // JSON:APIのベース(Relationなし / dataがArray)の型 export type BaseJsonApiArrayType = { data: BaseJsonApiType [ "data" ][] ; } ; // JSON:APIのベース(Relationあり / dataがObject)の型 export type BaseRelationshipApiType < MetaType extends Record < string , unknown > = Record < string , unknown > > = { data: { id: string ; type : string ; attributes: Record < string , unknown >; relationships: Record < string , { data: { id: string ; type : string ; }[] ; } >; included: BaseJsonApiType [ "data" ][] ; } ; meta?: SnakeObjectToCamelType < MetaType >; } ; // JSON:APIのベース(Relationあり / dataがArray)の型 export type BaseRelationshipApiArrayType < MetaType extends Record < string , unknown > = Record < string , unknown > > = { data: BaseRelationshipApiType < MetaType > [ "data" ][] ; meta?: BaseRelationshipApiType < MetaType > [ "meta" ] ; } ; // SnakeCaseをCamelCaseへ変換(string) export type SnakeStringToCamelCaseType < T extends string > = T extends ` ${ infer R } _ ${ infer U } ` ? ` ${ R }${ Capitalize<SnakeStringToCamelCaseType<U>> } ` : T ; // SnakeCaseをCamelCaseへ変換(object) export type SnakeObjectToCamelType < T extends Record < string , unknown >> = { [ K in keyof T as ` ${ SnakeStringToCamelCaseType< string & K > } ` ] : T [ K ] extends Record < string , unknown > ? SnakeObjectToCamelType < T [ K ] > : T [ K ] extends Array < any > ? SnakeObjectToCamelType < ExtractArrayType < T [ K ] >> [] : T [ K ] ; } ; // NOTE: UnionのObjectから絞り込む // UnionObject : Union型のObject // UnionObjectKey : Objectを識別するプロパティ // UnionObjectKey : 絞り込みたいObjectのUnionObjectKeyの値 export type NarrowUnionObjectType < UnionObject extends Record < string , unknown >, UnionObjectKey extends keyof UnionObject , UnionObjectValue extends UnionObject [ UnionObjectKey ] > = UnionObject extends { [ Key in UnionObjectKey ] : UnionObjectValue } ? UnionObject : never ; // Array<T>のTを取得 export type ExtractArrayType < T > = T extends ( infer U ) [] ? U : T ; 例として、先程のデシリアライズされる前のムービーのレスポンスの型を、以下のように上記の型を通してデシリアライズした型を生成します。 // デシリアライズする前のJSON:APIフォーマットの型 type RawMovieResponseType = { data: { id: string ; type : "movie" ; attributes: { title: string ; year: number ; } ; relationships: { actors: { data: { id: string ; type : "actor" ; }[] ; } ; } ; included: { id: string ; type : "actor" ; attributes: { name: string ; } ; }[] ; } ; } ; // デシリアライズした後の型 type DeserializedMovieResponseType = JsonApiRelationshipDeserializedType < RawMovieResponseType >; // 👇 静的チェックが無事通る const movie: DeserializedMovieResponseType = { id: "1" , title: "アナ雪" , year: 2013 , actors: [ { id: "1" , name: "クリステン・ベル" , } , { id: "2" , name: "イディナ・メンゼル" , } , ] , } ; 型の説明 まず、FANTS で使用している JSON:API フォーマットとして、大きく分けて 4 つありました。 対象リソースが単一で、リレーションがない場合 JSON:API フォーマットで言う、 data がオブジェクトで、 relationships , included がない場合 対象リソースが複数で、リレーションがない場合 JSON:API フォーマットで言う、 data が配列で、 relationships , included がない場合 対象リソースが単一で、リレーションがある場合 JSON:API フォーマットで言う、 data がオブジェクトで、 relationships , included がある場合 対象リソースが複数で、リレーションがある場合 JSON:API フォーマットで言う、 data が配列で、 relationships , included がある場合 JSON:API フォーマットとしては、 links やリレーション先が単一リソースである場合 など、上記以外にも様々なエッジケースが想定されますが、FANTS では上記の 4 つが主なユースケースであるため、今回デシリアライズする対象としても 4 つに絞りました。 また、前提として、サーバーサイドで返却される JSON データはスネークケースであり、フロントエンドで扱うフォーマットは基本的にキャメルケースであるので、スネークケースからキャメルケースへ変換する処理も加える必要もあります。 それらを踏まえた上で、まず最初に、1, 2 の「リレーションがない場合」の変換について説明します。 リレーションがない場合 リレーションがない場合は、以下の型がデシリアライズする型となります。 export type JsonApiDeserializedType < T extends BaseJsonApiType | BaseJsonApiArrayType > = T extends BaseJsonApiType ? SnakeObjectToCamelType < T [ "data" ][ "attributes" ] > & { id: string } : T extends BaseJsonApiArrayType ? SnakeObjectToCamelType < ExtractArrayType < T [ "data" ] > [ "attributes" ] & { id: string } > [] : never ; リレーションがない場合は単純で、ジェネリクスで JSON:API 形式の型を受け取り、Conditional Types を使って、data がオブジェクトであるか、配列であるかの条件によって分岐しています。 オブジェクトである場合は、 attributes の型と id の型 を intersection して、それらをキャメルケースへ変換しています。 キャメルケースへ変換する処理に関しては、Mapped Types の Key Remapping を使ってオブジェクトの Key をスネークケースからキャメルケースへ変換し、それに対応する値は、オブジェクトか配列の場合に Recursive Conditional Types を使って再帰的にスネークケースの key をキャメルケースへ変換しています。 // SnakeCaseをCamelCaseへ変換(string) export type SnakeStringToCamelCaseType < T extends string > = T extends ` ${ infer R } _ ${ infer U } ` ? ` ${ R }${ Capitalize<SnakeStringToCamelCaseType<U>> } ` : T ; // SnakeCaseをCamelCaseへ変換(object) export type SnakeObjectToCamelType < T extends Record < string , unknown >> = { [ K in keyof T as ` ${ SnakeStringToCamelCaseType< string & K > } ` ] : T [ K ] extends Record < string , unknown > ? SnakeObjectToCamelType < T [ K ] > : T [ K ] extends Array < any > ? SnakeObjectToCamelType < ExtractArrayType < T [ K ] >> [] : T [ K ] ; } ; リソースが複数 ( data が配列) である場合も基本的には同じです。 以下のような、 ExtractArrayType を定義して、 T["data"]<U> の U を取得することで、リソースが単一である場合と同じように T["data"]["attributes"] と { id: string } を intersection させています。 type ExtractArrayType < T > = T extends ( infer U ) [] ? U : T ; 以下がサンプルデータとなります。 // デシリアライズする前のJSON:API(Object)フォーマットの型 type RawMovieResponseType = { data: { id: string ; type : "movie" ; attributes: { title: string ; year: number ; } ; } ; } ; // デシリアライズする前のJSON:API(Array)フォーマットの型 type RawMoviesResponseType = { data: { id: string ; type : "movie" ; attributes: { title: string ; year: number ; } ; }[] ; } ; // 👇 静的チェックが無事通る const movie: JsonApiDeserializedType < RawMovieResponseType > = { id: '1' title: 'アナ雪' , year: 2013 } // 👇 静的チェックが無事通る const movies: JsonApiDeserializedType < RawMoviesResponseType > = { id: '1' title: 'アナ雪' , year: 2013 }[] リレーションがある場合 リレーションがある場合は、以下の型がデシリアライズする型となります。 export type JsonApiRelationshipDeserializedType < T extends BaseRelationshipApiType | BaseRelationshipApiArrayType > = JsonApiDeserializedType < T > & ( T extends BaseRelationshipApiType ? SnakeObjectToCamelType < { [ K in keyof T [ "data" ][ "relationships" ]] : SnakeObjectToCamelType < NarrowUnionObjectType < ExtractArrayType < T [ "data" ][ "included" ] >, "type" , ExtractArrayType < T [ "data" ][ "relationships" ][ K ][ "data" ] > [ "type" ] > [ "attributes" ] & { id: string } > [] ; } > : T extends BaseRelationshipApiArrayType ? SnakeObjectToCamelType < { [ K in keyof ExtractArrayType < T [ "data" ] > [ "relationships" ]] : SnakeObjectToCamelType < NarrowUnionObjectType < ExtractArrayType < ExtractArrayType < T [ "data" ] > [ "included" ] >, "type" , ExtractArrayType < ExtractArrayType < T [ "data" ] > [ "relationships" ][ K ][ "data" ] > [ "type" ] > [ "attributes" ] & { id: string } > [] ; } > [] : never ); リレーションがある場合は、リレーションがない場合と比較してかなり複雑となっています。以下のようなリレーションありの JSON:API フォーマットの型をサンプルとして、順々に説明していきます。 type RawMovieResponseType = { data: { id: string ; type : "movie" ; attributes: { title: string ; year: number ; } ; relationships: { actors: { data: { id: string ; type : "actor" ; }[] ; } ; directors: { data: { id: string ; type : "director" ; }[] ; } ; } ; included: | { id: string ; type : "actor" ; attributes: { name: string ; } ; }[] | { id: string ; type : "director" ; attributes: { name: string ; } ; }[] ; } ; } ; まず始めに、リソースの attributes を取得する際は、先程見たリレーションがない場合と同じであるので、 JsonApiDeserializedType を使って取得します。 export type JsonApiRelationshipDeserializedType < T extends BaseRelationshipApiType | BaseRelationshipApiArrayType > = JsonApiDeserializedType < T >; const movie: JsonApiRelationshipDeserializedType < RawMovieResponseType > = { id: '1' title: 'アナ雪' , year: 2013 } 次に、リレーション部分ですが、大枠の方向性としては、 relationships にある type と、 included にある type を突合させて、リレーションとして返す型を決定してあげます。 data がオブジェクトの場合ですが、以下のように実装しています。 // ... SnakeObjectToCamelType < { [ K in keyof T [ 'data' ][ 'relationships' ]] : SnakeObjectToCamelType < NarrowUnionObjectType < ExtractArrayType < T [ 'data' ][ 'included' ] >, 'type' , ExtractArrayType < T [ 'data' ][ 'relationships' ][ K ][ 'data' ] > [ 'type' ] > [ 'attributes' ] & { id: string } > [] } > // ... keyof T['data']['relationships'] で RawMovieResponseType の key である actors と directors のそれぞれを Mapped Types で分配して展開しています。 NarrowUnionObjectType の箇所にある type の型パラメータが突合の部分ですが、 ExtractArrayType<ExtractArrayType<T['data']>['included']> で、 included のユニオン(今回で言うと included の actor と director のユニオン型)を、分配された K の relationships にある type を使って絞り込んでいます。 例えば、 K が actors の場合、以下のように included が絞り込まれます。 type NarrowUnionObjectType < UnionObject extends Record < string , unknown >, UnionObjectKey extends keyof UnionObject , UnionObjectValue extends UnionObject [ UnionObjectKey ] > = UnionObject extends { [ Key in UnionObjectKey ] : UnionObjectValue } ? UnionObject : never NarrowUnionObjectType < { id: string ; type : "actor" ; attributes: { name: string ; } ; } | { id: string ; type : "director" ; attributes: { name: string ; } ; } , 'type' , "actor" > // 👇 typeがactorを持つincludeに絞り込まれる { id: string ; type : "actor" ; attributes: { name: string ; } ; } 最後に絞り込んだ included の attributes と id を intersection させたオブジェクトをキャメルケースへ変換し、リレーション先の型を決定しています。 最初に見た対象リソースの attributes とリレーション先の intersection させると、リレーションありの場合の型生成が完成します。 // 👇 最終的に以下のような型になる SnakeObjectToCamelType < { title: string ; year: number ; } > & { id: string ; } & SnakeObjectToCamelType < { actors: SnakeObjectToCamelType < { name: string ; } & { id: string ; } > [] ; directors: SnakeObjectToCamelType < { name: string ; } & { id: string ; } > [] ; } >; type ResponseMovieType = JsonApiRelationshipDeserializedType < RawResponseMovieType >; // 👇 静的チェックが通る const movie: ResponseMovieType = { id: "1" , title: "アナ雪" , year: 2013 , actors: [ { id: "1" , name: "クリステン・ベル" , } , { id: "2" , name: "イディナ・メンゼル" , } , ] , directors: [ { id: "1" , name: " クリス・バック" , } , { id: "2" , name: " ジェニファー・リー" , } , ] , } ; 実際に運用してみて 今までは、stoplight 上のレスポンスの型を見て、デシリアライズした型に変換して記述していましたが、今回デシリアライズした型を実装した後では、stoplight 上で記述されている型をそのまま TypeScript の型で書くだけで良くなり直感的になった他、レスポンスの変更があった場合でも、静的チェックでデシリアライズする前と後で矛盾がないかを検知できるようになりました。 しかし、今回書いた型は JSON:API におけるベーシックな部分のみをカバーしたものであるため、今回のデシリアライズ変換でカバーできないエッジケースに対しては随時拡張していく必要があります。 また、stoplight 上で OpenAPI を管理しているため、OpenAPI から自動で TypeScript の型を生成できるようにできれば、Rails API とフロントエンドは、より型安全な API スキーマによって連携できるため、今後検討していきたいです。(OpenAPI がどれくらいの精度で TypeScript の型を生成できるか、運用上のコストを天秤にかけた上で) 最後に 今回実装した内容は、github で公開しているので、よりよい書き方がある場合やエッジケースの拡張など、PR お待ちしています! json-api-typescript-deserializer 最後まで読んでいただきありがとうございました!
Work illustrations by Storyset こんにちは、スタメンの滿本、若園、田中、近藤です。スクラムでのチーム名は、チームねぎまです。 (2022年冬から スクラム開発に移行しました ) 本記事では、QuickSightの概要、マルチテナント構成とその運用方法について紹介します。 QuickSightとは QuickSightは、AWSが提供しているクラウドBIサービスです。 本項では、QuickSightの主な構成要素であるユーザーとアセットについて紹介します。 ユーザー QuickSightでは、下記の3種類のユーザー種別があります。 ロール 権限範囲 エンタープライズ版の費用 *1 管理者(ADMIN) 各種設定の変更, ダッシュボードの作成 24ドル/月 作成者(AUTHOR) ダッシュボードの作成 24ドル/月 閲覧者(READER) ダッシュボードの閲覧 最大5ドル/月 アセット QuickSightでは、データ、分析、ダッシュボードの3つをアセットと呼びます。 データ データソースとデータセット グラフなどを用いた分析を行う前に、データソースへの接続もしくはアップロードを行い、データセットと呼ばれるデータ群を準備する必要があります。QuickSightは、 様々なデータソース に対応しています。データセットは、データソース内の指定した任意のテーブルを元に作成することができ、テーブルの中身をそのまま使用したり、SQLを用いてカスタマイズした状態でデータセット化することができます。 ※サンプルデータです。 分析 分析は、データセットを元に作成したグラフやピボットテーブルなどの要素群のことを指し、作成した分析をダッシュボードに公開することで分析結果をダッシュボード化することができます。 ※サンプルデータです。 ダッシュボード ダッシュボードは、複数シート化・Eメール送信・印刷・PDF出力ができ、APIを用いると埋め込み用のURLを生成することもできます。 ※サンプルデータです。 QuickSightを用いたマルチテナント構成 QuickSightには、ユーザーをまとめる概念として、 グループ と 名前空間(Namespace) の2つがあります。 グループを用いる場合、グループを作成しユーザーを所属させ、そのグループ単位でアセットへのアクセス権限を付与することできます。 この場合、QuickSightにデフォルトで存在している名前空間(名前空間名はdefault)上に、全ユーザー・アセットが存在している状態になります。 同一の名前空間上に存在しているため、アクセス権限付与のミスオペレーションなどでグループ間で意図しないアセットが共有されてしまう恐れがあり、マルチテナント構成には向いていないことが分かりました。 一方で名前空間(Namespace)を用いる場合、名前空間別に環境を分けることができるため、確実にユーザー・各アセットを隔離された空間に分離できます。これにより自分が所属している名前空間以外のユーザーとアセット共有することが出来なくなります。 今回の検証では下図のようなマルチテナント構成を準備しました。 AWS CLI *2 によるユーザーとデータの作成 以下の手順でQuickSightのマルチテナント構成を構築しました。 名前空間の作成 ユーザーの作成・登録 データソースの作成 データセットの作成 名前空間の作成 QuickSightの名前空間を作成します。 $ aws quicksight create-namespace \ --aws-account-id {aws-account-id} \ --namespace {namespace} \ --identity-type QUICKSIGHT ユーザーの作成、QuickSightへの登録 IAMでユーザーを作成し、QuickSightへ登録します。分析を作成する権限を持つユーザーは user-role を AUTHOR に指定し、閲覧のみのユーザーは READER とします。 $ aws quicksight register-user \ --aws-account-id {aws-account-id} \ --namespace {namespace} \ --identity-type IAM \ --email {email} \ --user-role {role} \ --iam-arn {iam_arn} データソースの作成 今回はAthenaからデータソースを作成しました。permissionsで quicksight:UpdateDataSource 、 quicksight:DeleteDataSource などの必要な権限を付与しています。 $ aws quicksight create-data-source \ --aws-account-id {aws-account-id} \ --data-source-id {data_source_id} \ --name {data_source_name} \ --type ATHENA \ --data-source-parameters AthenaParameters={WorkGroup=primary} \ --permissions Principal={user_arn},Actions={permitted_actions} データセットの作成 クエリが複数行にわたるため、cli-input-jsonオプションでjsonファイルを指定してデータセットを作成します。 $ aws quicksight create-data-set \ --cli-input-json {cli_input_json_file_path} // 読み込ませるjson { " AwsAccountId ": aws_account_id, " DataSetId ": data_set_id, " Name ": data_set_name, " PhysicalTableMap ": { " AthenaPhysicalTable ": { " CustomSql ": { " DataSourceArn ": data_source_arn, " Name ": custom_sql_name, " SqlQuery ": sql_query, // Athenaで実行するSQL " Columns ": columns , // データセットとして利用するカラムの Name と Type を指定 } } } , " ImportMode ": 'SPICE' , // SPICE または DIRECT_QUERY " Permissions ": [ { " Actions ": permitted_actions, " Principal ": user_arn } ] } 上記の手順でQuickSightコンソールから分析を作成できる環境を用意しました。 日々のSPICEインポート更新の設定と管理 QuicksightではSPICEと呼ばれる、高速なインメモリエンジンをデータ格納先として使用することができます 。 データセットの作成・編集においてSQLを用いる場合、「直接クエリ」と「SPICEへのインポート *3 」という2種類のクエリモードのいずれかを選択できます。 下記の比較の通り、直接クエリと比較してメリットが大きかった為、SPICEを使用することが決まりました。 SPICEと直接クエリの比較 SPICEのメリット ①処理速度 SPICEにデータをインポートすることで、分析作成時やダッシュボード閲覧時において明らかな高速化が確認出来ました。 ②コスト 直接クエリの場合、データセットを参照する毎に、データのクエリに対して課金されます。一方で、 SPICEのデータはインポートするデータの容量 *4 で決まるため、一旦インポートすればコストは固定です。 SPICEのデメリット 容量管理 使用可能なSPICEの残容量は、QuickSightのコンソール上で確認することになります。容量は事前購入の必要があり、オートスケールさせることが出来ません。その為、容量が不足している場合は更新が失敗します。 SPICEデータの更新 SPICEの「スケジュールに基づいたデータセットの更新 *5 」を利用することで、毎日の早朝に自動で最新のデータをSPICEにインポートし更新しています。更新のスケジュールは、データセットごとに設定することが可能です。またもし更新が失敗した場合は、メールで通知を受け取ることが出来ます。 リポジトリとスクリプトによるデータ管理 運用する中で、既存のデータセットを更新したいという要望が頻繁に発生しました。 また、都度QuickSightコンソール上でカスタムSQLを更新してしまうと、誰がどのような理由でデータセットのカスタムSQLを変更したのかが把握しづらくなるという課題がありました。 これらの問題を解決するために、カスタムSQLは下記のようにyamlファイルとして管理するようにしました。 ID : users Name : ユーザー一覧 SqlQuery : |- SELECT name, age, ..., FROM users LEFT JOIN ... WHERE .... GROUP BY ... Columns : - Name : ユーザー名 Type : STRING - Name : 年齢 Type : INTEGER - ... また、 update-data-set のコマンド引数に渡す --physical-table-map を都度コマンド入力するのが大変だったので、スクリプト化することでより運用しやすくなりました。 # cmd/update_dataset_by_id # 特定のデータセットを更新するスクリプト #! /usr/bin/env ruby # frozen_string_literal: true require ' json ' require ' optparse ' require ' yaml ' require_relative ' ../src/aws/config ' require_relative ' ../src/aws/quicksight/update_data_set ' class UpdateDataSetById def initialize option = CmdOptionParser .new.execute @indicator_file_id = option.indicator_file_id end def execute puts ' ===== START Update Data Set ===== ' puts " === Update Indicator ID: #{ @indicator_file_id }" # 対象のyamlファイルの読み込み indicator = YAML .load( File .read( " ./indicators/ #{ @@indicator_file_id } .yml " )) data_source_arn = " arn:aws:quicksight:ap-northeast-1: #{ Aws :: Config :: AWS_ACCOUNT_ID } :datasource/ #{ @indicator_file_id }" Aws :: Quicksight :: UpdateDataSet .new.execute(indicator, data_source_arn) puts ' ===== FINISH Update Data Set ===== ' end end class CmdOptionParser Option = Struct .new( :indicator_file_id ) def initialize @option = Option .new end def execute :: OptionParser .new do |o| o.on( ' -i ' , ' --indicator-file-id [SQL_DEFINED_FILE_ID] ' , ' SQL Defined File ID (Required) ' ) { |v| @option .indicator_file_id = v } o.on( ' -h ' , ' --help ' , ' Show help. ' ) do |_v| puts o exit end o.banner = ' Usage: update_data_set ' o.parse!( ARGV ) return @option if valid? warn ' indicator_files required. ' puts o.help exit ( 1 ) end end private def valid? unless @option .indicator_file_id warn ' Argument indicator-file-id required ' exit ( 1 ) end true end end UpdateDataSetById .new.execute # src/aws/quicksight/update_data_set.rb # aws cliを実行するクラス require_relative ' ../../aws/config ' require_relative ' ../../utils ' module Aws module Quicksight class UpdateDataSet def initialize ; end def execute (indicator, data_source_arn) File .open( ' tmp-update-data-set.json ' , ' w ' ) do |file| JSON .dump(cli_input_json(indicator, data_source_arn), file) end ` aws quicksight update-data-set \ --cli-input-json file://tmp-update-data-set.json ` ` rm tmp-update-data-set.json ` unless $? .success? warn ' ===== Command failed: aws quicksight update-data-set ===== ' exit ( 1 ) end end private def cli_input_json (indicator, data_source_arn) { " AwsAccountId " : Aws :: Config :: AWS_ACCOUNT_ID , " DataSetId " : "#{ indicator[ ' ID ' ] }" , " Name " : "#{ indicator[ ' Name ' ] }" , " PhysicalTableMap " : { " AthenaPhysicalTable " : { " CustomSql " : { " DataSourceArn " : data_source_arn, " Name " : ' CustomSQL ' , " SqlQuery " : indicator[ ' SqlQuery ' ], " Columns " : indicator[ ' Columns ' ] } } }, " ImportMode " : ' SPICE ' } end end end end 参考 運用設計 -きめ細かなユーザー権限とアクセス管理におけるポイント- アカウント/ユーザー管理におけるポイント まとめ 以上が、QuickSightを用いたマルチテナント構成とその運用方法を効率化した内容となります。 BIツールとしてQuickSightの利用を検討している方、現在利用していて運用を効率化したいと考えている方にとって、少しでも参考になれば幸いです。 エンジニアとして、やりがいのある仕事がしたい!わくわくする仕事をしたい!という方がいらっしゃいましたらぜひ、弊社採用窓口までご連絡ください。 採用ページ フロントエンドエンジニア バックエンドエンジニア モバイルエンジニア インフラエンジニア TUNAGの開発体制、使用技術については下記ブログ記事を参照ください。 TUNAGの技術と開発体制のすべてを紹介します! また株式会社スタメンでは、オンラインサロン用プラットホームFANTSというサービスも開発・運営しております。 ご興味がある方はぜひ下記のリンクをご覧ください。 FANTSの開発技術・開発組織を紹介します! *1 : エンタープライズ版の価格表 *2 : AWS CLI Command Reference quicksight *3 : SPICE へのデータのインポート *4 : SPICE 容量の管理 *5 : スケジュールに基づいたデータセットの更新