TECH PLAY

BASE株式会社

BASE株式会社 の技術ブログ

590

こんにちは。BASE BANK 株式会社 Dev Division にて、 Software Developer をしている東口( @hgsgtk )です。 TL;DR July Tech Festa 2021 winter に「TDD から ATDD へ歩みをすすめる」というタイトルで登壇しました アジャイルテストとその中で有効なプラクティスとされる ATDD (Acceptance test-driven development) ATDD を実践するための E2E テスト基盤を gauge と CircleCI を用いて構築する July Tech Festa 2021 winter July Tech Festa はインフラエンジニアのための祭典と題したイベントです。昨年からオンラインでの開催となっており 500 名超えの参加者が集まった大規模イベントになっています。 jtf2021w.peatix.com ハッシュタグは #jtf2021w でして、ここから当日の盛り上がりを見ることができます。また、登壇者の発表資料一覧は conpass の イベント資料一覧 にまとめられています。 私は、おもに CI/CD や DevOps といったテーマが多い C トラックにて 25 分時間をいただき発表しました。 発表資料 こちらが当日の発表資料になります。「TDD から ATDD へ歩みをすすめる」というタイトルです。アジャイルテストの実践や、シナリオテスト・E2E テストの整備に興味がある方にも参考になると思います。 この資料は、実際に BASE BANK の開発組織内で「イテレーションごとの(外部)品質を高める開発の方法は何か」というテーマについて考えて取り組み始めた内容をまとめました。 具体的には次のような内容をまとめています。 アジャイルテストの考え方 ATDD (Acceptance test–driven development) とはなにか ATDD を実践する開発文化とプロセス 受入テスト自動化フレームワーク gauge を用いたテスト作成環境 エンドツーエンドテストの自動実行基盤 CircleCI を用いた CI/CD パイプラインへの組み込み TDD から ATDD へ歩みをすすめた現場の実例 当イベントは「インフラエンジニアのための祭典」ですので、とくに参加者層にあわせてなるべく自動テストを実行する CI/CD 基盤について具体的に語っています。 以下、発表資料を用いて話した内容を少し省略しつつご紹介します。 アジャイルテスト アジャイルテストという言葉はこの分野において著名な Lisa Crispin 氏と Janet Gregory 氏がこのように定義しています。 agiletester.ca 始まりからデリバリーまで、そしてそれ以降も 継続的に実施される協調的なテスト の実践 により、お客様への 価値の頻繁な提供 をサポートします。テスト活動は、 高速なフィードバックループ を用いて理解を検証しながら、プロダクトの品質を築くことに重点を置いています。このプラクティスは、 品質に対するチーム全体の責任 という考え方を強化し、サポートします。 このアジャイルプラクティスの中で役に立つプラクティスというものを紹介しており、その中のひとつが ATDD です。 アジャイルテストで役立つとされるプラクティスたち 赤枠で示したとおり、ユーザーストーリー(雑にまとめると「顧客視点の機能価値を定義したもの」)の完成条件を、具体的な例を用いて理解することから始める考え方です。 ATDD ATDD の開発ステップを簡易的にまとめるとこのようになります。 ATDD開発ステップ このステップの中で実装作業としてはまずは失敗する受け入れテストから始めます。 入れ子のフィードバックループを作る 失敗するストーリー受け入れテストを最初に書く ストーリー受け入れテストを通す過程でユニットレベルのサイクルを回す n 回のチェックイン後、ストーリー受け入れテストを通す いわば TDD Cycle よりもう一つ大きなフィードバックループをストーリー受け入れテストによって回す入れ子構造を作ることになります。 CircleCIとgaugeを用いたテスト実行基盤 この取組を実践するには、受け入れテストを自動化するというテスト実行基盤がセットになってついてきます。 当資料内では CircleCI と gauge を用いた自動受け入れテスト基盤を紹介しています。 CircleCIでのCI/CDパイプラインに組み込む gauge とは ThoughtWorks 社がメンテしている Go 製の受け入れテスト自動化フレームワークです。 gauge.org 私が検討した際に感じたこととして、次のような特徴を持っています Markdown ベースのシンプルな仕様記述 Syntax ステップ実装のための言語は Java/JS/TS/Python/Ruby/C# をサポートしている Visual Studio Code との統合がよくできている メンテナンスが活発(Issue をあげたら数分後に返事が来た) 実際に gauge で作成したテストを CircleCI で実行するサンプルを作成したので実際に試してみたい方は参考にしてみてください(例では Python を用いています)。 github.com gauge を用いた ATDD 開発ステップ 弊チームで実践し始めた gauge を用いた ATDD 開発ステップは次のようになります。 スプリントプランニング等で、ユーザーストーリーの受入条件をチームで会話し特定する 「ストーリー受入条件」としてユーザーストーリーに記述 自動化可能なテストであれば、失敗する受け入れテストコードを記述(「仕掛中」マーキング) 機能実現に向けて実装する(n回のCycle) 機能完成後、「仕掛中」を外してテストが通ることを確認 「仕掛中」と「完成」を区別するというアイデアは、そもそも受け入れテストのフェーズが 2 つに区分されるため実施しています。 仕掛中 「まず最初に失敗する受け入れテストを書く」 進捗を測るテストであり失敗することがわかっている、ビルドフローには含めない 完成 リグレッションを検出するテスト 完成したストーリーのテストは常に成功することが期待するためビルドに含める 具体的に gauge でこれらを区別するための活用アイデアは、発表資料内にツール仕様とともに解説しているので、実際に gauge を使ってみようと思った方はぜひご覧ください。 謝辞 2020 年に続き 2 年連続スピーカーとしてお邪魔させていただきました。 devblog.thebase.in 発表時間以外もとても楽しく参加させていただきました。基本的にずっと C トラックにいたのですが、直前で発表いただいた Masahiko Funaki さんが「 縁の下をどう支えるか〜CI/CDの伝え方 」にて CircleCI を用いた話をされていて、自分の発表内ととても関連していたので口頭で参照させていただいたりしていました。 14:20~14:45 / C4 -「TDDからATDDへ歩みを進める」 東口和暉 さん CircleCIをご活用いただき、ありがとうございます! #CircleCIJp #JTF2021w #JTF2021w_C — 舟木将彦/Masahiko FUNAKI (@mfunaki) January 24, 2021 たくさんのトラックが同時並行で走る中、運営いただいたスタッフの皆様ありがとうございました!特に、C トラックをご担当いただいた小松様が発表中に聴講者としてうなずく等オフラインのカンファレンスでは見られていた聴講者の反応を zoom 上でしていただいたおかげで、「今ちゃんとインターネットとつながってる」と安心しながら登壇できました。この場を借りて改めて御礼申し上げます。 おわりに 『 実践テスト駆動開発 (Object Oriented SELECTION) 』の「第 1 章テスト駆動開発のポイントとは?」では学習プロセスとしてのソフトウェア開発という捉え方を提示しています。 開発者は自分たちが使う技術はプロジェクト完成の過程で学ぶ 何を実現しようとしているかを理解をしていく 実地からのフィードバックを活用しシステムに活用していくこと 今回の発表で紹介した ATDD についてもフィードバックループをいかに回していくかというアイデアになります。当資料がアジャイルテストのマインドをベースとした開発戦略とそこから必要される基盤構築を試行錯誤する際の助けになれば幸いです。 後記 @CircleCIJapan さんに取り上げていただきました。CircleCIさんいつもありがとうございます。 今日のご紹介は @hgsgtk さんの #ATDD 実践とCircleCI・ #gauge でのE2E自動テスト基盤 です。昨日紹介させていただいた #JTF2021w にて中の人の後の登壇ということもあり聞かせていただいた話です。これぞリアルユースケースという深い内容です!多謝! #CircleCIJp https://t.co/0KbPFDnf4D — CircleCI Japan (@CircleCIJapan) January 26, 2021
こんにちは。BASE BANK 株式会社 Dev Division にて Manager をしている東口( @hgsgtk )です。 昨年 2020 年は本ブログにて個人の足し算ではなく掛け算で成果が出せるようなチームを目指したアジャイル開発の取り組みを継続して紹介してきました。 チーム開発の潜在的課題が見つかる振り返りワーク「Mad Glad Sad(喜、怒、哀)」 少人数でのアジャイル開発への取り組み実例 (一歩目の踏みだし方) | 詳説 | July Tech Festa 2020 登壇レポート アジャイル開発におけるユーザーストーリー分割実践 〜画面リニューアルの裏側〜 これらの考え方やプラクティスは全体の一部で、開発チームとしての組織ローカルなプラクティスを『BANK DEV 白書』として整理しています。『BANK DEV 白書』では次のような内容を整理しています。 一般的なアジャイル文脈のプラクティスで出てくる概念の実用の仕方 ex.「ストーリーポイント」を用いるシーン・ポイントの付け方 オリジナルな開発プラクティス ex.「井戸端会議」・「TODO コメントのチケット化」 これらは主に振り返り(レトロスペクティブ)にて発見し、その結果をまとめることで積み上げてきました。本記事ではこのプラクティスをブログとして公開します。 知見の積み上げ『BANK DEV白書』 この取組が始まったのは、「なんでこの取組・プラクティスをやっているのか」という認識が人によってブレることを防ごうとしたことがきっかけです。 たとえば、「ペアプログラミング」という 1 つのプラクティスを手にとってもそれぞれ個人が感じる認識・温度感は違います。しかし、「私たちは設計等迷った際のコミュニケーションツールとしての意義に重きを置く」ということが言語化されていれば「ペアプログラミング」という語彙を会話の中で使いやすくなります 1 。 最初は GitHub 内にドキュメントを作成し随時更新し続ける運用としていました。しかし、ドキュメントでの運用は文章を書く心理的ハードルが高いため、基本的に発起人の @hgsgtk が書記となって更新し続けていました。 その後、継続していくうちにチームの文化定着がうまくいき『 アジャイルの「ライトウィング」と「レフトウィング」 』でいうところの 協働でゴールに向かう「チーム環境」 であるレフトウィングがしっかり生えてきたという実感がでてきました。具体的には、レトロスペクティブなど対話の場のファシリテータを持ち回りでやるようになりました。意識の高い個人が引っ張る構図からチーム自身が自身を成長へ引っ張っていく構図へと変わってきました。 そのため、現在は共同編集をより促進できるよう、より更新の負荷が低く各プラクティス間の関係性も明示しやすい Miro に移行しました。 Miroで作成したBANKDEV白書 ロン・ジェフリーズ氏が XP を描いた図である「 Circle of Life 」のように、ビジネス・チーム・技術という 3 つのリングに分類するといったカテゴリライズの必要性は検討しましたが、現場ではビジネスから技術までかなり密接となっており、まだまだ洗練されきっていないため、あえてカテゴライズしていない選択をしました。 一方で Martin Fowler 氏が指摘するような、技術プラクティスが骨抜きにされた「 FlaccidScrum(ヘロヘロスクラム) 」となることを避ける必要があるため、リファクタリングなどといった技術プラクティスも内包されるよう努めています。 前提: BANKチームが目指すあり方・戦略 上の『BANK DEV 白書』と紹介した Miro の画像には中心が定義されていました。この中心が前提となり目指すあり方と戦略です。 『 小さなチームが始めたアジャイル開発 』という資料にて、アジャイル開発を何故始めたかについてまとめています。 BANK チームでの思考プロセスは『 みんなでアジャイル――変化に対応できる顧客中心組織のつくりかた 』という書籍の考え方に最も影響を受けていますが、そもそもの自分たちの課題分析から始まっています。いわゆる「アジャイルをやってみよう」という入り口で考えておらず、課題定義と未来の理想像の定義から逆算した結果「アジャイルだった」という思考をしています。これを明文化したのが下のマップです。 北極星の明文化 これは BANK チーム固有の価値観定義なので、軽くサマリーするのにとどめます。「 生き生きとした開発とはなんだろうか 2 」といった問いについて議論し、 個々人の達成感(個人)と関係各所との約束を守る事(全体)を両方満たすバランス の取れた開発の仕方が必要だろうとなりました 3 。 また、BANK チームでは「技術戦略」と呼称した短期的計画に依存しないエンジニアリング組織としての戦略を定義しています。 技術戦略 ここでは「 柔軟 」という漢字 2 文字を現在定義しています。現在次のような意図を設定しています。 システムの変更可能性を維持・向上すること 運用・監視の効率性を上げることでグロースや新規サービスに割く時間を増やすこと 新規サービス開発の際の作業量を減らす開発効率向上に務めること チーム内での人の動き方を柔軟に対応できるようにすること この「技術戦略」は四半期計画やその場での現場の意思決定に用いる用途で設定されています。 四半期計画における運用方法は以下です。 まず PdM がプロダクトとしてやりたいこと・開発メンバが「プロダクトに対して間接的だが技術的な課題」を用意 それらを INPUT に個々人がやっていきたいことを「野望」として決める 「野望」では四半期レベルに収まらないものも含めて個人目標を設定します 『 駆け出しマネジャーの成長論 7つの挑戦課題を「科学」する 』では、メンバーが同じ船に乗って納得感を持つためには 計画に自分の意思決定が適度に反映される「対話空間」を用意する ことの重要性について語っています。技術戦略は対話空間を設計するための目線合わせのためのツールとなります。 さて、少し前置きが長くなってしまいましたが、これらの前提を踏まえてもう一度プラクティス図の概要を説明します。 Miroで作成したBANKDEV白書 まず中心に添えられているのが、「北極星」と「技術戦略」となります。 中心に添えた北極星と技術戦略 これらを中心に派生していくのがプラクティス図となります。 プラクティスの紹介 さて、それでは具体的な各プラクティスについて特に積み上げが多いプラクティスについて紹介していきます。 ストーリーポイント 「ストーリーポイント」はアジャイル開発において一般的に知られるプラクティスです。ストーリーポイント自体を軽く説明しますと、 相対見積り の考え方に基づいたものです。必要な作業・複雑さ・リスク等を鑑みてポイント設定します。ストーリーポイントの考え方について体系的に知りたい方は、『 アジャイルな見積りと計画づくり ~価値あるソフトウェアを育てる概念と技法~ 』がおすすめです。 ストーリーポイントに関するプラクティス BANK チームではストーリーポイントの運用を続けて次の内容について更に言語化しました。 大きさの構成要素 Our 1pt(私達の 1pt) Issue 作成時のポイント付与有無判断基準 ポイントが付けられない時 タスクもストーリーポイントでの見積もりを行う 「見積り」がソフトウェア開発において難しいテーマであるがゆえに、BANKチームでもレトロスペクティブを通じた積み上げが一番多くなっています。 大きさの構成要素 大きさの構成要素として「 複雑性 」・「 量的要素 」・「 検証ループの回しやすさ 」という 3 点があるとしました。 複雑性 (例)複数クラスにまたいで触る (例)リポジトリの数がさまざま分散している 量的要素: 作業量の多さ 同質なものをたくさん並べる (例)テストコードをたくさん書く (例)脳死してコピペするような「量」 検証ループの回しやすさ 通常の作業よりも速度が落ちる ハマったときのリスクがある (例)Terraform だと検証ループが回しにくい Our 1pt ストーリーポイントは相対見積の概念であると前述しましたが、相対の比較基準となるストーリーを用意します。Robert C. Martin 氏の『 Clean Agile 基本に立ち戻れ 』では、これを ゴールデンストーリー という語彙で紹介しています。 BANK チームでは現在 1pt を比較基準としており「だいたい半日程度で終わる程度のユーザーストーリー」をピックアップしています。前提としてユーザーストーリーは時間と直接マッピングされる概念ではないので厳密に半日であることに重点を置いているわけではありません 4 。概ねのチーム内の基準の認識合わせのために「半日程度」というニュアンスを利用しています。 この基準に基づいて使うポイントはフィボナッチ数列内の 1pt・2pt・3pt・5pt・8pt・?pt としています。 Issue作成時のポイント付与有無判断基準 Issue 作成時にポイントを付けるか、Issue を受け取った人がポイントを付けるかの判断基準を整理しました。 Issue作成時の判断基準 課題に対して解決策(HOW)がわかっている場合は Issue 作成者がポイントを付けてしまうことにしています。一方で解決策(HOW)がわかっていない場合は Issue を受け取った人が後述する「スパイク」を利用したりして、ポイントをつけることにしました。 タスクもストーリーポイントでの見積もりを行う BANK チームでは Issue に 3 つのバリエーションを現在設定しています。 ISSUEのバリエーション Epic / ユーザーストーリー / タスクの 3 つです。これについて、特にタスクレベルの見積について組織によって運用が異なるかと思います。たとえば、場合によってはタスクレベルでは理想日(絶対値基準である時間・日にちを用いる見積手法)を使用するパターンも考えられます。現時点ではあえてここを分ける理由と必要性が出ていないため、タスクに対してもストーリーポイントをつけて運用しています。 ここで、そもそもタスクについて見積もりを行うことの意義として BANK チームでは 3 つの理由を言語化しました。 手空きのヘルプ時の状況把握 ブラックボックス化の回避 ゴール設定の意識 見積もりを行う意義 実際この運用によって、個人の作業のブラックボックス化・ゴールの曖昧化が防がれ、協働での開発がスムーズになっていると感じます。 スパイク ポイントが付けられないときに活用する「スパイク」 ユーザーストーリーやタスクに対して、技術的な懸念や不明点によってポイントがつけれない場合があります。これに対して「スパイク」という手法を利用します。『 Ryuzee.com: スクラムにおける技術的スパイクの進め方 』では、次のように説明している概念です。 リリース可能なプロダクトを作るのではなく、質問に答えたり情報を収集したりすることを目的とした作業。 開発チームが技術的な質問や設計上の問題を解決するための実際の作業を行うまで、見積りができないようなプロダクトバックログ項目が出てくることがあります。 解決策は、「スパイク」を行うことです。 目的は、質問に対する回答やソリューションを見つけることです。 いわば、 ストーリーを見積もりためのストーリー と言えます。具体的には次のようなフォーマットで Issue を作成することにして、ISSUE_TEMPLATE にも登録して運用しています。 「Xxx のストーリーの見積もりをするため、Xxx を(検証・調査・把握)する」 ISSUE_TEMPLATEの一覧 このスパイクの使い所として、BANK チームでは 歴史的経緯がある気がしたらすぐにスパイクを打つ という使い方をしています(「スパイクを打つ」というのはスパイク活用の際の言い回しです)。 長年稼働しているソフトウェアに機能追加する場合は歴史的経緯はどうしてもあり、その歴史的経緯の発覚によって大幅な手戻りが発生し見積が大幅に上振れるリスクがあります。実際にプロジェクト運営の中で歴史的経緯にハマってしまった事例がありチームのプラクティス活用術として定義しました。 井戸端会議 これはオリジナルのチームプラクティスです。次のようなシーンで活用します。 何らかの問題が発生しているが次の方向性・やることは決まっていない場合や、 使用可能な道具が目の前にあるがその活用可能性・活躍のさせ方についてアイデアが得られていない際に、 その課題に対して、大まかな見解を得るための対話(Dialogue)を行う。 井戸端会議 井戸端会議で行われるコミュニケーションは「対話(dialogue)」であると定義しています。『 問いのデザイン: 創造的対話のファシリテーション 』では、対話を「自由な雰囲気のなかで行われる新たな意味付けをつくる話し合い」であると定義しています。この定義の通りざっくばらんにトピックについて話し、方針が「なんとなく」決めることを目指します。 これまで次のトピックで開催されました。 OneLogin の活用 OneLogin を活用してできることはなんだろう? Go らしさ・サーバーサイドアーキテクチャ Go を活用する際のチームの認識合わせ OOP を前提とした戦略・戦術とのバランスをどう取るか サーバーサイドアーキテクチャ Logging New Relic の活用を始める中でどのようにロギングを考えるか 5 次の具体的なアクションはどうするか Go に関する井戸端会議の Miro の様子を一部チラ見せするとこのような話題をざっくばらんに取り上げました。 Goらしさ井戸端会議の様子 Go 井戸端会議の中では、自分たちのコードベースではどのように「値オブジェクト」を定義するか、といった具体的な実装指針が決まるところまで至れました。 チームメンバーの感想は次のようなものでした。 雑な情報交換が出来た 事前準備がいらなかったのがよかった、しないくらいのが認識が固定化しなくていい ゴール・やりたいことも見つかった! リモートで欠落しがちなコミュニケーションが補える 実際井戸端会議で行われた内容は、物理的に同じ場所にいれば自然と行われていた会話でもあります。しかし、WFH のなかでお互いリモート同士となるとそういったコミュニケーションが欠落しがちな点を、井戸端会議はうまく補ってくれています。 リファクタリング リファクタリングは技術プラクティスとして重要な活動であることは言及するまでもありません。Martin Fowler 氏は「 FlaccidScrum(ヘロヘロスクラム) 」にて、リファクタリングなどの技術プラクティスを欠いたことで、開発速度が低下していく現象を示しました。 リファクタリングとTODOコメントのチケット化 BANK チームとしてのリファクタリングにおける具体的なプラクティスとして「 TODO コメントのチケット化 」というものがあります。これは BANK チームの @applepine1125 さんが社内ドキュメントに投稿したエッセイから始まったプラクティスです。 社内ドキュメントに投稿したエッセイ 代筆してサマリーすると 人は時間がない・タスク分割の一時的メモとして TODO/FIXME コメントを書く TODO/FIXME は回収する仕組みができないとまずい チケット化することでプランニング等棚卸しで管理しやすくなり、リファクタリングを組み込めるようになる それにより、TODO/FIXME の回収可能性が高まる TODO コメントのチケット化は、いわば意識的にリファクタリングを開発に組み込むための基盤となるものです。その後自然とチケットを作らず無意識にリファクタリングが組み込まれる未来が来ることを見据えた点についても語っています。 開発のスケジュールの中で意識的にリファクタリングを組み込む。というやり方を積み重ねていくことで、最終的にチケットなんて作らなくても日々のフィードバックをもとに自然と正しくあるべき箇所に手が入るようになると嬉しいね。 by @applepine1125 『 エッセンシャル スクラム 』の第 8 章「技術的負債」は技術的負債について非常に有名で示唆に富む内容を語っていますが、その中では技術的負債の発生の管理、可視化、返済の重要性と適用方法について語っています。当プラクティスはその中の「可視化」に一役買うものと言えます。 実際に、実践すると次のような効果が得られました。 特に他の人と協働する時に活用したほうが良いとわかった PR のときに気をつけるようになった TODO の寿命を短くするように意識するように ペアオペ・ペアプロ・ライブコーディング 開発中ペアで作業することについて、これまでのレトロスペクティブで「よかった」という振り返りがあったのがペアオペ・ペアプロ・ライブコーディングでした。 ペアオペ・ペアプロ・ライブコーディング これらは都度都度コミュニケーションツールとして活用する方針としています。組織によっては常時ペアオペ・モブプロするといったところもありますが、BANK チームでは必要に応じての活用にとどめています 6 。 レトロスペクティブ 紹介するプラクティスの最後は「レトロスペクティブ(振り返り)」です。 レトロスペクティブ レトロスペクティブでは 3 つのワークを行っていました。1つ目は「Mad Glad Sad」ですが、こちらは『 チーム開発の潜在的課題が見つかる振り返りワーク「Mad Glad Sad(喜、怒、哀)」 』にて詳説しています。 2つ目は「 見積り振り返り会 」です。この取組は「自分たちの見積もりに対してフィードバックループが回せているだろうか」という課題感から始まったワークです。見積りに課題感を感じた Issue を取り上げて次のような問いを重ね、原因と取りうる ActionItem をチームで見つけ出していきます。 なぜ見積り精度が微妙だったか 何が原因で想定よりも大幅に労力が必要だったか どういう属性のあるものだったか タイムマシーンに乗って昔に戻るなら何をテコ入れするか 今後どうすればよいとおもうか 当ブログで述べている見積もりに関するプラクティスはこの「見積もり振り返り」の結果が得られたものがとても多いです。たとえば、ストーリーポイントの大きさの構成要素は、このワークから発見されたものです。 3つ目は「KPT」で、振り返りワークの中で最も一般的なものと言えるでしょう。 それぞれのワーク内で 「難しいね」で終わらせないファシリテート を心がけようとしています。EVP of Development の @fshin さんの 『部下に対して「難しいね」で終わらせないマネジメント 』で提示したマネジメントの注意事項を参考にしたものです。 仕事に難しいことがあるのは当たり前 当たり前の言葉を言わない 簡単だったら、そもそもあなたに相談しない レトロスペクティブにおける話の進め方についても同様に、「難しい」話題はあがりますが、難しいで終わってしまうとそこでフィードバックループは止まってしまいます。 プラクティスの積み上げ方 組織に限らず「成長」するために必要不可欠なのはただ実践するだけでは足りないと思っています。たとえば、『 エンジニアの知的生産術 』では具体→抽象→応用という学びのサイクルを提示しています。 『エンジニアの知的生産術』における学びのサイクル  レトロスペクティブは上図の「抽象」のフェーズでありスプリント内で経験した「具体」を分析して次の「応用」を発見することに意義があると考えます。BANK チームではレトロスペクティブで「ActionItem」をまとめますが、その ActionItem がプラクティスになるという概念整理によってプラクティスの積み上げを試みています。 レトロスペクティブからプラクティスへの昇華ループ つまり、レトロスペクティブの中で得られたアイデアを ActionItem として実践し、プラクティスとして昇華していくというやり方をしています。 この思考方法が開発チームを洗練させることにつながるかは、2021 年終わりに開発プラクティスの集まりをどのように進化させていったかによって評価されることでしょう。 おわりに BASE BANK の開発チームでは日々「どうすればより良いプロダクトを作れるか」といったことを考え、フィードバックループを自分たち自身に回し進化していけるよう業務に邁進しています。 open.talentio.com 現場の雰囲気に興味を持っていただいた方はお気軽にカジュアルなお話をしましょう。 @hgsgtk 宛に DM 頂いても構いません。 語彙を育てていくという考え方は、クリストファー・アレグザンダー氏の提唱した「パタン・ランゲージ」をプロジェクトで行なう際、個別プロジェクトで調整した「プロジェクト・ランゲージ」を作成するという中埜氏の解説にインスパイアドされたものです。詳しくは『 パターン・ランゲージ: 創造的な未来をつくるための言語 』の「第1章 建築におけるパターン・ランゲージの誕生」をご覧ください。 ↩ 「生き生きとした」という問いは、クリストファー・アレグザンダー氏の建築理論におけるテーマである「生き生きとした場所をもたらすこと」という目的感に影響を受けて設定しました。 https://speakerdeck.com/hgsgtk/design-pattern-usage-inspired-by-pattern-language?slide=11 ↩ 『 エクストリーム・プログラミング 』(俗に言う「XP 白本」)では、『プログラマーの生活を良くし創造的にいきいきと働くために XP を体系化した』と語っています。彼らの考え方にも強く影響を受けて現在の試みを進めています。 https://speakerdeck.com/hgsgtk/xp-is-social-change-in-timeless-programming-way?slide=21 ↩ 『 アジャイルな見積りと計画づくり ~価値あるソフトウェアを育てる概念と技法~ 』では、ストーリーポイントの優れた点は 作業量の見積もりと期間の見積もりを分けたこと だとしています。期間の見積もりは規模(ポイント全合計)/ ベロシティ(1 回のイテレーションで完了させたストーリポイントの合計)などの計算式が責務をもっています。 ↩ BASE 社では New Relic プラットフォームを用いた取り組みを開始しています。具体的には CTO @dmnlk さんのこちらの資料をご覧ください。 https://speakerdeck.com/dmnlk/phpcon2020jp-observability ↩ 実際、『 Clean Agile 基本に立ち戻れ 』にて Robert C. Martin 氏は、ペアプログラミングについて、ペアになることは任意であり強制されるものではないし、常にペアになるわけではない点を説明しています。一人でコードを書きたいこともあるので、個人・チームがどのくらいの割合で行なうか決めればいいと。 ↩
明けましておめでとうございます。 BASE株式会社でUIデザイナーをしている野村( @nomjic )です。 外出自粛ムードに拍車がかかる昨今、週末や連休でもなるべく外に出るのを控えたいところですね。 家に籠もって読書して過ごそう、という人も多いのではないでしょうか。 そんなわけで一つ、読書ネタを書いてみたいと思います。 と言っても「連休に読むオススメ本10選」みたいな話ではなく、読書へのモチベーションの高め方とか、習慣化とかに繋がるような話をさせてもらおうかと。 話す内容をざっくり言うと「 デザインチームで読書会を始めてかれこれ3ヶ月経ったのでその報告 」です。 ちなみに 開発チームでの読書会についての記事 という記事が先月アップされていますが、あちらに比べてこちらの内容はだいぶゆるいです。 知識や技術を身に付けることよりも、会の参加メンバーが読書を習慣化することを支援するとか、チームメンバー間のコミュニケーションを促進する、といった比重が強めです。 巣篭もりタイムを活用して読書したいけどモチベーションが上がらない、または読書を始めたものの習慣化できる自信がない、といった人の参考になれば幸いです。 経緯 昨年より、弊社では新型コロナウィルスの影響を受け在宅勤務が推奨されており、実際多くのメンバーが自宅にて業務を遂行しています。そんな中で、ある時期から以下のような声がチラホラと聞こえていました。 「リモートワークだとやはりコミュニケーションが希薄になる感じあるね」 「今まで通勤時間が読書タイムだったのだけど、その習慣がすっかり消えてしまった」 リモートワークあるあるですね。 あと、リモートワークとは関係なしによく耳にする話で、 デザイナーは活字が苦手 問題があったりします。 私はしばらく前から社外での読書会に参加してまして、読んだ本について話しつつ時には脱線して下らない話で盛り上がりながら楽しく会話する、という状況を経験していたので、「 じゃあデザインチームで読書会やってみるかな 」と思ってチームメンバーに話振ってみたら結構食いつき良かったので企画してみた、というのがデザイナー読書会に至る経緯です。 企画 コミュニケーション活性化とか、読書のモチベーション向上・習慣化とかに重きを置いているので、あまり労力を割かずに 読書が苦手な人でもゆるく楽しく続けられる形 を目指して考えてみました。 zoomを使ったオンライン開催 頻度は週一回、30分〜1時間程度。 一回あたりのボリュームは本の一章分くらい。ページ数で言うと40〜60ページくらい? 各々読んできて、読書会の場では「思ったこと」「わからないこと」等を自由に語る場とする。 どのくらいしっかり読み込んでくるかは自由。斜め読みでもいいし、部分的にしか読んでなくてもいい。何だったら目次しか見てなくたっていい。 その書籍の掲げるテーマをネタにしてコミュニケーションが発生すればそれでよし 。 このくらいのゆるい感じで企画をしました。 2つのメソッド 読書および読書会の品質向上のために、2つ工夫をしてみました。 ①Kindleのマーカー&メモ機能を活用 私は書籍は基本電子版で読んでいるのですが、本を読みつつ「ここ読書会で話そう」と思った箇所にマーカーを引いてコメントを書き残してみました。(音声入力使うと楽です。) 語りたかったことを言いそびれないように始めた工夫だったのですが、やってみると「 同僚たちと語りたくなるような面白い箇所を探す 」という癖がついて、読書のモチベーションが一段階上がったように感じています。 ②Figmaを使って会話のメモ取り もう一つ工夫した点としては、「読書会の記録をどう残すか」です。 楽しく語り合えれば読書会としての目的は達成なのですが、あとから振り返えれたらよりGOODです。とは言えいわゆる「議事録」のようなものを取ると、手間が大きいし場の雰囲気が硬くなってしまいそうです。 ということで、参加者の1人が(というか、仕切り役である私が)読書会中にFigma上にメモを取りました。 書籍内の印象深い箇所とか、面白い発言を書き込んだりします。 一つの画面上にKindle画面で本のページを開きつつ、Figmaでメモを取りつつ、Zoomで皆さんの顔を見つつ... というスタイルをやってみました。 シームレスに 本のページ・メモ・メンバーの顔 が見えて良い具合でした。 詳しい記録を残すことは意図していなくて、「どのへんに興味を持たれていたか、どんな話題が出たか」がざっくり残っていれば良し、くらいに考えました。 詳しく振り返りたいなら書籍そのものを再読すればいいじゃない、というスタンスですね。 1冊目 「オブジェクト指向UIデザイン」 そんなわけで、1冊目の題材を選定して読書会を始めました。 読むのは2020年のUIデザイン界ベストセラーである オブジェクト指向UIデザイン 、通称OOUI本です。 日時は月曜11:00〜11:30に設定しました。 月曜の午前という、「いまいち仕事のエンジンかからない時間帯」にゆるい読書会を差し込むことで、良い具合にウォーミングアップになるのではないか、ということを意図しています。 9月後半から11月初旬までの時間をかけてデザインチーム数名で読みきりました。 読書会中に取ったメモ(の一部)は以下のような感じです。 1冊読み終わってテコ入れ 1冊読み終わった時点で、進め方とか時間帯とか、そもそもこの読書会続けるべきか?という点を再検討するためにKPT会(振り返り会)をしました。 概ね出てきたのは以下のような意見。 記憶の定着に良いし習慣にもなる。続けるべき。 読んで理解できなかった点をちゃんと議論して理解できた。 一回30分は短すぎた。盛り上がってきたあたりで終わる。 進行役は持ち回りにした方が良さそう。(ここまで毎回私がやってました) 進行役がメモ取り同時にやるのつらい。 といった意見を踏まえ、進め方にテコ入れしました。 「午前は苦手だからお昼くらいがいいなぁ」という人もいたので、時間帯も多少調整しています。 テコ入れ1 :モデレータ(進行役)とメモ取りの担当を毎回変える。 テコ入れ2 :時間を12:15 - 13:00の45分間とする。 次何読もうか会 2冊めに入る前に、読書会の時間を一回使って「次何読みたい?」を語る会をしました。 各々興味ある本を提案し合って、最後にみんなで投票して決めています。 書籍だけでなくブログ記事なども候補に挙がりました。 2冊め 「デザインの伝え方」 参加者の興味を一番集めた デザインの伝え方 を11月中旬から読み進めています。12月末時点で半分ほど読み終えたところです。 時間を午前から昼にずらしたのが好ましかったようで、参加者がいくらか増えました。以前の時間帯では4人前後の時が多かったのですが、今はコンスタントに6〜7人参加しています。 また、仕切り役とメモ取り役を分けたことで、メモの精度も向上したように思われます。 以下、メモの一部を抜粋。 まとめ(というか所感) この読書会、割と話題が脱線します。 「 ○章のあたり読んでて、□□の業務の時のこと思い出した 」のような発言から始まり、その業務で何があったか、何に困ったか、何が面白かったか、という話になり、気づけば全然書籍の内容と関係ない話題に及んでいたりします。 本の内容についての知識・理解を深めるという意味では好ましくないかもしれませんが、そもそものこの読書会の意図の一つとして「 コミュニケーションの活性化 」がありますので、我々の読書会においてはこのような脱線をウェルカムとしています。 むしろ、本の内容が呼び水になって各々の業務スタイルやデザインに対する価値観・思い入れといった、 普通の雑談ではなかなか至らない深いところ まで話題が及んでいるので、結構良い具合に成功しているなぁと企画者である私としては思っております。 そんなこんなで、2021年も読書会をゆるく続けていきたいと思います。
こんにちは!デザイナーの渡邊です。 前回、BDIというデザイナーの勉強会のロゴについての記事を書かせていただきました。 BASEのデザイナー勉強会『BDI NIGHT』のロゴを制作しました - BASEプロダクトチームブログ 今回はBDIでゲームを作るワークショップを企画したので、準備や当日の様子をお伝えできればと思います。 デザイナーもデザイナー以外にも楽しんでもらえる勉強会を BDIは主にデザインチームを対象とした勉強会でしたが、デザイナーが大切にしていることを知ってもらったり、デザインをより身近に感じてもらうために、デザインチーム外の方の参加も歓迎してはどうだろうかという意見が上がりました。 またBDIそもそものコンセプトとして、メンバー間のコミュニケーションを活発にしたり、リラックスして楽しんでインスピレーションを得て欲しいという目的もありました。 ワークショップや情報交換ももちろん大事ですし楽しいのですが、さらにたくさんの人に楽しんでもらうため、年内最後の特別企画を考えることにしました! ゲームを作るぞ! 作る側も見る側も楽しい企画を...ということで、BDI改善チームから「『BASE』のゲーム作ってみる?」というアイデアが上がってきました。年末最後ということで特別感を出したいよね!と即決されました。 前半はBDI改善チームが作ったサンプルゲームを遊んでもらい、後半は実際にfigmaでゲームを作る構成に決定! サンプルゲーム制作開始 プロット・フロー図を書く ゲームの形式はプロトタイプで作ることが容易そうな難易度低めのADV(ノベルゲーみたいなもの)にしました。 ざっくり「BASE」を利用しているオーナーが選択肢を選び、様々な局面を切り抜けながら大成功する、という大筋のストーリーを決め、 BDI改善チームの三人でそれぞれmiroでプロットを書くことにしました。 最初は3つぐらい分岐があればいいかな...と思ったのですがついつい凝ってしまい、最終的には全体でこんな規模に...! Figmaでゲームのパーツのアセットを用意する ストーリーが書き終わったらいよいよFigmaで組み立てていきます! コピペで作業を簡単に進められるように、ゲームっぽいボタンやフレームのコンポーネントを用意しました。 最近のFigmaのアップデートでVariantsが設定できるようになり、バリエーションのあるコンポーネントを制作するのがかなり楽になりました! プロトタイプで遷移とアニメーションを設定する FigmaのPrototypeから遷移先を設定します。 すごい数!!!!遷移先、もうちょっとわかりやすく見られるようになるといいんですけどね...。 スタートとゴールに似たような黒いスライドが並んでいるのは、アニメーションを付けているためです。 キラキラ動くちょっとリッチなOPに BDI当日 当日はサンプルゲームをみんなで試遊して大いに盛り上がりました! プロトタイプ画面を共有しながら、選択肢は参加者全員の反応機能による多数決で決めていきました。 「BASE」の中の人だからわかるあるあるやトンデモエピソードでわいわい楽しむことができました。 後半は実際にストーリーを考えてからプロトタイプを組むまでをチームに分かれて実際に手を動かしてもらいました。プロトタイプ機能はもちろん、figmaのvariants機能などもサッと理解して自由にカスタマイズしてもらっていたようでよかったです。 BDIのTipsは誰でも見られるように 遊ぶだけじゃなく、少しでも参加者が知見を持ち帰れるようにFigmaのTipsは社内用のドキュメントにまとめてみんなが見れるようにしました。 その中の一部をご紹介します! Figmaの装飾ボタンの作り方 左右を装飾できるボタンの作り方がネット上になかったので、メモ書き程度に残しておきます! サイドパーツとセンターパーツを作ります センターパーツ内に最低サイズとして透明のrectangleを仕込んでおくと少ない文字数でもボタンの最小サイズが指定できます サイドパーツでセンターパーツを挟み、ボタンをAuto Layoutを使って作ります ボタン全体のドロップシャドウなどの効果を設定 コンポーネント化 コンポーネントを選択してVariantsの + をクリックしてVariantsを追加 名前やコンポーネントの差分を変更 完成! After DelayとSmart Animateでアニメーションを作る プロトタイプ機能の「After Delay」を活用することで、複数のアートボードを自動で遷移し続ける簡単なアニメを実装することができます。 After Delayに入力した秒数が経過すると、自動で遷移などの行動が発火します。 また「Smart Animate」を指定することで、遷移先と同じパーツがあった場合に透明度や位置、大きさの違いを検知して自動でモーフィングしてくれます。これを使って、タイトルが下からさがったりする動きを実装できます。 オープニングでははじめに再生されるアートボード外にタイトルロゴが配置されています。この状態でプロトタイプを連結すると、上からロゴが降ってくるアニメーションとして再生されます。 うまくSmart Animateが認識されるコツとしては Opt+ドラッグでアートボードを複製をしたあとにパーツを移動する レイヤー名を遷移前と遷移後で合わせる アートボード外に配置するとだいたいはアートボードのグループから外れてしまうので、手動でアートボードの階層にもどしてあげる を意識すると、意図した動きをしてくれることが多いです。 まとめ ゲームをつくるという題材にしたことで、日頃触る機会のなかった機能にもスムーズに馴染めたかな、と思います。デザイナーではない人にもBDIを通してデザインに興味を持っていただける良い機会なので、引き続き楽しくためになる取り組みを続けていきたいと思います!
「BASE」テキストコミュニケーション・ガイドラインをアップデートする話 この記事はBASE Advent Calendar 2020の25日目の記事です。 https://devblog.thebase.in/advent-calendar-2020 こんにちは。 Product Management GroupでPJのディレクション、Design GroupでUXライティングを担当している藤井です。 ふだんはネットショップ作成サービス「BASE」やショッピングアプリ「BASE」の新機能および改善のディレクション業務をおこないつつ、「BASE」プロダクト全般におけるテキストの品質を向上させるUXライティングに取り組んでいます。 社内で「テキストコミュニケーションをアップデートしていこう!」と声を上げてから約2年。「BASE」のUXライティングは、現在ではこんな視点で、テキストにおけるデザインシステムともいえる「用語リスト」と「運用ガイドライン」を作成、運用しています。 ◯運用ガイドライン・視点の一例 既存の言葉を使う→ユーザーがふだん使っている言葉に合わせるべきであり、すでに理解されている表現があるときに、いたずらに新しい言葉を作り出さないようにする 形を機能に従わせる→美しさ<機能。言葉のほうが伝わるのか、画像のほうが伝わるのか、目的達成のために効果的な方を選ぶ ゴール・オリエンテッドである→その画面において必要な情報のみを、その都度提供する また、タッチポイントごとの特性によって、テキストのコミュニケーションに求められるものも違うため、プロダクトの各タッチポイントで、最終レビュー担当者をそれぞれアサイン。管理画面やメールなどプロダクト全般で1名、ヘルプやFAQなどカスタマーサクセス視点で1名、SNS関連で1名、の体制で最適化を図っています。 あくまで暫定的なものだった、「BASE」のテキストコミュニケーション・ガイドライン と、社内にもプロダクトにもじわじわと根付いてきたUXライティングですが、よくよく考えてみると、あることに気づきました。いまのテキストコミュニケーションの土台になっているガイドラインや視点って、会社のミッションや他社事例を参考にしながら、あくまでもその当時の仮説として設定したものだったなあ、と。 実際のところ、最近では社内でテキストレビューをフィードバックすると、「ここは漢字にしたほうがよくないですか?」「ちょっとしっくりこないです」「ルールとしては合っているかもしれないけれど、読みづらいかも」などの声もちらほら。 そこで、今一度あらためて「BASE」にとってのUXライティングとはなんぞや、を考えてみることに。 UXライティングって? そもそも、UXライティングの定義とはどんなものなのでしょう? ユーザーに愛着を持ってもらえるプロダクトにするために、プロダクト内で使われるテキストを定義して運用すること(出典:効果的なUXライティングのための16のルール) 従来、マニュアルやヘルプ、リリースノートなどで求められてきた、いわゆるテクニカル・ライティングで重視されていたのが<正しさ><簡潔さ><ロジカルさ>だとすれば、UXライティングとは、それに加えて<愛着を持ってもらえる>ことが重要な要素となっているようです。 求められる、<人間らしさ> でも、<愛着を持ってもらえる>こと、つまり<人間らしさ>とユーザビリティのバランスって、とてもむずかしいですよね。多くの情報をすばやく明確に伝えようとすると、どうしてもかしこまってしまい、堅苦しい物言いになってしまうけれど、かといってなれなれしい言い回しも、ちょっと唐突に感じるかもしれない。 そこで、社内のメンバーに集まってもらい、ワークショップスタイルで<ボイス>=ブランドの声と<トーン>を定義してみることにしました。 ボイスとトーン、ボイスチャート <ボイス>とは、ユーザー体験全体を通して感じられる、ブランドの理念を反映した言葉遣いのこと。 <トーン>とは、ユーザー体験の各部分における、言葉遣いの変化のこと。 たとえば、パートナーから電話がかかってくれば、それが自分のパートナーだということがその声自体でわかるし(ボイス)、声の調子でどんな内容なのかがわかる(トーン)、といったところでしょうか。 それを、みんなでキーワードを出し合って5つの要素を定義することで、「BASE」の<ボイス>と<トーン>の拠り所となる「ボイスチャート」ができあがる、という仕組みです。 Product Principle(プロダクトの理念)  →プロダクトがユーザーに提供したい体験を表した言葉。ボイスチャートの土台になる。言葉を通してProduct Principleをユーザーに届けることが、ボイスの目標 Concepts(コンセプト)  →とくに強調したいアイデアやトピック Vocabulary(用語)  →Product Principleを表す象徴的な言葉 Verbosity(言葉数)  →言葉の多さ。情報を正確に、明確に伝えるために言葉を多くすることが適切になる場面もあれば、逆に言葉を少なくすることが適した場面もある Grammar(文法)  →話し言葉を主体とするか、簡潔な表現を主体とするか かくして、こんなワークショップに 今回は、デザインチームをはじめ、ユーザーからのお問い合わせに対応するカスタマーサクセスチーム、ショップオーナーさんが使う管理画面のお知らせやメルマガ、SNS運用を担当するオーナーズサクセスチーム、プロダクトの企画や開発ディレクションを担うプロダクトマネジメント/ディレクターのメンバーをふくむ13名でワークショップを実施。 さまざまな立場から、Product Principle(プロダクトの理念)となるキーワードを軸にして、Concepts(コンセプト)、Vocabulary(用語)、Verbosity(言葉数)、Grammar(文法)がどうあるべきか、じつにバラエティに富んだ数多くの単語が付箋に記され、折り重なっていきました。 そして、1時間半におけるワークショップの参加メンバーのなかで、Principle(プロダクトの理念)として定義づけられたのは、次の3つのキーワードでした。 ◯Principle(プロダクトの理念) 個人をエンパワーメントする 誰でも使える 信頼感 そして、できあがった「BASE」プロダクト・ボイスチャート Principle(プロダクトの理念) 個人をエンパワーメントする 誰でも使える 信頼感 Concept(強調したいアイデア・トピック) プロダクト作りに集中できる 専門知識がいらない みんなが使っている Vovabulary(象徴的なことば) 寄り添う/力を与える/オーナーズファースト かんたん/シンプル/いつでもどこでも やさしい/あんしん Verbosity(ことばの多さ) 「誰でも使える」ようにするために、専門用語や不要な形容詞、副詞を使わず簡潔に伝える 「信頼感」を持ってもらうために、誤解が生まれないように明確に伝える Grammar(文体) 「信頼感」「誰でも使える」ようにするため、シンプルな表現を主体とする 「信頼感」「誰でも使える」ようにするため、シンプルな表現を主体とする ワークショップを通じて見えてきた、「BASE」自身の多様性 興味深かったのは、出てきたキーワードの数々が、かならずしも「BASE」のコーポレート・ミッション「Payment to the People, Power to the People.」に紐づく単語だけではなかったこと。もちろん、<世界のすべての人に、自分の力を自由に価値へと変えて生きていけるチャンスを。あたらしい決済で、あなたらしい経済を。>という宣言から派生するキーワードもたくさんありつつ、たとえば<Webデザインの民主化><コロナ禍の状況におけるセーフティネットになる>など、ミッションのその先をも見据えているような言葉が飛び交うたび、自分の狭い視野だけで「BASE」を見てしまっているのだなあ、と痛感。同じプロダクトと向き合っていても、立場もさることながら、個によってまったく違った風景が見えているのは、とても新鮮な発見でした。 じつは、個人的にはこれが今回のワークショップのなかでの最大の収穫かも、と思っています。というのも、まさに時代と同じように、自分たち自身にとっても「BASE」というプロダクトのとらえ方には多様性があり、だからこそ、時代に合わせてPrinciple(プロダクトの理念)もアップデートしていくだろうし、テキストコミュニケーションもつねに進化を続ける必要がある、ということを、あらためて意識させてもらえたからです。 これから そんなワークショップを経て、今まさに来年からすぐ運用に乗せられるように、2021年版の「用語リスト」と「運用ガイドライン」をアップデート中。もちろん、ここで定義された<個人をエンパワーメントする><誰でも使える><信頼感>という、Principle(プロダクトの理念)を表現するためのテキストの<ボイス>と<トーン>の在り方も、現時点での仮説でしかありません。2021年とはいわず、Day1で検証され更新されていくものとして、「BASE」プロダクト同様、日々進化させ続ける必要があります。 すべては、「BASE」というプラットフォームを使ってくださるショップオーナー様/お客様の体験を、さらに一つ上のステージへと導くために。2021年の「BASE」にも、ぜひご期待ください。
この記事はBASE Advent Calendar 2020の24日目の記事です。 https://devblog.thebase.in/advent-calendar-2020 先日、BASEのデザインチームでユーザビリティテストを企画し、実施しました。 デザインチーム内でユーザビリティテストを実施したのは今回が初めてで、最初はどんな方法で行うのか検討もつかなかったのですが、みんなで知恵を出し合って、PC1台とスマートフォン2つで本格的なユーザビリティテストを実施してみたので、紹介したいと思います! これからやってみる方へのTipsになれば幸いです。 また、もっと良い方法あるで!!という方はこっそり教えてください。 はじめに 2020年4Qから、デザインチーム内で細かいUIUXの改善を企画から実装まで行うDESIGN PROJECTが始まりました。 サービスが始まってから8年が経過し、今では新しいデザインシステムができつつある中、過去リリースされたページのいくつかには古いデザインが残ってしまっているのが現状。 このプロジェクトは、ユーザーが「BASE」に対して抱くイメージに一貫性を持たせるためにも、旧デザインのページの改善をインパクトの大きいページから順にやっていこうという試みです。 ユーザーへの影響を把握し、今問題となっている箇所をクリティカルに改善するために、 改善するページを決める そのページの離脱箇所や滞在時間などの定量調査 仮説検証のためのユーザビリティテスト デザイン 実装 という順番で1つのページの改善を行いました。 この記事では、上記のステップ3にあたる「ユーザビリティテスト」の方法について説明します。 初めてのテストということもあり、テスト当日はバタバタしてしまったりハプニングもありましたが、今後やっていく上で知見がたまったので皆さんに共有したいなと思います! ※ ユーザビリティテストとは何か?を説明する記事ではないのであしからず! ユーザビリティテストの対象(被験者) 今回は、「BASE」でショップを開設する手順に関する改善だったので、対象は「BASE」初心者。 いきなり実際のユーザーに依頼をするのは少しハードルが高かったので、ショップオーナーではなく、「BASE」をまだ触ったことがない中途入社の方たちに被験者となってもらいました。 また、「BASE」のユーザーはスマートフォンを利用することが多いことや、スマートフォンを使って開設するユーザーの方が、PCユーザーに比べて離脱率が高いことから、ユーザビリティテストは被験者が持っているスマートフォンで行いました。 ユーザーテストで観測したい項目 今回のユーザビリティテストをする上で、観測したかったのは以下の項目です。 初見でどんな内容を入力するのか、どこにどのくらい時間をかけたのかわかるように、被験者のSP画面 どの位置で迷ったか、どこをスルーしたのかなどがわかるように、被験者の手元 疑問点やその時の感情がわかるように、被験者の声 感情と操作を一致させるために、被験者の表情 それぞれを、このようにして観測しました。 最小で被験者・ヒアリングする人の2名で実施することができます。 事前に設定しておいたこと ユーザーの設定 今回の場合、被験者はBASE社員であり実際のショップオーナーと状況が少し異なっていたので、 商材やその他の設定はペルソナ決めの要領で事前に用意して、当日被験者に共有しました。 ユーザビリティテストのセットアップと実施 今回の方法では、ユーザビリティテストじたいのセットアップと、被験者のデバイスへのセットアップがあるところが少し大変なポイントです。 それぞれ工程を分けて説明します。 テストじたいのセットアップ Zoomを立ち上げる→メンバーと被験者にも呼びかけて入室してもらう 被験者以外のZoomの音声をミュートにする 被験者のセットアップを行う 自分のSPでもZoomに入室してビデオをオンにし、被験者の手元を撮影する Zoomの画面収録を開始する ※ 画面収録はホストしかできませんのでご注意を! ステップ6では、このようにSPをハンガーラックに括り付けることで被験者の手元を撮影しました。 Zoomのビデオではズームすることができないので、できるだけ近くにSPをセッティングする必要があります。 (Zoomでズームはできない。というのも今回得た知見です。寒) 被験者のセットアップ 今回は被験者がMacとiOSを使っている場合のテストの方法の紹介です。 PCとSPをLighteningケーブルで接続する QuickTime Playerを立ち上げ、「新規movie作成」 録画ボタンの横の矢印から自分のiPhoneを選択することで、自分のSP画面をPCに写す PCでZoomに入室し、ビデオをオン。QuickTime Playerを画面共有し、PCに共有しているSPの画面をテストメンバー全員に見せる ※ この時声を録音したいので、被験者のPCのZoomはマイクをオンにする 参考: https://minatokobe.com/wp/it-information/tips/post-31188.html 以上のステップを踏むと、このように 被験者のSP画面 被験者の手元 被験者の声 被験者の表情 全てを同時に見ることができます! テストメンバーは遠隔にいてもZoomでテストの様子を見ることができました! tips紹介 この手法でユーザビリティテストをする上で、以下のことを被験者にお願いしました! その時の感じた違和感や疑問を知りたいので、独り言を言いながら行ってもらう SPで確認できるメールアドレスを準備する(メアド認証があるからね!) SPの通知をOFFにする(ZoomでPJメンバーにスマホの画面が共有されるからね!) 今回はBASEメンバーにテストをお願いしたので、比較的スムーズに行えました! 結果と所感 よかったこと ユーザビリティテストの知見が少ない中で、企画から実行までMove Fastに行えました。 また、この記事ではユーザビリティテストの手法に重点を置いてブログを書きましたが、 実際には仮説検証の場としてのテストであることが大切だと改めて感じました。 DESIGN PROJECTではただ単に古いUIを新しくするだけでなく、「なぜ古いUIだと問題が生じているのか」「古いUIの中でもどこを改善するのが効果的なのか」「どのUIや文言がユーザーを混乱させているのか」について、事前に定量データから仮説立ててテストまで実施することで、これまで以上にユーザーファーストな改善を行えるのではないかという期待があります。 そして、大きな録画設備がなくても、身近にあるデバイスで本格的なユーザビリティテストができたことも知見につながりました。 リリースはもう少し先になりそうですが、ショップオーナーの皆様に新しいUIを触っていただけるのが楽しみです! 反省点 メモを取りながら質問するのが難しかったです、、!書記は質問者とは別に担当を決めると良いかもしれません! また、Android端末の場合はiOSとは別の方法で画面共有をするのですが、少し手間取りました。 Andoroid端末でのテストの様子はまた別の記事で書けたらと思います! まとめ いかがだったでしょうか? この記事では、特別な施設や機械がないチームでもユーザビリティテストをする方法を説明しました。 どなたかのお役に立てば嬉しいです! BASEのデザインチームは、今回のように改善企画、リサーチ、ユーザビリティテスト、デザイン、コーディングをチーム内で完結させるプロジェクトもあり、デザイナーの裁量が大きい会社だと感じています! アドベントカレンダーでは、デザインチームからは既に北村さんが「デザイン編集リニューアルまでの長い道のり」の記事を書いているので必読です。 https://devblog.thebase.in/entry/2020/12/14/130000 また、明日はPMDとデザインチームを兼任する藤井さんがテキストライティングの記事を公開予定。 BASEのデザインチームに興味を持ってくれた方はこちらの募集要項からエントリーしていただけると嬉しいです!! https://open.talentio.com/r/1/c/binc/homes/4380 それではみなさん、メリークリスマス🎄👋
私がGoのソースコードを読むときのTips この記事はBASE Advent Calendar 2020の23日目の記事です。 devblog.thebase.in BASE BANK 株式会社 Dev Division でSoftware Developer をしている清水( @budougumi0617 )です。 freeeさんのAdvent Calendarでも同様の話題がありましたが 1 、私も今回はソースコードリーディング(Go)について書かせていただきます。 なぜ読むのか ライブラリやツールのコードを読む 言語のフォーマルなコーディングを学ぶ コードリーディングをするときのTips IDEを使って読む godocと一緒に読む 関連記事と一緒に読む 動かしながら読む デバッグしながら読む みんなで一緒に読む 終わりに 参考リンク なぜ読むのか まずなぜコードリーディングをするのでしょうか。 Goに限らず業務で多用される言語やフレームワークはさまざまなリッチな機能を提供しています。 それらを利用すればサンプルコードを少し編集してつなぎ合わせるだけでも動くコードを実装できます。 しかし、問題に直面したりあるいはバグなのか自分の使い方が悪いのかわからない場合、コードの中身を理解している必要があります。 ライブラリやツールのコードを読む 我々のGoのプロダクトは標準ライブラリの他にサードパーティのOSSをいくつか組み合わせることでwebサービスを実現しています。 車輪の再発明はしなくてもその車輪がどのような作りになっているのかは把握しておくと不具合発生時に安心です。 同様に普段利用しているツールの挙動を把握するにもコードリーディングは有用です。 弊社では Terraform や ecspresso などのツールを利用しています。 上記ツール以外にもGoで書かれているツールが多いため、すこし不思議な挙動があってもコードで確認できます 2 。 言語のフォーマルなコーディングを学ぶ GoはGoで実装されているため、Goが読めればその内部実装を読めます。 標準パッケージのコードを読むことには次のようなメリットがあります。 Goチームが実装・レビューした命名規則やテストの書き方がわかる その他のコードと比較してあらゆる場面を想定された実装がされている 実装者と見ず知らずの第三者が使っても呼び出し方を間違えないような設計がされている 3 標準パッケージはGopher全員が利用するパッケージです。 多くの呼び出し状況に対応する内部実装とメソッドシグネチャは大いに設計の参考になるでしょう。 また、たとえば変数名などで迷ったときは標準パッケージをgrepすることで「Go way」を類推することもできます。 コードリーディングをするときのTips 「ではGitHubを開いてコードを読みましょう!!」…では難しいですね。コードリーディングを効率的に行なうために私が実践しているTipsをいくつか紹介します。 今回は私が直近で読んだ DNSリゾルバ を例にします。 具体的には「webサーバでリクエストを受け取るたびに http#Client オブジェクトを作って外部APIを叩く実装を書いてるけど、これってDNS Lookupとかどうなるんだっけ?」というようなことを調べていました。 // 該当エンドポイントにリクエストを受け取るたびに外部APIにHTTP通信するハンドラ func indexHandler(w http.ResponseWriter, r *http.Request) { // Prepare request for another service cli := &http.Client{} res, err := cli.Do(req) // Parse response } IDEを使って読む 元も子もないですが、GoLandやLSPを使って読むのが圧倒的に速いです。 特にGoの場合は明示的にインターフェイスを実装しないので、「このインターフェイスを満たす実装は?」ということを調べるときはIDEに頼ったほうがよいでしょう。 godocと一緒に読む まずは何にせよ仕様を確認します。 最近のGoは標準パッケージも含めてpkg.go.devを検索することで仕様を確認できます。 pkg.go.dev DNSリゾルバについて調べたところ、 net パッケージにセクションが設けられ仕様が記載されていました。 普段は net/http パッケージばかりみているのでまったく読んだことがありませんでした。 pkg.go.dev 関連記事と一緒に読む 仕様の他にStack Overflowやブログ記事に情報がないか検索してみます。 Goは「Go」なのですが、検索のときは素直に「golang」で検索します。 今回は「 golang dns resolver 」「 golang dns ttl 」などでググりました。 (これは暗黙知ですが)信頼できるGopherがDNSリゾルバについて記事を書いていたので参考にしてみます。 qiita.com http#Client 構造体に自前のDNSリゾルバを設定する方法がわかりました。デバッグコードを仕込んで自前実装を差し込んで検証してもよいかもしれません。 shogo82148.github.io Go1.7からnet/http/httptraceというパッケージが追加され、 名前解決やコネクション確立etcのタイミングにフックを仕込めるようになりました。 これを利用すれば各段階でどの程度時間がかかっているかが具体的に分かるはずです。 頑張って自前でフックを差し込んでもよいのですが、 deeeetさんのgo-httpstatという便利パッケージがあるので、 これをありがたく利用させていただきます。 go-httpstatを使うと時間計測を行うコードを簡単に差し込むことができます。 こちらを読むと、仕様でデバッグログのようなものを簡単に出力できることがわかりました。 pkg.go.dev github.com これを使ってDNSリゾルバの挙動を検証するコードを書いてみます。 動かしながら読む コードを読むだけより動かしたほうが圧倒的に理解度が上がるので、ミニマムな検証コードを書きます。 今回は「リクエストを受けるたびに http#Client オブジェクトしてリクエストを飛ばすwebサーバ」を実装しました。 // 表示スペースの都合上エラーハンドリングは省略 package main import ( "fmt" "io" "io/ioutil" "log" "net" "net/http" "time" "github.com/tcnksm/go-httpstat" ) func indexHandler(w http.ResponseWriter, r *http.Request) { var result httpstat.Result req, err := http.NewRequestWithContext( r.Context(), http.MethodGet, "https://budougumi0617.github.io" , nil , ) ctx := httpstat.WithHTTPStat(req.Context(), &result) cli := &http.Client{} req = req.WithContext(ctx) res, _ := cli.Do(req) _, _ := io.Copy(ioutil.Discard, res.Body) res.Body.Close() result.End(time.Now()) w.Header().Set( "Content-Type" , "text/plain" ) w.WriteHeader(http.StatusOK) _, _ = fmt.Fprintf(w, "response code: %+v" , result) } func main() { srv := &http.Server{ Addr: ":8080" , Handler: http.HandlerFunc(indexHandler), } srv.ListenAndServe() } 普通の動作確認だったら実行がしやすいテストコードの形式でも良いと思います。 今回は「リクエストを受けるたびに(毎回別のgroutine上で)」という状況の挙動が知りたかったのでwebサーバの検証コードを書きました。 また、ネットワークという比較的OS層に近いロジックの動作を確認したかったため運用同様Linux上で動作させたくDockerfileも用意しました。 FROM golang:1.15.6-alpine3.12 as build-env ENV CGO_ENABLED 0 RUN apk add --no-cache git WORKDIR /debuggingTutorial/ ADD . /debuggingTutorial/ RUN go build -o /debuggingTutorial/srv . WORKDIR /go/src/ RUN go get github.com/go-delve/delve/cmd/dlv # 後述するデバッグ用の設定 FROM alpine:3.12 as debugger WORKDIR / COPY --from=build-env /debuggingTutorial/srv / COPY --from=build-env /go/bin/dlv / EXPOSE 8080 40000 CMD [ " /dlv ", " --listen=:40000 ", " --headless=true ", " --api-version=2 ", " exec ", " /srv " ] # デバッガのアタッチなしで起動させる設定 FROM alpine:3.12 as server COPY --from=build-env /debuggingTutorial/srv / EXPOSE 8080 CMD [ " /srv " ] Dockerで立ち上げたwebサーバへ2回リクエストを送ったときのログです。 $ docker build -t til/debug --target server . && docker run -d --rm -p 18080:8080 --name resolver til/debug $ curl localhost:18080/ response code: DNS lookup: 4 ms TCP connection: 21 ms TLS handshake: 89 ms Server processing: 10 ms Content transfer: 1 ms Name Lookup: 4 ms Connect: 25 ms Pre Transfer: 115 ms Start Transfer: 127 ms Total: 128 ms $ curl localhost:18080/ response code: DNS lookup: 0 ms TCP connection: 0 ms TLS handshake: 0 ms Server processing: 8 ms Content transfer: 1 ms Name Lookup: 0 ms Connect: 0 ms Pre Transfer: 0 ms Start Transfer: 8 ms Total: 9 ms 実際に動作させてみると、リクエストを受け取るたびに http.Client 構造体を新規に生成しているのに2回目のhttptraceではTCPコネクションとDNS Lookupが省略されています。 これは http.DefaultTransport 経由でコネクションが使い回されているからなのですが、正直実際に検証するまで自覚せずにつかっていました。 デバッグしながら読む 次のような処理が多いとただコードを読むだけではあまり理解が進みません。 クロージャが多く実動作でどんな関数(ロジック)が呼ばれているのかわからない inteface{} 型の変数やスライスに何が入るのかわからない こんなときはデバッガを利用して実際に動かしたときのメモリの状態を確認します。 Goの場合は delve というOSSを使うことでデバッグできます。 操作感覚はgdbに近いです。VS CodeやJetBrains IDEでデバッグをするときも裏でdelveが動いています。 github.com 詳細な操作方法は割愛しますが、デバッグモードで起動しておけば、Webサーバのプロセスや起動中のDocker上のプロセスのデバッグも可能です。 pleiades.io 前述の httptrace の知識より、DNSリゾルバの処理が走るときは DNSStart メソッドが呼ばれることを知っていたので、その周辺にブレークポイントを設置して動かしてみます。 私はGoLandを使ってデバッグしていますが、gdbなどに慣れていればターミナルからステップ実行など可能です。 実際にブレークポイントを使ってデバッグしているときの状態が次のスクショ画像です。 GoLandでデバッグ中 私はコードを読むだけでは http#Client.Do メソッドからうまく net#Resolver.lookupIPAddr メソッドまでまでたどり着けていませんでした。 しかし、デバッガで止めてスタックトレースを確認することどの関数やメソッドを経由して net#Resolver.lookupIPAddr メソッドが呼ばれているのかわかりました。 上記のTipsをオブジェクトのフィールド値を少し変更してから繰り返すことでコードの挙動を読み解いていきます。 みんなで一緒に読む コツや勘所がわかってくるとコードリーディングのスピードもどんどん速くなっていきます。 しかし、「コツや勘所がわかるためにはコードをたくさん読まないといけない」という鶏卵問題もあります。 それに対して、弊社ではBASEメンバーと合同で定期的にGoコードリーディングパーティを実施しています。 ひとりでは詰まってしまうようなこともみんなで見ていると解決の糸口がみつかったり、他のメンバーのエディタ捌きを盗むことができます。 お題にするコードはその時の参加者の気分で特に決まっていません。 業務でツールを使う前にどんな動きをするのか読みたい この前 terraform apply で失敗したときのエラーメッセージがどう出ているか確認したい Go1.15で追加された機能がどのように実装されているのか読んでみたい terraform-provider-awsにPRを作るのでテストケースを考えたい その時々の課題感でコードを読むので、自分では読もうと思っていなかったコードを読んだり、問題解決のきっかけになったりと毎回勉強になっています。 終わりに 今回の記事ではコードリーディングの重要さと私がコードを読むときによくやる方法をいくつか紹介させていただきました。 何かひとつでも参考になると幸いです。 最後に、今回はコードリーディングにフォーカスしましたが、BASE BANKの開発チームでは自分たちで開発したサービス・機能をグロース・サポートまで担当します。 2021年はコードだけでなくシステム開発ライフサイクル全般に積極的に関わっていきたいぞ!という方は @budougumi0617 までDMください。 open.talentio.com 明日はBASEデザインチームの河越さんです! 参考リンク https://pkg.go.dev https://github.com/go-delve/delve https://pleiades.io/help/go/attach-to-running-go-processes-with-debugger.html https://developers.freee.co.jp/entry/how-to-read-source-code-of-middleware ↩ むしろGoで書かれていることがツール選定理由のひとつになりえます。 ↩ strings.TrimRight 関数と strings.TrimPrefix 関数のような例もありますが。 ↩
この記事はBASE Advent Calendar 2020の22日目の記事です devblog.thebase.in どうもこんにちは、Web Frontend Groupの青木です 今回は、個人的にWeb開発を補助する目的でPuppeteerを使っていることがあるので、その話をします 前半では、普段どう使っているのか 後半では、ブラウザ操作を記録してコード生成してくれるRecoderについて紹介します そもそも、Puppeteerって? Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol. Puppeteer runs headless by default, but can be configured to run full (non-headless) Chrome or Chromium. はい、Chrome、またはChromiumを、DevTools Protocolを使って制御できるNodeライブラリです(※1) どう使っているのか 主に、開発中や問い合わせの動作確認の際に、手動で繰り返し行うことに対して使っています たとえば 開発環境で商品をn件購入する 開発環境で商品をn件登録する(※2) 開発環境でショップを新規に開設する リグレッションが発生していないか変更に伴い繰り返し動作確認する などですね 上記操作を必要とする要件は内容によってさまざまですが、たとえば注文管理の開発をしていた際に、ページャーの動作確認がしたくて商品をn件購入する、みたいな使い方をしていました Chrome CanaryのRecorderを使ってコードを生成してみる 普段はChrome DevToolsから操作する要素のselectorをcopyしてコードを書いて...としていたのですが、Chrome CanaryにPuppeteerで操作するコードを生成するRecoderがついて話題に(※3, ※4)なったりとPuppeteer使う敷居が下がってくれそうだったので、そちらの紹介も兼ねて試します Recoderを有効化し、コード生成 大まかには次の手順でコード生成できます Chrome CanaryのDevTools > Setting Recorder > ExperimentsからReoderを有効化 DevToolsを再読み込み SourcesのタブからRecordingsを選択し、Add Recordingを実行 Recordを実行し、画面操作 画面操作を終えたらRecordを終了し、生成されたコードを取得 画面操作で生成した流れは、商品をカートに入れて、カートから削除したものになります 実行環境を用意し、ひとまず実行、修正 今回向けに実行環境(※5)を用意したので、そちらに生成されたコードの主要部分を貼り付けひとまず実行してみます import { launch } from 'puppeteer' import * as expect from 'expect' ;(async () => { const browser = await launch ( { headless: false , args: [ '--window-size=2000,1000' ] , devtools: true , } ) const page = await browser.newPage () await page.setViewport ( { width: 1440 , height: 900 } ) await page. goto( URL ); await page.click ( "aria/マルチミニポシェットバッグ ¥ 4,800 20%OFF" ); await page.submit ( "div#mainitmAyLjI2NT > div.item-detail_container_3758d544 > div.item-detail_details_3758d544 > div.item-detail_itemOrder_3758d544.js-itemOrder.cot-itemOrder > form" ); await page.click ( "aria/削除" ); await page.click ( "div#orderItems > div.innerContent > div" ); await browser. close (); } )() さっそく、実行以前にTypeScriptで次の指摘が入ったのでその部分を修正 Property 'submit' does not exist on type 'Page'. - await page.submit(selector); + await page.click(selector); 次に実行すると、一番最初のclickで失敗するのでDevToolsからselectorのコピーをして修正 - await page.click("aria/マルチミニポシェットバッグ ¥ 4,800 20%OFF"); + await page.click("#itemList > li:nth-child(1) > a > div > div > div > div.items-grid_infoContainer_c3a2778a"); 次に実行すると、 Error: No node found for selector となったので要素が出るまで待つ処理を追加 + await page.waitForSelector(selector); await page.click(selector); 上記のような修正をして、ひととおり動作を再現 生成したコード修正版 import { launch } from 'puppeteer' import * as expect from 'expect' ;(async () => { const browser = await launch ( { // headless: false, args: [ '--window-size=2000,1000' ] , // devtools: true, } ) const page = await browser.newPage () await page.setViewport ( { width: 1440 , height: 900 } ) await page. goto( URL ) await page.click ( '#itemList > li:nth-child(1) > a > div > div > div > div.items-grid_infoContainer_c3a2778a' ) await page.waitForSelector ( 'div#mainitmAyLjI2NT > div.item-detail_container_3758d544 > div.item-detail_details_3758d544 > div.item-detail_itemOrder_3758d544.js-itemOrder.cot-itemOrder > form' ) await page.click ( 'div#mainitmAyLjI2NT > div.item-detail_container_3758d544 > div.item-detail_details_3758d544 > div.item-detail_itemOrder_3758d544.js-itemOrder.cot-itemOrder > form' ) await page.waitForSelector ( 'aria/削除' ) await page.click ( 'aria/削除' ) await page.waitForSelector ( 'div#orderItems > div.innerContent > div' ) const innerText = await page. evaluate (() => ( document .querySelector ( 'div#orderItems > div.innerContent > div' ) as HTMLDivElement ) .innerText ) expect ( innerText ) .toBe ( 'ショッピングカートに商品は入っていません。' ) await browser. close () } )() expectを追記、headlessで動くようlaunchの引数オプション一部コメントアウト 気に入っているところ 繰り返し画面操作をする手間を少し楽できる 画面操作で作りたいデータを作るハードルを下げてくれる Recorderで生成したコード、下書き程度ではあるが結構嬉しい 課題と感じているところ Recorderで生成したコードは手入れする前提 コードの寿命が短い UIの変更やデプロイによって、要素の特定に使っているclass名のhash部分が変わったりするため 最後に 最近のrecorderを使ってみた結果、興味を持ってもらえそうだと記事にしてみました 課題となる性質を理解した上で、少しでもWebの画面操作の自動化に対する敷居が下がったら嬉しいなと思うところです 明日は、BASE BANK株式会社の清水さんです! お楽しみに! 参考 ※1 puppeteer/puppeteer: Headless Chrome Node.js API ※2 商品をまとめて登録したい場合には、ショップオーナー様は CSV商品管理 から行えます ※3 Automatically record puppeteer tests - Chrome DevTools - Dev Tips ※4 Puppeteer と ARIA Handler | Medium ※5 aokiken/baseinc-advent-calendar-2020
この記事は BASE Advent Calendar 2020 の21日目の記事です。 はじめに お久しぶりです。BASEビール部部長(兼Data Strategyチーム)のbokenekoです。 今年はほんと辛い1年でしたね。コロナで全くビール部の活動ができませんでした。 その反動で通販でクラフトビール買いまくって冷蔵庫が溢れました。定期便の利用は計画的に。 と、まあそんな私生活はおいておいて、今日はData Strategyチームでのリコメンドにおける取り組みについてお話しします。 BASEでは、ネットショップ作成サービス「BASE」で開設された130万のショップが集まる購入者向けのショッピングアプリ「BASE」を提供しています。アプリでは商品やショップのおすすめを表示していますが、ここに使われているリコメンドのアルゴリズムは実は複数アルゴリズムの組み合わせになっています。例えば協調フィルタリングやFactorization Machinesなどです。今回はそこにさらにGraph Neural Networkによるリコメンドを追加しようとしているというお話をしようと思います。 Graph Neural Networkとは Graph Neural Network(GNN)は2017年頃から話題がでてきた、Deep Learningでグラフ構造を扱う手法のことです。GNNでは大まかに以下のような問題を解くことができます。 Node classification/regression ノードの分類やノードが持つ値の推定 Link prediction 二つのノード間に特定のエッジが存在するかどうかを予測 Graph classification/regression グラフ自体の分類やグラフに関連する数値の推定 GNNのリコメンドへの応用 Link Prediciton Link Predictionはグラフ上のあるノードとノードの間にエッジが存在するか否かを推測する問題です。現在観測されているグラフ構造から、まだ観測されていないエッジが存在しているかどうかを推測します。 今回のリコメンドで言えばユーザーノードと商品ノードを購入エッジで繋いだグラフがあったとして、今はまだないあるユーザーと商品の間の購入エッジが存在しうるかどうか、つまりまだ買ってないけど買ってくれる可能性が高いかどうかを推測します。 ユーザー・商品購買グラフ グラフ構造 購入エッジの存在を予測するにあたって、利用したデータは以下の通りです ユーザーがどの商品を購入したか ユーザーがどの商品をお気に入りしたか ユーザーがどのショップをフォローしたか どのショップがどの商品を販売しているか これらの情報をユーザー・商品・ショップを繋ぐグラフ構造にします。 ユーザー・商品・ショップを繋ぐグラフ このグラフでbuyエッジがあるユーザーと商品の間にあるかどうかを予測するのが目的になります。 (ちなみにこうしたノード・エッジが複数種類あるグラフのことをheterogeneous graphと呼びます。) モデル 今回採用したモデルは R-GCN というモデルです。 R-GCNの構造(論文から借用) R-GCNはあるノードについて、そのノードに出入りしている各エッジ種ごとにGCNを行いそれをまとめるという方法でグラフを畳み込みます。これで計算されたユーザーと商品の特徴量に対して購買エッジが存在するかを分類問題として解きます。 GCNは通常のDNNでいうところのCNNのようなものをイメージしていただければよいです。 実装 処理の手順は以下のようになります。 各ノードに対してノード種毎にIDを振ってグラフを作成 グラフをR-GCNに通して各ノードの特徴量を計算 ユーザーと商品の間にあるリンクが存在するかを二値分類問題として解く 実装にあたっては pytorch と dgl を利用しました。 グラフ作成 グラフは双方向グラフとして作成しています。 import torch import dgl # buy c2i_buy = torch.tensor([(customer_node_id, item_node_id), ...]) # fav c2i_fav = torch.tensor([(customer_node_id, item_node_id), ...]) # follow c2s_follow = torch.tensor([(customer_node_id, shop_node_id), ...]) # sell s2i = torch.tensor([(shop_node_id, item_node_id), ...]) graph_data = { ( "customer" , "buy" , "item" ): (c2i_buy[:, 0 ], c2i_buy[:, 1 ]), ( "item" , "bought-by" , "customer" ): (c2i_buy[:, 1 ], c2i_buy[:, 0 ]), ( "customer" , "fav" , "item" ): (c2i_fav[:, 0 ], c2i_fav[:, 1 ]), ( "item" , "fav-by" , "customer" ): (c2i_fav[:, 1 ], c2i_fav[:, 0 ]), ( "customer" , "follow" , "shop" ): (c2s_follow[:, 0 ], c2s_follow[:, 1 ]), ( "shop" , "follow-by" , "customer" ): (c2s_follow[:, 1 ], c2s_follow[:, 0 ]), ( "customer" , "buy-from" , "shop" ): (c2s_buy[:, 0 ], c2s_buy[:, 1 ]), ( "shop" , "sell-to" , "customer" ): (c2s_buy[:, 1 ], c2s_buy[:, 0 ]), ( "shop" , "sell" , "item" ): (s2i[:, 0 ], s2i[:, 1 ]), ( "item" , "selled-by" , "shop" ): (s2i[:, 1 ], s2i[:, 0 ]), } g = dgl.heterograph(graph_data) training import torch model = RelationPredict(g, 64 , 16 ) opt = torch.optim.Adam(model.parameters(), lr= 0.01 ) model.train() for epoch in range ( 60 ): opt.zero_grad() # グラフから各ノードの特徴量を計算 embed = model(g) labels = torch.zeros( 10000 , dtype=torch.long) # グラフには取り込んでないが存在する購買エッジをpositive sampleとして利用 pos_s = [] pos_d = [] random.shuffle(c2i_buy_train) for c, i in c2i_buy_train[: 5000 ]: pos_s.append(c) pos_d.append(i) pos_s = torch.tensor(pos_s) pos_d = torch.tensor(pos_d) labels[: 5000 ] = 1 # ランダムに取り出したユーザー・商品の組をnegative sampleとして利用 # 全組み合わせのうち本当に存在してるエッジは無視できるほど少ないので問題はず neg_s = torch.randint(g.number_of_nodes( "customer" ), ( 5000 ,)) neg_d = torch.randint(g.number_of_nodes( "item" ), ( 5000 ,)) train_data = { "srcs" : torch.cat([pos_s, neg_s]), "dsts" : torch.cat([pos_d, neg_d]), "labels" : labels } loss = model.get_loss(embed, train_data) loss.backward() opt.step() モデル全体 import torch import torch.nn as nn import torch.nn.functional as F import dgl import dgl.nn as dglnn class RelGraphConvLayer (nn.Module): """ R-GCN layer """ def __init__ (self, in_feat, out_feat, rel_names, bias= True , activation= None , self_loop= False , dropout= 0.0 ): super (RelGraphConvLayer, self).__init__() self.in_feat = in_feat self.out_feat = out_feat self.rel_names = rel_names self.bias = bias self.activation = activation self.self_loop = self_loop self.conv = dglnn.HeteroGraphConv({ rel : dglnn.GraphConv(in_feat, out_feat, norm= 'right' , weight= False , bias= False ) for rel in rel_names }, aggregate= 'sum' ) self.weight = nn.ParameterDict() for rel_name in rel_names: weight = nn.Parameter(torch.Tensor(in_feat, out_feat)) nn.init.xavier_uniform_(weight, gain=nn.init.calculate_gain( 'relu' )) self.weight[rel_name] = weight # bias if bias: self.h_bias = nn.Parameter(torch.Tensor(out_feat)) nn.init.zeros_(self.h_bias) # weight for self loop if self.self_loop: self.loop_weight = nn.Parameter(torch.Tensor(in_feat, out_feat)) nn.init.xavier_uniform_(self.loop_weight, gain=nn.init.calculate_gain( 'relu' )) self.dropout = nn.Dropout(dropout) def forward (self, g, inputs): g = g.local_var() wdict = {} for rel_name in self.rel_names: wdict[rel_name] = { "weight" : self.weight[rel_name] } hs = self.conv(g, inputs, mod_kwargs=wdict) def _apply (ntype, h): if self.self_loop: h = h + torch.matmul(inputs[ntype], self.loop_weight) if self.bias: h = h + self.h_bias if self.activation: h = self.activation(h) return self.dropout(h) return {ntype : _apply(ntype, h) for ntype, h in hs.items()} class RelGraphEmbed (nn.Module): """ node embeding layer node_id -> node embeding """ def __init__ (self, g, embed_size): super (RelGraphEmbed, self).__init__() self.g = g self.embed_size = embed_size # create weight embeddings for each node for each relation self.embeds = nn.ParameterDict() for ntype in g.ntypes: embed = nn.Parameter(torch.Tensor(g.number_of_nodes(ntype), self.embed_size)) nn.init.xavier_uniform_(embed, gain=nn.init.calculate_gain( 'relu' )) self.embeds[ntype] = embed def forward (self): return self.embeds class RelationPredict (nn.Module): def __init__ (self, g, h_dim, out_dim, num_hidden_layers= 1 , dropout= 0.5 , use_self_loop= True ): super (RelationPredict, self).__init__() self.h_dim = h_dim self.out_dim = out_dim self.rel_names = list ( set (g.etypes)) self.rel_names.sort() self.num_hidden_layers = num_hidden_layers self.dropout = dropout self.use_self_loop = use_self_loop self.embed_layer = RelGraphEmbed(g, self.h_dim) self.layers = nn.ModuleList() # i2h self.layers.append(RelGraphConvLayer( self.h_dim, self.h_dim, self.rel_names, activation=F.relu, self_loop=self.use_self_loop, dropout=self.dropout)) # h2h for i in range (self.num_hidden_layers): self.layers.append(RelGraphConvLayer( self.h_dim, self.h_dim, self.rel_names, activation=F.relu, self_loop=self.use_self_loop, dropout=self.dropout)) # h2o self.layers.append(RelGraphConvLayer( self.h_dim, self.out_dim, self.rel_names, activation= None , self_loop=self.use_self_loop)) # score self.fc1 = nn.Linear(self.out_dim* 2 , self.out_dim) self.act1 = nn.ReLU() self.fc2 = nn.Linear(self.out_dim, 1 ) self.act2 = nn.Sigmoid() def calc_score (self, h, srcs, dsts, src_type= "customer" , dst_type= "item" ): s = h[src_type][srcs] d = h[dst_type][dsts] x = torch.cat([s, d], dim= 1 ) x = self.act1(self.fc1(x)) x = self.act2(self.fc2(x)).view(- 1 ) return x def forward (self, g): h = self.embed_layer() for layer in self.layers: h = layer(g, h) return h def get_loss (self, h, train_data): srcs = train_data[ "srcs" ] dsts = train_data[ "dsts" ] labels = train_data[ "labels" ] score = self.calc_score(h, srcs, dsts) predict_loss = F.binary_cross_entropy_with_logits(score, labels) return predict_loss 結果 直近90日分の売り上げ・お気に入り商品・フォローショップのデータを用いて、売り上げの80%をグラフに取り込み、残りの18%を教師データ、2%をテストデータとして試験したところ、テストデータでは以下のような結果になりました。 accuracy: 0.891 recall: 0.858 specificity: 0.925 precision: 0.920 もちろんここでの成績が直ちにユーザーの満足度につながるわけではないのですが、数値的にはかなり優秀なんではないでしょうか。 最後に このモデルは現在開発環境で試験中で、問題なければ来年頭にはアプリのリコメンドに組み込まれる予定です。 GNNはグラフ構造にならば何にでも適用できますので是非皆さんがお持ちのデータでも試していただけると。 明日は、フロントエンドチームの青木さんです。お楽しみに。 もしBASEで働くことに興味を持っていただけた方は、ぜひご連絡ください。 採用情報はこちらから 最後の最後に なんかre:Inventで Neptune ML とかいうの出てましたね。NeptuneでGNNできるんですね。もうちょっとさあ、早くだして欲しかったなぁ。
TDDで過去と戦った話 この記事はBASE Advent Calendar 2020 20日目の記事です。 devblog.thebase.in こんにちは。BASE BANK 株式会社 Dev Division にて、 Software Developer をしている永野( @glassmonekey )です。 今回は先日リリースした「BASE」上での売上情報をCSVでダウンロードできる売上データダウンロードAppの裏話的な内容となります。 タイトルにTDDとつけたものの、そこまでTDDの話は出てきませんのでご了承ください。 BASE( ᐛ )⛺️ 新しい機能「売上データダウンロード App」が誕生しました〜!👶🎉 売上にまつわるデータをCSVにてダウンロードすることが可能💡 またサービス手数料や、決済手数料についても確認することができます👍 利益の分析にも使うことができるので、ぜひ使ってね💴 https://t.co/WTM4bJlaei — BASE(ベイス) (@BASEec) 2020年11月18日 売上データダウンロードAppについて apps.thebase.in 最初に今回の実装した機能の説明をします。 使い方 のヘルプページに詳細は記載しておりますが、「BASE」上の売上やお金の動きをCSVでダウンロードできる機能です。 この機能の元々は、実験的に弊社のCSEチームが手動で明細を内部向けに出力していた機能でした。それを「BASE」の拡張機能としてリリースすることとなり開発の手はずとなりました。 CSEチームが普段どのような業務をしているかに関しては、弊社の小林( @sharakoba )が書いた 社内業務改善を行うCSEグループのご紹介 を御覧ください。 devblog.thebase.in 「BASE」は今年で8周年 少し話は変わりますが、先日弊社は今年で8周年を迎えました。ネットショップ作成サービス「BASE」は、BASE株式会社設立の1ヵ月ほど前にリリースされたので、サービスもリリースしてから8年の月日が経過しています。 【御礼】 BASE株式会社は創業8周年を迎えました https://t.co/UOuAWrES3G @binc_jp より — BASE, Inc. (@binc_jp) 2020年12月11日 私自身は入社して半年ではありますが、これだけ長く愛されるサービスに関わることができてエンジニアとして感無量だったりします。 ただ、8年も経過するとサービスの内部仕様は当然変化しますし、データもローンチ当初とは色々異なる面がでてきます。 今回作ろうとしていた機能は、データベースの内容を集計して出力する機能なだけではありました。 しかし、過去のデータを加味すると現在のアプリケーションコードでは読み取れないデータの動きなどを想定する必要が出てくることとなり、それなりに難易度の高い機能だったわけです。 過去と戦うアプローチについて では8年の過去と戦うにあたり事前に要件などをそろえることは容易でありませんでした。 今回役に立った観点は以下の3点でした。 生SQLを書く TDDを開発プロセスの軸とする 推測をせず計測をする 生SQLを書く 集計に必要なデータも複数テーブルに散らばっていたり、複雑な条件といった実情がありました。なので、処理的観点と可読性的観点で手続き的に書くことは現実的ではありませんでした。 そこで今回の実装においては、生SQLを書くことで実現させました。 また生SQLを使っていたおかげで、ソースコード上のSQLからGUIツールを使用して、実行計画の検証などが容易に行うことができた点は良かったと考えます。 別途SQLファイルをGit管理するなども検討はしましたがメンテナンス対象を増やしたくなく、SQLをソースコードの中のロジックとして一元管理することを選びました。結果として250行ほどのプロダクションコードで動く生SQLを生むことになってしまいました。 ただ、大規模なSQLのレビューをすることもしてもらうことも現実的ではありません。少なくとも私には難しい… そこで、SQL文自体をレビューすることは半ば諦めて、レビュー時にテストコードや実行計画をつかって共有することでレビューの意図を伝えやすくする工夫をしました。 TDDを開発プロセスの軸とする 今回実装しようとした機能は、基本的なコア機能は集計処理なので、人間が目検で確認することはほぼ意味をなしません。 開発の初期段階では自分たちでデータを作ってチェックしていたのですが、何が正しいか判断がつきずらくあまりワークしませんでした。 そこで、テストデータには過去のデータベースのデータや、CSEチームが作成してくれた先行プロダクトの結果を採用しました。 これにより、我々開発者の手元でフィードバックループを回せるようになったので、より確度の高い開発を効率よく進めることができました。 また、前述の通りプロダクションコードの大部分がSQLを締める実装だったので、レビューも容易ではありませんでした。 テストコードが結果的にどのようなケースを担保するかを明記することになるので、レビュイー側としてはテストコードを見ればよくなります。 どのような意図でSQLを変更したのかのコミュニケーションができたことは良かったです。 ただ、テストのバリエーションに関しては、いたずらに増やしすぎたところもあるので今回の反省として開発に活かしていきたいですね。 推測をせず計測をする また、データ量、バリエーションともに多い機能開発ではあったので事前に、「BASE」上のおおまかなデータ量の分布を算出してはいました。 そのため集計データ量の規模感やワーストケースは事前に把握をできてはいました。 今回の実装処理のボトルネックがデータベースレイヤーだったこともあるので、上記のSQLレイヤーの検証でその要点を押さえることができた点も大変意義のあるものでした。 これにより、我々が作ろうとしている処理の規模感とパフォーマンスを効率よく把握ができました。この点も手戻りの少なく開発できた要因でした。 現在はDBに記録された内容を元に、redashのダッシュボード上で可視化するようにしています。そこから継続的なパフォーマンス確認しています。 Redash上の計測 将来的には弊社で導入をすすめている New Relic に寄せていけたらと思っています。 まとめ 今回は「BASE」上のアプリケーション開発における裏話をさせていただきました。 推測するな計測しろを地でいく開発ができたので、一部予想外な出来事はあったものの大きなハプニングもなく乗り切ることができてよかったという思いです。 今回、TDDというアプローチを取ったことで、エンジニア個人だと観測出来ない問題を手元で観測できるようになりました。その恩恵で、開発期間の中のバグ修正時に、大きな手戻りを引き起こすこともなく効率よく開発を進めることが出来たので実践してみて良かったです。 さらに、今回は大規模SQLをメインに据える選択をしましたが、テストコードのおかげで挙動に対する一定の理解を助けるツールにもなってくれました。レビューなどのチームとのコミュニケーション時に大変助けられました。 リリース後に嬉しい反響もありまして、これを励みに開発者としてこれからも頑張っていきます。 @BASEec の売上データダウンロード機能追加キター!欲しかった機能に歓喜。 https://t.co/RmByzNDcO8 — センベイブラザーズの兄:(有)笠原製菓4代目 (@senbeibrothersA) 2020年11月18日 この機能欲しかったやつ! https://t.co/PXnPn28HJV — 道草 michikusa (@warannahito) 2020年11月19日 昔は「BASEでいい」だったのが最近は「BASEがいい」になってる https://t.co/69azfxhkqi — 陶 (@jamdxsp) 2020年11月19日 そのような開発を一緒にわいわいやっていく仲間を募集中です。 @glassmonekey へのDMなどでも気軽にご相談ください。 open.talentio.com 明日は BASE Data Strategyチームの氏原 ( @beerbierbear )さんによるGNNレコメンドについてです!! こうご期待!!
この記事はBASE Advent Calendar 2020 19日目の記事です。 devblog.thebase.in BASE株式会社 Product Dev Division エンジニアの田中( @tenkoma )です。 10月から、 ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 の社内読書会をオンラインミーティングでやっています。 開発チーム内で設計や実装パターンについて議論できる知識を増やす目的で始めましたが、オンライン開催で、進行難しそうだな、と思いました。最初の4回くらいは、うまく進められてないと感じていましたが、最近はツールの活用法を理解したり、参加者の皆さんに助けられたりして、進められるようになったと思います。 社内読書会の進行役として何をやっているかや、改善したところを紹介します。 開催の形式 週1回 火曜日16:00〜17:00(現状) Slack に読書会チャンネルを作り、話題に興味がある人に入ってもらう Zoom でオンラインMTG 参加者は10〜12名 読む範囲を予め決め、前半30分で読み、話したいこと・感想をGitHub Issueに書く 後半30分で議論 Zoom スクリーンショットは撮っていませんが、コメントを書き込める場所は、GitHub Project/Issueで管理しています。 GitHub Projectを使うと、今どこを進んでいるかが、参加してなくてもわかること、 GitHub Issueを使うと、後で議論を追いかけられること、実際の開発プロジェクトと相互リンクしやすいため使っています。 実際に使っているページを紹介します。 GitHub Project で、進行がわかるようにする 読書会中のコメントの様子 進行役として何をやっているか 書籍購入の稟議取りまとめ スケジュール調整 読書会をゆるく進行する 進行する時難しいと思った点と改善策 議論をどう始めたらいいかわからない 前半の読書タイムが終わった後、議論をどう始めるかが2,3回は難しかったです。 開催している読書会は複数のチームから参加者が集まっていて、今年2月にリモートワーク体制へ移行後に入社された方も複数いる中で 初対面の場合もありました。 予め議論したい話を僕が決めてみても、うまく話が広がらなかったりして、改善の必要があると感じていました。 改善策としては、読書タイムが終わった時点で、すでに GitHub Issue にコメントがたくさん書かれていたので、 それをZoomで映しながら議論したいところをみて行くようにしました。 そうすると、全員で同じ画面をみているので、他の方も話したいことを見つけられるようになります。 また、議論する内容の出発点が画面に映っているので、話の流れも掴みやすそうです。 話の内容が共有できているか把握しづらい オンラインだと、音質があまりよくなかったりして、話の内容を理解するのに集中しづらいこともあります。 話題の理解度が低いと議論が発展しずらそうだなと思いましたので、なるべく話す内容について聞き返すようにしています。 MTG中に生まれる発言のない間をどうするか オンラインだと、オフラインに比べて遅延があるのと、会話に割り込みしづらさがあり、発言のない間が生まれます。 そこに気まずさを感じて何か話さないとな、と思ってしまうのですが、特にネタを提供できるわけでもなかった場合が辛かったです。 改善策は、無いです!というより、発言がない微妙な間を許容する、というのが改善策と言えると思います。 GitHub Issueに書いていただいたコメントを読み返したり、本を読み返したりする時間にしましょう。 そうしていると、話したいことがある人が出てくることが何度もありました。 おわりに リモートワーク体制で、技術書の読書会をやってみて、難しいと思っている点と改善策を紹介しました。 独特の難しさはあると思いますが、ツールを利用したり、話す内容を丁寧に確認することで、読書会の効果を引き上げられるのではないかと思って取り組んでいます。 明日は永野さん( @glassmonekey )のTDDの話です!
この記事は BASE Advent Calendar 2020 の 18 日目の記事です。 こんにちは。BASE BANK 株式会社 Dev Division にて、 Software Developer をしている東口( @hgsgtk )です。 先月・先々月と連続で Terraform に関連したブログを投稿しているのですが 2020 年最終月も Terraform 話で締めさせていただきます ^1 。 TL;DR Terraform 0.14 が GA(General Availability)になった dependency lock file .terraform.lock が追加され、VSC 管理化に含めるかについてプロジェクトによって扱いの検討が必要 0.14.0 では、 ignore_changes = all を使用したリソース定義の扱いにバグがあり 0.14.1 で修正された 当該機能を利用している場合は 0.14.1 >= のリリースがおすすめ Terraform 0.14がGAになった 12 月 2 日 0.14.0リリースタグ が切られ、メジャーバージョン 0.14.x が GA(General Availability) となりました。 www.hashicorp.com Highlights となっている機能として紹介されているものは 3 つあります。一番体感としてわかりやすいのは Concise Diff という機能です。文字通り 差分表示が簡潔に なりました。 module.cwl.aws_cloudwatch_log_group.sample will be updated in-place ~ resource "aws_cloudwatch_log_group" "sample" { id = "/ecs/sample/sample" name = "/ecs/sample/sample" ~ retention_in_days = 0 -> 1 tags = {} # (1 unchanged attribute hidden) } Plan: 0 to add, 1 to change, 0 to destroy. # (1 unchanged attribute hidden) と表示されているように関係がなく変更がない記述に関しては差分表示にて省略され、見やすくなりました。 2つ目が、 機密性の高い Variable に対する CLI 表示での扱い についてです。 sensitive=true という設定によって CLI 表示をマスキングすることが出来るようになりました。次の公式の例がわかりやすいですが、 sensitive=true と設定した variable をリソース定義した場合、コンソール表示が (sensitive) と調整されるようになっています。 Terraform will perform the following actions: # some_resource.a will be created + resource "some_resource" "a" { + name = (sensitive) + address = (sensitive) } Plan: 1 to add, 0 to change, 0 to destroy. www.hashicorp.com 上記の説明にありますが、state ファイル内にはそのままの機密情報が記載されるようになっています。 Sensitive values are still recorded in the state, and so will be visible to anyone who is able to access the state data. もう 1 つの新機能が Provider Dependency Lockfile という機能です。こちらについては 0.14.x へアップグレードする際に扱う方法を検討する必要があります。 Provider Dependency Lockfile Terraform 0.14 では Provider の依存関係を terraform init 時点でバージョンロックする機能が導入されました。 www.hashicorp.com BASE BANK の開発では、 AWS Provider を Provider として利用しています。記事執筆時点(2020 年 12 月 9 日)の最新 3.19.0 の状態で terraform init すると次のような内容で .terraform.lock というファイルが作成されます。 # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { version = "3.19.0" constraints = "~> 3.19.0" hashes = [ "h1:FJwsuowaG5CIdZ0WQyFZH9r6kIJeRKts9+GcRsTz1+Y=", "h1:c/ntSXrDYM1mUir2KufijYebPcwKqS9CRGd3duDSGfY=", "h1:yre4Ph76g9H84MbuhZ2z5MuldjSA4FsrX6538O7PCcY=", "zh:04f0a50bb2ba92f3bea6f0a9e549ace5a4c13ef0cbb6975494cac0ef7d4acb43", "zh:2082e12548ebcdd6fd73580e83f626ed4ed13f8cdfd51205d8696ffe54f30734", "zh:246bcc449e9a92679fb30f3c0a77f05513886565e2dcc66b16c4486f51533064", "zh:24de3930625ac9014594d79bfa42d600eca65e9022b9668b54bfd0d924e21d14", "zh:2a22893a576ff6f268d9bf81cf4a56406f7ba79f77826f6df51ee787f6d2840a", "zh:2b27485e19c2aaa9f15f29c4cff46154a9720647610171e30fc6c18ddc42ec28", "zh:435f24ce1fb2b63f7f02aa3c84ac29c5757cd29ec4d297ed0618423387fe7bd4", "zh:7d99725923de5240ff8b34b5510569aa4ebdc0bdb27b7bac2aa911a8037a3893", "zh:7e3b5d0af3b7411dd9dc65ec9ab6caee8c191aee0fa7f20fc4f51716e67f50c0", "zh:da0af4552bef5a29b88f6a0718253f3bf71ce471c959816eb7602b0dadb469ca", ] } このファイルによって Provider のバージョンがロックされ例えば違うバージョンの Provider を利用した際にエラーとすることで、どの環境でも同一バージョンを保証できます。このファイルは基本的に .terraform.lock は手で変更することはせず、 terreform init -upgrade を用いて更新することが期待されています。 同一バージョンを保証するこの機能を有効活用するかは開発対象のリポジトリによりますが、有効活用する場合は Git 等の VCS の管理対象に含めることが推奨されています。 一方で、バージョン固定は不要だと判断した場合は、 .gitignore にファイルを記載することでこの挙動を無効にすることが可能と説明されています。 You can continue with a model similar to the v0.13 behavior after upgrading to v0.14 by placing .terraform.lock.hcl in your version control system's "ignore" file, such as .gitignore for Git. www.terraform.io 筆者の現場では、Git 管理対象に含めることを選択しました。Git 管理対象に含める場合の今後のアップグレード作業は次のようなパターンが考えられます。 バージョンを固定していない場合は、「単に最新に上げておきたい」といったモチベーションになるでしょう。 terraform init -upgrade をさっと実行することで作業完了です。 一方で、必要な機能が含まれるバージョンに固定したいといった理由で tf ファイルで必要なバージョンを定義する場合は、まず tf ファイル内の version を変更します。 required_providers { aws = { source = " hashicorp/aws " version = " 3.19.0 " # ここのバージョンを更新する } } その後、 terraform init -upgrade を実行することで期待した Provider のバージョンに変更できます。なにか最新バージョンでは意図しない差分が発生したといった場合にダウングレードしたいケースがありますが、そういった場合にもこのオペレーションを行ないます。 0.14.0 アップグレード時に発生したError このような目玉機能がある Terraform 0.14 ですが、筆者の現場で最初に 0.14.0 にあげた際に次のようなエラーが発生しました。 Error: Computed attribute cannot be set on ../../modules/sample-api/ecs.tf line 56, in resource "aws_ecs_task_definition" "sample-api": 56: resource "aws_ecs_task_definition" "sample-api" { Error: Computed attribute cannot be set on ../../modules/sample-api/ecs.tf line 56, in resource "aws_ecs_task_definition" "sample-api": 56: resource "aws_ecs_task_definition" "sample-api" { Error: Invalid or unknown key on ../../modules/sample-api/ecs.tf line 56, in resource "aws_ecs_task_definition" "sample-api": 56: resource "aws_ecs_task_definition" "sample-api" { 発生した箇所では次のように変更差分をすべて検知しない設定を入れているものでした。 lifecycle { ignore_changes = all } なにが影響したかを確認していくと changelog 内にあるこちらの機能リリースにおいて問題が発生したようでした。 github.com 当該機能に対する Issue はこちらのものです。具体的には ignore_changes = all と設定した場合に意図しない振る舞いになっていた という Issue です。 github.com それに対して機能を追加したコミッターの方が即座に issue に対して反応、バグ修正が実施されました。 github.com この修正が入っているバージョンが 0.14.1 になります(なお、この 3 時間後に新たに Bug Fixes が含まれた 0.14.2 がリリースされています)。 Terraform 0.14.2 への更新 done (ちょっとした話は社のアドベントカレンダーにでも書こう) pic.twitter.com/Zp8qcjbOFT — Kazuki Higashiguchi (@hgsgtk) December 9, 2020 具体的な 0.14 へのアップグレードについては、 Upgrading to Terraform v0.14 をご参照いただくことになりますが、端的な手順はこちらになります。 0.13.x の最新マイナーバージョンにまず上げておく terraform plan / apply で no changes であることを確認する terraform バージョンを 0.14 に変更する deprecatedとなっているProviderのバージョン指定方法 Provider のバージョンを指定する際に次のように version を指定する方法がありました。 provider " aws " { region = var.region version = " ~> 3.19.0 " } しかし、この記述では terraform plan 等をした場合に Warning が発生します。 Warning: Version constraints inside provider configuration blocks are deprecated on providers.tf line 8, in provider "aws": 8: version = "~> 3.19.0" この非推奨記述については Provider Configuration: version: An Older Way to Manage Provider Versions にて説明があります。 The version argument in provider configurations is deprecated . In Terraform 0.13 and later, version constraints should always be declared in the required_providers block . required_providers の記述を用いて Provider のバージョンを指定することで Warning は解消されます。 terraform { # (省略) required_providers { aws = { source = " hashicorp/aws " version = " ~> 3.19.0 " } } } ちなみに、terraform の内部実装としては次のような処理が加えられて Warning が出るようになっています。 if attr, exists := content.Attributes[ "version" ]; exists { diags = append (diags, &hcl.Diagnostic{ Severity: hcl.DiagWarning, Summary: "Version constraints inside provider configuration blocks are deprecated" , Detail: "Terraform 0.13 and earlier allowed provider version constraints inside the provider configuration block, but that is now deprecated and will be removed in a future version of Terraform. To silence this warning, move the provider version constraint into the required_providers block." , Subject: attr.Expr.Range().Ptr(), }) var versionDiags hcl.Diagnostics provider.Version, versionDiags = decodeVersionConstraint(attr) diags = append (diags, versionDiags...) } https://github.com/hashicorp/terraform/blob/923e157b5ce4d8650ff0ade755b52aa23423b63c/configs/provider.go#L71-L81 version というアトリビュートがユーザーの tf ファイルに含まれる場合 Warning を出すという内容ですね。 github.com 2020 年 9 月 8 日にマージされ 0.14.0 のリリースの内容に含まれました。 The version argument inside provider configuration blocks has been documented as deprecated since Terraform 0.12. As of 0.14 it will now also generate an explicit deprecation warning. To avoid the warning, use provider requirements declarations instead. (#26135) おわりに Terraform 0.14.x 自体の目玉機能についておさらいし、当該バージョンへのアップグレードにて発生しうる Error/Warning への対応方法について紹介しました。 BASE BANK の開発チームでは特定レイヤーの技術に限定されることなく、自チームで開発したプロダクト・機能をリリースからグロース・サポートまで担う、フルサイクル開発者の考え方で日々業務に邁進しています。興味のある方はお気軽にカジュアルなお話をしましょう。 @hgsgtk 宛に DM 頂いても構いません。 open.talentio.com 明日は BASE 株式会社基盤チームの @tenkoma さんです。お楽しみに!
この記事はBASE Advent Calendar 2020の17日目の記事です。 devblog.thebase.in はじめまして。 BASEの情シスでマネージャーをやっている猪股です。 ITを活用した社員の利便性の向上とセキュリティを考慮した環境の整備をしています。 2017年1月からひとり情シスで業務を行っていましたが、今年はメンバーも増えチームとしてたくさんの業務を「Move Fast」にこなすことができた一年でした。 新型コロナウイルス感染症の拡大でBASEも一斉に「Work From Home」(以下WFH)へ移行しました。 WFH施策を主に2020年を振り返っていきたいと思います。 2020年 1月 新メンバーが1名入社し、2名体制でスタート 新たな試みとして情シス関連のお知らせを社内報として月1でお届け 2月 「WFH推奨」への移行対応 VPNアカウントの確保 セキュリティインシデント発生時の問い合わせフロー整備 オンライン面接で利用するZoomの使い方マニュアルの作成 通信帯域の監視 2月の社内報のTOPICS VPNとは フリーWiFiや野良WiFiのリスクとは 3月 新メンバーが1名入社し、3名体制に 「WFH推奨」期間延長に伴う対応 オンライン会議ツールをGoogleハングアウトからZoomに変更 全社定例をZoomで開催 社内にメンバーが複数いるときの会議のためにWeb会議用スピーカー内蔵マイク(Jabra SPEAEK 510)を会議室に設置 全社で日報の運用を行うためにSlackのbotサービスのGeekbotを活用 GeekbotからDMでメッセージが届くので、質問に1つ1つ回答 ボットと会話しているかのような感じで回答できます すべての回答が終わると、回答を共有するチャンネルに通知されます GeekbotからのDM 回答を共有するSlackチャンネル 「WFH推奨」から「原則WFH」への移行対応 会社にいなくてもPCで通常の電話のように保留・内線・取り次ぎ可能なCallConnectの導入 3月の社内報のTOPICS ファイアウォールとは WiFiルーターの紹介 4月 「WFH推奨」から「原則WFH」への移行対応 社内で利用しているディスプレイ等の周辺機器を希望者に配送 VPNが接続できない・自宅にネットワーク環境がない人のためにポケットWi-Fiの貸出 4月の社内報のTOPICS インターネット回線の紹介 5月 フルオンライン入社対応 全社定例後の懇親会「締め会」および毎週水曜実施の「みんなの食堂」の代わりに、バーチャルオフィスのように同じテーブルにいる人に話しかけられるツールのRemoをお試し導入 オンラインでセミナーや採用候補者向けのmeetupを開催するためにZoomの ウェビナーを導入 5月の社内報のTOPICS WFHツール(疲労軽減編) 6月 YAMAHA ルーターとMeraki ルーター間をVPNで接続 「原則WFH」から「WFH推奨」への移行対応 「オフィスの人口密度はどれくらいか」「オフィスに誰が行くか」というのをざっくり可視化するために、Pollyを使って翌営業日出社するかのアンケートをSlackに配信 7月 社外とのファイル受け渡し方法の一つとしてDropboxを導入 情報を取得しやすいようにSlackチャンネル名のPrefixを整理 8月~9月 IT全般統制及びIT業務処理統制の整備評価を実施 10月 クラウドサービスの利用フローを作成 オンラインメンバーとの距離感を縮め、Web会議をスマートに行えるよう360°カメラ、マイク、スピーカー一体型を検証 (来年1月から利用開始予定) 12月 ユーザー認証における社内・社外の利便性の向上と安全なクラウドサービス利用に向けたセキュリティレベルの向上を実現させるためOneloginを導入 情報セキュリティ研修実施中 IT全般統制及びIT業務処理統制の運用評価を実施中 全社定例バージョンアップに向けATEM Mini Proと配信ソフトOBSを用いてマルチ映像配信を検証 全社員がWFHに!?その時情シスは… 急なWFH移行によって発生した様々な作業やトラブル、その対処法について振り返ります。 2月17日 新型コロナウイルス感染症拡大の影響で「WFH推奨」を発動する可能性が出てきており、対応の相談がきました。 前提として、BASEではWFHを実施していませんでしたので、WFHを想定したインフラ整備は整っていませんでした。 しかし、BASEの社内システムは完全クラウド化(個人情報を取り扱うなど重要なクラウドサービスへのアクセスはIP制限を設けている)されていて、 また、社員に貸与しているPCはノート型であり、比較的WFHへの移行はしやすい環境でした。 初動として想定される対応の洗い出しを行ったところ YAMAHAルーターのVPN対地数(最大設定可能数)は100のため、60アカウント分足りないことが分かりました。 2月18日 20日からWFHを開始したいと連絡がきました。 不足しているVPNアカウントを用意するため、バックアップ回線とCisco MerakiのWebinar受講でゲットしたルーターをテストし、この環境を利用することに決めました。 (Cisco MerakiのWebinarを受講すると3年間のライセンス付きで機器をもらえるのでお勧めです!) YAMAHAルーターに登録済みのVPNアカウントは60個でしたので、40個新規に登録し、残り60個をMerakiルーターに登録しました。 2月19日 PM16:00 WFHのガイドラインが全社にアナウンスされました。 PCへのVPN設定が完了次第、WFHへの移行が認められたため、VPN設定待ちの行列が・・・ 同じSectionのCSEメンバー2名にもご協力いただき4名でPC側のVPN設定作業をこなし続けました。 VPN設定待ちの列 VPNに同時にアクセス可能な社員数は全社員の1割~2割程度を想定し設計していましたので、VPNはIP制限を設けているサービスを利用する際にのみ接続してもらい、利用が終わったら切断してもらうようアナウンスしました。 2月20日以降 通信帯域の監視とVPN接続できない等の問い合わせ対応が続きました。 みんなの協力もあり、同時接続数は30台程度を推移していたため、帯域を使い切ってしまうこともなく何とかWFHへの移行が出来ました。 VPN接続が出来なかった主な原因は以下でした。 自宅のルーターでVPNパススルー機能が無効になっていた 自宅のネットワークセグメントとVPN接続先のネットワークセグメントが同一だった 我々VPN設定作業者の設定ミス 3月6日 Zoomを利用した最初の全社定例は、ごたつきました。 Proプランを契約していて、最大接続数が100名だったため、ホストを2台用意し、好きな方どちらかに接続してもらうようにしましたが、伝わりづらかったし、安定もしなかったため、30分全社定例開始時間を遅らせてもらい、Businessプランに変更しホスト1台で対応しました。 YAMAHAルーターとMerakiルーター間をVPNで接続 QAなどを担うProcess Engineeringチームにより社内にOpen STF環境が構築されました。 しかし、3月の作業ではMerakiルーターは社内ネットワークに接続させていなかったので、MerakiにVPNアカウントがあるメンバーが、Open STF環境に接続できない問題が発生しました。 YAMAHAルーターとMerakiルーター間をVPNで接続させる設定を紹介します。 ネットワーク概要 YAMAHAルーター側 項目 設定値 接続種別の選択 IPsec ネットワーク環境 自分側と接続先の両方とも固定のグローバルアドレスまたはネットボランチDNSホスト名を持っている 接続先の情報 > 接続先のホスト名またはIPアドレス Merakiルーターの固定IPアドレス 接続先と合わせる設定 > 認証鍵 Merakiルーターとの事前共有鍵 接続先と合わせる設定 > 認証アルゴリズム 特定の認証方式 接続先とわせる設定 > 暗号アルゴリズム 特定の暗号化方式 接続先のLAN側のアドレス MerakiルーターのLAN側ネットワークアドレス Merakiルーター側 項目 設定値 タイプ ハブ(メッシュ) VPN設定 > ローカルネットワーク > サブネット Merakiルーターのネットワークアドレス VPN設定 > ローカルネットワーク > VPNへの参加 VPN ON VPN設定 > NATトラバーサル 自動 オーガナイゼーション全体の設定 > Meraki以外のVPNピア > パブリックIP YAMAHAルーターの固定IPアドレス オーガナイゼーション全体の設定 > Meraki以外のVPNピア > プライベートサブネット YAMAHAルーターのLAN側ネットワークアドレス オーガナイゼーション全体の設定 > Meraki以外のVPNピア > 事前共有シークレット YAMAHAルーターとの事前共有鍵 IPsecポリシー 項目 設定値 フェーズ1 > 暗号 特定の暗号化方式 フェーズ1 > 認証 特定の認証方式 Diffie-Hellmanグループ 特定のグループ Lifetime(seconds) 任意のLifetime期間 フェーズ2 > 暗号 特定の暗号化方式 フェーズ2 > 認証 特定の認証方式 PFSグループ オフ Lifetime(seconds) 任意のLifetime期間 2021年 2021年もITを活用した社員の利便性の向上とセキュリティを考慮した環境の整備をしていく予定です。 新しい働き方をより安全かつ効率的に実現するために、リモートアクセスのスケールアップ、モバイル端末を管理するMDMを導入したいです。 いつでもどこでも信頼できる環境ではないという前提に立ち、徹底したエンドポイントの保護に努めたいと思っています。 また、持ち帰らないことを前提として選定していたコーポレートやCS部門のPC軽量化も行っていきたいです。 総括 BASEの社内インフラ環境は決して十分とは言えません。 バックアップ回線や検証用のルーターがなければどうなっていたか、と考えると本当にぞっとします。 全社員がWFHするための環境整備は、情シスにとってまさに緊急事態で、自分一人だけだったとしたら速やかに進めることができなかったでしょう。 WFH初動段階でチーム化できたこと。実はこれが一番のファインプレーだったのではと個人的には思っています。 明日は BASE BANK 株式会社の東口さん( @hgsgtk )です!お楽しみに!
こんにちは!2020 年ももう少しで終わりですね。さて、この度は、12/12(土)にオンラインで開催された PHP Conference Japan 2020 にて、4 名のメンバーが登壇しました。また、プラチナスポンサーとして協賛いたしました。今回は、スピーカーとして参加した川口(@dmnlk)、イアン(@brison_ian)、東口(@hgsgtk)、永野(@glassmonekey)の 4 名から参加レポートをお届けします! PHP Conference Japan 2020とは 2020/12/12 (土) に PHP Conference Japan 2020 が開催されました。BASE はこれまでにも開催されている PHP カンファレンスへの登壇並びにスポンサードをコミュニティ貢献活動として行って参りました。当カンファレンスではプラチナスポンサーとして当カンファレンスに協賛しています。 https://phpcon.php.gr.jp/2020/ 当日は、オンライン参加している人たちで専用の slack チャンネルを作成してわいわい盛り上がっておりました。 専用のslackチャンネルでの盛り上がりの様子 一日中カンファレンスに参加しておりましたが、スピーカーの方々の発表は非常に参考になるものが多く、当 slack チャンネルは発表内容についての感想戦が度々起こっていました。 スピーカーより参加レポート スピーカーとして参加した川口(@dmnlk)、イアン(@brison_ian)、東口(@hgsgtk)、永野(@glassmonekey)の 4 名より、登壇内容の資料やカンファレンス参加した際の感想をそれぞれお届けします。 川口(@dmnlk) 今回はスポンサードセッションの場を頂いたので「NewRelic プラットフォームを使ったオブザーバビリティ入門」というセッションをさせていただきました。 PHP らしさのない発表だったのと BASE での取り組みがまだまだということもあり消化不良感のある発表にはなってしまいましたが、これからの可観測性について考えていきたいチームの皆様の一助になれば幸いです。 久々の大きいカンファレンスだったのでとても楽しかったです。 来年はオフラインで出来るといいなと楽しみにしています。 イアン(@brison_ian) こんにちは!11 月に入社したばかりのイアンです! 今年の PHP カンファレンスは、一般参加者としてのみ参加予定だったのですが、ご縁があってパネルセッションの若者役としての参加の打診をいただき、メインの田中ひさてるさんや司会の小山哲志さんと楽しくお話しできました。 当日は自身も田中ひさてるさんの言葉に終始聞き入ってしまい、言葉数はそう多くはなかったのですが、少しでも場を盛り上げることができていたのであれば幸いです。 また、今年のカンファレンスも様々な方の発表から新しい発見が得られて、とっても楽しいカンファレンスでした。来年以降も継続的に参加しつつ、なんらかの形で PHP コミュニティに還元できたらと思っています。 関わった皆さん、今年もありがとうございました! 東口(@hgsgtk) 15:20 から 25 分間 Track5 (PHP8 Special)でお時間をいただき発表いたしました。 当発表では、PHP8 の新機能について PHP 内部実装のテストスクリプトである phpt を読んでいく体験を発表しました。phpt を読むことで、ドキュメントを見るだけでは分からない仕様や内部実装の知識が得られる点を説明しています。 同じ時間帯に魅力的なトークが並ぶ中聴講頂いた方々ありがとうございました。少しマニアックでコアな内容を発表しましたが、聴講いただいた方々の感想を見る限りしっかりマニアックな魅力を伝えることができたようで安心しました。 テストphptから読むやつ聞いてて楽しかったなぁ。 若き頃の好奇心が呼び起こされた気がする。気がする。 — じん (@jin_penguin_616) December 12, 2020 #phpcon 東口さんのセッション良かった。そういえば phpt をメインにしたセッションってあまり無かったですね。phpt は、php-src を見る人は知っていても、まとまった資料も少ないですし、気づいていない人も多いと思うので、PHP の挙動を手軽に知りたい人にはオススメです。 https://t.co/8t3Uq7MtAC — Masashi Shinbara (@shin1x1) December 15, 2020 なお、発表中は YouTube のチャット欄・Discord・Twitter の TL をモニターに表示して、モニター上の反応をちらちら同時に見ておりました。オンライン登壇ではどうしても発表中の反応は見えにくいですが、それらを同時にモニターで開いておくと安心です(コメント等で感想を書いていただいていた方々ありがとうございました!)。 ちなみに他のセッションですが、わたしは基本的にずっと Track5 (PHP8 Special)にいました。 どの発表もコアな内容がたくさん触れられていて非常に興味深かったです。JIT についての仕組みやパフォーマンスを出すための現在の使い所・match 式を活用した JSON パーサー実装や、PHP8 の言語機能からみたフレームワークの未来感の評価、これまでのコミッターたちの歴史的経緯を加味した web に限らないユースケースの未来、その他たくさんの発表がありましたがとても刺激的な内容でした。 永野(@glassmonekey) はじめまして、永野です。 新卒で社会人して以降、PHP を業務で書いてもう約 5 年目です。 今回 PHP コミュニティでの発表を初めてさせていただいて、やっとコミュニティへ参加できた思いがあり感無量です。 PHP カンファレンスには度々参加させていただいておりました。 今回の発表の情報を仕入れた経緯としては、元々は入社前に私が執筆した 2019年のGithubActionのアドベントカレンダー のネタ探しに PHP 版 npm audit コマンドを探していたところ、 issue にて紹介されていたことがきっかけでした。 皆様の Twitter などの感想を拝見していると、このとき知り得た知見を再利用できたのは良かったと実感しております。 改めて、運営の皆様、参加された皆様お疲れ様でした!!大変楽しい時間でした。 謝辞 一日中とても刺激的な時間を過ごさせていただきました。今回は昨今の事情によりカンファレンス運営委員会の方々としても初のオンライン開催となりました。しかし、参加者として不自由なく一日楽しむことができ、スタッフの方々の入念な準備には感謝しきれません。 スピーカー向けのリハーサル期間を数週間用意されていたりと、スタッフの方々には業務でお忙しいにも関わらず、多くの時間をカンファレンス準備に注いでいただいたかと思います。この場を借りて御礼申し上げます。 弊社としても協賛・社員のスピーカー参加を通して PHP コミュニティの盛り上がりの一助になれ大変有意義な時間となりました。運営の皆様・参加者の皆様、誠にありがとうございました。 そしてPHP Conference Japan 2021へ 来年の 2021 年は 10 月 2 日・3 日に開催されます。いまからカレンダーを抑えておきましょう! @phperkaigi @phpcondo @phpcon_sendai @phpcon_nagoya @phpcon_kansai @phpcon_fukuoka @phpcon_okinawa @laraveljpcon はじめまして、PHPカンファレンスと申します。 来年のPHPカンファレンス2021は10月2,3日に開催予定です。 どうかお見知りおきください。 #phpcon #phpcon2021 — PHPカンファレンス2020 (@phpcon) December 15, 2020 直近はPHPerKaigi 2021 PHPer にとって一番近いカンファレンスは PHPerKaigi 2021 になります。3 月 25 日〜27 日の 3 days です。 phperkaigi.jp 現在、スポンサー・プロポーザル絶賛募集中だそうです。ぜひ応募してみてはいかがでしょうか?
この記事はBASE Advent Calendar 2020の16日目の記事です。 devblog.thebase.in はじめに こんにちは。BASE株式会社フロントエンドチームに所属している田中です。 こちらの記事にもあるように、BASEではここ一年弱の間、リモートワークメインの働き方でした。 devblog.thebase.in そんな中、数ヶ月前にスタートしたあるプロジェクトに、私含め2名のフロントエンドエンジニアがアサインされました。アサインされた方はその月にJoinされたばかりの方だったことと、プロジェクトスタート時は私が別のプロジェクトを並行して進めていたこともあり、メインで関わることができていないプロジェクトに対して、何かよいアプローチはないだろうかと模索していました。その1つの手段として、実験的にモブプログラミングをやってみた様子をご紹介したいと思います。 事前に行ったこと モブプログラミングについておおまかな概要を知っておく 必要な情報の共有 現在進行中のプロジェクトで取り組めそうなタスクをピックアップし、仕様概要やデザインとともに共有 作業中のブランチを共有し、ローカルで動く状態にしておいてもらう 今回私以外の他のメンバーはモブプロをやるぞと言ってモブプロをやったことはあまりなかったそうですが、気づいたらその状態になっていたことはあったということでした。私自身はモブプロを全く経験したことがなかったため、モブプロの概要について、どのような役割があるのかや、実施したレポート記事などを読んでおきました。 実施概要 メンバーは3名 プロジェクトメンバーのフロントエンドエンジニア2名、プロジェクトメンバーではないフロントエンドエンジニア1名 時間は各回60~90分程 タイピスト(1名)とモブ(タイピスト以外)を時間交代制 全員リモートワークでの実施だったため、タイピストがエディタとブラウザを画面共有する形式 メンバー数に関しては、プロジェクトメンバーだけのペアプロでも良かったのですが、実験的にプロジェクト外のメンバーともやってみたいと思い3名で行いました。 結果と所感 進め方について リモートとなると交代時にpushやpull、開発環境を整える時間などがある程度かかった。 今回プロジェクトメンバー2名とプロジェクトメンバーではない方1名での実施で、実施前は仕様の共有や前提条件の説明などが難しいかもしれない、という懸念があったが、大きな問題はなく進められた。 時間の区切りが難しく、ある程度キリのよいところで交代とはいえもう少し…と延長してしまいがちだった。 時間配分については改善の余地がありそう。 プロジェクト外のメンバーも含めたことの懸念については、どちらにせよ担当プロジェクト外のものもチーム内でコードレビューを行うということと、むしろその場でコードを見ながら説明し、レビューが行われているような状態になるため、コードレビューの効率化にも繋がりそうだなと思いました。また、フロントエンド開発ではブラウザとエディタとデザインを行き来するが、ディスプレイ1枚の場合は画面共有で埋まってしまうのでモブの役割の場合は難しそうと感じた。 心理的な面について コードと作業を共有することでチームで働いている感を感じられたという意見が出た 新しく入ったメンバーへのオンボーディング的に行うのも良さそうという意見も リモートワーク環境下で、まだ直接会ったことがないメンバーとも仕事を進める中で、上記のような意見があり実施して良かったです。新しく入ったメンバーに関しても、タイピストの役割であれば、モブに指示されたことをタイピングしつつ、分からないことをその場で質問できるという環境になるのでよさそうですね。 タスクについて 今回用意していたタスクが比較的小規模の単純なコンポーネント作成やAPI連携だったため、いくつかタスクを終わらせることはできた。 正直各回作業できた量はそこまで多くはなかった。 タスクの数や難易度に関しては、あまり最初からやり方を固めすぎず、ゆるく始めて改善していければいいなと思っていたので、今後ある程度の回数を実施し、慣れてくればこなせるタスク数は自然と増えていくのではないかなと思いました。今後は試行錯誤が必要な複雑なタスクに取り組んでみるのもおもしろそうです。 その他 タイピストは自分の意志を反映させずにモブに言われたとおりタイピングするだけという役割になれるコードを客観的に見ることができ、対象箇所以外のところまで思考をめぐらせることができたという意見があり、新鮮でした。また、バックエンドエンジニア、またはエンジニア以外の方がオブザーバー的に参加するのもよさそうという声もありました。 まとめ 新メンバーも多い中のリモートワーク環境下で、プロジェクトを円滑に進めるための取り組みとして、フロントエンドメンバー数名でモブプロを実施してみました。まだ数回しか実施できておらず、よりよい進め方は模索中ですが、今後定期的に実施し改善を重ねていくことで質を上げていけたらと思います。 明日は、情シスチームの猪股さんです! もしBASEで働くことに興味を持っていただけた方は、ぜひご連絡ください! 採用情報はこちらから
この記事はBASE Advent Calendar 2020 15日目の記事です。 devblog.thebase.in こんにちは、Native Application Groupの大木です。最近React.jsを使ったフロントエンドアプリケーションの開発に取り組んでいますが、プロジェクトをmonorepoで管理しています。 今回は、monorepo管理にしたはいいが、Visual Studio Codeエディター(以下vscode)で、TypeScriptのモジュールのautoimportのパス解決に悩まされてやったことを、Next.jsというReact Frameworkを使った例でご紹介します。 Monorepo? monorepoは、次のうち1つまたは複数当てはまる場合に採用するのが効果的かと思います。 Web/ネイティブなど複数プラットフォームのアプリケーションが存在 メイン/管理画面など同じデータソースにアクセスする別々の役割を持つ複数のアプリケーションが存在 それらのアプリの実行環境に依存せず同じように利用する機能が存在 API Clientや共通UIコンポーネントなど特定アプリの環境に依存しない独立した共有する機能 最後の 実行環境に依存せず同じように利用する機能 というのは、過去に書いた ガワネイティブアプリ(Creator)を、React Nativeで置き換えてみての一年間戦いの記録 で、React Nativeの複数の実行環境を共存する試みで紹介しました。 さて、フロントエンドアプリケーション開発で適切に機能分割したパッケージを、manyrepoなど機能ごとにリポジトリが分割されたものではなく、monorepoで管理する利点はなんでしょうか? Guide to Monorepos for Front-end Code という記事によると、次の5つが挙げられています。 全ての設定とテストを一つの場所で 一連の関連するコミットをまとめてグローバルに機能を簡単にリファクタリング 簡略化されたパッケージのバブリッシング より簡単な依存関係管理 分離された状態を維持したまま、共有パッケージのコードを再利用可能 1.は運用の話で、パッケージごとにCIやテストの構成を一つにしておけば、それらの設定を追加する必要はなく、すぐに起動することが可能となります。 また、4.と5.の話の意味するところは、依存関係を適切に保てるし、新たにパッケージをmonorepo内に追加しても、共有パッケージを参照することが簡単になるということです。 Multi-root Workspace vscodeの Multi-root Workspaces は、複数のプロジェクトフォルダーをまとめて操作できる機能です。 記事の解説によると、manyrepoのような機能ごとにリポジトリが分割されたものに対して有効な機能であるという認識をもちますが、今回は次のようなフォルダー構成を持つmonorepoに適用しました。 . ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .stylelintrc ├── .vscode/ │   └── settings.json ├── README.md ├── multi-root-ts-monorepo.code-workspace ├── package.json ├── packages/ │   ├── admin/ │   │   ├── .vscode/ │   │   │   └── settings.json │   │   ├── next-env.d.ts │   │   ├── next.config.js │   │   ├── package.json │   │   ├── pages/ │   │   │   ├── _error.tsx │   │   │   └── index.tsx │   │   └── tsconfig.json │   ├── storybook/ │   │   ├── .storybook/ │   │   │   ├── addons.js │   │   │   ├── config.js │   │   │   └── preview-head.html │   │   ├── package.json │   │   └── stories/ │   │   └── Button.stories.js │   ├── ui/ │   │   ├── dist/ │   │   │   ├── components/ │   │   │   ├── constants/ │   │   │   ├── hooks/ │   │   │   ├── index.d.ts │   │   │   └── index.js │   │   ├── package.json │   │   ├── src/ │   │   │   ├── components/ │   │   │   ├── constants/ │   │   │   ├── hooks/ │   │   │   └── index.ts │   │   └── tsconfig.json │   └── web/ │   ├── .vscode/ │   │   └── settings.json │   ├── next-env.d.ts │   ├── next.config.js │   ├── package.json │   ├── pages/ │   │   ├── _error.tsx │   │   └── index.tsx │   └── tsconfig.json └── yarn.lock 26 directories, 37 files monorepo内には、4つのパッケージが存在します。 - admin: 管理画面のNextアプリケーション - storybook: 共有UIコンポーネントに対するStorybook - ui: Nextアプリケーションで利用する共有UIに関するコードを管理するパッケージ - web: 本体のNextアプリケーション これら一つ一つをworkspaceに追加すると、エディターのファイルエクスプローラーでは次のように見えます。 なぜ、monorepoにこの機能を適用するの? という話ですが、monorepoプロジェクト全体のvscodeの設定(workspace設定)の他に、それぞれのフォルダごとにvscodeの設定をカスタマイズできるようになります。それによってvscodeの機能を利用するのにいくつか利点があり、特にvscodeのautoimportに関して助かったので、紹介していきます。 vscodeのautoimport vscodeでTypeScript/JavaScriptファイルにコードを記述していると、次のようなサジェストが表示されることがあります。 サジェストを適用すると、下記のように参照先を自動でimportしてくれます。 import * as React from 'react' ; // **↓自動で挿入** import { SIZES } from '../../constants' ; export const useSize = ( size: keyof typeof SIZES ) => { const [ value , setValue ] = React.useState < number >(); React.useEffect (() => { setValue ( SIZES [ size ] ); } , [] ); return value ; } ; この自動インポートなのですが、vscodeの設定ファイルに次の設定を追加することにより、サジェストされるパスに変化が生まれることになります。 " typescript.preferences.importModuleSpecifier ": " auto " 設定値は全部で3つありそれぞれ下記の通りです。 設定値 説明 auto インポート パス スタイルを自動的に選択します。 non-relative jsconfig.json / tsconfig.json で構成されている baseUrl に基づきます。 relative ファイルの場所を基準にします。 上のコード例は、アプリケーションからライブラリのように利用する共有パッケージの実装コードでしたが、 Nextアプリケーションのパッケージではどのようにこの機能を利用するのが良いでしょう? アプリケーションパッケージの例 アプリケーションでは複数のパッケージを組み合わせて多くのコードを実装するため、フォルダー階層が深くなりがちです。そのため相対パスでimportするという設定だと、import文が長くなる可能性があり好ましくありません。また、リファクタリングの際にソースファイルの配置を頻繁に移動する可能性もあり、階層がズレるとimport文にある相対パスの ../ を増やしたり減らしたりする作業が発生します。 それに対する解決案の一つとしては、モジュールバンドラーと tsconfig.json の paths を使うというものです。 Nextでは、Webpackというモジュールバンドラーを使っているため、Webpackの機能 resolve.alias を利用して、パスのaliasを作成することができ、指定するimportパスを柔軟に変更することができます。 const withPlugins = require('next-compose-plugins'); const withTM = require('next-transpile-modules'); module.exports = withPlugins( [ withTM(['@multi-root-ts-monorepo/ui']), ], { reactStrictMode: true, webpack(config, options) { // [...] config.resolve.alias['~web/i18nextConfig'] = path.join( __dirname, 'i18next.config.js' ); config.resolve.alias['~web/defaultConfig'] = path.join( __dirname, 'default-config.js' ); config.resolve.alias['~web'] = path.join(__dirname, 'src'); return config; } } ); 次に、このエイリアスをTypeScriptで使えるように tsconfig.json の paths にも定義するようにします。参照先のルートが必要なため、 baseUrl も併せて指定します。 { "extends": "../../tsconfig.json", "compilerOptions": { "target": "es5", "lib": ["es2016", "dom", "dom.iterable", "esnext", "webworker"], "noEmit": true, "jsx": "preserve", "baseUrl": ".", "paths": { "~web/i18nextConfig": ["i18next.config.js"], "~web/defaultConfig": ["default-config.js"], "~web/*": ["src/*"] } }, "exclude": ["node_modules"], "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", "../../@types/worker-loader/index.d.ts" ] } このエイリアスを利用することによって、相対パスやソースコードを格納したフォルダーの外のパッケージルートにある i18next.config.js のようなファイルの実際のパスを、隠蔽することが可能となるわけです。 // 相対パスの代わりにエイリアスを使う例 import { FC , FormHTMLAttributes } from 'react' ; import { FormProvider , UseFormMethods } from 'react-hook-form' ; //import { FormDevTool } from "../../lib/react-hook-form/devtool"; <- この代わりにエイリアスを使う import FormDevTool from '~web/lib/react-hook-form/devtool' ; type FormProps = { // eslint-disable-next-line @typescript-eslint/no-explicit-any methods: UseFormMethods < any >; } ; type Props = FormHTMLAttributes < HTMLFormElement > & FormProps ; const Form: FC < Readonly < Props >> = ( { id , methods , children , onSubmit , className , } ) => ( < FormProvider { ...methods } > < FormDevTool control = { methods.control } / > < form id = { id } className = { className } onSubmit = { onSubmit } > { children } < /form > < /FormProvider > ); export default Form ; // `src/` フォルダーを跨いだ位置にあるファイルのimportで `src/`を隠蔽する例 import { useTranslation } from '~web/i18nextConfig' ; import Editor from './editor' ; type Props = { editorName: EditorName ; onDismiss?: () => void ; } ; const CheckoutEditModal: FC < Props > = ( { editorName , onDismiss , } ) => { useKeydown ( 'Escape' , onDismiss ); const { t } = useTranslation (); vscodeの設定 さて、アプリケーションパッケージにおいて、柔軟にimportパスを設定できることは説明しましたが、結局vscodeの設定についてあまり触れておりませんでした。 フロントエンドのmonorepoプロジェクト構成で、パッケージのimportがどう動いて欲しいのかをまとめますと、次の通りです。 1. monorepo内の他のパッケージをimportする際には、npmの他のライブラリと同じく、 package.json に定義した name でimportパスが解決される { "name": "@multi-root-ts-monorepo/ui", // [...] } // 参照するパッケージのnameで解決 import { Button } from '@multi-root-ts-monorepo/ui' ; import * as React from 'react' ; const App: React.FC = () => { return ( < div > Hello , World ! < Button onClick = { () => null } m = {[ '0' , '0 1rem' ]} label = "a test button" > Test ! < /Button > < /div > ); } ; export default App ; 2. 共有パッケージ内に属するソースファイル同士のimportは、相対パスでimportが解決される import * as React from 'react' ; // 同じパッケージ内のソースファイルの相対パスで解決 import { SIZES } from '../../constants' ; export const useSize = ( size: keyof typeof SIZES ) => { const [ value , setValue ] = React.useState < number >(); React.useEffect (() => { setValue ( SIZES [ size ] ); } , [] ); return value ; } ; 3. アプリケーションパッケージ内のソースファイル同士のimportは、webpackを利用するため、エイリアスでimportが解決される import { FC , FormHTMLAttributes } from 'react' ; import { FormProvider , UseFormMethods } from 'react-hook-form' ; // 相対パスの代わりにエイリアスを使う import FormDevTool from '~web/lib/react-hook-form/devtool' ; type FormProps = { // eslint-disable-next-line @typescript-eslint/no-explicit-any methods: UseFormMethods < any >; } ; type Props = FormHTMLAttributes < HTMLFormElement > & FormProps ; const Form: FC < Readonly < Props >> = ( { id , methods , children , onSubmit , className , } ) => ( < FormProvider { ...methods } > < FormDevTool control = { methods.control } / > < form id = { id } className = { className } onSubmit = { onSubmit } > { children } < /form > < /FormProvider > ); export default Form ; 1.に関しては特別な設定は必要ないため考えることはないのですが、2.と3.に関しては異なるimportパスを解決する必要があるため、全体と個別の設定を適切に定義する必要があります。 Multi-root Workspacesを利用すると、全体のworkspace設定と個別に追加したフォルダーごとの設定の2種類の設定を定義することができます。3.の場合、webpackというモジュールバンドラーのサポートが必要なことから、2.の方式を全体で設定、3.の方式を個別に設定するのが良さそうということで出来上がったのが次の設定です。 全体のworkspace設定(multi-root-ts-monorepo.code-workspace) 2.の方式を優先する設定を追加 { " folders ": [ { " name ": " project-root ", " path ": " . " } , { " path ": " packages/admin " } , { " path ": " packages/storybook " } , { " path ": " packages/ui " } , { " path ": " packages/web " } ] , " settings ": { " typescript.tsdk ": " ./node_modules/typescript/lib ", // 相対パス方式 " typescript.preferences.importModuleSpecifier ": " relative ", " typescript.preferences.importModuleSpecifierEnding ": " minimal ", " eslint.alwaysShowStatus ": true , " eslint.packageManager ": " yarn ", " eslint.validate ": [ " javascript ", " javascriptreact ", " typescript ", " typescriptreact " ] , " editor.codeActionsOnSave ": { " source.fixAll.eslint ": true , " source.fixAll.stylelint ": true } } } } アプリケーションパッケージでの個別設定(settings.json) "non-relative" を指定すると、monorepo内の他パッケージのimportパスまでも、 "baseUrl" からみた相対パスに変更されてしまうようなので、 "auto" を設定。 実際に試してみたところ、importしようとしているソースファイルが、親フォルダまでの位置にある場合は相対パスが選択され、それ以上に離れている場合は、エイリアス設定が優先されるようです。 { "typescript.preferences.importModuleSpecifier": "auto", } その他考慮すべきこと はじめの方に紹介した Guide to Monorepos for Front-end Code で出てきた「全ての設定とテストを一つの場所で」に関連する話です。 上の説明に従い、テスト実行環境やESLint環境は、パッケージが増えても追加設定しなくてもいいように、プロジェクトルートに用意していました。そのため、これらのツールからはWebpackで定義したエイリアスなどを認識できるように追加設定が必要となります。 テストやESLintのために必要な追加設定 Webpackで定義したエイリアス設定が、プロジェクトルートからみたら何処にあたるのか認識できるようにするようにする必要があります。 { "extends": "./tsconfig.base.json", "compilerOptions": { "baseUrl": ".", "paths": { "~web/i18nextConfig": ["packages/web/i18next.config.js"], "~web/defaultConfig": ["packages/web/default-config.js"], "~web/*": ["packages/web/src/*"] // [...] } }, "exclude": ["**/dist", "**/build", "node_modules"] } まとめ アプリケーションの開発に必ずしも必要ではないが、開発効率に大きく貢献する機能が提供されていることを知ることは重要だなと改めて思いました。また良さそうな機能を見つけたら紹介していきたいと思っております。 明日は、フロントエンドチームの田中さんです!お楽しみに! 参考 ガワネイティブアプリ(Creator)を、React Nativeで置き換えてみての一年間戦いの記録 Guide to Monorepos for Front-end Code Multi-root Workspaces
この記事はBASE Advent Calendar 2020の14日目の記事です。 devblog.thebase.in こんにちは。BASE株式会社 デザインチームの北村( id:lllitchi ) です。 「BASE」は今年の10月に、デザイン編集機能のフルリニューアルを行いました。 binc.jp baseu.jp デザイン編集はサービスリリース当初からほぼ改修されず、「BASE」の管理画面の中でもかなり古いUIの画面でした。ショップデザインを作るメイン機能であるにもかかわらず、多すぎる課題を抱えていたため、なかなかフルリニューアルが行えなかった機能です。 今まで関わった改修のなかでも最長の開発期間になったこのデザイン編集リニューアルの、どんな部分が大変だったかをプロジェクトに関わった人間の目線で振り返ってみようと思います。 HTML編集Appと、ショップテーマの関係 「BASE」のショップデザインは、いくつかあるオフィシャルテーマから好きなものを選択して、自分のショップデザインに反映することができます。また、「 デザインマーケット 」でデザイナーが制作した高品質なテーマをお手ごろな価格で購入することもできます。 さらに、「BASE」の拡張機能である「 HTML編集App 」というAppを提供しており、このAppをインストールすると、利用しているテーマのHTMLとCSSを直接自由に編集できるようになります。 デザイン編集が抱えていた、多すぎる課題 課題1: 「HTML編集 App」で編集したテンプレートは更新することができない 「HTML編集 App」でテーマを利用すると、前述のとおり、テーマのHTMLとCSSを直接自由に編集できるようになります。オーナーさんには、ショップのトップページにバナーを配置したり、ナビゲーションにブランドサイトへのリンクを追加したりと、さまざまな用途で利用して頂いてました。 ですが、 1度でもコード変更をしてしまうとBASEによるショップページの更新(Appをふくむ新規機能の追加、不具合修正など)が自動で適用されず 、オーナーさん自らメンテナンスをしなくてはいけないという大きな問題点を抱えていました。 最近では、商品にギフトラッピングや名入れオーダーなどを追加できる「 商品オプションApp 」や、商品ページに動画やスライドショーなどの表現を追加できる「 商品説明カスタム 」といった、ショップ情報の充実に役立つAppがリリースされているのですが、こういった新規機能も追従できなくなってしまいます。 Appを利用したい場合、テーマを選び直してイチから再編集する必要があり、オーナーさんにはとても大きな負担になっていました。 課題2: HTML編集せざるを得ない、シンプルすぎるショップテーマ 「BASE」が提供しているオフィシャルテーマは、必要最低限のナビゲーションと画面があるだけの非常にシンプルなページ構成になっています。ブログや商品検索やカテゴリー表示を利用したいオーナーさんは、「BASE Apps」で機能を追加するというのがBASEのショップテーマの作りになっています。 さらに、設定できるデザイン項目は「ロゴ」と「背景」と「ナビゲーションカラー」のみという自由度の少なさでした。ちょっとした工夫にも、HTML編集Appの利用が必要でした。 課題3: サポート業務の圧迫 設定できる項目が少なすぎるため、ショップのブランド表現を高めるためには、前述のようにHTML編集Appを利用して直接テーマを編集せざるを得ない作りになっています。ですが、気軽にインストールできるとはいえ、すべてのショップオーナーさんがHTMLやCSSを使いこなせるわけではありません。 基本的なサポートやリファレンス・ヘルプはほぼなく、CSには日々テーマ編集に関する問い合わせが届いていました。主にフロントエンドチームとデザインチームがこのお問い合わせへ対応しなければならず、日々の業務を圧迫するという大きな課題も抱えていました。 課題4: PC表示用のテーマとスマートフォン表示のテーマ オフィシャルテーマはレスポンシブ対応されておらず、 PC表示用のテーマとスマートフォン表示のテーマが別々で用意 されてきました。スマートフォン用のテーマはもともとHTML編集Appに対応していないため、スマートフォン用のテーマは独自で開発されてきました。 スマートフォン用テーマ独自機能の例 例えば、スマートフォンでは「最近チェックした商品」や「関連商品」の欄が表示されますが、PCでは表示されません。PC表示とスマートフォン表示では情報に差異が生まれてしまい、ショップのブランディングを統一するのが難しくなっていました。 HTML編集Appという大きな足かせ 自由度の少なさをHTML編集Appでカバーしているが、HTML編集を利用すると新規機能のアップデートに追従できない…という負のループをBASEのショップデザインはずっと抱えていました。 手軽に編集できる機能としてHTML編集Appを提供している以上、長年積み上げた負債を返済しなければなりませんでした。 新しいデザイン編集が目指したこと 新しいオフィシャルテーマ 今までの「BASE」のデザイン編集に欠けていた、「多様なデザイン選択肢」を提供すること、HTML編集Appに頼らなくても「直感的な操作」で誰でもかんたんにイメージ通りのショップをデザインできる機能を目指しました。 特にデザインの上で大変だったのは、テーマのレイアウトを新しく刷新することです。既存のオーナーさんがスムースに移行できるように、レイアウトは既存を踏襲しつつ、より利用シーンが分かるようなクリエイティブにアップデートしました。オフィシャルテーマはデザインチームのみんなに手伝ってもらって、以前の倍のテーマ数を用意することができました。 すべてのオフィシャルテーマがスマートフォン対応デザインに 新しいオフィシャルテーマ 無料で利用できるオフィシャルテーマが、すべてスマートフォン対応デザインとなりました。ショップデザイン機能からデザインした内容は、スマートフォンページ・PCページに同時に反映されます。 ノーコードで直感的にデザインを編集 「ショップデザイン機能」では、HTMLやコードの編集を必要とせず、ノーコードで直感的に編集することができます。 さらに、フォントの変更や、ナビゲーションの追加や並び替え、「BASE」以外のサイトURLの追加など、今回新たに追加した編集機能も全て無料で利用することができます。 また、お知らせやピックアップ商品、スライドショーやSNSアイコンなど、20種類以上あるパーツの中から必要なものをネットショップに追加することが可能です。パーツを追加する位置も調整できるので、伝えたい情報や商品などの構成をご自身で決めることが可能です。 「HTML編集 App」をアップグレード 「HTML編集 App」を、より本格的なショップページ制作にも対応できるエディタにアップグレードしました。新しい「HTML編集 App」は、画面全体を使ってのコーディングとプレビューができるようになり、大規模な編集もより快適におこなうことができるようになりました。 テキストの変更や画像の追加など、デザインパーツを活用して手軽にデザインをおこないたい場合は、「ショップデザイン機能」を。専門知識を活かして本格的な編集をおこないたい方は、「HTML編集 App」を、と目的に応じて使い分けてもらうような機能として提供しています。 ショップテーマをリニューアルして 結果的に長期プロジェクトにはなりましたが、今後の「BASE」のショップデザインにおいて負債になっている部分を解決できる開発に関わることができたことの達成感は大きく、1年間このプロジェクトに携われてよかったなと思っています。 去年のアドベントカレンダーでも自社の開発について振り返ったのですが( https://devblog.thebase.in/entry/2019/12/13/110000_1 )、過去の開発において、そのときにくだした意思決定は圧倒的に正しくて(あとから状況が変わってそれが最適ではなくなることももちろんありますが)、現在のBASEはそれを積み重ねてきた結果なんだなと思っています。 デザイン編集リニューアルの開発のさなか、コロナの影響で弊社も全社員がリモートワークへ移行しました。プロジェクトの「Times」チャンネルを作り、日々の雑談もOKというチャンネルを作って積極的にオンラインコミュニケーションを取ろうという施策をおこなったところ、30日でのチャット投稿数が全チャンネル内でも2位という結果になり、リモートであってもこのプロジェクトを進めていく熱量のようなものを感じられて、個人的にとても良かったです。 1ヶ月間のSlackチャンネル投稿数 新しいデザイン編集、ぜひ触ってみてください。 明日はネイティブアプリチームの roothy さんです!
TDDのTips
前置き この記事はBASE Advent Calendar 2020 13日目の記事です。 devblog.thebase.in こんにちは、BASE株式会社 Product Dev Division でバックエンドエンジニアを務めている元木です。 以前、社内で同僚のエンジニアと話していたとき、 「TDDって頭では分かっているけど、テストから書くってなかなか難しいよね」 という話がありました。 そこで、自分がTDDでプログラムを書くときに行なっているTips的なものを紹介してみたいと思います。 あくまで 「自分はこういう感じで実践している」 というものであり、 「これが正しいTDDだ!」 と主張するものではありませんので、軽い気持ちで読んでいただけたら幸いです。 そもそも、TDDとは? テスト駆動開発 (Test Driven Development) のことです。いいね? 本題 前置きが長くなりましたが、自分が実践しているやりかたは 「テストコードを書く前に、メソッドコメントを書く」 です。 テストを書こうとしても手が進まない場合、 「実装しようとしているメソッドの仕様が、曖昧なままだから」 ということが原因の一つにあるように思います。 そのため、まずは実装しようとしているメソッドの仕様をメソッドコメント(と、メソッドのシグニチャ)という形で明確にしてみると、テストケースの洗い出しがしやすくなるかも知れません。 以下、サンプルコードを例に説明します。 今、PHPで電話帳アプリを開発していて、 「氏名の一部を入力したら、電話帳に登録されている人の中から氏名が一致する人の電話番号を返す」 というメソッド ( PhoneBook::search() ) を実装しようとしているとします。 何もない状態からいきなりテストを書こうとすると、 <?php class PhoneBookTest extends TestCase { public function test_氏名の一部を入力したら、一致する人の電話番号を返す () { } } という 1つくらいしかテストケースが思い浮かばなかったりします。 そこでまず、メソッドのシグニチャと大まかなメソッドコメントだけ先に書いてみます。 <?php class PhoneBook { /** * 氏名の一部を入力したら、電話帳に登録されている人の中から氏名が一致する人の電話番号を返す。 * * @param string $name * @return array */ public function search ( string $ name ) : array { } } このメソッドコメントを充実させていくことが、テストケースを洗い出すことにつながるわけです。 例えば、 <?php class PhoneBook { /** * 氏名の一部を入力したら、電話帳に登録されている人の中から氏名が一致する人の電話番号を返す。 * * @param string $name 電話番号を検索したい人の氏名の一部。 * 漢字・カナのどちらでも良い。 * @return array */ public function search ( string $ name ) : array { } } と書けば、テストケースは <?php class PhoneBookTest extends TestCase { public function testSearch_漢字で検索 () { } public function testSearch_カナで検索 () { } } の 2つになります。 次に、戻り値に <?php class PhoneBook { /** * 氏名の一部を入力したら、電話帳に登録されている人の中から氏名が一致する人の電話番号を返す。 * * @param string $name 電話番号を検索したい人の氏名の一部。 * 漢字・カナのどちらでも良い。 * @return array 入力した氏名の一部に一致する人の電話番号の配列。 * 一致する人がいない場合、空の配列を返す。 */ public function search ( string $ name ) : array { } } と書けば、テストケースも <?php class PhoneBookTest extends TestCase { public function testSearch_漢字で検索 () { } public function testSearch_カナで検索 () { } public function testSearch_一致する人がいない () { } } となります。 さて、ここで 「引数$nameが空文字列だった場合は、どうしようか?」 と思いつきました。 何も考慮せずに実装すると、電話帳に登録されているすべての人の電話番号を返してしまいます。 そこで、以下のような仕様に決めてみました。 <?php class PhoneBook { /** * 氏名の一部を入力したら、一致する人の電話番号を返す。 * * @param string $name 電話番号を検索したい人の氏名の一部。 * 漢字・カナのどちらでも良い。 * * @return array 入力した氏名の一部に一致する人の電話番号の配列。 * 一致する人がいない場合、空の配列を返す。 * * 引数 $name が空文字列の場合も、空の配列を返す。 */ public function search ( string $ name ) : array { } } テストケースも 1つ、追加されました。 <?php class PhoneBookTest extends TestCase { public function testSearch_漢字で検索 () { } public function testSearch_カナで検索 () { } public function testSearch_一致する人がいない () { } public function testSearch_渡された氏名が空文字列 () { } } ところでこの電話帳アプリ、データは CSVファイルで管理するのですが、後日、データベースに載せ替えことになるかもしれません。 そのため、メソッドの呼び出し元にはメソッド内でファイルを操作していることを隠蔽しておきたいと思いました。 そこで、CSVファイル操作時にエラーが発生した場合は独自に定義した例外を投げるように仕様を決めておきます。 <?php class PhoneBook { /** * 氏名の一部を入力したら、一致する人の電話番号を返す。 * * @param string $name 電話番号を検索したい人の氏名の一部。 * 漢字・カナのどちらでも良い。 * * @return array 入力した氏名の一部に一致する人の電話番号の配列。 * 一致する人がいない場合、空の配列を返す。 * * 引数 $name が空文字列の場合も、空の配列を返す。 * * @throws PhoneBookException 検索中にエラーが発生した場合。 */ public function search ( string $ name ) : array { } } 例外のテストケースも追加されました。 <?php class PhoneBookTest extends TestCase { public function testSearch_漢字で検索 () { } public function testSearch_カナで検索 () { } public function testSearch_一致する人がいない () { } public function testSearch_渡された氏名が空文字列 () { } /** * @expectedException PhoneBookException */ public function testSearch_エラーが発生 () { } } いかがでしたでしょうか? TDDではテストコードという形で仕様を記述しますが、何も決まっていない状態からいきなりテストコードを書くのは、なかなか難しいです。 やはり、自然言語で仕様を記述してからテストコードを書いたほうが、アイデアをまとめやすいと思います。 仕様書を書くのと何が違うの? メソッドの仕様を自然言語で書いていては、 「仕様書を書くのと何が違うの?」 と思うかもしれません。 実際、これほど詳細にメソッドコメントを書くのと仕様書を書くのでは、かかるコストは同じくらいかもしれません。 しかし、仕様を(仕様書ではなく)メソッドコメントという形で記述することには、以下のようなメリットがあると考えます。 そのメソッドの仕様が、そのまま他の人にも読める形でソースコード内に残る。 ある程度、決まった書式に沿って書くことになるので、考えを整理しやすい。 また、コメントの書式は phpDocumentor などのドキュメンテーションツールのそれに合わせておくことをお勧めします。 そうすれば、人間だけでなくIDEにも解釈可能になり、プログラミング作業を助けてくれる可能性が高くなるからです。 最後に 今回は、TDDでプログラミングする際に自分が実践しているTIPSをご紹介しました。 皆さんのプログラミング作業の一助になりましたら、幸いです。 明日はデザインチームの北村さん( id:lllitchi )です。 お楽しみに!
この記事はBASE Advent Calendar 2020の12日目の記事です。 devblog.thebase.in こんにちは!BASE株式会社 ServiceDevのShopグループ所属でエンジニアをしている炭田( @tanden )です。 「BASE」の裏側で動いているアプリケーションはCakePHP 2を使っています。そのCakePHP 2にプルリクエストを送ったけど先を越されてしまった話をします。 過去にも弊社の田中( @tenkoma )が同じような記事を書いていたので、そちらも合わせてご覧いただけると嬉しいです! devblog.thebase.in プルリクエストの内容 今回自分がプルリクエストを送ったのは、Validation::time()の不具合の修正です。Validation::time()は渡された文字列が妥当な時刻の形式になっているかどうかをチェックします。 渡された文字列が妥当な時刻かどうかを検証します。時刻は 24時間形式 (HH:MM) または am/pm ([H]H:MM[a|p]m) です。秒までは検査できません。 データバリデーション - 2.x から引用 しかし、ある開発作業のコードレビューの中で、Validation::time()を使おうとなり、実際にValidation::time()を使ったコードをテストをしていたところ「12:00のようなHH:MMの形式以外は受け付けないはずなのに、12のような『:MM』がない文字列でもパスしてしまう」現象が起きてしまいました。なぜかと思い、CakePHP 2のValidation::time()のコードを確認してみると、以下のようになっていました。 /** * Time validation, determines if the string passed is a valid time. * Validates time as 24hr (HH:MM) or am/pm ([H]H:MM[a|p]m) * Does not allow/validate seconds. * * @param string $check a valid time string * @return bool Success */ public static function time($check) { return static::_check($check, '%^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$|^([01]\d|2[0-3])(:[0-5]\d){0,2}$%'); } https://github.com/cakephp/cakephp/blob/2.x/lib/Cake/Utility/Validation.php#L386-L396 原因となる正規表現 せっかくなので原因となる正規表現を詳しく見ていきます。前半部分の ((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m)) は「AM/PM形式の時刻のチェックをしている」部分になります。今回の問題点は24時間表記(HH:MM)の部分なので飛ばして | ORで区切られた後半部分を確認していきます。 後半部分は以下のようになっています。 ^([01]\d|2[0-3])(:[0-5]\d){0,2}$ ^ は ^ の次に指定された文字列で始まっていることを示すメタ文字で、 $ は $ の前に指定された文字列で終っていることを示すメタ文字です。なので、その間の ([01]\d|2[0-3])(:[0-5]\d){0,2} を見ていけば、今回の24時間表記の時間のバリデーションの問題の原因がわかりそうです。前半の ([01]\d|2[0-3]) では「0もしくは1が先頭にきて、次に任意の数字(0から9)がくる OR( | ) 20, 21, 22, 23のいずれかがくる」文字列にマッチさせていることがわかります。これは24時間表記のHHの「00 - 23」までを表し特に問題なさそうです。 次に時間の「分」の部分をマッチさせる後半の (:[0-5]\d){0,2} では「まず : がきて、0から5のいずれかの次に任意の数字がくる文字列( (:[0-5]\d) )の0回以上2回以下の繰り返し( {0,2} )」になっています。ここで問題になるのは「0以上2回以上の繰り返し」が指定されているので「 : 以下が無し、 :00 、 :00:00 」の3パターンの文字列にマッチしてしまいます。ドキュメントだと、24時間表記の場合はHH:MMのみで秒はチェックしないとあるので、ドキュメントとの相違が2つ含まれていることがわかります。 HHのみの形式でもtrue HH:MM:SSの形式でもtrue なので、 {0,2} の部分を外してしまって、以下にような正規表現にするとドキュメントとの相違点が無くなりそうです。 - '%^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$|^([01]\d|2[0-3])(:[0-5]\d){0,2}$%' + '%^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$|^([01]\d|2[0-3]):[0-5]\d$%' この部分を修正して、テストも一緒にプルリクエストとして出せば取り込まれて直せそうだな、めでたしめでたし(※)と思い、CakePHP 2向けに以下のプルリクエストを作成して送った( 最初に出したプルリクエスト )のですが、早速以下のコメントをいただきました。 2.x is at security maintenance mode only now. バージョン2系はもうセキュリティ面以外のメンテナンスはされていないのですね...(知りませんでした)。Validationメソッドはできるだけカスタムメソッドを使って実装したくなかったので、できれば本体に取り込んだものを使いたかったのに...残念。 ※ 秒数を受けないようにする対応はAM/PM表記用の正規表現においても対応する必要がありました。 CakePHP 4にもプルリクエストを出してみる CakePHP2での変更はかないませんでしたが、せっかくなのでバージョン4系のValidation::time()ではこの問題が修正されているのか見てみることにしました。 以下は修正前の古いコードです。 /** * Time validation, determines if the string passed is a valid time. * Validates time as 24hr (HH:MM[:SS][.FFFFFF]) or am/pm ([H]H:MM[a|p]m) * * Seconds and fractional seconds (microseconds) are allowed but optional * in 24hr format. * * @param mixed $check a valid time string/object * @return bool Success */ public static function time($check): bool { if ($check instanceof DateTimeInterface) { return true; } if (is_array($check)) { $check = static::_getDateString($check); } if (!is_scalar($check)) { return false; } $meridianClockRegex = '^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$'; $standardClockRegex = '^([01]\d|2[0-3])((:[0-5]\d){0,2}|(:[0-5]\d){2}\.\d{0,6})$'; return static::_check($check, '%' . $meridianClockRegex . '|' . $standardClockRegex . '%'); } https://github.com/cakephp/cakephp/blob/4.next/src/Validation/Validation.php#L628-L655 starndardClockの正規表現を見てみると... ([01]\d|2[0-3])((:[0-5]\d){0,2} 時間の「分」をマッチさせる箇所で「0回以上2回以下」の指定がそのまま残っていました。これでは、HHのみの文字列でもtrueが返ってしまいます。なので、以下のように修正する プルリクエスト をテストと一緒に出してみました。 - '^([01]\d|2[0-3])((:[0-5]\d){0,2}|(:[0-5]\d){2}\.\d{0,6})$'; + '^([01]\d|2[0-3])((:[0-5]\d){1,2}|(:[0-5]\d){2}\.\d{0,6})$'; 時間の「分」をマッチさせる箇所で「0回以上2回以下」のところを「1回以上2回以下」に変更しています。しかし、コメントで"We should update the documentation instead. Treating 12 as 12:00 seems like a reasonable thing to me."(HHの形式でも違和感ないから、仕様が書いてあるコメントの方を変更しよう)となり、「あれ、仕様の方を変えるのか...」と一瞬思いましたが「たしかに、00から23をtimeとして扱うのはわからなくもない」と考え、修正用のプルリクエストはクローズして メソッドのコメントだけ変えるプルリクエスト を作成し、こちらは無事に取り込まれました。 さらにコメントを貰うが放置してしまう メソッドのコメントを修正するプルリクエストを作っているときに別の方から"I dont think TimeType will marshal HH only time if that matters."とコメントをいただきました。自分は最初このコメントの意味がよくわからず(marshal?)、またちょうど業務で忙しくなってきたタイミングだったので、メソッドのコメントを修正したプルリクエストを出したままでそのまま放置していました。しかし、後日CakePHPのリポジトリを覗いてみると以下のプルリクエストがマージされていました。 Restrict Validation::time() so it requires minutes not just hours 「実装の方を修正してる!"marshal"(things in order)ってそういうことかー!」と悔しい思いをしました(せっかくなら実装に差分があるコミットを取り込まれたかった...笑)。ですが、「OSSでも日頃の業務と同じくプルリクエスト上で色々議論しながら作っていくんだな」と当たり前のことを体験することができました。こちらの修正は4.2.0でリリースされるようです( 4.2.0のマイルストーン )。とりあえずプルリクエストを出しておくのは大事ですね! ちなみに:CakePHP 2でValidation::time()の不具合に対応するには CakePHP 2をお使いの場合、Validation::time()の不具合に対応するには以下の正規表現でカスタムのバリデーションメソッドを作成すれば、「HHのみの形式」や「秒数付きの文字列」は受けつけないようにすることが可能です。 '%^((0?[1-9]|1[012])(:[0-5]\d){0,1} ?([AP]M|[ap]m))$|^([01]\d|2[0-3]):[0-5]\d$%' まとめ 日頃お世話になっているフレームワークに少しは貢献できたかなと思いつつ、今度は実装を修正したコミットが取り込まれるよう機会があれば再チャレンジしたいと思います! 明日は同じServiceDevのShopグループの元木さんです! 「BASE Advent Calendar」の記事一覧はこちら。 追記 この記事を書いていて、「そういえばCakePHP 3の方はまだ対応されていない気がするな」と思い、CakePHP 3のコードを見てみると、やはり同じような実装になっていたのでプルリクエストをとりあえず出してみました。どうなるかわかりませんが、今回は最後までやりきりたいと思います! CakePHP 3に出したプルリクエスト
この記事はBASE Advent Calendar 2020の11日目の記事です。 devblog.thebase.in BASE株式会社 Data Strategy チームの @tawamura です。 BASEではオーナーの皆様や購入者様のお問い合わせに対して、Customer Supportチームが主となって対応をしています。その中でもいくつかの技術的なお問い合わせに対しては、以下のようにSlackの専用チャンネルを通して開発エンジニアに質問を投げて回答を作成することになっています。 CSチームから調査を依頼されるお問い合わせの例 これらのCS問い合わせ対応は日々いくつも発生しており、 CSお問い合わせ対応を当番制にして運用してみた話 でもあるように週ごとに持ち回り制で各部門のエンジニアが対応しているのですが、どうしても調査や対応に時間が取られてしまうという問題が発生していました。 devblog.thebase.in ただ、いくつかの新規問い合わせに関しては過去に同様・類似のお問い合わせ事例があり、調査や回答の参考になる場合もありました。それならば、過去の類似の投稿を自動で取ってきてBotが提示することで、問い合わせ対応の一助となるかと思いました。 Botの提示例(社内情報が多く、マスクばかりで恐縮です) 過去の類似するお問い合わせ調査 関連する社内ドキュメント 今回は、その問い合わせ対応半自動化のシステム構築についてお話しさせていただきます。ちなみに、この内容は Data Strategy チームの HackWeek の導入とその効果 で行なった1週間の実施タスクでして、今回はここでの結果にもう少し機能追加などを行い整えた記事になります。 devblog.thebase.in 技術選定、システム概観 Slack、API Gatewayなどの連携設定 過去のSlack投稿の取得と解析、Elasticsearchへの保存 Slack投稿の取得 問い合わせ文書の解析 Elasticsearchに過去問い合わせを保存 処理Lambdaの実装 Slack周り 検索クエリの抽出ロジック Kibela検索クエリについて デプロイ 終わりに 技術選定、システム概観 弊社はAWSで環境構築を行なっている部分が多いため、今回もAWS環境を前提に進めます。 結果的に以下のようなシステムを構築しました。 参考情報自動投稿システム まず、特定チャンネルにおいてSlackに新規問い合わせがあった時にそれをイベントとして拾います。そこで拾ったイベントをAWS上で活用しやすいようにAPI Gatewayを使用します。 API Gatewayでイベントを受け取った後に、実際にその内容に対して処理を行うのですが、問い合わせは多いと言ってもデイリーで数十件には上らない程度の発生頻度であり、あまり重い処理を行うわけではないことから、AWS Lambdaを使用することにしました。 Lambda内ではお問い合わせ内容について類似する過去の問い合わせの検出と、弊社でドキュメントサービスとして使用しているKibelaの記事のうち関連する記事を取得する処理を行います。過去の問い合わせ検出には、大量のSlackドキュメントから全文検索により取得することを考えてElasticsearchを使用しました。 受け取った情報からレスポンスを整形し、Slackの特定のチャンネルに投稿します。今回はあくまでサブ機能としての提供で考えていたので、別途自動回答チャンネルを設けてそちらに書き込みをさせました。 弊社の場合、Slackに投稿する部分については、SNSに特定のイベントを投げることで完結するようなシステムがすでにありますので、具体的には以下のようなフローになっています。こちらのSlack投稿システムについては本記事では割愛し、直接Slackに投稿できるようなものを紹介します。 参考情報自動投稿システム(Slack投稿部分分離) Slack、API Gatewayなどの連携設定 Slackの投稿内容をLambdaで解析するためには、Slackの投稿イベントを拾いAPI Gatewayを通してAWS内で扱えるようにし、Lambdaに紐つけることでイベント内容を処理するという一連の連携の設定が必要になります。 こちらの内容については、以下の記事で同様の連携設定がまとめてありますので、そちらを参考に構築していただければと思います。図解も詳しくとてもわかりやすいです。 qiita.com 上記連携を行うことで、Botを追加したチャンネルでの新規投稿について、Lambdaでの個別処理を行うことができます。 今回はPythonでの処理を想定しているので、Lambda作成時のランタイムですが、記事にあるようにPython 3.6やPython 3.7などに指定してください。 過去のSlack投稿の取得と解析、Elasticsearchへの保存 Slack投稿の取得 作成したBotのtokenを利用して、まずは過去のSlackの問い合わせ投稿の取得を行います。Jupyter Notebookなどで実施するのをお勧めします。 headers = { "Content-type" : "application/json" , "Authorization" : f "Bearer {token}" # 取得したslack botのtoken } # 決めで2018年以降の投稿を取得 def fetch_messages_by_channel (channel_id): oldest_ts = None start_date = pd.to_datetime( '2018-01-01' ) endpoint = 'https://slack.com/api/conversations.history' ls_messages = [] while True : payload = { 'channel' : channel_id, 'latest' : oldest_ts, 'count' : 1000 } data = requests.get(endpoint, headers=headers, params=payload).json() messages = data[ 'messages' ] ls_messages.extend(messages) if data[ 'has_more' ]: time.sleep( 1 ) oldest_ts = messages[- 1 ][ 'ts' ] oldest_datetime = pd.to_datetime(oldest_ts, unit= 's' ) sys.stdout.write(f " \r {oldest_datetime}" ) sys.stdout.flush() if oldest_datetime < start_date: sys.stdout.write(f " \r finish!" + ' ' * 50 ) break else : break df = pd.DataFrame(ls_messages) df[ 'channel_id' ] = channel_id return df 対象となるチャンネルのIDは以下のエンドポイントから取得可能(チャンネル数が1000を超える場合は、適宜ループ取得をしてください)。以下は対象チャンネルが #お問い合わせチャンネル の場合の例です。 endpoint = 'https://slack.com/api/conversations.list' payload = { "limit" : 1000 } data = requests.get(endpoint, headers=headers, params=payload).json() channel_df = pd.DataFrame(data[ 'channels' ]) display(channel_df.query( "name == 'お問い合わせチャンネル'" )) 取得したいチャンネルのIDがわかったら、先ほどの関数に渡すことで再帰的に過去の投稿を取得できます。 messages_df = fetch_messages_by_channel(channel_id) 2018年以降の投稿を取得するのですが、1000件ずつ取得していって2018年以前になっていたら終了するというループなので、厳密には2017年後半に一部も含まれます。 続いて、取得したデータを整形していきます。同時に該当の投稿へリダイレクトできるリンクを取得情報から作成します。 # typeの選別 not_message_types = [ 'channel_join' , 'channel_leave' , 'channel_topic' , 'channel_archive' , 'channel_purpose' , 'sh_room_created' , 'channel_name' , 'pinned_item' , 'reminder_add' , 'app_conversation_join' ] messages = messages[~messages[ 'subtype' ].isin(not_message_types)] # slack linkの作成(ドメイン名は適宜変更してください) messages[ "link" ] = "https://xxx.slack.com/archives/" + messages[ "channel_id" ] + "/p" + messages[ "ts" ].str.replace( '.' , '' ) 次に問い合わせ投稿の抽出を行います。弊社の場合、 @cs_dev_team というメンショングループで投稿されているものがそれに該当するため、それらの投稿を抽出します。 # subteamのIDはmessages_dfの中身を確認するなどして代入してください cs_res_df = messages[messages[ "text" ].str.contains( "<!subteam^*********|@cs_dev_team>" )] これで期間中の全投稿 messages_df と問い合わせ投稿の cs_res_df が作られたことになります。 問い合わせ文書の解析 問い合わせ投稿の類似投稿の検索や、社内ドキュメントの検索のために、新規問い合わせの文章を適切に処理して行えるよう事前に解析しておきます。 import re import mojimoji # 事前に不要な項目をtextから削除する関数 def filter_contents (text): # タグ・emojiは削除 subed = re.sub( "<.*?>" , "" , text) subed = re.sub( ":.*?:" , "" , subed) # その他、社内独自ID系などの削除処理 # ******** return subed # 単語の正規化 def normalize (text): text = text.strip() text = mojimoji.zen_to_han(text, kana= False ) text = mojimoji.han_to_zen(text, digit= False , ascii = False ) text = text.lower() return text 全投稿文から名詞のみ抽出します。形態素解析器はJanomeを使用しました。あとでも触れますが、Janomeは辞書も含めてpipで簡単にインストールすることができるので、Lambdaのような環境で簡単に形態素解析を行うのに適しています。 from janome.tokenizer import Tokenizer t = Tokenizer() ndocs = [] for line in tqdm(messages_df[ "text" ]): parsed = [] subed = normalize(filter_contents(line)) # janomeは改行を無視できない for token in t.tokenize( " " .join(subed.split( " \n " ))): tok = token.surface hinsi, sub, _ = token.part_of_speech.split( "," , 2 ) # 数字のみは名詞だがスキップ if tok.isdecimal(): continue if hinsi == "名詞" : parsed.append(tok) ndocs.append( " " .join(parsed)) これで ndocs に名詞列のスペース区切り文字列が投稿数分だけlistとして格納されます。 これを利用してチャンネル全体での単語のTFIDF値(重要度のようなもの)を計算します。TDIDFについては以下の記事などをご参照ください。今回は簡単のためにscikit-learnに含まれるTfidfVectorizerを計算に使用しました。 qiita.com from sklearn.feature_extraction.text import TfidfVectorizer # 文書全体の90%以上で出現する単語は無視、語彙数は10000のみ vectorizer = TfidfVectorizer(max_df= 0.9 , max_features= 10000 ) X = vectorizer.fit_transform(ndocs) words = noun_vectorizer.get_feature_names() idf_list = [ " \t " .join( map ( str , line)) for line in list ( zip (vectorizer.get_feature_names(), vectorizer.idf_))] pd.DataFrame(idf_list).to_csv( "./idf.tsv" , index= False , header= False ) これで idf.tsv というIDF辞書が作成できます。 単品 9.973116006811027 匿名 9.839584614186505 カーソル 9.21097595476413 ショップ 3.3278392845417772 : TF値は新規問い合わせ時にわかりますので、これで新規問い合わせについての名詞のTFIDF値が計算できることになります。 Elasticsearchに過去問い合わせを保存 過去の問い合わせをElasticsearchに保存して、新規問い合わせが来た時に検索をかけられるようにします。まずElasticsearchに過去問い合わせ用のインデックスを作成します。事前にboto3で使用するcredentialsを以下の記事など参考に取得できるよう設定しておく必要があります。 qiita.com import io import boto3 from requests_aws4auth import AWS4Auth from elasticsearch import Elasticsearch, RequestsHttpConnection region = 'ap-northeast-1' service = 'es' credentials = boto3.Session().get_credentials() awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token) es = Elasticsearch( hosts = [{ 'host' : '{Elasticsearchのhost}' , 'port' : 443 }], http_auth = awsauth, use_ssl = True , verify_certs = True , connection_class = RequestsHttpConnection ) index_name = "autores_index" mappings = { 'properties' : { 'orig_text' : { 'type' : 'text' }, 'text' : { 'type' : 'text' , 'analyzer' : 'autores_kuromoji_analyzer' }, 'keyword' : { 'type' : 'text' , 'analyzer' : 'autores_kuromoji_analyzer' }, 'ts' : { 'type' : 'text' }, 'thread_ts' : { 'type' : 'text' }, 'link' : { 'type' : 'text' }, } } settings = { 'analysis' : { 'analyzer' : { 'autores_kuromoji_analyzer' : { 'type' : 'custom' , 'tokenizer' : 'kuromoji_tokenizer' } }, 'autores_kuromoji_tokenizer' : { 'kuromoji' : { 'type' : 'kuromoji_tokenizer' } } } } es.indices.create(index=index_name, body={ 'settings' : settings, 'mappings' : mappings}) 登録するtextなどについて、analyzerに kuromoji_tokenizer を使用することで、全文検索時に形態素解析を活用した検索をすることができます。 過去の問い合わせ投稿について、Elasticsearch用の事前処理を行います。 bulk_dataset = [] for index, row in tqdm(cs_res_df.iterrows(), total=cs_res_df.shape[ 0 ]): text = row[ "text" ] ts = row[ "ts" ] thread_ts = row[ "thread_ts" ] link = row[ "link" ] # タグ除去(メンションやリンク) subed_text = filter_contents(text) # tokenize keyword = " " .join(tp.make_tokens(subed_text)) tfidf_score, unique_keys, shop_ids, phrase_list = tp.process(text) # skip too short message if len (subed_text) < 30 : continue # bulk bulk_dataset.append({ "index" : { "_index" : index_name}}) bulk_dataset.append({ "orig_text" : text, "text" : subed_text, "keyword" : keyword, "ts" : ts, "thread_ts" : thread_ts, "link" : link, }) def bulk (data, dry= False ): if len (data) == 0 : return buf = io.StringIO() for d in data: buf.write(json.dumps(d, ensure_ascii= False )) buf.write( " \n " ) buf.seek( 0 ) if dry: print (buf.read()) else : body = buf.read() es.bulk(body) 以下で200件ずつデータをinsertしていきます。 rest_dataset= bulk_dataset insert_rows_len = 200 while len (rest_dataset) > 0 : data = rest_dataset[:insert_rows_len] bulk(data) rest_dataset = rest_dataset[insert_rows_len:] これでElasticsearchの autores_index にデータが追加されます。以下のクエリなどでデータを確認できます。 es.search( scroll= '5m' , index=index_name, body={ "query" : { "match_all" : { } }, "_source" : [ "orig_text" , "link" ], }, ) 処理Lambdaの実装 Lambdaで実装するのは主に以下の処理です。 API Gatewayから流れてきたイベントを正しく受け取って処理可能な状態にする テキスト解析を行いElasticsearch、Kibelaの検索を行う BotとしてSlackへ回答を投稿する いくつかLambdaで新しく導入する必要のあるモジュールがありますが、それはLambda Layerという形で事前に基盤のようなものを用意しておくことになります。 docs.aws.amazon.com requirements.txtに追加で必要なモジュールを記載し、それらのファイルを一つのzipとしてまとめる必要があります。同時に、事前に作成したIDF辞書もここでLayer内に含めます。以下のようなMakefileを同階層に作って構築すると楽です。 janome requests_aws4auth elasticsearch mojimoji build-layer: rm -rf python && mkdir -p python docker run --rm -v ${PWD} :/var/task lambci/lambda:build-python3.7 \ pip install -r requirements.txt -t python cp idf.tsv python zip -r autores_layer.zip python rm -rf python 以下のコマンドで、Layerに適用可能なzipファイル autores_layer.zip が作成されます。これをLayerとして新規登録しましょう。Webでもできますし、CLIツールでも可能だと思います。 $ make build-layer 今回、Lambda上で形態素解析を行うためにJanomeを採用したと言いましたが、理由として動作が軽いのと、シンプルなpipによるインストールで完結していることが理由として挙げられます。他の形態素解析器でもLayerやEFSなどを活用することでLambda上でも使用できるようなのですが、手っ取り早く扱いづらかったため今回は見送りました。 LambdaのWeb管理画面に行き、新規関数を作成していきます。これは前に説明したAPI Gatewayとの連携設定をしていれば、そのLambda関数を選択するので問題ないです。 まずLayerとして先ほど新規作成したLayerを使用します。 Layerの設定 デザイナータブ内のLayersをクリックすると、下部にレイヤーを管理するタブが出ますので、そこでレイヤーの追加を行います。Layer追加時に互換性のあるランタイムでPython3.7などを指定していればカスタムレイヤーで選択できますが、直接LayerのARNを指定するのでも問題ありません(ARNはLayerの画面右上などにあります)。 Lambdaの設定ですが、使用メモリを512MBとしています。Janomeでの形態素解析を行うのに少し余裕が必要になるためですが、このくらい確保しておけば大抵は動作するかと思います。 次にfunction.pyの中身を実装します。 import boto3 from requests_aws4auth import AWS4Auth from elasticsearch import Elasticsearch, RequestsHttpConnection import json import logging import io import requests import urllib import mojimoji import re from janome.tokenizer import Tokenizer import collections import time import hmac import hashlib logger = logging.getLogger() logger.setLevel(logging.INFO) KIBELA_DOMAIN = "****.kibe.la" # Kibelaのドメイン KIBELA_KEY = "secret/***************" # KibelaのAPIキー ES_DOMAIN = "********.es.amazonaws.com" # Elasticsearchのドメイン SLACK_DOMAIN = "******.slack.com" # Slackのドメイン SLACK_SIGNING_SECRET = "************" # SlackのSigning Secret BOT_TOKEN = "xoxb-********************" # Slack Botのtoken BOT_USER_ID = "U*********" # Slack Botのuser_id INDEX_NAME = "autores_index" # elasticsearchのインデックス名 REGION = "us-east-1" # 利用中のregion OUT_CHANNEL = "#autores_bot" # Botの投稿先チャンネル service = 'es' credentials = boto3.Session().get_credentials() awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, REGION, service, session_token=credentials.token) es = Elasticsearch( hosts=[{ 'host' : ES_DOMAIN, 'port' : 443 }], http_auth=awsauth, use_ssl= True , verify_certs= True , connection_class=RequestsHttpConnection ) def is_valid (event): if SLACK_SIGNING_SECRET is None : return False if "x-slack-signature" not in event[ "headers" ] or "x-slack-request-timestamp" not in event[ "headers" ]: return False timestamp = int (event[ "headers" ][ "x-slack-request-timestamp" ]) if abs (time.time() - timestamp) > 60 * 5 : return False request_body = event[ "body" ] sig_basestring = f "v0:{timestamp}:{request_body}" digest = hmac.new( SLACK_SIGNING_SECRET.encode(), sig_basestring.encode( "utf-8" ), hashlib.sha256).hexdigest() my_sig = f "v0={digest}" if hmac.compare_digest(my_sig, event[ "headers" ][ "x-slack-signature" ]): return True else : return False def event_to_json (event): # API Gatewayから流れてきたイベントから本文を抽出 if 'body' in event: body = json.loads(event.get( 'body' )) return body elif 'token' in event: body = event return body else : logger.error( 'unexpected event format' ) exit class TfidfProcessor (): def __init__ (self): self.word2idf = {} self.subed = "" self.tokens = [] self.tfidf_score = {} self.phrase_list = [] # Layer内のIDF辞書を読み込み with open ( "/opt/python/idf.tsv" ) as f: for line in f: word, score = line.rstrip().split( " \t " ) self.word2idf[word] = score self.t = Tokenizer() def process (self, text): self.filter_contents(text) self.make_tokens() self.calc_tfidf() self.extract_phrase() return self.phrase_list def normalize (self, text): text = text.strip() text = mojimoji.zen_to_han(text, kana= False ) text = mojimoji.han_to_zen(text, digit= False , ascii = False ) text = text.lower() return text def filter_contents (self, text): # タグ・emojiは削除 subed = re.sub( "<.*?>" , "" , text) subed = re.sub( ":.*?:" , "" , subed) # その他、社内独自ID系などの削除処理 # ******** self.subed = self.normalize(subed) def make_tokens (self): self.tokens = list (self.t.tokenize( " " .join(self.subed.split( " \n " )), wakati= True )) def calc_tfidf (self): tf_dic = self.calc_tf() idf_dic = self.calc_idf() for token, idf_score in idf_dic.items(): self.tfidf_score[token] = float (tf_dic[token]) * float (idf_score) def calc_tf (self): tf_tokens = {} tokens_len = len (self.tokens) counts = collections.Counter(self.tokens) for token, freq in counts.items(): tf_tokens[token] = freq / tokens_len return tf_tokens def calc_idf (self): idf_tokens = {} for token in self.tokens: if token in self.word2idf: idf_tokens[token] = self.word2idf[token] return idf_tokens def extract_phrase (self): phrase = {} tmp_phrase = [] for token in self.t.tokenize( " " .join(self.subed.split( " \n " ))): tok = token.surface hinsi, sub, _ = token.part_of_speech.split( "," , 2 ) # ストップワードなどあれば、この辺りで処理しておく self.tokens.append(tok) if tok in self.tfidf_score and hinsi == "名詞" : tmp_phrase.append(tok) elif len (tmp_phrase) > 0 : phrase[ "" .join(tmp_phrase)] = sum ( [self.tfidf_score[tp] for tp in tmp_phrase] ) tmp_phrase = [] phrase_score_list = sorted (phrase.items(), key= lambda x: x[ 1 ], reverse= True ) self.phrase_list = [phrase[ 0 ] for phrase in phrase_score_list] # for link with slack event subscriptions class ChallangeJson ( object ): def data (self, key): return { 'isBase64Encoded' : 'false' , 'statusCode' : 200 , 'headers' : {}, 'body' : key } def search_es (phrase_list): res = es.search( scroll= '5m' , index=INDEX_NAME, body={ "query" : { "match" : { "text" : " " .join(phrase_list[: 20 ]), } }, "_source" : [ "text" , "ts" , "link" , ], "size" : 3 , }, ) return res def search_kibela (phrase_list): # 互いに含有しないtfidf値の高い最大2単語をクエリとして使用 q = phrase_list[ 0 ] for p in phrase_list[ 1 :]: if p in q: continue q = q + " " + p break # 全検索(合計で最大3件になるまで) res_text_list = [] query = """ query { search(query: \ """" + q + """ \ ", first: 10) { edges { node { title, url, folder { fullName } } } } } """ res = get_kibela(query) for edge in res[ "data" ][ "search" ][ "edges" ]: title = edge[ "node" ][ "title" ] url = edge[ "node" ][ "url" ] # 適宜取りたくない記事はルールベースでスキップ if re.search( r'hoge|fuga' , title, re.IGNORECASE): continue res_text_list.append(f "{title} \n {url}" ) if len (res_text_list) > 2 : break return " \n\n " .join(res_text_list) def get_kibela (query): endpoint = f "https://{KIBELA_DOMAIN}/api/v1" headers = { "Authorization" : f "Bearer {KIBELA_KEY}" , "Content-Type" : "application/json" , "Accept" : "application/json" , } payloads = { "query" : query } r = requests.post(endpoint, data=json.dumps(payloads), headers=headers) res = r.json() return res def save_to_es (text, tp, ts, thread_ts, target_link): bulk_dataset = [] bulk_dataset.append({ "index" : { "_index" : INDEX_NAME}}) subed_text = tp.subed phrase_list = tp.phrase_list bulk_dataset.append({ "orig_text" : text, "text" : subed_text, "keyword" : phrase_list, "ts" : ts, "thread_ts" : thread_ts, "link" : target_link, }) bulk(bulk_dataset) def bulk (data, dry= False ): if len (data) == 0 : return buf = io.StringIO() for d in data: buf.write(json.dumps(d, ensure_ascii= False )) buf.write( " \n " ) buf.seek( 0 ) if dry: print (buf.read()) else : body = buf.read() es.bulk(body) def send_slack (channel, text): headers = { 'Content-Type' : 'application/json; charset=UTF-8' , 'Authorization' : 'Bearer {0}' .format(BOT_TOKEN) } post_data = { 'channel' : channel, 'text' : text, } url = 'https://slack.com/api/chat.postMessage' req = urllib.request.Request( url, data=json.dumps(post_data).encode( 'utf-8' ), method= 'POST' , headers=headers ) urllib.request.urlopen(req) time.sleep( 3 ) def lambda_handler (event, context): # verify slack if not is_valid(event): return body = event_to_json(event) # return if it was challange-event if 'challenge' in body: challenge_key = body.get( 'challenge' ) logging.info( 'return challenge key %s:' , challenge_key) return ChallangeJson().data(challenge_key) # skip timeout retry, http_error retry if "x-slack-retry-reason" in event[ "headers" ] and event[ "headers" ][ "x-slack-retry-reason" ] in ( "http_timeout" , "http_error" ): return # SlackMessageに特定のキーワードが入っていたときの処理 if "type" in body.get( "event" ) and body.get( "event" ).get( "type" ) == "message" \ and "user" in body.get( "event" ) and body.get( "event" ).get( "user" ) != BOT_USER_ID: text = body.get( "event" ).get( "text" ) # @cs_dev_teamのみ拾う if "<!subteam^*********|@cs_dev_team>" not in text: return channel = body.get( "event" ).get( "channel" ) ts = body.get( "event" ).get( "ts" ) thread_ts = body.get( "event" ).get( "thread_ts" ) target_link = "https://{}/archives/{}/p{}" .format(SLACK_DOMAIN, channel, ts.replace( '.' , '' )) # tokenizer tp = TfidfProcessor() phrase_list = tp.process(text) # search es es_res = search_es(phrase_list) es_list = [] for hit in es_res[ "hits" ][ "hits" ]: link = hit[ "_source" ][ "link" ] es_list.append(link) if len (es_list) > 2 : break es_list_text = "関連: \n " + " \n " .join(es_list) # search kibela kibela_res = search_kibela(phrase_list) # post message to main send_slack(OUT_CHANNEL, target_link) # post ref data message to main send_slack(OUT_CHANNEL, es_list_text) # post kibela message to main send_slack(OUT_CHANNEL, kibela_res) # save message for future questions save_to_es(text, tp, ts, thread_ts, target_link) return コード中の各種キーやドメイン名などは、動作環境に合わせて適宜修正してください。 大枠の流れは、 API Gatewayから受け取ったイベントを処理し、処理すべきイベントか判断 Janomeを使用して本文を解析し、Elasticsearch、Kibelaを検索するための単語を抽出 それぞれ検索を行い、結果をSlackに投稿 受け取った新規問い合わせは、次回の過去問い合わせとなるのでElasticsearchに過去問い合わせとして登録 という流れで処理を行います。 以下に一部コードを抜粋して説明します。 Slack周り API Gatewayからイベントを受け取るときに、Slackからきたイベントだというのを判別するため is_valid という関数で処理しています。下記リンクが詳細になります。 SLACK_SIGNING_SECRET にはSlack Botの管理画面の「Signing Secret」を代入してください。 api.slack.com また、Botを使った自動投稿システムを作るときに、自分自身の投稿も反応対象として拾ってしまうと無限に投稿をし続けてしまうという問題が発生してしまいます。今回は特定のメンションを含むときを対象に抽出するので問題はないかもしれませんが、レスポンス内容を変更する場合は事前にBotからの投稿は無視するという処理をしておくと良いでしょう。 if "type" in body.get( "event" ) and body.get( "event" ).get( "type" ) == "message" \ and "user" in body.get( "event" ) and body.get( "event" ).get( "user" ) != BOT_USER_ID: text = body.get( "event" ).get( "text" ) ここの部分です。Botのuser_idはどこにあるかわかりにくいですが、こちらでBotのtokenを渡すことで取得できると思います。 api.slack.com その他、投稿する内容については send_slack 内のpost_dataに追記することで色々できるようです。また、投稿についてですがAPI Limitなどの余裕を持って3秒ほどsleepさせています。 検索クエリの抽出ロジック 問い合わせのテキストを解析し、検索に使用する単語を抽出するのは TfidfProcessor クラスで行います。 具体的な処理の内容としては、 絵文字やリンクなどを削除 形態素解析をし、名詞についてのみ形態素群を抽出し、それぞれのTFIDF値を計算 形態素解析時に連続する名詞は名詞句として扱い、TFIDF値は合算値とする 「振込/申請」とあった場合は、「振込申請」として「振込」と「申請」のTFIDF値を合算したものを使用する TDIFD値の高い順に名詞・名詞句を並び替えておく という流れになります。これによりTFIDF値の高い名詞・名詞句が獲得できるようになったので、以下のようにそれぞれ検索を行っています。 Elasticsearchでは上位20件の名詞・名詞句の空白区切りテキストを、過去問い合わせのtextに対して全文検索する (結構適当でもうまくやってくれるイメージです) Kibelaでは上位2件の名詞・名詞句をスペース区切りでクエリとして全文検索する 「振込申請」「振込」「注文」と続く場合は「振込申請」「注文」を選択するような処理を入れています 過去問い合わせとKibelaドキュメントのどちらも最大3件までとしました。 Kibela検索クエリについて Kibela APIの検索クエリはGraphQLで書く必要があります。 github.com 以下のschemaに沿ってクエリを作成します。 github.com 特定のフォルダ直下で検索したいときなどは、 query { search(query: "hoge fuga", first: 10), folderIds: ["{フォルダのID}"]) { edges { node { title, url, folder { fullName } } } } } という風に書くと絞り込みができたりしますので、弊社の場合はその検索も併用していたりします(フォルダIDはAPIを叩いて得られたものを手で入れました)。 デプロイ あとは保存をしてデプロイを行うだけです。うまくいけば、特定チャンネルの対象グループへのメンション投稿に対して、 そのメンションへのリンク(展開されます) Elasticsearchを利用した過去問い合わせ3件へのリンク(展開されます) Kibelaで検索した関連記事3件のタイトルとリンク が指定したチャンネルに順次Botから投稿されるかと思います。イメージは冒頭の画像の通りです。 実際には綺麗な出力にしていくためには、ストップワードを人手で登録したり、Kibelaの検索を細かい設定で分けたりフィルターをかますことで結果の記事を良い感じに調整するような泥臭い作業が重要になってきます。本記事では具体例は割愛しましたが、その辺りはいくつかサンプルで試してみて見つけつつ、運用が始まったら実際の出力などをみつつさらに調整する感じが良いかと思います。 終わりに エンジニアによる調査が必要だったりする技術的なお問い合わせに対して、過去の類似したお問い合わせや関連する社内ドキュメントの推薦を行うシステムを社内で導入した件についてまとめました。まだまだ精度改善の余地などはありますが、これ以前もあったような気がするな・・?というようなお問い合わせについては過去の事例を参照することで、同様の調査や回答の作成を楽に行えるようになったかと思います。実際に「自動で出してもらった回答がそのまま使えました!」というような声もいただいたりしました。 弊社の場合はSlack通知部分をSNSなどを利用して汎用システムとして用意していたり、各サービスの連携や変数などは構成管理ツールで管理させていたりします。また、実際には構築済みのVPC内で実装を行っていましたが、それらの説明については今回は省略しました。BASEでは現在、不正検知エンジニアを中心に機械学習エンジニアを募集しています。詳しいお話などは是非ご面談等でお話お伺いできればと思います! 今後も引き続き改善を行い、より早くお問い合わせの返信を行えるよう努力していければと思います。 明日はService Devチームの炭田さんです!お楽しみに!