Studyplus Engineering Blog

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

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開発チームは様々な紆余曲折の末に以上のような開発プロセスを行っています。

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

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

Dagger の Assisted Inject 統合とマイグレーション

こんにちは、モバイルクライアントグループの中島です。 年末少し膝を痛めてしまいランニングを中断していたのですが、そろそろ再開していきたい今日この頃。

さて、今回は Dagger が Assisted Inject を統合したことによるマイグレーションについてお話ししたいと思います。 特に、WorkManager と、2/9にリリースされた v2.32 の変更部分とでつまづいたことについてお話したいと思います。

Assisted Inject とは

簡単に言えば、Daggerによる Inject constructor に対して、 Dagger によってinjectできるもの以外のパラメータを入れ込むための機能です。 Androidで一般的な使い所としては、固有のデータIDを用いて詳細を表示する画面などで、そのIDを不変な値としてViewModelのconstructorに入れたいときなどでしょう。

square/AssistedInject

この機能は以前より外部サポートライブラリである square/AssistedInject を用いて実現されていました。

compileOnly `com.squareup.inject:assisted-inject-annotations-dagger2:0.6.0`
kapt `com.squareup.inject:assisted-inject-processor-dagger2:0.6.0`
@Module(includes = [PresenterModule::class])
abstract class HogeModule {
    // ViewのInjector定義など
}

@AssistedModule
@Module(includes = [AssistedInject_PresenterModule::class])
internal abstract class PresenterModule
// ViewModel
class HogeDetailViewModel @AssistedInject constructor(
    @Assisted private val hogeId: String,
    private val repository: HogeRepository,
) : ViewModel() {

    @AssistedInject.Factory
    interface Factory {
        fun create(hogeId: String): HogeDetailViewModel
    }
}
// View
    private val args: HogeDetailFragmentArgs by navArgs()

    @Inject
    lateinit var viewModelFactory: HogeDetailViewModel.Factory
    private val viewModel: HogeDetailViewModel by assistedViewModels {
        viewModelFactory.create(hogeId = args.hogeId)
    }

なお、assistedVieModels はtakahiromさんの以下の記事を参考にさせていただき作成して運用している、Dagger用の拡張関数です。 詳しくはそちらをご参照ください。

qiita.com

Dagger 2.31 における公式への統合

今年の1月15日、 Dagger v2.31 のアップデートにて Assisted Injection が公式に統合されました。

github.com

これにより square/AssistedInject の依存が消え、PresenterModule など、 Module への追加記述も必要なくなりました。

// ViewModel
class HogeDetailViewModel @AssistedInject constructor(
    @Assisted private val hogeId: String,
    private val repository: HogeRepository,
) : ViewModel() {

    @AssistedFactory <- // ここだけアノテーション名が違います
    interface Factory {
        fun create(hogeId: String): HogeDetailViewModel
    }
}
// View
    private val args: HogeDetailFragmentArgs by navArgs()

    @Inject
    lateinit var viewModelFactory: HogeDetailViewModel.Factory
    private val viewModel: HogeDetailViewModel by assistedViewModels {
        viewModelFactory.create(hogeId = args.hogeId)
    }

このマイグレーションについてもtakahiromさんの記事が大変参考になりました。

qiita.com

今回つまづいたところ

本題に入っていきます。公式の Assisted Injection へのマイグレーションを行なう上で、つまづいた点が二箇所ほどありました。

  • WorkManager のビルドが通らない
  • 同じ型の Assisted パラメータが判別できない

順を追って説明していきます。

WorkManager のビルドが通らない

Studyplus Android では一部のバックグラウンド処理に WorkManager を利用しています。 WorkManager の Assisted Inject について詳しくは以前の記事をご覧ください。

tech.studyplus.co.jp

問題

ViewModelと同様にマイグレーションを行なっていたところ、ビルドエラーが発生しました。

interface ChildWorkerFactory {
    fun create(appContext: Context, params: WorkerParameters): ListenableWorker
}

class HogeWorker @AssistedInject constructor(
    @Assisted private val appContext: Context,
    @Assisted private val params: WorkerParameters,
    private val repository: HogeRepository,
) : CoroutineWorker(appContext, params) {

    override fun doWork(): Result {
        // ~~
    }

    @AssistedFactory // <- アノテーションの変更
    interface Factory : ChildWorkerFactory
}
エラー: [~~.ChildWorkerFactory.create(android.content.Context, androidx.work.WorkerParameters)]
Invalid return type: androidx.work.ListenableWorker. An assisted factory's abstract method must return a type with an @AssistedInject-annotated constructor.

該当する Dagger の生成コードを見てみます。

    @dagger.assisted.AssistedFactory()
    public static abstract interface Factory extends ~~.ChildWorkerFactory {
    }

要は何もoverrideされていないので、継承元である ChildWorkerFactory のままListenableWorkerをcreateしようとしているようです。 その結果、「@AssistedInjectのアノテーションが付与されているconstructorがないぞ」と言われているわけですね。

それならばとcreateメソッドまでoverrideして、返り値の型を「@AssistedInjectのアノテーションが付与されているconstructorを持つWorker」にしてみました。

    @AssistedFactory
    interface Factory : ChildWorkerFactory {
        override fun create(appContext: Context, params: WorkerParameters): HogeWorker
    }

その結果、またビルドエラーが出ましたがメッセージが変わりました。

エラー: 不適合な型:
 dagger.internal.Factory<HogeWorker_Factory_Impl>をProvider<~~.HogeWorker.Factory>に変換できません:

…?

FactoryをProviderに変換できない? それはそうだろうと思うのですが、そこが変わるような変更を加えた覚えがなく、色々いじっても解決することなく結局合計2日程度ここで止まってしまいました。

解決

2月9日、 Dagger v2.32 のアップデートにて解決しました。 リリースノートを見ると、 Java 7で起きる型推論issueだったようです。

余談ですが、試しにDagger v2.31.2 へ戻した上でWorkManagerのあるモジュールをJava 8でビルドしてみたところ、2日間悩んでいたのが嘘のように通りました。 Dagger 関連のコードではなく、gradleでJavaバージョンを変更することで通るようになるとは発想が至りませんでした…修行不足です。

// build.gradle(:workmanager)
compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
}

v2.32 で修正されたので「DaggerのためにJava 8でビルドする」ということはもう必要ないとは思いますが、デフォルトでJava 8になるのはいつかなぁなどとふと思いを馳せました。

同じ型の Assisted パラメータが判別できない

問題

v2.32 にすることでWorkManagerの問題は解決したのですが、また新たな壁が立ちはだかりました。 同じ型のパラメータがAssisted Injectされているとエラーメッセージが出る問題です。

エラー:
 @AssistedInject constructor has duplicate @Assisted type: @Assisted java.lang.String
解決

これについてはv2.32のリリースノートを見てすぐ解決しました。

Parameters in @AssistedFactory classes that have the same type now require a name to be set via @Assisted("foo") to disambiguate between arguments. Previously, order of parameters was used.

今までは記述された順番でパラメータの対応をしていたけど、v2.32 からは @Assisted("foo") でそれぞれに名前を設定してcreateメソッドと対応させるよ、ということですね。 やることとしては、同じ型をAssisted Injectしている箇所全てに名前を付けていくだけでした。

// ViewModel
class HogeDetailViewModel @AssistedInject constructor(
    @Assisted("hogeId") private val hogeId: String,
    @Assisted("fugaId") private val fugaId: String,
    private val repository: HogeRepository,
) : ViewModel() {

    @AssistedFactory
    interface Factory {
        fun create(
            @Assisted("hogeId") hogeId: String,
            @Assisted("fugaId") fugaId: String,
        ): HogeDetailViewModel
    }
}

アノテーションへの値の設定もですが、createメソッドのパラメータにも@Assistedアノテーションが必要になったのは新しい要素ですね。 以前のsquare/AssistedInject では引数の名前そのものの一致で対応させていたので、自由度は増えたけど少し冗長かなという印象は否めない感じでしょうか。

終わりに

簡単ではありますが、Studyplus Androidにおける、Dagger の公式Assisted Inject対応の際に引っかかった事例を紹介しました。

Assisted Injectは、Studyplus Androidにとって必須の機能として重宝しているので、 Dagger に統合されたのは非常に嬉しいですね。 square/AssistedInject の完成度も高かったためかどうしても比較してしまう部分もあります。 ですが、issueの対応なども積極的に行なわれているので、これからもより便利になっていくだろうと思っています。

最後までご覧いただき、ありがとうございました!