Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

React Query で optimistic-update な UI を実装してみる

こんにちは @okupara です。 去年から Studyplus for School の API 周りの state 管理を Redux から React Query にし始めました。キャッシュや無限スクロールでのリクエストのサポート、ページネーションのサポートなど、隈雑になりがちな処理を抽象化してくれてるので、かなり使いやすいです。

最近個人的に React Query を使った optimistic-update な UI の実装方法をどうすれば良いのか試行錯誤していたので、現時点での実装案を共有できればと思います。

optimistic-update (optimistic-ui) "楽観的な更新・UI"とはなんぞやについてはカミナシさんの開発者ブログでわかりやすく解説されてます。 Reduxによるいいねボタンの実装例もありますので是非ともそちらををご参考ください。

今回自分が試しに実装してみたかったことは下記の通り

  • 勉強の履歴を入力する簡単なフォーム
  • 一つ履歴を入力すると、サーバーからのレスポンスを待たずに履歴一覧に加える
  • サーバーからのレスポンスが返ってくるまで、その行はローディングモードの表示をする
  • サーバーから成功のレスポンスが返ってきたら、通常の一覧のレコードと同じ表示にする
  • サーバーからエラーが返ってきたら、その行はエラーの表示にする
  • エラーの場合はリトライできるように、リトライボタンを配置
    • 押されたら、再度上記のローディングモード〜のフローになる

f:id:okupala:20210511101341g:plain
こんな感じです

React Query の公式ドキュメントにも下記のような optimistic-update の実装例が載っているので、参考にしつつ実装しています。

 const queryClient = useQueryClient()
 
 useMutation(updateTodo, {
   // When mutate is called:
   onMutate: async newTodo => {
     // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
     await queryClient.cancelQueries('todos')
 
     // Snapshot the previous value
     const previousTodos = queryClient.getQueryData('todos')
 
     // Optimistically update to the new value
     queryClient.setQueryData('todos', old => [...old, newTodo])
 
     // Return a context object with the snapshotted value
     return { previousTodos }
   },
   // If the mutation fails, use the context returned from onMutate to roll back
   onError: (err, newTodo, context) => {
     queryClient.setQueryData('todos', context.previousTodos)
   },
   // Always refetch after error or success:
   onSettled: () => {
     queryClient.invalidateQueries('todos')
   },
 })

useMutationonMutate onSuccess onError onSettled のコールバックを持っているので、それらを使えば良さそうです。

onMutate と コンテキスト

「一つ履歴を入力すると、サーバーからのレスポンスを待たずに履歴一覧に加わる」を実装するには、先ほどの公式ドキュメントの実装例にも使われている、useMutationonMutate コールバックを使います。 これは mutate が呼ばれた直後、Promise の処理が走る前に実行されます。第一引数は mutate の引数で指定したデータが渡されます。 ポイントは戻り値に何かを返すと、それがそのリクエストのコンテキストデータとして onSuccessonError で参照できることです。

export function useSaveStudyTime(props: Props) {
  const { onMutate, onSuccess, onError } = props;
  const client = useQueryClient();

  return useMutation<StudyTime, Error, StudyTimeFormData, StudyTime>(
    async (params) => {
         ........
    },

  onMutate(variable) {
  .....
    const dataInProcess: StudyTime = {
      // まだサーバーにリクエストしてないので、暫定 ID を振ってローディング中のデータを特定できるようにする
      id: `loading-${id}`, 
      status: "loading",
      ...params
    };
    client.setQueryData<StudyTimeList>(cacheKey, (previous) =>
      previous ? [dataInProcess, ...previous] : []
    );
    onMutate?.(dataInProcess);
    // ここで返したデータは、コンテキストデータとして、onSuccess 時、 onError 時に参照できる
    return dataInProcess;
  }

入力されたデータに、status=loading と暫定の ID を付与したデータ dataInProcess をキャッシュの一覧に入れることで、即座にデータ一覧に反映されます。

一覧に表示するデータには全て status というフィールドを生やしています。

  • 通常のデータ (done)
  • ローディング中 (loading)
  • エラー (failed)

このフィールドを行ごとに、ローディング中のスタイル表示やエラーのスタイル表示の判定をするために使います。 よって、一覧のデータ取得時にサーバーから返ってくるデータは、Promise 側の処理で全てに status=doneをつけています。

また、onMutate?.(dataInProcess) で props で渡されたコールバックを実行していて、内部ではローディング中のデータを親の state に保持しています。 これはローディング中に React Query のデフォルト機能である、window を切り替えて戻ったときに refetch する機能が動作した時のためのものです。 その機能が動作して refetch の結果が登録処理より先に返ってきてしまうと 先ほどの、onMutate 内にて setQueryData でキャッシュに追加したローディング中の status を持つデータが refetch で返ってきたデータで上書かれ、ローディング中の行が消えてしまうのを防ぐのが目的です。

refetch 時に消えてしまっても表示が維持できるように、別途 state でも処理中のデータを管理して、一覧取得のAPIから取得したデータとマージして表示するようにします。

先ほどの公式ドキュメントを見る限りcancelQueries でキャッシュをアップデートさせないということができるようなのですが、どうも期待した動きにならなかったので、やむなくこの方法をとっています。 もし、cancelQueries でもこのユースケースがカバーできる方法を知ってる方がいたら教えていただけるとありがたいです・・・

公式ドキュメントの実装例では、onMutate 時点でキャッシュにあるデータを取得してコンテキストデータとして返し、エラーの時にそのデータでキャッシュを上書きしてます。 これによって、入力したはずのエラーのデータ自体は消えるようになっています。

今回は、エラーの行はそのまま残しつつ、エラーが起きた行を分かるように表示したいです。さらにエラー行内のリトライボタンから再登録を促したいので、上記の方法は取れません。

よって、先ほどのdataInProcessをそのまま返して処理中のレコードとして扱いonSuccessonError から特定できるようにします。

これで、「一つ履歴を入力すると、サーバーからのレスポンスを待たずに履歴一覧に加わる」「登録処理中の行はローディング表示」の実装が整いました。

成功時

データ登録処理に成功した場合は先述の通り、useMutation に指定した onSuccess コールバックが実行され、第三引数でコンテキストのデータが受け取れます。 先ほど、ローディング表示する前に暫定IDを振ったdataInProgressを state に保存していました。 ここでは別途 props で受けた onSuccess コールバックを実行しています。 その内部ではそのコンテキストデータから state にローディング中のデータとして保存していたものを特定し、削除する処理を行っています。

onSuccess(data, varialbes, context) {
  if (context) {
   // props から別途渡される onSuccess
    onSuccess(context);
  }
},

こちらは useMutation で指定できる onSettled コールバックです。 これは成功もしくはエラーにかかわらず、処理が終わった時に呼ばれるコールバックです。

ここで ReactQueryClient の invalidateQueries を呼んで、API から最新のデータを取得します。 このタイミングで state に保存していたデータとレスポンスデータをマージして表示されます。 登録に成功したデータは先ほどの onSuccess のコールバックによって state から削除されているので、ローディング中から通常のデータに切り替わったように見えます。

onSettled() {
  client.invalidateQueries(cacheKey);
}

エラー時

ここからさらに話がややこしくなってきます。エラーの時にやりたいことは

  • ローディング中だった行をエラー用のスタイルが当たった行に表示を変更する
  • ボタンを表示して、リトライを促す
  • リトライが押されたら、再度ローディングのフローに戻る

なので、ローディング中だったレコードの status を failed にして、該当の行をエラー表示できるようにします。

こちらも useMutation で指定できる onError コールバックにて、 props から別途渡される onError を実行しています。 その内部では先ほどのコンテキストデータからレコードを特定し status を failed に更新して state に保持しています。

onError(error, variables, context) {
  if (context) {
    onError(context);
  }
},

先ほど "成功時" のところでも書いた、onSettled がエラーの時にも実行されるので、再度 API から取得する処理が走ります。この際、先ほど state に保存しているエラーのレコードがマージされて表示されるので、該当の行がローディングからエラー表示に切り替わったように見えます。

リトライ

ようやく最後ですね。 エラー行のリトライボタンが押されたら、もう一度登録処理を実行しつつその行をまたローディング表示にしたいです。 登録時の onMutate では、最初の "onMutate と コンテキスト" では送られた入力データを一律キャッシュ一覧に 追加 することで、ローディング表示していました。 しかしながら、入力データはエラーのレコードとしてキャッシュもしくは state の中に既に存在しているので、そのデータを特定して、status = "loading" に更新したいです。 なのでフォームから登録された場合に "New" という属性を 、リトライから登録された場合には "Update" という属性を一緒に送って、onMutate の中で条件分岐することにしました。

フォームに並んでる登録ボタンの onClick コールバック

onClick: () =>
  mutate({
    type: "New",
    material: formState.material,
    minutes: formState.minutes
  })

RETRYボタンの onClick コールバック

onClick={() => {
  mutate({ ...item, type: "Update" });
}}

先ほどの useMutationonMutate の中身に、下記のように "Update" の場合の処理を追加します。 "Update" の属性と一緒に送られてきたデータから、キャッシュの中の status がエラーのものを特定し、ローディングに更新して再度キャッシュに登録し直します。 そして"New" の場合は先に説明した追加処理で対応するようにします。

追加の時にも使っていた propsから渡される onMutate は内部で state の一覧に存在しないデータは追加し、同じIDを持つデータがある場合は、status をローディングに更新して保存しています。 これにより再度ローディング中のフローに入ることができまます。

onMutete(params) {
    if (params.type === "Update") {
      const queryData = client.getQueryData<StudyTimeList>(cacheKey);
      if (queryData) {
        // React Query のキャッシュを走査して
        // 該当レコードがあれば status="loading" に更新したデータを作る
        const newStudyList = queryData.reduce<StudyTimeList>((p, c) => {
          if (c.id === params.id) {
            return [...p, { ...c, status: "loading" }];
          }
          return [...p, c];
        }, []);
        // 更新したデータでキャッシュを上書き
        client.setQueryData<StudyTimeList>(cacheKey, newStudyList);
      }
      // props からの onMutate コールバックで state 側の一覧も更新する
      onMutate?.(params);
      return params;
    }
    // "New"の場合は先述の追加処理を実行する
}

以上が今回試行錯誤した結果となります。 個人的に試してみたかったものなので、プロダクションで実装しているものではないのですが、いつかどこかでこういったUIも取り入れられると良いなーと思いつつ試行錯誤していました。

今回の勉強履歴の登録のような基本的に自分しか登録・編集しないデータについては、optimistic-update は効果を発揮しそうです。 反対に、複数人が同一レコードを同一タイミングで編集し合う可能性があるようなアプリケーションで、表示の整合性も担保しなければならないケースでは不向きかもしれません。

コードはsandboxにおいているので適宜ご覧いただければと・・・ 上記解説の通り、やむなく React Query 側のキャッシュと state でデータを二重管理していたりと、結構泥臭くなってしまってます。。。 もっとエレガントにかける方法を思いついた方は是非教えてください!

最後に

Studyplus では一緒に働いてくれるエンジニアを募集しております! WebフロントエンドのUI実装をバリバリやってみたい方も、そうでない方も、Studyplusに興味を持たれた方は気軽にカジュアル面談にお申し込みください〜

www.wantedly.com open.talentio.com

Flutter + Firebase FunctionsでZendesk Supportを活用する

こんにちは、モバイルクライアントグループの若宮(id:D_R_1009)です。 今年もスギ・ヒノキ花粉は辛かったです。自室でPCを見ている時間が長いこともあり、特に目が辛かった年でした。

今回はPortoで昨年11~12月ごろに対応したZendeskへのCSツール移行について書いてみたいと思います。

play.google.com

apps.apple.com

Zendesk

Zendeskは多くの企業で採用されているCSツールです。 アプリ内お問い合わせやメールによるお問い合わせ、ヘルプセンターの管理、Google Play Storeとの連携などのさまざまな対応を一箇所で対応することができます。 ※App Storeには対応していません*1

www.zendesk.co.jp

Portoではユーザーからのお問い合わせ、ユーザーへのお知らせの2つをZendeskで対応しています。 お問い合わせはZendesk Supportを、お知らせはZendesk Guideです。 Zendesk Guideと言う名前は見慣れないかもしれませんが、SlackやDiscordなどのヘルプページでも利用されています。

https://porto-book.zendesk.com/hc/ja

Zendesk Guideの利用は簡単なため、今回はZendesk Supportにチケットを作成する仕組みについて紹介します。

ZendeskにFlutterからチケットを作成する

FlutterアプリからZendesk Supportにチケットを作成する場合、以下の2つの手段があります。

  1. Zendesk Pluginを利用して、Flutterアプリから直接Zendesk Supportにリクエストする
  2. Firebase Functionsなどを利用して、Zendesk API経由でZendesk Supportにリクエストする

Zendesk Plugin

pub.dev を確認してみると、ZendeskのMobile SDKをFlutterから利用できるようにしたPluginが見つかります。

pub.dev

Pluginの良し悪しの判断は実装と対応時期によりけりになるため、ここでは議論しません。 Mobile SDKを利用するメリットとしては、Flutterアプリ(Flutterのアプリプロジェクトリポジトリ)で完結することが挙げられます。 モバイルアプリの環境だけで開発とテストが行えることは、特に小さなチームでは大きなメリットです。 コードの影響範囲も小さくなるため、チェックする範囲も比較的小さくなります。

一方で、Pluginを使うとサービス固有のデータ(ユーザ情報など)をZendeskに送信することが難しくなります。 「バックエンドからZendesk Supportに送信して、CSチームに表示したいデータがある」には、このアプローチは不適となります。

少し異なる観点で見ると、アップデートとその普及の問題があります。 お問い合わせフォームの改修のために、ユーザーにアプリのバージョンアップを促すのは心苦しいものです。 しかしお問い合わせフォームに「必須」の項目は、CS対応のために「必須」となるものになりがちです。 Flutterのように早く作って改善を回していくプラットフォームの場合、より重要になる視点かなと個人的に思っています。

Firebase Functions

本題です。 今回PortoではFirebase FunctionsからZendesk APIにリクエストする方法を取ることとしました。

ZendeskにAPIリクエストを送る時にはzendesk developersを確認します。 構成が少々独特なため戸惑うことも多いのですが、今回は「チケットの作成」と「チケットへの画像添付」の2つを実装するので ticketsattachments を主に確認します。

developer.zendesk.com

developer.zendesk.com

Zendesk APIドキュメントの読み方

APIドキュメントを開いたら、まずAPIが対応している要素のテーブルをよく読みます。 ドキュメント内にはレスポンスの一覧はありますが、リクエストのプロパティ一覧はありません。 このため、レスポンスの詳細欄から組み立てていくことになります。

構造化されたリクエストを確認したい場合には、提供されているライブラリのコードを読みにいくこともできます。 GitHub上で動いているコードを確認することができるので、各言語に親しんでいれば把握しやすくなります。 ただし、公式にサポートされているのがRubyのSDKのみとなることに留意してください。

developer.zendesk.com

リクエスト

ドキュメントとコードから型を推測し、curlコマンドなどでリクエストを送り、エラーコードから修正をするサイクルを回していきましょう。 試す場合にはAPIドキュメントに載っているJSONのうち、必要となる項目を絞り込んで少しずつ増やしながら対応することをお勧めします。

3分クッキングではありませんが、試行錯誤の結果作成したリクエスト用のコードが次のものになります。*2 なお、コードはブログ用に改変したものとなります。

export = functions
  .region('asia-northeast1')
  .https.onCall(async (data, context) => {
    const uid = context.auth?.uid;
    if (!uid) {
      throw new functions.https.HttpsError('invalid-argument', 'UIDがありません', data);
    }

    const imageTokens: string[] = [];
    const images = request.images ?? [];

    for (const [index, base64] of images.entries()) {
      const mine = guessImageMime(base64);
      const fileName = `image${index}.${mine.split('/')[1]}`;
      const buffer = Buffer.from(base64, 'base64');

      const res = await gaxios.request<AttachmentResponse>({
        url: `https://*****.zendesk.com/api/v2/uploads.json?filename=${fileName}`,
        method: 'POST',
        headers: {
          'Content-Type': `application/binary`,
          Accept: 'application/json',
          Authorization: `${functions.config().zendesk.auth}`,
        },
        body: buffer,
      });

      if (res.status == 201) {
        imageTokens.push(res.data.upload.token);
      } else {
        throw new functions.https.HttpsError('unavailable', 'failed request', data);
      }
    }

    const userInfo = getUserInfo(uid: uid);
    const ticket = new Ticket(request, userInfo, imageTokens);

    const res = await gaxios.request({
      url: 'https://*****.zendesk.com/api/v2/tickets.json',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json;charset=utf-8',
        Accept: 'application/json',
        Authorization: `${functions.config().zendesk.auth}`,
      },
      data: { ticket: ticket },
    });

    return {};
  });

function guessImageMime(base64: string): string {
  if (base64.charAt(0) == '/') {
    return 'image/jpeg';
  } else if (base64.charAt(0) == 'R') {
    return 'image/gif';
  } else if (base64.charAt(0) == 'i') {
    return 'image/png';
  } else {
    // fallback
    return 'image/jpeg';
  }
}

const images = request.images ?? []; にて取得している images は、アプリ側で画像の File を取得しエンコードしたものになります。 今回はZendesk Support上で確認できれば十分なため、アプリ側で画像取得時に品質を圧縮しています。 このため、比較的小さなファイルサイズを扱うこととなりました。 もしも大きなファイルサイズになる場合には、別の方法をお勧めします。

final images = <String>[];
for (final file in _imageList) {
  final bytes = await file.readAsBytes();
  images.add(base64Encode(bytes));
}

画像をBase64エンコードしてしまうとMIME typeが失われてしまいます。 今回は送信元の処理が限定されており十分なテストも可能だったため、文字列を確認して判定する簡易な手法を採用しました。

stackoverflow.com

より汎用的にする場合には、リクエストJSONにMIME typeを含める方が安全だと思われます。 もしもコードを参考にする場合にはご留意ください。

ドキュメントを読み解き、リクエスト用の型定義は下記のようにします。 なお、送信しているデータはブログ用に改変しています。

export interface TicketRequest {
  // default fields
  subject: string;
  message: string;
  name: string;
  email: string;
  // custom fields
  app_version: string;
  os_version: string;
  os: string;
  // option fields
  images: string[] | undefined;
}

const field = {
  username: *****,
  app_version: *****,
  os_version: *****,
  device_model: *****,
  os: *****,
  is_trial: *****,
} as const;

export class Ticket {
  brand_id: number;
  ticket_form_id: number;

  subject: string;
  comment: Comment;
  requester: Requester;
  custom_fields: CustomField[];

  constructor(json: TicketRequest, userInfo: UserInfo, attachmentsToken: string[]) {
    this.brand_id = *****;
    this.ticket_form_id = *****;

    // ポルトのチケットとわかるようにsubjectにポルトの文字を入れる
    this.subject = `(ポルト) ${json.subject}`;
    this.comment = { body: json.message, uploads: attachmentsToken };
    this.requester = { name: json.name, email: json.email };
    this.custom_fields = [
      // TicketRequest
      { id: field.app_version, value: json.app_version },
      { id: field.os_version, value: json.os_version },
      { id: field.os, value: json.os },
      // UserInfo
      { id: field.username, value: userInfo.uid },
      { id: field.is_trial, value: userInfo.isTrial ? 'true' : 'false' },
    ];
  }
}

interface Comment {
  body: string;
  uploads: string[];
}

interface Requester {
  name: string;
  email: string;
}

interface CustomField {
  id: number;
  value: string;
}

TicketRequest と対応する field はZendesk Supportを確認して取得、もしくは作成してください。 固定のフィールド以外は作成時に型を指定するため、かなり柔軟に設計することができます。

画像をアップロードする場合には、 comment プロパティに uploads として生成されたトークンを指定します。 このトークンは画像をアップロードした際に生成されるもので、 comment プロパティなどに含めて使用されることで消費される仕組みとなります。 サンプルコードではコメントの送信前に画像をアップロードし、生成されたトークンを配列で Ticket クラスに渡すことで対応しています。

終わりに

簡単ではありますが、Zendesk APIを利用してチケットを作成するコードを紹介しました。 実装前は簡単だろうと思っていたのですが、色々と独特な点があり苦労したので、どなたかの助けになればいいなと思っています。

私が所属しているモバイルクライアントグループでは、モバイルクライアントと銘打っていますが、必要な範囲で領域を広げてコードを書いたりチャレンジしたりすることができます。 4月時点ではポジションがオープンにはなっていませんが、もしも興味がある場合には、気軽に下記リンクよりカジュアル面談をご応募ください。

open.talentio.com

また、サーバーサイドエンジニアのポジションがオープンになっています。 ご興味のある方はご覧になっていただけますと幸いです。

open.talentio.com

*1:Integration of iTunes AppStore into Zendesk

*2:HTTPクライアントはgaxiosを利用しています。

Apple M1プロセッサ搭載macbookでの開発環境構築

こんにちは、ForSchool事業部の冨山(@atomiyama1216)です。
好きなエディタはVimです。どんなにVSCodeが流行っても僕はVimを使い続けます。

2021年4月に業務用マシンを買い換えApple M1チップ搭載モデルに買い替えました。
その際環境構築でなかなかに躓いたのでそのことについて記録しておきます。 主にRails関連のgemをインストールするときとNode.jsをインストールするときに困ったのでそれらについて書きます。
今回の環境構築ではRosetta 2は使いません。

Disclaimer

以下の環境で構築したときの記事になります。

$ uname -a
Darwin PC21-010.local 20.3.0 Darwin Kernel Version 20.3.0: Thu Jan 21 00:06:51 PST 2021; root:xnu-7195.81.3~1/RELEASE_ARM64_T8101 arm64
$ system_profiler SPHardwareDataType
Hardware:

    Hardware Overview:

      Model Name: MacBook Pro
      Model Identifier: MacBookPro17,1
      Chip: Apple M1
      Total Number of Cores: 8 (4 performance and 4 efficiency)
      Memory: 16 GB
      System Firmware Version: 6723.81.1
      Serial Number (system): ...
      Hardware UUID: ...
      Provisioning UDID: ...
      Activation Lock Status: Disabled

TL;DR

  • 環境構築のためのbootstrapを書いたatomiyama/dotfiles
  • homebrewのインストール先はIntelプロセッサでは/usr/local、Apple Siliconでは/opt/homebrewになっている*1
  • ffiはv1.14.0移行がApple Silicon環境に対応している
  • Node.jsはv15.3.0以上をソースコードからコンパイルする*2

bootstrapを作成した

これまでもdotfilesをshellスクリプトで書いて複数環境で管理していたのですが今回Ansibleを用いる形に書き換えました。 一応これをローカルで実行して最低限の環境が揃ったので参考にしてください。
お星さままってます☆(ゝω・)v☆

github.com

mysql2のインストールでコケる

これは以前から頻出の問題でググるとたくさん記事が出てきます。
ただし進研ゼミでやったやつだ!と思ってこれまでのIntel環境同様の手順を脳死で踏むともれなくコケて下のようなエラーが出力されます。

$ bundle install
...
2 warnings generated.
compiling infile.c
compiling mysql2_ext.c
compiling result.c
compiling statement.c
linking shared-object mysql2/mysql2.bundle
ld: library not found for -lssl
clang: error: linker command failed with exit code 1 (use -v to see invocation)
make: *** [mysql2.bundle] Error 1

make failed, exit code 2

Gem files will remain installed in
...

ld: library not found for -lxxxっていうのはGNUリンカのldコマンド実行時にxxxってライブラリが見つからんかったってエラーです。
なのでその場所を教えて上げればいいわけです。
これまではこのエラーが出たら脳死で以下のコマンドを実行すれば解決しました。

$ bundle config --local build.mysql2 "--with-ldflags=-L/usr/local/opt/openssl/lib"

ただApple M1チップ環境では失敗します。 homebrewのドキュメント(こちら)に以下の記載があるようにインストール先が/opt/homebrewに変更されています。

This script installs Homebrew to its preferred prefix (/usr/local for macOS Intel, /opt/homebrew for Apple Silicon) so that you don’t need sudo when you brew install.

どのパスを指定すればよいかは下の方法で確認できます。

$ brew info openssl
...
For compilers to find openssl@1.1 you may need to set:
  export LDFLAGS="-L/opt/homebrew/opt/openssl@1.1/lib"
  export CPPFLAGS="-I/opt/homebrew/opt/openssl@1.1/include"
...

つまりここに書いてある通り/opt/homebrew/opt/openssl@1.1/libを指定してあげれば問題ないので

bundle config --local build.mysql2 "--with-ldflags=-L/opt/homebrew/opt/openssl@1.1/lib"

としてからbundle installなりgem install mysql2すれば成功するはずです。

rails cが起動しない

ここまでやって一通りbundle installは成功したのでbundle exec rails cしたら次は下のエラーがでました。

$ bundle exec rails c
Traceback (most recent call last):
...
/path/to/rails/project/vendor/bundle/ruby/2.7.0/gems/ffi-1.13.1/lib/ffi/types.rb:69:in `find_type': unable to resolve type 'size_t' (TypeError)

これはffiのFFI#find_typeで型定義が解決されないことに起因する問題でこちらのPRで解決され、v1.14.0でリリースされています。

github.com

Node.jsが動かない

つぎにNode.jsをいれようとしたら動かなくて困りました。(見かけ上はinstallできたが一部jestなどが動作しなかった) こちらのissueにて言及されているようにv15.3.0より前のリリースではdarwin-arm64環境はサポートされていません。*3
https://nodejs.org/dist/v15.14.0を見るとわかるのですがv15.x以前ではApple M1チップ環境darwin-arm64向けのビルド済みパッケージは配布されていません。
現在CURRENTリリースのv16.x以上であればdarwin-arm64向けのビルド済みパッケージが配布されています。 それ以前のバージョンを使用する場合はv15.3.0以降のバージョンを自身でコンパイルして使用するしかないと思います。
ただしこちらに関してもExperimentalサポートなので完全な動作を保障するものではないですが...

なので今回は以下のページを参考にnodebrewを使用してソースコードからコンパイルすることにしました。

tech.tabechoku.com

手順は以下の通りです

  1. nodebrewのインストールとセットアップ
$ brew install nodebrew
$ nodebrew setup_dirs
  1. nodebrewのスクリプトの編集 こちらでシステム環境を特定するサブルーチンsystem_infoを以下のように書き換えました。
$ vim $(which nodebrew)
sub system_info {
    my $arch;
    my ($sysname, $machine) = (POSIX::uname)[0, 4];

    if  ($machine =~ m/x86_64/) {
        $arch = 'x64';
    } elsif ($machine =~ m/arm64/) {
        $arch = 'arm64';
    } elsif ($machine =~ m/i\d86/) {
        $arch = 'x86';
    } elsif ($machine =~ m/armv6l/) {
        $arch = 'armv6l';
    } elsif ($machine =~ m/armv7l/) {
        $arch = 'armv7l';
    } elsif ($machine =~ m/aarch64/) {
        $arch = 'armv7l';
    } elsif ($sysname =~ m/sunos/i) {
        # SunOS $machine => 'i86pc'. but use 64bit kernel.
        # Solaris 11 not support 32bit kernel.
        # both 32bit and 64bit node-binary even work on 64bit kernel
        $arch = 'x64';
    } else {
        die "Error: $sysname $machine is not supported."
    }

    return (lc $sysname, $arch);
}
  1. nodeのコンパイル
$ nodebrew compile v15.14.0

この作業はだいぶ時間がかかりました。が、ここまでやれば一通り自分は環境が整いました。

まとめ

Apple M1チップ搭載マシンはまだ未対応のライブラリなどがあるため環境構築で躓くことは多くありましたが、バッテリーの持ちとか動作の速さは素晴らしいと思います。 前であればDockerを立ち上げた状態でZoom会議に参加するとマウスカーソルが遅延したりと使うに耐えない状態でした。 ただ業務マシンを買い替えてからはそういった問題が起きることなく快適に仕事ができてます。 ライブラリの問題などは積極的にフィードバックして一日でも早く人類がM1チップ環境に追いつく日がくるといいなと思ってます。

最後に

現在Studyplusではエンジニアを絶賛募集中です!
M1搭載マシンをつかって仕事がしたい!とかちょっと気になる!って方がいれば、カジュアル面談から対応可能なのでぜひ気軽に連絡いただけると嬉しいです! www.wantedly.com open.talentio.com

あなたの知らないStudyplusモバイルクライアントグループの世界

こんにちは、モバイルクライアントグループのリーダー大石です。
春から息子が就活を始めたので親としては期待と不安が入り混じる季節です。
今回はモバイルクライアントグループで何をどのような体勢で開発しているか、どのような取り組みをしていたか昨年度を例にご紹介します。

開発しているプロダクト

学ぶ喜びをすべての人へという弊社のミッションのもとに、以下のプロダクトを開発しています。

Studyplus Porto
プラットフォーム Android/iOS Android/iOS
開発環境 Kotlin/Swift Flutter
概要 勉強記録・学習管理アプリ 参考書読み放題サービス
URL https://www.studyplus.jp/about https://porto-book.jp

メンバー構成

現在のモバイルクライアントグループにはエンジニアが7名おり、メンバー構成は以下のとおりです。

  • リーダー * 1
    iOSエンジニアとリーダー業務・プロダクト推進を兼任
  • テックリード * 1
    iOS, Android, Flutter, サーバーサイドを横断
  • Studyplus
    • iOSエンジニア * 2
    • Androidエンジニア * 2
  • Porto
    • Flutter(兼サーバーサイド)エンジニア * 1

メンバーの働き方

約1年前から基本的にはリモート勤務になっています。(私が昨年3月以降にオフィスに出社した回数は3, 4回でした)
基本的な勤務時間は10時~19時ですが、早朝から稼働するメンバーもいたり、日によっては12時くらいから稼働するメンバーもいて様々です。
個人的な話ではありますが、有志による自作キーボード部の活動が1年以上できていないので寂しいです。

開発の進め方

四半期ごとに企画チームや広告チームなど事業のグループ、サーバーやモバイルなど開発のグループから起案された機能追加や技術的な改善をロードマップに落とし込んでプロダクトの開発を行っています。
毎週開催しているProduct Planningというミーティングにてグループをまたがるタスクの進捗や優先度の確認、各グループから持ち込まれたタスクをメンバーにアサインします。
開発タスクの種類としては主に事業に関わるタスクと技術的な取り組みの2つです。 事業サイドからのタスクに関しては無理のないスケジュールで開発ができるようエンジニアからの意見を踏まえて計画しています。

技術的な取り組み

エンジニアサイドからの技術的な観点による新機能の開発や改善を行っています。
最近の取り組みをプロダクトごとにご紹介します。

Studyplus iOS

  • iOS 14 Widget対応
    • エンジニア主導で技術調査を行い、事業サイドのグループによるデザインを元に開発、リリースしました tech.studyplus.co.jp
  • Siriショートカット対応
    • こちらもWidget対応と同様にエンジニア主導で開発しました tech.studyplus.co.jp
  • Objective-C → Swift移行
    • 2021年4月現在Objective-Cのソースコードが残り1%となり、もう少しでSwiftへの移行が完了しそうなところまで来ています。
先月 今月

Studyplus Android

  • アーキテクチャの刷新
    • JavaからKotlinへの完全移行
    • MVCアーキテクチャからJetpack MVVMアーキテクチャへの移行
    • マルチモジュール構成への移行
    • Dagger, Dagger AssistedInjectによるDIの整備
  • 最新ライブラリの導入
    • Navigation, DataStore, PagingなどのJetpackライブラリ
    • Retrofit, CoilなどのKotlin Coroutines活用ライブラリ
  • タブレットやChrombookなどのマルチデバイス対応

MAD Scoreを計測した記事も参照ください tech.studyplus.co.jp

Porto

  • FlutterによるマルチOS対応アプリの開発を初期に決定 tech.studyplus.co.jp
  • freezed + StateNotifierによるViewModelアーキテクチャの採用
    今後、Riverpodに移行する予定です。
  • 課金処理も含めたバックエンドをFirebaseに統一

ミーティング

現在、メンバーが参加している定例ミーティングは以下のとおりです。
チーム内では基本的には火〜木曜日にミーティングを設定するようにして、月曜日と金曜日は開発に集中しやすく休暇も取りやすいように意識しています。
現在はほぼ全員がリモートで参加しています。

  • 毎週
    • 事業部全体定例 (30分)
    • Product Planning (30分)
    • iOS定例 (30分) ※
    • Android定例 (30分) ※
  • 隔週
    • 1on1 (30分)
    • Portoエンジニア定例 (30分) ※
    • モバイルクライアント定例 (30分)
    • スタディプラス エンジニア定例 (30分)
      サーバーサイドとSREチームのメンバーも参加する定例で、他グループへの相談や情報共有、技術的な雑談などを行っています

※関係するエンジニアが参加して各プラットフォームの開発に関する報告や技術的な情報共有を行っています

グループでの主な取り組み

開発リソースの効率化

iOS・Androidエンジニアが別プラットフォームの開発もできるようにすることで開発リソースの効率化を目指しており、強制ではありませんが手軽な実装タスクを担当することを推奨しています。
昨年度では当時在籍していたiOS 2名、Android 2名がそれぞれ別プラットフォームの実装にチャレンジすることができました。
私もAndroidの開発経験はありませんでしたが、Android Studioの使い方レベルからレクチャーを受けながら簡単な開発を経験することができました。
今後はiOS・AndroidエンジニアにもFlutter開発を経験してもらい、グループに所属するエンジニアがどのプロダクトにも携われるような体制を目指しています。

評価制度のアップデート

360度フィードバックの導入

半年ごとに期間中同じプロジェクトなどを担当したメンバーからフィードバックを受けます。
あくまでフィードバックとしているので評価には影響せず、他のメンバーからのフィードバックを参考に今後の目標設定やアクションに反映してもらうことが目的です。

キャリアラダーの導入

エンジニア向けの評価制度としてキャリアラダーの導入を予定しています。

自己学習制度

毎日の勤務時間中の30分を自由な学習に使って良いという制度です。業務に差し支えがない前提ではありますが、2時間~2時間半程度を1週間のどこかで割り当てるでも可としています。
実はこの制度は現在うまく運用できておらず、大きめの目標を立てても1日30分でできることは限られているので、計画はしたものの業務を優先したために全く自己学習できなかったというケースがありました。
その経験を踏まえて例えば、積読している技術書を業務の合間に読む、新しい開発言語のチュートリアルを実行するといった手法で有効活用できないかと考えています。

いかがでしたか?

簡単ではありますが、モバイルクライアントグループについて紹介しました。
弊社のモバイルアプリ開発について興味を持っていただけたら幸いです。
まだまだグループとしてやっていくべきことが沢山ありますので引き続きメンバーと一緒に良いプロダクトを作れるよう改善を行っていきたいと思っています。

最後に

現在、モバイルアプリエンジニアの募集はしていないのですが、もしご興味のある方はカジュアル面談でより詳しいお話しをできればと思っております。 お気軽にお申し込みください。 open.talentio.com

なお、現在弊社のサーバーサイドエンジニアを募集しております。ご興味のある方はご覧になっていただけますと幸いです。 open.talentio.com

最後までお読みいただきありがとうございました。

New Relicを活用したアプリケーションのパフォーマンス改善の流れ

こんにちは。サーバーグループ エンジニアの山田です。

サーバーグループの仕事の一つにアプリケーションのパフォーマンス改善があります。
今回は普段行っているRailsアプリケーションのパフォーマンス改善の流れについて紹介します。

遅い処理を見つける

前提として遅い処理、遅くなった処理を知る必要があるので、APMなどを使って確認します。
弊社のRailsアプリケーションではNew Relicを使用しているためその画面で説明していきます。APIのレスポンスタイムの改善を行う場合はまず以下を確認することが多いです。

  • Transactions > Most time consuming
  • Transactions > Slowest average response time

Transactions > Most time consuming

リクエスト数上位のAPI(コントローラのアクション)。この上位を改善できると効果が大きい。

Transactions > Slowest average response time

平均レスポンスが遅い上位API。一概には言えないがレスポンスに時間がかかっているためユーザー体験が悪くなっている可能性が高い。

改善対象のAPIを決める

上に挙げた二つを中心に見て、効果が大きくユーザー体験も改善されるであろうAPIを対象にしていきます。

いつ確認しているか

現状はチームでのNew Relic確認は週次のスプリントイベント内で見るようにしています。ただし毎回必ず見ているわけではなく、以下のような状況の時に特に重点的に確認しています。

  • 突発的なアクセス数の増加(例えばコロナの影響)やパフォーマンスに影響を与えそうな修正を入れた時など
  • パフォーマンスに関わる問題発生が多くなっている時

以前は必ず毎回のスプリントイベントの中で見るようにしていました。しかしパフォーマンス面の変化が起こることは少なかっため、全員で見る頻度は少なくしました。

ボトルネックとなっている処理の調査

ここからは最近行った改善を例に説明していきます。 Studyplusアプリの中心である勉強を記録するAPIが、想定よりも時間がかかっていることがわかったためその改善を行っていきました。

New Relicの Transaction trace > Trace details で詳細を確認すると

MySQL StudyRecord find からMySQL StudyRecord create の間で2秒以上かかる場合があるとわかりました。ここを中心に調査していきます。

ローカル環境で確認する

パフォーマンス問題はデータ量に起因している場合が多いため、本番以外の環境で再現できない場合は多いです。しかし何からしら解決の手掛かりがないかローカルの環境で確認します。

何をどうやって確認するかはその時によって様々で、ソースコードを見てすぐに原因がわかる時もあればそうでない時もあると思います。

今回は rack-lineprof というGemを使って調査を行いました。 時間がかかっているであろう処理の周辺で確認をしていきます。

  10.4ms     2 |  135          record.study_unit = bookshelf_entry.study_unit
               |  136        end
               |  137
   9.2ms     1 |  138        record.save!
  84.4ms     1 |  139        record.create_event!
               |  140
              .......

結果としてNew Relicで測定される結果ほど顕著に時間がかかっている箇所は見つけられなかったため、より詳細を確認できるように本番にログを仕込む方向にしました。

ログを仕込む

New RelicのMethod tracers を使えば簡単にメソッドのトランザクション内の時間を計測して、New Relicで表示することができるのでそれを使っていきます。

今回の例では、リクエスの中で呼ばれているStudyRecordというモデルの以下のメソッドを計測するようにしました。

  • study という独自に作成したクラスメソッド
  • save! というActiveRecrdのモデルオブジェクトをDBに保存にするインスタンスメソッド
+ require 'new_relic/agent/method_tracer'

class StudyRecord < ApplicationRecord
+  include ::NewRelic::Agent::MethodTracer

  # save! メソッドをトレースする。 New Relic上は Custom/study_record_save という名前で表示する
+  add_method_tracer :save!, 'Custom/study_record_save'

 # study というクラスメソッドをトレースする。 New Relic上は Custom/study_record_study という名前で表示する
  class << self
+    include ::NewRelic::Agent::MethodTracer

+    add_method_tracer :study, 'Custom/study_record_study'
  end
end

ログを仕込んだ結果

先ほどのTrace detailsよりもどのメソッドで時間がかかっているかが詳細に見れるようになりました。StudyRecord#save! を実行してcreateのSQLの実行が完了するまでに時間がかかっていることが明確になりました。

また、SQL実行時間を確認するとcreateのSQL(insert)は時間がかかっていませんでした。そのため直前の処理が原因であるとわかります。

原因特定と修正

save! の内で重そうな処理は、バリデーション以外にはなさそうだったためそこを中心に確認しました。 本番と同等のデータで確認した結果、あるバリデーションの処理に時間がかかっていることがわかりました。

そのバリデーションは特定の条件で実行すればよいが、必要ない場合も無条件で実行するようになっていました。
そのため条件を満たす場合に実行するという1行の修正を加えました。

効果測定

修正した後に本当に速くなっているか、どれぐらい速くなっているかの測定を行います。

今回の修正では対象のAPIの全てのリクエストが改善できたわけではないため、修正前後の1週間分の平均レスポンスタイムで確認しました。

平均レスポンスタイム
修正前 0.88 sec
修正後 0.33 sec

無事にレスポンスタイムが短くなっていることを確認できました🎉

最後に

例を交えてRailsアプリケーションのパフォーマンス改善の流れについて紹介しました。 普段どのような流れで行っているかが伝われば幸いです。

個人的にパフォーマンス改善の仕事は、可能性を絞って原因を特定していく感じが問題を解くような楽しさがあり好きな仕事の一つです。 今後も継続的に改善を行っていきたいです。

We Are Hiring

現在スタディプラスでは、サーバーサイドエンジニアを募集しています! open.talentio.com

EKSのCluster AutoscalerでNodeのスケールイン時に502、504エラーがでるのを解消した

チャオ。SREチームの栗山(@sheepland)です。 好きな漫画は「僕の心のヤバイやつ」です。毎回心がバキバキになりながら読んでいます。

今回はEKSでCluster Autoscalerを使った際にNodeがスケールインするタイミングで502、504エラーがでるのを解消した話です。

TL;DR

  • EKSでCluster Autoscalerを使う場合、Ingressはtarget-type: ipにする
  • target-type: ipにした場合、PodのpreStop内で長めにsleepを入れる

前提

  • ALBの管理にはAWS ALB Ingress Controllerを使用 (※近いうちにAWS Load Balancer Controllerにバージョンアップ予定)
  • トラフィックモードはデフォルトのInstance modeを使用
  • Podの終了時に新規リクエストが来なくなるのを待つためにのpreStopの中でsleepを10秒入れている

事象

Cluster Autoscalerを導入するにあたりNodeのスケールアウト/スケールイン時にリクエストエラーが発生しないかを検証していました。検証方法は簡単でLocustという負荷試験ツールを使ってひたすらリクエストを投げるというものです。リクエストエラーが発生すればLocustの画面のFailuresタブから分かります。
image.jpeg (96.4 kB)

スケールアウト時には問題なかったのですが、Nodeがスケールインするタイミングで数リクエストが502や504エラーになるという問題が発生しました。 最初はスケールインするNodeにのっているPodの退避がNodeのスケールインに間に合わずエラーが発生しているのかと思っていました。
しかし調査をするとNodeがスケールインする前にPodは退避されているというのと、スケールインするNodeにアプリケーションPodがのっていない場合でもリクエストエラーが発生していたためPodのターミネート処理に問題があるのではなく、ALB周りになにか問題があると推測。
ここからは想像ですがNodeが終了するときに他のNodeのiptablesを更新するときに一部のリクエストがエラーになっているような印象をうけました。

Ingressのtarget-typeを変えてみる

Ingressのトラフィックモード(target-type)がデフォルトのInstance mode から IP mode に変更してみました。 そうするとNodeのスケールイン時にリクエストエラーが発生しなくなりました 🎉

しかし別の問題が…

今度はPodが終了するときにリクエストエラーが発生するようになりました。Instance modeのときは発生しなかった事象です。 しかもNodeのスケールイン時のリクエストエラーよりもエラー数が多くなってます。

preStopのsleepを伸ばしてみる

色々ネットで調べてみるとIP modeにするとALBのターゲットグループからPodを登録解除するのに時間がかかるということでした。
(参考記事: スマホゲームの API サーバにおける EKS の運用事例 | エンジニアブログ | GREE Engineering)
そのため10秒sleepするだけではターゲットグループからの解除が間に合わず、Podが終了しているにも関わらずリクエストがPodにきていたと推測されます。 実際にpreStopの中でsleepを10秒から30秒に伸ばしたところリクエストエラーが発生しなくなりました 🎉

まとめ

Cluster AutoscalerのNodeのスケールイン時のリクエストエラーの解消方法について紹介しました。
スケールイン時のリクエストエラーは80RPSくらいの負荷の中で1回リクエストエラーが発生するくらいだったので、リクエストがそこまで多くないサービスであれば気づかなかったり問題にならなかったりするかもしれません。しかし大きなサービスになるとこういった問題が顕在化してきます。
やはり事前検証をしっかり行うのは大切ですね。調査当初は何が原因か分からずかなり焦りましたが無事解決できてよかったです。

リモートチームでスクラム開発

こんにちは、ForSchool事業部の島田です。好きな漫画は「王様達のヴァイキング」です。

スタディプラス社では、現在リモートでの開発が主体となっています。その状況の中でStudyplus for School(以下FS)開発チームはスクラムによる開発を進めています。

今回はFS開発チームのリモート環境下における開発プロセスを紹介したいと思います。

(ちなみに、Studyplusのサーバーサイドチームについてはこちらの記事を参照していただければと思います)

tech.studyplus.co.jp

開発プロセスについて

以下が、FS開発チームのスクラムの概要です。

  • スプリント期間:2週間
  • デイリースクラム:毎日30分
  • スプリントプランニング:隔週2時間
  • スプリントレビュー:隔週2時間
  • スプリントレトロスペクティブ:隔週2時間

デイリースクラム

スプリントゴールの達成に対して課題・障害がないかを確認するミーティングです。 スプリントバッグログの進行具合を中心にして、チームがその日に取り組んだ事を確認していきます。その中でこのまま開発を進めて計画どおりに行かないと考えられる場合は課題を明確にして(別のミーティングを設けるなどして)解決策を考え、早い段階で軌道修正できるようにしています。

スプリントプランニング

スプリント期間の最初に行われる、スプリントの作業を計画するイベントです。

スプリントで実現するバックログの項目を選択してスプリントで実現するタスクにしていきます。

選択する項目は、機能開発や技術的な改善も含まれています。それらを具体的にどう実現していくかをタスク化して見積もります。

以下のステップで進めています。

  1. スプリントバックログの優先度を決める
  2. バックログの見積もりをする
  3. これまでのベロシティを参考にしてスプリントゴールを決める

スプリントレビュー

スプリントの最後に行われるスプリントの成果物をレビューするイベントです。プロダクトオーナー、開発者を中心にして、レビューをしてフィードバックをしていきます。その結果により新たなバックログが追加されたりします。

  1. スプリントの成果物のレビュー・デモ
  2. レビューに応じて、必要ならば新たなバックログを追加

スプリントレトロスペクティブ

スプリントレビューの後に、チームをより良くするために改善策を話し合うイベントです。 スプリントを振り返り、ゴールの達成度(ベロシティはどうだったか)を確認します。発生した課題の解決策やチームをより良くするための改善案ついて話したりします。 FS開発チームでは話し合いの際に、Lean Coffeeのやり方を参考にしてすすめています。以前はKPTで進めていました。しかし各自の課題感が発散した場合に議論の優先度を付けるのが難しくなり、本当に話し合うべき事を話す時間が足りなくなる事も少なくありませんでした。このやり方と後述するScatterSpokeというツールの相性もあり、チーム全体で重要だと思われる課題から優先的に話していけるようになり、ミーティングの質が向上したと感じました。

  1. Brainstorm(5分)
    1. 各自がスプリントで気になったトピックを記載する
    2. 各自二票まで全員があげたトピックの中で気になったトピックへ投票する
  2. To Discuss
    1. 投票数があったトピックをピックアップする
    2. 投票数が多い順に議論を始める
  3. Discussing
    1. 7分議論する
    2. 時間が経過したら、このまま継続して議論するかを親指のサインで投票する
      1. 継続の意思が過半数以上の場合-> 4分議論、過半数に満たない場合 -> 2分議論
  4. Discussed
    1. 全員がこれ以上の議論が必要ないとなったら、当該トピックの議論を終える
    2. 必要に応じて改善アクションを決める

以上が、FS開発チームのスプリントイベントの内容になります。

ツールについて

FS開発チームで利用しているツールについて紹介したいと思います。

Slack

ほぼ全てのやりとりはチャット上でやります

Zoom

定例のミーティングは主にZoomを利用しています。サクッと話したい場合にはSlack Callなども使います。

monday.com

バックログ・タスクの管理ではmonday.comを利用しています。詳しいツールの説明についてはこちらの記事を参照していただければと思います。

tech.studyplus.co.jp

esa

仕様や議事録などesaで管理しています。必要に応じてGoogelスプレッドシートを利用したりもします。

Scrum Poker Online

スプリントプランニングの見積もりで利用しています。オンラインでプラニングポーカーが出来るツールは数多くありますが必要最小限の事が実現できるので重宝しています。以前はSlack上で見積もりの数を出すなどもしていたのですが、やはりタイミングや一覧性などもあり課題がありました。このツールは無料で、簡単にroomをつくれて入ることができ全員の見積もりが揃ったらポイントを確認することが出来るので大変便利です。

f:id:yo-shimada:20210222093957j:plain
Scurm Porcker Online

ScatterSpoke

スプリントレトロスペクティブで利用しているツールです。

振り返りを実施する際に各自が課題をあげて投票を行うために利用しています。リモート以前は付箋を利用するなどしてました。リモート後はesaなどに記載していましたが、ドキュメント共有ツールだと他者が記載している内容や投票がわかるので、自分以外の意見に影響を受けてしまう可能性もありました。

ScatterSpokeを利用することにより、

  • 他者の振り返りトピックの内容・投票が公開されるまで分からない
  • タイマー機能で制限時間がわかりやすい
  • トピック内容が被った場合にグルーピングしやすい
  • Slack連携がありアクションアイテムを確認する事ができる

などのメリットがあります

f:id:yo-shimada:20210222095756j:plain
ScatterSpoke

その他

その他にリモート環境下だと中々きっかけがないとコミュニケーションが発生しなかったりする事があるので、開発チームが話し合うチャンネルにSlackのリマインダーで「時報」をするようにしています。今やっている事を簡単に書いたり、困ったことなどを気軽に投稿したりしています。

f:id:yo-shimada:20210222100126p:plain

最後に

世間にはリモートで生産性をあげるためのツールは数多くありますが、チームやメンバーの状況に応じて合ったツールを選ぶ事が重要だと思いました。

現時点でのFS開発チームは様々な紆余曲折の末に以上のような開発プロセスを行っています。

これも日々のスクラムでの振り返りによる成果であり、半年後にはまた何かやり方を変えている可能性があります。リモートやリアルにかかわらず、大切なのは現状に満足することなく常により良くできないかを考えることだと思いました。

幸いな事として会社やチームは新しいツールや仕組みの導入を試す事に対して寛容なので(もちろん、それ相応の必要性がないとダメですが)、それも改善の後押しになっていると感じています。