株式会社RevCommのブログ - TECH PLAY

TECH PLAY

株式会社RevComm

株式会社RevComm の技術ブログ

185

9/10(水)〜12(金)に東北工業大学(仙台)で開催される日本音響学会秋季研究発表会に、プリンシパルリサーチエンジニアの石塚賢吉が登壇します。会場にいらした方は、是非お立ち寄りください。 登壇日時・内容 日時 : 9/12(金)13:00〜15:00(※石塚は13:00〜14:00に対応します) 場所 : ポスター会場 タイトル : 「Room Simulator を用いたデータ拡張による Neural Speaker Diarization モデルの実環境適応」 概要 : 対面での会議録音では、話者ダイアリゼーションの際に残響を考慮する必要があります。本研究は、 残響を考慮したデータセットを効率よく構築し、その有効性を示した ものです。 イベント概要 日程 : 9/10(水)〜12(金) 会場 : 東北工業大学 八木山キャンパス(宮城県仙台市) リンク : https://acoustics.jp/annualmeeting/program/ 登壇者 石塚 賢吉 株式会社RevComm プリンシパルリサーチエンジニア 筑波大学大学院博士後期課程卒業。博士(工学)。日本HP株式会社にて通信事業者向けのシステム開発、株式会社ドワンゴで全文検索システムの開発などに従事。2019年12月株式会社RevComm入社。音声認識、音声感情認識、全文検索システムの研究開発を行なっている。 → 過去記事一覧
はじめに 生成AIの急速な発展により、エンジニアを取り巻く環境は激変しています。特に注目すべきは、Coding Agentの登場によって多くの場面で生成AIが実用的なコードを書けるようになったことです。実際、単純な機能実装からバグ修正まで、Coding Agentに任せる仕事が日々増えています。 一方で、Coding Agentを効果的に使いこなすには、エンジニア自身の高い技術力が不可欠です。適切な指示を出し、生成されたコードの品質を評価し、システム全体の整合性を保つためには、従来以上の深い理解が求められます。また、アーキテクチャ設計、技術選定、チームマネジメントなど、未だにCoding Agentには任せられない重要な業務も数多く存在しています。 さらに重要なのは、Coding Agentが人間の成長速度を大きく上回るペースで発展し続けていることです。エンジニアとして社会に貢献する活動を続けたいなら、自身もこれまで以上のスピードで成長することが不可欠になっています。 そこで本記事では、このような時代背景を踏まえ、私がエンジニアとして社会に貢献し続けるために生成AI(主にChatGPT)を活用している手法を紹介します。 1. 自分には何が足りていないか、何をやるべきかをChatGPTを使って考える 1.1 エンジニアグレードの客観的評価 まず、自分の現在地を正確に把握するために、ChatGPTにエンジニアのグレード判定を依頼しました。以下のプロンプトを使用しています。 私に質問をしながらソフトウェアエンジニアとしてのレベルを把握し、シニアになるために必要な知識や技術を整理してください。 質問は問題でも構いません。 質問は20問として、1回に1つ行なってください。似たような質問にならないようにしてください。 このプロンプトの特徴は、対話式で進行し、ChatGPTが1つずつ質問を出してくることです。1回に1つ、という指示を入れることで1回のやりとりで大量に質問してくるのを防いでます。例えば以下のような質問がありました。 「システム設計でキャッシュを導入する際に、キャッシュの有効期限(TTL)を設定する場合、どのような考慮事項がありますか?具体例を交えて説明してください。」 「データベースにインデックスを作成する際、インデックスを追加することによる利点と欠点をそれぞれ1つずつ挙げ、具体的なシナリオを用いて説明してください。」 「分散システムで 'CAP定理'(Consistency, Availability, Partition Tolerance)の3つの要素を簡単に説明し、それぞれがトレードオフとなる理由を述べてください。」 回答後、ChatGPTからは以下のような評価を受けました: あなたのスキルは、すでに中堅エンジニアとして高いレベルにあります。ただし、シニアエンジニアとして求められる「より大規模で複雑なシステムの設計」や「チーム全体を見据えた意思決定」のスキルをさらに伸ばす余地があります。 このような客観的な評価により、自分の現在地と次のステップが明確になりました。 1.2 マンダラチャートによる成長戦略の立案 次に、エンジニアリングマネージャー、ソフトウェアアーキテクト、スタッフエンジニアの3つのキャリアパスを想定したマンダラチャートの作成を依頼しました。 マンダラチャートとしたのは、必要だと思われる要素を網羅的に抽出したかったからです。 5年目のSaaS開発のソフトウェアエンジニアが、*** になるためのマンダラチャートを作成してください ※ *** の部分には、ソフトウェアアーキテクト、エンジニアリングマネージャー、スタッフエンジニアなどのキャリアパスを入れて実行します。 このプロンプトは非常にシンプルで、キャリアパスごとに別々に実行することで、それぞれに特化したマンダラチャートを生成できます。SaaS開発の経験を前提としているため、より実践的で具体的なスキルセットが提案されます。 例えばソフトウェアアーキテクトでは以下のようなチャートが生成されました。 1.3 ポジショニングマップによる現状分析 マンダラチャートの各項目を抽象化し、強み・弱みを横軸、機会の多さを縦軸にしたポジショニングマップを作成しました。また、NLP(神経言語プログラミング)の学習5段階をカスタムした成熟度の自己評価を各項目に対して行いました。 無意識的未経験(知らないしやったこともない)→ 緑色 意識的未経験(知っていてもやったことがない)→ 青色 意識的経験(知っていてやったことはある)→ 黄色 意識的有能(考えるとできる)→ 赤色 この評価をマッピング時の色に反映させることで、優先的に取り組むべき領域を視覚化しました。 3,4ヶ月くらい前に作成したのですが、青色も業務でやっていないことはないので、もっとやっていないといけないという厳し目の自己評価だったのだろうと思います。 1.4 実践への落とし込み ポジショニングマップの分析結果から、主に真ん中よりも左側にある青色の項目は比較的機会もあり伸ばす領域なのでここに注力すると良さそうなことがわかります。これによって業務では以下の2点を意識してタスクを取ったり、課題を探したりしやすくなりました。 課題が明確で、取り組めるもの x 自分が伸ばしたい領域のもの 課題がありそうだが明確ではなく、調査が必要なもの x 自分が伸ばしたい領域のもの 加えて、設計は開発と比べてたくさんやれる機会があるわけではないので、後述する Architectural Katas を始めることにも繋がりました。 2. Architectural KatasとChatGPTを使ってシステムデザインを学ぶ 2.1 Architectural Katasとは Architectural Katas( https://www.architecturalkatas.com/ )は、プログラマーがプログラマーとしての実践の機会を必要とするのと同じように、ソフトウェアアーキテクトにはソフトウェアアーキテクトとしての実践の機会が必要であるという願望から生まれたサイトであり実践方法です。社内で設計を学びたいならこういうものがある、と教えていただきました。 Architectural Katas 自体はグループで行う前提となっていますが、私は試しに ChatGPT とやってみて一定価値を感じたのでその方法を共有します。 2.2 ChatGPTによるシステムデザイン面接の実践 ChatGPTで Architectural Katas を実施する方法は、海外のBig Tech 企業でもよくあるシステムデザイン面接を参考にしています。ChatGPT を面接官としてどのように振る舞って欲しいかをプロンプトで指定します。 私はソフトウェアエンジニアとして優れたアーキテクトになるべくArchitectural Katas を活用して勉強したいです。 あなたは以下の指示に従い、ソフトウェアデザインの試験官のように振る舞って私の学習をサポートしてください。 - まず、私が Architectural Katas の設問をソフトウェアデザインの試験官であるあなたに与えます。 - 与えられた設問について曖昧な部分は、試験官であるあなたが定義する必要があります。定義をするだけで私が質問するまで公表する必要はありません。 - 私が設問について質問した場合にソフトウェアデザインの試験官として適切な返答をしてください。 - その返答を参考に、私は設問に対して設計を行い、回答を仕上げます。 - 私の回答に対してレビューを行い、良い点、更に考慮すべき点、改善案を提示してください。更に考慮すべき点、改善案については、その考慮をしたり案を出したりすることが、どれくらいのレベルのソフトウェアエンジニアに求められるか、難易度がわかるようにしてください。 このプロンプトによって、ChatGPT は問題を把握しても質問や回答があるまでは、何かをユーザーに教えるようなことはありません。ユーザーは問題を確認し、前提条件や想定されるユーザー数やリクエスト数やデータサイズなどを見積って ChatGPT に認識の確認するとズレていないか回答してくれ、設計をして提出するとフィードバックを得ることができます。 2.3 実際のイメージ オンラインでドキュメントを作成・編集・バージョン管理・印刷でき、手動・自動決済もできるサービスをつくるというような問題が出たときの、実践模様を書いていきます。 システムプロンプトを渡したあと、問題を渡します。 ChatGPT が返事をしてきます。(確認の観点を先回りして発言していますが、そういうときもあります。) 以下のように前提条件を確認すると、ChatGPTから返事が来ます。長いので割愛します。 実際に設計してみた回答を ChatGPT に送ります。 ChatGPT からフィードバックが返ってきます。 良い点も挙げてくれますが、改善すべき点もレベルと一緒に教えてくれます。 以下はフィードバックの抜粋です。 総評 スコープ内のコア機能(ドキュメント編集・バージョン管理・印刷ジョブ・決済)を 4 つのサービスに分割しており、役割も明確です。 エッジに CDN を置き、リアルタイム更新は WebSocket 専用サービスに任せる設計も理にかなっています。 初期利用者「数千 → 数百万」を見据え、キューで印刷ジョブを非同期化しているのもスケールに有効です。 ~~~ さらに考慮すべき点 & 改善案 バージョン管理の実装(Mid) S3 バージョニング+version_id を RDS に参照保存差分保存(delta/DDS)で容量削減 リアルタイム編集方式(Senior) 単独編集ならロック制御 (optimistic/pessimistic) 将来協調編集を見込むなら OT/CRDT ライブラリ採用 印刷ゲートウェイ不足(Senior) Print Job Service の下に Printer Gateway を追加:IPP over TLS、Secure Release (PIN/NFC)、ドライバー隔離 (コンテナ) 決済の信頼性 (Mid) PSP からの Webhook 受信 → 冪等キーで payment_status 更新 サービス間通信は Outbox/Polling で二重送金を防止 更に考慮すべき点 & 改善案 で知らなかったものについてはチケット化し、別でインプットしたり掘り下げることで、幅広い知識と観点を養えることを期待しています。 週1回を目安に始めて、まだ1ヶ月程度ですがそれでも知らなかった技術に出会い、自分が見落としやすい観点を見つけ、どこの知識が足りないのか、知っていると思っていて実はちゃんと知らなかったものは何かがわかるので、とてもおすすめです。 3. 英会話をChatGPTとやる 3.1 背景と課題 生成AIによるサービスが急増するなかで英語を学ぶことに時間を使うかどうかは、人によって判断が分かれると思います。私自身は、特に予定はありませんが社会に貢献し続けるために将来的に海外で働いたり大学院に進学したりを、自分のしたいタイミングでできるよう、IELTS を定期的に受験しています。IELTSは Reading、Listening、Writing、Speaking 全てのテストがあり、Speaking は自己紹介とインタビュー、スピーチ、ディスカッションという3つのパートに分かれています。現在の私のスコアはOverall 6.0ですが、Speaking だけが5.0とボトルネックになっている状況です。弊社には英語話者も在籍していますが、私が所属しているチームや関わる方々の多くは日本語話者なので、日常的に英語を話す機会はありません。 3.2 ChatGPTをIELTS試験官として活用 弊社には多言語学習・会話レッスン補助制度があるので、オンライン英会話を受けていた時期もありましたが、ChatGPTに音声による会話機能が出てから、ChatGPTに移行しました。現在は以下のようなプロンプトを利用しています。 IELTS Speaking Testの練習を行います。以下の条件に従って練習を全力で手伝ってください。 - "OK, I'm ready for {パート番号}" と入ったらそのパートの問題を1つランダムに出題してください。 - 私が英語で回答するので、それに対してAnswer(私の回答), Mistakes and Corrections, Natural Corrections, High Score Rephrasing, Additional Points, Example Answer を整理して教えてください このプロンプトによってフィードバックが6つの項目に体系化されます。 スピーキング力の向上のためには、話す内容が思いついていないのか、英語に変換できないのか、を分離するため、英語で話す以前にそもそも日本語で同じ質問をされたときに十分な回答ができるのか?ということを振り返る必要があります。 このプロンプトでは、「Additional Points」を挙げるよう指示しており、そのとき自分が思いつかなかった観点を教えてくれるので、話す内容を思いつけていないことを自然と振り返れるようになっています。 また、話す内容を思い付いているけれどもっと他の言い回しはなかったのだろうか、というときには「High Score Rephrasing」で、同じ内容をより高いスコアが期待できる表現に変換してくれるため、自分では思いつかなかった表現を教えてくれます。 3.3 実際のイメージ 技術進歩がコミュニケーション方法にもたらした影響について聞かれたときの模様を書いていきます チャット形式になっていますが、実際は音声でやりとりしています。 回答を終えるとChatGPT からフィードバックが返ってきます。 実際に私が話した内容について、三人称単数形になっていないことや、より自然な言い回しを教えてくれています。加えて私が言及していない内容に触れ、それらを含めた回答を作成してくれています。 以下はフィードバックの抜粋です。 Mistakes and Corrections Instead of "technology enable us," it should be "technology enables us." "Communicate remotely" is great, but you can simplify "in the past, we could not communicate with other people remotely" to "in the past, we couldn't communicate over long distances." Instead of "bring our content of talking or texting," you can say "transmit our conversations and messages." ~~ Additional Points You could also mention how technology has introduced new forms of communication, like video calls, social media, and instant messaging. ~~ Example Answer “In my opinion, the most significant impact of technology on communication is the removal of physical barriers. In the past, people had to be in close proximity to interact, but today we can instantly transmit our conversations and messages across the globe. Furthermore, innovations like instant messaging, social media platforms, and video calls have transformed how we connect: we can chat in real time via apps, share life updates and multimedia on social networks, or hold face-to-face conversations with someone thousands of miles away. These developments have made communication more seamless, diverse, and engaging than ever before.” 3.4 実践結果 ChatGPTから添削された文章を音読して空で言えるようにする、というのを始めてからボキャブラリーが増えたと思います。優先度が下がって数ヶ月やらない期間があったりしながら、1年くらいこの方法でやっています。定期的に、といいましたが1年弱はIELTSを受けていない気がするので、そろそろ受けようかと思います。 まとめ 本記事では、エンジニアとして社会に貢献し続けるための、私の実践例を紹介しました。 おそらく似たようなアプローチを取っている方も多いでしょうし、中にはこれらの手法をアプリケーション化して運用している方もいらっしゃることと思います。 生成AIを業務に取り入れることはもちろん、うまく使って継続的に成長し続けることが重要だと思うので、ぜひ何かの参考になれば幸いです。
RevComm で音声処理を中心に研究開発を担当している加藤集平です。 私はADHD(注意欠陥・多動症)という障害を抱えています 。ADHDを持つ人は日常生活でさまざまな困難に直面するもので、もちろん仕事をしていく上でも困難があります(障害を持たない人と同じやり方では困難に直面します)。私も例に漏れずさまざまな困難に直面していますが、 2022年12月に本ブログで公開した記事 では、当時それらの困難にどのように対処しようとしていたのかを紹介しました。また、弊社の働き方の特徴であるフルフレックス・フルリモート環境が及ぼす影響についても取り上げました。 本記事では、 前回の記事の公開から2年半が経過し状況が変化したことを踏まえ 、私が現在直面している困難とそれに対する対処、弊社の働き方の特徴であるフルフレックス・フルリモート環境が及ぼす影響について 改めて整理してお伝えします 。 内容に前回の記事と重複するものが多くありますが、この記事単体で読んでいただけるようにしたいと思ってのことです(ADHDの方は特に、複数の記事を行き来するのはつらいかと思います)。ご容赦くださいませ。 加藤集平(かとう しゅうへい) シニアリサーチエンジニア。RevCommには2019年にジョインし、音声処理を中心とした研究開発を担当。ADHDと付き合いつつ業務に取り組む2児の父。 個人ウェブサイト X → 過去記事一覧 本記事を読むにあたっての注意 私はADHDを専門とする医師でもその他の専門家でもありません。 ADHDに関する正確な情報は、専門家の発信をご参照ください 。 ADHDの症状(困難に直面するポイントあるいは本人の特性)は人によって異なる ことが知られています。また、同じ症状に対して同じ対処が有効とは限りません。本記事で取り上げるのは私の症状と私が実践している対処法であり、 万人に通用するものではありません 。 ADHDの診断は医師のみが行うことができます 。自己判断はかえって困難を増大させるおそれがあります(例えば、症状が似た違う病気かもしれません)。他人を勝手にADHDだと断定することについても同様に、本人および周囲の困難を増大させるおそれがあります。 ADHDとは ADHD(注意欠陥・多動症)とは、精神障害のうち発達障害に分類されるものの一つです。発達障害とは、生まれつきみられる脳の働き方の違いにより、幼児のうちから行動面や情緒面に特徴がある状態です。発達障害の中でもADHDは不注意・多動性・衝動性の3症状を主な特徴としており、それらの症状の影響で日常生活・学業・仕事などに様々な困難が生じることがあります。かつては子供だけに見られる病気と考えられていましたが、現在では大人になっても症状が継続する場合があることが知られています。 私とADHD 私がADHDと診断されたのは、2017年(30歳頃)のことでした。当時は前年に発症した強迫性障害という病気の治療のために心療内科に通っており、通院・治療の過程でADHDであることが発覚しました。 強迫性障害とあわせて障害者手帳の交付を受けています(現在はカード型が選択できるようになりました)。 思えば物心ついた頃から忘れ物や物をなくすのは日常茶飯事で、部屋は常に散らかっており、コツコツ勉強することは決してなく、学校のテストではよく不注意で失点をしていました。 大人になり仕事を始めてからは、順序立てて仕事を処理することが苦手で締切に間に合わなかったり、他人に出した指示をすっかり忘れたり、単調な作業ですぐに寝てしまったり、体調に波があるために毎日8時間パフォーマンスを出し続けることが難しかったりして、仕事の遂行に支障をきたしていました。また、かつての勤務先では毎日オフィスに出社していたのですが、電話番をすることや周囲の話し声(雑音)が苦痛で頭がいっぱいになったりといった困難もありました。 診断を受けてからは、定期的に通院の上、服薬および日常生活の中での治療を続けています。 この状況は2年半前と特に変わっていません 。 フルフレックス・フルリモート環境における恩恵と困難 ADHDを持つ人にとって、弊社のようなフルフレックス・フルリモート環境は適しているのでしょうか?私の場合は恩恵のほうが大きく勝りますが、フルフレックス・フルリモートならではの、オフィス出社にはない困難も感じています。これらの恩恵と困難を紹介します。 恩恵 体調の波を吸収しやすい(フルフレックス) ADHDを持つ人すべてに当てはまるわけではないと思いますが、私は体調に比較的大きな波があります。つまり調子のいい日と悪い日の仕事のパフォーマンスの差が大きくなるおそれがあります。 現在は2年半前と比べてパフォーマンスの差は小さくなりましたが、依然として多少の波はあります 。 フルフレックスの制度下では体調に合わせて比較的柔軟に勤務時間(長さおよび時間帯)の調整ができます。当然、打合せやプロジェクトの進行状況などの制約条件があるので完全に自由に調整できるわけではありませんが、それでも 毎日絶対に決まった時間に仕事をしなければならない状況よりは安心感が違います 。 静かな環境で仕事ができる(フルリモート) 自宅や家族構成などの諸条件に左右されますが、オフィスよりも静かな環境を用意することができる場合があります(私は用意できています)。私の場合は雑音が多い環境が苦手なので、 静かな環境は集中力を高めるのに役立っています 。 困難 自主的にやる気を管理する必要がある(フルフレックス・フルリモート) フルリモート環境では、オフィスのように衆人環視の中で仕事をするわけではありません。人の目がない環境だとどうしても怠けやすくなります。しかし怠けすぎると、仕事の成果が出ず問題になります。 逆に、やる気に満ちあふれている時には過剰な長時間労働をするおそれもあります。フルフレックスの制度下では(法令の範囲内で)極端な時間の使い方をすることも不可能ではありませんが、健康の観点や、組織の一員として周囲と協調しつつ働く観点からは望ましくないでしょう。 ADHDを持つ人にはやる気のある時とない時の差が激しい人が少なくありませんが、やる気のない時に最低限のやる気を出すことと、やる気に満ちあふれている時に働きすぎないようにする工夫は、体調を整えつつ安定したパフォーマンスを出す上で重要だと考えています。 私の場合は、先述したように子育ての関係で ある程度決まった時間に働くことになっており、2年半前と比較して体調とパフォーマンスをより安定させることに繋がっていると感じています 。 家事などの私生活と仕事のバランスを意識して取る必要がある(フルフレックス・フルリモート) フルフレックス・フルリモート環境では、仕事中にいつでも私用を挟むことができます。特に在宅勤務の場合は、仕事の合間に家事をすることは珍しくないでしょう。 ところが、ADHDを持つ人には一度集中したら他のタスクになかなか移り難い傾向のある人が少なくありません(過集中)。つまり、家事を始めたらいつまでも仕事に戻れなかったり、逆に仕事に熱中して家事が疎かになったりすることがあります。 仕事に戻れないことは当然問題になりますし、家事が疎かになることも私生活においては問題になりえます。 フルフレックス・フルリモートとは関係のない一般的な困難 膨大なタスクを適切に管理する 前回の記事を公開した2年半前と異なり、現在の私は多数のプロジェクトに少しずつ関わるという働き方をしています。このような働き方においては、 自ずとタスクの数は増え、しかもそれらを同時並行でこなす 必要があります。ADHDを持つ人にとって、多くのタスクを同時並行でこなすことは一般に苦手なことの一つだと思います。私も苦手なので、対処する必要があります。 私が困難に対処している方法の例(★は前回の記事以降に新たに始めたこと) 「自主的にやる気を管理する必要がある」に対して 仕事前に着替える 在宅勤務では、打合せがなければパジャマのままでも仕事をすることが可能です。打合せがあっても、下半身はパジャマのままでもバレません。しかし、私の場合は気持ちを仕事に切り替えるために、仕事前に必ずパジャマから着替えることにしています。オフィスに出社していれば通勤時間で気持ちを切り替える人も多いかと思いますが、在宅勤務は通勤時間がないので代わりにしっかり着替えることにしています。パジャマよりも寝心地が悪いので、安易な昼寝を防止する効果も期待できます。 筆者の仕事着の例。下は白いですがジーンズです。 専用の仕事部屋で仕事をする 誰もが実践できる方法ではありませんが、私はほぼ仕事専用の部屋を用意しています。私生活の場と空間を分けることで、仕事に対するやる気を出しやすくなります。やる気に乏しい日でも、机に座ってしまえば仕事ができることは珍しくありません。 特にADHDの人には、トリガーが大事であることは経験的にご理解いただけるかと思います 。 ★毎日ある程度決まった時間に働く フルフレックスの精神に反すると感じられる方もいらっしゃるかもしれませんが、 自分で決めた時間でいいので毎日ある程度時間を決めて働くことは、体調の安定に資すると実感しています 。 私の場合は、2年半前と違って子供が保育園に通っている時間に大半の仕事を終える必要があり、自ずと毎日ある程度決まった時間に働くことになっています。自ら望んで決まった時間に働いているというよりは、そうせざるを得なかったので決まった時間に働いているだけなのですが、結果としてはいい方向に作用していると感じています。 ★原則として規定の労働時間しか働かない フルフレックスでも、1か月あたりの規定の労働時間や、それを超えた場合の残業という概念はあります。毎日ある程度決まった時間に働き、原則として1か月あたりの規定の労働時間の範囲内で働くことで、 締切効果 (締切直前だけ頑張れるアレです)が働きます。 私の場合は、子供が保育園に通っている時間に大半の仕事を終える必要があるため、自ずと労働時間に制限がかかります。この 制限をうまく活かして、集中力を発揮する ことに成功しています。 ★ポモドーロ・テクニックを活用する ポモドーロ・テクニックとは、25分間の作業と5分間の休憩を繰り返すことで、集中力を維持して生産性を向上させるやり方です。 定期的なリズムと短時間の集中が、やる気をうまく発揮し、かつ疲れすぎないことに繋がっていると感じています 。 2年半前には既にリマインダーで過集中を防いでいましたが、対処がより洗練された方法になった感じです。 「家事などの私生活と仕事のバランスを意識して取る必要がある」に対して 私生活の時間はカレンダーをブロックしてしまう 前回の記事では、昼食を取り損ねるために昼食の時間 (12:00 – 13:00) のカレンダーをブロックしていました。 現在は昼食が自然と取れるようになったのでその時間はブロックしていませんが、 起きてから子供を保育園に送るまでの時間と、迎えの後に子供が寝るまでの時間はブロック しています。こうすることで、その時間はしっかりと家事・育児に集中することができます。 「膨大なタスクを適切に管理する必要がある」に対して ★タスクをリマインダーで管理する さまざまなベストプラクティスがあると思いますが、私が現在使っているのは広く知られたやり方の一つであるGetting Things Done (GTD) という方法です。単にタスクを列挙するだけでは特にADHDを持つ人にはつらいかと思いますが、GTDではタスクをシステマティックに管理することができます。タスクを管理する媒体は人それぞれですが、私はiPhoneのリマインダーを利用しています。 詳細は書籍や解説記事をご覧いただければと思いますが、個人的にはGTDを採用することで認知負荷が劇的に減り、 目の前のタスクに安心して集中できる ようになりました。これにより、より多くのタスクを、より少ない疲労で完了させることに成功しています。 2年半前はやっていたが、現在はやらなくなったこと 朝起きたら布団を畳む 仕事中に寝ることがほとんどなくなったので、やめました(行儀としては畳むべきかもしれません)。 コンテンツブロッカーを使う コンテンツブロッカーを使わなくてもネットサーフィンをすることが減ったので、やめました。 適度に打合せを入れる 状況の変化により意識しなくても打合せが入るようになったので、わざわざ入れることはやめました。 労働時間をトラッキングする タスク管理で十分に仕事が終わるようになったので、やめました。 スマートスピーカーに頼る(タイマー・アラーム) 2年半前は「洗濯をしたのに、つい仕事に熱中して何時間も干し忘れる」ことへの対策でタイマーを活用していましたが、自動で乾燥まで行ってくれる洗濯機に買い替えたので、やめました。 おわりに 2年半前とは生活も業務内容も随分と変わり、それに伴って困難や対処も変わりました。 全体としては、日々工夫を重ねることで、より上手に対処できるようになったと感じています 。 なお、以上の困難や工夫は私にとって一部であり、他にも仕事・私生活を問わず様々な困難に対してさまざまな工夫を日々行っています。また、周囲の方々の支援なくして良好な社会生活を送ることはできません。 改めて家族やRevCommの仲間をはじめとする周囲の方々に感謝いたします 。
RevCommで音声処理を中心とした研究開発を担当している加藤集平です。昨年3月に第二子が生まれて、1年間の育児休業を取得しました。私は男性ですが、男性の育児休業取得率・取得期間ともにここ数年急速に伸びている実感があります。しかし、1年間の育児休業を取得する例はまだまだ少ないように思います。本記事では、 男性として実際に1年間の育児休業を過ごした経験から、正直どうだったのか について共有します。 加藤集平(かとう しゅうへい) シニアリサーチエンジニア。RevCommには2019年にジョインし、音声処理を中心とした研究開発を担当。ADHDと付き合いつつ業務に取り組む2児の父。 個人ウェブサイト X → 過去記事一覧 なぜ1年間の育児休業を取得することにしたのか? 第一子のときは1か月間だった 第一子もRevComm在籍中に生まれたのですが、その際は里帰り出産への同行+1か月間の育児休業(生後2か月目の1か月間)という形でした。なお、妻は産後休暇と、子供が1歳になるまでの育児休業を取得しました。 ところが、1か月間の育児休業は、 家庭内環境の激変による部屋の模様替えと片付け(出産・育児で物が増えたので片付けてスペースを空ける必要がありました) いただいた出産祝の管理(内祝を返す必要があるので表にしていました) 来客対応(第一子ということもあり多かったのです) に追われているうちに、あっという間に終わってしまいました。妻が体力の回復と子供の世話に集中するのに役に立ったとは思いますが、 自ら子育てをしたか?と言われると疑問符の残る状況 でした。 さらに、生まれたばかりの子供というのは、昼夜を問わず「寝る→起きる→泣く→授乳→寝る→…」を3時間〜4時間ごとに繰り返します。私は昼間業務があるので夜はまとめて寝かせてもらっていましたが、妻は細切れにしか睡眠が取れないので毎日大変です。個人差は大きいと思いますが、第一子の場合、夜に比較的まとまった睡眠を取るようになったのは生後4か月目頃からでした。 第二子は当初3か月間の予定だったが、1年間に延長した というわけで、第二子の誕生に際して、 当初は3か月間 の予定で育児休業に入りました。なお、妻は今回も産後休暇と、子供が1歳になるまでの育児休業です。私が育児休業中は、2人で休業していることになります。 ところが、実際にやってみると3か月間もやはりあっという間に過ぎていきます。上に第一子がいますから子育ての難易度としては上がっており、なかなか心身の調子が整いません。育児休業も3か月目に入る頃、そのように思い悩んでいました。色々考えましたが、だったら思い切って1年間休んだほうが、自分にとっても家族にとっても会社にとっても最終的には利益になるのではないかと考え、当初の計画を延長し、 1年間の取得 とすることにしました。 1年間何をしていたのか? 子供の成長フェーズによって世話の負担は変わる 男性が1年間育児休業を取得する例は現状では少ないので、何をしていたのか気になると思います。まず言えるのは、 生後1年間の子供の世話の負担は一様ではない ということです。あくまで私の子育ての経験 (n=2) の話ではありますが、おおむね以下のようでありました。 1〜3か月: とにかく忙しくて眠い およそ3か月目までは、前述のとおり子供はしょっちゅう寝たり起きたりして、授乳間隔も短い状態です。養育者全員(うちの場合は夫婦)が毎回付き合う必要はありませんが、睡眠はどうしても浅くなりがちです。また、 公的手続や儀礼が多数あり 、それらを確実にこなすにはかなりの労力が必要です。実際、第一子の時は完璧にやり終えたのですが、第二子の時は健康保険の手続を失念して少々困ったことになりました。今回は第二子ということで、赤ちゃん返りする上の子のケアも欠かせません。とにかく忙しくて眠い日が続きました。 4〜6か月: 空き時間が最も多い 生後4か月頃になると、(うちの場合は)昼夜のリズムがだんだんとできてきて授乳間隔も伸び、したがって大人も比較的寝られるようになります。子供はというと、首はすわったけれど、ハイハイはできないような時期です。つまり、その場から動くことはできません。 子の安全を常に監視する必要はありますが、危険は比較的少ない状態 です。うちの場合は、子供が息をしているか感知するセンサーを布団の下に仕込んでいました。そうすると、少しばかり空き時間ができます。この空き時間に心身を休めることも重要ですが、余裕があれば他のことをすることができます。 7〜9か月: 動き始めて目が離せなくなる 個人差が大きいですが、この時期にハイハイ(ずり這いを含む)を始める子が多いと思われます。 ハイハイを始めると、子供が危険に遭遇する確率はグッと上がります 。ずっと目を離さないのも疲れるので実際にはベビーサークルに入れたりしていましたが、目を離せない時間が増えるのは間違いなく、空き時間はその分減ります。 10〜12か月: 復職準備 スムーズに復職したければ、どうしても準備が必要 です。最低限、すっかり変わってしまった生活リズムを元に戻すことは欠かせません。子育てに何とか慣れてきたところではありますが、最後の3か月は、だんだんと復職に向けた動きをすることになるでしょう。 私がしていたこと 上記のように、育児休業といえど100%の時間を子供の世話に費やすわけではありません(うちは夫婦で休業していたので、少なくともそうです)。むしろ、業務と子育ての両方で忙しい普段よりもまとまった時間が取れることもあるでしょう。私は以下のようなことをしていました。 業務に関する学び直し 私は業務で機械学習(特に深層学習)を扱いますが、深層学習が普及したのは私が大学院を卒業した後であり、体系的に学ぶ機会がありませんでした。深層学習はそれ以前の機械学習とは根本的に性質の異なる面があり、いわゆるアンラーニングが必要な状況でもありました。 そこで、育休の4〜6か月目を中心に、オンライン動画の講座を利用して、深層学習について体系的に学ぶことにしました。ついでに、機会学習のための数学・機械学習全般・敵対的生成ネットワーク (GAN)・自然言語処理・デジタル信号処理についても同様に学び直しを行いました。 さらに、復職準備の頃には、かねてより伸ばしたいと思っていたソフトスキルの講座を受け始めました(現在継続中)。 なお、育児休業中に育児以外のことをするのは賛否あるかと思います。個人的には、育児休業法の理念に照らしても、空き時間を活用してよりよい復職のための準備を行うことは、決して悪いことではないと考えています。 育児・介護休業法 第三条の2 子の養育又は家族の介護を行うための休業をする労働者は、その休業後における就業を円滑に行うことができるよう必要な努力をするようにしなければならない。 実家への長い帰省 いつもお盆と正月に短期間滞在するだけの実家ですが、普段より長めの帰省を行いました(私はついでに学会の聴講をしていたのですが…)。うちの場合は夫婦ともに実家が遠方であり、貴重な機会となったと考えています。 復職はスムーズだったか?復職して思うことは? 復職は比較的スムーズだった 復職準備には前述のように3か月間を充てることができたので、余裕を持って準備をすることができました。うちの場合は4月1日にいきなり第二子を保育園に預ける生活が始まるわけでもちろん混乱はありましたが、何とか乗り切ることができました。なお、私は子供の誕生日の前日(3月)の復職、妻は慣れ保育(慣らし保育)が終わった4月中旬の復職と復職時期をずらすことで、一つ一つの変化を小さく抑える工夫をしました。 また、1年間業務から離れていましたが、子育てに忙しい中でも比較的余裕を持って心身を整えることができ、慌てずに復職することができました。もっとも、2人の子供を育てる中で、多少のことでは動じない心がいつの間にか身についていたのかもしれません。 復職して思うことはたくさんある 1年間の育児休業を取得するという選択は、 1年間業務に従事しないという選択 でもあります。嫌々業務に取り組んでいるなら別ですが、業務を離れるということは多少なりともつらい思いがありますし、悔しいものでもあります。 比較的スムーズに復職することができて、現状では育児休業に入る前よりもむしろよいパフォーマンスを出せていると実感しています。これは、業務を長期間離れることでよい意味で心身がリセットされたこと、空き時間に行った学び直しを応用できていること、子育てを通じてよりタフになったことなどが関係していると考えています。 一方で、もし業務を離れていなければ、自分なりの貢献ができたのではないかと思う事象に遭遇することもあります。離れていた時間が戻ってくることはありませんし、よいパフォーマンスで今から貢献するしかないのですが、申し訳なくさみしく思う気持ちはゼロではありません。 長期の育児休業がハードスキルとソフトスキルに与える影響について 真摯に子育てに取り組んだことでソフトスキルが伸びた あくまで私の個人的な経験でしかありませんが、 真摯に子育てに取り組んできたことは、ソフトスキルを伸ばすことに役に立った と考えています。私の場合は妻と共同で行っているので、妻との間で子育ての方針から細かな点まで合意形成をする必要があります。たとえ夫や妻がいなくても、子育ては一人でできるものではなく、どうしても周りの人や社会的な支援を受けながら行うことになります。夫や妻がいれば、互いに助け合うことになります。どのような支援を受けるか、あるいはどのように助け合うか、 主体的に考え調整する 必要があります。 このような作業は時に面倒で大きな労力を要しましたが、真摯に取り組んだことで、特に合意形成や調整といった面でソフトスキルが伸びたと考えています。前述のように別途ソフトスキルの講座は受けていますが、それだけでは足りない実践力が身につきました。さらに言うと、 子の成長を見守るというのも大事な経験 でした。無理やり成長させようというのは不可能です。粘り強く見守る力も身についたのかもしれません。 ハードスキルはキャッチアップが必要 一方で、 ハードスキルについてはキャッチアップする必要 がありました。私の取り組んでいる機械学習(特に深層学習)の世界は、まさに日進月歩。本当に1年間離れていれば浦島太郎です。絶え間なくキャッチアップする必要はありませんが、どこかでキャッチアップする必要はあります。離れるのは育児休業だから仕方ありませんし、浦島太郎になることは別に悪いことではないと思いますが、元に戻るためにはキャッチアップが必要です。ただ、個人的にはハードスキルは勉強すればいい話でソフトスキルを伸ばすほうが難しいと考えているので、 長期の育児休業を取ったことは(少なくとも結果的には)よかった と考えています。 さいごに: 自分の選択を尊重しよう 育児休業を取得するという選択も、取得しない選択も、どちらも大きな決断を伴います。それが1年間という長期間であればなおさらです。何かを選択するということは、他の選択肢を諦めるということであり、何かを得る代わりに何かを失うということです。 私は第二子の誕生にあたり1年間の育児休業を取得する選択をしました。会社から一人が一年間離れるというのは、決して小さなことではなく、さまざまな人に影響を与える選択でした。しかし、(選択をした当時としては)最善を尽くした選択だったと考えており、結果として現在はよい状態とパフォーマンスで業務にあたることができています。もちろん、万人に1年間の育児休業を勧めているわけではなく、他の方は他の選択をされるかもしれません。育児休業に限った話ではありませんが、大きな選択であればあるほど 最善を尽くして選択を行い、少なくとも自分自身がその選択を尊重される ことを願います。
はじめに 昨今、パッケージなどのエコシステムをターゲットとしたサプライチェーン攻撃が増加しています。 各種プログラミング言語向けのパッケージマネージャーやレジストリにおいては、インストールするパッケージのバージョンを固定したり、チェックサムを検証したりすることにより、サプライチェーン攻撃被害のリスクを軽減する仕組みが導入されています。 もちろんGitHub Actionsにおいても、サードパーティー製のワークフローを利用する場合に、サプライチェーン攻撃の被害を受けるリスクが生じますが、このような仕組みを導入するには少々作業が必要になります。 そこで本記事では、pinactを利用して簡単にGitHub Actionsにおけるサプライチェーン攻撃被害のリスクを軽減する方法を紹介します。なお、本記事で紹介する内容は、弊社で最近実施されたものです。 なぜ実施したのか? 弊社の一部リポジトリで利用している tj-actions/changed-files において、サプライチェーン攻撃が発生しました: www.stepsecurity.io 具体的には、 tj-actions/changed-files の運用のためのボットで利用していたPATが流出し、それを悪用して攻撃者による悪意のあるコミットが紛れ込み、各タグなども改竄されてしまったようです。 時差の関係もあって運よく被害は発生しなかったのですが、仮に被害が発生した際の影響は大きなものになりえます。今後のリスク軽減のために、以下で紹介する対策を実施することにしました。 実施したこと pinact の導入 pinact とは? GitHub Actionsのワークフローにおける各種依存アクションのバージョンを固定してくれるCLIツールです。 github.com 使い方 pinact はHomebrewなどで導入可能です。 $ brew install pinact 導入したら、以下を実行します。 $ pinact run すると、リポジトリ内の各種ワークフローにおける依存アクションを検出し、以下のようにバージョンの定義を書き換えてくれます。 steps: - - uses: actions/setup-node@v4 + - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0 with: node-version: '22' - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinactの実行結果 このように、 pinact によってサードパーティー製ワークフローのコミットハッシュによる参照を強制することで、意図せず改竄されてしまったバージョンのワークフローが実行されるリスクを軽減することができます。 pinact の導入に関する選択肢 今回、 pinact の導入に当たって実現したいことは以下の内容です。 CI (GitHub Actions) で pinact run --check を実行したい。 pinact run --check を実行すると、GitHub Actionsのワークフローにおいてバージョンが固定されていない依存アクションを検出してくれます。 目的はサプライチェーン攻撃へのリスクを低下させることなので、できるだけ安全な方法で pinact を導入したい。 その上で、 pinact を導入する方法としては以下のあたりが選択肢として考えられそうです。 Homebrew メリット 公式から Homebrew/actions が提供されており、GitHub Actionsからの利用が容易である。 pinactによって推奨されるインストール方法の一つである。 使い慣れているユーザーが比較的多いと思われる。 デメリット インストールするツールのバージョンを固定できない。 aqua メリット 公式から aquaproj/aqua-installer が提供されており、GitHub Actionsからの利用が容易である。 pinactによって推奨されるインストール方法の一つである。 依存ツールのバージョンの固定が可能である(後述)。 aqua-checksums.json によるチェックサムの管理・検証が可能である。 デメリット Homebrewや後述する mise と比較すると、まだ使用例は多くないと思われる。 mise メリット 作者によって jdx/mise-action が提供されており、GitHub Actionsからの利用が容易である。 aqua バックエンド を利用すれば pinact を導入可能である。 aqua と同様に、依存ツールのバージョンの固定が可能である。 今回導入予定の pinact 以外に、Node.jsなどのさまざまなランタイムのバージョン管理もできる。 デメリット aquaバックエンドは、実験的サポートの段階である (※)。 今回の目的とメリットを踏まえると、 aqua か mise がよさそうです。 mise の aqua バックエンドはまだ実験的サポート(※) であったことと、 aqua-checksums.json によるチェックサム管理の仕組みがあることなどから、本記事では aqua を試してみることにしました。 ※ ⚠️ 記事の執筆を開始した当初は、 mise の aqua バックエンドはまだ実験的サポートの段階でしたが、本記事の公開時点ではすでに実験的という表記は削除されています ( af36cfd )。 今回は aqua を採用しましたが、 mise も選択肢として有望だと思います。 aqua とは CLIツール向けのパッケージマネージャーで、サプライチェーン攻撃に対する対策が強く意識されているのが特徴です。 slsa-verifier によりパッケージが検証される( SLSA はサプライチェーン攻撃への保護を目的としたフレームワーク・仕様です) チェックサムが検証される プロジェクトごとに依存ツールのバージョンが固定される 導入方法 ローカルに導入する際は、Homebrewや公式のインストーラーなどで導入可能です。 $ brew install aqua GitHub Actionsで aqua を利用したい場合は、 aquaproj/aqua-installer を使用します。 - uses : aquaproj/aqua-installer@e2d0136abcf70b7a2f6f505720640750557c4b33 # v3.1.1 with : aqua_version : 'v2.46.0' skip_install_aqua : "true" - uses : actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with : path : '~/.local/share/aquaproj-aqua' key : v2-aqua-installer-${{runner.os}}-${{runner.arch}}-${{hashFiles('aqua.yaml')}} restore-keys : | v2-aqua-installer-${{runner.os}}-${{runner.arch}}- aqua の設定 まず、設定ファイルである aqua.yaml を生成します。 $ aqua init (推奨) aqua.yaml を生成したら、まずチェックサムの検証を有効化することを推奨します。 # aqua.yaml checksum : # See https://github.com/aquaproj/aquaproj.github.io/blob/4709985f3f10c1c257fc812d9f791ab595cad266/docs/reference/config/checksum.md#require_checksum for details enabled : true require_checksum : true # 以下は必要に応じて調整します (`aqua update-checksum`の実行時に該当の環境向けのチェックサムが登録されます) supported_envs : - darwin - linux/amd64 この aqua.yaml と後述する aqua-checksums.json は、バージョン管理に含めます。 パッケージの追加 aqua g -i <パッケージ名> でパッケージを追加できます( aqua のパッケージレジストリは こちら にあります)。 # 例) pinactを追加 $ aqua g -i suzuki-shunsuke/pinact # 例) actionlintを追加 $ aqua g -i rhysd/actionlint すると、 aqua.yaml にパッケージの定義が追加されます。 packages : - name : suzuki-shunsuke/pinact@v2.0.4 - name : rhysd/actionlint@v1.7.7 aqua.yaml で定義されている各種パッケージをインストールするには、下記コマンドを実行します。 $ aqua i ( 推奨 ) aqua.yaml でチェックサムの検証を有効化している場合、依存パッケージの追加や更新などを行なった際に、 aqua update-checksum で aqua-checksums.json を更新しておく必要があります。 $ aqua update-checksum actionlint を導入する pinact に加えて、 actionlint もGitHub Actionsにおけるセキュリティを改善する上で有用なツールです。今回はあわせて導入します( actionlint についてはすでにWeb上に情報が十分にあるため、詳細は割愛します)。 actionlint は aqua でも導入可能です。 $ aqua g -i rhysd/actionlint pinact と actionlint をGitHub Actionsで実行する aqua によって pinact と actionlint を導入し、GitHub Actionsによって実行を自動化します。 name : Lint workflows on : push : branches : - main paths : - '.github/**/*.yml' - '.github/**/*.yaml' pull_request : branches : - main paths : - '.github/**/*.yml' - '.github/**/*.yaml' jobs : lint : name : Lint workflows runs-on : ubuntu-latest steps : - uses : actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses : aquaproj/aqua-installer@e2d0136abcf70b7a2f6f505720640750557c4b33 # v3.1.1 with : aqua_version : 'v2.46.0' skip_install_aqua : "true" - uses : actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with : path : '~/.local/share/aquaproj-aqua' key : v2-aqua-installer-${{runner.os}}-${{runner.arch}}-${{hashFiles('aqua.yaml')}} restore-keys : | v2-aqua-installer-${{runner.os}}-${{runner.arch}}- - name : Run pinact run : pinact run --check - name : Run actionlint run : actionlint 一連の対策によってGitHub Actionsに関するサプライチェーン攻撃へのリスクを軽減することが期待されます。 おわりに 以下のブログ記事では、今回紹介したものよりもさらに踏み込んだ対策が紹介されています(ちなみにこの記事は、今回紹介した aqua や pinact の作者の方によって書かれています)。 zenn.dev 参考になる記事だと思いますので、興味がありましたらぜひ上記の記事もご覧ください。
2025年4月12日 (土)に開催される「ふりかえりカンファレンス2025」にバックエンドエンジニアの大谷紗良が登壇します。 イベント概要 名称: ふりかえりカンファレンス2025 日程: 2025年4月12日 (土) 9:00〜18:00 会場: 株式会社 フィードフォース confengine.com 登壇情報 大人数会議のカオス化を防ぐふりかえりフレームワークを考えてみた 「15人以上で1年規模のプロジェクトのふりかえりを1時間でしよう!!!!!!(えっ」 大人数での会議はカオス化しがちだと思います。 基本的にはまず適切な人数にできないかを考えるのが良いと思いますが、 大人数の会議で抱えている問題は本当に大人数の会議だけの問題でしょうか? ファシリテーターの腕に自信がなくてもフレームワークの工夫でカオス化を乗り切る一例を紹介します。 登壇者: 大谷紗良 株式会社RevComm バックエンドエンジニア 日時: 2025年4月12日 (土) 16:35〜16:40 参加申し込み 参加申し込みやイベントの詳細などについては下記ページから確認いただけます。オンラインでの参加も可能なため奮ってご参加ください。 retrospective.connpass.com
はじめに Recoilからの移行先について Jotai について 移行の方針 1. MiiTel Phoneにおいて使用されているRecoilのAPIを一通り洗い出して、それぞれのAPIにおけるJotaiへの移行方法を調査する 2. 依存関係としてjotaiパッケージを追加する 3. MiiTel Phoneにおける特定の機能において、RecoilからJotaiへの移行を実施する 4. 検証環境で様子を見る 5. 問題のない機能から順次、リリースを実施する 6. 3〜5のステップを繰り返す 7. 一通り移行が完了したら、依存関係からrecoilパッケージを削除する Recoil と Jotai の各APIの対応と移行方法について jotai/utilsモジュールについて atom() useRecoilState() useSetRecoilState() useRecoilValue() useResetRecoilState() selector() 非同期selector useRecoilValueLoadable() useRecoilCallback() useRecoilRefresher_UNSTABLE() Atom Effects atomFamily()/selectorFamily() 悩みどころ/ハマったところ atomFamily()/selectorFamily()の移行について RecoilとJotaiにおける更新タイミングの微妙な差異について おわりに 参考 はじめに 今年のはじめにRecoilのGitHubリポジトリがアーカイブされ、話題になりました。 > This repository has been archived by the owner on Jan 1, 2025. It is now read-only. https://t.co/uiv2W0Hd04 — Daishi Kato (@dai_shi) 2025年1月5日 github.com 弊社で提供している MiiTel Phone においても、フロントエンドの状態管理のためにRecoilを採用していました。 Recoilのメンテナンス停止に伴い、今後、バグや脆弱性などに関するリスクが増加してしまう可能性があることや、React v19や周辺ライブラリのアップデートなどに当たって問題となる可能性もあります。そのため、RecoilからJotaiへの移行を実施することにしました。 Recoilからの移行先について Recoilから別のライブラリへの移行については、すでに他の企業でも事例がありそうです。 speakerdeck.com 弊社においては以下の理由から、Recoilからの移行先としてはJotaiが最も有力な選択肢と判断し、移行先として選択しました。 JotaiはAPIや思想がRecoilに近く、移行コストを抑えやすい RevCommにおける他のサービスにおいてすでにJotaiの利用実績があり、十分に安定して動作することが期待できる JotaiのリポジトリやJotaiの作者である dai-shi さんの ブログ などにおいてアウトプットが活発に行われており、今後、採用事例などがより増えることが期待できる Jotaiはドキュメントが充実しており、またRecoilと比較するとライブラリのサイズも大幅に小さいです。調べたいことや気になることなどがあった際に、ドキュメントやソースコードなどから調査が行いやすいと考えられます。 Jotai について Jotaiが開発された経緯や概要については、作者である dai-shi さんによる解説記事が公開されています。これらの記事を参照するのがおすすめです。 zenn.dev zenn.dev zenn.dev 移行の方針 まず、RecoilからJotaiへの移行にあたって、ビッグバンリリースは避けたいと考えていました。できる限り、すでに存在する機能への影響やバグの発生を最小限に抑えつつ、段階的に移行が行えると理想的です。そこで、以下のような方針で移行を進めていくことにしました。 MiiTel Phoneにおいて使用されているRecoilのAPIを一通り洗い出して、それぞれのAPIにおけるJotaiへの移行方法を調査する 依存関係として jotai パッケージを追加する MiiTel Phoneにおける特定の機能において、RecoilからJotaiへの移行を実施する 検証環境で様子を見る 問題のない機能から順次、リリースを実施する 3〜5のステップを繰り返す 一通りの機能で移行が完了したら、依存関係から recoil パッケージを削除する 1. MiiTel Phoneにおいて使用されているRecoilのAPIを一通り洗い出して、それぞれのAPIにおけるJotaiへの移行方法を調査する まずは、Jotaiへの移行が現実的に可能であることや具体的な移行方針を決めやすくするために、MiiTel PhoneにおけるRecoilの各APIの使用方法を洗い出して、それらのAPIのJotaiへの移行方法を調査しました。幸いなことに、Jotaiが提供する jotai/utils モジュール (詳細は後述します) において、Recoilが提供する機能はほとんどカバーされていることがわかりました。調査を通して懸念や移行方法などは概ね把握できたため、実際に移行を進めていくことにしました。 2. 依存関係として jotai パッケージを追加する RecoilからJotaiへの移行に当たり、重要度や影響度合いの低い機能から優先して段階的に移行が行えると理想的です。幸いなことに、JotaiはRecoilと比較してフットプリントがかなり小さいです。そこで、移行期間中は jotai パッケージと recoil パッケージがプロジェクトに共存した状態で移行を進めていくことにしました。 recoil パッケージについては、一通り移行が済んでから削除します。 3. MiiTel Phoneにおける特定の機能において、RecoilからJotaiへの移行を実施する 重要度や影響が低めの機能から優先して、順次、RecoilからJotaiへの移行を実施します。MiiTel Phoneでは Qase というサービスを使って手動のテストケースを管理しています。そこで、Qaseによってテストがしやすい単位ごとにプルリクエストを分割して、各機能におけるRecoilを使用したコードをJotaiへ段階的に移行していきました。 また、もしAtom Effectsや selector などのRecoilにおける高度な機能を使用している箇所については、移行に先立ってユニットテストを用意しておくとより安全に移行が行えます。 React Testing Library の renderHook() を使用して該当の atom / selector もしくはそれらを利用するカスタムフックに対してテストを記述しておくと、Jotaiへ移行する際のテストの書き換えをできる限り抑えられて良いと思います (下記の例だと、 RecoilRoot と useRecoilState() をそれぞれJotaiの Provider と useAtom() へ置き換えるだけで移行できるはずです) import { renderHook } from '@testing-library/react' ; import { RecoilRoot, useRecoilState } from 'recoil' ; import { act } from 'react' ; describe ( 'preferencesState' , () => { afterEach (() => localStorage. clear ()); it ( 'persists state to localStorage' , () => { const { result } = renderHook( () => { const [ preferences , setPreferences ] = useRecoilState(preferencesState); return { preferences , setPreferences } ; } , { wrapper : RecoilRoot, } , ); expect (localStorage. getItem ( 'preferences' )).toBe( null ); act(() => result. current .setPreferences( { theme : 'dark' } )); expect (result. current .preferences).toEqual( { theme : 'dark' } ); expect (localStorage. getItem ( 'preferences' )).toBe( JSON . stringify ( { theme : 'dark' } )); } ); } ); 4. 検証環境で様子を見る 今回のRecoilからJotaiへの移行に当たって、新機能の開発や要望への対応などはストップせずに、それらのタスクと並行しながら進めました。RecoilからJotaiへの移行を実施した機能については、すぐにはリリースをせずに一週間ほど検証環境にデプロイをして様子を見ることにしました。 5. 問題のない機能から順次、リリースを実施する 検証環境で様子を見て特に問題がなさそうであれば、他の機能や修正などと合わせて少しずつJotaiへ移行したコードをリリースしていきました。 6. 3〜5のステップを繰り返す 一通り移行が完了するまで、関連した機能ごとにRecoilからJotaiへの移行を行い、少しずつ段階的にリリースを進めていきます。重要度や影響度の高い機能については移行を後回しにして、最後にまとめて移行をすることにしました。 7. 一通り移行が完了したら、依存関係から recoil パッケージを削除する すべてのRecoilのコードをJotaiへ移行し終えたら、ようやく recoil パッケージを削除できます。今回の移行に当たって段階的に移行を進めていたことや、RecoilとJotaiは全体的に思想やAPIがよく似ていて移行が行いやすかったこともあり、特に障害が発生することもなく無事に移行をすることができました。 Recoil と Jotai の各APIの対応と移行方法について Recoil の各APIごとに、Jotaiへの移行方法について紹介いたします。 jotai/utils モジュールについて Recoilが提供する高度なAPIの多くは jotai/utils モジュールによってカバーされています。この記事でも jotai/utils モジュールから提供されているAPIをいくつか紹介しますが、紹介していない機能もまだまだあります。 jotai/utils モジュールは atom の活用方法の観点からもとても参考になるため、一度、内容を調べてみるのも良いかもしれません。 atom() atom の移行は単純で、基本的には key を削除して、デフォルト値を atom() の引数に指定するよう書き換えることで移行できます。 - import { atom } from 'recoil'; + import { atom } from 'jotai'; - export const isLoadingState = atom<boolean>({ - key: 'users/isLoading', - default: false, - }); + export const isLoadingState = atom(false); ただし、 useResetRecoilState() を使用している atom についてはこの方法では移行できず、後述する atomWithReset() を使用するとよいです。 useRecoilState() Recoilの useRecoilState() はJotaiの useAtom() へそのまま置き換えることができます。 + import { useAtom } from 'jotai'; - import { useRecoilState } from 'recoil'; ... - const [counter, setCounter] = useRecoilState(counterState); + const [counter, setCounter] = useAtom(counterState); useSetRecoilState() Recoilの useSetRecoilState() はJotaiの useSetAtom() へそのまま置き換えることができます。 + import { useSetAtom } from 'jotai'; - import { useSetRecoilState } from 'recoil'; ... - const setIsLoadinge = useSetRecoilState(isLoadingState); + const setIsLoading = useSetAtom(isLoadingState); useRecoilValue() Recoilの useRecoilValue() はJotaiの useAtomValue() へそのまま置き換えることができます。 + import { useAtomValue } from 'jotai'; - import { useRecoilValue } from 'recoil'; ... - const isLoading = useRecoilValue(isLoadingtate); + const isLoading = useAtomValue(isLoadingState); useResetRecoilState() useResetRecoilState() を使用した atom をJotaiへ移行するには、 jotai/utils モジュールによって提供される atomWithReset() を使う必要があります。 - import { atom } from 'recoil'; + import { atomWithReset } from 'jotai/utils'; - export const isLoadingState = atom<boolean>({ - key: 'users/isLoading', - default: false, - }); + export const isLoadingState = atomWithReset(false); atomWithReset() によって定義された atom は、 jotai/utils の useResetAtom() によってデフォルト値へのリセットが可能です。 + import { useResetAtom } from 'jotai/utils'; - import { useResetRecoilState } from 'recoil'; ... - const resetIsLoading = useResetRecoilState(isLoadingState); + const resetIsLoading = useResetAtom(isLoadingState); selector() Recoilの selector については、Jotaiにおいては derived atom によって同様のことが実現できます。例えば以下のような selector があったとします: import { atom, selector } from 'recoil' ; const countState = atom( { key : 'count' , default : 0 , } ); const isEvenState = selector( { key : 'isEven' , get : ( { get } ) => get(countState) % 2 === 0 , } ); const doubledCountState = selector( { key : 'doubledCount' , get : ( { get } ) => get(countState) * 2 , } ); この場合、Jotaiでは以下のようにして同じことが実現できます: import { atom } from 'jotai' ; const countState = atom< number >( 0 ); const isEvenState = atom< boolean >( ( get ) => get(countState) % 2 === 0 , ); const doubledCountState = atom< number >( ( get ) => get(countState) * 2 , ); 非同期 selector Recoilの非同期 selector については、 非同期Atom を作成することで同様のことが実現できます: // Recoilの非同期selector import { selector } from 'recoil' ; export const myProfileState = selector< MyProfile >( { key : 'myProfile' , get : async () => { const profile = await client.getMyProfile(); return profile; } , } ); 以下のように atom() に Promise を返却する関数を渡すことで、同様のことが実現できます: // Jotaiの非同期atom import { atom } from 'jotai' ; export const myProfileState = atom< Promise < MyProfile >>( async () => { const profile = await client.getMyProfile(); return profile; } , ); useRecoilValueLoadable() Recoilの useRecoilValueLoadable() は非同期 selector に関する状態を問い合わせるためのAPIです: const loadable = useRecoilValueLoadable< MyProfile >(myProfileState); switch (loadable. state ) { case 'loading' : return < Loading /> ; case 'hasError' : return < Error error = { loadable.contents } /> case 'hasValue' : return < Profile profile = { loadable.contents } /> ; } Jotaiにおいて同様のことを実現したい場合、まず jotai/utils で提供されている loadable() というAPIによって非同期 atom をラップします: import { loadable } from 'jotai/utils' ; import { atom } from 'jotai' ; export const myProfileState = atom< Promise < MyProfile >>( async () => { const profile = await client.getMyProfile(); return profile; } , ); export const myProfileLoadableState = loadable(myProfileState); loadable() によって返却された atom に対して useAtomValue() を呼ぶことで、 useRecoilValueLoadable() とほぼ同様のことが実現できます: const loadable = useAtomValue(myProfileLoadableState); switch (loadable. state ) { case 'loading' : return < Loading /> ; case 'hasError' : return < Error error = { loadable. error } /> ; case 'hasData' : return < Profile profile = { loadable.data } /> ; } useRecoilCallback() Recoilの useRecoilCallback() によって、状態を柔軟に操作することができます: const runTaskIfNeeded = useRecoilCallback( ( { snapshot } ) => async ( taskId : TaskId ) => { const isTaskInProgress = await snapshot.getPromise(isTaskInProgressState); if (isTaskInProgress) return ; snapshot. set (isTaskInProgressState, true ); try { await runTask(taskId); } finally { snapshot. set (isTaskInProgressState, false ); } } , [ runTask ] , ); useRecoilCallback() は jotai/utils モジュールから提供される useAtomCallback() に置き換えることができます: import { useAtomCallback } from 'jotai/utils' ; // ... const runTaskIfNeeded = useAtomCallback( useCallback( async ( get , set , taskId : TaskId ) => { const isTaskInProgress = get(isTaskInProgressState); if (isTaskInProgress) return ; set(isTaskInProgressState, true ); try { await runTask(taskId); } finally { set(isTaskInProgressState, true ); } } , [ runTask ] ), ); 注意点として、Jotaiの公式ドキュメントにも記載されていますが、 useAtomCallback() に渡す関数は、基本的に上記のように useCallback() を適用しておく必要があります ( https://github.com/pmndrs/jotai/blob/v2.12.2/docs/utilities/callback.mdx ) もし、 useRecoilCallback() の引数として渡されるオブジェクト ( CallbackInterface )の refresh 関数に依存している場合は、次に紹介する方法へ移行する必要があります。 useRecoilRefresher_UNSTABLE() Recoilの useRecoilRefresher_UNSTABLE() は非同期 selector を再評価したい場合に利用できます。 const refresh = useRecoilRefresher_UNSTABLE(myProfileState); Jotaiで同様のことが実現したい場合は、まず jotai/utils モジュールで提供される atomWithRefresh() を使用して atom を作成します: import { atomWithRefresh } from 'jotai/utils' ; export const myProfileState = atomWithRefresh< Promise < MyProfile >>( async () => { const profile = await client.getMyProfile(); return profile; } , ); そして、この atom に対して useSetAtom() を呼ぶことで、 useRecoilRefresher_UNSTABLE() と同等のことが実現できます: const refresh = useSetAtom(myProfileState); Atom Effects JotaiにはAtom Effectsに相当する機能はありません。しかし、 atom を2つ用意するなどの工夫をすることで、Atom Effectsと同様のことが実現できます。 例えば、以下のように状態の更新時にAtom Effectsを活用してロギングを行なっている atom があったとします: import { atom } from 'recoil' ; export const countState = atom< number >( { key : 'count' , default : 0 , effects : [ ( { onSet } ) => { onSet(( newValue ) => { logger. info ( 'countState has been updated to %d' , newValue); } ); } , ] , } ); この場合、Jotaiにおいては2つの atom を組み合わせることで同様のことが実現できます。このように2つ以上の atom を組み合わせて複雑なことを実現するパターンは jotai/utils モジュールの内部においても頻繁に利用されています。 import { atom } from 'jotai' ; const baseAtom = atom< number >( 0 ); export const countState = atom< number , [ number ] , void >( ( get ) => get(baseAtom), ( get , set , newValue : number ): void => { set(baseAtom, newValue); logger. info ( 'countState has been updated to %d' , newValue); } , ); 他にも、Jotaiの jotai/utils モジュールでは atom の状態を localStorage へ同期してくれる atomWithStorage などのAPIも提供されています。このようなAPIを活用することで、RecoilにおいてAtom Effectsを利用していたコードを置き換えることも可能です。 atomFamily() / selectorFamily() 注意: ここでは jotai/utils の atomFamily() を使用した例を紹介しますが、後述するように jotai/utils の atomFamily() はユースケースによってはメモリリークを引き起こす可能性があるため、適切なタイミングでクリーンアップする必要があります。 import { atomFamily } from 'recoil' ; export const taskState = atomFamily< Task , string >( { key : 'task' , default : ( id ) => ( { id , state : 'todo' } ), } ); jotai/utils モジュールから atomFamily() が提供されており、概ね同じような用途で使用できます: import { atom } from 'jotai' ; import { atomFamily } from 'jotai/utils' ; export const taskState = atomFamily(( id : string ) => { return atom< Task >( { id , state : 'todo' } ); } ); また、 selectorFamily() についても似たような方法で移行ができます: import { selectorFamily } from 'recoil' ; export const tasksByProjectIdState = selectorFamily< string | undefined , Array < Task >>( { key : 'tasksByProjectId' , get : ( projectId : string ) => async ( { get } ) => { const filter = get(tasksFilterState); const tasks = await fetchTasksByProjectIdAndFilter(projectId, filter); return tasks; } , } ); Jotaiにおいては、 jotai/utils モジュールから提供される atomFamily() と非同期 atom を併用することで、概ね同じことが実現できます: import { atom } from 'jotai' ; import { atomFamily } from 'jotai/utils' ; export const tasksByProjectIdState = atomFamily( async ( projectId : string ) => { return atom< Array < Task >>( ( get ) => { const filter = get(tasksFilterState); const tasks = await fetchTasksByProjectIdAndFilter(projectId, filter); return tasks; } , ); } , ); atomFamily() はデフォルトでパラメーターの比較を同値性に基づいて行います。そのため、パラメーターとしてプリミティブ値ではなくオブジェクトを指定したい場合は、 atomFamily() の第2引数にオブジェクト同士の深い比較を行う関数を指定する必要があります。 Jotaiの公式ドキュメントでは fast-deep-equal を使用した例が掲載されています。 github.com 悩みどころ/ハマったところ atomFamily() / selectorFamily() の移行について 先ほども紹介しましたが、Jotaiが提供する jotai/utils モジュールには atomFamily() というAPIがあります。これは名前が示す通り、Recoilの atomFamily() とよく似た振る舞いをしてくれます。 しかし、一つ注意点があります。Jotaiの公式ドキュメントにおいても記載されていますが、 atomFamily() は内部において作成された atom の一覧を Map を用いて管理しています。この Map で保持されている atom の一覧は、該当の atom が unmount されたとしても破棄されることはないため、ユースケースによっては意図せぬメモリリークが発生してしまう可能性があります。 github.com このメモリリークへの対策としては、以下のいずれかが考えられると思います: AtomFamily#remove を用いて、不要になった atom を削除する Recoilからの移行に当たり、 atomFamily() の使用をやめる 1. AtomFamily#remove を用いて、不要になった atom を削除する Jotaiの atomFamily() が返却する AtomFamily オブジェクトは remove というメソッドを提供しています。 atomFamily() の内部では Map を使ってパラメーターと作成された atom の紐付けを管理しています。 github.com AtomFamily#remove メソッドにパラメーターを指定することで、 Map から指定されたパラメーターのエントリーを削除することができます。適切なタイミングで AtomFamily#remove を呼ぶことで、 Map に無制限にエントリーが残り続けてしまう問題を回避できます。 AtomFamily#getParams メソッドと併用することで、例えば、 atomFamily() が内部にキャッシュするエントリー数に制限を掛けることなどもできそうです。 また、 AtomFamily オブジェクトには setShouldRemove というメソッドもあります。このメソッドには、 atom の作成日時 及び atomFamily() に渡されたパラメーターの2つの値を引数として受け取り、 boolean を戻り値として返却する関数を指定します。この関数が true を返却した場合、 atomFamily() の内部で管理されている Map から該当のパラメーターに対応するエントリーが削除されます。古くなったパラメーターに紐づく atom を削除したいケースにおいて役立ちます。 MiiTel Phoneにおいては、できる限り移行のコストを軽減することや、移行に当たって意図せぬリグレッションなどを防止することを優先して、この方法を採用しました。しかし、Jotaiの使い方としては、この方法よりも次に紹介する方法の方がより理想的なのではないかと思っています。 2. Recoilからの移行に当たり、 atomFamily の使用をやめる Jotaiにおいて、 atom() から返却される値の実体はプレーンなオブジェクトです github.com Jotaiの Store はこのプレーンなオブジェクトから状態へのマッピングを WeakMap によって管理しています。 公式ドキュメントでも言及されているように、 useMemo() などとの併用は必要ですが、Jotaiの atom はコンポーネントのレンダリングフェーズにおいても作成することが可能です。この性質をうまく活用すると、 atomFamily() を使用せずに同様のことをより直感的に実現することも可能そうです。 github.com github.com MiiTel Phoneにおいても、徐々にこの方式への移行を検討していきたいです。 RecoilとJotaiにおける更新タイミングの微妙な差異について RecoilからJotaiへ移行するに当たって、微妙なタイミングのずれから useEffect が意図したタイミングで発火せずに不整合が起きてしまうバグに遭遇しました。 しっかりとした調査ができているわけではないので自信はないですが、Recoilは useSyncExternalStore を使っているようで、それが原因で再レンダリングなどのタイミングが微妙にJotaiとは異なっている可能性があるのではないかと推測しています。 github.com おわりに Recoilはとても便利なライブラリであり、MiiTel Phoneでもたくさん活用していました。そのため、メンテナンスが停止されてしまい残念には思いましたが、これほどの規模や需要を持つライブラリをメンテナンスし続けることは実際には非常に大変なことなのではないかと思いました。 今回、移行先として選択したJotaiは、全体的にとてもシンプルで使い勝手の良いライブラリだと思いました。ドキュメントも充実しており学習も行いやすく、とても良いライブラリです。Recoilのメンテナンス停止に伴い、今後さらに人気が増すのではないかと思います。 参考 github.com blog.logrocket.com
概要 こんにちは、RevCommのエンジニア、加藤(涼)です。今回はMiitelでAWS CognitoでSAML/OIDC SSOを汎用化した件についてお話ししようと思います。 背景 MiitelではOIDCの認証プロトコルかつ、GoogleとMicrosoft Azureのプロバイダーを用いたSSOのみにしか対応していませんでした。しかし今回、お客様からのご要望に伴いSAMLプロトコルやその他のプロバイダーに対応することとなりました。まず各用語について確認していきたいと思います。 OIDC とは OIDC (OpenID Connect)は、OAuth 2.0をベースにした認証プロトコルです。OAuth 2.0は RFC 6749 にて規定されています。 OIDCの主な特徴は以下の通りです: OAuth 2.0の認可フローに加えて、IDトークン(JWT)を使用したユーザー認証情報のやり取り Claim(ユーザー属性情報)の標準化された取得方法を提供 Authorization Code Flow、Implicit Flow、Hybrid Flowなどの認証フローが規定されており、用途に応じて適切なフローを選択します。 SAML とは SAML(Security Assertion Markup Language)は、XMLベースの標準規格で、組織間でユーザー認証情報を安全に交換するためのプロトコルです。主にエンタープライズ環境での Single Sign-On (SSO) に使用されます。SAMLは RFC 7522 で規定されています。 SAMLの主な特徴は以下の通りです: XMLベースのメッセージフォーマットを使用し、セキュリティアサーションを交換 IdP(Identity Provider)とSP(Service Provider)の間で認証情報を安全に伝送 SAMLでは、ユーザーがサービスにアクセスする際、IdPが認証を行い、認証結果をSPに対してXML形式のアサーションとして送信します。これにより、ユーザーは一度の認証で複数のサービスにアクセスすることが可能になります。 AWS Cognito とは AWS Cognitoは、AWSが提供するユーザー認証・認可サービスです。Webアプリケーションやモバイルアプリケーションにおけるユーザー管理、認証、アクセス制御を実装することができます。 Miitelではユーザー管理にCognitoを利用しています。SAML/OIDCプロバイダーをユーザープールに設定することができるため、今回は自前で実装せず、その機能をメインで使うことにしました。 構成 before 課題を再確認します。 GoogleとMicrosoft Azure のプロバイダーにしか対応していない。(DBやAPIでEnumでの管理) OIDC プロトコルのみ ユーザーがMiitel管理者にSSO設定依頼をする必要があった。 これらの問題の影響でSAMLでのご要望に応えられなかったり、SSOの設定に時間がかかり、ヒューマンエラーが発生することもありました。 After 上記の課題から ユーザのSSO設定フローを変更 SAMLの設定を可能に GoogleとMicrosoft Azure以外のプロバイダーをサポート するよう変更しました。 変更後のイメージはこのような感じです。 ユーザーはMiitel Admin上でSSOを自由に設定できるようになりました。 OIDC/SAMLおよびプロバイダーの種類に制限がなくなりました。 開発時の注意点 AWS Cognitoの1ユーザーにリンクされたID数は5つまで 開発時に陥ったエラーです。Cognitoでは1ユーザーに紐づくIDプロバイダーの数が5つまでに設定されています。 これはSSOログインをする度にユーザー属性の identities にログインのプロバイダーが登録されていき、6つ目のプロバイダーではログインしようとするとできないようになっています。 実際6以上のプロバイダーを使うユーザーはほぼほぼいないのですが、開発時には何個も登録するためこのクォータに引っかかりました。 上記の解決方法ですが aws cognito-idp admin-disable-provider-for-user を使うことによりユーザーとSSOの連携を解除できます。 create/update-identity-providerのProviderDetailsオプションが複雑 ユーザープールにidentity providerを設定するには create-identity-provider / update-identity-provider で可能です。 ただ、SAML, OIDCでパラメータが大きく異なります。 ProviderDetails というオプションがあるのですが、この中にSAMLとOIDCの詳細を全て追加します。OIDCはスネークケースのキー名なのに対し、SAMLはパスカルケースになっています。 https://docs.aws.amazon.com/cli/latest/reference/cognito-idp/create-identity-provider.html —saml-provider-details / —oidc-provider-details に名前を分けてほしいですね。 まとめ 今回はMiitelでOIDC/SAMLプロトコルの両方に対応し、プロバイダーも複数選べるようになった件についてお話ししました。 認証方法については「ログイン・パスワード」・「Google」というのは良くみますが、SAMLプロトコルを用いたSSOにしか対応していないお客様も少なくありません。本記事が参考になれば幸いです。 以上、アカウントチームより、加藤がお話しさせていただきました。 参考 https://auth0.com/intro-to-iam/saml-vs-openid-connect-oidc https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-saml-idp.html https://docs.aws.amazon.com/cli/latest/reference/cognito-idp/admin-disable-provider-for-user.html https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/quotas.html#resource-quotas
2025年1月21日(火)に開催されたML@Loft #16にリサーチエンジニアの石塚が登壇しました。 今回はイベントの振り返りとして登壇資料と登壇者の感想を紹介します。 ml-loft.connpass.com 登壇振り返り 発表タイトル: トーク解析AI MiiTelの音声処理について 発表スライド: https://speakerdeck.com/ken57/aws-yin-sheng-ji-pan-moderu-tokujie-xi-ai-miitelnoyin-sheng-chu-li-nituite 発表者: 石塚賢吉(→ 過去記事一覧 ) 登壇者の感想 ML@Loft#16音声基盤モデルのイベントでの発表は三件で、第一発表者の株式会社レアゾン・ホールディングスの末永様からは音声認識処理の基礎的な話や、商用利用可能な高精度音声認識モデルを含めたプロダクト「ReazonSpeech」を活用した音声認識の実践的な方法についての講演がありました。そして、第二発表者のKotoba Technologies, Inc.の笠井様からは、OpenAIの音声認識モデルWhisperの高速化や、TextToSpeech、同時通訳システムに関する講演がありました。 双方とも非常に興味深いお話であったと思います。 最後に、株式会社RevCommの私から、トーク解析AI MiiTelの音声処理について講演をさせていただきました。 発表後は3つのグループに分かれて議論を行いました。 主に話者ダイヤリゼーション機能や音声感情認識機能の実応用について、興味深い議論ができたと思います。 総じて、非常に有意義な会であったと思っています。 今後も、折を見てこのような会に参加し、社外の方々とも意見交換をさせていただきたいです。
はじめに 2025年01月21日(火)に開催される「ML@Loft #16 音声基盤モデル」にRevCommプリンシパルリサーチエンジニアの石塚 賢吉が登壇します。 イベント概要 ml-loft.connpass.com 名称: ML@Loft #16 音声基盤モデル 日程: 2025年01月21日 (火) 会場: AWS Startup Loft Tokyo 主催: アマゾンウェブサービスジャパン合同会社 登壇者 石塚 賢吉 株式会社RevComm プリンシパルリサーチエンジニア 筑波大学大学院博士後期課程卒業。博士(工学)。日本HP株式会社にて通信事業者向けのシステム開発、株式会社ドワンゴで全文検索システムの開発などに従事。2019年12月株式会社RevComm入社。音声認識、音声感情認識、全文検索システムの研究開発を行なっている。 → 過去記事一覧 参加登録 参加登録やイベントの詳細などについてはconnpassページより確認いただけます。奮ってご参加ください。 ml-loft.connpass.com
皆さんこんにちは。RevComm の CTO の平村 ( id:hiratake55 , @hiratake55 ) です。今年もあと数日となりました。この記事では、2024 年の RevComm の開発チームの振り返りを行いたいと思います。 この記事は、 RevComm Advent Calendar 2024 の 25 日目の記事です。 1 月: 組織体制変更 1 月には、エンジニア組織の組織変更を行いました。2023 年 12 月まではマトリックス型の組織を採用し、フロントエンドやサーバサイド、インフラ、モバイルなど、それぞれの技術スタックの専門性を活かしながら各開発プロジェクトに所属して開発を進める組織形態でした。 しかし主力の MiiTel Phone に加え、MiiTel Meetings (オンライン会議解析) や MiiTel RecPod (対面商談解析) など製品が増えてきたこともあり、プロダクト単位の開発組織への変更を行い、よりわかりやすい組織体制に変更しました。新しい組織体制ではプロダクト別の組織に加えて、組織横断で最適化を行う CTO 室で構成されています。 2 月: 開発者向けサイト MiiTel Developers を発表 開発者向けサイトの MiiTel Developers を発表しました。2023 年には、Incoming Webhook や Outgoing Webhook を開発者向けにリリースし、MiiTel にデータを登録したり、外部のサービスに連携することが容易になりました。 MiiTel Developers は、このような機能を開発する開発者向けにチュートリアルや API ドキュメントを整備することで、Developer Friendly な製品へ前進しました。 MiiTel Developers 日本語版: https://developers.miitel.com/ MiiTel Developers 英語版: https://developers.en.miitel.com/ www.revcomm.co.jp 3 月: MiiTel Phone Mobile バージョン 3 をリリース MiiTel Phone Mobile バージョン 3 をリリースしました。バージョン 3 では、プラットフォームを Flutter に変更するため、全てのコードを書き直しました。約 1 年間にわたって技術検証と開発を進め、これまで数多く寄せられていた多数の要望にも併せて対応しました。 MiiTel Phone Mobile は、バックグラウンドやロック画面での処理など、VoIP アプリならではの苦労もありましたが、4 名のチームでリリースを成功しました。 4 月: 会話コーチング機能のリリース 「会話コーチング機能」は、生成 AI を活用して、ユーザーの会話の傾向が他のユーザーと比較してどのような状態にあるのかを、システムが自然な文章で会話の改善点を学校の通知表のように届ける機能です。 これまでは、ダッシュボードを操作して傾向を把握する必要がありましたが、このリリースにより自分自身の会話の改善点やよくできている点を簡単に具体的に把握することができるようになりました。 www.revcomm.co.jp 5 月: SMS 機能のリリース MiiTel の SMS 機能は通話終了後や通話中に SMS をユーザーが取引先のお客様に送信できる機能です。また、コールセンターでオペレーターにつながるまでに時間を要する場合や、オペレータの数が限られている場合に、SMS を送信しお客様をお待たせしないようにするための機能です。 この機能の開発は多くのメンバーが関わりました。音声通信システムを開発するチーム、ブラウザ上の通話アプリを開発するチーム、応対履歴データを管理するチーム、料金計算を担当するチーム、キャリアから回線の仕入れを行うチームなど社内の多数のチームがコラボレーションすることで、短期間でリリースを成功させました。 www.revcomm.co.jp 7 月: MiiTel Scan To Call のリリース MiiTel Scan To Call をリリースし、記者発表会を開催しました。MiiTel Scan To Call は、QR コードをスキャンするだけで、通話料無料、アプリのインストールを必要とせず、モバイルブラウザから電話による通話が可能な革新的なサービスです。また、どの媒体を見て発信したかをトラッキングできるため、これまで困難とされていた電話の広告効果測定が可能になりました。 www.revcomm.co.jp 8 月: 全社オフサイトミーティング RevComm では約 260 名の社員がフルリモート・フルフレックスで業務にあたっています。オフサイトミーティングでは、フルリモート・フルフレックス勤務のレブコムにとって、年に1度、全社員が通常業務から離れ、部署を超えたコミュニケーションを取ることのできる貴重な機会です。オフサイトミーティングでは、CEO の會田、経営企画の鈴木、そして CTO の私から、経営や事業に関するプレゼンテーションを行った後、懇親会で交流を深めました。 note.com 10 月: 経団連へ入会 経団連へ入会しました。スタートアップが経団連に入会したというニュースには驚いたメンバーも多く、私から入会目的や狙いを説明しました。 入会の理由としては、国内外の経済動向や政策に関する情報収集を強化し、事業成長を加速させること。また、日本の経済界や各業界のリーダーと連携し、AI や音声テクノロジーを中心としたイノベーションを推進すること。経団連というと、重厚長大系のお堅い企業群というイメージがありますが、ここに新しい風を吹かせることがスタートアップに期待されていること、グローバルで日本を代表して活躍する企業を目指していくことです。 www.revcomm.co.jp 10 月: インドネシア出張 インドネシアのジョグジャカルタで開催された PyCon APAC 2024 で RevComm から 3 名のエンジニアのプロポーザルが採択され、プレゼンテーションを行うためインドネシアへ出張しました。 また、ジャカルタにあるインドネシア子会社の RevComm Indonesia のオフィスにも訪問し、同時にユーザー会のイベントや顧客訪問のため出張で滞在していたプロダクトマネージャーや私も合流して交流会を開催しました。 RevComm Indonesia では販売とサポートを行い、日本のメンバーとはリモートでインドネシアチームと新機能の企画やお客様との対応についてディスカッションを行っていますが、実際に現地にインドネシアの社会課題やカルチャー、テクノロジーの浸透度を肌で感じることができ、モチベーションが高まりました。 note.com note.com 11月: 総務大臣賞受賞 「第18回 ASPIC クラウドアワード 2024」で表彰を受けた全約 130 社中、最高位の総務大臣賞を受賞し、阿達総務副大臣より表彰を受けました。受賞の背景として、ユーザーが最新のテクノロジーを日々のビジネスに活用できるようになっている点、ユーザー数や導入企業数の増加度合い、海外進出をしている点を高く評価されました。 表彰の概要は 総務省のサイト にも掲載されました。 www.revcomm.co.jp 12 月: re:Invent 参加 米国ラスベガスで開催された AWS の re:Invent に 3 名のエンジニアが最新技術の調査のため参加しました。生成 AI やデータベースの新機能、機械学習モデルを効率的に学習・推論するための仕組みについて、これまでリリースされていたものの知らなかった機能や、量子コンピューターやブロックチェーン、スポーツにおける IT の活用、IoT など業務では扱うことない知識を得ることができ、新サービスや既存サービスの効率化を考える上でのインスピレーションになりました。 まとめ 2025 年も魅力的な新サービス、新機能のリリースを予定しています。世界で活用される MiiTel のサービスの開発に興味のある方は、ぜひ応募をお待ちしております。
こんにちは。Corporate Engineeringチーム所属の @mottake3 と申します。本記事は RevComm Advent Calendar 2024 の 24 日目の記事です。 はじめに ツールの説明 実装手順 slack appのインストールとtokenの取得 tokenをSecret Managerに登録 アプリケーションコードの説明 Cloud Runへのデプロイ Event Subscriptionsの設定 Slack Channelへインテグレーションの追加 終わりに 参考 はじめに Slack などのテキストコミュニケーションにおいて、伝えたいことを丁寧な言葉遣いでスムーズに作文するのが難しいことがあります。特に音声入力などでメッセージを作成する場合、丁寧な表現にしようとすると発話数が増えてしまい、入力に時間がかかってしまいます。ChatGPT などを活用して文章を校正している方もいるかと思いますが、複数のアプリ間で作業を切り替えるのは少々手間がかかります。そこでSlack上で画面を切り替えることなく、より簡単に自然で丁寧な文章を Slack に投稿できるようなプチツールをSlack BoltとVertex AIを用いて作成してみました。 注意事項 本記事のコードはあくまでサンプルですので参考程度に御覧ください。 セキュリティなどの考慮についても同様になります。 ツールの説明 特定のスタンプを押すと、Vertex AI上のLLMに文章を校正するプロンプトが投げられ、その結果が新規メッセージとして投稿されます。スタンプを外すと編集前のメッセージは削除されます。編集前と編集後のメッセージを見比べて問題があれば手動で微修正をすることを想定してます。スレッド内のメッセージの場合はそのスレッド内で新規メッセージが作成されます。 アーキテクチャの略図は以下のようになります。長くなってしまうので本記事では赤枠の部分の実装を目標にご説明しようとおもいます。 ※その他の部分に関しては別記事として追ってどこかに掲載しようと考えてます。 アーキテクチャ略図 Cloud Run 実行環境です。利用しないときは0スケールさせてコストを節約することを想定しています。 Slack Bolt Slack Appを簡単につくれるフレームワーク。Websocketを使うmodeもありますが、今回はhttpを使うmodeを使用しています。 Flask PythonのWebフレームワークです。Flask上でSlack Boltを起動しています。 Vertex AI LLMの実行環境です。今回はファンデーションモデルにGemini 1.5 Flashを選んでいます。 SQLite ファイル保存形式の軽量なDBMS。SlackのUser TokenなどのUser情報を保存するために利用しています。 Litestream SQLiteをGCSなどにロジカルレプリケーションできるツール。Cloud Runがゼロスケールした際にSQLiteのDBファイルが破棄されてデータが消えてしまう問題に対処するために利用しています。コンテナがコールドスタートする際にGCSからDBファイルを復元しています。 Cloud RunにGCSをボリュームマウントし、そこにDBファイルを置いてもよかったのですが、レスポンスの速さを考えてDBファイルはコンテナに持たせるようにしました。 BigQuery 分析基盤です。プロンプト・編集前後のメッセージ・ユーザー自身が手動で変更等行い最終確定したメッセージの4つを履歴として保存しておき、Gen AI evaluation service等を利用してプロンプトやファンデーションモデルの評価・改善に利用します。 実装手順 slack appのインストールとtokenの取得 こちら のドキュメントを参考にslack appのインストールとtokenを取得してください。 TokenのScopeは以下のキャプチャのように付与してください。 App home -> App Display Nameで表示名をSaveしないとworkspaceへのAppのインストール時に以下のようなエラーが出るのでお気をつけください。 tokenをSecret Managerに登録 SLACK_BOT_TOKEN と SLACK_SIGNING_SECRET をCloud Runから読み込めるようにSecret Managerへ登録しておきます。 アプリケーションコードの説明 ディレクトリ構成 ├── Dockerfile ├── Makefile ├── main.py ├── requirements.txt ├── slack_util_tools.db main.py import os import logging from slack_bolt import App from slack_bolt.adapter.flask import SlackRequestHandler from flask import Flask, request import vertexai from vertexai.generative_models import GenerativeModel import sqlite3 logger = logging.getLogger(__name__) app = App( token=os.environ.get( "SLACK_BOT_TOKEN" ), signing_secret=os.environ.get( "SLACK_SIGNING_SECRET" ) ) flask_app = Flask(__name__) handler = SlackRequestHandler(app) vertexai.init(project=os.environ.get( "PROJECT_ID" ), location=os.environ.get( "LOCATION" )) model = GenerativeModel( "gemini-1.5-flash-002" ) @ app.event ( "reaction_added" ) def reaction_added (say, event): emoji = event[ "reaction" ] user = event[ "user" ] # 自分のmessageに対してスタンプを押したとき if emoji == "メッセージ編集" and "item_user" in event and user == event[ "item_user" ]: channel = event[ "item" ][ "channel" ] ts = event[ "item" ][ "ts" ] thread_ts = None message_text = None #スタンプを押したmessageの取得 conversations_history = app.client.conversations_history( channel=channel, oldest=ts, latest=ts, inclusive= True ,limit= 1 ) if not conversations_history[ "messages" ]: #スレッド内のmessageへのスタンプだったとき reply_history = app.client.conversations_replies( channel=channel, ts=ts) message_text = reply_history[ "messages" ][ 0 ][ "text" ] thread_ts = reply_history[ "messages" ][ 0 ][ "thread_ts" ] else : #通常のメッセージへのスタンプだったとき message_text = conversations_history[ "messages" ][ 0 ][ "text" ] response = model.generate_content( f """ 以下のメッセージを丁寧にしてください。 候補を出すのではなく最適な1つのメッセージのみを答えてください。 もし相手を傷つけてしまいそうな感情的な文章の場合は、相手を思いやった文章に編集してください。 {message_text} """ ) #DBファイルからuser_oauth_tokenの取得 conn = sqlite3.connect(os.environ.get( "DB_NAME" )) cur = conn.cursor() res = cur.execute( f "SELECT user_token FROM user WHERE user_id = '{user}'" ) user_token = res.fetchone()[ 0 ] conn.close() result = app.client.chat_postMessage( channel=event[ 'item' ][ 'channel' ], thread_ts=thread_ts, token=user_token, text=response.text, ) logger.info(result) @ app.event ( "reaction_removed" ) def reaction_removed (say, event): emoji = event[ "reaction" ] user = event[ "user" ] if emoji == "メッセージ編集" and "item_user" in event and user == event[ "item_user" ]: # DBファイルからuser_oauth_tokenの取得 conn = sqlite3.connect(os.environ.get( "DB_NAME" )) cur = conn.cursor() res = cur.execute( f "SELECT user_token FROM user WHERE user_id = '{user}'" ) user_token = res.fetchone()[ 0 ] conn.close() result = app.client.chat_delete( channel=event[ 'item' ][ 'channel' ], token=user_token, ts=event[ "item" ][ "ts" ], ) logger.info(result) @ flask_app.route ( "/slack/events" , methods=[ "POST" ]) def slack_events (): payload = request.get_json() if 'challenge' in payload: #チャレンジリクエストのとき return payload[ 'challenge' ] else : return handler.handle(request) @ app.middleware def skip_retry (logger, request, next ): if "x-slack-retry-num" not in request.headers: #再送リクエストでないとき return next () # ローカル開発用 if __name__ == "__main__" : flask_app.run(debug= True , host= "0.0.0.0" , port= int (os.environ.get( "PORT" , 3333 ))) 補足が必要そうな部分を説明します。 slack上のメッセージはchannelとtsで特定されます。 メッセージの取得にはconversations.history API、スレッド内のメッセージの取得にはconversations.replies APIを利用する必要があるため、以下のようにhistory APIで取得出来なかった場合にreplies APIに切り替えています。 #スタンプを押したmessageの取得 conversations_history = app.client.conversations_history( channel=channel, oldest=ts, latest=ts, inclusive= True ,limit= 1 ) if not conversations_history[ "messages" ]: #スレッド内のmessageへのスタンプだったとき reply_history = app.client.conversations_replies( channel=channel, ts=ts) 以下のコードのチャレンジリクエストの場合の処理がないと、後ほど説明するslack appへのEvent Subscriptionsの設定時に認証エラーとなってしまいます。 @ flask_app.route ( "/slack/events" , methods=[ "POST" ]) def slack_events (): payload = request.get_json() if 'challenge' in payload: #チャレンジリクエストのとき return payload[ 'challenge' ] else : return handler.handle(request) Slack APIには「3秒以内に応答がないとリトライされる 」という制約があります。 その制約に対処するために以下のようにリクエストヘッダーをみてリトライの場合は処理をしないようにしています。 @ app.middleware def skip_retry (logger, request, next ): if "x-slack-retry-num" not in request.headers: #再送リクエストでないとき return next () Cloud Runへのデプロイ 今回は手動でデプロイします。以下のようなDockerfileとrequirements.txtを用意します。 Dockerfile FROM python:3.12-bookworm ENV PYTHONUNBUFFERED True ENV APP_HOME /app WORKDIR $APP_HOME COPY . ./ RUN pip install -U pip && pip install -r requirements.txt ENTRYPOINT gunicorn --bind :$PORT --workers 1 --threads 2 --timeout 0 main:flask_app requirements.txt flask google-cloud-aiplatform gunicorn slack-bolt デプロイを実行します。今回はmakeファイルを用意しているので make deploy とコマンドを打てばOKです。 Makefile # 環境変数 PROJECT_ID={プロジェクトID} SERVICE_NAME={サービス名} LOCATION={リージョン} DB_NAME={DBファイル名} IMAGE_NAME=gcr.io/$(PROJECT_ID)/$(SERVICE_NAME) SERVICE_ACCOUNT=${SERVICE_NAME}@$(PROJECT_ID).iam.gserviceaccount.com # シークレット情報 SECRETS=SLACK_BOT_TOKEN={bot tokenを保存しているシークレット名}:latest,SLACK_SIGNING_SECRET={signing secretを保存しているシークレット名}:latest deploy: gcloud builds submit --tag $(IMAGE_NAME) gcloud run deploy $(SERVICE_NAME) --image $(IMAGE_NAME) \ --platform managed \ --service-account $(SERVICE_ACCOUNT) \ --region $(LOCATION) \ --update-secrets=$(SECRETS) \ --set-env-vars "PROJECT_ID=${PROJECT_ID}" \ --set-env-vars "LOCATION=${LOCATION}" \ --set-env-vars "DB_NAME=${DB_NAME}" \ gcloud run deploy コマンドの --update-secrets オプションにシークレットマネージャに保存しているtokenのパスを指定すると、デプロイ時にシークレットの値を環境変数として設定することができます。 Event Subscriptionsの設定 Slack AppのEvent Subscriptionsを有効化します。 Request URLにはCloud RunのURLに /slack/events というディレクトリ名を付与したものを設定してください。 Subscribe to bot eventsには reaction_added と reaction_removed を設定してください。 Slack Channelへインテグレーションの追加 任意のSlack Channelへ作成したSlack Appを追加したら完了です。 追加方法はいくつかあるのですが、追加したいChannelでSlack Appに対してメンションを投げることで追加する方法がお手軽かと思います。 終わりに メッセージの編集を行うプロンプトに以下のような命令を入れていました。 もし相手を傷つけてしまいそうな感情的な文章の場合は、相手を思いやった文章に編集してください。 これは“空気の読めるAI“のようなものが人間同士のコミュニケーションの間に入ってきて、受け手にとって最適な解釈ができるように“いい感じ“にしてくれるのを期待して入れています。今回はテキストコミュニケーションですが、音声コミュニケーションについてもあと何回かブレイクスルーが起きて、同様な事が出来るようになったら面白いのではないかと感じています。 著者自身はAI開発は素人ではありますが、社内の詳しい方に話を聞くたびに、そんな未来がくるかも、とワクワクしてしまいます!(妄言多謝) 最後に弊社採用もオープンしておりますので、気になりましたらお気軽にご応募くださいね!お話できることを楽しみにしています。 hrmos.co 参考 Getting started over HTTP | Bolt for Python bolt-python/examples/google_cloud_run/flask-gunicorn at main · slackapi/bolt-python · GitHub 【Slack】インストールするボットユーザーがありませんと出たときの対処方法 | THE SIMPLE Slack botをCloud Runで動かしてみた|まりーな/エンジニア Slack BoltをGoogle Cloudにデプロイするノウハウ conversations.history method | Slack conversations.replies method | Slack
はじめに Full-stack チームの豊崎です。 RevComm では、MiiTel Analytics の議事録作成をはじめ、LLM を用いた機能開発が活発に行われています。 今回、社内ユーザーが誰でも利用できる RAG 環境を作成しました。これは、RAG 環境をユーザーに提供するための PoC として実施したものです。 構成 この PoC では、プロダクトへの直接的な機能の埋め込みは行わず、以下のような構成で実装しました。 Amazon Bedrock, Amazon Bedrock Knowledge Bases ベクターストア: Pinecone データソース: S3 dynamoDB Python langchain streamlit etc… 問題点 この PoC を開始した時点での主な課題は、次の疑問から生まれました。 "Amazon Bedrock Knowledge Basesを使って、どのようにユーザーごとにRAG環境を提供できるのか?" 各ユーザーに個別の Amazon Bedrock Knowledge Bases を作成することは現実的ではなく、この課題に苦心しました。 結論として、ベクトルストアからデータを取得する際のフィルタリング機能が不可欠だと判明しました。 メタデータフィルタリング Amazon Bedrock Knowledge Bases には メタデータフィルタリング という機能があります。 これこそが、私の課題を解決する機能でした。 使い方 Knowledge Bases のデータソースに配置される各文書に対して、カスタムメタデータファイル( .metadata.json )を作成する必要があります。 このメタデータを利用して、ベクトルデータのフィルタリングを行います。 今回の目的はユーザーごとの RAG 環境提供ですが、このフィルタリングにより検索対象のチャンク数を削減でき、パフォーマンスと正確性の向上も実現できます。 以下が metadata.json のフォーマットです。 例 ) miitel_analytics_dashboard_overview . pdf をデータソースに配置した場合 // miitel_analytics_dashboard_overview.pdf.metadata.json { "metadataAttributes" : { "session_id" : "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" , "user_id" : "YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY" , } } metadataAttributes 配下には、フィルタリング時に利用したい key-value を設定できます。 私は、ユーザーがファイルをアップロードする際に、このメタデータファイルが自動生成されるように実装しました。 サンプル (Python) 今回、私は langchain_community.retrievers.bedrock で提供されている AmazonKnowledgeBasesRetriever を利用しました。以下にそのサンプルコードを掲載します。 先ほどの metadata.json で設定した session_id と user_id をここで指定しています。 retriever = AmazonKnowledgeBasesRetriever ( knowledge_base_id = BEDROCK_KNOWLEDGE_BASE_ID , retrieval_config = { "vectorSearchConfiguration" : { "numberOfResults" : 10 , "filter" : { "andAll" : [ { "equals" : { "key" : "session_id" , "value" : "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" , } , } , { "equals" : { "key" : "user_id" , "value" : "YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY" , } , } , ] } , } , } , ) まとめ 今回は、Amazon Bedrock Knowledge Bases のメタデータフィルタリング機能について紹介しました。この機能により、ユーザーごとに独自の RAG 環境を提供しながら、不要なベクトルデータを効率的に除外することが可能となりました。 当初目標とした「社内の人が誰でも使える RAG 環境」は実現できましたが、以下のような課題が残されています: データソースとの連携間隔 本番環境での実装の実現性 etc... 今後は、プロダクトへの RAG 環境の実装検討をさらに進め、MiiTel ユーザーの業務効率化により一層貢献できるよう取り組んでいきます。 ご清覧ありがとうございました。
はじめに こんにちは! RevComm のフロントエンドエンジニアの楽桑です。 私たちのコールセンターシステムでは、 GraphQL を使用してデータを管理しており、これまでは Recoil を使ってローカルステートを管理していました。 最近では、 Recoil の代わりに Apollo Client の Local Cache を採用し、サーバーデータの取得・管理をより簡潔かつ効率的に行っています。 この記事では、 Apollo Client のキャッシュ利用について紹介します。 背景 今までは Recoil を使ってグローバルな状態管理を行ってきました。Recoilには以下のようなメリットがあります: 使いやすさ :シンプルで直感的に状態管理が可能。 学習コストが低い :初学者でも簡単に扱える設計。 プロジェクト初期の段階においては、これらのメリットを活かして素早く状態管理を整えることができ、 Recoil は悪くない選択肢でした。 しかし、サーバーから取得したデータを管理するケースにおいては、以下のような課題が浮き彫りになりました: データ同期の手動実装が必要 サーバーから取得したデータをローカルの Recoil 状態と同期させるには、追加の実装が必要です。これにより、コードの冗長化や保守性の低下を招きます。 データ取得の効率化が困難 同じデータを複数のコンポーネントで使用する場合、無駄なAPIリクエストが発生しやすく、パフォーマンスが低下します。 最新データの取得とパフォーマンスのトレードオフ リアルタイム性が求められる場合、常にサーバーからデータを取得する実装ではパフォーマンスの劣化を避けられません。 こうした背景から、サーバーと連携した効率的な状態管理を実現するために、 Apollo Client の導入を決めました。 Apollo Client は、 GraphQL の強力なキャッシュ管理を活用し、データ取得の効率化と同期の手間を軽減することで、これらの課題を解決します。 Apollo Client Cache とは Apollo Client Cache は、GraphQLを使ったデータ取得の効率を最大化するためのキャッシュ機能です。 一度取得したデータをクライアント側に保存し、再利用することでネットワークリクエストの削減など多くメリットある強力な機能です。 実装例 これまでのRecoilを用いた実装では、まずRecoil Atomを定義するところから始める必要がありました。 export const userState = atom ({ key : 'userState' , default : [] , }) ; たとえば useGetUser などのフックを定義する場合、データ取得が成功したタイミングで onCompleted コールバック内から手動で Recoil State へデータをセットする必要があります。 const useFetchUsersWithRecoil = () => { const [ users , setUsers ] = useRecoilState ( userState ) ; const [ getUsers , { data , loading , error }] = useLazyQuery ( GET_USERS , { fetchPolicy : 'network-only' , onCompleted : ( data ) => { setUsers ( data . users ) ; } }) ; return { getUsers , users , loading , error } ; } ; また、 Recoil State を更新するたびに再レンダリングが発生するため、 useLazyQuery を用いて取得回数を必要最低限に抑える必要があるなど、いくつかのデメリットも存在します 一方、 Apollo Client のローカルキャッシュ機能( Local Cache )のみを用いる場合、 Recoil State の定義や初期設定といった手順は不要になります。 const useFetchUsersWithApollo = () => { const { data , loading , error } = useQuery ( GET_USERS , { fetchPolicy : 'cache-first' , }) ; const users = data ?. users ?? [] ; return { users , loading , error } ; } ; fetchPolicy を cache-first に設定すると、 Apollo Client はすでにローカルキャッシュ上に存在するデータを優先的に返し、サーバーへの新規リクエストを行わなくなります。 これは、同じデータを何度も取得する必要がない場面でのパフォーマンス最適化につながり、不要なネットワーク通信を削減することが可能になります。 サブスクリプション動作 WebSocket を使用してデータの更新をサブスクリプションで行う場合の例をご紹介します。 従来の Recoil State を使ったデータ更新では、手動で setState を呼び出す必要がありました。以下はその実装例です: export const useUserSubscription = () => { const [ users , setUsers ] = useRecoilState ( usersState ) ; // Users配列の状態を取得・更新 useSubscription ( USER_UPDATED_SUBSCRIPTION , { onSubscriptionData : ({ subscriptionData }) => { if ( subscriptionData . data ?. userUpdated ) { const updatedUser = subscriptionData . data . userUpdated ; // 現在のusersを直接参照して更新 const updatedUsers = users . map (( user ) => user . id === updatedUser . id ? { ... user , ... updatedUser } : user ) ; setUsers ( updatedUsers ) ; } } , }) ; } ; 一方、 Apollo Client が提供する Local Cache を利用する場合、コードは非常に簡潔になります。 以下はその例です: export const useUserSubscription = () => { useSubscription ( USER_UPDATED_SUBSCRIPTION ) ; } ; そして、特定のユーザー情報を更新する場合、 Cache に keyFields を追加することで、 Apollo Client はそのユーザーのキャッシュのみを自動的に更新することが可能です。 export const graphqlCache = new InMemoryCache ({ typePolicies : { User : { keyFields : [ account_id ] } } }) このように、サブスクリプションデータの更新時に特定の副作用( Side Effect )が必要ない場合、非常にシンプルな実装が可能です。 カスタムキャッシュマージ Apollo Client では、フェッチしたデータがキャッシュ上に既に存在する場合、そのデータをどのように更新・統合(マージ)するかを柔軟に制御することができます。 これを typePolicies や merge 関数を用いて実現できます。 今回は、ユーザー情報のマスキングを例として挙げます。 たとえば、ユーザー情報を管理者のみが閲覧できる仕様にしたい場合、権限判定に応じたマスキング処理をフロントエンド側で行うケースを考えてみます。 まず、ログイン中のユーザー情報を取得し、管理者かどうかを判定します。 管理者であれば、すべてのユーザー情報を開示しますが、管理者でない場合は、 maskUser 関数を使用して隠したい情報をマスキングするように実装します。 export const graphqlCache = new InMemoryCache ({ typePolicies : { User : { keyFields : [ account_id ] merge ( existing , incoming , { cache } ) { const myAccount = cache . readQuery<MyAccountQuery> ({ query : GET_MY_ACCOUNT , }) ?. myAccount ; if ( ! myAccount ) { return incoming ; } const { account_id , permissions } = myAccount ; const isAdmin = permissions ?. includes ( 'is_admin' ) || false ; if ( isAdmin ) { return incoming ; } const maskedUser = maskingUser ( incoming , account_id ) ; return { ... existing , ... maskedUser , } ; } , } , } , }) ; このように、サーバーからユーザーのデータが更新される際に、キャッシュを更新する動作をカスタマイズすることができます。 終わり 最後までお読みいただきありがとうございます! この記事では、 Apollo Client を活用した GraphQL キャッシュの活用方法について解説しました。 Recoil から Apollo Client への移行を通じて、サーバーデータの効率的な取得やキャッシュ管理がどれほど便利かをご理解いただけたと思います。 Apollo Client の強力なキャッシュ機能は、単なるデータ取得の効率化だけでなく、アプリケーション全体のパフォーマンス向上やコードの保守性の向上にも寄与します。 特に、カスタムキャッシュマージを活用することで、フロントエンドでの柔軟なデータ操作が可能になります。 プロジェクトの要件によって最適な状態管理ツールは異なりますが、サーバーデータとの連携が必要な場合、 Apollo Client は非常に強力な選択肢です。 この記事が、皆さんのアプリケーション開発の参考になれば幸いです。
こんにちは。レブコムのコーポレートエンジニアリングチームの @ken-1200 です。 この記事は、 RevComm Advent Calendar 2024 の 20 日目の記事です。 1. はじめに 2. 開発の背景・モチベーション 3. 前提条件 4. Salesforce CPQ APIの概要 5. 商談(Opportunity)の作成 6. 見積(Quote)の作成 7. 見積品目(Quote Line Items)の登録 8. ポイントの振り返り 9. 自動化により得られた効果 10. 苦労した点・ハマりどころ 11. まとめ 12. 参考文献 1. はじめに 記事の目的 本記事では、Salesforce CPQ APIを活用して、商談から見積作成、見積品目の登録までのプロセスを自動化する手順をご紹介します 対象読者 Salesforce CPQを利用するエンジニアの方を主な読者と想定しています 2. 開発の背景・モチベーション なぜ開発に至ったのか 営業プロセスでは、商談・見積・見積品目などの情報入力や修正が手動で行われ、工数がかかっていました。さらに、オンライン申込対応時には、商品の追加・削除・価格調整を都度行う必要があり、手作業によるミスや作業遅延が発生しがちでした これらの課題解決のため、Salesforce CPQ APIを用いた自動化によって、業務フローを効率化し、正確性とスピードの向上を目指しました 3. 前提条件 Salesforce環境の準備 Salesforce CPQが有効化されていること 必要なAPIアクセス権限(ユーザー権限設定)が設定されていること 開発環境のセットアップ Salesforce Sandbox環境 プログラミング言語:Python 3.10以上を推奨します 基本的な知識 Salesforce CPQの基本概念 REST APIの基礎知識 4. Salesforce CPQ APIの概要 APIの種類 REST APIとSOAP APIの2種類が存在しますが、軽量で汎用性が高く、JSON形式のデータ交換が容易なREST APIを選択します 認証方法 一般的にはOAuth 2.0を使用することで、トークンベースの認証・認可が可能です エンドポイントとリソース Salesforce CPQには特定のエンドポイントを介して見積や商品情報へアクセスできます。以下は主な例です QuoteReader : /services/apexrest/SBQQ/ServiceRouter?reader=SBQQ.QuoteAPI.QuoteReader&uid={quote_id} 指定したQuote IDの詳細情報を取得します ProductLoader : /services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.ProductAPI.ProductLoader&uid={product_id} 指定した商品IDに対する商品情報やオプションを取得します QuoteProductAdder : /services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteProductAdder 見積に商品を追加するために使用します QuoteCalculator : /services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteCalculator 見積品目を追加後に、見積全体の価格計算を行います QuoteSave : /services/apexrest/SBQQ/ServiceRouter 設定した見積や見積品目を保存および確定します これらのエンドポイントを組み合わせることで、商談作成→見積生成→見積品目追加→価格再計算→保存という一連の流れを自動化できます SalesforceRestApiClientクラスについて Salesforce CPQ APIやSalesforce標準APIへのアクセスを簡潔にするために、本記事では共通的に利用できる SalesforceRestApiClient クラスを用いています from collections.abc import Mapping from typing import Any import httpx class SalesforceRestApiClient : """Salesforce REST APIクライアントクラス Salesforce APIを呼び出すための基本クラスです """ def __init__ (self, path: str , additional_headers: dict | None = None ): self.base_url = "https://your-instance.salesforce.com" # SalesforceインスタンスURLを指定してください self.path = path # ここでは例としてAuthorizationヘッダを省略していますが、 # 実際にはOAuth2トークンや有効な認証ヘッダを設定してください self.headers = { "Authorization" : "Bearer your_access_token" , "Content-Type" : "application/json" } if additional_headers: self.headers.update(additional_headers) async def get (self) -> httpx.Response: """GETリクエストを送信します""" async with httpx.AsyncClient() as client: return await client.get(f "{self.base_url}{self.path}" , headers=self.headers, timeout= 30 ) async def patch (self, json: Mapping[ str , Any]) -> httpx.Response: """PATCHリクエストを送信します""" async with httpx.AsyncClient() as client: return await client.patch(f "{self.base_url}{self.path}" , headers=self.headers, json=json, timeout= 30 ) async def post (self, json: Mapping[ str , Any]) -> httpx.Response: """POSTリクエストを送信します""" async with httpx.AsyncClient() as client: return await client.post(f "{self.base_url}{self.path}" , headers=self.headers, json=json, timeout= 30 ) 5. 商談(Opportunity)の作成 必要なデータ 商談名、ステージ、取引先情報など。必要に応じて追加してください APIリクエストの構築 POST メソッドを用いて、指定のエンドポイントへJSON形式でデータを送信します。Salesforce APIは Content-Length ヘッダが必要となる場合があるため、事前にJSON文字列の長さを計算して設定します エンドポイント例 path="/services/data/vXX.X/sobjects/Opportunity" ヘッダー例 headers={"Content-Length": str(len(json.dumps(data)))} サンプルコード 以下はPythonによる実装例です。非同期HTTPクライアント(httpx)を用いてSalesforce APIにPOSTリクエストを送信し、商談を作成します import asyncio class SalesforceOpportunity : async def create_opportunity (self, data: Mapping[ str , Any]) -> httpx.Response: """Salesforceの商談をAPIで作成します Args: data (Mapping[str, Any]): 作成する商談の情報を含んだ辞書型データ Returns: httpx.Response: Salesforce APIからのレスポンス """ # APIクライアントを初期化(必要なヘッダを設定) sf_api_client = SalesforceRestApiClient( path= "/services/data/vXX.X/sobjects/Opportunity" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.post(json=data) if __name__ == "__main__" : # 実行例:商談を作成します async def main (): sf = SalesforceOpportunity() response = await sf.create_opportunity( { "Name" : "Test Opportunity" , "StageName" : "Prospecting" , "CloseDate" : "2024-12-31" , "AccountId" : "0015g00000A3X7dAAF" , # 有効なAccountIdを設定してください } ) print (f "{response.status_code=}" ) print (f "{response.json()=}" ) asyncio.run(main()) エラーハンドリング Salesforce APIへのPOST時には、 201 Created が成功時の典型的なステータスコードです。エラー時には 400 や 404 などが返り、レスポンスボディ内に errorCode や fields などの詳細が含まれます 以下はエラーが発生した場合の例です [ { "message": "不正な種別の ID 値: 0015g00000A3X7dAAF", "errorCode": "MALFORMED_ID", "fields": ["AccountId"] } ] このようなエラーに対しては、ログ出力やリトライ、適切なエラーメッセージのユーザー通知などを行います。 6. 見積(Quote)の作成 商談との関連付け 見積と商談は、 SBQQ__Opportunity2__c フィールドで関連付けます 必要なフィールド 商談ID、価格表ID、期限日など。要件に応じて追加フィールドやカスタムフィールドを設定します APIリクエストの詳細 POST リクエストを使用して、 SBQQ__Quote__c オブジェクトにデータを送信します エンドポイント例 path="/services/data/vXX.X/sobjects/SBQQ__Quote__c" ヘッダー例 headers={"Content-Length": str(len(json.dumps(data)))} サンプルコード 以下は、Pythonを使用して見積を作成するサンプルコードです import asyncio class SalesforceQuote : async def create_quote (self, data: Mapping[ str , Any]) -> httpx.Response: """Salesforceの見積をAPIで作成します Args: data (Mapping[str, Any]): 作成する見積の情報を含んだ辞書型データ Returns: httpx.Response: Salesforce APIからのレスポンス """ # APIクライアントを初期化(必要なヘッダを設定) sf_api_client = SalesforceRestApiClient( path= "/services/data/vXX.X/sobjects/SBQQ__Quote__c" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.post(json=data) if __name__ == "__main__" : # 実行例:見積を作成します async def main (): sf = SalesforceQuote() # 以下は例としてのフィールド設定です。実際のIDや値は有効なものを指定してください。 response = await sf.create_quote( { "SBQQ__BillingCity__c" : "Chiyoda" , "SBQQ__BillingPostalCode__c" : "100-0000" , "SBQQ__BillingState__c" : "Tokyo" , "SBQQ__BillingStreet__c" : "1-1-1" , "SBQQ__EndDate__c" : "2024-12-31" , "SBQQ__Opportunity2__c" : "0065g00000B3X7dAAF" , # 商談ID "SBQQ__PricebookId__c" : "01s5g00000A3X7dAAF" , # 価格表ID "SBQQ__PrimaryContact__c" : None , "SBQQ__Primary__c" : True , "SBQQ__QuoteTemplateId__c" : "a1s5g0000003X7dAAF" , # テンプレートID "SBQQ__StartDate__c" : "2024-01-01" , "SBQQ__SubscriptionTerm__c" : 12 , } ) print (f "{response.status_code=}" ) print (f "{response.json()=}" ) asyncio.run(main()) エラーハンドリング エラーが発生した場合、 400 Bad Request などのステータスコードとともに、 errorCode や message が返されます。以下は一般的なエラー応答例です [ { "message": "invalid cross reference id", "errorCode": "INVALID_CROSS_REFERENCE_KEY", "fields": [] } ] このようなエラーに対しては、ログ出力やIDの再確認、必要なデータフィールドの修正を行います 7. 見積品目(Quote Line Items)の登録 手順 見積の読み取り 商品の読み取り 商品の追加 見積の計算 見積の保存 これらのステップを通じて、見積品目を自動的に追加できます 製品情報の準備 製品ID、数量、価格などのデータ。必要に応じて追加フィールドを設定できます APIリクエストの構築 見積IDを基に見積品目を追加するには、CPQ固有のエンドポイントを使用します。 QuoteReader 、 ProductLoader 、 QuoteProductAdder 、 QuoteCalculator 、 QuoteSaver といったCPQ APIエンドポイントを順番に呼び出すことで、一連の処理を自動化できます バルク操作の考慮 複数の見積品目を一度に登録する場合、一括処理用のコンテキストをまとめて送信することで、パフォーマンスを最適化できます 商品を一括追加した後、見積を再計算し、最後に保存する流れで処理を完結させます サンプルコード 以下のサンプルコードは、見積に商品を追加する一連の流れをCPQ APIで実現します import asyncio class SalesforceCpqQuote : async def get_quote (self, quote_id: str ) -> httpx.Response: """Salesforceの見積をCPQ APIで取得します""" sf_api_client = SalesforceRestApiClient( path=f "/services/apexrest/SBQQ/ServiceRouter?reader=SBQQ.QuoteAPI.QuoteReader&uid={quote_id}" , ) return await sf_api_client.get() async def get_product (self, product_id: str , data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの商品をCPQ APIで取得します""" sf_api_client = SalesforceRestApiClient( path=f "/services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.ProductAPI.ProductLoader&uid={product_id}" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.patch(json=data) async def add_product (self, data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの見積品目をCPQ APIで作成します""" sf_api_client = SalesforceRestApiClient( path= "/services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteProductAdder" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.patch(json=data) async def calculate_quote (self, data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの見積をCPQ APIで計算します""" sf_api_client = SalesforceRestApiClient( path= "/services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteCalculator" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.patch(json=data) async def save_quote (self, data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの見積をCPQ APIで保存します""" sf_api_client = SalesforceRestApiClient( path= "/services/apexrest/SBQQ/ServiceRouter" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.post(json=data) class CreateQuoteLineItem : def __init__ (self) -> None : self.salesforce_cpq_quote = SalesforceCpqQuote() async def execute ( self, quote_id: str , product_id: str , pricebook_id: str , currency_code: str , product_counts: dict , ): """処理の流れ: 1. 見積の読み取り 2. 商品の読み込み 3. 商品の追加 4. 見積の計算 5. 見積の保存 """ # 見積の読み取り cpq_quote = await self.get_quote(quote_id) print (f "Successfully get quote: {cpq_quote}" ) # 商品の読み込み product = await self.get_product(product_id, pricebook_id, currency_code) print (f "Successfully get product: {product}" ) # 商品の追加 add_product_to_quote = await self.add_product_to_quote(product_counts, cpq_quote, product) print (f "Successfully add product: {add_product_to_quote}" ) # 見積の計算 calculate_quote = await self.calculate_quote(add_product_to_quote) print (f "Successfully calculate quote: {calculate_quote}" ) # 見積の保存 save_quote = await self.save_quote(calculate_quote) print (f "Successfully save quote: {save_quote}" ) async def get_quote (self, quote_id: str ) -> dict : """Salesforceの見積を取得""" quote_response = await self.salesforce_cpq_quote.get_quote(quote_id) return json.loads(quote_response.json()) async def get_product (self, product_id: str , pricebook_id: str , currency_code: str ) -> dict : """Salesforceの商品を取得""" product_data = { "context" : json.dumps(ProductGetContext(pricebookId=pricebook_id, currencyCode=currency_code).dict()) } product_response = await self.salesforce_cpq_quote.get_product(product_id, product_data) return json.loads(product_response.json()) async def add_product_to_quote (self, product_counts: dict , quote: dict , product: dict ) -> dict : """Salesforceの商品を見積に追加""" # ProductModelやConfigurationModelなど product_model = ProductModel(**product) list_of_product_model = [] list_of_configuration_model = [] # バンドル商品の子商品を追加 for mb_op in product_model.options: product_id = mb_op.record[ "SBQQ__OptionalSKU__c" ] quantity = product_counts.get(product_id) if quantity: mb_op.record[ "SBQQ__Quantity__c" ] = quantity cf_model = ConfigurationModel( configuredProductId=product_id, optionId=mb_op.record[ "Id" ], optionData=mb_op.record, configurationData=mb_op.record, inheritedConfigurationData= None , optionConfigurations=[], configured= False , changedByProductActions= False , isDynamicOption= False , isUpgrade= False , disabledOptionIds=[], hiddenOptionIds=[], listPrice= None , priceEditable= False , validationMessages=[], dynamicOptionKey= None , ) list_of_configuration_model.append(cf_model.dict()) # バンドル商品本体へのオプション追加 if product_model.configuration is not None : if product_model.configuration.optionConfigurations is not None : product_model.configuration.optionConfigurations.extend(list_of_configuration_model) product_model.configuration.configured = True list_of_product_model.append(product_model.dict()) # 見積と商品モデルをcontextにセット context = ProductAddContext( quote={k: v for k, v in quote.items() if k != "ui_original_record" }, products=list_of_product_model, ) add_product_response = await self.salesforce_cpq_quote.add_product({ "context" : json.dumps(context.dict())}) return json.loads(add_product_response.json()) async def calculate_quote (self, quote: dict ) -> dict : """Salesforceの見積を計算""" calculate_quote_data = { "context" : json.dumps({ "quote" : {k: v for k, v in quote.items() if k != "ui_original_record" }}) } calculate_quote_response = await self.salesforce_cpq_quote.calculate_quote(calculate_quote_data) return json.loads(calculate_quote_response.json()) async def save_quote (self, quote: dict ) -> dict : """Salesforceの見積を保存""" save_quote_data = { "saver" : "SBQQ.QuoteAPI.QuoteSaver" , "model" : json.dumps({k: v for k, v in quote.items() if k != "ui_original_record" }), } save_quote_response = await self.salesforce_cpq_quote.save_quote(save_quote_data) return json.loads(save_quote_response.json()) if __name__ == "__main__" : """見積品目の追加を実行する例です。実際には有効なIDと通貨コードを設定してください""" async def main (): quote_id = "a0B5g00000DwJtEEAV" # 見積ID product_id = "01t5g00000B1Q0PAK" # 商品バンドルID pricebook_id = "01s5g0000008Q5eAAE" # 価格表ID currency_code = "JPY" # 通貨コード product_counts = { "01t5g00000B1Q0MKA" : 1 , # 見積商品IDと数量 "01t5g00000B1Q0NAAV" : 2 , } await CreateQuoteLineItem().execute( quote_id, product_id, pricebook_id, currency_code, product_counts, ) asyncio.run(main()) CPQ API のリクエストモデル定義 以下は、CPQ APIとのやりとりで使用するデータモデルの例です。 pydantic を用いてスキーマを定義し、バリデーションやコメントを明確にしています。これらのモデルは、受け取ったJSONデータを明確な型情報のあるPythonオブジェクトとして扱うことで、可読性を向上させます from pydantic import BaseModel, Field class ConfigurationModel (BaseModel): configuredProductId: str = Field(..., title= "商品ID" , description= "The Product2.Id" , example= "01t6F00000B8XZTAA3" ) optionId: str | None = Field( default= None , title= "オプションID" , description= "The SBQQ__ProductOption__c.Id" , example= "01t6F00000B8XZTAA3" ) optionData: dict = Field( ..., title= "オプションデータ" , description= "Editable data about the option, such as quantity or discount" , example={ "Id" : "01t6F00000B8XZTAA3" }, ) configurationData: dict = Field( ..., title= "構成データ" , description= "Stores the values of the configuration attributes." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) inheritedConfigurationData: dict | None = Field( default= None , title= "継承された構成データ" , description= "Stores the values of the inherited configuration attributes." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) optionConfigurations: list = Field( ..., title= "オプション構成" , description= "Stores the options selected on this product." , example=[{ "Id" : "01t6F00000B8XZTAA3" }], ) configured: bool = Field( ..., title= "構成済み" , description= "Indicates whether the product has been configured." , example= False ) changedByProductActions: bool = Field( ..., title= "商品アクションによる変更" , description= "Indicates whether a product action changed the configuration of this bundle." , example= False , ) isDynamicOption: bool = Field( ..., title= "動的オプション" , description= "Indicates whether the product was configured using a dynamic lookup." , example= False , ) isUpgrade: bool = Field( ..., title= "アップグレード" , description= "Queries whether this product is an upgrade." , example= False ) disabledOptionIds: list = Field( default= None , title= "無効なオプションID" , description= "The option IDs that are disabled." , example=[ "01t6F00000B8XZTAA3" ], ) hiddenOptionIds: list = Field( default= None , title= "非表示オプションID" , description= "The option IDs that are hidden." , example=[ "01t6F00000B8XZTAA3" ], ) listPrice: float | None = Field(default= None , title= "定価" , description= "The list price." , example= 0.0 ) priceEditable: bool = Field( ..., title= "価格編集可能" , description= "Indicates whether the price is editable." , example= False ) validationMessages: list = Field( ..., title= "検証メッセージ" , description= "Validation messages." , example=[ "Error message" ] ) dynamicOptionKey: str | None = Field( default= None , title= "動的オプションキー" , description= "Internal property for dynamic options." , example= "01t6F00000B8XZTAA3" , ) class OptionModel (BaseModel): record: dict = Field( ..., title= "オプション" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) externalConfigurationData: dict | None = Field( default= None , title= "外部構成データ" , description= "Internal property for the external configurator feature." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) configurable: bool = Field( ..., title= "構成可能" , description= "Indicates whether the option is configurable." , example= False ) configurationRequired: bool = Field( ..., title= "構成必須" , description= "Indicates whether the configuration of the option is required." , example= False , ) quantityEditable: bool = Field( ..., title= "数量編集可能" , description= "Indicates whether the quantity is editable." , example= False ) priceEditable: bool = Field( ..., title= "価格編集可能" , description= "Indicates whether the price is editable." , example= False ) productQuantityScale: float | None = Field( default= None , title= "商品数量スケール" , description= "Returns the value of the quantity scale field for the product being configured." , example= 0.0 , ) priorOptionExists: bool | None = Field( default= None , title= "前のオプションが存在する" , description= "Checks if this option is an asset on the account that the quote is associated with." , example= False , ) dependentIds: list = Field( ..., title= "依存するオプションID" , description= "The option IDs that depend on this option." , example=[ "01t6F00000B8XZTAA3" ], ) controllingGroups: dict = Field( ..., title= "制御グループ" , description= "The option IDs that this option depends on." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) exclusionGroups: dict = Field( ..., title= "排他グループ" , description= "The option IDs that this option is exclusive with." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) reconfigureDimensionWarning: str = Field( ..., title= "再構成次元警告" , description= "Reconfigures the warning label for an option with segments." , example= "01t6F00000B8XZTAA3" , ) hasDimension: bool = Field( ..., title= "次元がある" , description= "Indicates whether this option has dimensions or segments." , example= False ) isUpgrade: bool = Field( ..., title= "アップグレード" , description= "Indicates whether the product option is related to an upgrade product." , example= False , ) dynamicOptionKey: str | None = Field( default= None , title= "動的オプションキー" , description= "Internal property for dynamic options." , example= "01t6F00000B8XZTAA3" , ) class FeatureModel (BaseModel): record: dict = Field( ..., title= "機能" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" } ) instructionsText: str | None = Field( default= None , title= "指示テキスト" , description= "Instruction label for the feature." , example= "01t6F00000B8XZTAA3" , ) containsUpgrades: bool = Field( ..., title= "アップグレードが含まれている" , description= "This feature is related to an upgrade product." , example= False , ) class ConfigAttributeModel (BaseModel): name: str | None = Field( default= None , title= "名前" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.Name." , example= "01t6F00000B8XZTAA3" , ) targetFieldName: str = Field( ..., title= "ターゲットフィールド名" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__TargetField__c." , example= "01t6F00000B8XZTAA3" , ) displayOrder: float | None = Field( default= None , title= "表示順" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__DisplayOrder__c." , example= 0.0 , ) columnOrder: str = Field( ..., title= "カラム順" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ColumnOrder__c." , example= "01t6F00000B8XZTAA3" , ) required: bool = Field( ..., title= "必須" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Required__c." , example= False , ) featureId: str = Field( ..., title= "機能ID" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Feature__c." , example= "01t6F00000B8XZTAA3" , ) position: str = Field( ..., title= "位置" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Position__c." , example= "01t6F00000B8XZTAA3" , ) appliedImmediately: bool = Field( ..., title= "直ちに適用" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AppliedImmediately__c." , example= False , ) applyToProductOptions: bool = Field( ..., title= "商品オプションに適用" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ApplyToProductOptions__c." , example= False , ) autoSelect: bool = Field( ..., title= "自動選択" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AutoSelect__c." , example= False , ) shownValues: list | None = Field( default= None , title= "表示値" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ShownValues__c." , example=[ "01t6F00000B8XZTAA3" ], ) hiddenValues: list | None = Field( default= None , title= "非表示値" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__HiddenValues__c." , example=[ "01t6F00000B8XZTAA3" ], ) hidden: bool = Field( ..., title= "非表示" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Hidden__c." , example= False , ) noSuchFieldName: str | None = Field( default= None , title= "存在しないフィールド名" , description= "If no field with the target name exists, the target name is stored here." , example= "01t6F00000B8XZTAA3" , ) myId: str = Field( ..., title= "ID" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.Id." , example= "01t6F00000B8XZTAA3" , ) class ConstraintModel (BaseModel): record: dict = Field( ..., title= "制約" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" } ) priorOptionExists: bool = Field( ..., title= "前のオプションが存在する" , description= "Checks if this option is an asset on the account that the quote is associated with." , example= False , ) class ProductModel (BaseModel): record: dict = Field( ..., title= "商品" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" } ) upgradedAssetId: str | None = Field( default= None , title= "SBQQ__QuoteLine__c.SBQQ__UpgradedAsset__c.Id" , description= "Provides a source for SBQQ__QuoteLine__c.SBQQ__UpgradedAsset__c." , example= "01t6F00000B8XZTAA3" , ) currencySymbol: str = Field( ..., title= "通貨シンボル" , description= "The symbol for the currency in use." , example= "¥" ) currencyCode: str = Field( ..., title= "通貨コード" , description= "The ISO code for the currency in use." , example= "JPY" ) featureCategories: list = Field( ..., title= "機能カテゴリ" , description= "Allows users to sort product features by category." , example=[ "01t6F00000B8XZTAA3" ], ) options: list [OptionModel] = Field( ..., title= "オプション" , description= "A list of all available options for this product." , example=[ "01t6F00000B8XZTAA3" ], ) features: list [FeatureModel] = Field( ..., title= "機能" , description= "All features available for this product" , example=[ "01t6F00000B8XZTAA3" ] ) configuration: ConfigurationModel = Field( ..., title= "構成" , description= "An object representing this product’s current configuration." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) configurationAttributes: list [ConfigAttributeModel] = Field( ..., title= "構成属性" , description= "All configuration attributes available for this product." , example=[ "01t6F00000B8XZTAA3" ], ) inheritedConfigurationAttributes: list [ConfigAttributeModel] | None = Field( default= None , title= "継承された構成属性" , description= "All configuration attributes that this product inherits from ancestor products." , example=[ "01t6F00000B8XZTAA3" ], ) constraints: list [ConstraintModel] = Field( ..., title= "制約" , description= "Option constraints on this product." , example=[ "01t6F00000B8XZTAA3" ] ) class ProductGetContext (BaseModel): pricebookId: str = Field( ..., title= "価格表ID" , description= "The ID of the price book to use." , example= "01s6F00000CneeJQAR" ) currencyCode: str = Field( ..., title= "通貨コード" , description= "The ISO code for the currency in use." , example= "JPY" ) class ProductAddContext (BaseModel): ignoreCalculate: bool = Field(default= True , title= "計算無視" , description= "計算無視" , example= True ) quote: dict = Field(..., title= "見積" , description= "見積モデル" , example={}) products: list = Field(..., title= "商品リスト" , description= "商品モデル" , example=[]) groupKey: int = Field(default= 0 , title= "グループキー" , description= "グループキー" , example= 0 ) エラーハンドリング エラーが発生した場合はHTTPステータスコードとエラーメッセージが返されます。たとえば、 500 Internal Server Error などが返された場合、レスポンス本文には errorCode や message フィールドが含まれます。 [ { "errorCode": "APEX_ERROR", "message": "System.AssertException: Assertion Failed: Unsupported quote object: a0B5g00000DwJtEEAV\n\n(System Code)", } ] 8. ポイントの振り返り 1. 商談(Opportunity)の作成 必要なデータ(商談名、ステージ、取引先ID、CloseDateなど)を準備します POST /services/data/vXX.X/sobjects/Opportunity エンドポイントを用い、JSON形式でデータを送信します 2. 見積(Quote)の作成 商談ID、価格表ID、期間や開始日などの必須項目を指定します POST /services/data/vXX.X/sobjects/SBQQ__Quote__c を使用し、JSON形式でデータを送信します 3. 見積品目(Quote Line Items)の登録 CPQ API固有のフロー( QuoteReader , ProductLoader , QuoteProductAdder , QuoteCalculator , QuoteSaver )を順序立てて実行します 製品IDや数量、オプション構成などを事前に用意し、バンドル構成商品に対応します 複数商品の一括追加時は、リクエストをまとめて送信し、パフォーマンスを最適化します 9. 自動化により得られた効果 自動化により、以下のような効果を得られました。 手動作業が減少し、ヒューマンエラーが抑制されました 営業担当者がコア業務に集中できる環境が整い、業務効率が向上しました 見積作成から承認までのリードタイムが短縮され、顧客対応スピードと満足度が向上しました 10. 苦労した点・ハマりどころ 開発過程では、以下のような課題に直面しました。 Salesforce CPQ APIに関するドキュメントや事例が少なく、適切なエンドポイント選定やデータモデル理解までに試行錯誤が必要でした 関連IDや依存関係の正確な把握が難しく、エラーの解消に時間がかかりました 適切なエラーハンドリングやPydanticでのデータモデル定義など、Python実装上のベストプラクティスを探りながら開発を進める必要がありました 11. まとめ 本記事では、Salesforce CPQ APIを用いて、商談から見積作成、見積品目の登録までを自動化する具体的な手順とポイントをご紹介しました。これにより、手作業を減らしてヒューマンエラーを抑え、業務スピードを向上させることで、顧客満足度を高められる可能性が見えてきたかと思います。 この記事が少しでも参考になり、読者の方々の開発や業務改善にお役立ていただければ幸いです。 12. 参考文献 Salesforce公式ドキュメント Salesforce CPQ APIガイド 参考例コード(GitHub Gist) https://gist.github.com/paustint/40b602503b6cd6ae879af7b85d910da8
はじめに Phone Div Backend チームの西園です。 私たちのチームでは、システムの性能を向上させるために k6 と Datadog を利用した負荷テストを実施しました。本記事では、その際に利用したツールや実施方法について共有します。 想定読者 負荷テストをやったことがない方 k6 を利用したことがない方 負荷テストをやってみたいが方法がわからない方 k6 の結果を Datadog と連携したい方 負荷テストとは 負荷テストは、システムに負荷を与えて挙動を観測するテストです。これにより以下のような情報を得られます: システムのボトルネックの特定。 システムが耐えられる最大負荷の確認。 システムのパフォーマンス向上に必要な改善点の発見。 負荷テストの種類 負荷テストには目的に応じていくつかの種類があります: Smoke Test : 最小負荷で基本動作を確認するテスト。 Average Load Test : 運用時の平均負荷を再現するテスト。 Stress Test : システムの限界を探るための負荷をかけるテスト。 Soak Test : 長時間負荷をかけてシステムの安定性を確認するテスト。 Spike Test : 短時間で急激に負荷を増加させた際の挙動を確認するテスト。 Breakpoint Test : 負荷を徐々に増やし、システムが壊れるポイントを特定するテスト。 注意点 負荷テストを行う際は、できるだけ本番環境と近いテスト環境を用意することが重要です。特に以下の点に注意してください: インフラリソース : テスト環境と本番環境でのCPU、メモリ、ネットワーク条件を可能な限り一致させる。 データ量 : データベース内のデータ量を本番に近い状態にすることで、より正確な結果を得られる。 k6 は Grafana Labs が提供するオープンソースの負荷テストツールです。以下の特徴があります: k6とは 幅広いプロトコル対応 : HTTP、WebSocket、gRPC などに対応。 柔軟なスクリプト作成 : JavaScript を用いて、シナリオに応じたスクリプトを作成可能。 多様な負荷パターン : Executor を利用して、一定負荷や段階的負荷増加などのシナリオを設定可能。 k6スクリプトの実装方法 では実際にどのようにスクリプトを作成するかを見てみましょう。 以下は Read 系のエンドポイントに負荷を掛けるスクリプトの例です。 import http from "k6/http" ; import { check } from "k6" ; export const options = { scenarios : { test1 : { executor : "ramping-arrival-rate" , exec : "testRequests" , startRate : 1 , timeUnit : "1s" , preAllocatedVUs : 25 , maxVUs : 50 , stages : [ { target : 25 , duration : "10s" } , { target : 25 , duration : "50s" } , { target : 0 , duration : "10s" } , ] , } , } , } ; export const setup = () => { // リクエストを送る前の事前処理を記載 } // もしsetup関数で取得したトークンなどを利用する場合は引数を設定する export const testRequests = () => { const requestParams = { headers : { // 必要なヘッダー情報を追加 } , } ; const url = "" const body = { // bodyが必要な場合は記載 } ; const res = http . post ( ` ${ url } ` , JSON . stringify ( body ) , requestParams ) ; // リクエストが想定通りかを検証 check ( res , { "status is 200" : ( r ) => r . status === 200 , }) ; } ; 上記の scenario は ramping-arraival-rate を設定しています。これは負荷を段階的に上げたり下げたりすることができます。 各パラメーターの説明は以下です。 executor : どのような負荷を与えていくか(負荷を徐々に上げていくなど) exec : 実行する関数名(testRequestsという関数を実行する) startRate : testRequests関数を実行する単位 timeUnit : どのくらいの間隔でtestRequests関数を実行するか( startRate / timeUnit でRPSを表現) preAllocatedVUs : あらかじめ用意するバーチャルユーザー数 maxVUs : 負荷テストで利用する最大のバーチャルユーザー数 stages : どのような間隔で負荷を増減させるか つまり以下の内容の負荷をかけることになります。 1RPS から負荷を与えていき、10s かけて 25RPS まで負荷を上げる。 そして 50s 間 25RPS を維持し、最終的に 10s かけて 0RPS まで負荷を落とす。 他にも選択できる Executor はありますので詳細を知りたい方は以下の公式ドキュメントを参考にしてください。 参考: https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/ k6スクリプトの実行とDatadogとの連携 Datadog にダッシュボードを作成 Datadog を使用して負荷テスト結果を可視化するには、以下の手順を実施します: k6 のインテグレーションを有効化 サイドメニューIntegrationから k6 を検索して有効化してください。 必要なメトリクスを表示するダッシュボードを作成 以下は、実際に利用したダッシュボードの例です: 各メトリクスは一例ですが、以下のような設定をします。 ここで後ほど Docker コンテナで設定する DD_HOSTNAME と同じ値を host:<設定値> に設定しておきます。 後ほど説明しますがこの設定をすることで特定の負荷テストにターゲットを絞ってメトリクスを表示できます。 負荷を与える側のマシンを用意 まず前提として負荷を与える側にも負荷がかかるのである程度のスペックを用意したマシンが必要になります。 弊社では主に AWS を利用しているので、負荷テスト時はインスタンスタイプが m4.4xlarge の EC2 を用意して負荷テストを行いました。 インスタンスタイプは負荷に耐えうる、かつ少し余裕を持ったスペックを選定するのをお勧めします。 コンテナの用意 負荷をかける上で今回チームでは Docker を利用して EC2 内にコンテナを立てて負荷テストを実行しました。 まず以下のような docker compose 用のファイルを用意します。 services : k6 : container_name : k6 image : grafana/k6:latest networks : - k6 ports : - '6565:6565' environment : - K6_STATSD_ENABLE_TAGS= true - K6_STATSD_ADDR=datadog:8125 volumes : - # スクリプトのパスをマウントする depends_on : - datadog datadog : container_name : datadog-agent image : datadog/agent:latest networks : - k6 ports : - '8125:8125/udp' environment : - DD_SITE=datadoghq.com - DD_API_KEY=<YOUR_DD_API_KEY> - DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 - DD_HOSTNAME=<YOUR_HOSTNAME> volumes : - /var/run/docker.sock:/var/run/docker.sock:ro - /proc/:/host/proc/:ro - /sys/fs/cgroup/:/host/sys/fs/cgroup:ro Datadog エージェント用のコンテナの環境変数の DD_API_KEY には Datadog で利用している API key を利用してください。 また DD_HOSTNAME には特に指定はないですが、チーム名などわかりやすい名前を設定することをお勧めします。この値は Datadog のダッシュボードでメトリクスを指定する際に特定のテスト結果だけをメトリクス上に反映するために利用します。 参考: https://docs.datadoghq.com/ja/integrations/k6/ コンテナの準備ができたら以下のコマンドを実行して負荷をかけます。 docker compose run k6 run --out statsd <コンテナのスクリプトパス> ただし、現在 k6 のバージョン v0.55.0 で statsd のオプションは廃止されてしまったので xk6-output-statsd extension を利用して実行する必要があります。 詳しくは公式ドキュメントを参照ください。 参考: https://grafana.com/docs/k6/latest/results-output/real-time/datadog/ 負荷テストの実施 k6 の実行が完了すると以下のような結果の指標が表示されます。 以下の指標は今回実施した際の指標です。 k6 result /\ Grafana /‾‾/ /\ / \ |\ __ / / / \/ \ | |/ / / ‾‾\ / \ | ( | (‾) | / __________ \ |_|\_\ \_____/ INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0134] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0734] Failed on <executed api path>: expected 200 but got 500 source=console ✗ status is 200 ↳ 99% — ✓ 366602 / ✗ 8 █ setup ✓ authenticated in successfully checks.........................: 99.99% 366605 out of 366613 data_received..................: 3.1 GB 1.5 MB/s data_sent......................: 54 MB 26 kB/s dropped_iterations.............: 16827 8.005593/s http_req_blocked...............: avg=3.57µs min=1.76µs med=1.93µs max=14.21ms p(90)=2.12µs p(95)=2.34µs http_req_connecting............: avg=417ns min=0s med=0s max=8.2ms p(90)=0s p(95)=0s http_req_duration..............: avg=288.92ms min=62.39ms med=239.73ms max=2.57s p(90)=491.68ms p(95)=621.08ms { expected_response:true }...: avg=288.89ms min=62.39ms med=239.73ms max=2.57s p(90)=491.64ms p(95)=621.01ms http_req_failed................: 0.00% 8 out of 366613 http_req_receiving.............: avg=199.78µs min=18.52µs med=169.85µs max=208.51ms p(90)=304.83µs p(95)=513.89µs http_req_sending...............: avg=77.22µs min=28.42µs med=72.18µs max=1.16ms p(90)=91.57µs p(95)=100.69µs http_req_tls_handshaking.......: avg=935ns min=0s med=0s max=9.5ms p(90)=0s p(95)=0s http_req_waiting...............: avg=288.65ms min=62.24ms med=239.44ms max=2.56s p(90)=491.35ms p(95)=620.75ms http_reqs......................: 366613 174.419363/s iteration_duration.............: avg=1.44s min=643.55ms med=1.34s max=5.7s p(90)=1.96s p(95)=2.42s iterations.....................: 73322 34.883587/s vus............................: 0 min=0 max=60 vus_max........................: 60 min=50 max=60 running (35m01.9s), 00/60 VUs, 73322 complete and 0 interrupted iterations ※ executed api path の部分にはリクエストを送信したパスが表示されます。 上記の結果を見ると平均で約 174 RPS の負荷をかけた結果 99% のリクエストは成功しているが、8回リクエストが失敗 していることがわかります。 Datadog Datadog の指標も一部見てみましょう。 HTTP リクエストの動作を見ると、理想的な台形型の負荷曲線にはなっておらず、負荷上昇フェーズでリクエストが失敗する、または適切に送信されないケースが見られました。(通常、問題がなければ負荷曲線は綺麗な台形になります) さらに詳細な指標を確認した結果、データベースとのコネクションタイムアウトが発生していることが判明しました。 チームで議論した結果、以下の仮説を立てました: 「確保しているコネクションプールの上限を超えるリクエストが発生し、API と RDS Proxy 間の TLS 3way ハンドシェイクに時間がかかりタイムアウトした可能性がある。」 この問題は本番環境でも時折発生していたため、仮説に基づきプールサイズの調整を行い監視を続けた結果、これまでに出ていた 500 エラーを減らすことができました。 この負荷テストを通してエラーの原因に対して仮説を立て、システムの改善に至ることができたのでやって良かったと思っています。 その他利用できるツール 普段、Kubernetes や RDS のメトリクスを Datadog で取得しています。 なので負荷テスト時にそれぞれの指標が一緒に見れるように Datadog を利用して k6 の指標を確認しました。しかし他のツールを利用して結果を観測することもできます。 たとえば Grafana dashboards を利用すると以下の画像のようなリッチな感じで結果を見ることもできます。 引用: https://grafana.com/docs/k6/latest/results-output/grafana-dashboards/ まとめと今後の展望 k6 と Datadog を利用することで、簡単かつ効果的に負荷テストを実施できます。今後は、Write系エンドポイントへの負荷テストや、RDS Proxy間のパフォーマンス改善に取り組んでいく予定です。 もし負荷テストを検討している方がいれば、ぜひ試してみてください!
はじめに Corporate Engineering という部署で社内営業組織が業務で使用するSalesforceの運用や社内システム開発を担当している瀧山です。 RevCommではコミュニケーションツールとしてSlack、ドキュメント管理ツールとしてNotionを使用しています。 今回は、Slackで投稿された有益なスレッドにリアクション(以下、「スタンプ」と記載)をつけた際にNotion連携するアプリケーションを社内営業組織向けに作成したので概要や仕組みなどを説明したいと思います。 本ブログ内で書かないこと 処理のコーディング 使用技術の詳細な説明 想定読者 Slack Boltを使用したSlack App開発に興味がある方 Slackに投稿されたスレッドをナレッジ化したいと思っている方 Notion連携に興味がある方 開発に至る背景 依頼元の営業チームでは、不明点などがあった際にSlackやNotionにて関連のスレッドやページを検索するが情報量が多くて答えに行きつきにくく、検索に時間がかかってしまうという課題がありました。またドキュメント化を行う時間も中々取ることができなくて、以前誰かが経験したナレッジを共有することが上手くできないことから知識が属人化してしまうという課題もありました。 上記2つの課題から、テキストとして記録されているSlackのスレッドにスタンプをつけるだけで、Notionの指定のページに情報が集約される仕組みを作るのと定期的に集約された情報をチームで共有し合う時間を設けることにより知識の平準化を図ることを目的として、今回の開発に至りました。 開発したアプリケーションについて 使用した技術 Slack Bolt ユーザーのSlack内のアクションをトリガーにしてバックエンドで処理を行うために使用 Python Slack からのイベントをハンドリングして処理するバックエンドロジックを実装 Google Cloud Cloud Run を用いてサーバーレスな環境でアプリケーションをホストするために使用 Notion API Notionの指定のページに情報を登録するために使用 処理概要 全体の処理の内容は以下のシーケンスになります。 処理フロー 処理の流れだけだとイメージしづらいと思うので、キャプチャを元に補足したいと思います。 【シーケンス①〜②の処理】 連携したいSlackの投稿にスタンプを付与します。 【シーケンス③〜④の処理】 Notion に新規ページが作成されます。ページはViewとして管理されており、データテーブルとして一覧化しています。デフォルト作成時にはタイトルとタグ(情報を仕分ける際に使用)が適切に設定されていない状態になります。 補足なのですが、自動連携する情報として下記のような情報があります。 投稿者:スレッドを投稿したユーザー 推薦者:スタンプを付与したユーザー URL:スレッドのURL AI要約:Notion側で自動でページ情報の要約を生成してくれる 内容:スレッドのテキスト 【シーケンス⑤〜⑦の処理】 作成したSlack Appからスタンプを付与したユーザーに対してDMが届きます。 DMの内容には、「作成したNotionページのURL」、「作成したNotionページを更新するためのフォームボタン」があります。 「タイトルとタグを設定する」ボタンを押すと下のキャプチャの様なフォームが開きます。 適宜必要な情報を入力して送信を押下します。 【シーケンス⑧の処理】 フォームで入力した内容でタイトルとタグを更新します。 工夫したところ 営業チームの担当者から「スタンプを押してSlackからNotionへ連携する際に、タイトルとタグは手動で入力する必要があるがNotionページを開いてタイトルとタグを入力することは若干手間のため運用が回らない可能性がある。なんとかSlack内で完結できないか?」という要望をいただきました。 「Slack内で完結すること」と「なるべく手間となる作業がないこと」の二つを意識して仕組みを検討しました。 結果、スタンプを押したユーザーにDMを送信、フォームで必要事項の入力だけであれば運用が回りそうという回答をいただき実装しました。(シーケンス⑤〜⑧の内容) 詳細は割愛しますが、送信されたフォームイベントを受信するために Interactive messages を使用しました。 導入後 現在は営業チームだけでなく、Customer Successのチームでも活用されており、日々Slack内の投稿がナレッジとしてNotionへ集約されており、各チームでナレッジの共有時間を設けることで知識の平準化が行われています。 今後 アプリケーションをさらに進化させるために、Vertex AIを活用した自動化を検討しています。具体的には、自然言語処理モデルを用いてSlackの投稿内容からタイトルを自動生成し、分類モデルを用いて適切なタグを自動付与する機能の実装を目指しています。これにより、ユーザーはタイトルやタグを入力する手間が省け、より迅速かつ気軽にナレッジを集約できるようになります。また、要約モデルを活用することで、長文の投稿内容を簡潔に要約し、Notionページに表示することで、ユーザーがより効率的に情報を把握できるようにしていく予定です。
この記事は RevComm Advent Calendar 2024 の 12日目の記事です。 はじめに こんにちは、バックエンドエンジニアの矢島です。 普段は主にバックエンド領域の開発・保守・運用を行っていますが、チームのサブマネージャーとして組織の運用改善なども行っています。 多くのソフトウェア開発チームが1度は直面する課題の一つに「属人化」があります。特定の機能や領域の知識が特定のメンバーに集中してしまい、その人が不在の際に対応できない、あるいは新機能の開発スピードが落ちてしまうといった問題です。 この記事では、弊社のプロダクトMiiTel の開発チームで実施した、属人化の解消への取り組みについて紹介します。 属人化の解消の背景 RevCommでは、電話・Web会議・対面での全ての会話を最適化する音声解析AIのMiiTelを開発しています。 中でも私が所属してる電話のデータを解析・可視化するMiiTel Phone Analytics は、RevComm創業当初からあるプロダクトで様々な機能拡張が行われてきました。 製品の成長に伴い多くの機能を保守・運用することに加え、プロダクトの改善も行う必要があります。 そこで半年のうちに3名のメンバー増員があり、以下のような課題に直面しました。 各機能の仕様や実装の複雑化による新メンバーの学習コストの増加 特定のメンバーしか対応できないことによる開発や問い合わせ対応のボトルネックの発生 一部メンバーへの負担の集中や、チーム全体の生産性の低下の恐れ これらの課題に対し、「チームの全メンバーが全ての機能の調査・開発を行えるようにすることで中長期視点でのチームの生産性を向上させる」というミッションを設定し、約半年間かけて属人化の解消の取り組みを実施しました。 属人化の解消への取り組み 現状の把握と効果測定の方法を決定 属人化効果を測定するため、以下のようなアンケートを属人化の解消の取り組み前と、1ヶ月単位のスプリント終了時に実施することにしました。 各機能への経験を選択してもらう 「該当の機能の存在を知っているか」、「機能を使ったことがあるか」、「ドキュメントを読んだことがあるか」、「レビューをしたことがあるか」、「問い合わせ対応をしたことがあるか」、「実装したことがあるか」という複数回答可の選択項目を用意しました。 各機能への対応における精神的な負担度を5段階で評価してもらう 取り組み始めた当初は、精神的な負担が属人化に繋がるメインファクターであると考えており、各機能の理解が進むことで、機能対応時の精神的負担が減り、属人化解消に繋がると考えていました。この考えは後で更新されることになります。 機能ごとの理解度や不安要素を自由記述してもらう 施策1:機能共有会の実施 施策としてまず取り組んだのが、各機能の詳細な共有会の実施です。タイムパフォーマンスを考えた時に全ての機能の共有会を実施することは好ましくないため、アンケートから特に属人化している機能や新機能についての共有会を実施しました。 特に複雑な機能については、機能の概要・設計思想とシステムアーキテクチャ・コードリーディングのように複数回に分けて共有を行いました。 施策2:意図的なタスクのアサイン 学習のレベルにおいても、知っていることよりも行うことのほうがより高いレベルに位置付けられるのと同じで、ドキュメントを読むよりも実際に手を動かす方が理解が進むということはよくあると思います。そこで実際に普段触れる機会の少ない機能について、本来は優先度が高くないものも含めた以下のようなタスクを作成し、意図的にアサインしました。 既存機能の単体テスト追加:コードリーディングができることに加え、テストケース作成を通じて機能の仕様理解 ・コードの品質向上と保守性の改善を図れます。 エラーハンドリング改善タスク:コードリーディングができることに加え、保守・運用の改善を図れます。 音声ファイル処理バッチの改善:データフローの全体像の把握ができ、パフォーマンスチューニングも実現できます。 なかでも単体テストの追加は、実際のコードを動作させながらテストを書くことで、自然と機能への理解が深まるという効果がありました。顧客影響がないコードを追加できるという点でも、属人化の解消を推進するタスクとしてはおすすめです。 アンケートの結果からチームに施策の相談 施策1, 2 を実施したところで2回のスプリントが終わり、アンケートも2回実施しました。 当初の取り組みでは、精神的負担の軽減という指標で効果を測定していましたが、アンケート結果に改善が見られなかったため、チーム全体で相談の機会を設けました。 そこから新たな施策やこれまでの取り組みの改善の発案がされ、実際に以降の取り組みに活かすこととなりました。 アンケートの改善 精神的な負担が属人化に繋がるメインファクターであると考えてアンケートを作成していましたが、「精神的負担」という指標よりも「各機能に対応できるか」という具体的な指標の方が、属人化解消の目的に適していることが明確になりました。この気づきを基に、アンケートの設計を以下のように見直しました。 各機能への対応における精神的な負担度を5段階で評価してもらう → 問い合わせ調査やバグフィックスを依頼されたときに対応できるかを判断してもらう 「わからない」「対応できない」「内容の指示を貰えば対応できる」「基本1人で進め」「必要に応じて質問しながら対応できる」「1人で対応できる」という選択項目を用意しました。 これにより、対応できはするけど精神的に負担はあるという状態があっても、より属人化の解消という目的に即した効果測定が可能になりました。 施策3: オンコール制の導入 アンケート項目に問い合わせ対応経験を聞く項目があったことから、一次対応を当番制にしてよいのではないかという意見があがりました。 そこで、業務時間中に発生する問い合わせに対して迅速な一次対応と、問い合わせ対応を通じたキャッチアップの促進を行うための仕組みとして、オンコール制を導入しました。週替わりで2名のエンジニアがメインとサブに分かれてオンコールの担当となり、問い合わせ内容の初期調査や必要に応じて適切なチームメンバーへのエスカレーションを行うようになりました。 この制度により、問い合わせ対応の効率化だけでなく、各メンバーが様々な機能についてキャッチアップする機会が増えました。「問い合わせ対応を通じて自然と機能理解が深まった」という声がメンバーからも出るようになり、結果として属人化の解消にも貢献しました。 施策4:カオスエンジニアリングの実践 最後の比較的大きな施策として、意図的に障害を発生させ、その調査や対応を通じて理解を深める「カオスエンジニアリング」を実施しました。 カオスエンジニアリングとは、本番環境で起こりうる障害や異常な状態を、制御された環境で意図的に再現し、システムの回復性や耐障害性を検証する手法です。Netflixが先駆けとなり、現在では多くの組織で採用されています。 RevComm でも各開発チームでカオスエンジニアリングの取り組みが定期的に行われており、私も別チームで行ったことがありました。 属人化解消の取り組みを行うチームでは行ったことはありませんでしたが、この手法を知識共有の手段として活用することにしました。 通常のカオスエンジニアリングは本番環境の堅牢性や可用性を確認する目的で行われますが、私たちの場合は開発環境で実施し、障害対応を通じた学習に重点を置きました。これにより、メンバーは実際の障害対応に近い形で、システムの動作原理や障害発生時の調査方法を学ぶことができました。 具体的良かった点は以下の通りです。 チーム全体での障害調査と解決プロセスの実践 ・調査手法やログの見方を実践的にキャッチアップできる。 チームメンバー間での知識共有が促進される。 システムの挙動確認と知識の共有 ・システムの依存関係や連携フローを具体的に把握できる 。 トラブルシューティングのノウハウを蓄積できる。 この取り組みは特に効果的で、振り返りでは「新鮮な体験で機能理解が大きく進んだ」という評価を得ました。 結果と考察 約半年間の取り組みによって、以下のような結果が得られました。 アンケートでは4つの施策を通して精神的負担が下がることはなかった。 一方で、メンバーが対応できると言える機能が増加し続けた。 対象としていた機能については「わからない」「対応できない」と回答するメンバーはゼロとなった。 当初設定していた「精神的負担」という指標では改善が見られませんでしたが、業務の属人化の解消は十分実現できていると考えられる結果となりました。 まとめ この取り組みを通じて、以下のような重要な学びが得られました。 目的に合った適切な指標を設定することが重要。一方でOKRの Key Result のように途中で指標を変えることも必要に応じて検討することで進みながらの改善が可能。 ドキュメントやコードリーディングよりも、実際の体験を通じた学習の方が効果的。 個人の努力だけでなく、チームで会話し継続的に実施・改善していくことが重要。 今後は、これらの学びを活かしながら、新しい機能や技術が導入された際にも、スムーズに知識を展開できる体制を維持していきたいと考えています。また、この経験を他のチームとも共有し、組織全体としての改善にも繋げていければと思います。 属人化の解消は、一朝一夕には実現できない課題です。しかし、明確な目標設定と複数の施策の組み合わせ、そして適切な指標による効果測定を行うことで、着実に改善を進めることができます。皆様の組織でも、本記事で紹介した取り組みが何かしらの参考になれば幸いです。
KubernetesではAPIサーバーやバッチ処理、イベント駆動型のタスクなど、さまざまなケースに合わせた「ワークロードリソース」の種類を選択し、柔軟に運用できます。 本記事では、弊チームのシステム設計の例をもとに、ワークロードリソースの中でも、ScaledJobとScaledObject + Deploymentの違いに注目して、使い分けにおける学びを共有できればと思います。 結論 設計の背景 初期選択:ScaledJobの活用と課題 方向転換:ScaledObject + Deploymentの選択 manifestの例 学びと今後の展望 結論 イベント駆動型タスクであっても、実行間隔がPod起動にかかる時間より短いのであればScaledJobではなくScaledObject + Deploymentを選定するのが良いです。 設計の背景 ユーザーが行った通話の情報(通話時間、対応者、発信か着信か、不在かなど)を整えてデータベースに保存する仕組みを新たに構築することになりました。このデータは、サービスのさらなる利便性向上のために活用されるものです。 しかし、既存のシステムから最新の情報をスピーディーに連携することが難しく、既存の仕組みのみでは「最新情報を遅延2分以内に取得したい」という計画当初の要件を満たすことができませんでした。また、長期間のデータ集計の際に、レスポンス遅延が発生する懸念がありました。そこで、新たな仕組みを導入することになりました。 この通話情報は1000件/分以上の頻度で送られてきても処理できる必要があります。 ただし遅延は減らしたいものの、リアルタイム反映が求められるシーンとは利用シーンの切り分けができました。そこで将来的に通話情報を複数形式で保存する可能性を見越し、AWSのSNS+SQSのファンアウト形式を採用しました。この形式を取ることで、情報を柔軟に拡張し、新たな処理を追加することになっても影響を最小限に抑えることができます。 また、これにより通話情報を送信する側の通話履歴管理システムと、通話情報を利用する側の連絡先システムとで、関心ごとの分離を明確にできました。それぞれのシステムを運用している別々のチームが独立してシステムの改善や保守を進めやすい構成にできた点も大きなメリットです。 構成図 初期選択:ScaledJobの活用と課題 「イベント駆動型タスクの処理になる」と考えたことから、最初にScaledJobを選択しました。ScaledJobは、SQSをトリガーにしてジョブをスケーリングできることから最適に思えたためです。さらに、処理時間が短いタスクなので、Podの台数を増やすことで大量のSQSであってもさばけると考えました。 しかしScaledJobではトリガーごとに新しいPodを立ち上げる必要があります。つまりPod起動時間がかかります。メインとなる処理時間は短時間だったとしても、Pod起動の時間に数分かかるとすると、起動中にも次々やってくる1000件/分のSQSを処理するためには結果的に同時に何千ものPodを立ち上げていないといけないことになります。これでは、求めるスピードや効率に達することが難しいと判断し、別のアプローチを検討することになりました。 方向転換:ScaledObject + Deploymentの選択 次に選んだのは、ScaledObject + Deploymentを用いる方法です。ScaledObjectでスケーリングをして、DeploymentのPod内で、SQSのメッセージを定期的にポーリングし続け、ScaledObjectのスケーリングとは別に内部でも並列で処理する形で実装しました。これにより、Podの立ち上げ時間の無駄を削減し、大幅に効率を改善することができました。 Rolloutを選ばずDeploymentを採用した理由は、ジョブ処理ではサービスを維持しながら安全にデプロイする必要がないためです。APIのように即時性を求められるものではなく、メッセージがSQSに溜まる設計であるため、サービスを一時停止しても問題ないからです。 また、Rollout は ArgoCD によるワークロードリソースなので、特に理由がなければKubernetesの標準機能であるDeploymentを選定し、シンプルに扱えるようにしたいと思いました。 ScaledObject + Deploymentを選択したことで、最新情報の取得速度は大幅に改善され、当初目指していた以上に遅延縮小することができています。また、Deploymentの利用により、必要なPodの台数を削減し、リソース効率も向上しました。 manifestの例 今回の構成のmanifestでの定義の例と、チューニングのポイントです。 apiVersion : apps/v1 kind : Deployment metadata : name : sample-deployment spec : selector : matchLabels : app : sample-app template : metadata : labels : app : sample-app spec : terminationGracePeriodSeconds : 300 containers : - name : sample image : DOCKER_IMAGE command : [ "echo hoge" ] --- apiVersion : keda.sh/v1alpha1 kind : ScaledObject metadata : name : sample-scaledobject spec : scaleTargetRef : name : sample-deployment pollingInterval : 60 minReplicaCount : 1 maxReplicaCount : 16 triggers : - type : aws-sqs-queue Deployment terminationGracePeriodSeconds scale in発生時に処理途中で終わったSQS messageに関してはpopしたmessageをdeleteせずに終わる可能性がある メイン処理に必要な時間を考慮した上で適切に設定する必要あり 余裕をもたせると良い ScaledObject maxReplicaCount 一番SQSが多い時間帯に十分なPodが起動できるように設定 pollingInterval scale in/outが発生する頻度の調整をする 急激なSQS量の増減に備えるため 学びと今後の展望 Kubernetesは学習コストが高いと言われますが、実際に使う中で得た知識や工夫を共有し合うことで理解を深めることが重要だと感じています。また、今回のように要件に応じて最適なアプローチを模索するプロセスは非常に有意義でした。 今後は、さらにKubernetesの知識を深め、最新バージョンの機能も活用することで、よりシンプルで効率的な設計・運用を目指していきたいです。 この記事が、Kubernetesの運用に興味を持っている方々の参考になれば幸いです。
はじめまして。 RevComm でフロントエンドエンジニアをしている大石と申します。 私の所属しているチームでは、Lean スタートアップの考えを基にプロダクト開発に取り組んでおり、エンジニアが機能を実装してリリースして終わりではなく、そのリリースした機能の利用実態を定量データとして収集し、プロダクト成長の方針などの検討に活かしています。 その定量データを収集する方法として Google Analytics を利用しており、今回の記事では Google Analytics で詳細なデータを収集するための実装方法を紹介します。 ユーザープロパティを設定してユーザーをセグメントで分ける Google Analytics では、 ユーザープロパティ を使用してユーザーに関する任意の情報を付与することができます。 これによりユーザーを任意条件のセグメントとして分けることができ、特定のイベントのユーザーごとの実行割合をセグメント単位で算出したり、リリースした機能を使ってくれているかどうかを分析したりできます。 ユーザープロパティの設定 Google Analyticsの管理画面で ユーザープロパティ を設定します。 例えば、ユーザーのステータスやWebサービスにおける権限情報などを追加します。 公式ドキュメント にはいくつか方法がありますが、ここでは Google タグでの方法を紹介します。 ユーザープロパティの送信 ユーザーがサイトにアクセスした際にユーザープロパティをGoogle Analyticsに送信します。 例: gtag('set', 'user_properties', {'user_id': 'USER_ID', 'role': 'admin'}); ユーザースコープのカスタムディメンションを作成 Google Analyticsの管理画面で ユーザースコープのカスタムディメンションを作成 します。 カスタムディメンションの範囲を「ユーザー」に設定し、先ほど送信したユーザープロパティに対応するディメンションを作成します。 例: user_id や role などのカスタムディメンションを作成します。 ユーザープロパティの活用例 既存UIに対する大規模な変更を加える場合、いきなり新UIを適用するのではなく新旧両方のUIを切り替えられるようにして提供することでユーザーの混乱を抑えることができます。 この場合に、新UIがどれだけユーザーに受け入れられているのかをGoogle Analyticsのユーザープロパティを利用して調査することができます。 例として、サービス全体のリデザインに伴う新UIを提供する場合に切り替えボタンを用意しておき、そのボタンをクリックしたら新旧UIを切り替えられるような機能を想定します。 その際にブラウザリロードしても新旧どちらを選択しているのかを保持しておきたいため、ローカルストレージなどに {is_new_ui: true || false} のような形で保存しておきます。 前述したユーザープロパティとしてこのローカルストレージの値を送ることで、どれくらいのユーザーが新UIを使っているのかをGoogle Analytics上で調査することができ、これにより新UIが受け入れられているのかどうかが判断できます。 一例ではありますが、こういった活用を通じてユーザーの行動やニーズをより深く理解し、プロダクトの改善戦略の最適化に役立てることができます。 カスタムプロパティを追加しイベント実行時の詳細なデータを送る Google Analytics ではイベントをトラッキングする際にカスタムプロパティを追加することができます。 これにより、特定のイベントに関連する詳細な情報を収集することが可能です。 公式ドキュメント カスタムプロパティを使ってイベントの詳細を確認する方法 カスタムイベントの設定 フロントエンドのコードで、イベントが発生した際にカスタムプロパティを含むイベントをGoogle Analyticsに送信します。 例: gtag('event', 'action_name', {'custom_property': 'xxx'}); イベントスコープのカスタムディメンションを作成 Google Analyticsの管理画面で、 イベントスコープのカスタムディメンションを作成 します カスタムディメンションの範囲を「イベント」に設定し、先ほど送信したカスタムプロパティに対応するディメンションを作成します カスタムプロパティの活用例 大量のデータを分析するための機能としてダッシュボードがあります。 一般的にダッシュボードにはデータを絞り込むためのフォームが用意されていますが、そのフォームをサブミットした際に「サブミットした」ことだけをイベントとして収集していても絞り込み機能が使われたことしかわかりません。 ユーザーがダッシュボードで何を見ているのかを知るためには、どういう条件で絞り込みをしているのかの詳細情報まで知る必要があり、その詳細情報はカスタムプロパティとして収集することができます。 例えば、複数の選択肢から期間を選択するセレクトボックスがあり、その選択肢は以下のように定義されているとします。 const periods = [ { value : 1 , label : '今日' , } , { value : 2 , label : '先週' , } , { value : 3 , label : '先月' , } , { value : 4 , label : '過去30日' , } , ] フォームがサブミットされた時点でユーザーがどの期間を選択していたのかを把握するために、以下のような形で選択された期間の値をカスタムプロパティとして収集することができます。 gtag( 'event' , 'form_submit' , { 'selected_period' : ` ${ periods[].value } ` } ); これにより、ユーザーはどの期間のデータをよく見ているのかを分析することができます。 また、ユーザーで絞り込んでデータを見ることができる場合に、「自分自身」を絞り込み条件に含めているかどうかを収集するような使い方もできます。 const own = { id : 1 , label : '山田 太郎' , } const users = [ { id : 1 , label : '山田 太郎' , } , { id : 2 , label : '鈴木 一郎' , } , { id : 3 , label : '佐藤 花子' , } , ] const selectedUsers: typeof users = [] ユーザーの絞り込みにユニークなid情報を利用している場合、そのユーザー自身のidが絞り込み条件の中に含まれているかどうかを判定し、以下のような形でカスタムプロパティを送ることで、ユーザーが自分自身のデータを見る目的でダッシュボードを利用しているのかどうか、という分析をすることができます。 gtag( 'event' , 'form_submit' , { 'is_selected_own' : selectedUsers. some ( u => u. id === own. id ) } ); エンジニアならではの提案でプロダクトを改善していきましょう ユーザーにとって価値あるプロダクトを作るためにはユーザーのことをより深く知る必要があり、そのための方法は実際のコードレベルで実装ができるエンジニアだからこそ提案できることがあります。 この記事で紹介した Google Analytics の活用方法が、実際のプロダクト改善のための分析のお役に立てれば幸いです。