TECH PLAY

BASE株式会社

BASE株式会社 の技術ブログ

587

こんにちは!デザイナーの渡邊です。 前回、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チームの炭田さんです!お楽しみに!
この記事はBASE Advent Calendar 2020の10日目の記事です。 devblog.thebase.in はじめに こんにちは、BASE株式会社 ServiceDevセクション マネージャーの菊地です! サービスの急成長に伴って組織の拡大が急務であり、最近は採用活動に専らコミットメントしています。BASEに興味ある方はお気軽に 私まで ご連絡ください! さて、BASEでは120万を超えるショップオーナー様と多くのユーザー様にご利用いただいており、日々多くのお問い合わせを頂いております。基本的には弊社カスタマーサポートチーム(以下CSチーム)が一次受けして回答しているのですが、CSチーム内で回答できないものについては開発チームに依頼がきて調査/対応しています。 採用活動を行う中で他社のエンジニアと話す機会が多くあるのですが、「CS対応の運用がうまくいかない。一部のメンバーに負担が集中してしまう」といった悩みを持っている会社さんがとても多いようです。一方「BASEではCS対応を当番制にしたらうまく運用できています」とお話しすると興味を持って頂けることが多くあり、弊社のCS対応に関する知見を共有することは一定の需要があるのかなと思い、アドベントカレンダーのネタとして採用しました。 CS当番制導入以前の対応について 開発チームへのお問い合わせ対応依頼は1日あたり10数件程あります(11月23日~12月4日のデータを集計)。すぐに回答できるものもあれば、不具合が発覚し不具合修正に1日かかってしまうケースも少なくありません。 当番制導入以前はSlackにある #CSお問い合わせ対応チャンネル (弊社の全エンジニア約60名がジョインしています)において @here で全エンジニアに対応が呼び掛けられていました。 しかしどのエンジニアもメインのPJの機能開発で忙しいため、こういった日々の突発的なお問い合わせの対応を行うことは各々の主体性に期待しているだけではなかなかうまくいきませんでした。 @here のメンションが飛んできても誰も反応しないということが多々あり、結果的に人一倍当事者意識の強いCTOやテックリードに対応の負担が集中してしまうという状況が起きていました。CTOやテックリードには彼らにしか解決できない難しい課題に取り組んで欲しいので、毎日数時間をCS対応に費やす状況は好ましい状況ではありませんでした。 そこで課題を解決するべく、今年の4月頃から全開発組織を巻き込んでCS当番制を導入してみようということになりました。 CS当番制の仕組みについて 「CS当番に期待されていること」、「お問い合わせの対応フロー」等は社内のドキュメントに明文化してあり、CS当番が選出されるたびにメンバーを集めて読み合わせを行い認識のすり合わせを行っています。 仕組みの大枠の部分はエンジニアリングマネージャ(以下EM)とテックリード(以下TL)陣で議論して決めましたが、運用が始まってからはメンバーからの改善案も多く取り入れています。取り入れた案についての具体的な事例は後ほど紹介します。 下記にドキュメントから一部抜粋して弊社のCS当番制の仕組みについて紹介します。 CS当番のメンバー CS対応に責任を持つメンバーを毎週全開発チーム(バックエンド, フロントエンド, SRE, DS, etc)から1人以上選出して @CS当番 というグループを作成します 当番の人数は大体10人弱くらいになります 担当期間は1週間です 開発チームは各チーム6人程度なので大体1.5ヶ月に1回当番が回ってくるペースになります 全体の指揮(決起会、振り返り会の開催、改善案の採用など)はEMが行います CS当番に期待されていること 当番の週はCS対応を優先に行うこと。本来の業務に専念したい場合等は上長に相談して当番の週を変更してもらいましょう ボールを持った人が必ずしも調査/対応を行う必要はありません。難しかったり、忙しかったりした場合は他のメンバーや上長にヘルプを求めましょう 一部の人に負荷が集中しないように、みんなで協力して対応しましょう お問い合わせ対応を通してBASEのサービス&システムの理解を深めていきましょう 月曜日に決起会、金曜日に振り返り会を行い改善していきましょう お問い合わせの対応フロー @CS当番 のメンションがきたら対応をお願いします。 メンションがきたら5分以内の反応を心がけましょう。 絵文字で反応だと調査に取り掛かっているのか分からないので「確認します」のように一言書いてボールを持っている人が誰だかはっきりと分かるようにしましょう。 1問い合わせにつき1スレッドでやりとりを行いましょう。 問い合わせの回答が得られたら 済 の絵文字をCSメンバーに入れてもらいましょう。 調査/対応したことはドキュメントにまとめて知見を貯めていきましょう。 運用していく中で改善したこと 当番制を導入後、メンバーから上がってきた多くの改善案を取り入れてきました。上記の対応フローの中にもメンバーから上がってきた案が多く含まれています。ここでは案を取り入れるに至った背景なども含めていくつか紹介させていただきます。 決起会・振り返り会を行うことでチームの結束力を高める CSお問い合わせは自分が詳しくない領域のものも多くあるので、自分以外の当番のメンバーが「どういった領域が得意な人たちなのか」を把握していることはコミュニケーションをとって円滑にお問い合わせ対応を行う上でとても重要です。 一方で最近ではフルリモート下で入社してきたメンバーも多く、彼らにとってはもはや「話したこともないし、見たこともない」メンバーとの連携が求められることになります。さすがにこれを新入社員のコミュニケーション能力でカバーしてもらうことを期待するのは酷なのではないかという課題感がありました。 そういった課題感を感じていたときにメンバーから、「当番がスタートする月曜日に決起会を行い、そこで自己紹介や得意な領域等に関する共有を行うようにし、金曜日には振り返り会としてCS対応を行ってみて感じたことや改善案などを話し合う場を設けるようにしたらメンバー間でコミュニケーションが取りやすくなるのではないか」という提案があり、取り入れてみることにしました。 決起会・振り返り会を行うようになってからは新入社員に限らず、古くから在籍しているメンバーからも「コミュニケーションが取りやすくなり、お問い合わせ対応を協力して行いやすくなった」という声があがっています。 決起会の様子です。本記事を書くにあたって久しぶりに参加したら好きな動物について紹介し合っていました。この画像の中だけでもフルリモート下で入社してきたメンバーが5人もいます。 「誰がボールを持っているか」を明確にする CS対応は1つの「コト」に対して多くの「ヒト」で向き合っているので、「誰が何をするのか/しているのか」が明確になっていると、状況の進行に対して安心感を付与させられると思います。1番不安なのは「ボールが宙に浮いて誰も手を付けていない」という状況です。その他にも、「全く同じことを調べていた」「複数人で別個に調査資料をまとめていた」というのも勿体ないです。 そういった課題感を感じていたメンバーから、「調査に取り掛かる際は「確認します!」のようにはっきりと宣言して、ボールを持っている人が誰だか分かるようにしよう」という提案があり、対応フローの一つとしてルール化しました。 ルール化して以降は 「誰がボールを持っているか」が把握しやすくなったため、「あの人がボールを持ってくれてるから自分は他の問い合わせの調査をしよう」「ボールを持っている人のフォローとして私に何か出来ることはあるか」などそれぞれのメンバーが自分が今何をすべきかを理解し、チームとして効率的に動けるようになったと感じています。 1問合せにつき1スレッドで対応する BASEではスレッドの運用について明確にルールはなく、CS対応についても人によってチャンネルとスレッドでやりとりが混在していました。それによって次のような課題がありました。 複数の問い合わせのやりとりが、 #CSお問い合わせ対応チャンネル 上に混在しているのでやりとりを追いづらい 各お問い合わせの対応ステータス(解決済みなのかどうかなど)が追いづらい そういった課題感を感じていたメンバーから、「スレッドでやりとりするようにルール化すれば、問い合わせ元のメッセージに対して「 済 」などと絵文字を入れておくだけで、状況の把握が容易になるし、「どの問題が発生中・進行中なのか」について、チャンネルを開くだけで判然とするようになるのでは」という提案があり、対応フローの一つとしてルール化しました。 ルール化して以降は下の画像のようにお問い合わせが解決したかどうか、一目で把握できるようになり非常に見通しが良くなりました。 過去の類似のお問い合わせや関連する社内ドキュメントを自動的に取得する 過去の類似のお問い合わせや関連する社内ドキュメントを見つけることができれば容易に解決できるパターンがそれなりに多かったため、DS(機械学習)チームのメンバーがそれらを複数件自動取得してくるシステムを自発的に作ってくれました。これにより調査がしやすくなりました。こちらについては明日のアドベントカレンダーで詳しく紹介させていただきます。 その他 その他にも「調査/対応した際はドキュメントを書くこと」をルール化したり、「誰に相談したら分からないときに相談できるチャンネルを作成」したりなどメンバーからの改善案を多く取り入れてきました。今後もさらに改善を積み重ね、より良い運用を行っていきたいです。 CS当番制を導入して良かったこと CS対応を当番制とすることで責任の所在や期待されていることが明確になり、BASEの全エンジニアがお問い合わせ対応に取り組むようになりました。 導入前、一番課題に感じていた一部のメンバーへ負荷が集中するという問題が大きく軽減されました。 コミュニケーションが不足しがちになる昨今のフルリモート環境下において、決起会や振り返り会を行うなどチームを超えて協力し合うことでコミュニケーションの活性化に役立っています。 お問い合わせ対応を通して普段馴染みのない領域の調査を行うことでBASEの幅広いサービス理解・システム理解に役立っています。 新入社員がBASEに馴染むためのオンボーディングとしても役立っています。 まとめ 以上、CS対応を当番制にしたらうまく運用できていますという紹介でした。少しでも参考になれば幸いです。 今後も改善を積み重ね、より早くお問い合わせの返信ができるように体制を整えていきたいと思います。 明日は、DSチームの粟村さんです!お楽しみに!ばーい! ※ 文中で用いている #CSお問い合わせ対応チャンネル や @CS当番 という名前は事実とは異なります 改めて、仲間大募集中です! Webアプリケーションエンジニア open.talentio.com Webアプリケーションエンジニアは主体技術はバックエンド実装ですが、サービスを作る時にフロントエンドも書いています。 フロントエンドエンジニア open.talentio.com 開発プロジェクトにおけるフロントエンド実装と、BASEのフロントエンド実装におけるライブラリや実装技術の守り神を担います。
はじめに この記事はBASE Advent Calendar 2020 9日目の記事です。 初めまして、BASE株式会社 CSEチームに所属している秋谷です。CSEについては下記の記事に詳しく書かれていますので詳細は省きますが、一言で言うと社内の業務効率良くして働きやすくして行こう!をミッションに、社内業務改善と内部統制の二つの軸で業務を遂行しています。 devblog.thebase.in 私は今年の3月に入社しましたが、その頃には既にコロナが流行し始めており、特にBASEはWork From Home (以下WFH)をいち早く実践していたため、出社した回数はトータルで1ヶ月もありません。 そんな私がこの10ヶ月を振り返り、WFH下でのCSEとしての業務の振り返りをしていきたいと思います。 社内業務改善 社内業務改善では、私は主に経理業務の業務改善に携わっています。「業務改善」とは現存のプロセス全体を最適化することを目的としており、ごくごく一部だけ自動化するプログラムを書いて終わりではありません。関係する各所へのヒアリングや業務全体に関わるフローの確認、不要なプロセスの削減・代替方法の検討など様々あります。 このWFH下では既存の業務の多くを見直し、フローをより良く改善する良い機会になったのではないかと個人的には感じています。 実際に入社後すぐに経理業務の一部を改善する機会をいただいたのですが、その際に既存業務のフローを洗い出し、出社が必要な部分のフローが本当に必要なのかをヒアリングし、その業務に関わる全てのフローを出社無しで従来の半分以下の時間で実施出来るようになりました。 元々業務を経理からCSEに移管する話は出ていましたが、更に業務に関わるフローを短縮できたのはWFHのおかげではないかと感じています。 余談ですが、この時の業務は後に取得できる情報を大幅にアップデートし、売上データダウンロードAppとしてリリースされています。ぜひご活用ください。 apps.thebase.in 内部統制整備 こちらの記事 にもあるように、BASEでは上場企業が守らないといけないJ-SOX法に対して、2021年度末までに未整備な項目の是正・必要書類の作成などが必要になります。一社員として決められた項目に沿って証跡を取得するのではなく、整備していく立場になるのは殆どのメンバーが初めての経験だったため、まずはCSEチーム内での内部統制本の輪読会を実施し、内部統制への理解を深めてから担当にわかれ、それぞれが業務を遂行していきました。 輪読した本はこちら www.amazon.co.jp 私はIT業務処理統制の担当になったのですが、IT業務処理統制ってなんぞ??なところからスタートしています。そのため、最初に社内で対象になりそうな項目を洗い出していただき、その担当者に対してヒアリングを実施していきました。余談ですが、このヒアリングが社内業務の理解にも繋がり、内部統制とは直接は関係ないところでも大いに役立っています。ヒアリングした内容はPlantUMLを用いてワークフロー図に起こし、社員全員が見られるところに公開しました。これにより、新しい取り組みを開始する前に既存のワークフローの確認やいろんな場面で共有し易くなり、フロー図の修正も容易になりました。 また、内部統制上必要なテーブルに対して変更が実施された場合、この変更を内部統制のレポートを作成するシステムに反映する必要がありますが、このWHF化ではこの情報をキャッチするのがなかなか大変でした。 これを解決するために、BASEでは期の初めにその期で実施するプロジェクトの概要をスプレッドシートにまとめて全社員に共有するという取り組みがあるのですが、そのプロジェクト一覧に内部統制上必要なテーブルに対して影響があるかどうかを記載するようにしてもらい、影響がありそうであれば担当者に話を聞きにいくということを実施するようになりました。このシートにはいつ頃リリース予定かが記載されているため、リリース前の少なくとも1ヶ月前には必要な項目を確認していくことができるようになったのでやりやすくなったかなと思います。 終わりに 多くの企業が在宅に踏み切る中、いきなり在宅になって戸惑った方も多くいらっしゃるかと思います。 ここで紹介した内容はちょっとした工夫程度のものですが、意外とそういったちょっとした工夫でフローは良くなっていくことが多々あります。この機会に、手元の業務のフローや伝達方法を見直してみてはいかがでしょうか。 明日はServiceDevセクションの菊地さんです!
はじめに この記事はBASE Advent Calendar 2020の8日目の記事です。 devblog.thebase.in BASE株式会社 ServiceDevのShopグループ所属、エンジニアの栗田です。 Shopグループではネットショップ作成サービス「BASE」及びショッピングアプリ「BASE」の機能をチームで協力しながら開発しております。 この記事では、私が属するShopグループで勉強会を続けて行くことができたよ。というお話と付随して様々なコミュニケーションのきっかけになったよ。というお話を紹介したいと思います。 チーム勉強会開始前 元々エンジニアを中心に社内で不定期に勉強会が開催されておりました。 具体的には下記のようなテーマ・形式の勉強会でした。 ここ1,2年の間に開催されていたテーマ BASEで使われている特定の技術の勉強会 エンジニア向けの決済勉強会 全従業員向けの BASE BANK勉強会 (※ BASE BANKは金融サービスを扱うグループ会社) デザイナー勉強会 その他多数 開催されていた形式 特定のメンバーで輪講形式 特定のメンバーで事前読み&感想共有形式 特定のチーム内での開催をする形式 個人で読書メモを残す形式 特定のメンバーで輪講形式で行われた「入門 監視」については、SREグループの富塚が書いた記事がありますので もしよければ、こちらも合わせてご覧いただければと思います。 devblog.thebase.in Shopグループでの勉強会 今までは例えば決済代行について突発的な勉強会の開催や、特定の問題について突発的にカジュアルに話す機会はありましたが、1つの本や話題についてチームで腰を据えて学ぶといった事はありませんでした。 個人個人で主に技術的にスキルアップしてその結果をプロダクトに還元できる流れを作っていこう!という事で開催していく事になりました。 題材は 1冊目にクリーンアーキテクチャ、2冊目にオブジェクト指向プログラミングの本を読みました。 1冊目の選定は結構勢いで決まり、2冊目の選定は社内のテックリードエンジニアとも話合って、BASEの環境で直に実践で使えそうで勉強会にオススメの本を何冊か紹介してもらいその中から選びました。 どんな形式で行ったか 自由参加で、各自が好きなタイミングで1週間に1章読んでドキュメントに感想をまとめる形式を取りました。 平日に読む事のできなかったメンバーは休日にもり返したりと各自好きなペースで進めていきました。感想がまとまった後は、ドキュメント上で会話したり 1時間ほど時間を取ってZoomでランチ勉強会したりしました。 ちょうど他のチームも以前読んでいた本なので、様々なメンバーの感想がドキュメント上で見れたり会話のキッカケになる事もあったのも良かったポイントでした。 またランチ勉強会に関しては、オンライン懇親会制度を活用して美味しいランチを食べながら進めるなどメリハリをつけて進めました。 basebook.binc.jp 良かったこと オンラインで集まる事が難しくなった中でのコミュニケーションのきっかけになった。 チームを超えて参加があった。システム基盤の開発を担うチームからの参加・サポートも得て知識を補完する事ができた。 普段の開発の内容に勉強会の内容を盛り込めるようになった。 次の勉強会は何にしようかという声が自然に上がるようになった。 テックリードエンジニアに教えてもらえるのは福利厚生! まとめ 皆様の会社ではどのような方法で勉強会をされていますか? またどのようにチームでの勉強会のコミュニケーションを取られていますか? BASEではShopグループ以外にも同じProductDevのPaymentグループや特定のプロジェクト内などでも勉強会が活発になり、より盛り上がりを見せています。 一緒にプロダクトを作っていく仲間と共に学べて、学びをプロダクトに還元できるサイクルが回っていくととても良いですね。 明日はCSEグループの秋谷さんです!