TECH PLAY

株式会社モバイルファクトリー

株式会社モバイルファクトリー の技術ブログ

222

こんにちは、駅メモ!開発チームエンジニアの id:hayayanai です! 私が開発に関わる駅メモ!は、今年で 10 周年を迎えたゲームです。フロントエンドは Vue.js で開発されていて、現在もコード量が増加しています。 今回は、そんな駅メモ!のフロントエンドに vue-tsc を導入して、GitHub Actions で型チェックを実行し、reviewdog に Pull Request で指摘してもらえる状態を作った話を紹介します。 駅メモ!のフロントエンドの状態 はじめに駅メモ!のフロントエンドの簡単な概要を紹介します。 フレームワークは Angular → Vue 2 → Vue 3 パッケージマネージャーは Yarn Linter や Formatter の GitHub Actions は導入済み JavaScript と TypeScript が混在 JS ファイルでも @ts-check で型チェックをしているものもある TSConfig の設定は strict: false フロントエンドの規模感は以下の通りです *1 。 Vue コンポーネント数は 1650 コンポーネント JS と TS 合わせて 14 万行 Vue 内の script を含む また、型周りで次のような課題を抱えていました。 型チェックはテキストエディタによるものに頼っている ビルド時はトランスパイルのみで型チェックは行われていない 実装者のエディタ次第では TS エラーが出なかったり、気づかず対応漏れしたりすることがある Remote SSH で開発している都合、変更に対する追従が遅くなると起こりがち レビュワーが TS エラーになっていることがわからない 2024 年 4 月頃までエディタで import 時の path 解決ができておらず、型定義を書いても参照されない状態だった 型を書くことのモチベーションが下がっていた 誤った型定義を書いても気づかなかった 開発中に実装が変わっても型定義がそのままになっていることがあった 型に対する信用が落ちていた これらの問題を解決し、より堅牢なフロントエンドを開発するため、vue-tsc を導入して、GitHub Actions で型チェックを実行し、reviewdog に PR でコメントしてもらえる状態を作りました。 また、このタイミングで TSConfig の設定を strict: true に変更しました。 やったこと 1. TypeScript のアップデート vue-tsc で必要な TypeScript のバージョンは 5.0.0 以上でした。取り組み始めたときはプロジェクトにはバージョン 4.9.5 が入っていて、これでは動作しませんでした。 当然 typescript@latest までアップデートしたかったのですが、当時は Node.js 16 を使っていたのもあり諸々のパッケージのバージョンが上げられず、結局 5.3.3 となりました。 加えて、ts-loader も古いバージョンから上げられませんでした。この影響を動作確認したところ、型パラメータに const を用いるなどの新しい TS の構文がエラーになってしまうことがわかりました。 しかしながら、今回の目的は型チェック体制を作ることだったため、これらのバージョン問題は別の機会に解決することとしました。既に Vue の Language Server が TS5.0 以上を要求していて、プロジェクト内ではなくエディタ側の TypeScript を使うような設定が入っていたのもスルーした理由の1つです。 2. vue-tsc のインストール yarn add -D vue-tsc ちょうど Vue 3.5.0 がリリースされていたため、vue-tsc をインストールした後も関連して bug fix が出ていないかは確認していました。 また、Vue2 から Vue3 への移行作業が並行していて、リリースが近づいていたため Vue3 移行後のコードで正常に動くことを確かめていました。 インストールしたら早速型チェックを実行してみます。 yarn run vue-tsc --noEmit 大量にエラーが出ることを想定していたのですが、数個のエラーが出ただけで終了してしまいました。 プロジェクトや環境によるものが多いかもしれませんが、エラー文を読みつつ、vue-tsc にもっとエラーを出してもらうために直したところを書いておきます。 プロジェクト内に存在していた壊れた TS ファイルを直す 括弧が閉じられていないなど、 TS の構文としておかしなファイルを読み込んでエラー 「型定義を書いても参照されない状態」だったため、気づかれず放置されていた 数は少なかったので手動で修正 カスタムディレクティブに不要な記述がされていて TS1003 エラー Vue SFC Playground で再現してみた例 これも数は少なかったので手動で修正 JavaScript heap out of memory NODE_OPTIONS の max-old-space-size を増やすことで解決 Mac と CI を実行する Ubuntu でデフォルト値が異なっていて気づくのが遅れた NODE_OPTIONS='--max-old-space-size=4096' ./node_modules/.bin/vue-tsc 3. tsconfig.json の調整 この機会に合わせて strict: true に変更しました。経緯は以下の通りです。 今まではエラーまみれになるという理由から strict: false にして運用していた 一方で、社内に既に TS を途中導入したうえ strict: true で運用していたチームの実績があった 手元で strict: true に変更したら、型の上では null や undefined になり得る状態のまま値を扱っている箇所が多かった 今まではエンジニアの脳内で null かどうかを考えながらコードを書いていた 当然、見落としもあってフロントエラーになることもあった チェックを強化したほうが、安全で、余計な思考を減らせると感じた その他にも細かいオプションを調整しました。 4. GitHub Actions で vue-tsc + reviewdog が実行されるようにする 既に Linter と Formatter の workflow ファイルが存在しており、発火トリガーはほぼ同じものだったため、既存の workflow ファイルに追記しました。 以下に抜粋した設定ファイルとコメントを掲載します。 jobs : build : runs-on : [ self-hosted, ubuntu-20.04, large ] steps : # ブランチを checkout したり依存を install したりするステップ - uses : reviewdog/action-setup@v1 with : reviewdog_version : latest - name : Run vue-tsc run : | yarn run --silent vue-tsc | reviewdog -name="vue-tsc" -f=tsc -reporter=github-pr-review -filter-mode=file -fail-level=error env : NPM_TOKEN : ${{ secrets.GITHUB_TOKEN }} REVIEWDOG_GITHUB_API_TOKEN : ${{ secrets.GITHUB_TOKEN }} 上述した NODE_OPTIONS については package.json 側に記述 reporter は github-pr-review にした reviewdog の PR コメントに返信できる形式が便利そうだったため filter-mode は file にした 変更した行だけでなく、ファイル全体でエラーを出すため reviewdog のオプションの詳細は 公式の README を参照 実行する self-hosted runners のスペックアップ それまでのインスタンスの設定では vue-tsc が完走できずタイムアウトしてしまっていた メトリクスを確認したところ RAM が不足していた RAM の多いインスタンスが割り当たるように設定を変更して解決 GitHub-hosted runners を使っている場合は RAM が多くあまり遭遇しないかもしれない 使用されたメモリ量は vue-tsc --diagnostics 等でも確認可能 ここまでの設定で、当初の目的を達成することができました。 5. 運用ルールを決める チームで以下のようなルールを定めて運用しています。 工数やレビュワーと相談しつつ、無理のしない範囲でエラー対応をする TS エラーが無い状態から増えたものは必ず対応する 基本的に「 メリハリのある TypeScript 」で運用 まとめ 今回は駅メモ!のフロントエンドに vue-tsc を導入し、GitHub Actions と reviewdog による型チェックの仕組みを導入した話を紹介しました。 導入後、以下のような改善が得られています。 レビュー時に型エラーを自動的に検出できるようになった 開発環境に依存せず、一貫した型チェックが可能になった strict: true への移行により、より安全なコードベースを目指すことができるようになった 導入当初に起きていた TS エラーは、エラーコード単位で一括修正を行ったこともあり、1 ヶ月ほどで約 20%削減することができました。 現在はフロントエンドコード品質のメトリクスの集計も開始しており、それを踏まえて今後の方針を検討していきたいと考えています。 また、今回の導入で浮き彫りになった各種ライブラリのバージョン問題も含め、引き続き改善を進めていく予定です。 記事を読んでいただきありがとうございました! 採用募集案内 モバファクでは中途採用・新卒採用ともに絶賛募集中です。会社の情報については、モバファクブログでも発信しています。 技術好きな方、モバファクにご興味をお持ちの方は、ぜひご応募ください! モバファクブログ: https://corpcomn.mobilefactory.jp/ 採用サイト: https://recruit.mobilefactory.jp/ *1 : tokei コマンドにて算出
アバター
はじめに こんにちは。駅メモ!開発チームの横井です。 今回はプロダクトの機能開発をしながら改善に取り組むためのチーム構成について話します。 背景 駅メモ!はありがたいことに今年で 10 周年を迎えました。 10 年もの間、機能追加や改修をしていくことでアプリケーションは使いやすく進化してきましたが、それとともにコードベースも肥大化し、保守性の面での課題が浮き彫りになっていました。 そんな中、エンジニアとしてその課題を認識しながらも、開発チーム全体として改善に割くリソースが不足していることに気づいたのが 2022 年頃。 ビジネス側の協力を得て、機能開発と並行して改善を進められるチーム体制を構築したのが 2023 年頃です。 2024 年に入って、さらにチームの実情に沿った形へと体制が変更して今に至るのですが、そんなチーム構成の変遷と、気づいたことを説明していきます。 独立した改善チーム(2023 年) はじめは、メインである機能開発チームとは別で、独立した改善チームを結成する方針になりました。 改善チームを独立させた理由は以下の通りです。 メインである機能開発はそれだけに集中して欲しいから 規模の大きな改善をチームで連携しながら効率よく進めるため 駅メモ!開発チームはさらに小さなチームの集合体となっており、各チームから少数のメンバーを引き抜いた少数精鋭で構成されました。 成果と問題点 改善のみに集中するチームができたことで、Linter や Formatter の整備、ライブラリ導入やアップデート、他にも開発効率を上げる様々な改善が実施されました。 特に、フロントエンドの Vue3 への移行は大きなプロジェクトでしたが、改善チームがあったことで機能開発への影響を抑えて完遂できました。 tech.mobilefactory.jp しかし、改善チームによって機能開発チームのリソースが減り、プロダクトの改善に手が回らなくなってしまいました。 もともとその辺りも改善チームが実施するはずでしたが、チームの間に距離があったことで、プロダクトの課題とその重要度が改善チームに十分伝わっていなかったのが原因だと考えています。 定期的な改善デー(2024 年) 1 年ほど改善チームを運用し、先述した問題点も明らかになってきたところで、改善に取り組むためのチーム構成が再び検討されました。 新構成の方針は以下の通りです。 駅メモ!開発チーム全体として改善に割くリソースの割合を維持する 機能開発チームにも改善を進める余裕を与える 開発者体験の改善は優先度を一旦下げる 結論として、改善チームは解散して、月に 2 回の改善デーを導入することにしました。 1 ヶ月を 20 営業日とすると、業務時間の 10% を改善に充てるということになりますね。 改善デーについて 改善デーのルールは以下の通りです。業務への影響を抑えるために緩やかなルールとなっています。 プロダクトの運用効率化や保守性向上を行う チーム内で取り組むべき改善の優先度を決める 優先度の高いものから個人で進行する 緊急対応が必要な場合はそちらを優先する ビジネス側からの要求にも必要があれば対応する 定常的な会議類は普段通り参加する 成果と問題点 狙い通り、プロダクトの改善は大いに進みました。そして予想通り、規模の大きな改善や難易度の高い改善はあまり進まなくなりました。 ビルド基盤の整備、CI/CD の高速化、アーキテクチャの見直しなど、個人が短期間で完了するのは困難な改善について「やりたいけどできない。取り組むならもっと集中してやらないと無理だ」という声が上がっています。 気づき 2 つのチーム構成を運用してみての結果をまとめると 独立した改善チーム 大規模な改善を連携して進められるが、プロダクトの課題が改善チームに伝わりにくい 定期的な改善デー 大規模な改善は進みにくいが、プロダクトの運用効率化などコンテキストの深い改善が進む これらは対照的な結果となりました。 今後の展望 大規模な改善とコンテキストの深い改善をバランスよく進める必要があります。 独立した改善チームの構成を改善し、プロダクト寄りの改善の重要度を共有する体制を強化 定期的な改善デーの構成を改善し、大規模改善をチーム内で協力して進める仕組みを作る 両方のチーム構成を交互に実施 両方のチーム構成を同時に実施するため、リソースを追加 いくつか選択肢がありますが、現時点では「定期的な改善デー」のチーム構成を改善することになるのではと思っています。 おわりに お読みいただきありがとうございました。プロダクトも、開発者体験も、どんどん改善して生産性上げていきたいですね。 採用募集案内 モバファクでは中途採用・新卒採用ともに絶賛募集中です。 会社の情報については、モバファクブログでも発信しています。 技術好きな方、モバファクにご興味をお持ちの方は、ぜひご応募ください! モバファクブログ: https://corpcomn.mobilefactory.jp/ 採用サイト: https://recruit.mobilefactory.jp/
アバター
駅奪取チームの id:kimkim0106 です。 駅奪取チームで Qodo Merge(旧:PR-Agent) を使ってみた感想の記事になります。 結論から言いますと、人間のレビューや作業をある程度代替できており、業務の効率化につながっていると感じました。 Qodo Merge とは Qodo 社(旧:Codium-AI 社)が提供する、AI コードレビューツールです。 さまざまな LLM モデルを使ってコードレビューができるほか、GitHub や GitLab などの API を使用してプルリクエストにコメントをしてくれます。 github.com 導入背景 駅奪取チームは限られたエンジニアで開発と運用を行っており、コードレビューはチームメンバー間で分担して行っています。 しかし、チームメンバーの入れ替わりにより、コードレビューできる人が少なくなり、負荷が増大していました。 また、定期的にリードタイムを計測しているのですが、ファーストレビューの遅延によりリードタイムが伸び、ユーザへの価値提供が遅くなり始めていました。 そこで、コードレビューの負担軽減を図るために Qodo Merge を検証することになりました。 検証内容 以下の点を期待して検証を行いました。 リードタイムの短縮 コードレビュー完了までの時間を短縮することで改善を見込める 品質の向上 コードレビューの見落とし等を防ぐ 不具合や障害を減らす ただし、人間によるレビューを代替することは考えていません。 あくまで、AI による補助的なコードレビューを事前に実施し、レビュワーの負担を軽減することが目的です。 また、モデル変更によるレビュー精度の比較を行いました。 今回検証に用いたモデルは以下の 3 つです。 OpenAI GPT-4o (OpenAI API) Anthropic Claude 3 Haiku (Amazon Bedrock) Anthropic Claude 3.5 Sonnet (Amazon Bedrock) 設定内容 プルリクエスト作成時に GitHub Actions で自動実行するように設定しました。 設定方法は公式ドキュメントの通りですが、リポジトリに IP アドレスによるアクセス制限があるため社内のサーバにて Self-hosted Runner で動かしています。 qodo-merge-docs.qodo.ai レビュー結果を日本語で出力させるため、以下のような Extra Instructions を設定しています。 PR_REVIEWER.EXTRA_INSTRUCTIONS : "日本語で記述してください。" PR_DESCRIPTION.EXTRA_INSTRUCTIONS : "日本語で記述してください。" PR_CODE_SUGGESTIONS.EXTRA_INSTRUCTIONS : "日本語で記述してください。" PR_IMPROVE_COMPONENT.EXTRA_INSTRUCTIONS : "日本語で記述してください。" また、初期設定だと、レビューしやすさなどのラベルが付与されるのですが、チームが独自に設定しているラベルの視認性が低下するため、無効にしています。 無効にしてもプルリクエスト内で確認できるため、とくに問題はありません。 PR_REVIEWER.ENABLE_REVIEW_LABELS_SECURITY : false PR_REVIEWER.ENABLE_REVIEW_LABELS_EFFORT : false PR_DESCRIPTION.PUBLISH_LABELS : false 検証結果 コードレビューの負担軽減に貢献しており、導入する価値があると感じました。 一方で課題もあり、人間のコードレビューを完全に代替することは難しそうです。 以下に実際に使ってみた PR のスクショを貼っておきます。 なお、使用しているモデルは Claude 3.5 Sonnet です。 このような差分のある PR を Qodo Merge にかけてみます。 PR の差分 PR を作成すると、ユーザーが記載した内容の下に Description や Changes walkthrough を生成してくれます。 プロンプトを設定しているので日本語で表示されます。 Description と Changes walkthrough 生成後 また、PR Reviewer Guide として、レビューしやすさなどの情報を出してくれます。 PR Reviewer Guide PR Code Suggestions では、変更の提案についてスコアを含めて行ってくれます。 PR Code Suggestions 総評 良かった点 一般的な内容に対する指摘がありがたい 例外をキャッチしているか コードの可読性の向上させる改善 プルリクエストの変更内容のサマリーを日本語で出してくれる GitHub Copilot pull request summaries は英語しかサポートされていない 悪かった点 たまに致命的なハルシネーションがある 変更内容と逆のことをサマリーを出してくる 参考にできないレビューを返すこともある 差分しか見てくれないので、別モジュールまで実装を追いかけてくれない モデルによるレビュー精度 モデルによって、レビュー精度は左右されることもわかりました。 チームメンバーによる評価が高かった順は以下のとおりです。 Claude 3.5 Sonnet > GPT-4o > Claude 3 Haiku コスト プルリクエスト 1 件あたりのコストを算出しました。 Claude 3.5 Sonnet は GPT-4o よりも安く、レビュー精度が高いため、コストパフォーマンスに優れていました。 モデル名 コスト(ドル/件) GPT-4o $ 0.3 Claude 3.5 Sonnet $ 0.15 Claude 3 Haiku $ 0.1 チームメンバーへのヒアリング チームメンバーにもヒアリングを行いました。 検証を行った順番で掲載しています。 GPT-4o 良かった点 プルリクエストの変更内容のサマリーを日本語で出してくれる 日本語なので、読みやすい GitHub Copilot にも似たような機能があるが、英語でしか出せない 変更内容の説明は PR-Agent に任せ、意図のみ記載する、という形で棲み分けができて、効率が良くなった 一般的な注意すべき点をレビュー・サジェストしてくれる 例外をキャッチしているか こう書くと行数が減る・ネストが深くならない・再利用性が高まるなど レビューだけでなく、suggestion のコードを書いてくれるので助かる 見る目が一つ増えた 特に人間が読み落としそうな浅くて些細な箇所はありがたかった 悪かった点 変更内容のサマリーに、たまに致命的なハルシネーションがあるので、100%信頼はできなさそう 変更内容と逆のことを出してきたことがあった 一般的な内容に対する指摘はありがたい 誰向けのどういった機能か、といった文脈で不要と判断した実装はいろいろある けど、それは本当に不要か?といったことを考えさせてくれる 参考にできない指摘もちらほらある 間違っている理由を考えることで見える視点もあるだろうから、それはそれでよいけどノイズにはなる どうしても 2, 3 個は suggest したいのか、無理くりなものもあるような 差分しか見てくれないので、別モジュールまで実装を追いかけてくれない 「正しく実装されているか確認してください」みたいなレビューしかしてくれない 追いかけて返り値とかまでチェックしてくれるとうれしかったが、そこまでは厳しそう Claude 3 Haiku 良かった点 とくになし 悪かった点 「Respond in Japanese」とか「日本語で記述してください。」をプロンプトに入れても日本語で答えてくれない 冗長になる書き方を提案したり、使わない変数をテンプレートに渡す提案をしたり、あまり有益でない提案が多かった そもそも差分すら認識できておらず、レビューとして成立しない Claude 3.5 Sonnet 良かった点 GPT-4o に比べ、ちょうどいい粒度の description を生成してくれる ハルシネーションも少ないので、概要の手書きを PR-Agent に置き換えることができ、時短になった 誤った提案は他のモデルよりは少ない印象 typo を指摘してくれて助かった description が比較的正確だと思った 悪かった点 あまりない 存在しないコードを作ってそれに対してレビューをする?ことがあって混乱した ネクストアクション Qodo Merge 導入によって、業務効率化の効果が出ているかを検証したいと思っています。 直近のリードタイムは改善して来ているのですが、Qodo Merge 導入によるものなのかはまだ明確ではありません。 また、さらに新しいモデルに入れ替えて検証したいと思っています。 LLM の評価指標には色々とありますが、アップグレード後の Claude 3.5 Sonnet や OpenAI o1-mini あたりが、現在使っている Claude 3.5 Sonnet よりも高かったので、次はこれらを試してみたいです。 モバファクでは中途採用・新卒採用ともに絶賛募集中です。 会社の情報については、モバファクブログでも発信しています。 技術好きな方、モバファクにご興味をお持ちの方は、ぜひご応募ください! ・モバファクブログ: https://corpcomn.mobilefactory.jp/ ・採用サイト: https://recruit.mobilefactory.jp/
アバター
こんにちは。駅メモエンジニアの id:dorapon2000 です。 約半年前の 6 月 1 日にステーションメモリーズ!(駅メモ!)10 周年を記念してタイムラインと地図の切替機能をリリースしました。大変好評を頂いておりとても嬉しいです。 今回は、その機能の中で毎秒最寄り駅を計算するロジックをどのように実現しているのかについてお話します。様々なスペックの端末で遊ばれているため、可能な限りリソースを節約するような工夫をしました。堅い言い方をすれば、過去の計算情報を使った最近傍探索アルゴリズムを実装しました。 記事中のサンプルコードは TypeScript で記述しています。 2024/11/22 追記: はてなブックマークでのご指摘ありがとうございます。 ご指摘をいただいた「事前計算の時間計算量」と「基準点と現在地の距離が近すぎるとき」の説明部分を修正しております。 誤:事前計算を O(N) で行い 正:事前計算を O(N log(N)) で行い 誤:さて、赤円内には最低で 1 駅以上は存在していることが前提となります。 正:さて、青円内には最低で 1 駅以上は存在していることが前提となります。(ここ以外の文章もここに習って修正) ソースコードのロジックには変更を加えておりません。 実現したいこと 制約 実装方法 全探索 実際に実装したアルゴリズム 図解 アルゴリズムコードの解説 事前計算 最寄り駅計算 5km についての解説 エコ方式の計算量 まとめ 採用募集案内 実現したいこと 駅メモ!では全国 9000 以上の鉄道駅を扱っている その中からユーザーの現在地からの最寄り駅を求めたい 最寄り駅は毎秒求めたい 最寄り駅を求めることで、地図上に最寄り駅のマークを表示したいわけですね。 制約 駅メモ!は駅の位置情報連動型ゲームです。アプリ上でボタンを押すことで、最寄り駅へ訪れたことになります。この最寄り駅の計算ロジックはバックエンドに存在します。したがって、バックエンドに API 経由で問い合わせて最寄り駅を取得する方法がまず最初に考えられます。 結論から述べると、この方法は採用しませんでした。 駅メモ!で遊びながら鉄道で移動するユーザーはそれなりの速度で動いています。駅が密集している都市部では目まぐるしく最寄り駅が変わります。毎秒最寄り駅を更新したいときに API 通信を都度していてはユーザー体験を損ないそうです。また、通信量やサーバー負荷も気になります。 そのため、フロントエンドで最寄り駅を計算することにしました。フロントエンドでは以下の情報を持っています。 日本全国 9000 駅の座標情報 現在地の座標情報 実装方法 調べてみると最近傍探索アルゴリズムにはいくつかあるようです。 ChatGPT に聞くと KD-Tree という初めて聞くデータ構造で時間計算量を落とす方法を紹介されました。ただ、業務の中で難解なアルゴリズムをあまり採用したくありません。コードレビューもメンテナンスも大変です。データ構造の初期化でそれなりの計算リソースを消費しても、地図をすぐ閉じられてしまい無駄になる可能性もあります。データ構造を保持するためのメモリ使用量についても気になります。 最近傍探索の最もシンプルなアルゴリズムは全探索です。私が実装したアルゴリズムの紹介の前に、一旦全探索のアルゴリズムではどうなるか見てみましょう。 全探索 function getNearestStation () { nearestStation = null distanceToNearest = Infinity // 現在地から最寄り駅までの距離 for (station in 日本全国9000駅) { if (distance(現在地, station) < distanceToNearest) { nearestStation = station distanceToNearest = distance(現在地, station) } } return nearestStation } 毎秒実行(地図上に最寄り駅を描画(getNearestStation())) シンプルなのであまり説明は不要かと思います。現在地と各駅との距離を計算して、その中で最も短い距離の駅が最寄り駅です。それを毎秒計算します。時間計算量は毎秒 O(N) です。 メリット わかりやすい デメリット 毎秒 9000 駅の計算をしていると端末の消費電力が心配 実際に実装したアルゴリズム このあとの説明がしづらいため、考えたアルゴリズムは「エコ方式」と名付けます。 エコ方式は大きく「事前計算」と「最寄り駅計算」の 2 つの工程に分かれます。 事前計算 最寄り駅計算の前に事前計算して、適当な時間間隔で再計算する 計算時の現在地を基準点 center とする center からみた各駅との距離を配列に保存する メソッド updateCenter で行う 最寄り駅計算 事前計算の結果をもとに毎秒最寄り駅を求める 基準点 center を中心として、基準点-現在地間の距離の 3 倍の半径を持つ円内に含まれる駅の中から全探索をする メソッド get で行う なぜこれで最寄り駅計算ができるのか図解します。 図解 事前計算については工程の説明の通りです。現在地 (基準点 center) と各駅との距離を計算し、配列に格納します。配列にはのちの計算の都合上、駅が近い順に入れておきます。 エコ方式もジャンルは全探索ですが、探索範囲をできるだけ小さくしようと試みています。現在地を中心とした探索円 (青円) を描き、その円内に最低 1 駅以上存在していれば、探索円内に最寄り駅も必ず存在していると言えます。つまり、青円に属する駅の中から全探索すればよいです。しかし、9000 個もある駅のどれが青円内にあるのかわからないことが問題です。 そこで基準点という概念を持ち込んで次のように考えます。基準点を中心とした探索円 (赤円) を描き、その中に青円が完全に内包されていれば、同じようにその中にも最寄り駅が存在しています。しかも、事前計算によって赤円に属する駅は特定可能です!その赤円というのが、「基準点-現在地間の距離の 3 倍の半径を持つ円」なんですね。 さて、青円内には最低で 1 駅以上は存在していることが前提となります。しかし現在地と基準点の距離が近すぎるときに、青円が 1 駅も含めないほど小さくなってしまうかもしれません。その場合は、基準点に最も近い駅が青円の中に入るように調整し、かつ赤円は青円を含むように赤円の半径を調整します。 アルゴリズムコードの解説 先程の説明をコードで示します。 type LngLatArray = [ number , number ] // [経度, 緯度] type MapStation = { stationId : number coordinate : LngLatArray } type StationDistance = { station : MapStation distance : number // center から駅までの距離 (座標の差) } /** * ユーザーの座標から最寄り駅を計算するクラス * * - 基準点 center と全駅との距離を事前に計算し、配列 sortedStationDistances に記録しておく * - 配列 sortedStationDistances は距離で昇順にソートする * - 最寄り駅を求める際は、ユーザーの現在地と基準点の距離 distanceCenterToUser を計算し、 * 配列の中にある distanceCenterToUser * 3 以下の駅の中で最寄り駅を再計算する * - ユーザーの現在地と基準点が離れすぎたときは、基準点を更新して sortedStationDistances を再計算する */ class NearestStation { /* 再計算の基準になる座標 */ private center : LngLatArray = null as unknown as LngLatArray /* 基準座標からの距離で昇順にソートされた駅リスト */ private sortedStationDistances : StationDistance [] constructor ( stations : MapStation []) { this .sortedStationDistances = stations. map (( station ) => ( { station , distance : Infinity, } )) } /** * 基準座標を更新し、全駅で基準座標との距離を再計算する * * @param userLocation ユーザーの座標 */ private updateCenter ( userLocation : LngLatArray ) { this .center = userLocation this .sortedStationDistances = this .sortedStationDistances . map (( { station } ) => { const distance = NearestStation.distance( this .center, station.coordinate ) return { station , distance } } ) . sort (( a , b ) => a.distance - b.distance) } /** * ユーザーの座標から最寄り駅を計算して返す * * @param userLocation ユーザーの座標 * @returns 最寄り駅 */ public get ( userLocation : LngLatArray ): MapStation { if ( this .center === null || ! this .isInEffectiveRange(userLocation)) { this .updateCenter(userLocation) return this .sortedStationDistances[ 0 ].station } // 探索円の半径 const searchCircleRadius = (() => { const distanceCenterToUser = NearestStation.distance( this .center, userLocation ) const distanceCenterToFirstStation = this .sortedStationDistances[ 0 ].distance // 基準点を中心として基準点とユーザーの距離の 3 倍の半径をもつ円の中に必ず最寄り駅がある // ただし、基準点とユーザーの距離が近過ぎた場合は、青円を内包する探索円に最低1駅は入ることを保証する return Math . max (distanceCenterToUser, distanceCenterToFirstStation) * 3 } )() let distanceUserToNearestStation = Infinity let nearestStation: MapStation = null as unknown as MapStation for ( const { station , distance : distanceCenterToStation } of this .sortedStationDistances) { // 探索円内に必ず最寄り駅がある if (searchCircleRadius <= distanceCenterToStation) { break } const distanceUserToStation = NearestStation.distance( station.coordinate, userLocation ) if (distanceUserToStation < distanceUserToNearestStation) { distanceUserToNearestStation = distanceUserToStation nearestStation = station } } return nearestStation } /** * 基準座標とユーザー座標が離れすぎているときに false を返す * * @param userLocation ユーザー座標 * @returns 基準座標を再計算するべきかどうか */ private isInEffectiveRange ( userLocation : LngLatArray ) { // 距離的に約 5 km離れていたら再計算させる const LIMIT_DISTANCE = 0.055 return NearestStation.distance( this .center, userLocation) < LIMIT_DISTANCE } /** * 2点間の距離を計算する * ただし、単位はメートルではなく座標なので注意 * * @param c1 座標1 * @param c2 座標2 * @returns 距離 */ private static distance ( c1 : LngLatArray , c2 : LngLatArray ) { return Math . sqrt ((c1[ 0 ] - c2[ 0 ]) ** 2 + (c1[ 1 ] - c2[ 1 ]) ** 2 ) } } const nearestStation = new NearestStation(日本全国9000駅) 毎秒実行(地図上に最寄り駅を描画(nearestStation. get (現在地))) 事前計算 最寄り駅計算の前に事前計算して、適当な時間間隔で再計算する public get(userLocation: LngLatArray): MapStation { if ( this .center === null || ! this .isInEffectiveRange(userLocation)) { this .updateCenter(userLocation) return this .sortedStationDistances[ 0 ].station } 最寄り駅の初回計算時、あるいは基準点 center と現在地が約 5km 離れたら基準点を現在地に更新してメソッド updateCenter で再計算します。 get は毎秒呼ばれるため、ほぼリアルタイムに基準点と現在地が 5km 離れているかどうかを監視していると言えます。 5km に大きな理由はないのですが、後述します。 計算時の現在地を基準点 center とする center からみた各駅との距離を配列に保存する private updateCenter(userLocation: LngLatArray) { this .center = userLocation this .sortedStationDistances = this .sortedStationDistances . map (( { station } ) => { const distance = NearestStation.distance( this .center, station.coordinate ) return { station , distance } } ) . sort (( a , b ) => a.distance - b.distance) } 保存先の配列は sortedStationDistances で、center への距離が短い駅順にソートしておきます。 つまり、updateCenter を呼んだ時点での現在地は center であるため、最寄り駅は sortedStationDistances[0] に入っていることになります。 最寄り駅計算 基準点 center を中心として、基準点-現在地間の距離の 3 倍の半径を持つ円 // 探索円の半径 const searchCircleRadius = (() => { const distanceCenterToUser = NearestStation.distance( this .center, userLocation ) const distanceCenterToFirstStation = this .sortedStationDistances[ 0 ].distance // 基準点を中心として基準点とユーザーの距離の 3 倍の半径をもつ円の中に必ず最寄り駅がある // ただし、基準点とユーザーの距離が近過ぎた場合は、青円を内包する探索円に最低1駅は入ることを保証する return Math . max (distanceCenterToUser, distanceCenterToFirstStation) * 3 } )() 基準点-現在地間の距離の 3 倍を探索円の半径 searchCircleRadius として求めています。 もちろん、探索円の中に駅がある場合は最寄り駅の存在が保証されているのですが、探索円の中に駅がない可能性もあります。 その場合は、青円を内包する探索円内に少なくとも 1 駅はある程度まで半径を広げています。 円内に含まれる駅の中から全探索をする let distanceUserToNearestStation = Infinity let nearestStation: MapStation = null as unknown as MapStation for ( const { station , distance : distanceCenterToStation } of this .sortedStationDistances) { // 探索円内に必ず最寄り駅がある if (searchCircleRadius <= distanceCenterToStation) { break } const distanceUserToStation = NearestStation.distance( station.coordinate, userLocation ) if (distanceUserToStation < distanceUserToNearestStation) { distanceUserToNearestStation = distanceUserToStation nearestStation = station } } return nearestStation 配列 sortedStationDistances の中から基準点に近い順に最寄り駅を走査して、探索円外に出たらその時点で探索を打ち切ります。 その時点での最寄り駅 nearestStation が真の最寄り駅と同一です。 5km についての解説 現在地が基準点から 5km 離れたら、基準点を更新するために再計算すると述べました。ここでの再計算は全国約 9000 駅に対する全探索です。 現在地が基準点に近いほど、探索円 (赤円) の半径も小さくなり、計算効率が上がります。したがって、現在地と基準点が「離れ過ぎたら」、適度に基準点を現在地に更新するほうが効率が良くなります。その「離れ過ぎたら」という基準が明確に定まっておらず、感覚で 5km と設定しました。ここでその 5km のことを「再計算距離」とラベリングしておきます。 さて、少なくとも次のことが言えます。 ユーザーの移動速度が速い場合、すぐに基準点と「離れ過ぎて」しまい全探索を何度もすることになるため、再計算距離は長いほうがよい ユーザーの移動速度が遅い場合、長い間探索円内に滞在してくれるので、現在地と基準点は近いほうがよく、早めに基準点の更新をできるよう再計算距離は短い方がよい ユーザーが地方を移動している場合、現在地と基準点が離れていても探索円内に駅が少ないため計算コストが小さく、再計算距離は長くてよい ユーザーが都内を移動している場合、逆に計算コストが高くなるため、再計算距離は短い方がよい これらすべてにちょうど良い唯一の再計算距離は自明でないので、感覚で 5km としてしまいました。 エコ方式の計算量 全駅数を N ≒ 全国約 9000 駅、探索円内の駅数を n < N とすると、ユーザが基準点から 5km 離れるたびに事前計算を O(N log(N)) で行い、ユーザーが基準点から 5km 以内の円内にいる間は毎秒 O(n) で最寄り駅探索ができます。事前計算が N log(N) になりますが、毎秒全探索の毎秒 O(N) よりはずっと計算量が落ちています。 具体例をあげます。現在地が基準点から 1km 離れている場合、n は基準点を中心として半径 3km の円内にある駅の数です。地方であれば数駅でしょうし、都内だと山手線の内側の面積の約 45% の範囲と同じなので 50 駅程度でしょうか...。現在地が基準点から 5km 離れている場合、地方だと十数駅で、都内だとおおよそ 23 区の面積と同じなので 500 駅くらいです。 まとめ 過去の計算情報を使った最近傍探索アルゴリズムを実装した 時間計算量を毎秒 O(n) < O(N) に抑える事ができる ただし、事前計算は O(N log(N)) 個人的には計算中にマジックナンバーが出てくる面白いアルゴリズムを思いついたなと思っています。 リアルタイムに最近傍探索をしたいとき、こちらのアルゴリズムを検討してみてはいかがでしょうか! 採用募集案内 モバファクでは中途採用・新卒採用ともに絶賛募集中です。 会社の情報については、モバファクブログでも発信しています。 技術好きな方、モバファクにご興味をお持ちの方は、ぜひご応募ください! モバファクブログ: https://corpcomn.mobilefactory.jp/ 採用サイト: https://recruit.mobilefactory.jp/
アバター
こんにちは、エンジニアの id:mp0liiu です。 かなり遅くなってしまいましたが、今年も6/10にPerlの最新安定バージョンである5.40がリリースされたので新機能や変更点についてまとめます。 安定化した実験的機能 try-catch 構文 5.34 で追加された try-catch 構文が安定化して use feature 'try' もしくは use v5.40 で有効にできるようになりました。 use v5.40 ; try { die 'Some error occurred.' ; say 'Success' ; } catch ( $e ) { say 'Failure' ; } finally ブロックに関してはまだ実験的機能なので注意してください。 finally ブロックを使用した際に発生する警告を抑制するには no warnings qw( experimental::try ); が必要です。 forループの繰り返しごとに複数の要素を参照する構文 5.36で追加された、for文の括弧内にレキシカル変数を列挙することで複数の要素に対して反復処理を行う構文が実験的でなくなりました。 my %hash = ( a => 1 , b => 2 , ); for my ( $key , $value ) ( %hash ) { say " $key => $value " ; } a => 1 b => 2 builtin モジュール 5.36で追加された、新しい組み込み関数を提供しかつ組み込み関数を扱う新しい仕組みである builtin コアモジュールが安定化しました。 builtin で提供される関数の中にはまだ実験的な関数もあります。詳しくは builtin モジュールのドキュメントを参照していただきたいですが、5.40 では 5.36 で追加されたほとんどの関数が安定化しています。 5.40 で安定化した関数をまとめて使いたい場合は use builtin ':5.40' するかもしくは use v5.40 します。 以前の記事 でも解説していますが、安定化した関数の中でもリストの各要素の順番と各要素のペアのリストを返す関数 indexed、真値と偽値を返す true と false、blessed などリファレンス判定系の関数、ceil、floor あたりの関数は使う頻度が高いと思うので積極的に利用していきたいです。 実験的な関数を使う場合は以前同様警告が発生するため、抑制したい場合は no warnings 'experimental::builtin'; する必要があります。 ちなみにこれによりforループの繰り返しごとに複数の要素を参照する構文と indexed 関数を組み合わせて配列のindexと要素の列挙が楽に書けるようになっています。 use v5.40 ; my @array = qw( red blue green ) ; for my ( $index , $value ) (indexed @array ) { say " $index => $value " ; } 0 => red 1 => blue 2 => green その他の変更 class 構文のフィールド変数に reader 属性を指定可能に 5.38 に実験的機能として追加された class 構文のフィールド変数に reader 属性を指定可能になりました。 これは getter メソッドを自動で生成する機能です。(Moose でいうところの is => 'ro' なアクセサ) field $name :reader; は field $name ; method name () { return $name ; } と同等です。 use v5.40 ; use experimental 'class' ; class People { field $name :param :reader; } say People ->new( name => 'John' )->name; # John みたいな使い方になると思います。 __CLASS__ キーワードが追加 こちらも class 構文の話で、method文, ADJUSTブロック、フィールド変数の初期化式の中で呼び出し元のクラス名を返すキーワードです。 やっていることは ref($class) と同じで、スーパークラスから呼び出された場合はスーパークラス名を返し、サブクラスから呼び出された場合はサブクラス名を返すようになっています。 use v5.40 ; use experimental 'class' ; class Parent { field $num :reader = __CLASS__->DEFAULT_NUM; sub DEFAULT_NUM { 10 } } class Child :isa(Parent) { sub DEFAULT_NUM { 20 } } say Parent ->new->num; # 10 say Child ->new->num; # 20 優先度の高い排他的論理和演算子、 ^^ が追加 Perlの論理演算子は評価優先度の高い && , || と優先度の低い and , or とがありますが、今まで排他的論理和演算子は優先度の低い xor 演算子しかありませんでした。 5.40からは優先度の高い排他的論理和演算子、 ^^ が追加され利用できるようになります。 builtin に関数 inf と nan, load_module が追加 builtin モジュールに新しい実験的関数が追加されました。 inf は無限を、 nan は非数を返す関数です。 inf は今までオーバーフローする浮動小数点演算を行って得ていた値を、 nan は inf 同士の計算で得ていた値を返してくれるので状況によっては便利になりそうです。 load_module は引数にモジュール名を渡すことで実行時にモジュールロードを行う関数です。コアモジュールである Module::Load の load 関数を builtin に持ってきた形になるかと思います。 5.40までに追加された 関数 は use v5.40; か use builtin ':5.40' で使用することができますが、5.40 で新たに追加された builtin に関数の使用は use builtin '関数名' が必要です。 また使用した際に発生する警告を抑制するには no warnings 'experimental::builtin' が必要です。 Test2::Suite がコアに追加 Test2を用いて作られたテストツール群 Test2::Suite がコアに同梱されるようになりました。 要は Test2::V0 でテストが書けるようになります。Test2::V0 は追加のテストモジュールをインストールしなくても現代的なテストが書けるテストモジュールで、複雑なデータ構造のテストを簡単にかけたり、RSpec風のテストがかけたり、テスト結果が見やすくなったりと良いことがたくさんあるので積極的に利用していきたいです。 goto による外部スコープから内部スコープへのジャンプが廃止予定に goto で外部スコープから内部スコープにジャンプすることが Perl5.42 で廃止されることになりました。 例えば以下のようなコードは動かなくなります。 { LABEL: say "smoething" ; } goto LABEL; goto を使ったコードを書いてる人はほとんどいないと思うのであまり影響はないかと思いますが、古かったり複雑なループ処理をしているコードだと使われている箇所もあるかもしれないので一度確認した方が良さそうです。 5.40 では上記のようなコードがあると警告が発生するようになっています。 まとめ 今回は5.38のときほどの大きな変更はありませんでしたが、重要な実験的機能が安定化したり細かな改善をしたりと、確実に進化し続けています。 次回以降の安定バージョンでも名前付き引数や文字列中で式展開ができる構文などが入る気配があり楽しみです。 この記事では書けなかったこともあるので詳しいことが気になった方は 公式ドキュメント もぜひ読んで見てください。
アバター
はじめに モバイルファクトリーは、21 年度から完全リモートワークに移行しています。 リモートワークではコミュニケーション不足に陥りがちです。まだ会社に慣れていない、社員の顔と名前が一致していないような状態にある新卒のエンジニア達はなおさら、コミュニケーションに困難を感じるのではないかと想像されます。 リモートワーク下でも、新卒エンジニア同士 / 新卒エンジニアと先輩社員 がコミュニケーションしやすい状況を作りたい! というわけで、今年の新卒技術研修を担当しました( id:kaidan388 )が、コミュニケーションしやすい状況作りのために新人技術研修で行った工夫について説明します。 端的にいえば、コミュニケーションするきっかけを増やすことに注力して、内容を組みました。 具体的には、新人技術研修に以下の工夫を盛り込んでいます。 朝会と夕会で雑談タイムを作り、互いのことを話す 幅広い社員を募った「座談会」を定期的に開催し、多くの社員とコミュニケーションする場を用意する 研修の参加者には同じ Google Meet の部屋に入ってもらい、参加者どうし相談しやすい環境を作る 先輩社員が常に 1 人以上いる Google Meet の部屋を用意し、いつでも相談できる場所を作る はじめに 新人技術研修の目的 新人技術研修の具体的な内容 なぜコミュニケーションのきっかけを増やすか コミュニケーションのきっかけを増やす具体的な工夫 朝会/夕会の雑談タイム 幅広く社員を募った「座談会」 研修の参加者同士の相談部屋 先輩社員がいる相談部屋 まとめ 新人技術研修の目的 研修の目的は、新卒エンジニアが開発業務に加わりやすい状態を作ることです。 参加者に対しては、意識してほしい目標として以下の 3 つを共有しました。 業務に必要な最低限の技術スタックを身に着ける 研修の場だけで全ての技術を使いこなす状態に持っていくことは考えていません ここでいう最低限とは「何ができるかをぼんやり知識として知っている/参照すれば使える状態」になることです 疑問や課題を言語化・質問して解決する能力を身に着ける 前述の通り、技術研修で業務に必要な知識を全てインストールするのは不可能です そのため、未知に遭遇した場合に、周りの環境を利用しつつそれを解決する能力が非常に重要になります いわゆる"聞く力"です 仕事を円滑に進められるための関係性を構築する 上記に関連しますが、聞く力を鍛えるだけでなく新人が気兼ねなく質問・相談できる環境を作ることも研修の狙いとしています また、この中でも、3 つめの「仕事を円滑に進められるための関係性を構築する」が重要だよ、という点を繰り返し強調して伝えました。 これは、冒頭でも述べたコミュニケーションするきっかけを増やしたいという意図を、参加者にも意識して欲しかったからです。 新人技術研修の具体的な内容 以下のようなスケジュールで進行しました。 日程 概要 詳細 4/11 研修キックオフ - 研修キックオフ - 技術スタック紹介 - 開発環境整備 - Git/GitHub 研修 4/12 ~ 4/25 サーバサイド研修 - MySQL - Perl 研修 - AWS 研修 4/26 ~ 5/9 フロントエンド研修 - JavaScript 研修 - Vue 研修 - TypeScript 研修 5/10 ~ 5/15 Web サービス開発 ユーザー認証付きの掲示板を作る 5/16 発表 社員に、開発した掲示板と研修の内容をスライド発表する モバファク では基本的にどのエンジニアもフロントとサーバの両方を扱うことが多いので、研修でも両方を満遍なく扱うようになっています。 なぜコミュニケーションのきっかけを増やすか 研修の目的である新卒エンジニアが開発業務に加わりやすい状態を作るために一番重要なことは、コミュニケーションの経験を通して先輩社員との関係性を作っておくことだと考えたからです。 例えば、同期や先輩社員との関係性があまりないまま開発業務に加わってしまい、質問や相談がうまくできずに開発効率が落ちてしまう、というのは割とよくある話なのではないかと思っています。 逆に、技術研修中でコードの書き方を学んだとしても、その場では必要性を実感しづらいですし、実際必要になった場面では内容を忘れてしまっていることもよくある話です。 実際、自分の入社直後の Slack 上でのやり取りを見返してみると、技術的に難しい実装にぶつかった時相談するのが遅くて、開発に時間かかってしまっている状況はしばしば起きていました。 特にリモートだと、実装で困っている時は自分が声をあげなければ、周りのメンバーが気づくこともできず手助けが遅れてしまいがちです。そういう意味でも、話がしやすい関係性を作ることが重要なのではないかと思います。 個人的には、新卒技術研修という名目ですが技術それ自体はコミュニケーションのきっかけになる共通の話題でしかなく、関係性を作っておくことの方が重要だ、くらいの気持ちで研修の準備を行っていました。 コミュニケーションのきっかけを増やす具体的な工夫 ここまでで、コミュニケーションの経験を通して先輩社員との関係性を作っておくことの重要性を述べました。 ここからは、どのようにしてコミュニケーションするきっかけを増やしたか、具体的な工夫について 4 点紹介します。 朝会/夕会の雑談タイム 毎日、出勤直後と退勤直前に、15 分ずつ朝会/夕会を開きました。 主な目的は、研修の参加者の進捗を聞き、必要であればサポートなど行うことです。 また、時間が余ったら雑談をしてお互いのことを知るための時間に当てました。 雑談では、よく見ているコンテンツの話や、今日食べた朝ごはんの話などをよく話しましたね。 リモートワークの影響で参加者が全国各地に住んでいるので、たとえば全国区だと思っていたパンが実はローカルでしか売られていなかったりと、住んでいる地域の違いに関する話題がよく盛り上がりました。 概ね良かったのですが、研修終盤になってくると、話題がだんだんと尽きてくるのがちょっと難点でしたね...... 幅広く社員を募った「座談会」 事前にエンジニアの社員を広く募っておいて、毎回違うメンバーをゲストとして招いて技術に関する話をする座談会を開きました。 座談会は 1 回 40 分ほどです。 まずゲスト社員からの簡単な自己紹介を行い、次に研修の参加者からゲスト社員に研修の内容に関する質問を行った後、最後に事前に考えてもらった「先輩社員から新卒に伝えたいこと」を話していただきました。 ゲスト社員は、できるだけ様々なチームから満遍なく呼ぶようにしています。 この会もコミュニケーションのきっかけを増やすことが大きな目的になっていて、自己紹介や質問を通して、ゲスト社員と研修の参加者が互いの興味関心などを知るきっかけになるように設けたものです。 研修の内容に関する質問では、JavaScript のこの機能が便利そうですが、これはプロダクトのコードでも使われていますか?など、研修の内容と実際の業務を結びつける情報を聞くようなものが多かったです。 研修後に効果について新卒メンバーに聞いてみたところ、モバファク で働いている社員が全体的にどんな雰囲気の人が多いか把握できて良かった、というようなコメントをいただきました。 ゲスト社員を様々なチームから満遍なく呼んでいた効果があったようです。 また、「先輩社員から新卒に伝えたいこと」が新卒メンバーからの評判がよく、働く際の心構えやリモートワークで便利なアイテムなどの話について、聞けて良かったという感想がありました。 研修の参加者同士の相談部屋 事前に Google Meet の部屋を作っておいて、研修の参加者には研修中その meet に入って作業してもらいました。 これは、困り事があったときに同期同士で相談しやすい状況を作ろうという意図です。業務が始まった後で一番話をしやすい他人は同期だと思うので、ここの関係値を積んでもらうことを目指していました。 ただ、研修の序盤はあまりうまく行かず、会話があまり発生していない状態になっていました。 研修の参加者は 5 名だったのですが、5 名を 1 部屋に入れるにはちょっと人数が多かったのかもしれません。 研修の中盤で、新卒同士コミュニケーションして欲しい旨を伝えた上で 2 人部屋と 3 人部屋に分けるとうまくコミュニケーションが起き始めたようで、朝会や夕会の雑談でその日話した内容を聞くようになりました。 研修後に効果について新卒メンバーに聞いてみたところ、研修中に雑談することで気が抜けるタイミングを作れた、もし相談部屋がなかったら辛かっただろう、といったコメントをいただきました。 また、新卒同士コミュニケーションして欲しい旨を伝えたことと、2 人部屋と 3 人部屋に分けたことの両方が、コミュニケーションに作用していたようでした。 2 人部屋と 3 人部屋に分かれてもらった後、今日の作業について軽く話したりと、コミュニケーションのきっかけを作るための雑談を互いに行っていたとのことです。 新卒メンバーはまだ互いに相手のことを深く知らない状態なので、相談部屋に入ってもらった後どう会話してもらうかをある程度イメージして、人数の調整や朝会/夕会のファシリテーションを行うと、初めからスムーズに進んでいたかもしれません。 先輩社員がいる相談部屋 研修の参加者が参加している GoogleMeet とは別に、先輩社員がいつでも 1 名以上いる相談用の Google Meet を用意しました。 これは何か困り事があったらそこの部屋に入るだけでいいという状態を作る意図です。研修担当者にはいつでも質問していいよと伝えてるとはいえ、実際 Slack 上でメンションをいきなり飛ばすことにはハードルがあるかと思うので、そこを解消しようと考えました。 大体 1 日 1 回以上利用があったので、質問しやすい状況を作るという狙いは達成されたかと思います。 研修後に効果について新卒メンバーに聞いてみたところ、新卒同士の部屋でもある程度疑問は解消できていたものの、たとえば実装方針の相談など自分たちだけでは決めきれない相談がある時、相談しやすくて助かったとコメントをいただきました。 質問しやすい状況を狙い通りに作れていたのではないかと思います。 また、先輩社員と話すきっかけが増えたかと聞いてみたところ、増えはしたが、疑問を聞いて解消するための会話に終始するので、関係性を作るきっかけにはなりづらそうとコメントをいただきました。 先輩社員がいる相談部屋の取り組みは、関係性の構築というよりは、心理的に安心して作業できるようになるといったメリットの方が大きいようです。 まとめ 24 年度の新人技術研修では、新卒エンジニアが開発業務に加わりやすい状態を作ることを目的に、メンバー同士が関係性を構築することを重視して内容を作りました。 この記事では、コミュニケーションするきっかけを増やすための工夫を 4 点、紹介させていただきました。その結果、 座談会の開催によって、新卒メンバーが モバファク で働いている社員にどんな雰囲気の人が多いかを把握する機会を作れた 研修の参加者同士の相談部屋によって、新卒メンバー同士が雑談するきっかけを作れた といった効果を得られました。 また、先輩社員がいる相談部屋によって、心理的に安心して研修が受けられる環境を作ることができました。 今後の課題としては、研修参加人数に応じて、うまく会話が起きるよう部屋の設定を、研修開始前に考えておくようにしたいです。 これは研修参加者にインタビューをして感じたことなのですが、入社直後の状態だと、仕事中にどのくらい人と雑談しても許されるのかという感覚がよくわからず、雑談しづらいという面があったようです。 人数の調整もそうですが、コミュニケーションしてほしい旨を伝えたり、ファシリテーターどうしが雑談してる姿を見せたりと、どういうコミュニケーションをとってほしいかがわかるような説明を追加したいですね。 研修終了から半年以上経った後の記事公開になってしまいましたが、この内容が 25 年度以降の新人研修を考えている誰かの役になったなら幸いです。
アバター
駅メモ!開発基盤チームです。 今回はサービスで利用している Amazon Aurora MySQL を v2 から v3 へ移行したときのことを書きます。 概要 駅メモ!をはじめとする弊社のサービスでは、データストアとして Amazon Aurora MySQL(以降 Aurora MySQL) を利用しています。すでにアナウンスされている通り、 Aurora MySQL v2 は 2024 年 10 月 31 日に 標準サポート終了を迎える ため、Aurora MySQL v3 への移行が重要な課題になっていました。これに対し、駅メモ!開発基盤チームでは綿密な計画を立て、今年の初め頃に無事に移行を完了させることができました。このエントリはその時にどんな手法を取ったかを書きます。誰かの参考になればと思います。 やったこと 最初にざっくりとした流れを示します。 調査 新機能・廃止された機能の調査 非推奨事項の調査 移行手段の調査 設計 パラメータグループの設計 移行手順の設計 動作確認・負荷試験の設計 開発環境で利用している MySQL の移行手順の設計 事前準備 廃止された機能を v2 の段階で無効化 動作確認・負荷試験の実装と実施 v3 に向けてアプリケーション・スキーマの最適化 移行手順の実装 MySQL 5.7/8.0 の両バージョンで CI を実行できるように 移行実施 深夜メンテナンスにて移行実施 1. 調査 まずは移行対象の調査を行いました。MySQL や AWS のドキュメントを読み、サービスに影響がありそうな変更を重点的に深堀りしていきました。 調査したものの内、移行前にやることと移行後にやることを分類しました。以下にいくつかの例を示します。 移行前にやること 整数型の表示幅変更 クエリキャッシュの無効化と代替の検討 移行後にやること utf8mb3 から utf8mb4 への変更 分類はそのタスクが必須であるかや、重さ・難易度で決めました。例えば、クエリキャッシュの無効化は廃止となってしまうため、必ず移行前に対応する必要があります。一方、MySQL 8.0.12 で追加された ALGORITHM=INSTANT で実行可能な ALTER は、INPLACE や COPY よりも高速に実行できるので、作業の簡単化のために移行後の実施としました。 などです。 2. 設計 調査内容を元にパラメータ、移行手順などの設計を行いました。ここは並行で設計できる部分があったので、チームで手分けして行うことにしました。 それぞれの設計では様々な工夫をしましたが、ここでは移行手順の設計について書きます。 移行手順の設計 Aurora MySQL v2 から Aurora MySQL v3 への移行手順を設計するうえで特に気を使ったのは切り戻しに関する部分です。移行メンテナンス中やサービスイン後など、どのタイミングで問題が発覚しても最悪の手段として Aurora MySQL v2 へ切り戻すことができるように考える必要がありました。最初は Aurora Blue/Green Deployments を利用することでうまく切り戻しを実現できないかを考えましたが、後述の理由からこれだけでは要件を満たせないことがわかりました。しかしながら、それ以外の Blue/Green Deployments の利点は採用したいものが多い状況でした。 チームで考えた最終的な構成を以下に示します。 最終的な構成 1. Blue/Green Deployments と 復旧用クラスターを構成 まずは普通に Aurora Blue/Green Deployments を構成します。これは AWS のドキュメントに従って作業を行います。 次に復旧用クラスターを Green 環境のレプリカとして作成します。 構築 この時点で Green 環境と復旧用クラスターに Blue 環境からの変更が同期されていることを確認します。 2. Green 環境を Aurora MySQL v3 にアップグレード 一通りの確認ができた後は Green 環境をアップグレードします。 アップグレード中にはあらかじめレプリケーションを停止しておきます。理由はいくつかありますが、Green 環境が期待通りになっていることを確認してから再開したいというのが主なものです。 Aurora MySQL v3へのアップグレード Green 環境のアップグレードが完了した後はレプリケーションを再開と動作確認を行います。今回は経過観察としてこの構成のまま数日稼働させました。ここまでの作業はサービスを止めること無く実施できます。 3. 切り替え サービスをメンテナンスモードにし、切り替えを実施します。切り替え後の旧 Blue クラスターは不要になるので削除します。昇格した Green のクラスターエンドポイントは変更になるので、復旧用クラスターへのレプリケーション設定を適切に変更する必要があります。 切り替え 動作確認が終わればメンテナンスを解除、サービスを再開します。サービス再開後、特に問題がなければ復旧用クラスターは不要になるので削除します。 切り替え完了 4. 切り戻し メンテナンス中、Blue/Green Deployments 切り替え前に問題がわかった場合は、Blue/Green Deployments を解除することで切り戻しを行います。 そうでなく、メンテナンス後や Blue/Green Deployments 切り替え後に切り戻しが必要になった場合は、復旧用クラスターをプライマリに昇格させることで切り戻しを実現します。 切り戻し 3. 事前準備 設計が終わったのであとはそこに向かって作業をするだけです。ここもチームで分担して行いました。 負荷試験では Locust を使った負荷試験環境を構築しました。これについてはいずれ別記事で紹介できればと思います。 4. 移行実施 移行計画通り深夜メンテナンスにて切り替えを実施しました。 入念な準備の甲斐もあってか、復旧用クラスターが必要になるような問題・パフォーマンス劣化は見つかりませんでした。 今またやるなら 現在また同じようなメンテナンスを行う場合は、下記 AWS ブログで紹介されているように Blue/Green Deployments 切り替え後の旧 Blue クラスターを再利用するのが簡単だと考えています。 aws.amazon.com 私達の設計初期段階でもこの旧 Blue を使えないか?と模索していました。調査の結果、切り替え後のログファイル名とポジションを特定できないと判断しました。先に Blue/Green Deployments だけでは要件を満たせないと書いたのはこのためです。実際には、AWS のブログにもある通り、ログファイル名とポジションを含むイベントが発行されるのでそれを利用すれば良いことになります。 このエントリで紹介した方法であれば事前にほぼすべての準備をすることができるので、メンテナンス中手順が減らせるという利点が一応あります。しかし 1 クラスター分の費用が追加でかかってしまうので一長一短です。 まとめ この記事では駅メモ!で利用している Amazon Aurora MySQL v2 クラスターを v3 にアップグレードした手法について述べました。また、今またやるならどうするかについても述べました。 DB のマイグレーション、アップグレードはかなり慎重になるタスクですよね。大きな問題が起こらなくてとても安心したのを覚えています。 今後は Aurora MySQL v3 移行後に実施しようとしていたものをひとつずつ進めていく計画です。また書けそうなことがあったら書こうと思います。 以上です。 参考文献 GitHub.com を MySQL 8.0 にアップグレード Implement a rollback strategy after an Amazon Aurora MySQL blue/green deployment switchover モバファクでは中途採用・新卒採用ともに絶賛募集中です。 会社の情報については、モバファクブログでも発信しています。 技術好きな方、モバファクにご興味をお持ちの方は、ぜひご応募ください! ・モバファクブログ: https://corpcomn.mobilefactory.jp/ ・採用サイト: https://recruit.mobilefactory.jp/
アバター
駅奪取チームエンジニアの id:kimkim0106 です。 「レポートを書くまでが YAPC」とのことなので、自分も書こうと思います。 YAPC::Hakodate の概要 2024/10/5(土)に、北海道函館市の公立はこだて未来大学にて開催されました。 YAPC は Yet Another Perl Conference の略で、Perl を軸とした IT に関わる全ての人のためのカンファレンスです 前夜祭 会場入口の看板 前夜祭会場のスクリーン 前夜祭のトークテーマも気になるものが多かったので、前夜祭から行きたいと思っていました。 特に印象に残ったトークをいくつか紹介します。 アンカンファレンス いくつかトピックがあった中で、生成 AI に関するトピックがありました。 GitHub Copilot などの生成 AI を使っている人が多く、これからは生成 AI の時代なんだと改めて感じることができました。 また、ある方は「最大公約数的なコードが生成されることで、書き手の個性がなくなる」ということをおっしゃっていた。 Perl の哲学である「やり方は一つではない (TMTOWTDI: There's More Than One Way To Do It.)」という言葉みたいだなと思い、Perl のカンファレンスに来たことを実感したのでした。 小さな勉強会の始め方、広げ方、あるいは友達の作り方 by あらたま speakerdeck.com 前夜祭のトークの中で一番印象的でした。 特に、「ほんのちょっとの勇気を持って、全てに能動的に関わっていくこと」という言葉が心に残りました。 前夜祭後 技術広報っぽい仕事をしてる人たちでいか太郎来た!明日の本編も頑張りましょ〜! #yapcjapan pic.twitter.com/AC4qtXFWeT — もりけん (@molmolken) 2024年10月4日 さっき聞いた話を実践できる機会がないかと考えていたところ、会場の外で飲み会に行こうと集まっている人たちを見かけました。 たまたま、技術広報に関わっている人が集まっており、「一緒に行ってもいいですか!」と勇気を出して声をかけて参加させてもらいました。 他所の技術広報がどんなことをしているのかを聞けてとても勉強になったので、声をかけて良かったなと思いました。 本編 会場のはこだて未来大学は、明るく広々としていて、とてもいいところでした。 特に、講義室には机に一人 1 つコンセントが設置されていたのが羨ましかったです。 今回、モバイルファクトリーは U25 支援のスポンサーだったので、「U25 支援企画」のセッション内でスポンサー LT をしてきました。 私が入社以来どういう経験をしてきたか、という内容で発表しました。 リアルで発表するのは学生以来数年ぶりだったので緊張しましたが、うまく発表できて良かったです。 こちらも印象に残ったトークをいくつかピックアップします。 U25 支援企画 「U25 支援企画」では、スポンサー LT と U25 支援で参加された方の LT がありました。 学生時代のエピソードや開発経験などを発表されていて、修論発表直前にシステムが動かなくなるというゾッとする話もありました。 すごく興味深い話を聞けました。 ありがとうございました! プロファイラ開発者と見る「推測するな、計測せよ」 by Daisuke Aritomo (osyoyu) fortee.jp タイトルの通り、プロファイラで計測するときの話です。 計測したら予想外なところに問題がみつかるという話は、負荷対策などでも実際経験したのでとても共感できる話でした。 また、得られた情報が正しいかどうかということは今まで考えたことがなかったので、いい学びでした。 たしかに、調査のためログに時刻を出力しても、システムの時刻が更新されていたら狂うわけですし、正しいかどうかを確認しないといけません。 テストコードの品質を客観的な数値で担保しよう 〜Mutation Testing のすすめ〜 by Kanon fortee.jp テストのカバレッジが高いからといってテストの品質が高いとは限らないという話は、以前どこかで目にしたことがあったのですが、ではどうすれば品質を高められるのかというのは分からずにいました。 Mutation Testing は初めて知ったのですが、テストの品質を高める 1 つの方法として良さそうと思いました。 品質マネジメントで抑えておきたい 2 つのリスクを見分けて未来に備えよう by 巻 宙弥 fortee.jp 2 つのリスクとは「プロジェクトリスク」と「プロダクトリスク」で、それぞれ対処しないと行けないという話でした。 要件の増加でスケジュールが遅れるみたいな話は思い当たる節があったので、どこでもそういうことはあるんだなと思いました。 誰になんと言われても「いい開発環境」を作りたくて頑張っている話 by Tatsuro Hisamori 開発者体験を「なんとかする」か「転職する」みたいな 2 択があるなか、「なんとかしたほうが社会が良くなる」という言葉が心に残りました。 また、テストの高速化や Nix を導入するなど、開発者体験が良くなっていくのは楽しいだろうなと思いました。 引用されていたこちらのツイートも心に残りました。 余所の開発者体験がよさそう、なんじゃなくてお前が所属しているチームの開発者体験をよくするんだよ!!!!!!!!!!!!!!!!!!!!!!!q — あそなす (@asonas) 2024年7月17日 ブース ブースもいくつか回りました、印象深かったブースをいくつか紹介します。 LayerX さんのブースでは、実際の ADR(Architectural Decision Records)が見られるということで、ADR をどのように書いているかや意思決定について学ぶことができました。 DELTA さんのブースでは、AWS コスト削減クイズというのをやっていました。結構難しいクイズだったのですが、なんと会場 1 位のスコアを取ることができました! 景品として、分割キーボードをいただきました! コスト削減クイズのランキング 感想 今回が初めての YAPC 参加だったのですが、Perl コミュニティの温かさを感じることができ、すごく楽しかったです。 また、Perl の歴史が長いので、幅広い年代の参加者と交流できるのも良かったです。 普段はリモートワークなので、リアルでのコミュニケーションに新鮮味を感じつつ、また人と直接話せるというのがオフラインイベントの大きなメリットだと実感しました。 楽しいカンファレンスを作っていただいたスタッフの方々や登壇者の方々、本当にありがとうございました。 そして、次の YAPC も絶対に行くぞ!という気持ちになりました。 余談 函館といえばラッキーピエロということで、滞在中 2 回行ってきました! ハンバーガーがメインのお店ですが、他にも色々とメニューがあり、焼きそばもおいしかったので、オススメです。 ラッキーピエロの焼きそば モバファクでは中途採用・新卒採用ともに絶賛募集中です。 会社の情報については、モバファクブログでも発信しています。 技術好きな方、モバファクにご興味をお持ちの方は、ぜひご応募ください! ・モバファクブログ: https://corpcomn.mobilefactory.jp/ ・採用サイト: https://recruit.mobilefactory.jp/
アバター
はじめに vim に最近目覚めた。そこから NeoVim、LunarVim を使うようになった流れについて、自分が思う好きなポイントと絡めてまとめる。 書かないこと エディタ戦争 VSCode も、vim も、emacs も、みんな違ってみんないい あくまでも vim のココスキをまとめるので比較はしない どうして vim か VSCode を今まで使っていて、remote の接続が悪かったり重かったりしていたのでこれを機に、気になっていた vim に乗り換えてみた vim を選んだ理由は、 慣れるとコーディングスピードがすごいらしい 脳とコーディングを直結したい 軽そう 使ってる人が多い つまりググったときの情報が多い という辺り。 どうして NeoVim か vim について色々調べていると、どうやら新しい NeoVim というのがあるらしい *1 事に気づいたのでそっちを使うことにした。 どうせ 0 から始めるなら、後から乗り換えしなくても済むようにしたいので NeoVim から始めた vim すごい オペレータとモーション 実は学生の頃にも先輩に勧められて vim を試していて、調べてみるとコマンドが多くて複雑そうで慣れるのに時間かかりそう…と思っていた。 「なんで丸かっこ内を削除するコマンドは di( なんだ…?」「 d は delete っぽいけど i って何…?」などの疑問が出てやめてしまった。 が、今回調べ直して知ったがどうやらコマンドはオペレータ、モーション、テキストオブジェクトの組み合わせになっているらしく、それを知るとむしろすぐ慣れることができた。 さっきの例だと d が削除のオペレーター、 i( が inner () というテキストオブジェクトで、その組み合わせによって「()の内側を削除する」という結果になる。 さらに、これを応用すれば覚えてないコマンドでも自由自在にコマンドを作れる。「インデントの中をすべてコメントアウトする」を何も調べずに一発で出来たときは感動した。 拡張性 vim のコマンドを使うだけなら VS Code に vim プラグイン入れることでも出来るが、vim の場合は拡張性の高さもさらに使い勝手を良くしていると思った。 さらに、「デフォルトでは真っ更だから後はやりたいようにやりな」というのを vim から(勝手に)感じてる。なので、よく使う操作をコマンドの組み合わせを 0 から設定することができる。 プラグイン 超便利なプラグインが豊富にあるのも良い。ちょっとしたモーションを追加するものから、外観を一気に変えるものまで、なんでもござれ。 しかし うまく行かないな〜と思っていたものがいくつかある プラグイン多すぎ問題 vim の利点で書いた事と早速矛盾するが、世の中にはプラグインが多すぎる。 例えば vim からターミナルを使えるようにするプラグインをググるとめちゃくちゃ出てくる。 その中から今も動くものとか流行っているものとかを選定するのが大変。 NeoVim awesome もあるがそれ自体も複数あってなにがなにやら。 この辺は VSCode だと拡張機能マーケットプレイスがあって、人気なプラグインがすぐわかるしインストールも簡単だからスゲ〜と思う。 LSP NeoVim で LSP(構文解析したり lint したりするやつ。Language Server Protocol)の動かし方がまだよくわからない、というか動かない。いくつかプラグインを入れて、設定して、バージョンごとに相性があって、試行錯誤して… この辺も VSCode だとよしなにやってくれるからスゲ〜と思う LunarVim の出会い 悩んでたら最近 LunarVim というのを 記事を読んで知った NeoVim ディストリビューション(そういうのもあるんだというのもここで知った)の1つらしく、名前もかっこいいし使ってみた 最初から機能十分 LSP が最初から動くようになっていて、ファイルを開くと拡張子をみて自動的に LSP をインストール、起動してくれる。これがとても便利、かなり VSCode でできていたことに近づいてきた。 他にも 最初からプラグインが色々入っていて 、まっさらな状態の時点で十分に便利。もちろんそれらの設定も可能。 vim の良さとして最初は何もなくて後とから拡張できる、というメリットに反している…と思ったけど前言撤回。 VSCode に慣れてしまった自分にとってはエディタに求める当たり前品質が多いのと、現代のソフトウェア開発は複雑度が高まっていてそれに追従できるための道具としてのエディタも求められる機能が増えている、と思った。 カスタマイズもできる とはいえ、カスタマイズも十分できるしプラグイン追加も可能。素の NeoVim で難しかったことが簡単に出来るようになりつつ、使っていたキーコンフィグ・プラグインも使えて今のところ LunarVim に落ち着いている。 今の見た目はこんな感じ 便利なプラグイン 最後に自分が使っているプラグインを抜粋して紹介する。 phaazon/hop.nvim https://github.com/phaazon/hop.nvim 楽にカーソル移動できるようになるプラグイン。 カーソルから手を離さずに移動したい場所へカーソル移動が出来るようになった、これのお陰でマウスを使うことが激減した。 nvim-neo-tree/neo-tree.nvim https://github.com/nvim-neo-tree/neo-tree.nvim ディレクトリやファイルをツリーで見られるプラグイン。業務で使うときは大量のディレクトリを行ったり来たりするので必須。 echasnovski/mini.nvim https://github.com/echasnovski/mini.nvim 独立した小さめのプラグインてんこ盛りのプラグイン。2024 年 6 月現在 40 個のプラグインがあり、それぞれ個別で使うことも出来る。痒いところに手が届いて、ほとんどはここからプラグインを選んで完結する。 特に mini.surround はもうこれ無しでは生きられない体になった。 *1 : この記事を書いてる時に知ったが、NeoVim は vim の新しいもの、という事では ないらしい 。NeoVim は vim を fork して作られたもので開発者も違うしそれぞれ独自路線を進んでいる。
アバター
こんにちは、駅奪取エンジニアの id:kimkim0106 (旧: id:kaoru_k_0106 )です。 今回の記事は、駅奪取でテーブルにレコードが「無ければ INSERT、あれば UPDATE」(いわゆる UPSERT)をする箇所で Duplicate entry が出ていたのを修正したり、未然に防ぐ実装をしたときに得られた知見です。 このような処理はよく使われますが、うまく実装しないとエラーが発生したりパフォーマンスの問題が生じたりします。 この記事では、自分が試した方法のメリット・デメリットについて説明します。 目次 前提条件 Duplicate entry とは 1. Duplicate entry が出たらトランザクション自体をやり直す 2. INSERT ... ON DUPLICATE KEY UPDATE 3. とりあえず INSERT して Duplicate entry が出たら SELECT 4. 前もって必要なレコードを INSERT しておく 5. トランザクションが同時に走らないようにロックを取る まとめ 前提条件 今回、このようなテーブルがあったとして話を進めます。 CREATE TABLE report ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, report_on DATE NOT NULL , referrer VARCHAR ( 50 ) NOT NULL , dau INT UNSIGNED NOT NULL DEFAULT 0 , UNIQUE report_row(report_on, referrer) ); トランザクション分離レベルは InnoDB のデフォルトである REPEATABLE READ の場合を想定しています。 また、サンプルコードは Perl にて記載されています。 Duplicate entry とは? Duplicate entry は主キーが重複したときやユニーク制約違反が起きたときに発生するエラーです。 例えば、以下のようにユニーク制約に違反して INSERT すると Duplicate entry が出ます。 mysql> INSERT INTO report (report_on, referrer, dau) VALUES ( ' 2024-05-30 ' , ' campaign_202404 ' , 1 ); Query OK, 1 row affected ( 0 . 01 sec) mysql> INSERT INTO report (report_on, referrer, dau) VALUES ( ' 2024-05-30 ' , ' campaign_202405 ' , 1 ); Query OK, 1 row affected ( 0 . 00 sec) mysql> INSERT INTO report (report_on, referrer, dau) VALUES ( ' 2024-05-30 ' , ' campaign_202405 ' , 1 ); ERROR 1062 ( 23000 ): Duplicate entry ' 2024-05-30-campaign_202405 ' for key ' report.report_row ' 1. Duplicate entry が出たらトランザクションをやり直す use DBI; use Sub::Retry qw/retry/ ; retry $retry_times , $delay , sub { try { $dbh->begin_work ; my $report = $dbh->selectrow_hashref ( "SELECT * FROM report WHERE report_on = ? AND referrer = ?" , undef , $today , $referrer ); if ( $report ) { $dbh->do ( "UPDATE report SET dau = dau + 1 WHERE id = ?" , undef , $report->{ id } ); } else { $dbh->do ( "INSERT INTO report (report_on, referrer, dau) VALUES (?, ?, 1)" , undef , $today , $referrer ); } $dbh->commit ; } catch { $dbh->rollback ; die $@ ; } }; メリット Duplicate entry でエラーにならない デメリット トランザクションが大きい場合、時間がかかる 一番シンプルなやり方で、標準 SQL の範囲にも収まっているので、MySQL に限らず他の DB エンジンでも使える手法です。 ですが、トランザクションが大きい場合、全体をやり直すため実行時間が長くなってしまいます。 ただし、トランザクションのやり直しは同時に INSERT しようとしたときだけなので、毎回発生するわけではありません。 また、この方法では同時に複数のトランザクションが実行されていた場合に Duplicate entry が起こり得ます。 別トランザクションで INSERT されたレコードが最初の SELECT で取得できなかった場合、こちらのトランザクションでも INSERT してしまうので Duplicate entry が出ます。 注意点としては、最初の SELECT で FOR UPDATE をつけると、2 つのトランザクションが同時に実行されたときに Deadlock が起こります。 +-------------------------+-------------------------+ | Transaction 1 | Transaction 2 | +-------------------------+-------------------------+ | BEGIN; | BEGIN; | | SELECT ... FOR UPDATE; | | | | SELECT ... FOR UPDATE; | | INSERT INTO report ...; | | | | INSERT INTO report ...; | +-------------------------+-------------------------+ まず、 SELECT ... FOR UPDATE で該当レコードが存在しないとギャップロックが取得されます。 ギャップロックは共有ロックなので他のトランザクションをブロックしません。 一方、INSERT は排他ロックなので他のトランザクションをブロックし、INSERT が互いにブロックすると Deadlock になります。 トランザクション分離レベルが REPEATABLE READ の場合、ギャップロックは避けられません。 駅奪取で Duplicate entry が出ていた箇所もできればこの方法で直したかったですが、トランザクションが大きく影響範囲も大きかったのと、実行時間にも懸念があったので、別の方法にしました。 2. INSERT ... ON DUPLICATE KEY UPDATE $dbh->do ( "INSERT INTO report (report_on, referrer, dau) VALUES (?, ?, 1) ON DUPLICATE KEY UPDATE dau = dau + 1" , undef , $today , $referrer , ); メリット クエリが 1 つで済む デメリット 複数のユニークキーが存在するテーブルには非推奨 AUTO INCREMENT の値が余分に増えてしまう MySQL の場合、 INSERT ... ON DUPLICATE KEY UPDATE を使うことで 1 つのクエリで実現できます。 一見これで解決しそうですが、いくつか注意点があります。 一般に、一意のインデックスが複数含まれているテーブルに対して ON DUPLICATE KEY UPDATE 句を使用することは避けるようにしてください。 MySQL :: MySQL 8.0 リファレンスマニュアル :: 13.2.6.2 INSERT ... ON DUPLICATE KEY UPDATE ステートメント まず、MySQL のドキュメントにも書かれている通り、ユニークキーが複数ある場合は非推奨となっているため、そのようなテーブルに対しては避けたほうが良さそうです。 また、テーブルに AUTO INCREMENT のカラムがある場合、UPDATE の場合でも AUTO INCREMENT の値が増えてしまいます。 UPDATE の頻度が高いテーブルであれば、AUTO INCREMENT の値がどんどん大きくなり、場合によってはオーバーフローのおそれもあります。 innodb_autoinc_lock_mode を 0 にすることで UPDATE で AUTO INCREMENT の値が増えなくなりますが、並列性が下がります。 また、以下のように最初に SELECT して存在しない場合だけ INSERT ... ON DUPLICATE KEY UPDATE をすることで、AUTO INCREMENT の値が増えにくくなります。 $dbh->begin_work ; my $report = $dbh->selectrow_hashref ( "SELECT * FROM report WHERE report_on = ? AND referrer = ?" , undef , $today , $referrer ); if ( $report ) { $dbh->do ( "UPDATE report SET dau = dau + 1 WHERE id = ?" , undef , $report->{ id } ); } else { $dbh->do ( "INSERT INTO report (report_on, referrer, dau) VALUES (?, ?, 1) ON DUPLICATE KEY UPDATE dau = dau + 1" , undef , $today , $referrer , ); } $dbh->commit ; 3. とりあえず INSERT して Duplicate entry が出たら SELECT use DBI; use Try::Tiny; $dbh->begin_work ; try { $dbh->do ( "INSERT INTO report (report_on, referrer, dau) VALUES (?, ?, 1)" , undef , $today , $referrer ); } catch { my $error = $_ ; if ( $error =~ / Duplicate entry / ) { $report = $dbh->selectrow_hashref ( "SELECT * FROM report WHERE report_on = ? AND referrer = ? FOR UPDATE = 1" , undef , $today , $referrer ); $dbh->do ( "UPDATE report SET dau = dau + 1 WHERE id = ?" , undef , $report->{ id } ); } else { die $error ; } }; $dbh->commit ; メリット Duplicate entry は起きなくなる トランザクション全体をやり直さないので 1. よりは早い デメリット Deadlock が起きるようになる この方法は、シンプルでわかりやすく、一見問題なさそうです。 しかし、1. と同様に SELECT するときに FOR UPDATE しているためギャップロックが発生します。 そのため、同時に INSERT しようとしたときに Deadlock が起きてしまうのでおすすめしません。 4. 前もって必要なレコードを INSERT しておく use DBI; my $rows = $dbh->selectrow_arrayref ( " SELECT DISTINCT referrer FROM report WHERE report_on = ? AND dau > 0 " , {}, $today ); for my $row ( @$rows ) { $dbh->do ( "INSERT INTO report (report_on, referrer) VALUES (?, ?)" , undef , $tomorrow , $rows->{ referrer } ); } use DBI; $dbh->begin_work ; # 前もってINSERTしているので基本的にレコードが存在する my $report = $dbh->selectrow_hashref ( "SELECT * FROM report WHERE report_on = ? AND referrer = ?" , undef , $today , $referrer ); if ( $report ) { $dbh->do ( "UPDATE report SET dau = dau + 1 WHERE id = ?" , undef , $report->{ id } ); } else { # こちらに来ることはほとんどないので、Duplicate entryが起きることもほとんどない $dbh->do ( "INSERT INTO report (report_on, referrer, dau) VALUES (?, ?, 1)" , undef , $today , $referrer ); } $dbh->commit ; メリット 既存のコードの変更が不要で、副作用が少ない デメリット Duplicate entry が絶対に出なくなるわけではない これは、どのようなレコードが INSERT されるか前もってわかるのであれば、先に INSERT しておくことで同時に INSERT されずに済むという解決策です。 例えば、今回の例に出したテーブルは、毎日 referrer ごとにレコードが INSERT されます。 そこで、翌日のレコードを前日のうちにあらかじめ INSERT しておくことで、基本的に UPDATE するだけで済みます。 これにより、Duplicate entry が発生しづらくなります。 一方で、前日存在しなかった referrer が同時に INSERT されようとした場合は Duplicate entry が発生し得ます。 ただ、referrer の種類があまり増えないものであったり、同時にくることが少なければ、これで十分な対策になります。 駅奪取では日替わりタイミングで起きていた Duplicate entry をこの方法でなくすことができました。 5. トランザクションが同時に走らないようにロックを取る use DBI; use Try::Tiny; my $key = $today . '_' . $referrer ; try { my $get_lock = $dbh->selectrow_array ( "SELECT GET_LOCK(?, ?)" , undef , $key , $timeout ); if ( $get_lock ) { $dbh->begin_work ; my $report = $dbh->selectrow_hashref ( "SELECT id, dau FROM report WHERE report_on = ? AND referrer = ?" , undef , $today , $referrer ); if ( $report ) { $dbh->do ( "UPDATE report SET dau = dau + 1 WHERE id = ?" , undef , $report_id ); } else { $dbh->do ( "INSERT INTO report (report_on, referrer, dau) VALUES (?, ?, 1)" , undef , $today , $referrer ); } $dbh->commit ; $dbh->do ( "SELECT RELEASE_LOCK(?)" , undef , $key ); } else { die "cannot get lock" ; } } catch { $dbh->rollback ; $dbh->do ( "SELECT RELEASE_LOCK(?)" , undef , $key ); }; メリット トランザクションが同時に走らないので Duplicate entry や Deadlock が起きなくなる デメリット 並列性が下がり、パフォーマンスが低下する トランザクションが同時に走るから Duplicate entry が起きるのであれば、同時に走らないようにロックしてしまうという手もあります。 MySQL で完結する仕組みとして GET_LOCK と RELEASE_LOCK を使う方法があります。 ただし、データベース分離レベルを SERIALIZABLE にするのと同じようなことをしているので、並列性が下がり、パフォーマンスが低下するので気をつけないといけません。 トランザクション内でリトライしたくない処理がある場合は、この方法を使うとうまく実装できるかもしれません。 まとめ この記事では、MySQL で「無ければ INSERT、あれば UPDATE」を実現するための方法をいくつか紹介しました。 各方法にはメリット・デメリットがあり、ケースバイケースで使い分けが必要です。 できれば 1. もしくは 2. のように修正するのがいいと思うのですが、スキーマや影響範囲、パフォーマンスの制約を考慮すると難しい場合もあります。 その場合は、他の方法を使ってみるとうまくいくかもしれません。 参考資料 MySQL :: MySQL 8.0 リファレンスマニュアル :: 13.2.6.2 INSERT ... ON DUPLICATE KEY UPDATE ステートメント 第145回 InnoDBの行ロック状態を確認する[その1] | gihyo.jp なかったらINSERTしたいし、あるならロック取りたいやん? | PPT モバファクでは中途採用・新卒採用ともに絶賛募集中です。 会社の情報については、モバファクブログでも発信しています。 技術好きな方、モバファクにご興味をお持ちの方は、ぜひご応募ください! ・モバファクブログ: https://corpcomn.mobilefactory.jp/ ・採用サイト: https://recruit.mobilefactory.jp/
アバター
駅奪取チームでエンジニアをしている id:kebhr です。 今回は、駅奪取チームにおけるプロジェクト管理のツールとして、従来利用していたガントチャートに加え、新たに バッファ傾向グラフ を導入してみた経験について書きます。 バッファ傾向グラフとは このプロジェクトでは、プロジェクト管理手法として CCPM (Critical Chain Project Management) を採用しました。 CCPM では、個別のタスクにはバッファを設けず、すべてのバッファをプロジェクトの終盤に設けます。このバッファをプロジェクトバッファと呼びます。 必然的に、プロジェクトが進行するにつれて、プロジェクトバッファを消費していきます。 プロジェクトバッファの消費量を可視化するツールがバッファ傾向グラフです。 下図のような、横軸に日付、縦軸にバッファ消費率を取るグラフです。 バッファ傾向グラフ バッファ消費率は、 プロジェクトバッファの総量に対するプロジェクトバッファの消費量の割合 として求めます。 プロジェクトバッファの消費量は、 完了していないタスクの終了予定日のうち最も早い日と、その日の差 として求めます。 重視したこと バッファ傾向グラフを運用する上で、運用に時間を掛けないよう、 グラフの運用が容易に行えること を重視しました。 そのため、横軸には、一般的にバッファ傾向グラフで用いられるプロジェクト進捗率ではなく日付を採用しました。 横軸に日付を採用することで、ガントチャートとバッファ傾向グラフの横軸が 1:1 対応するため、スプレッドシート上での管理が容易です。 バッファ消費量についても、このルールで求める場合、ガントチャートからある時点でのバッファ消費率を簡単に求めることができます。 利点 バッファ傾向グラフを使用したことで、次のような利点がありました。 プロジェクトの進捗を、プロジェクト内外のメンバーが一目で理解できる 遅延に対する介入の必要性を、統一された基準に基づいて判断できる 1 つ目の利点は、ガントチャートの利点でもあります。しかし、ガントチャートに比べて、グラフ 1 つで済むバッファ傾向グラフはより簡潔で、理解しやすいものになります。 2 つ目の利点は、バッファ傾向グラフを使用することにより得られる利点です。 プロジェクトに遅延はつきものです。しかし、その遅延が許容される程度の遅延なのか、介入の必要がある遅延なのかといった評価は、各メンバーによって異なる場合があります。 バッファ傾向グラフは明快な判断基準を与えてくれます。 問題点 一方で、現状の仕組みは不完全であり、次のような問題点を抱えていることもわかりました。 タスクの着手順序を入れ替えた際に、そのタスクが完了しないまま終了予定日を過ぎると、プロジェクトバッファの消費量が増え続ける この問題を解決する手段には、以下の 2 通りがあります。 タスクの着手順序を入れ替えた際に、タスクの終了予定日を入れ替える プロジェクトバッファの消費量の定義を変更する プロジェクトバッファの消費量から、先行して着手できたタスクの本来の着手予定日からその日までの日数を引いた値とする 1 つ目の手段は、簡単に行えて影響が小さいです。しかし、変更の履歴を残すことが難しいです。 プロジェクト完了後の振り返りにおいて、タスク着手順序の変更を振り返りの対象とすることを容易でなくする可能性があります。 2 つめの手段は、仕組みそのものの改善策になります。しかし、スプレッドシート上で行うにはやや複雑さがあります。 バッファ傾向グラフの運用が属人化することは避ける必要があります。 まとめ バッファ傾向グラフは簡単に作成することができ、ガントチャートの隙間を埋めることができるツールです。しかし、最大限の効果を発揮するためには調整を重ねる必要があります。 複数のプロジェクトを繰り返す中でより仕組みが洗練されれば、また紹介したいと思います。 モバファクでは中途採用・新卒採用ともに絶賛募集中です。 会社の情報については、モバファクブログでも発信しています。 技術好きな方、モバファクにご興味をお持ちの方は、ぜひご応募ください! ・モバファクブログ: https://corpcomn.mobilefactory.jp/ ・採用サイト: https://recruit.mobilefactory.jp/
アバター
駅メモ!開発基盤チームの id:xztaityozx です。 今回はテスト実行のボトルネックを OverlayFS を利用することで解消した話と、OverlayFS の動作を調べるために bpftrace を使った話をします。 かんたん概要 Test::mysqld を使って挿入済みのデータを持った mysqld をテストごとに起動していた データが増えてきたことでコピーがめちゃくちゃ遅くなり、開発体験が最悪になった コピーを OverlayFS でのマウントに置き換えてすごく速くした 動作について気になる点があったので bpftrace を使ってトレースを行い、カーネル関数の呼び出しも観察した 前提 この記事で登場する主なツールのバージョンを示します Ubuntu 22.04.4(WSL2) カーネル: 5.15.146.1-microsoft-standard-WSL2 hyperfine 1.18.0 Docker 26.0.0, build 2ae903e sysbench 1.0.20 bpftrace 0.12.0 背景 単体テストで用いる DB をどのように起動していますか? Test::mysqld などを用いて mysqld を起動したり、MySQL コンテナを起動したりなど様々かと思います。駅メモ!でも先にも登場した Test::mysqld と App::Prove::Plugin::MySQLPool を用いて、テスト開始前に専用の mysqld を起動していました。 Test::mysqld は copy_data_from にディレクトリへのパスを渡すことで、そのディレクトリのコピーを MySQL のデータディレクトリとすることができます。つまり、データを複製して起動することができるということですね。 駅メモ!ではこの仕組みを用いることで、テストで必ず使うことになるデータ(例えばマスターデータ)が事前に挿入された MySQL を起動しています。こうすることでテストの開始にかかる時間の短縮と複雑なデータセットの挿入の簡略化を狙っています。元々どんな目的で運用されていたかはわかりませんが、少なくとも現在はこの目的で運用しています。 課題点 この運用は課題点がありました。それは事前挿入しておきたいデータが増えたことにより、コピーに時間がかかりすぎるようになったことです。具体的にかかる時間として、コピー元のデータが壊れていないこと前提で、平均 40 秒ほどかかる状況でした。 1 回当たりのテスト実行のたびに 40 秒+テスト実行時間が掛かっていれば開発体験は最悪です。エンジニアは少し変更しては待機、少し変更しては待機を繰り返す必要があるわけですが、何か別のことをするには短すぎ、計算資源だけではなく人件費の無駄にもなっていました。 もちろんチームではなにも対策をしていなかったわけではないのですが、次の手が見つかっていない状況でした。 課題に対するアプローチ 先にも述べた通り課題点はコピーすべきデータが増えてしまったことです。そこでまずは挿入しておきたいデータを無くす、もしくは最小限に減らすことです。そのテストで必要なデータは before all などでテストごとに挿入します。データセットの柔軟性もよくなるのでこの方法を採用したいですが、複雑なデータセットの挿入を再現するのにかかるコストが高いこと、直すべきテストが多すぎることから取りたくない方法でした。 次に考えたのはデータ入りのコンテナイメージを作っておく方法です。なんでも試そうということでやってみましたが、これは少しだけ効果がありました。 $ docker run --rm -d -p 3306:3306 \ -e MYSQL_ALLOW_EMPTY_PASSWORD = 1 \ -v $PWD /data:/var/lib/mysql mysql:8. 0 . 34 # だいたい3GBのデータを挿入... $ docker stop .... $ cat <<EOF > Dockerfile FROM mysql:8.0.34 COPY ./data /var/lib/mysql EOF $ docker build -t hoge . $ hyperfine --warmup=3 \ --prepare= ' docker stop db||true ' -- \ ' docker run --rm -d -e MYSQL_ALLOW_EMPTY_PASSWORD=1 --name db -P hoge ' Benchmark 1: docker run --rm -d -e MYSQL_ALLOW_EMPTY_PASSWORD = 1 --name db -P hoge Time ( mean ± σ ) : 8 . 716 s ± 2 . 697 s [ User: 0 . 008 s, System: 0 . 014 s ] Range ( min … max ) : 6 . 480 s … 14 . 670 s 10 runs 大体 8 秒程度で起動できるようになりました。40 秒からかなり高速化できています。しかしながら、イメージサイズが大きくなるにつれて起動時間が増加することを確認できたため、いずれ同じ課題に直面してしまうことが考えられました。 とはいえ、データディレクトリをコピーするよりかなり速いことには変わりなく、このあたりにヒントがありそうでした。 OverlayFS ヒントというか答えは OverlayFS でした。OverlayFS は Linux カーネル 3.18 で追加された複数のファイルシステムを 1 つにマージする仕組みで、Docker にも用いられています。OverlayFS では書き込み時に元のファイルをコピーしてくる(Copy on Write)ので、OverlayFS 作成時にはコピーのコストが乗って来ません。Docker コンテナは(ストレージドライバにもよりますが)OverlayFS による重ね合わせで実現されるため、単純にコピーするより速く起動できたということでした。さらに詳しい解説は Docker のドキュメント やその他の解説記事をご参照ください。 さて、この OverlayFS は通常 mount コマンドで作成しますが、 docker run 時に --mount オプションを以下のように書けば OverlayFS の作成とマウントが同時に行えます。ちょっと複雑ですが… $ docker run --rm -it --mount type= volume,dst =/tmp/hoge,volume-driver =local ,volume-opt = type = overlay, \" volume-opt = o = lowerdir = ${PWD} /lower,upperdir = ${PWD} /upper,workdir = ${PWD} /work \" ,volume-opt = device = overlay ubuntu 作成した Ubuntu コンテナ内で df コマンドを実行し、マウントされた OverlayFS を確認してみます $ df -h /tmp/hoge Filesystem Size Used Avail Use% Mounted on overlay 1007G 29G 928G 3% /tmp/hoge OverlayFS をコンテナにマウントできることがわかったので、これを今回の課題に応用していきます。つまりホストマシンに用意したデータディレクトリを OverlayFS としてコンテナにマウントすればよいということです。データ入りコンテナと同じように hyperfine でのベンチマークを取ってみます。 $ hyperfine --warmup=3 \ --prepare ' docker stop db||true; sudo rm -rf ./upper ./work;mkdir ./upper ./work ' -- \ ' docker run --rm -d -P --name db -e MYSQL_ALLOW_EMPTY_PASSWORD=1 --mount type=volume,dst=/var/lib/mysql,volume-driver=local,volume-opt=type=overlay,\"volume-opt=o=lowerdir=${PWD}/data,upperdir=${PWD}/upper,workdir=${PWD}/work\",volume-opt=device=overlay mysql:8.0.34 ' Benchmark 1: docker run --rm -d -P --name db -e MYSQL_ALLOW_EMPTY_PASSWORD = 1 --mount type= volume,dst =/var/lib/mysql,volume-driver =local ,volume-opt = type = overlay, \" volume-opt = o = lowerdir = ${PWD} /data,upperdir = ${PWD} /upper,workdir = ${PWD} /work \" ,volume-opt = device = overlay mysql:8. 0 . 34 Time ( mean ± σ ) : 316 . 6 ms ± 38 . 3 ms [ User: 5 . 0 ms, System: 10 . 2 ms ] Range ( min … max ) : 271 . 5 ms … 392 . 3 ms 10 runs 300 ミリ秒程度でコンテナの起動が完了しています。40 秒から比べると 100 倍以上高速です。 mysqld が使えるまでの時間を調べる Copy on Write(CoW)の影響が起動シーケンスにどれぐらい出るのかも調べてみます。今回は以下のようなシェルスクリプトと hyperfine で簡単なベンチマークを取ることで検証とします。 #!/usr/bin/env zsh CONTAINER_NAME=db function prepare-container-with-overlayfs() { docker stop " $CONTAINER_NAME " || true sudo rm -rf ./work ./upper mkdir ./work ./upper docker run --rm -d -p 3306 : 3306 \ --name " $CONTAINER_NAME " \ --mount type =volume,dst=/var/lib/mysql,volume-driver= local ,volume-opt= type =overlay, \" volume-opt=o=lowerdir= ${PWD} /data,upperdir= ${PWD} /upper,workdir= ${PWD} /work \" ,volume-opt=device=overlay \ -e MYSQL_ALLOW_EMPTY_PASSWORD= 1 \ mysql: 8.0 . 34 } function prepare-container() { docker stop " $CONTAINER_NAME " || true docker run --rm -d -p 3306 : 3306 \ --name " $CONTAINER_NAME " \ -e MYSQL_ALLOW_EMPTY_PASSWORD= 1 \ hoge } function launch() { while ! docker exec " $CONTAINER_NAME " mysqladmin ping --silent; do ; done while ! docker exec " $CONTAINER_NAME " mysql -e "SELECT 1" &> /dev/null; do ; done } if [[ " $1 " == "prepare-container-with-overlayfs" ]]; then prepare-container-with-overlayfs elif [[ " $1 " == "prepare-container" ]]; then prepare-container elif [[ " $1 " == "launch" ]]; then launch else exit 1 fi hoge イメージと ./data は以下の手順で作成しています $ docker run -d --rm \ -e MYSQL_ALLOW_EMPTY_PASSWORD = 1 \ -e MYSQL_DATABASE =test \ -- name hoge \ -v $PWD /data:/var/lib/mysql mysql:8. 0 . 34 $ docker exec hoge mysql -e ' create database test ' $ sysbench /usr/share/sysbench/oltp_read_write.lua \ --db-driver=mysql \ --mysql-db=test \ --mysql-user=root \ --mysql-password= "" \ --mysql-host=127.0.0.1 \ --mysql-port=3306 \ --tables=7 \ --table-size=1000000 prepare $ docker stop hoge $ cat <<EOF > Dockerfile FROM mysql:8.0.34 COPY ./data /var/lib/mysql EOF $ docker build -t hoge . 実行結果は以下の通りです。 # データ入りのコンテナの場合 $ hyperfine --warmup=3 --prepare= ' ./benchmark.zsh prepare-container ' -- ' ./benchmark.zsh launch ' Time ( mean ± σ ) : 918 . 3 ms ± 64 . 5 ms [ User: 158 . 4 ms, System: 142 . 4 ms ] Range ( min … max ) : 855 . 9 ms … 1049 . 1 ms 10 runs # OverlayFSなデータディレクトリの場合 $ hyperfine --warmup=3 --prepare= ' ./benchmark.zsh prepare-container-with-overlayfs ' -- ' ./benchmark.zsh launch ' Time ( mean ± σ ) : 935 . 5 ms ± 92 . 6 ms [ User: 165 . 7 ms, System: 174 . 3 ms ] Range ( min … max ) : 831 . 3 ms … 1149 . 7 ms 10 runs 20 ミリ秒程度の差があるようでした。OverlayFS なデータディレクトリの場合は mysqld の起動シーケンス中にほとんどのファイルで CoW が発生し、そのぶん時間がかかってしまうと思っていたのですが、20 ミリ秒ということであればそういうわけではなさそうです。 クエリを実行してみる テスト用の DB を高速にするという課題は、OverlayFS なデータボリュームを使うことで解決できそうなことがわかりました。準備を自動で行うスクリプトを実装・配布、実際に利用してもらい、改善されたことが確認できました。 しかしながら、起動時の処理では CoW がほとんど発生していないことが個人的には気になっていました。 そこで、ここをもう少し追うことにしました。具体的には、起動直後のコンテナに対して sysbench を流すことでコピーのコストが発生するかを確認してみます。CoW の動作を見たいので、 oltp_write_only を使うこととします。ここからは趣味です。 まずはデータ入りのコンテナに対して実行したものを示します $ sysbench /usr/share/sysbench/oltp_write_only.lua \ --db-driver=mysql \ --mysql-db=test \ --mysql-user=root \ --mysql-password= "" \ --mysql-host=127.0.0.1 \ --mysql-port=3306 \ --tables=7 \ --table-size=1000000 run sysbench 1 . 0 . 20 ( using system LuaJIT 2 . 1 .0-beta3 ) Running the test with following options: Number of threads: 1 Initializing random number generator from current time Initializing worker threads... Threads started! SQL statistics: queries performed: read: 0 write: 7164 other: 3582 total: 10746 transactions: 1791 ( 179 . 04 per sec. ) queries: 10746 ( 1074 . 25 per sec. ) ignored errors: 0 ( 0 . 00 per sec. ) reconnects: 0 ( 0 . 00 per sec. ) General statistics: total time: 10 .0029s total number of events: 1791 Latency ( ms ) : min: 2 . 58 avg: 5 . 58 max: 15 . 51 95th percentile: 9 . 91 sum: 9999 . 11 Threads fairness: events ( avg/stddev ) : 1791 . 0000 / 0 . 00 execution time ( avg/stddev ) : 9 . 9991 / 0 . 00 次に OverlayFS なデータディレクトリの場合です。 $ sysbench /usr/share/sysbench/oltp_write_only.lua \ --db-driver=mysql \ --mysql-db=test \ --mysql-user=root \ --mysql-password= "" \ --mysql-host=127.0.0.1 \ --mysql-port=3306 \ --tables=7 \ --table-size=1000000 run sysbench 1 . 0 . 20 ( using system LuaJIT 2 . 1 .0-beta3 ) Running the test with following options: Number of threads: 1 Initializing random number generator from current time Initializing worker threads... Threads started! SQL statistics: queries performed: read: 0 write: 5348 other: 2674 total: 8022 transactions: 1337 ( 133 . 68 per sec. ) queries: 8022 ( 802 . 11 per sec. ) ignored errors: 0 ( 0 . 00 per sec. ) reconnects: 0 ( 0 . 00 per sec. ) General statistics: total time: 10 .0008s total number of events: 1337 Latency ( ms ) : min: 2 . 86 avg: 7 . 48 max: 1651 . 00 95th percentile: 9 . 91 sum: 9997 . 96 Threads fairness: events ( avg/stddev ) : 1337 . 0000 / 0 . 00 execution time ( avg/stddev ) : 9 . 9980 / 0 . 00 注目したいのは Latency (ms) の max です。データ入りコンテナの場合は 15.51 ミリ秒 なのに対して、OverlayFS なデータディレクトリの場合は 1651.00 ミリ秒 となっています。そのほかの Latency の統計については特に差はありません。Write のどのタイミングで発生しているかは明確ではないですが、CoW のコストが Write に掛かっているように見えます。 CoW のコストがいつ乗ってくるのかを確認してみる CoW の影響があるのは、書き込みクエリ実行時であることはなんとなく予想できたので、次はそのコストがいつ発生するのかを見ていきます。CoW の性質を考えると初回の書き込みが Commit されたときであるのは自明なのですが、気になったので調べます。 調べ方としては、以下のようなシェルスクリプトを OverlayFS なデータディレクトリを持つコンテナに対して実行するというものです。 for i ($( seq 10 )) ; do s = $( date +%s%3N ) docker exec db mysql -e " insert into test.sbtest1(k, c, pad) values(99999999, 'char', 'pad') " echo $i : $(( $ ( date +%s%3N ) - $s)) ms done sysbench が作ったテーブルに対して 1 件のレコードを挿入することを 10 回繰り返しています。実行後は挿入にかかった時間をミリ秒で出力しています。 これを実行してみると以下のような結果が得られました。 1 : 384 ms 2 : 43 ms 3 : 45 ms 4 : 38 ms 5 : 38 ms 6 : 39 ms 7 : 39 ms 8 : 37 ms 9 : 38 ms 10 : 36 ms 1 回目だけ 10 倍程度遅いですね。ここに CoW のコストが乗っていると考えてよさそうです。 bpftrace で本当に 1 回目だけコピーが発生しているかを見てみる はじめて書き込みを行ったときにコピーのコストが乗ってきているのはほぼそうだと言えそうなのですが、ここまで来たらカーネルのトレースをしてコピー系の関数が最初だけ呼ばれていることを確認したいと思ったのでやってみます。 今回これを確認するために使ったのは bpftrace/bpftrace という、 awk と似た文法で書けるトレーシングツールです。詳しい解説は README やその他の解説記事などをご覧ください。 github.com OverlayFS のヘッダファイル を見てみると、OverlayFS に関する関数は ovl_ で始まることが分かるので $ bpftrace ' kprobe:ovl_* { printf("%s\n", func) } ' などとして OverlayFS で CoW が発生するとき、しないときの関数呼び出しを記録、両者を比較すると以下のような呼び出し順の違いがありました。因みにめちゃくちゃざっくりで、めちゃくちゃ端折っています CoW なとき CoW じゃないとき sys_enter_openat sys_enter_openat ovl_open ovl_open ovl_already_copied_up ovl_already_copied_up ovl_already_copied_up ovl_copy_up_start ovl_copy_up_end ovl_already_copied_up これらの関数の呼び出しをトレースしてみます。トレースするのは以下のようなシェルスクリプトを実行する間です。内容としてはコンテナの起動と sbtest2 テーブルへの書き込みを繰り返すものです。1 回目だけ CoW が発生するはずなので、その様子を確認します。 sudo rm -rf ./upper ./work mkdir ./upper ./work for i ($( seq 3 )) ; do docker stop db sleep 10 docker run --rm -d -p 3306:3306 \ --name " $CONTAINER_NAME " \ --mount type= volume,dst =/var/lib/mysql,volume-driver = local ,volume-opt = type = overlay, \" volume-opt = o = lowerdir = ${PWD} / data,upperdir = ${PWD} / upper,workdir = ${PWD} /work \" ,volume-opt = device = overlay \ -e MYSQL_ALLOW_EMPTY_PASSWORD = 1 \ mysql:8. 0 . 34 sleep 10 ; s = $( date +%s%3N ) docker exec db mysql -e " insert into test.sbtest2(k, c, pad) values( $RANDOM , 'char', 'pad') " echo $i : $(( $ ( date +%s%3N ) - $s)) ms done docker stop db bpftrace のコードは以下の通りです。 tracepoint : syscalls : sys_enter_openat /str(args->filename) == " . / test /sbtest1 . ibd" / { printf ( "elapsed: %ldms \t comm: %s\t probe: %s\t name: %s\t flags: %d\n " , elapsed / 1_000_000 , comm , probe , str(args - >filename) , args - >flags) } kfunc : ovl_open { printf ( "elapsed: %ldms \t comm: %s\t probe: %s\t name: %s\n " , elapsed / 1_000_000 , comm , probe , str(args - >file - >f_path.dentry - >d_name.name)) } kfunc : ovl_copy_up_start , kfunc : ovl_copy_up_end { printf ( "elapsed: %ldms \t comm: %s\t probe: %s\t name: %s\n " , elapsed / 1_000_000 , comm , probe , str(args - >dentry - >d_name.name)) } kretfunc : ovl_already_copied_up { printf ( "elapsed: %ldms \t comm: %s\t probe: %s\t name: %s\t retval: %d\n " , elapsed / 1_000_000 , comm , probe , str(args - >dentry - >d_name.name) , retval) } シェルスクリプトの実行ログは以下の通りです。 Error response from daemon: No such container: db 8eefa5dec9b920fa2b483a32f86e4b90ac1089fd750fd2f3e6fd28efcc1a73d5 1 : 359 ms db 0ba1c5ed2784922a515ff0ae1fd5d75dfcc2db0892b2c54f6ef5fbbc8c849992 2 : 44 ms db 4da332ad30eb565dcb0ec91e67b4a705ec52c1592298180e66c23995ccb7c853 3 : 41 ms db トレースログは以下の通りです。 # sbtest2のイベントだけに注目する # elapsed: 経過時間(ms) # comm: タスク名。/proc/<pid>/comm の内容 # prove: プローブの名前。今回は呼び出された関数の名前ととらえてOK # name: 関数の引数から取り出した操作対象のファイル名 # flags: ファイルのオープンモード # retval: 関数の戻り値。今回は ovl_already_copied_up だけ出力させていて、この関数の戻り値はbool型 $ cat trace.log | grep sbtest2 | column -t elapsed: 20757ms comm: boot probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 0 elapsed: 20757ms comm: boot probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 20757ms comm: boot probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 0 elapsed: 20972ms comm: boot probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 0 elapsed: 20972ms comm: boot probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 20972ms comm: boot probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 0 elapsed: 30413ms comm: connection probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 0 elapsed: 30413ms comm: connection probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 30413ms comm: connection probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 0 elapsed: 30414ms comm: connection probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 2 elapsed: 30414ms comm: connection probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 30414ms comm: connection probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 0 elapsed: 30414ms comm: connection probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 0 elapsed: 30414ms comm: connection probe: kfunc:ovl_copy_up_start name: sbtest2.ibd elapsed: 30729ms comm: connection probe: kfunc:ovl_copy_up_end name: sbtest2.ibd elapsed: 30729ms comm: connection probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 1 elapsed: 42805ms comm: boot probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 0 elapsed: 42805ms comm: boot probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 42805ms comm: boot probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 1 elapsed: 42925ms comm: boot probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 0 elapsed: 42925ms comm: boot probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 42925ms comm: boot probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 1 elapsed: 42953ms comm: ib_buf_dump probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 0 elapsed: 42953ms comm: ib_buf_dump probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 42953ms comm: ib_buf_dump probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 1 elapsed: 42953ms comm: ib_buf_dump probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 2 elapsed: 42953ms comm: ib_buf_dump probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 42953ms comm: ib_buf_dump probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 1 elapsed: 64644ms comm: boot probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 0 elapsed: 64644ms comm: boot probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 64644ms comm: boot probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 1 elapsed: 64764ms comm: boot probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 0 elapsed: 64764ms comm: boot probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 64764ms comm: boot probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 1 elapsed: 65764ms comm: ib_src_main probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 0 elapsed: 65764ms comm: ib_src_main probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 65764ms comm: ib_src_main probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 1 elapsed: 65765ms comm: ib_src_main probe: tracepoint:syscalls:sys_enter_openat name: ./ test /sbtest2.ibd flags: 2 elapsed: 65765ms comm: ib_src_main probe: kfunc:ovl_open name: sbtest2.ibd elapsed: 65765ms comm: ib_src_main probe: kretfunc:ovl_already_copied_up name: sbtest2.ibd retval: 1 1 回目だけ copy_up_start と copy_up_end が呼ばれており、その処理に 315 ミリ秒かかっていることがわかります。シェルスクリプトの 1 回目の実行時間は 359 ミリ秒なので、辻褄があっていそうです。 さらに 2 回目以降は copy_up_start と copy_up_end の前の ovl_already_copied_up が 1 を返しており、コピーアップ系の処理がスキップされているのがわかります。以上で無事 CoW の様子を観察することができました。 (ところでクエリ実行中の comm が connection や ib_buf_dump 、 ib_src_main だったりするのはなぜなんでしょう?) まとめ この記事では単体テストで利用する DB を高速に起動する方法として、OverlayFS を使った例を示しました。さらに bpftrace を使ったトレースを行い、OverlayFS の挙動の一部を観察、理解することができました。 単体テスト用 DB 高速起動の仕組みは、すでにチームには実験段階の機能として公開しており、使い心地などをフィードバックしてもらっている段階です。また何か進展があったら記事を書きたいと思います。 以上です。 モバファクでは中途採用・新卒採用ともに絶賛募集中です。 会社の情報については、モバファクブログでも発信しています。 技術好きな方、モバファクにご興味をお持ちの方は、ぜひご応募ください! ・モバファクブログ: https://corpcomn.mobilefactory.jp/ ・採用サイト: https://recruit.mobilefactory.jp/
アバター
言葉の定義 モバファクの 1on1 の目的 1on1 で自分が大事にしていること 1on1 はメンティーの時間である 1on1 はメンターの時間でもある 1on1 初回 今使っている 1on1 のフォーマット 体調 半期目標の進捗振り返り ネクストアクションの振り返り うまくいかなかったこと・もっとよくなりそうなところ・うまくいったこと・その他に話したいこと ネクストアクション 1on1 の中でのやりとり お休みの取り方がわからない 最近見積もりの精度が高くなっている 朝会の議事録をとるようにしたい 最近チームの動きがぎこちないと感じている 1on1 定期的な振り返り まとめ こんにちは。駅メモエンジニアの id:dorapon2000 です。 今回は自分自身がメンター側として実施している 1on1 について、どのように実施しているのかご紹介しようと思います。 1on1 のやり方はメンターとメンティーの組み合わせで千差万別です。同じ会社内でも人それぞれです。その具体的なやり方にまで踏み込んだ記事があまりないと感じているため、自分で書いていきます。なお、自分の 1on1 は 「 ヤフーの 1on1―――部下を成長させるコミュニケーションの技法 」がベースになっています。 言葉の定義 ここでは、以下のように言葉を定義します 1on1 メンティーの成長のために設けられるメンターとの会話の時間 最低月 1 回あり、人によっては毎週や隔週で実施する メンター 1on1 の聞き手 上司や先輩、メンター制度のメンター メンティー 1on1 の話し手 部下や後輩、メンター制度のメンティー モバファクの 1on1 の目的 モバファクでは 1on1 の目的は多義的であるとして 5 つ掲げています。 ① 関係性の強化 ② 行動と学習の促進 ③ 意欲の喚起 ④ 情報共有と促進 ⑤ 問題解決の促進 ひとことで言えばメンティーの成長です。 1on1 で自分が大事にしていること 3 つあります。 1on1 はメンティーの時間である 1on1 はメンターの時間でもある 1on1 初回 1on1 はメンティーの時間である 1on1 はメンティーのための時間であるという意識を大事にしています。 理想を言えば、メンティー自身が 1on1 をどのように進めたいかフォーマットを考え、話したいことを話し、1on1 の頻度を決めてもらいたいです。 メンターはメンティーが自分にあったフォーマットで 1on1 を進められるよう選択肢を与え、メンティー自身が気付いていない考えを深堀ってあげ、定期的に 1on1 の頻度は今のままでいいか聞いてあげます。 1on1 では、メンティー自らが考え内省しながら物事を進めていく体験が成長につながると考えています。例えるなら、このリップクリームいいよと教えてもメンティーがリップクリームを買うことはないでしょう。そうではなく、メンティー自身が唇のカサカサを気にしていることを認識して、わからないなりにリップクリームを買って試して失敗しながらコレがいいと思うことが大事です。 1on1 はメンターの時間でもある 1on1 はメンターの時間であることも忘れてはいけません。1on1 の中でどのようにティーチング・コーチング・メンタリングをしていくのか、その使い分けはどうするか、考える必要があり、メンター自身の成長にも繋がります。もちろん、メンティーの学びはそのままメンターの学びにもなります。 1on1 を通して、1on1 の目的はメンティーではなくメンターにも向いていることに気付かされます。 1on1 初回 自分は初回の 1on1 はメンターとメンティーの関係を決め、その後の 1on1 の内容も大きく変わる大切な時間だと考えています。そのため、自分は以下の 3 点について初回に説明します。 ① 1on1 の目的と大事にしたいこと ② あなたの協力が必要であること ③ メンターは聞き手だということ ① はここまでに書いた記事の内容のことです。隠す必要はないので、素直に伝えます。① を踏まえると ③ も自然かと思います。 ② はメンター側の気持ちが伝わるので、メンティーに 1on1 を自分ごととして考えてもらいやすくなると思っています。1on1 はメンターがメンティーを引っ張り上げるものではなく、1on1 というプロジェクトを成功させるためにお互いメンバーとして協力するものだと説明します。 今使っている 1on1 のフォーマット 現在 1on1 をしているメンティーから許可を頂いたので紹介します。 - 体調 (10 段階) - 半期目標の進捗振り返り - ネクストアクションの振り返り - うまくいかなかったこと・もっとよくなりそうなところ・うまくいったこと・その他に話したいこと - ネクストアクション ちなみにこれは、初回 1on1 に私がベースとして出したフォーマットから、お互いに話し合いながら少しずつ成長させたものです。最初は以下のフォーマットでした。 - 体調 (10 段階) - うまくいったこと - うまくいかなかったこと - その他 - ネクストアクション いくつか変わっていますね。 体調 最も大事なセクションだと思っており 1on1 に外せません。仕事 < 健康です。 体調は 10 段階で自己評価してもらいます。なぜ 10 段階かというと、5 段階ではだいたい 4 に収まってしまい、気づきたい変化に気づけなくなってしまうからです。先週の 7 と今日の 8 の違いは偶然なのか心当たりがあるのか、深堀っていきます。 体調以外に、最近の仕事量や心理的な負担があるかどうかなども併せて聞いてあげます。回答によっては、上司に相談したり業務調整を行います。 半期目標の進捗振り返り モバファクでは半期ごとに各自目標を立てて、その目標を達成するために行動することが期待されます。その進捗を話すセクションです。 ネクストアクションの振り返り 毎回の 1on1 の最後に、1on1 を通して気付いた学びをどのような具体的行動につなげるかを考えてもらっています。考えっぱなしにならないようにと、前回考えたネクストアクションはどうなったか振り返りをするセクションを設けました。 うまくいかなかったこと・もっとよくなりそうなところ・うまくいったこと・その他に話したいこと 初回に提示したフォーマットのうちの 3 項目をぎゅっと 1 つにまとめて「もっとよくなりそうなところ」を追加したセクションです。 メンティーがうまくいったことを話しづらいという気付きがあったため、「もっとよくなりそうなところ」と言い方を変えて追加しています。 うまくいったことを先に話すと、うまくいかなかったことを話す時間が圧迫されてしまうという気付きから、うまくいかなかったことを先に話せるように先頭にもってきました。 ネクストアクション ここまでの 1on1 の内容を踏まえて、メンティーにネクストアクションを考えてもらいます。どのようなネクストアクションでもメンティーが考えたものであれば尊重したいと思っていますが、以下の点に注意しています。 実現が心理的に困難なものでないか 例:本を毎日 1 ページ読む ⇒「毎日 1 行読むという目標ですら、人によっては案外難しかったりすると思いますが、実現する自分を想像できますか」 抽象的過ぎる目標 例:ミスをしないように気をつける ⇒ 「抽象的すぎると行動しづらいので、状況を絞ったり、気をつけるための手順を書いたりしてほしいです」 もちろん抽象的である理由をメンティーが持っていれば OK 具体的すぎる目標 例:次の勉強会では率先して議事録をとる ⇒ 「抽象的にしてより広いネクストアクションにするのはどうですか」 あるいは、学びではあるけれど、メンティーがネクストアクションをうまく言語化できず書かなかったというケースもあります。その場合、書かないよりは行動が変わったらラッキーくらいの気持ちで「〇〇を気をつける」と書くこと、をよく提案します。 言語化しづらいネクストアクション 例:バグがありそうという直感が正しかったエピソードのネクストアクションを書かなかった ⇒ 「直感を大事にするくらいのふわっとしたネクストアクションでもよいので書いておくと、なにか行動が変わるかもしれませんよ」 全体的に、メンティーが出したネクストアクションの主旨が変わらないことは意識しています。 例:本を毎日 1 ページ読む 本ではなく勉強会に参加するのはどうですか?は主旨が変わってしまうので避けています。 1on1 の中でのやりとり 1on1 でよくありそうなシチュエーション別に具体的なやりとり例を示します。実際のやりとりではなく、説明しやすいようにそれらしい話題で自分が創作したものです。 また、最初に記載した通り 1on1 のやり方は千差万別なので、このやり方がよいかは人によると思います。 A:メンティー B:メンター お休みの取り方がわからない A「お休みの取り方がわかりません」 B「お休みはマネージャーにメンションして、カレンダーに登録してますよ。このドキュメントに書いてあります。このドキュメントはこのインデックスドキュメントから辿れるので覚えておいてください。」 典型的なティーチングのやりとりです。社内・チームルール、所属したてで調べ方すらわからない内容についてはティーチングをします。意識するのは、似たような状況になったとき、どうすればいいのか How も教えることです。 魚を与えるだけではなくて釣り方も教える、と自分は意識しています。 最近見積もりの精度が高くなっている A「最近見積もりの精度が高くなっていると感じています」 B「いいですね。具体的にはどんなタスクでそう感じましたか?」 A「そうですね... Aのプロジェクトを進めるときにガントチャートを組みました。中盤に差し掛かっても、スケジュール通りに進んでいるので、これは精度が高かったからだと思っています」 B「なるほど、これから懸念することはありますか」 A「進捗通りなのでないです。強いて言えば、今後の動作確認次第で修正箇所が多くなったときに、ガントチャートの引き直しが発生するかもしれません」 B「そう思ったのはコレまでにもそのようなことがあったからですか」 A「はい、前回のプロジェクトで動作確認後の修正が予想より多くて大変でした」 B(ちょっと黙ってみる) A「...なので、振り返りのときに動作確認を2回に分けるようネクストアクションを出したんでした」 B「よさそうですね。効果はありそうですか」 A「...それが、2回に分けるのを入れ忘れていました」 B「おぉ、そうですか」 A「忘れないように、ガントチャートのテンプレート作ってメモしておきます」 B「はい、お願いします。」 B「では、ちょっと視点を変えてもらいたくて、精度が高い見積もりをすることによるデメリットはありますか」 A「最近見積もりの精度が高くなっていると感じています」 B「いいですね。具体的にはどんなタスクでそう感じましたか?」 1on1 で褒めることは大事ですね。隙あらば褒めていきます。「いいですね」「よさそう」「考えたことなかったです」「チームの人たちきっと喜んでますよ」 具体的なエピソードを掘り下げていきます。 A「そうですね... A のプロジェクトを進めるときにガントチャートを組みました。中盤に差し掛かっても、スケジュール通りに進んでいるので、これは精度が高かったからだと思っています」 B「なるほど、これから懸念することはありますか」 少し別の視点を持ってもらうために、良かった話から懸念点へとベクトルを曲げます。さらに掘り下げるような質問も考えられます。「ここでの精度とは期間のことですか、あるいは見積もりのことですか」「ガントチャートはどうやって組みましたか」 A「進捗通りなのでないです。強いて言えば、今後の動作確認次第で修正箇所が多くなったときに、ガントチャートの引き直しが発生するかもしれません」 今回の例では A さんが「強いて言えば」で文章を続けてくれましたが、続けてくれなかったときはメンターから「強いて言えば?」と促すこともできます。 B「そう思ったのはコレまでにもそのようなことがあったからですか」 A「はい、前回のプロジェクトで動作確認後の修正が予想より多くて大変でした」 B(ちょっと黙ってみる) 少し黙ってみると続けて話してくれることはよくあります。もちろん、メンティーの目が泳いだりして考えているように見えるときも黙るのは有効です。ただし、沈黙に耐えられる長さは人それぞれなため、注意が必要です。自分は経験ないですが、相手が居心地の悪さを示したら「ごめんなさい、ちょっと考えてもらうために黙っていました」と素直に伝えるとよいかと思います。 A「...なので、振り返りのときに動作確認を 2 回に分けるようネクストアクションを出したんでした」 B「よさそうですね。効果はありそうですか」 A「...それが、2 回に分けるのを入れ忘れていました」 B「おぉ、そうですか」 1on1 の中でネクストアクションが実行されないことはよくあることです。責めてしまうと次から失敗の話はメンティー自身から出なくなってしまうかもしれません。事実を把握したというリアクションが大事だと思います。 A「忘れないように、ガントチャートのテンプレート作ってメモしておきます」 B「はい、お願いします。」 B「では、ちょっと視点を変えてもらいたくて、精度が高い見積もりをすることによるデメリットはありますか」 一見デメリットがないようなことに対してデメリットを考えてもらう質問を自分はよくします。ここからメンティー自身でも気づかない発見がよくあります。視野の広さを持ってもらいたいですね。 注意したいことは、この質問はメンターのスタンスを示しているわけではないことを理解してもらうことです。今回の例では、別にメンターは「精度の高い見積もりはよくない」と思っているわけではありません。あくまで視野を広げてもらうための 1on1 中の質問に過ぎません。 朝会の議事録をとるようにしたい A「毎日の朝会で内容を忘れてしまうことがあるので、議事録を取るようにしたいです」 B「いいかもしれませんね。でも今まで議事録を取っていなかったということは、他のメンバーはどうしていたと思いますか」 A「わからないです。朝会で話し合う内容は簡単なものなので、覚えてしまうのかもしれません」 B「このあたりはチーム全体で話し合えるとよさそうです。今度朝会で提案してみるのはどうですか」 これは 1on1 の中で業務の相談があったシチュエーションです。もちろん 1on1 では話しづらいことを気軽に話してもらえる場として活用するのも大切です。しかし、1on1 が業務の相談ばかりになってしまったら目的とずれてしまいます。適度に切り上げて、業務の相談はより適切な場が別であることを示してあげます。 最近チームの動きがぎこちないと感じている A「最近チームの動きがぎこちないと感じています」 B「ぎこちない?」 A「はい、朝会やミーティングが淡々と進みすぎているように思います」 B「それを他の人もそう感じているいないに関わらず、その感覚や気持ちは大事にしてもらいたいです」 B「ぎこちないということはAさんはポジティブ、ネガティブで言うとネガティブに感じているということですよね」 A「うーん、そうなんでしょうけど、一概に悪くないとも思っています。なぜなら、必要最低限の時間でミーティングが完了できて、それは本来目指すべき姿だと思うからです」 B「ほうほう。でもAさんは部分的には問題だと考えているわけですよね」 A「最近チームの動きがぎこちないと感じています」 B「ぎこちない?」 気になるキーワードを拾って話を促します。もちろん、他にもいろいろな返しが考えられます。 A「はい、朝会やミーティングが淡々と進みすぎているように思います」 B「それを他の人もそう感じているいないに関わらず、その感覚や気持ちは大事にしてもらいたいです」 これは 1on1 というよりも単純に自分が大事にしていることです。もしそうでないとしても、感情はあらゆる原動力なので注目することはいいことだと思います。 B「ぎこちないということは A さんはポジティブ・ネガティブで言うとネガティブに感じているということですよね」 A「うーん、そうなんでしょうけど、一概に悪くないとも思っています。なぜなら、必要最低限の時間でミーティングが完了できて、それは本来目指すべき姿だと思うからです」 B「ふむふむ。でも A さんは部分的には問題だと考えているわけですよね」 感情をポジティブとネガティブに分類してもらう質問も自分はよくやります。感情は複雑なので、自分が誤った想定で質問を続けてしまうことを防止できます。また、感情にゆっくり向き合う時間というのは日頃の生活でないことなので、ぜひメンティーにもいろいろ考えてもらいたいですね。 1on1 定期的な振り返り 3 ヶ月ごとくらいにメンターとメンティーの 2 人で簡単な 1on1 の振り返りをします。以下のようなことを話し合います。 1on1 の頻度は今のままでいいか 1on1 のフォーマットは今のままでいいか よりメンターにこうしてもらいたいという提案はあるか メンティーのキャリアのすり合わせ 1on1 の中ではキャリアについても考えるきっかけを作っています。メンティーの成長にあわせて、より技術志向な 1on1 にしたいなどないか、すり合わせをします。メンターがメンティーにタスクを割り振る立場であれば、目指すキャリアにあわせてタスクに挑戦させることができます。 まとめ ずいぶんと長くなってしまいました。これから新年度を向かえて、1on1 を任される方がいらっしゃるかもしれません。1on1 の内容は前述した通り千差万別だと思いますが、参考にしていただけたら幸いです。 1on1 はメンターとメンティーのための時間 1on1 の初回でそれを伝える 1on1 のフォーマットをお互いに考えて成長させる 1on1 のやりとりに答えはない 定期的に振り返りをする
アバター
こんにちは、ブロックチェーンチームの id:charines です。 今回は ERC-721 コントラクト(NFT コントラクト)にメタトランザクションを導入した開発事例について紹介します。 主にブロックチェーンに関する開発者の方を対象とした内容になります。 メタトランザクションの導入理由 1. マーケットプレイスのユーザが NFT を出品しやすい 2. NFT クリエイターがコントラクトを管理しやすい 実装方針 実装 フォワーダー ERC-721 まとめ メタトランザクションの導入理由 メタトランザクションとは、トランザクションの実行に必要なガス代を実行者ではない第三者が払うシステムです。 これによりトランザクション実行者は ETH を保持する必要がなくなり、 DApps を利用する障壁の 1 つを取り除くことができます。 弊チームでメタトランザクションを導入したい具体的な目的は主に 2 つです。 1. マーケットプレイスのユーザが NFT を出品しやすい 弊チームが提供していた NFT マーケットプレイス「ユニマ」では、ユーザが NFT を出品する機能は設けていませんでしたが、新機能として所有する NFT の二次販売を可能にする予定でした。 ブロックチェーンに詳しくないユーザにも使いやすいというのがユニマのコンセプトの 1 つでもあるため、出品のハードルを下げるためにガス代の肩代わりは必須でした。 2. NFT クリエイターがコントラクトを管理しやすい マーケットプレイスのユーザと同様に、NFT を作成・販売するクリエイターも必ずしもブロックチェーンに詳しいわけではありません。 ERC-721 コントラクトには mint や pause 、メタデータ URI の変更などコントラクト管理のための関数を実装していますが、それらの管理をクリエイターがガス代なしで行えるようにすることも、販売のハードルを下げるために重要でした。 実装方針 メタトランザクションの実装方針として大きく 2 つの方針を検討しました。 1 つ目は機能ごとにメタトランザクション用の関数を実装することです。 例えば ERC-2612 は FT である ERC-20 において approve 関数のメタトランザクションを可能にする permit 関数を定義しています。 approve は自身の持つ FT を移動させる許可を与える関数ですから、 transferFrom など特定のアカウントの FT を移動させる関数を組み合わせることで transfer のメタトランザクションと同等のことを可能にします。 ERC-721 においても approve が存在するので同じ仕組みの実装が可能です。 しかしこの方法では、複数の機能についてメタトランザクションを可能にしようとすると、それぞれに対応する関数の実装が必要になります。 そこで 2 つ目の方針として、 ERC-2771 に基づいたメタトランザクションの仕組みの実装を検討しました。 ERC-2771 はフォワーダーと呼ばれる信頼できるコントラクトが署名を検証し、受信者(今回は ERC-721 コントラクト)はフォワーダーからのメタトランザクションを受け入れるというプロトコルです。 この方法では機能ごとに新しい関数を実装する必要がない上、 Gas Station Network (GSN) で既に広く利用されている実績もあったため、今回はこの方法を採用することにしました。 実装 フォワーダー フォワーダーは前述の通り、署名を検証し受信者にリクエストを転送するコントラクトですが、この部分には独自実装が必要ないため新規実装は行わずに既存のフォワーダーを利用する方針としました。 具体的には GSN で Ethereum と Polygon チェーン上に展開されているコントラクトを使用します。これらの実装は Etherscan で見られるため、信頼に足る実装であることも確認できます。 ERC-721 GSN のフォワーダーを利用するのに合わせて、こちらも GSN の実装 を利用します。 BaseRelayRecipient でメタトランザクションに必要な _msgData() と _msgSender() が実装されているのでこれを継承します。 import "@opengsn/contracts/src/BaseRelayRecipient.sol" ; また弊チームで開発しているコントラクトは元々 OpenZeppelin をベースにしています。 こちらにも _msgData() と _msgSender() を含む Context の継承が含まれており多重継承となるため、override を明記する必要があります。 function _msgData() internal view override(ContextUpgradeable, BaseRelayRecipient) returns ( bytes calldata ret) { return BaseRelayRecipient._msgData(); } function _msgSender() internal view override(ContextUpgradeable, BaseRelayRecipient) returns ( address ret) { return BaseRelayRecipient._msgSender(); } 最後に、メタトランザクションを行うには信頼できるフォワーダーを事前に知っておく必要があるため、コンストラクタで _setTrustedForwarder() を呼び出すようにすれば実装は完了です。 まとめ ERC-721 コントラクトにメタトランザクションを導入する上での技術選定や実装の流れについて紹介しました。 特に ERC-2771 によって任意の関数をメタトランザクションとして実行可能にした GSN の実装や展開済みのコントラクトを利用することで工数を抑えて開発できた の 2 つが今回のポイントです。 弊社は 4 月 1 日を以ってブロックチェーン事業を撤退することとなりましたが、この記事やこれまでの発信が、今後ブロックチェーン技術を活用する開発者の方々の助けになれば幸いです。
アバター
みなさん、こんにちは。新卒エンジニアの id:matsuda0528 です。 今日は、Mapbox GL JS を使用して地図の描画領域を変更するアニメーションを実装する方法についてお話します。 TL;DR 以下のように、 setInterval() 関数を用いて resize() 関数を繰り返し実行する方法で実装しました。 const onClickMapResizeButton = () => { clearInterval(mapResizer) mapResizer = setInterval(() => { map.value.resize() } ) } const onTransitionend = () => { clearInterval(mapResizer) } 駅メモの地図について 駅メモでは、Mapbox GL JS を使用していくつかの地図表示機能を実装しています。 最近ではスタンプラリーイベントで新たに地図表示機能が追加されました。 今回は、スタンプラリーイベントで新しく実装した 地図の大きさを変更するアニメーション について解説します。 地図のサイズ変更アニメーション 下記のような実装を用意します。 その上で <div class="map"> の大きさを変更する処理を追加すれば、地図のサイズ変更アニメーションを実装することが可能です。 < div class = "map" > <!-- 地図を描画するコンポーネント --> < v- map ... /> </ div > .map { transition : height 0.5s ease ; } しかし、Mapbox GL JS の地図は描画領域に関する情報を保持していて、CSS アニメーションだけでは地図本体が追従しません。 実際に行ってみると、以下の図のように地図を囲む要素の大きさは変わるものの、地図自体の描画領域は変化しません。 サイズ変更前、サイズ変更後の地図 地図の描画領域も同時に動かすためには、 resize() 関数を用いて随時地図領域を更新する必要があります。 今回は、地図の外枠に対して transition でアニメーションを行いつつ、その間中に resize() を実行し続けることで対応しました。 以下に Vue3 での実装例を挙げています: <template> <main> <div class = "map" : class = "mapSize" @transitionend= "onTransitionend" > <v-map ref= "map" ... /> </div> <v-button @click= "onClickMapResizeButton" >サイズ変更</v-button> </main> </template> <script setup> import { ref } from 'vue' ; const map = ref( null ); const mapSize = ref( 'map-size-normal' ); const mapResizer; const onClickMapResizeButton = () => { mapSize.value = 'map-size-large' ; mapResizer = setInterval(() => { map.value.resize(); } ); } ; const onTransitionend = () => { clearInterval(mapResizer); } ; </script> <style> .map { transition: height 0.5s ease; } .map-size-normal { height: 50px; } .map-size-large { height: 100px; } </style> clearInterval に届かない可能性を考える この実装の懸念点は、 setInterval() から clearInterval() までに別のイベント( onTransitionend )を経由するため、 clearInterval() が必ず実行される保証がないことです。 たとえば、一度「サイズ変更ボタン」を押した後、 transition の途中で「サイズ変更ボタン」をもう一度押してしまうと、 onTransitionend が発火しないまま新しく setInterval() が実行されてしまいます。 これによって、最初の setInterval() の intervalID が失われてしまい、インターバルの処理が終わらなくなってしまう可能性があります。 この問題への対応策として、transition が中断する可能性のあるアクションの前に clearInterval() を実行します。 const onClickMapResizeButton = () => { mapSize.value = "map-size-large" clearInterval(mapResizer) mapResizer = setInterval(() => { map.value.resize() } ) } まとめ 今回紹介した方法では、 setInterval() を使用して resize() 関数を繰り返し実行することで、地図のサイズ変更アニメーションを実現しました。 ただし、この方法では clearInterval() が常に適切に実行される保証がないため、使う際には注意が必要です。 参考サイト Mapbox GL JS | Mapbox. ( https://docs.mapbox.com/mapbox-gl-js ) javascript - MapBox Smooth Transition of Resizing Map - Stack Overflow. ( https://stackoverflow.com/questions/61490901/mapbox-smooth-transition-of-resizing-map )
アバター
駅メモ!チームエンジニアの id:yumlonne です。 この記事では Redis の sorted sets で実装していたランキング処理を MySQL に移行した仕組みを紹介します。 背景 駅メモ!には複数のランキングがあり、Redis の sorted sets を使うことでパフォーマンスの高いランキング処理を実現していました。 中にはリリースからの全期間に渡るデータを利用するランキングもあり、Redis のメモリ使用率は日に日に増えていく一方でした。 何度か Redis をスケールアップしてメモリを増やすことで対応していましたが、根本的に対応しなければ今後も Redis をスケールアップもしくはスケールアウトさせ続けるしか選択肢がなく、コストが増え続けてしまう状況でした。 調査したところ、一部のランキングがメモリ使用率の 2/3 程度を占めていることが判明しました。 そこで、その巨大なランキングを Redis から MySQL に移行させることを考えました。 Redis とデータベースの詳細 Redis: Amazon ElastiCache Redis 7.x データベース: Amazon RDS Aurora v2 (MySQL 5.7 InnoDB) ランキングについて 以下は対象となるランキングの要件と仕様です。 要件 1. 上位数件のユーザとそのスコアを取得できる 要件 2. ユーザは自分自身の順位を取得できる 要件 3. 順位は同率を考慮する(スコア 100,90,90,80 とある場合、順位は 1,2,2,4 となる) 要件 4. 上記の処理をそれぞれ 10ms 程度で実行できる 仕様 1. 古いデータを削除することはない 仕様 2. スコアの更新は増加のみで、減少させるような更新はない また、ランキングのスコアにはかなりの偏りがあります。 以下は偏りのイメージです。 縦軸はユーザ数ですが偏りが酷いので対数目盛にしています。 横軸はスコアです。イメージなので数値は表示していません。 MySQL でのランキング処理の課題 「要件 1. 上位数件のユーザとそのスコアを取得できる」 はスコアにインデックスを張っておけば 以下のように指定するだけで高速に取得できるため問題ありません。 SELECT * FROM ranking ORDER BY score DESC LIMIT 10 ; 問題は 「要件 2. ユーザは自分自身の順位を取得できる」 で、こちらはインデックスだけでは解決できません。ユーザの順位を求めるためには、そのユーザのスコアより高いスコアのユーザ数を知る必要があります。 下記 SQL にて自分よりスコアが高いユーザ数を取得できますが、スコアが低いユーザの場合、カウント対象行が多くなるためインデックスを有効活用しづらくなります。 SELECT COUNT (*) FROM ranking WHERE score > 50 ; この問題を回避するためにカウント対象行を絞る対応を考えました。 ランキングを区切る 順位を出すときにカウント対象行が多くなりうる問題を解消するため、ランキングを区切ることを考えてみます。 例えば、事前に「1000 位のユーザーのスコアが 500」だと集計できているとします。 スコアが 450 のユーザーの順位は、「スコアが 450 より大きく 500 以下」を満たすレコード数に 1000 (位)を加えることで求められます。 順位 スコア 1000 500 2000 340 3000 250 4000 210 ... ... 上の表のように順位を等間隔で区切ることにより、いかなる順位でもカウント対象をほぼ一定に保つことができ、パフォーマンスが安定します。 逆に、スコアを等間隔で区切るとランキングデータの偏りの影響でカウント対象がばらつくため不採用としました。 説明のため、以降はランキングを区切るデータのことをランキングインデックスと表記します。 サンプルスキーマ CREATE TABLE `ranking` ( `id` bigint( 20 ) unsigned NOT NULL AUTO_INCREMENT, `user_id` int ( 10 ) unsigned NOT NULL , `score` bigint( 20 ) unsigned NOT NULL , PRIMARY KEY (`id`), UNIQUE KEY `user_uniq` (`user_id`), KEY `score_idx` (`score`) ) CREATE TABLE `ranking_index` ( `id` bigint( 20 ) unsigned NOT NULL AUTO_INCREMENT, ` rank ` bigint( 20 ) unsigned NOT NULL , `score` bigint( 20 ) unsigned NOT NULL , PRIMARY KEY (`id`), KEY `rank_idx` (` rank `), KEY `score_idx` (`score`) ) 順位取得処理のサンプルコード 順位取得処理のサンプルコードです。シンプルなコードなので Perl がわからない方でも流れを理解できると思います。 sub get_rank_by_score { # rankを算出するスコアを受け取る my ( $score ) = @_ ; # $scoreに近いranking_indexを探す my $ranking_index = get_ranking_index_by_score( $score ); if ( defined $ranking_index ) { if ( $ranking_index->{ score } == $score ) { # ranking_indexのスコアがrankを求めたいスコアと同じだった場合はcountするまでもなく順位がわかる return $ranking_index->{ rank } ; } # ranking_indexのscoreと$scoreの間に存在するレコードをカウント my $cnt = exec_sql( "SELECT COUNT(*) AS cnt FROM ranking WHERE $score < score AND score <= $ranking_index->{ score } " )->{cnt}; return $ranking_index->{ rank } + $cnt ; } else { # ランキング上位の場合はranking_indexが存在しない # この場合$scoreより高いスコアを持つレコードが少ないので素直にカウントする # 順位は1からスタートするので+1して返す my $cnt = exec_sql( "SELECT COUNT(*) AS cnt FROM ranking WHERE $score < score" )->{cnt}; return $cnt + 1 ; } } sub get_ranking_index_by_score { my ( $score ) = @_ ; # 与えられた$score以上のscoreのうち最も$scoreに近いレコードを取得する return exec_sql( "SELECT * FROM ranking_index WHERE score >= $score ORDER BY score ASC LIMIT 1" ); } sub exec_sql { # SQLを受けとって実行結果を返す # サンプルコードをシンプルにする便宜上の関数 # 本来はSQLインジェクション対策をすべきだが単純化のため省略 } ランキングインデックスの管理 ランキングを区切ることによって順位計算時のカウント対象を減らし、パフォーマンスを向上させることができます。 しかし、ランキングを区切ったことで新たにランキングインデックスを管理する必要が出てきます。これを適切に更新しなければ、ユーザに誤った順位を返してしまうことになります。 更新 ランキングインデックスは以下の条件に当てはまるものを更新する必要があります。 更新前スコア(新規作成の場合は0) <= ランキングインデックスのスコア < 更新後スコア 例えば以下のようなランキングインデックスがあったとします。 順位 スコア 1000 500 2000 340 3000 250 4000 210 ... ... とあるユーザのスコアを 250 から 500 に更新する場合、ランキングインデックスは以下のように更新します。 順位 スコア 備考 1000 500 同率順位のデータが増えただけなので影響しない 2000 + 1 340 スコアが 340 を超えるレコードが増えたので順位が下がる 3000 + 1 250 スコアが 250 を超えるレコードが増えたので順位が下がる 4000 210 スコア 210 より上のデータが動いただけなので影響しない ... ... 順位を +1 するのがポイントです。 スコアは更新しないので他のトランザクションの更新対象に影響を与えず、順位は 順位 = 順位 + 1 のようにすれば更新前の状態でロックを取っておく必要はありません。 これによりランキングインデックスのロック時間を最小限に抑えることができるようになります。 順位取得処理のサンプルコード sub update_score { # 更新するユーザとそのスコアを受け取る my ( $user_id , $score ) = @_ ; with_transaction( sub { my $user_ranking = exec_sql( "SELECT * FROM ranking WHERE user_id = $user_id FOR UPDATE" ); my $before_score ; if ( defined $user_ranking ) { # すでにuser_idに対応するrankingレコードがある場合は更新 # 更新前スコアを保持しておき、ranking_indexの更新範囲決定に使う exec_sql( "UPDATE ranking SET score = $score WHERE id = $user_ranking->{ id } " ); $before_score = $user_ranking->{ score } ; } else { # rankingレコードがない場合はレコードを作る # 更新前スコアは0とすることで$score未満の全てのranking_indexを更新対象にする exec_sql( "INSERT INTO ranking(user_id, score) VALUES ( $user_id , $score )" ); $before_score = 0 ; } # スコアの更新幅に含まれるranking_indexを更新する increase_ranking_index( $before_score , $score ); }); return ; } sub increase_ranking_index { my ( $before_score , $after_score ) = @_ ; exec_sql( "UPDATE ranking_index SET rank = rank + 1 WHERE $before_score <= score AND score < $after_score " ); return ; } sub exec_sql { # SQLを受けとって実行結果を返す # サンプルコードをシンプルにする便宜上の関数 # 本来はSQLインジェクション対策をすべきだが単純化のため省略 } sub with_transaction { # 与えられたコードブロックをDBのトランザクション内で実行する関数 } 定期実行処理 ランキング更新によってランキングインデックスの順位がずれていってしまうため、間隔が一定に保てなくなり、徐々にパフォーマンスが劣化していってしまいます。 これを防ぐため、ランキングインデックスが順位で等間隔になるよう定期的に調整する必要があります。 上記のランキング更新の都合で、ランキングインデックスのスコアは更新できないため、新しいランキングインデックスを挿入し古いランキングインデックスを削除する実装にしました。 これによりランキングの更新や順位取得に影響を与えずにランキングインデックスの間隔調整ができます。 駅メモ!ではランキングインデックスの間隔調整処理を毎日実行しています。ランキングの更新頻度やスコア分布などの特性によってパフォーマンス劣化のスピードは変わるため、適切な頻度を見極めて実行する必要があります。 テーブルのメンテナンス 定期実行スクリプトではレコードの作成と削除をしているため、ランキングインデックステーブルが断片化してしまいます。 サービスのメンテナンス時にランキングインデックステーブルに対して OPTIMIZE TABLE を実行することで対応しています。 まとめ Redis の sorted sets で実装していたランキングを MySQL に移行するため、データベースでランキング処理をするようにしてみました。 その結果、Redis のメモリ使用量を元の 1/3 程度まで減らすことができ、Amazon ElastiCache Redis のスペックを下げることができました! Redis 内で大きくなり続けるランキングにお困りの際はデータベースに処理を移すことを検討してみてはいかがでしょうか。
アバター
皆さんこんにちは、最近ずっとポットのお湯を沸かし続けないと寒くて耐えられないエンジニアの id:Dozi0116 です。 今回は、 dayjs で相対時間を求める方法、自由自在に操る方法を紹介します。 TL; DR 以下は今日紹介する出力をいじるための設定と、利用例です。 import dayjs from "dayjs" import relativeTime from "dayjs/plugin/relativeTime.js" import updateLocale from "dayjs/plugin/updateLocale.js" import "dayjs/locale/ja.js" // 基準になる時刻を調整 // l: relativeTimeで使うkey // r: dで判定した時、この値以下までがこの閾値になる // d: 判定に利用する時間単位、省略した場合は直前の判定に用いた単位 const relativeTimeConfig = { thresholds: [ { l: "s" , r: 59, d: "second" } , // 0〜59秒 { l: "m" , r: 1 } , // 1分表示用: 単数用の処理があるため、これを書かないと1分が1秒と表示されてしまう { l: "m" , r: 59, d: "minute" } , // 1分〜59分 { l: "mm" , r: 60 * 24 - 1 } , // 1時間〜23時間59分 { l: "d" , r: 1 } , // 1日 { l: "d" , d: "day" } , // 2日〜 ] , rounding: Math.floor, // 閾値判定時に用いる丸め関数 } // 日本語で扱えるように dayjs.locale( "ja" ) // プラグインを利用する dayjs.extend(updateLocale) dayjs.extend(relativeTime, relativeTimeConfig) // 相対時間の表示ルール設定 dayjs.updateLocale( "ja" , { relativeTime: { future: "%s後" , past: "%s前" , s: "数秒" , // %d を時間に置換して表示。%dがなくてもOK m: "%d分" , mm: (abs) => { // 関数でカスタマイズも可能 if (abs % 60 === 0) { return ` ${abs / 60} 時間` } return ` ${Math.floor(abs / 60)} 時間 ${abs % 60} 分` } , d: "%d日" , } , } ) //////////////////// const baseTime = dayjs( "2021-01-01 00:00:00" ) const targetTime1 = dayjs( "2021-01-01 00:00:01" ) const targetTime2 = dayjs( "2021-01-01 00:01:00" ) const targetTime3 = dayjs( "2021-01-01 00:59:59" ) const targetTime4 = dayjs( "2021-01-01 01:00:00" ) const targetTime5 = dayjs( "2021-01-01 01:20:30" ) const targetTime6 = dayjs( "2021-01-02 00:00:00" ) const targetTime7 = dayjs( "2021-02-01 00:00:00" ) console.log(targetTime1.from(baseTime)) // 数秒後 console.log(targetTime2.from(baseTime)) // 1分後 console.log(targetTime3.from(baseTime)) // 59分後 console.log(targetTime4.from(baseTime)) // 1時間後 console.log(targetTime5.from(baseTime)) // 1時間20分後 console.log(targetTime6.from(baseTime)) // 1日後 console.log(targetTime7.from(baseTime)) // 31日後 動作環境 今日紹介するコードは node v20.1.0 dayjs v1.11.0 で動作確認をしています。 dayjs とは https://day.js.org/ dayjs とは、 Moment.js という非推奨になってしまった時刻を扱うパッケージと同じインターフェースを備えているかつ、Moment.js より軽い構造になっていることが特徴のパッケージです。 以下のように書くことで、簡単に時刻を用意して扱うことが可能です。 import dayjs from "dayjs" const now = dayjs() console.log(now.format( "YYYY-MM-DD(ddd)" )) // -> 2023-12-22(Fri) (今日の日付) (オプション) dayjs で日本語表示をする dayjs は言語のデフォルトが英語になっているため、相対時間を表示しようとすると英語で表示されてしまいます。 今回の記事では日本語で相対時間を操るため、日本語のセットアップをしておきます。 import dayjs from "dayjs" import "dayjs/locale/ja.js" // 表示言語を日本語に設定 dayjs.locale( "ja" ) const time = dayjs( "2023-12-01" ) console.log(time.format( "ddd" )) // 金 以降この記事では特に書かれていないところでも locale を ja に設定して進めていきます。 dayjs で相対時間を扱うための準備 relativeTime プラグインの準備 dayjs は必要最低限の機能のみの実装でコードを小さくしているため、 dayjs の import だけでは相対時間を求められません。 しかし、公式がパッケージと共に提供しているプラグイン relativeTime を import することによって相対時間を扱えるようになります。 import dayjs from "dayjs" import relativeTime from "dayjs/plugin/relativeTime.js" dayjs.extend(relativeTime) 実際に時刻を計算する場合は to もしくは from というメソッドを使うだけです。 const baseTime = dayjs( "2023-12-01" ) const targetTime = dayjs( "2023-12-05" ) console.log(targetTime.from(baseTime)) // -> 4日後 fromNow や toNow というメソッドを使えば現在時刻からの相対時間を求められます。 const time = dayjs( "2023-12-01" ) console.log(time.fromNow()) // -> 21日前 (今日の日付依存) console.log(time.toNow()) // -> 21日後 (今日の日付依存) おめでとうございます!…? これだけで相対時間を扱えるようになりました! 他の日付も試してみましょう。 const baseTime = dayjs( "2023-12-01 12:00:00" ) const targetTime1 = dayjs( "2023-12-02 09:00:00" ) const targetTime2 = dayjs( "2023-12-02 10:00:00" ) console.log(targetTime1.from(baseTime)) // -> 21時間後 console.log(targetTime2.from(baseTime)) // -> 1日後 21 時間を境に、1 日前という判定になってしまいました。 厳密に判定するには 実は dayjs の relativeTime プラグインは結構アバウトな時間管理を行なっています。 実装ロジック を実際に見てみると、このような判定になっていることがわかります。 d: 判定に利用する時間単位 r: dで判定した時、この値以下までがこの閾値になる 範囲 表示 〜44 秒 n 秒 45〜89 秒 1 分 90 秒〜44 分 n 分 45 分〜89 分 1 時間 90 分〜21 時間 n 時間 22 時間〜35 時間 1 日 36 時間〜25 日 n 日 26 日〜45 日 1 ヶ月 46 日〜10 ヶ月 n ヶ月 11 ヶ月〜17 ヶ月 1 年 18 ヶ月〜 n 年 そのため、先ほどの例では 21 時間後と 22 時間後で表示が異なってしまったのでした。 これを厳密に扱いたい場合は公式が example を出してくれているように設定する必要があります。 https://day.js.org/docs/en/customization/relative-time#relative-time-thresholds-and-rounding import dayjs from "dayjs" import relativeTime from "dayjs/plugin/relativeTime.js" // from: https://day.js.org/docs/en/customization/relative-time#relative-time-thresholds-and-rounding const thresholds = [ { l: "s" , r: 59, d: "second" } , // ここだけ微調整: r: 59 / d: 'second' としないと秒周りがおかしくなるので注意! { l: "m" , r: 1 } , { l: "mm" , r: 59, d: "minute" } , { l: "h" , r: 1 } , { l: "hh" , r: 23, d: "hour" } , { l: "d" , r: 1 } , { l: "dd" , r: 29, d: "day" } , { l: "M" , r: 1 } , { l: "MM" , r: 11, d: "month" } , { l: "y" , r: 1 } , { l: "yy" , d: "year" } , ] dayjs.extend(relativeTime, { thresholds } ) const baseTime = dayjs( "2023-12-01 12:00:00" ) const targetTime1 = dayjs( "2023-12-02 09:00:00" ) const targetTime2 = dayjs( "2023-12-02 10:00:00" ) console.log(targetTime1.from(baseTime)) // -> 21時間後 console.log(targetTime2.from(baseTime)) // -> 22時間後 表示部分より細かいところも厳密にする もっと細かい表示を見てみます。 const baseTime = dayjs( "2023-12-02 10:00:00" ) const targetTime1 = dayjs( "2023-12-02 14:00:00" ) const targetTime2 = dayjs( "2023-12-02 14:29:59" ) const targetTime3 = dayjs( "2023-12-02 14:30:00" ) console.log(targetTime1.from(baseTime)) // -> 4時間後 console.log(targetTime2.from(baseTime)) // -> 4時間後 console.log(targetTime3.from(baseTime)) // -> 5時間後 4 時間 30 分を境に 4 時間後 → 5 時間後という切り替わりが起こっています。 これはデフォルトの diff を求めるロジックが Math.round であることに由来しており、4.5 時間の diff が丸められて 5 時間になっているためです。 これを解消するのも config が活躍してくれます。 config の rounding という項目に、判定に用いる関数を渡してあげることで、丸め方を指示できます。 今回は切り捨てである Math.floor を指定しました。 // 小数切り捨てのdiffで計算する dayjs.extend(relativeTime, { rounding: Math.floor } ) const baseTime = dayjs( "2023-12-02 10:00:00" ) const targetTime1 = dayjs( "2023-12-02 14:00:00" ) const targetTime2 = dayjs( "2023-12-02 14:29:59" ) const targetTime3 = dayjs( "2023-12-02 14:30:00" ) console.log(targetTime1.from(baseTime)) // -> 4時間後 console.log(targetTime2.from(baseTime)) // -> 4時間後 console.log(targetTime3.from(baseTime)) // -> 4時間後 r: 1 の意味 先ほど参考にした thresholds では、 r: 1 という設定がありました。 これは、他の言語用などに用意されている単数系の表示をするために用意されています。 判定ロジック を見てみると、「diff が 1 以下の場合、index が 1 つ手前の閾値を採用する」という処理になっています。 そのため単数系の区別がない日本語などでも、 r: 1 という設定がないと 1 つ前の設定、つまり 1 分を出すはずが 1 秒という表示になってしまうので注意してください。 カスタマイズしたい! ここまで来ると、閾値を自由に操って表示を切り替えられるようになったはずです。しかしこのままではまだ完全に操れるようになったとは言えないでしょう。 なぜならこのプラグインの力だけでは、表示形式を変えることはできないからです。 const baseTime = dayjs( "2023-12-01 12:00:00" ) const targetTime = dayjs( "2023-12-01 13:30:00" ) // 1時間後や2時間後だったり、90分後だったりはできるけど 1時間30分後という表示にできない! console.log(targetTime.from(baseTime)) これを叶えてくれるのが updateLocale プラグインです。(リンク先は relativeTime ですが、こちらの方に細かい使い方が載っています) これを使うと、求めた閾値に応じた出力をカスタマイズできます。 import dayjs from "dayjs" import relativeTime from "dayjs/plugin/relativeTime.js" import updateLocale from "dayjs/plugin/updateLocale.js" import "dayjs/locale/ja.js" const thresholds = [ { l: "s" , r: 59, d: "second" } , // ここは r: 59 / d: 'second' としないと秒周りがおかしくなるので注意! { l: "m" , r: 1 } , { l: "mm" , r: 59, d: "minute" } , { l: "mmm" , r: 60 * 24 - 1 } , // n時間を廃止して、1439分まで見れるようにした { l: "d" , r: 1 } , { l: "dd" , r: 29, d: "day" } , { l: "M" , r: 1 } , { l: "MM" , r: 11, d: "month" } , { l: "y" , r: 1 } , { l: "yy" , d: "year" } , ] dayjs.locale( "ja" ) dayjs.extend(updateLocale) dayjs.extend(relativeTime, { thresholds, rounding: Math.floor } ) // 出力のカスタマイズ // カスタマイズしないところも書く必要がある dayjs.updateLocale( "ja" , { relativeTime: { // 未来の場合、過去の場合につける文言のカスタマイズ future: "%s後" , past: "%s前" , // 時間出力のカスタマイズ // thresholdsで指定した `l` の値に応じた出力をする s: "数十秒" , // 1分未満は全部数十秒と表示させる m: "%d分" , mm: "%d分" , mmm: (abs) => { // 関数で指定することも可能。第一引数にはdiffの値がそのまま来る if (abs % 60 === 0) { return ` ${abs / 60} 時間` } return ` ${Math.floor(abs / 60)} 時間 ${abs % 60} 分` } , d: "%d日" , dd: "%d日" , M: "%dヶ月" , MM: "%dヶ月" , y: "%d年" , yy: "%d年" , } , } ) updateLocale プラグインは、 s m などの thresholds で設定した l の値ごとに、出力する内容を設定できます。 その時に string を設定すれば、 %d -> diff に変換したものが、function を設定すれば diff を受け取ってカスタマイズした返り値を出力することができます。 このように指定することで、出力のカスタマイズも可能になりました。 const baseTime = dayjs( "2023-12-01 12:00:00" ) const targetTime1 = dayjs( "2023-12-01 12:00:30" ) const targetTime2 = dayjs( "2023-12-01 13:30:00" ) console.log(targetTime1.from(baseTime)) // 数十秒後 console.log(targetTime2.from(baseTime)) // 1時間30分後 まとめ だいぶ長くなってしまいましたが、 dayjs というライブラリと、それを用いた相対時刻の操作について relativeTime プラグインを用いて相対時刻の判定ができる 厳密に判定したい場合は config の thresholds と rounding を調整 単数系の処理に注意 updateLocale プラグインを用いて相対時刻の表示方法をカスタマイズできる string を渡して簡易テンプレートを作ったり、関数を渡してより細かい制御をしたりできる ことを紹介しました。 この記事が誰かの助けになれば幸いです。みなさまもよき時刻判定ライフを〜 参考にしたサイト とても参考になりました、ありがとうございました! Day.js で相対日時を厳密に表示する(thresholds) https://zenn.dev/catnose99/articles/ba540f5c233847 dayjs - RelativeTime https://day.js.org/docs/en/plugin/relative-time dayjs - Relative Time https://day.js.org/docs/en/customization/relative-time GitHub - dayjs/src/plugin/relativeTime/index.js https://github.com/iamkun/dayjs/blob/f2e479006a9a49bc0917f8620101d40ac645f7f2/src/plugin/relativeTime/index.js
アバター
こんにちは!ブロックチェーンチームでエンジニアをしている id:dorapon2000 です。寒暖ある中でインフルも流行っているようで、私も咳がなかなか収まらず困っています。皆様におかれましても体調にはお気をつけください。 今回はタイトルの通り「緊急度が低く重要度が高く、そして重いプロジェクト」を私が担当した際に感じたことや学んだことをまとめた記事になります。前半で私の担当したプロジェクトの経過について感じたことをお話して、後半で学びをまとめます。 第二領域 重い第二領域の問題 私の場合 初期 中期 後期 重い第二領域プロジェクトを通しての学び まとめ 第二領域 「緊急度が低いが重要度が高いタスク」では記述にあたり少し長いので、ここでは第二領域 1 のタスクと呼ぶことにします。今すぐではないがいつかは必ずやらねばならないタスク。例えば OS やライブラリのアップデート、開発負債の解消などが第二領域のタスクとして考えられます。緊急度が低いこともあって後回しにされがちという傾向があり、みなさんも心当たりが大いにありますよね。 では、どうやって第二領域のタスクに着手するのか。第二領域のタスクは必ず実施しないとプロダクトに致命的な影響を与えます。重要度が高いとはそういうことです。そのため、後回しになりすぎないように、全体のスケジュールに合わせて事前に期日を決めるという対策は定番です。 重い第二領域の問題 軽い第二領域のタスクは着手が一番の問題で、着手したあとは完了を待つのみです。一方で、重い第二領域のタスク(プロジェクト)は着手してからも問題がおきます。自分が考える問題をここにあげておきましょう。 単純に重いタスクであるため、重いタスクにまつわる問題を引き継ぐ 学習時間、調査、検証、動作確認、反映をする時間を見積もりに含め忘れる 単純に見積もりが難しい もうお手上げだと思われる状況に何度もあたるとメンタルがすり減る 長期に渡るプロジェクトだと、モチベーションを保ち続けられないことがある 属人化 長期に渡る第二領域のプロジェクトは、差し込まれる緊急度の高いタスクに優先度を何度も譲らなくてはいけない スケジュールが立てづらい 再着手するときに過去のことを忘れている これらを踏まえて私の担当したプロジェクトについてお聞きください。 私の場合 私が前のチームにいたときの話です。プロダクトで運用しているサーバの OS のアップデートをするというプロジェクト担当になりました。というよりも、自分がやってみたいと手をあげました。これがまさに重い第二領域のプロジェクトになります。主な担当は自分で、サブで当時の上司にサポートをしていただきました。自分は難しくてもチャレンジさせてもらえるならチャレンジしたい性格なので、大変だとはわかりつつ、楽しみにもしていました。 初期 ほかチームでも OS のアップデートプロジェクトはすでに進行しており、知見を共有してもらいながらの進行でした。まず自分のチームではどのような作業が必要か洗い出し、誰と協力しなくてはいけないのか、どれくらい工数がかかりそうか、計画を立てました。この計画は今振り返るとまったく未熟で、全体の作業の 2 割程度しか網羅できていなかった気がします。計画を考えるために必要な AWS の知識が足りていなかったため、サーバを安全にアップデートするための具体的な手順と OS アップデートに伴う広い影響範囲をイメージできていませんでした。 不十分な計画ながらも、とりあえず最初にすべき検証や準備を少しずつ進めていました。 しかし、途中でサポート担当の上司がチーム異動をすることになりました。もちろん他チームのヘルプは借りつつも、これ以降はチーム内で自分だけがプロジェクト担当になります。1 人での対応に不安はありつつ、ヘルプを出すのは得意な方なので、とにかく自分がやり切るしかないという気持ちでした。 中期 自分以外のチームメンバーもそれぞれ異なるプロジェクトを持っており、それらは OS のアップデートよりも優先度が高かったです。そのため、ユーザーさんからのお問い合わせ調査やバグの修正など突発的なタスクは、第二領域のプロジェクトを進行していた自分がよく持っていました。また、ほかプロジェクトが遅延していたときに、自分がよくヘルプに向かいました。その度に自分のタスクは中断しています。数ヶ月中断せざるを得なかったことも数回ありました。 中断した数だけ自分のプロジェクトの完了は遅れるので、バッファありのガントチャートを組んでプロジェクトによりコミットできるよう工夫しました。しかし、これは結果的に自分のメンタルを責める原因になってしまいました。ただでさえ自分にとって未知の領域で見積もり通りにいかない、差し込みが多数発生する、その度にガントチャートを引き直す必要がありました。タスク単体の見積もりの曖昧さはバッファが吸収しますが、いつくるかわからない差し込みタスクをバッファは吸収しきれません。引き直すたびに完了予定が後ろ倒しになり、申し訳ない気持ちになりました。 ガントチャートは長期な第二領域のプロジェクトと相性がよくないのだと学びました。 それ以外に、このあたりから OS アップデートで更新したインフラ周りの知識が自分に属人化していることに気づき始めました。ですが、今からほかメンバーに参加してもらうにも逆に工数が膨らみそうであったため、作業ログを大量に残しつつ自分ひとりで進行を続けました。大量の作業ログは中断後の再開で記憶を取り戻すのにとても役に立ちました。 このあたりから気持ちは淀んできます。 後期 前述の件があったため、ガントチャートでの計画はやめて、各タスクに掛かった日数や工数の管理だけはちゃんと記録するようにしました。のちに振り返りの材料になります。 自分で調べたり周りに聞いたりしてもなかなか解決できない問題に何度も遭遇して、技術力不足なのかメンタルが淀んでいるのかわからなかったりしました。もしプロジェクトにほかメンバーがいれば、実は進め方に問題がある、あるいは今からでもプロジェクトメンバーを 1 人追加したほうがいい、などというような指摘を貰えるかもしれません。しかし、担当が自分だけであるので、それを自問自答しなくてはいけないことも辛かったです。このような場合、基本に立ち返ってほうれんそうが大事だと思いました。 初めてのカナリアリリースもうまくいき、いざ本番リリースと臨んだところでリリース手順中のスクリプトがうまく動かず延期ということもありましたが、最後は無事 OS アップデートすることができました。 憑き物が取れたような気持ちでした。プロジェクト開始から 1 年 8 ヶ月経っていました。 重い第二領域プロジェクトを通しての学び プロジェクトでは自分自身の経験不足なところが多くありました。その中で学んだことをあげます。 ビッグバンリリースを避ける ビッグバンリリースは避けるべきとわかっていても、細かいリリースに分けるだけの知識と経験がなかった そのために周りをもっと頼ることができたかもしれない 重い第二領域プロジェクトはガントチャートと相性が悪い 他のプロジェクトと兼任せず差し込み対応をしなくてよいならガントチャートは強力なツール ガントチャートを組まないにしても、各タスクに掛かった時間は記録して振り返りの材料にする 重い第二領域プロジェクトは 1 人で進行しない 一緒にコミットして助け合う仲間が必要 属人化させない 振り返りは短いスパンと長いスパンの両方でする ガントチャート周りは短いスパンの振り返りで気づいた 再計画とは別に再見積もりもする 手を動かしながら解像度が上がることで、より見積もり精度を高めることができる 初期の見積もりの精度を高めるより、その後に再見積もりが必要だと気づくことのほうが大切 特にガントチャートとの相性がよくなかったことは自分にとって印象的な発見でした。もちろん、常に相性が悪いわけではなく、今回は差し込みが多分に起きる状況だったため相性がよくなかったと思います。差し込みが多いというチームの状況がよくなかったという見方もできるかもしれません。よりよいプロジェクト管理のために、日々考え抜かなくてはいけません。 まとめ 記事を読んでいただきありがとうございます。緊急度が低いが重要度が高いそして重いタスクには特有の問題があることを学びました。1 年 8 ヶ月という期間はあまりに長く(もちろん途中で他のプロジェクトにスイッチすることもありつつ)、今の自分ならもっとスマートに完了できるのだろうかと空想するところです。 私の学びが他の方の学びになればと思います。 『7 つの習慣』という本で分類される呼び方を拝借しています。 ↩
アバター
こんにちは、ブロックチェーンチームの id:charines です。 今回はアップグレード可能なスマートコントラクトの開発事例について紹介します。 コントラクト開発者のみなさんの参考になればと思います。 アップグレード機能の必要性 アップグレード可能なコントラクトの仕組み 今回行った実装 コントラクト実装 プロキシをデプロイする仕組みの実装 まとめ アップグレード機能の必要性 ブロックチェーン上に展開されたコントラクトはオフチェーンのアプリケーションと異なり、通常は後から実装を修正したり機能を追加することはできません。しかしアップグレード可能なコントラクトとして設計することで、変更の余地を持たせることが可能になります。 例えば弊チームでは NFT をウェブコンソールから生成できる、「ユニキスガレージ」というサービスを開発・運用しています。 以前このブログでも紹介した ERC-2981 への対応 では、このサービスからデプロイされるコントラクトに、NFT が他のマーケットプレイスで再販売されたときロイヤリティの情報をマーケットプレイスへ提供するという機能を追加しました。 しかしこの機能追加以前にコントラクトをデプロイしたクライアントは、この機能の恩恵を受けることができません。 このようなケースでもアップグレード機能を実装していれば、今後デプロイされるコントラクトが常に最新の機能を利用できるようになります。 アップグレード可能なコントラクトの仕組み アップグレード可能なコントラクトはプロキシと呼ばれる仕組みによって実装され、これは機能実装にあたるロジックコントラクトと、ロジックコントラクトへの参照やストレージを持つプロキシコントラクトの組によって構成されます。 プロキシコントラクトはロジックコントラクトの関数を delegatecall することで自身がその関数を実装しているかのように振る舞うため、参照先のロジックコントラクトを変更することでアップデートが可能になります。 ただしアップグレード可能であることはその性質上、コントラクトの中央集権性を高めてしまうため慎重に行う必要もあります。例えば ERC-721 コントラクトの実装において、NFT の移転に関する関数をアップグレードすれば、NFT の所有権を操作することも可能になってしまいます。 弊チームではプロダクトの性質を考慮した上で、それでもユーザに利便性を提供することに価値があると考え今回の実装を行うことにしました。 今回行った実装 実は弊チームではコントラクトをデプロイする際のガス代を節約するために、アップグレード可能ではなかったものの以前からプロキシの仕組みを使用していました。 このコントラクトは古いバーションの OpenZeppelin で実装されていたため、今回もチームで利用実績のある OpenZeppelin をベースにしつつ、現行のバージョンで書き直す方針としました。 コントラクト実装 OpenZeppelin はアップグレード可能な ERC-721 コントラクトを ERC721Upgradeable として公開しています。さらに OpenZeppelin Contracts Wizard というウェブアプリケーションが提供されており、アップグレード機能を含め追加したい機能を選択していくだけで基本的な ERC-721 コントラクトを作れるため、これでベースを作っていきます。 ここに独自機能を追加していくのですが、ベース部分は今後の機能追加などを行った際にも書き直さずに使い回したいので、今回は Abstract Contracts として実装することとしました。 以下は OpenZeppelin Contracts Wizard で生成された実装を Abstract Contracts 化するにあたっての主要な差分です。 - contract BaseERC721V1 is Initializable, ERC721Upgradeable, ERC721PausableUpgradeable, AccessControlUpgradeable, ERC721BurnableUpgradeable, UUPSUpgradeable { + abstract contract BaseERC721V1 is Initializable, ERC721Upgradeable, ERC721PausableUpgradeable, AccessControlUpgradeable, ERC721BurnableUpgradeable, UUPSUpgradeable { bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - function initialize(address defaultAdmin, address pauser, address minter, address upgrader) - initializer public + function __BaseERC721V1(address defaultAdmin, address pauser, address minter, address upgrader) + internal onlyInitializing { ... また同様に独自機能についても、独立した各機能をアップグレード後も再利用できるように Abstract Contracts として実装し、それらを継承したロジックコントラクトを最終的にデプロイする構造にしました。 ディレクトリ構成は以下のようになります。 contracts ├── tokens │ └── BaseERC721V1.sol ├── features │ ├── FeatureA.sol │ └── FeatureB.sol └── logics └── ERC721LogicV1.sol デプロイするロジックコントラクトは logics/ERC721LogicV1.sol で、 tokens/ や features/ の各機能を継承しています。 プロキシをデプロイする仕組みの実装 OpenZeppelin はプロキシコントラクトを安全にデプロイするために Hardhat や Truffle 向けの プラグイン を使用することを推奨しています。しかし弊チームのプロダクトが SaaS である都合上 HTTP サーバからのデプロイが必要であり、開発環境として構成されている Hardhat などとの相性はあまり良くありません。 そこで今回はプラグインを利用せずに、 @openzeppelin/upgrades-core で提供されているバイトコードをそのまま使用してプロキシをデプロイする方針にしました。これは OpenZeppelin が提供するプラグインの内部で利用されているのと同じものです。 一方でプラグインにはデプロイ時に実装が安全にアップグレードできることを検証したり、アップグレード時に以前の実装と互換性があることを検証するなどの機能があり、プラグインを全く利用しない場合これらの恩恵を受けられません。そこでコントラクト開発のリポジトリのテストでのみプラグインを使用したデプロイやアップグレードのテストを行うことで、これらの安全性を担保できるようにしました。 まとめ アップグレード可能なコントラクトの仕組みや設計、デプロイ方法など開発の一連の流れを紹介しました。 コントラクトのアップグレードは中央集権的になってしまうなどの側面もあるので良く考えて導入する必要がある技術ではありますが、新しい機能を後から追加できるというのは利便性の面で非常に大きなメリットです。 ぜひコントラクト開発を行う際は検討してみて下さい。
アバター
こんにちは!ブロックチェーンチームでエンジニアをしている id:dorapon2000 です。最近買ってよかったものは「潮の華 あおさといわしふりかけ」です。 今回は Git の Squash マージについての知見を共有したいと思います。端的に言うと、 チーム開発で Non Fast-Forward マージをやめて Squash マージを採用し、再び Non Fast-Forward マージに戻した経緯の説明です。Squash マージを運用に導入するか考えたことがある方の参考になればと思います。 Squash マージとは マージには 3 種類ありますね。みなさんはトピックブランチを main へマージする際にどのマージ方法を利用していますか? Fast-Forward マージ git merge --ff-only Non Fast-Forward マージ git merge --no-ff Squash マージ git merge --squash GitHub 上のマージボタンではそれぞれ Rebase and merge Create a merge commit Squash and merge に対応します。今回注目する Squash マージは、複数コミットを単一のコミットにまとめてしまうマージ方法です。これらを説明するわかりやすい記事は多くあるため、そちらに説明を譲ります。 なぜ Squash マージをやってみたのか それぞれのマージ方法にはメリット・デメリットがあります。私達が利用していた Non Fast-Forward マージと Squash マージであげると以下のとおりです。 Non Fast-Forward マージ メリット マージコミットができるので、緊急時に Revert しやすい すべてのコミットログが残るため、コードの意図の調査をしやすい デメリット マージコミットが大量に発生する GitHub Flow において、main を feature へマージするときと feature を main へマージするときにマージコミットが発生する WIP なコミットや add 忘れなどの雑なコミットまで残る Squash マージ メリット マージコミットができるので、緊急時に Revert しやすい プルリク内のコミットは単一のコミットにまとめられてスッキリする デメリット 詳細なコミット履歴が失われる 私達のチームで利用していた Non Fast-Forward マージでも開発において不都合はありませんでした。しかし、マージコミットが大量に発生する点が気になっていました。実際に Non Fast-Forward マージで運用している現在の main ブランチの様子です。マージコミットだらけです。 メリットの中で最も重要な点は「緊急時に Revert しやすい」です。それは Squash マージにもあります。そして Non Fast-Forward マージのデメリットが目についたとき、Squash マージが魅力的に映りました。 それから私達のチームは Squash マージを採用しました。具体的には、main → feature は従来どおり Non Fast-Forward マージで、feature → main へのマージが Squash マージです。 やってみてつらかったこと Squash マージは 3 ヶ月間運用しました。しかし、運用の中で Non Fast-Forward マージのときには想像していなかったコンフリクトの問題が大量に現れました。 main から feature/α ブランチ (子) を切る feature/α にコミットハッシュ A を push feature/α から feature/β ブランチ (孫) を切る feature/β にコミットハッシュ B を push (画像 1 枚目) A と B はコンフリクトの関係にあるとする (補足参照) feature/α を main に Squash マージ (画像 2 枚目) ここで Squash されるので main にはコミットハッシュ A が入らない (コミットハッシュ C として追加) feature/β のベースブランチは main に切り替わる (最新にするため) main を feature/β へ Non Fast-Forward マージする (画像 3 枚目) main 中にコミットハッシュ A がないことで、 feature/β のコミット A+B と main の C の解決がうまくできずコンフリクトする Squash マージのデメリットである「詳細なコミット履歴が失われる」がコンフリクトという形で問題になりました。この状況によるコンフリクトを Squash コンフリクトと命名して説明を続けます。 やめた コンフリクトが辛かったため、チームで相談して対応を 4 つ考えました。 ① 気合で Squash コンフリクトを解消する ② Squash コンフリクトが発生する場合のみ、Non Fast-Forward でマージする ③ Squash コンフリクトが発生しないように、派生する feature ブランチがすべてマージされていることを確認してから Squash マージする ④ Squash マージをやめる 本記事のタイトルにもある通り、採用したのは ④ の Squash マージをやめることです。つまり、従来の Non Fast-Forward マージの運用に戻しました。 ①〜③ を採用しなかった理由をそれぞれ説明します。 ① は現実的でないと判断されました。もしロックファイル(pnpm-lock.yml など)でコンフリクトが起きてしまったときにあまりに悲惨です。 ② は運用でカバーする方法です。しかし、本来であれば認識する必要のない自身の子ブランチに合わせて 2 種類のマージを使い分ける必要があります。また、運用していると Non Fast-Forward マージすべきところをうっかり Squash マージしてしまったというミスが起きてしまいそうです。議論の余地なく不採用でした。 ③ も運用でカバーする方法です。Squash コンフリクトが起きる状況を作らないように、feature/A を feature/B より先にマージしなければいいのです。しかし、feature/A に依存する子ブランチが多いと、あるいは他の子ブランチがマージされるのを待っているうちに新しい子ブランチができるなど、いつまで経っても feature/A を main にマージできません。そして、十分にその状況がありえます。 ④ はもともと運用していた方法であり懸念はありません。 考察 当時のチームは新規プロダクトの開発初期段階にあり、 feature/B (孫ブランチ) のようなブランチがよく作成されていました。そのため Squash マージとの相性が良くなかったのだと考えられます。feature/B があまり発生しない状況や環境であれば Squash マージもよい選択肢になるかと思います。 まとめ 記事を読んでいただきありがとうございます。最後にまとめます。 Non Fast-Forward マージ戦略にはマージコミットが大量に発生するという問題がある 問題の解決のために Squash マージで運用した 特定の状況でコンフリクトが頻繁に発生した 結局 Non Fast-Forward マージ戦略に戻した (補足) コンフリクトの関係 Squash コンフリクトの例中で以下のように説明した箇所があります。 A と B はコンフリクトの関係にある 適切な用語を思いつかなかったためこのような説明になっていますが、内容はシンプルです。Squash コンフリクトの例を引き継いで、コードで説明します。 コミット A import bisect + import collections コミット B import bisect import collections + import math Squash マージコミット C は import bisect + import collections のようになり、コミット A+B とコミット C が Squash コンフリクトを起こす、ということです。
アバター