Studyplus Engineering Blog

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

入社して1年で見えてきた弊社のすてきなところ

こんにちは、Studyplus事業部サーバーグループの葉坂です。スタディプラスに入社して約1年が経ちました。

そこで、本記事では入社して1年経って見えてきた私の感じる弊社のすてきなところを紹介していきます。タイトルからして会社に忖度している胡散臭い記事に見えるかもしれませんが、すべて事実なのでご安心ください。

特に現在、転職活動中の方の参考になればと思います。

入社時に希望のスペックのPCを貸与してもらえる

35万円(税抜き)までを目安として希望のスペックのPCを貸与してもらえます。この予算であれば、ほとんどのPCを網羅できているはずです。ちなみに2年ごとに買い替えることもできます。

※予算に関しては入社時期によっては多少前後するかもしれません。

柔軟性の高い働き方ができる

フルフレックスとフルリモートを導入しているので、各々の生活に合わせて自由に働くことができます。もちろんコアタイムなしです。また、入社時からずっとフルリモートのメンバーもいたりします(私もそうです)。

ちなみにオンボーディングの際は、ZoomやSlack Callを繋ぎっぱなしにし、いつでも質問しやすい環境を作ってもらいました。 また、Slackに疑問点を投稿しておくと、メンバーが拾って答えてくれるので、リモートワーク中心の働き方でも特に困ることはありません。

社内勉強会が豊富

※下記はすべて業務時間内で枠を確保し開催しています。

Kubernetes輪講会の開催

以前私が書いた「Kubernetes輪講会を開催しました」でも紹介しているので詳細は省きますが、Kubernetesの本番導入に伴いサーバーチームもKubernetesの運用上必要な知見を高められるようSREチームと合同でKubernetes輪講会を開催しました。

tech.studyplus.co.jp

Golang輪講会の開催

サーバーチームが担当している10あるマイクロサービスのうち1つがGolangで書かれていることや、今後新たなマイクロサービス作成の際の言語の選択肢を増やすため、「Goプログラミング実践入門」を用いて輪講会を開催しました。

book.impress.co.jp

ちなみに

現在はGolang輪講会の開催枠で、Golangを使って各々が作りたいものを実装するモクモク会を開催しています。

個人主催の勉強会・共有会の開催

今後取り入れていきたい技術について勉強会を開催したり、新しく導入した技術・仕組みについての共有会も行っています。

  • Ruby2.7勉強会
  • Hanami勉強会
  • Kustomize共有会
  • Skaffold共有会
  • Datadog共有会
  • BigQueryへのデータ連携の仕組み共有会
  • etc...

ポストモーテムを書く文化がある

障害が発生した際には、ポストモーテムを書く文化があります。 障害内容のドキュメント化、根本原因の理解、再発防止策の検討・導入などを目的としています。 また、当たり前のことではありますが、犯人探しをして非難したりする場には決してならず、心理的安全性が高いです。 個人的には、障害対応のプロセスを振り返ることで苦手なインフラ周りの知見を高めることができるので、大変ありがたいです(障害は発生しないに越したことはありませんが)。

4半期ごとに社内LT大会が開催される

社内で開発された機能の共有だけでなく、各チームが開発する上で行った技術的な取組についても学ぶため、4半期ごとにLT大会を開催しています。

ユーザーの声を聴く習慣がある

Slack上でユーザーからの問い合わせが流れてくるようになっており、エンジニアもそれに目を通す文化があります。問い合わせにはカスタマーサポートと連携して対応にあたっています。 また、Studyplusを使用しているユーザーへのインタビューにエンジニアも参加できます。

褒め合う文化がある

これは私が所属するサーバーグループの話になりますが、大きな成果を上げたときだけでなく、小さなことでもメンバー同士がSlackで褒め合うというすてきな文化があります。

改善タスクを優先する時間を設けている

これも私が所属するサーバーグループの話になりますが、毎週金曜日の午後は改善タスクを優先する時間としています。これがないと日々のタスクばかりを優先し、技術的負債が増えてしまうため、そうならない工夫として時間を設けています。

細かいことですが

ミーティングの曜日が割と決まっている

開発中、合間にミーティングが入るとどうしてもスイッチングコストが高くなってしまいます。 それを防ぐため、サーバーグループではミーティングはなるべく火〜木曜日に設定し、月曜日と金曜日を開発に集中する作業デーとしています。

有給取得の自由度がかなり高い

有給取得の際は、前日までにSlackに投稿しておけば良いというかなり自由な環境です(常識の範囲内でチームに迷惑をかけない程度になりますが)。 もちろん取得日がわかったら早く共有するに越したことはありませんが、「あ!」という日や、「推しが急に...」な人にも優しい環境です。

最後に

最後まで読んでいただきありがとうございます。

いかがでしたでしょうか。弊社のすてきなところ、伝わりましたでしょうか。また、少しでもスタディプラスに興味を持っていただけたでしょうか。

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

詳しくは下記をご覧ください。 open.talentio.com

Ruby / Railsにおけるカレンダー機能を振り返る

こんにちは、ForSchool事業部サーバーサイドエンジニアのましばです。

Studyplus for Schoolでは3月にカレンダー機能をリリースしました。 色々と大変なこともあったので振り返りを含めて記事にしたいと思います。

iCalendarについて

カレンダー機能では、生徒や先生が登録した学習計画をiOSやAndroidのカレンダーアプリ上でも確認できる必要があります。
今回の実装ではカレンダーアプリと連携する手段としてiCalendarを使用しました。
iCalendarはカレンダーやスケジュールをインターネット上でやりとりするためのデータフォーマットであり、RFC5545で定義されています。

iCalendarでは、予定はイベントコンポーネントによって定義されます。
例えば、ある単発の予定は以下のようなイベントとして記述できます。

BEGIN:VEVENT
DTSTART:20210521T190000Z
DTEND:20210521T200000Z
SUMMARY:ブログ記事を書く
END:VEVENT

カレンダーといえば繰り返しの予定ですが、これはイベント内にRRULEを追加することで対応できます。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T120000
DTEND;TZID=Asia/Tokyo:20210521T130000
RRULE:FREQ=DAILY
SUMMARY:お昼ごはん
END:VEVENT

このように記述すると、毎日繰り返しの予定を定義することができます。

RRULEにはいくつか設定できる項目があります。以下に例を挙げます。

  • INTERVAL: 予定を繰り返す間隔。DAILY;INTERVEL=2とすれば1日おきの予定になります。
  • UNTIL: 繰り返しの終了を設定できます。
  • COUNT: 予定を繰り返す回数を設定できます。

繰り返す条件も様々な指定方法があり、毎日、毎週、毎月や日付指定、曜日指定なども可能です。

その他に、予定を作成しない例外の条件としてEXRULEEXDATEを指定することができます。
例えば以下のようにEXDATEを設定すると、5月31日にこの繰り返しの予定は定義されないようになります。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T120000
DTEND;TZID=Asia/Tokyo:20210521T130000
RRULE:FREQ=DAILY
EXDATE;TZID=Asia/Tokyo:20210531T120000
SUMMARY:お昼ごはん
END:VEVENT

ice_cubeによる実装

ForSchool事業部ではサーバーサイドをRuby / Railsで開発しています。
iCalendarを扱うgemはいくつかありますが、繰り返しの予定を多く扱うことや、ドキュメントの量なども加味して、今回はice_cubeを利用しました。
使い方は直感的で、IceCube::Scheduleクラスのインスタンスを作成し、必要な条件をメソッドで設定していくだけです。

schedule = IceCube::Schedule.new(Time.zone.parse('2021-05-21 10:00:00', end_time: Time.zone.parse('2021-05-21 11:00:00'))
# 毎日繰り返しの予定として設定
schedule.add_recurrence_rule(IceCube::Rule.daily)
# 22日から30日までに存在する予定を取得
schedule.occurrences_between(Time.new(2021, 05, 22), Time.new(2021, 05, 30))
# => [Sat, 22 May 2021 10:00:00 JST +09:00,
#  Sun, 23 May 2021 10:00:00 JST +09:00,
#  Mon, 24 May 2021 10:00:00 JST +09:00,
#  Tue, 25 May 2021 10:00:00 JST +09:00,
#  Wed, 26 May 2021 10:00:00 JST +09:00,
#  Thu, 27 May 2021 10:00:00 JST +09:00,
#  Fri, 28 May 2021 10:00:00 JST +09:00,
#  Sat, 29 May 2021 10:00:00 JST +09:00]

# 25日は例外に設定
schedule.add_exception_time(Time.zone.parse('2021-05-25 10:00:00'))
# 24日の次の予定が26日になっている
schedule.next_occurrence(Time.zone.parse('2021-05-24 10:00:00'))
# => Wed, 26 May 2021 10:00:00 JST +09:00

大変だった点

繰り返しデータのモデル化

カレンダーにおいて、繰り返しの予定は終了期限を設定しない限り半永久的な予定となります。
そのため、全ての発生するイベントをデータとして保持することは現実的ではありません。
今回の実装では、開始日の予定と繰り返し条件のみデータとして保持し、その後の予定はice_cubeのメソッドにより取得するようにしました。
なので、あるユーザーの特定の期間の予定を取得する場合は

  1. ユーザーの開始日の予定を取得
  2. 繰り返し条件と組み合わせて繰り返しの予定を取得
  3. 期間内に含まれるものを返す

といった処理になります。
なかなか複雑な実装だったので、検証するのが大変でした。

予定の編集、削除処理

最も苦労したことが、予定の編集と削除に伴うデータの更新処理でした。
例えば、ある日付以降は異なる条件の繰り返し予定に編集したい場合があります。
その場合、iCalendarでは古い条件にUNTILを設定し、新たなイベントを追加で作成します。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T120000
DTEND;TZID=Asia/Tokyo:20210521T130000
RRULE:FREQ=DAILY
UNTIL;TZID=Asia/Tokyo:20210531T235959
SUMMARY:お昼ごはん
END:VEVENT

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210601T121000
DTEND;TZID=Asia/Tokyo:20210601T131000
RRULE:FREQ=DAILY
SUMMARY:10分からお昼ごはん
END:VEVENT

一方で、もともとの予定の開始日からすべての予定を変更したい場合は、単に条件を書き換えるだけで可能です。

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20210521T121500
DTEND;TZID=Asia/Tokyo:20210521T131500
RRULE:FREQ=DAILY
SUMMARY:15分からお昼ごはん
END:VEVENT

このように、予定全てを変更するか、予定の途中以降を変更するかで期待する動作が異なります。
利用しているユーザーが予定を変更する時、その予定が開始予定日のものかそれ以降のものをかを意識することはありません。このため、リクエストが来た際に変更日時が予定開始日に相当するのかそれ以降の日時なのかをサーバー側で判断し、その後の処理を分岐させていく必要があります。
この他にも、変更条件や予定のバリデーションなども含めると想定が必要なケースがかなり増えてしまい、テストがとても大変でした。

最後に

非常にざっくりとでしたが、カレンダー機能の実装について振り返りました。
スケジュール的にもかなりタイトで大変でしたが、大きな不具合もなくリリースできてよかったです。
また、世の中のカレンダーアプリがいかにすごいかを実感することになりました。Googleカレンダーは半端ないですね。

最後に宣伝です。
Studyplus/Studyplus for Schoolではエンジニアを募集中です。
カジュアル面談から対応可能なのでご興味がある方はぜひご連絡ください。 www.wantedly.com open.talentio.com

Kustomize Componentsで構成管理

こんにちは。SREの菅原です。

突然ですがKustomize便利ですよね。

弊社ではKubernetesのManifest管理にKustomizeを使っています。Kustomizeの機能は複数ありどれも便利なのですが、今回はその中でもComponentsという機能を使って便利なのかどうなのかという話をしたいと思います。

KustomizeのComponentsについて

※KustomizeのComponentsについて話したいので、Kustomize自体の説明は省きます。

Kustomize Componentsはv3.7.0から使えるようになった機能です。

kubectl.docs.kubernetes.io

どのような機能かというと、componentsというディレクトリにManifestを配置します。それを各環境のkustomization.yamlから指定してあげることでそのManifestを各環境にapplyすることができます。

例えば、DatadogのManifestをcomponentsディレクトリに配置してproduction環境だけにapplyするようにしてみます。 以下のようにcomponents/datadogというディレクトリと各環境用ディレクトリを作ります。(※わかりやすさのために、Manifestは一部割愛しています。)

├── base
│   └── kustomization.yaml
├── components
│   └── datadog
│       ├── datadog.yaml
│       └── kustomization.yaml
└── overlays
    ├── staging
    │   └── kustomization.yaml
    ├── local
    │   └── kustomization.yaml
    └── production
        └── kustomization.yaml

そして以下のようにkustomization.yamlを作成します。

  • components/datadog/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component

resources:
  - datadog.yaml
  • overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

commonLabels:
  env: production

resources:
  - ../../base

components:
  - ../../components/datadog

これでproduction環境だけにDatadogのリソースをapplyすることができました。つまり、Componentsは必要な環境にだけManifestをapplyするということができます。

Componentsの便利なところ

Componentsは簡単にManifestを追加できるので、各環境すべてに入れたくは無いけれどスポットで使いたいというケースで便利だと感じます。

例えば普段はDatadogをproduction環境だけで使っているけれど、検証のため一時的にstaging環境へ入れたいというパターンで便利になります。

以下のようにcomponentsをstagingのkustomization.yamlに追加してapplyするだけで簡単に配置できます。

  • overlays/staging/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

commonLabels:
  env: staging

resources:
  - ../../base
# datadogをstagingに追加
components:
  - ../../components/datadog

ご存知の通りKustomizeにはpatchesStrategicMergeというものがあります。これはbaseにある変更元のファイルにoverlayで用意したpatchファイルをマージして、環境毎の差分を吸収する方法です。もちろんこの方法でもやり方によっては必要な環境にだけ配置できるのですが、Componentsを使ったほうが少ない修正で済みます。

Componentsで躓いたところ

便利なComponents機能ですがnamespaceの設定で躓いたところがありましたので注意が必要です。 何をしたかというとDatadogにnamespaceを一括で設定したかったので以下のような設定をしてみました。

├── base
│   └── kustomization.yaml
├── components
│   ├── datadog
│   │   ├── datadog.yaml
│   │   ├── namespace.yaml
│   │   └── kustomization.yaml
│   ├── cluster-autoscaler
│   │   ├── cluster-autoscaler.yaml
│   │   └── kustomization.yaml
│   └── metrics-server
│       ├── metrics-server.yaml
│       └── kustomization.yaml
└── overlays
    ├── staging
    │   └── kustomization.yaml
    ├── local
    │   └── kustomization.yaml
    └── production
        └── kustomization.yaml
  • components/datadog/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: datadog
  • components/datadog/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
# namespaceを追加
namespace: datadog

resources:
  - datadog.yaml
  - namespace.yaml
  • overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

commonLabels:
  env: production

resources:
  - ../../base

components:
  - ../../components/cluster-autoscaler
  - ../../components/datadog
  - ../../components/metrics-server

この設定でapplyするとdatadogだけではなく、cluster-autoscalerやmetrics-serverもdatadog namespaceに配置されてしまいます。つまり、componentsディレクトリ配下に配置している他のManifestにも影響が及んでしまいます。

この挙動に関してはこちらのコメントで詳しく説明されており、Componentsではなく通常のKustomizationで行うことを勧められています。

なのでnamespaceを設定したい場合は以下のような通常のKustomizationで設定をするか、datadog.yaml自体にnamespaceを設定する方が良さそうです。

├── base
│   └── kustomization.yaml
├── components
│   ├── cluster-autoscaler
│   │   ├── cluster-autoscaler.yaml
│   │   └── kustomization.yaml
│   └── metrics-server
│       ├── metrics-server.yaml
│       └── kustomization.yaml
└── overlays
    ├── staging
    │   └── kustomization.yaml
    ├── local
    │   └── kustomization.yaml
    └── production
        ├── datadog
        │   ├── datadog.yaml
        │   ├── kustomization.yaml
        │   └── namespace.yaml
        └── kustomization.yaml

まとめ

今回はKustomize Componentsを使ってみて便利だったところ、躓いたところを紹介してみました。Componentsは選択的に環境に配置することができて便利なのですが、通常のKustomizeとComponentsどちらを使うかというとその時の状況によるかなと思います。

Componentsを使ってみたいと考えている方の参考になれば、非常に嬉しい限りです。

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

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