GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

GDS のビルド方式をパッケージビルド方式に変更しました #GameWith #TechWith

はじめに

こんにちは!スケールアーキテクトチームの @53able, @inosy22, @nog です!

今回は GDS のビルドの構成を変更したので、紹介していきたいと思います!

GDS についてはこちらのブログで紹介していますので、御覧ください。

tech.gamewith.co.jp

課題

GDS での開発も1年半が経ち、いくつか課題が見えてきました。

ビルド成果物が一つにまとまっているため、下記のような課題があります。

  • 開発/リリースの度に他の機能への影響を及ぼす可能性がある
  • 不要な箇所で不要な機能まで読み込まれていることが多く、無駄な通信が発生している
  • また、リリースの度にバージョンが変わるため変更をしていない箇所の JS のキャッシュが消え再読み込みが走り、無駄な通信が発生している
  • 開発時にバージョンがよくコンフリクトする
  • ビルドにかかる時間が長くなってきた

これらの課題を解決するためにビルド成果物を1つにまとめず、パッケージ単位(幾つかのコンポーネントをバンドルした単位)で分割してビルドする方式に変更をしました。

今までのビルド方式

ビルドターゲットを全てのコンポーネントにしており、ビルド成果物を1つにまとめていました(ビルドは下記コマンドを叩くイメージです)

$ yarn build

生成されるパスは以下のようになっており、ひとつのバージョンに対してビルドする度に全てのコンポーネントが更新されていました。

gds/${version}/gds.min.js
gds/${version}/gds.0.min.js
gds/${version}/gds.1.min.js
gds/${version}/gds.2.min.js
gds/${version}/gds.3.min.js
...

パッケージビルド方式

変更後のビルド方式はパッケージ単位でビルドし、更新するコンポーネントを減らしました。

$ PKG=Sample1 yarn pkg
gds-packages/sample1/${version}/gds-sample1.min.js
gds-packages/sample1/${version}/gds-sample1.0.min.js
gds-packages/sample1/${version}/gds-sample1.1.min.js
gds-packages/sample1/${version}/gds-sample1.2.min.js
gds-packages/sample1/${version}/gds-sample1.3.min.js
...

$ PKG=Sample2 yarn pkg
gds-packages/sample2/${version}/gds-sample2.min.js
gds-packages/sample2/${version}/gds-sample2.0.min.js
gds-packages/sample2/${version}/gds-sample2.1.min.js
gds-packages/sample2/${version}/gds-sample2.2.min.js
gds-packages/sample2/${version}/gds-sample2.3.min.js
...

パッケージビルド方式では、パッケージ単位でバージョンを管理しているため、上記のようにパッケージ名がパスに含まれています。

CI/CD 環境

今までのバージョンの指定は package.jsonversion を利用していました。

パッケージビルド方式ではパッケージ名ごとにバージョンが違うため、各パッケージのディレクトリに .version ファイルを設置し利用しています。

今までの CI/CD では差分などはチェックせず毎回ビルドを行っていました。

パッケージビルド方式では開発中の CI/CD では git diff を行い master ブランチの差分を元にデプロイの対象を選定します。また、 .version ファイルが設置されていない場合はリリース対象になりません。

本番環境の CI/CD では S3 にデプロイされているバージョンと比較し、大きい場合のみデプロイされます。

苦労した点

複数のパッケージを同じページで読み込むと、一部のパッケージの WebComponents が使えないケースがありました。

この原因を調査して解決するには、 vue-cli によってビルドされた成果物が、WebComponents を使えるようにするための、内部の構造を理解する必要があったので、紹介させていただきます。

新しいパスを見て気づいた人もいるかもしれませんが、ファイル名にもパッケージ名を入れています。

すでに ${package-name}/${version} のようにパスにパッケージ名を入れているので、ファイル名はどのパッケージ名でも gds.min.js にしてもよいはずです(わざわざパッケージ名を入れる必要はありません)。

しかし、ファイル名にパッケージ名を入れているのは理由があります。

vue-cli からビルドされた成果物の内部コードでは、ファイル名をキーにして window にオブジェクトが新しく作られます。

そのため、ファイル名を gds.min.js で共通にしてしまうと、window["gds_jsonp"] に対して上書きし合うため、バグの温床になってしまいます。

ファイル名にパッケージ名を入れることで gds-xxx.min.js になりますが window["gds-xxx_json"] になるため、上書きを回避できます。

$ vue-cli-service build --target wc-async --inline-vue --name gds ...
(window["gds_jsonp"] = window["gds_jsonp"] || []).push([[0], ... }]);

$ vue-cli-service build --target wc-async --inline-vue --name gds-sample1 ...
(window["gds-sample1_jsonp"] = window["gds-sample1_jsonp"] || []).push([[0], ... }]);

http:// https://cli.vuejs.org/guide/build-targets.html#async-web-component

最後に

ブログの冒頭であげていたこれらの課題は、パッケージビルド方式にすることで解決されました!

開発/リリースの度に他の機能への影響を及ぼす可能性がある

👉 パッケージごとにビルドされるため、他のパッケージに影響を及ぼすことはありません。

不要な箇所で不要な機能まで読み込まれていることが多く、無駄な通信が発生している

👉 必要なページで、必要なパッケージを読み込むようにすることで、通信量を削減できるようになりました。

また、リリースの度にバージョンが変わるため修正をしていない箇所の JS のキャッシュが消え再読み込みが走り、無駄な通信が発生している

👉 リリースが行われても対象外のパッケージのバージョンは変わらないため、引き続きがキャシュが有効になり無駄な通信は発生しなくなりました。

開発時にバージョンがよくコンフリクトする

👉 パッケージ単位で .version が定義されるため、コンフリクトがほぼ発生しなくなり分担して作業が行いやすくなりました。

ビルドにかかる時間が長くなってきた

👉 概ね30秒程度短縮されて、1分以内でビルドが完了するようになりました。

パッケージビルド方式での課題としては、以下があります。

  • 引き続きバージョンは手動で書き換える必要がある
  • 実は全てのコンポーネントをパッケージビルド方式に変更しておらず、新旧のビルド方式が混ざっている
  • 全パッケージで利用しているファイルを修正した際に、全パッケージのバージョンを変更する必要がある
    • パッケージ毎にビルドできるメリットの裏側的な
  • パッケージを横断する修正を行った場合に、複数パッケージをリリースする必要がある

今回の改修でコンポーネント実装がよりスケールできるようになりました!

今後もより良いコンポーネントを作り、GameWith を改善していきます!

Twitter

Twitter にてテックブログの投稿をツイートしていますので、よろしければフォローをお願いします!

twitter.com

Wanted!

一緒に働く仲間(特にサーバサイドエンジニア)を絶賛募集中です!

以下 Wantedly のページからぜひカジュアル面談へお申し込みください!

www.wantedly.com

GameWith の フロントエンド を TypeScript へマイグレーションする #GameWith #TechWith

はじめに

こんにちは!Incremental Stream Team の @53able です!

今回は現在自分が着手中の TypeScript マイグレーション PJ について書いていきたいと思います!

GameWith の JavaScript

まず GamewWith の JavaScript について紹介していきたいと思います。

JavaScript のボリューム

Cloc というツールを利用し JavaScript のコード行をカウントしてみました。

github.com

brew 経由でインストールし、cloc ディレクトリ名 を実行すれば、カウントしてくれます。

brew install cloc
cloc /Gamewith

結果は3万6千行でした!

JavaScript のビルドフロー

次に、ビルドフローについて紹介します。

GameWith は Gulp を利用して JavaScript を連結・圧縮しています。

gulpjs.com

GameWith メインリポジトリでは Vue, React といったフレームワークは導入しておらず、jQuery を利用しています。

※GameWithDesignSystem のリポジトリでは Vue を利用しています

tech.gamewith.co.jp

TypeScript 化

今回 GameWith の3万6千行を一度に TypeScript 化し、リリースするのは現実的ではないと判断しました。

そのため、ファイル単位で TypeScript 化して修正をするという影響範囲を小さくしたマイグレーションのフローにしました。

新しいビルドフロー

TypeScript 化の際に長年積み上げてきた Gulp で行っているビルドフローの改修はとてもリスクが大きいため、Gulp は引き続き利用することにしました。

f:id:tiwu:20210830150127p:plain

新しいビルドフローは Gulp の前に TypeScript を割り込ませ、既存のフローをできるだけ変更せず導入を考えています。

JavaScript を中間コードとして扱う発想でこの新しいビルドフローを考えました。

マイグレーションの作業フロー

JavaScript -> TypeScript は ts-migration というツールを利用しました。

github.com

今回マイグレーションの作業フローを作るにあたって、以下の作業を行いました。

  1. ts-migration をいくつか適当なファイルに実行
  2. 生成された TypeScript のファイルを ESLint で整形し既存の JavaScript のファイルと目で差分のチェック
  3. 次に、自動化したいフローを洗い出すため手動でファイル操作を行いました
    1. ts-migration はフォルダをターゲットに動作するため、変換用フォルダへ JavaScript をコピーする
    2. ts-migration をフォルダ指定で実行し TypeScript へマイグレーション
    3. マイグレーションで変換後の TypeScript を元の JavaScript と同じ場所へコピーする
    4. TypeScript を tsc でコンパイルし、オリジナルの JavaScript が上書きされる ( ここで JavaScript 👉 TypeScript 👉 JavaScript の差分がわかる)
    5. TypeScript 化された JavaScript は ESLint 対象外、ファイルを削除し、.gitignore に追加する

f:id:tiwu:20210830150147p:plain

ts-migration は、ディレクトリをターゲットに TypeScript へマイグレーションするので、一旦変換用のディレクトリに作業ファイルをコピーしてマイグレーションを実行するようにしています。

手動で作業を確認後、同等の動作を Node で動作するスクリプトを書き、自動化をしました。

※スクリプトを書く際に js-fire を利用しました

github.com

最終的に完成した自動化のスクリプトは下記のように3つになりました。

// 1. 変換させるたのフォルダへ JavaScript をコピーする
npm run migrate -- start /GameWith/xxx.js

// 2. `ts-migration` をフォルダ指定で実行し TypeScript へマイグレーション
// 3. マイグレーションで変換後の TypeScript を元の JavaScript と同じ場所へコピーする
// 4. TypeScript を `tsc` でコンパイルし、オリジナルの JavaScript が上書きされる
npm run migrate:sync

// 5. TypeScript 化された JavaScript は ESLint 対象外、ファイルを削除し、`.gitignore` に追加する
npm run migrate -- end /GameWith/xxx.js

スクリプトは下記のようなイメージです。

const migration = {
    start: async (jsFile) => {
        // 1. 変換させるたのフォルダへ JavaScript をコピーする
        return jsFile;
    },
    end: async (jsFile) => {
        // 5. TypeScript 化された JavaScript は ESLint 対象外、ファイルを削除し、`.gitignore` に追加する
        return jsFile;
    },
};

fire(migration);

おわりに

破壊的なビルドフローの変更ではなく、既存ファイル以前のソースコードを設けることにより、マイグレーションコストを格段に減らしました。

TypeScript へマイグレーションしますが、中間コードが JavaScript なので、既存ファイルと共存は問題ないようにしています。

また、短期間ですべての JavaScript をマイグレーションしてしまうと既存の動いている他案件とコンフリクトし、解消するコストがかなり高くなってしまうため、修正の際に1ファイルずつ案件を担当するエンジニアがマイグレーションできるフローにしました!

まだマイグレーションは始まったばかりですが、完走したいと思います!

Twitter

Twitter にてテックブログの投稿をツイートしていますので、よろしければフォローをお願いします!

twitter.com

Wanted!

一緒に働く仲間(特にサーバサイドエンジニア)を絶賛募集中です!

以下 Wantedly のページからぜひカジュアル面談へお申し込みください!

www.wantedly.com

ペア・モブ作業(見積り・設計・プログラミングなど)の紹介 #GameWith #TechWith

はじめに

こんにちは。GameWith のエンジニアの tiwu です!

以前モブ設計・モブ見積りについて紹介しました。

tech.gamewith.co.jp

今回はチームや開発サイクルを紹介しつつ、いろいろなペア・モブ作業について紹介していこうと思います!

チーム・開発サイクルの紹介

自分が所属しているチームはエンジニア2人とディレクター1人の合計3人で構成されています。

チームは1週間のスプリント開発を行っており、ざっくり下記のようなサイクルで動いています。

※作業 = 設計・開発・コードレビュー・テスト・リリースなどが含まれます

  • 月曜日
    • 作業
    • 新規案件共有会・見積り会
  • 火曜日
    • 作業
    • スプリントの終了・開始
  • 水曜日
    • 作業
  • 木曜日
    • 作業
  • 金曜日
    • 作業

モブ見積り

新規案件共有会で新しい案件の共有を受けた後、モブ見積りを行っています。

自分が所属している開発チームのエンジニアとディレクター、他チームのエンジニアがモブ見積りに参加しています。

Google Meet で通話しながら Hatjitsu というツールを利用して見積りを行っています。

hatjitsu.toolforge.org

以前書いたモブ見積りでのブログでも紹介しましたが、とても重宝しています!ありがとうございます!

f:id:tiwu:20210719134903p:plain

見積りは1案件毎に下記フローで行っており、1案件 2,3 分で終わるスピードで進めています。

  • 案件の共有
  • 議論
  • 見積り
  • (ズレていたら)議論
  • 決定

見積もりの場に起票者はいないため、要件などに関する質問は新規案件共有会で起票者にしています。

見積りがズレた際は、3, 5 などの隣り合っている数値の場合は大きい方を採用しています。

3,5,8 など3パターン以上の場合は最小・最大の意見を聞いて、再度見積もりをしたり数値を変えたりして収束させています。

ペア設計

自分が所属しているチームはエンジニアが2人なので、スプリントの開始後は Google Meet で通話しながらペア設計をしています。

水曜日・木曜日・金曜日は基本的に 15:00 - 19:00 でペア作業をしているので、1時間毎に休憩を入れつつ作業を進めています。

ペアプログラミング

実装もエンジニア2人でペアプロしています。

Google Meet で通話しながら VSCode Live Share を利用してペアプロしています。

marketplace.visualstudio.com

ドライバー・ナビゲーターという役割分担はしていないですが、実際に docker を立ち上げている側が比較的ドライバーよりになっています。

Google Meet + VSCode Live Share + docker などを組み合わせると Mac の動作が結構重くなったり、一部言語はシンタックスハイライトが効かなかったりしますが、かなり重宝しています!

VSCode Live Share について詳しくはこちらを御覧ください!

tech.gamewith.co.jp

コードレビュー

普段1人で設計やプログラミングをした際は、チームメンバーにレビューをしてもらっています。

ペア設計・ペアプロをしている案件についてはメンバーと一緒に作業をしているので、レビューというフローは飛ばしています。

(PR を立てた後に簡単にチェック的なことはしたりしています)

テスト

テストケースもエンジニア2人で設計したりテスト作業も2人で分担して行っています。

案件によってはQAエンジニアと協力をしてケース作成、作業を行っています。

終わりに

チームのエンジニアが2人というのを最大限に活かし、ほとんどのタスクをペア・モブで作業をしています!

1人で作業をしているとレビュー待ちなどで案件がストップしたりしますが、基本的に止まること無く進んでいきます。

また、「案件 vs 自分」ではなく「案件 vs チーム」になり知恵を出し合いつつ、知見の共有を行うことができていると感じます。

作業側のメリットを考えペア・モブ作業を開始しましたが、雑談が発生するという副次的なメリットがあり、リモートワーク環境でのスタンダードになっていくのでは?と思ったりもしています!

チームのパフォーマンスは下記で計測しているので、ペア・モブ作業による変化を観測し、より良くしていきたいと思います!

tech.gamewith.co.jp

Twitter

Twitter にてテックブログの投稿をツイートしていますので、よろしければフォローをお願いします!

twitter.com

Wanted!

一緒に働く仲間(特にサーバサイドエンジニア)を絶賛募集中です!

以下 Wantedly のページからぜひカジュアル面談へお申し込みください!

www.wantedly.com

開発速度・パフォーマンスを可視化する #GameWith #TechWith

はじめに

こんにちは。GameWith のエンジニアの tiwu です!

今回は自分が所属する開発チームの開発速度・パフォーマンスを可視化してみたので、利用技術など解説していこうと思います!

開発チームの紹介

まず、簡単にチームの紹介をしたいと思います。

自分が所属しているチームは1週間のスプリント開発を、GitHub + ZenHub を利用して行っています。

起票された Issue は下記の図のように、起票から完了まで流れていきます。

※IceBox, Sprint Backlog, Close は ZenHub の Pipeline 名です

※途中に In Progress などがありますが省略しています

f:id:tiwu:20210702192112p:plain

月曜日に新規案件共有会があり、Issue が起票され IceBox に移動します。

f:id:tiwu:20210702192155p:plain

火曜日がスプリントの開始なので、IceBox から Sprint Backlog に移動させます。

f:id:tiwu:20210702192244p:plain

スプリント中に対応が終わった Issue は Sprint Backlog から Close に移動させます。

f:id:tiwu:20210702192323p:plain

計測

「LeanとDevOpsの科学」を参考に下記2つを計測していきたいと思います。

  • デプロイ頻度
  • 変更のリードタイム

デプロイ頻度

スプリント中に対応が完了した Issue の数を計測しようと思います。

Issue != デプロイ数ではありますが、いったん Issue 数で定義しました。

スプリントを GitHub のマイルストーンを利用し管理しているので、マイルストーンに紐づくクローズされた Issue の数がデプロイ頻度となります。

変更のリードタイム

これは2段階に分けて計測をしていきます。

IceBox ~ Sprint Backlog

まずは IceBox ~ Sprint Backlog の移動時間です。

f:id:tiwu:20210702193846p:plain

この時間が長ければ長いほど起票されてから開発着手まで時間がかかったことがわかります。

起票の日時は GitHub API を利用することで取得することが出来ます。

Sprint Backlog への移動日時は ZenHub API を利用することで取得することが出来ます。

Sprint Backlog ~ Close

次に Sprint Backlog ~ Close の移動時間です。

f:id:tiwu:20210702193903p:plain

1週間スプリントで動いているため、この時間が1週間以上のタスクはスプリント内に終わらなかったことがわかります。

Close された日時は GitHub API を利用することで取得することが出来ます。

実装

GAS 経由で GitHub API, ZenHub API を利用しデータを取得し、SpreadSheet に保存します。

データの可視化は DataStudio を利用します。

f:id:tiwu:20210705181912p:plain

GitHub API

まず、マイルストーンに紐づくクローズされた Issue を取得します。

docs.github.com

Issue 取得 API のmilestonestate パラメーターを利用して取得します。

const response = await fetch('https://api.github.com/repos/${owner}/${repo}/issues?milestone=${milestone_number}&state=closed', {
  headers: {
    'Authorization': 'token ${token}'
  },
});

次に Issue の起票日とクローズ日ですが、これは Issue 取得 API のレスポンスに created_at, closed_at が含まれているためこれを利用します。

ZenHub API

ZenHub の Sprint Backlog への移動は、Events API を利用することで取得できます。

github.com

const response = await fetch('https://api.zenhub.io/p1/repositories/${repo_id}/issues/${issue_number}/events?access_token=${token}');

レスポンスは下記のようになっており(公式から)、type = transferIssue イベントの to_pipeline = Sprint Backlog となっている日時が今回計測に使う日時になります。

[
  {
    "user_id": 16717,
    "type": "estimateIssue",
    "created_at": "2015-12-11T19:43:22.296Z",
    "from_estimate": {
      "value": 8
    }
  },
  {
    "user_id": 16717,
    "type": "estimateIssue",
    "created_at": "2015-12-11T18:43:22.296Z",
    "from_estimate": {
      "value": 4
    },
    "to_estimate": {
      "value": 8
    }
  },
  {
    "user_id": 16717,
    "type": "estimateIssue",
    "created_at": "2015-12-11T13:43:22.296Z",
    "to_estimate": {
      "value": 4
    }
  },
  {
    "user_id": 16717,
    "type": "transferIssue",
    "created_at": "2015-12-11T12:43:22.296Z",
    "from_pipeline": {
      "name": "Backlog"
    },
    "to_pipeline": {
      "name": "In progress"
    },
    "workspace_id": "5d0a7a9741fd098f6b7f58ac"
  },
  {
    "user_id": 16717,
    "type": "transferIssue",
    "created_at": "2015-12-11T11:43:22.296Z",
    "to_pipeline": {
      "name": "Backlog"
    }
  }
]

GAS

GAS では下記のような関数を作りデータを取得し、Spreadsheet に書き込みをします。

function setData(milestone, sheetName) {
  // シートの取得
  const sheet = SpreadsheetApp.openById('XXX').getSheetByName(sheetName);
  const lastRow = sheet.getLastRow();

  // マイルストーンに紐づく Issue を取得
  const response = UrlFetchApp.fetch('https://api.github.com/repos/${owner}/${repo}/issues?milestone=${milestone}&state=closed', {
    headers: {
      'Authorization': 'token XXX'
    },
  });
  const issues = JSON.parse(response.getContentText());

  issues.forEach((issue, index) => {
    // issue 毎にイベント情報を取得
    const eventsResponse = UrlFetchApp.fetch('https://api.zenhub.io/p1/repositories/${repo_id}/issues/${issue.number}/events?access_token=XXX');
    const events = JSON.parse(eventsResponse.getContentText());
  
    let springBacklogDate = null;
    // 最新順に取得できるので、古い順からみる
    for (const event of events.reverse()) {
      if (event.type === 'transferIssue' && event.to_pipeline.name === 'Sprint Backlog') {
        springBacklogDate = new Date(event.created_at);
        break;
      }
    }

    created_at = new Date(issue.created_at);
    closed_at = new Date(issue.closed_at);

    const row = lastRow + index + 1;
    sheet.getRange(row, 1).setValue(issue.title);
    // 起票日
    sheet.getRange(row, 2).setValue(Utilities.formatDate(created_at, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss'));
    // Sprint Backlog 移動日
    sheet.getRange(row, 3).setValue(Utilities.formatDate(springBacklogDate, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss'));
    // クローズ日
    sheet.getRange(row, 4).setValue(Utilities.formatDate(closed_at, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss'));
    // IceBox ~ Sprint Backlog
    sheet.getRange(row, 5).setValue(parseInt((springBacklogDate - created_at) / 1000)); // 秒
    // Sprint Backlog ~ Close
    sheet.getRange(row, 6).setValue(parseInt((closed_at - springBacklogDate) / 1000)); // 秒
  });
}

GAS には JS のように fetch 関数はないため UrlFetchApp を利用して、外部の API を叩きます。

developers.google.com

また、JSON で取得することは出来ないため、getContentText 後に JSON.parse をする必要があるます。

developers.google.com

DataStudio

DataStudio ではシンプルに棒グラフと折れ線グラフを作りました。

Issue 数は安定して 7.5 ほど対応できています(たまに20を超えることも)

f:id:tiwu:20210705183757p:plain

案件の速度は平均を取ってみました。

IceBox ~ Sprint Backlog はたまに平均 20オーバーの時もあり、起票後着手まで時間がかかった案件があることがわかります。

Sprint Backlog ~ Close に関しては、7日を超えたり越えなかったり。

f:id:tiwu:20210705183829p:plain

終わりに

今回は平均を取ってみましたが、中央値を取るべきかなどなど改善すべき箇所が他にもありそうです。

また、計測して終わりではなく BML ループのように学び・次に活かすループを回していこうと思います!

Twitter

Twitter にてテックブログの投稿をツイートしていますので、よろしければフォローをお願いします!

twitter.com

Wanted!

一緒に働く仲間(特にサーバサイドエンジニア)を絶賛募集中です! 以下 Wantedly のページからぜひカジュアル面談へお申し込みください!

www.wantedly.com

GameWith サービス開発だけではない話(1) #GameWith #TechWith

こんにちは!Incremental Stream Team の @53able です!

今回は、GameWith 開発するエンジニア採用強化のため、我々開発組織固有の雰囲気をカジュアルな形でより知っていただくように、

一緒に働くことをイメージできるきっかけになればと考えて GameWith の開発に参画する三人で談義した収録を発信しています。

スピーカー

柴山 (シバヤマ)

エンジニアマネージャー。GameWith歴4年。

竹内 (タケウチ)

ディレクター。新卒1期生。

只野 (タダノ)

フロントエンドエンジニア。GameWith歴2年。

どんなテーマなのか?

事業貢献するために、GameWith の開発組織として特別な取り組みの事例をふりかえっています。

この取り組みを行ってきた影響や結果を通して、当時感じていたことを外部はもちろんのこと、GameWith 内部に対しても関係強化をアピールできることを期待しています。

では、お聴きください!

Twitter

Twitter にてテックブログの投稿をツイートしていますので、よろしければフォローをお願いします!

twitter.com

Wanted!

一緒に働く仲間(特にサーバサイドエンジニア)を絶賛募集中です! 以下 Wantedly のページからぜひカジュアル面談へお申し込みください!

www.wantedly.com

エンジニアの採用情報をまとめた GitHub Repository を公開しました #GameWith #TechWith

ごぶさたしています。@serima です。

このたび GameWith では採用情報をまとめた GitHub Repository を公開しました。

github.com

採用に関する情報の分散化問題

GameWith ではエンジニアを積極採用中ですが、いざ採用候補者の立場にたってみると情報が各所に分散しており、せっかくの情報発信が十分に届いていないのではないかと感じていました。

たとえば年単位で運用している技術ブログの記事のなかで、どれが現在の会社の雰囲気や開発スタイルを適切に伝えているものなのかは外からでは分かりません。

さらに昨今は、採用にまつわる情報があらゆる媒体に分散して掲載される傾向があり、候補者視点では情報の収集も一苦労です。 特に、なかの人のインタビュー記事は自社運用の Wantedly や外部の求人媒体などそれぞれで掲載されたりする傾向が強いように思います。

f:id:serimaryo:20210528105105p:plainf:id:serimaryo:20210528104808p:plain
さまざまな媒体に掲載された採用候補者向けの情報

そこで、GameWith でエンジニアとして働くイメージを持ってもらえるような記事や資料を選定し、下記ジャンルに振り分けとりまとめることにしました。

  • 社内の取り組み
    • 組織としての取り組みを紹介したり社内の雰囲気やメンバーを伝えるもの
  • サービス開発
    • 実際にサービス開発が行われている雰囲気を伝えるもの
  • エンジニアリング
    • おもにアーキテクチャや技術的なアプローチを伝えるもの

recruitment/articles.md at master · GameWith/recruitment · GitHub

これを定期的に見直しをおこなうことで「いま読んでもらいたい」記事や情報などを集約できると良いなと考えています。

また、どのようなプロセスで選考が進むかなどざっくりでも全体像が分かるようにし、安心して選考に臨んでもらえるようにしました。

recruitment/interview_guide_engineer.md at master · GameWith/recruitment · GitHub

ほかにもカジュアル面談の申込みの導線を整えたり、最新の技術スタックについてアップデートしたりと細かいところも情報を更新しましたので、ご興味ある方はぜひご覧ください。

エンジニア組織のブランディング

他方で、エンジニア採用やエンジニア組織のブランディングを GameWith で働くエンジニアみんなのものにしていきたいという思いもあります。

組織ブランディングやカルチャーは決してひとりで作れるものではなく、全員の意思とアクションの積み重ねが結果的にブランドやカルチャーとなっていくと考えています。

エンジニアにとっては馴染み深い GitHub を使うことで、普段の業務の一環として各々が Pull Request を作成し Contribution してもらえると良いなと思っています。

採用のためのウェブサイトを運用・更新していく場合は少なからずデザインのことを考慮する必要がでてくる場合もあり、更新ハードルが上がってしまいます。

こうしたことを避けるため、マークダウンのみで簡潔に書けることも大事なポイントだと考えました。

そんな折に、Kyash さんがまさにそのようなことを取り組まれていることを知り、弊社でもこの取り組みを踏襲させていただくことにしました。

Kyash さんは素敵なことに Creative Commons by 4.0 のライセンスを適用していたため、それに従い弊社の取り組みも同様のライセンス適用としています。

GitHub Actions で文章校正を自動化

今はまだ自分ひとりしか commit していませんが、今後は複数のメンバでリポジトリを運用していく想定でいます。表現のゆらぎをなるべくなくし、読みやすい文章を保つことを目的として、GitHub Actions を利用した textlint を試験的に導入しています。

詳細は Repository をご覧いただければと思いますが、肝は action-textlint を利用することで、プルリクエストの行単位に自動でレビューコメントをポストしてくれるところです。

f:id:serimaryo:20210528105517p:plainf:id:serimaryo:20210528105450p:plain
コードレビューコメントの自動投稿のイメージ(action-textlint より転載)

textlint にはさまざまなルールプリセットが用意されていますが、さらにどの程度の強度で文章を校正するかなどもカスタマイズが可能です。

表記ゆれを防ぐための校正ツールとして prh (proofreading helper) という仕組みもあり、これらを組み合わせることで一貫性のある文章をコンピュータの手を借りながら書くことができるようになります。

カジュアル面談資料のアップデート

あわせてこのタイミングでカジュアル面談用の資料のアップデートをおこないました。 業務では Notion を利用してドキュメンテーションを行っているため、面談用の資料もスライドではなく Notion で作成することにしました。

見出しだけチラ見せすると、このような感じになっています。

f:id:serimaryo:20210528100709p:plain

一方的にこちらから話すのではなく、候補者の方が聞きたいことなどもカジュアルに聞いてもらえる場となっているのでご安心ください。

さいごに

GameWith の会社のミッションは「ゲームをより楽しめる世界を創る」というものです。

最近は、ウマ娘をはじめとした攻略関連のツールを提供したり、市況に合わせてゲーム紹介の場をととのえたりといろいろなプロジェクトが並行して走っています。

一方、裏側ではインフラ費用削減や運用コスト削減をおこなったり、フロントエンドのエコシステムを整えたりと地道なシステム改善も着実に実施しています。

こういった守りの部分も最終的には攻めに転じることになると考えており、ゲームを楽しむみなさまが、よりゲームを楽しめるような価値づくりへ繋げていければと思います。

Twitter

Twitter にてテックブログの投稿をツイートしていますので、よろしければフォローをお願いします!

twitter.com

Wanted!

一緒に働く仲間(特にサーバサイドエンジニア)を絶賛募集中です! 以下 Wantedly のページからぜひカジュアル面談へお申し込みください!

www.wantedly.com

ウマ娘フレンド募集掲示板の Firestore 設計と検索 #GameWith #TechWith

はじめに

こんにちは。GameWith のエンジニアの tiwu です!

自分が所属しているチームではいくつか攻略ツールを実装しており、去年はあつ森交換掲示板をリリースしたりしました!

その際のブログはこちらになります!

tech.gamewith.co.jp

今回はウマ娘フレンド募集掲示板を実装した際に得た Firestore の知見や工夫について書いていこうと思います!

ウマ娘フレンド募集掲示板

ウマ娘フレンド募集掲示板は、自分のプロフィールを投稿したり、いろいろな条件で他のプレイヤーを検索したりすることができます。

gamewith.jp

このツールもあつ森交換掲示板と同じく GameWith Design System を利用し Vue + TypeScript で実装を行っています。

tech.gamewith.co.jp

Firestore の設計

登録する内容は自分のフレンドコードに加えて、代表ウマ娘 + 親(継承元)1 + 親(継承元)2 の情報となっています。

代表ウマ娘, 親(継承元)1, 親(継承元)2 に関しては登録するデータの構造はほぼ同じになっているため、当初はサブコレクションを利用した設計を考えていました。

f:id:tiwu:20210507120943p:plain

サブコレクションは、親になっているドキュメントを取得した際に一緒に取得できるわけではなく、親のドキュメントとは別に取得する必要があります。

そのため1つの募集を表示するために、募集ドキュメント + 代表ウマ娘ドキュメント + 親(継承元)1ドキュメント + 親(継承元)2ドキュメント = 4回ドキュメントを取得することになります。

Firestore は取得するドキュメントの数で課金されるため、1つの募集を表示するために4回ドキュメントの課金が発生します。

firebase.google.com

今回コスト削減を考えサブコレクションでデータを持つのではなく、一覧で利用するデータは全て募集ドキュメントで持つようにし、1つの募集を表示するのに1回のドキュメント取得で済むようにしています。

検索について

サブコレクションの検索

一覧のコストを考えサブコレクションでデータを持たず、募集ドキュメントでデータを持っていますが、検索面の理由もあり募集ドキュメントでデータを持つようにしています。

例えば代表ウマ娘が「スピード」因子を持つ募集を検索する場合、サブコレクションでデータを持っていると

  1. ウマ娘サブコレクションから代表ウマ娘が「スピード」因子を持つ、代表ウマ娘ドキュメントを取得
  2. parent を利用して、親ドキュメントの documentId を取得
  3. 親ドキュメントを documentId を利用して取得する

とったフローで検索をすることになります。

const parentDocIds = [];

const subCollectionQuerySnapshot = await firestore.collectionGroup('SUB_COLLECTION_NAME').where('因子', '==', 'スピード').get();
subCollectionQuerySnapshot.forEach((doc) => {
  // 1つ上の親はドキュメントではなくコレクション自体を指すので、2つ上を参照する
  if (doc.ref.parent.parent) {
    parentDocIds.push(doc.ref.parent.parent.id);
  }
});

// in が 10件しか受け付けないので分割して検索
for (let i = 0; i <= parentDocIds.length; i += 10) {
  const splitParentDocIds = parentDocIds.slice(i, i + 10);
  const querySnapshot = await firestore.collection('COLLECTION_NAME').where(firebase.firestore.FieldPath.documentId(), 'in', splitParentDocIds).get();
}

firebase.google.com

firebase.google.com

表示と同じくドキュメント取得数が多くなるため、募集ドキュメントに検索に利用するデータをもたせています。

OR 検索の AND 検索

Firestore では (A or B or C) and (D or E or F) といった where-in の AND 検索はできません。

cloud.google.com

// できない
const querySnapshot = await firestore.collection('COLLECTION_NAME').where('サポートカード', 'in', ['1','2','3']).where('ウマ娘', 'in', ['A','B','C']).get();

// できる
const querySnapshot1 = await firestore.collection('COLLECTION_NAME').where('サポートカード', 'in', ['1','2','3']).get();
const querySnapshot2 = await firestore.collection('COLLECTION_NAME').where('ウマ娘', 'in', ['A','B','C']).get();

ウマ娘フレンド募集掲示板では、A or B or C で100件取得、D or E or F で100件取得し、重複を抽出をするようなロジックを組んで検索を実現しています。

ページング

ウマ娘フレンド募集掲示板では SNS の一覧のように、続きを読み込むというページング機能を実装しています(指定したページを表示する機能はありません)

firebase.google.com

シンプルな AND 検索と、OR 検索の AND 検索で実装方法を変えているのでそれぞれ紹介します。

AND 検索

AND 検索の場合は公式の例のようにシンプルな実装になっています。

startAt, startAfter を利用することでクエリの開始点を指定することができるため、前回の末尾のスナップショットを保持して利用することで実現しています。

class Api {
  lastSnapShot: firebase.firestore.QueryDocumentSnapshot | null;

  get() {
    let query = firestore.collection('COLLECTION_NAME');

    if (this.lastSnapShot) {
      query = query.startAfter(this.lastSnapShot);
    }

    const querySnapshot = await query.get();

    this.lastSnapShot = querySnapshot.docs[querySnapshot.docs.length - 1];
  }
}

OR 検索の AND 検索

前述したように、 (A or B or C) and (D or E or F) といった where-in の AND 検索はできないため、A or B or C で100件取得、D or E or F で100件取得し、重複を抽出をするようなロジックを組んで検索を実現しています。

AND 検索のページングのように1回のクエリで取得できないため、各クエリ毎にスナップショットを保持する設計で実現しています。

また、取得後の結果で重複を判定するため、過去に取得したデータも保持してします(下記サンプルでは細かいところは省略しています)

class Api {
  lastSnapShotMap: Map<'サポートカード' | 'ウマ娘', firebase.firestore.QueryDocumentSnapshot | null>;
  documents: any[];

  search(key: 'サポートカード' | 'ウマ娘', value: string[]) {
    let query = firestore.collection('COLLECTION_NAME').where(key, 'in', value);

    const lastSnapShot = this.lastSnapShotMap.get(key);
    if (lastSnapShot) {
      query = query.startAfter(lastSnapShot);
    }

    const querySnapshot = await query.get();

    this.lastSnapShotMap.set(key, querySnapshot.docs[querySnapshot.docs.length - 1]);

    querySnapshot.forEach((doc) => {
      this.documents.push(doc.data());
    });
  }

  get() {
    search('サポートカード', ['1','2','3']);
    search('ウマ娘', ['A','B','C']);
  }
}

終わりに

今回の知見をまとめると

  • ドキュメント毎の課金なので、一覧を実装する際に可能な限り一覧のドキュメントでデータを持つ
  • (A or B or C) and (D or E or F) といった where-in の AND 検索はできない
  • シンプルなページングであれば簡単に実現ができる

上記3点がポイントとなります。

(A or B or C) and (D or E or F) といった where-in の AND 検索ができないのは、多くの人が詰まるポイントかなと今回感じました。

これからもチャレンジしていくので、よろしくおねがいします!

Twitter

Twitter にてテックブログの投稿をツイートしていますので、よろしければフォローをお願いします!

twitter.com

Wanted!

一緒に働く仲間(特にサーバサイドエンジニア)を絶賛募集中です! 以下 Wantedly のページからぜひカジュアル面談へお申し込みください!

www.wantedly.com