RareJob Tech Blog

レアジョブのエンジニア・デザイナーによる技術ブログです

Vegetaライブラリを使ってGoでちょっとこだわった負荷試験シナリオを作る

こんにちは!プラットフォームチームの池田です。2回目の投稿になります。

元々ハンバーガーが好きで社内でもそれで自分を売り出していたのですが、つい最近とんでもない対抗馬と出会ってしまいました。そう、バインミーです(今更?)。

時代と文化が生んだ奇跡。私はハマってしまいました。バインミータベタイ

閑話休題、直近で負荷試験を実施する機会があり、それに関するトピックを紹介します。

はじめに

私が所属するチーム(プラットフォームチーム)ではGo言語をメインに開発していることもあり、負荷試験においてもGoでカスタマイズできるツールを探していました。特に気になったのがGo言語で開発されているOSSVegetaのライブラリでの利用方法でして、今回試してみました。

Vegetaライブラリを使った動的なテストシナリオの方法を紹介した記事はあまり見受けられず、ベストな選択肢では無いと承知しながらも、この記事を書いてみようと思いました。

【目次】

Vegetaとは?

安定した性能を発揮する多機能なHTTP負荷テストツールです。

GitHubのREADMEでは名前の由来である"彼"の姿を見ることできます。

Vegetaツール自体は古き良きApache HTTP server benchmarking tool (ab)と同様のCLIベースのツールです。

abと比較して、HTTP/2対応、分散攻撃機能、結果プロッティング機能などの付加的な特徴があります。(本記事ではこれらの機能には言及しません。)

Vegetaライブラリを利用するメリット

Vegetaライブラリで負荷試験シナリオを作るメリットは以下としています。

  • Goをメインに開発しているチームにとってスムーズに作成と保守ができる
  • Goの特徴であるシンプルなクロスコンパイルにより、どの環境にもシングルバイナリとして簡単に乗せることができる

逆にデメリットは以下になります。

  • JMeterGatlingLocustなど他の負荷テストツールと比較するとコードが複雑になり可読性が良くない

今回のシナリオの想定

プラットフォームチームが保持するマイクロサービスの1つである会員基盤サービス(REST API) に対する負荷試験という前提で進めます。

本記事では、弊社サービスにおいてシンプルだけれどもアクセスピークがクリティカルなシナリオとして、以下のユーザーストーリーを想定します。(実際の弊社サービスに対して正確な表現ではありません。)

  • Step1: 英会話レッスンが始まる直前の時刻にユーザーがログインする
  • Step2: レッスン予定を閲覧できるページにアクセスする(レッスン開始ボタンがある)
  • Step3: 開始ボタンを押してレッスンルームに入る

このとき、会員基盤サービスに関して、以下のようなやり取りが行われます。(下記システム構成も実際の弊社システムに対して正確ではありません。)

ログイン処理

f:id:ochataro:20200901234249p:plain
login

スケジュール取得

f:id:ochataro:20200901234329p:plain
schedule

単一アカウントのみのテストの場合

1つの会員アカウントを使って負荷をかける場合、VegetaのCLIを利用すれば簡単にテストが可能です。

f:id:ochataro:20200901234534p:plain

以下のログインエンドポイント用のJSONペイロードファイル(login.json)と対象エンドポイントを記載したファイル(target.txt)を用意します。

予定取得エンドポイント(/schedule)にてJWTトークンは予め用意したものを利用します。

login.json

{
  "email": "test_user@example.com",
  "password": "Password"
}

target.txt (※ この記事ではあえてローカル環境をターゲットにしています。)

POST http://localhost:8090/login
Content-Type: application/json
@login.json

GET http://localhost:8090/schedule
Content-Type: application/json
Authorization: Bearer xxxxx.yyyyy.zzzzz

負荷を以下のようなオプションでかけます。このとき、対象アカウントにてログインとスケジュール取得リクエストを交互に繰り返します。

vegeta attack -targets=target.txt -rate=50/s -duration 600s | \ 
  vegeta report -type=json | \
  jq

複数アカウントのテストの場合

テストシナリオを実環境にできる限り近づけるべく、テスト環境でバラバラな設定を持つユーザーらを事前に準備し負荷試験に利用するという前提です。

f:id:ochataro:20200901234517p:plain
multi

ポイントとなるのは、負荷時にログイン処理から返るJWTトークンを動的に取得し、予定取得エンドポイントのリクエストにてAuthorizationヘッダーへ取得したトークンを入れる処理を実装する必要があるということです。

プログラム

1000アカウント分のクレデンシャル情報を持つCSVファイルを事前に攻撃サーバに用意します。

test1@example.com,Passw0rdDAZE!
test2@example.com,Passw0rdNANOKA?
.
.
.
test1000@example.com,Passw0rdKAMONE&

Goのソースコードは下記になります。Vegetaライブラリのドキュメントとツール側の実装を参考にしながら作成しました。ちょっと長めなので折りたたんでいます。

主にJWTトークンの受け渡し部分と1000アカウント終了時に攻撃を停止する制御の部分でコードが少々複雑になってしまいましたがこれで動きます。

負荷をかける!

【はじめに注意】本記事用にダミーな負荷対象HTTPサーバをローカルに立てて検証していますので、下記の結果は弊社サービスの実際のシステムとは全く関係の無い結果であることにご留意ください。

上記のプログラムを動かし負荷をかけると結果が得られます。今回は2つのエンドポイントを混ぜ合わせた1シナリオとしての結果が得られるようにしています。

$ go build -o attack
$ ./attack -rate=50 -duration=60 | jq
{
  "latencies": {
    "total": 808174235516,
    "mean": 404087117,
    "50th": 410460112,
    "95th": 457702034,
    "99th": 470219198,
    "max": 509059811
  },
  "bytes_in": {
    "total": 64000,
    "mean": 32
  },
  "bytes_out": {
    "total": 52893,
    "mean": 26.4465
  },
  "earliest": "2020-mm-ddTHH:MM:SS.097961559+09:00",
  "latest": "2020-mm-ddTHH:MM:SS.097961559+09:00",
  "end": "2020-mm-ddTHH:MM:SS.097961559+09:00",
  "duration": 39957268820,
  "wait": 458157781,
  "requests": 2000,
  "rate": 50.053471097076844,
  "throughput": 49.4860544154324,
  "success": 1,
  "status_codes": {
    "200": 2000
  },
  "errors": []
}

攻撃結果を得ることができました。上記の場合は出力をJSONフォーマットにしていますが、プロッティングとして可視化できる仕組みもあります。

おわりに

複雑になってしまったリクエスト間のやり取りと終了時の制御は上手く抽象化すればプラグイン的な物が提供できそうだなと感じました。また次の機会に取り組みたいです。

参考

NuxtでAPI RequestをComposition Functionにする

APP/UXチームに所属しております、フロントエンドエンジニアの田原です。

夏が終わり、最近すっかり肌寒くなってきましたが皆さん如何お過ごしでしょうか?
最近、私の周りで結婚や婚約する友人・知人がとても多く、おめでたい出来事に嬉しくなる反面、個人的にはとても心寒い日々が続いております。

話は変わり、Vueを扱うフロントエンドエンジニアの皆さんにとっては既知の出来事かと思いますが 先日、Vue3が正式にリリースされたので界隈では大いに賑わっておりました。

github.com 名称がワン●ースなので、世はまさに大Vue時代、「Vueのアーキテクチャーか?欲しけりゃくれてやる。探せ!この世のすべてを3.0系に置いてきた!」 的なノリなのかな?とか勝手に感じておりますw

このリリースによりVue自体に新しく追加された機能は幾つかありますが、Composition APIを正式に使えるようになったということを嬉しく思っている方が多いのかなと思っています。(私もその一人です)

弊社でもこのリリースを見越してComposition APIをプロダクト内で利用できるようにしていきたいね!ということだったので 実験的な部分もありますがNuxtベースに作成しているプロダクト内でComposition APIを使えるようにしております。 参考:Nuxt Composition API

今回はNuxt+Composition APIを使い、Ajax Request(axios-moduleを利用)をComposition Functionとして関数を切り離した 実装をご紹介できればと思っております。まだまだ手探り状態である為、一つの方法として見て頂けると嬉しいです。 もっとこうしたら良いよ!などの優しい指摘・意見を頂けると嬉しいです。

目次

Composition Apiとは

自身の理解を大まかに一言で示すと 「ロジックの再利用性の向上とロジックとComponentの依存性を低くできる」新しい仕組みと捉えております。

こちら→公式Composition APIに詳細な説明がありますし、 様々な方が利点や旨みについて説明されているのでこのAPIの基本的な使い方や 意義についてなどの説明については、末尾参考の各記事をご覧ください。

useApi.tsの作成

まずはベースとなる、API Requestを行うFunctionの作成を行います。 ※ファイル名、変数名については適当なものを設定しているのでご容赦を

※尚、useFetchはNuxt Composition APIの機能名として既に存在する為、ご注意ください。 今回はuseApi.tsという名称でベースを作成します。

import { reactive, toRefs } from '@nuxtjs/composition-api'
import { NuxtAxiosInstance } from '@nuxtjs/axios'

// 各型は参考まで
type Options = {
  headers: {
    'X-transaction-ID'?: string
    'x-api-key'?: string
    Authorization: string
    'Content-Type'?: string
  }
}
type Params = {
  [key: string]: any
}
type baseState = {
  response: {}
  otherError: Error | null
  isLoading: boolean
}

const useApi = (
  $axios: NuxtAxiosInstance,
  url: string,
  params?: Params,
  options?: Options
) => {
  const state = reactive<baseState>({
    response: {},
    otherError: null,
    isLoading: false
  })
  // GET
  const getData = async () => {
    state.isLoading = true
    try {
      const res = await $axios.$get(url, options)
      state.response = res
    } catch (error) {
      state.otherError = error
    } finally {
      state.isLoading = false
    }
  }
  // POST
  const postData = async () => {
    state.isLoading = true
    try {
      const res = await $axios.$post(url, params, options)
      state.response = res
    } catch (error) {
      state.otherError = error
    } finally {
      state.isLoading = false
    }
  }
  // PUT
  const putData = async () => {
    state.isLoading = true
    try {
      const res = await $axios.$put(url, params, options)
      state.response = res
    } catch (error) {
      state.otherError = error
    } finally {
      state.isLoading = false
    }
  }
  // DELETE
  const deleteData = async () => {
    state.isLoading = true
    try {
      const res = await $axios.$delete(url, params)
      state.response = res
    } catch (error) {
      state.otherError = error
    } finally {
      state.isLoading = false
    }
  }
  return { ...toRefs(state), getData, postData, putData, deleteData }
}
  • @nuxtjs/composition-apiをimportします
  • axios-moduleの型をimportします。
  • useApiという名称で関数を定義して、response値やLoadingステータスなどのstate値をreactiveにします。
  • CRUDの関数を用意します。(ここではtry/catch/finallyを利用しておりますがNuxt内でaxios-moduleを使った一般的なRequestで問題ないです。)
  • returnでstateと各関数を返却します。この時stateはtoRefsでreturnしてuseApi.tsを利用する関数内で個別のreactiveな値として利用できるようにしておきます。

useSampleApi.tsの作成

次にuseApi.tsを呼び出す各endpoint側のComposition Functionを作成します。
ここではuseSampleApi.tsとしています。

import { toRefs, reactive } from '@nuxtjs/composition-api'
import { NuxtAxiosInstance } from '@nuxtjs/axios'
import useApi from '~/composition/useApi'

const useSampleApi = (axios: NuxtAxiosInstance) => {
  const sampleState = reactive<{
    response: any
    error: Error | null
    isLoading: boolean
  }>({
    response: [],
    error: null,
    isLoading: false
  })
  const apiGetTrigger = async () => {
    const { response, otherError, isLoading, getData } = useApi(
      axios,
      `https://hogehogehoge.com`
    )
    sampleState.isLoading = isLoading as any
    await getData()
    sampleState.response = response as any
    sampleState.error = otherError as any
  }
  return { ...toRefs(sampleState), apiGetTrigger }
}
  • こちらもuseApi.ts同様に@nuxtjs/composition-apiとaxios-moduleの型をimportします。
  • useApi.tsから取得できる値を詰め直すstateをreactiveにします。
  • useApi.ts内で設定したXXXData関数(ここではgetData)を利用する関数を用意します。(apiGetTrigger)
  • apiGetTrigger内でaxiosとpathを渡します。(後述しますがaxiosはcomponentから関数利用時に渡します)
  • useApi.tsから返されたreactiveな値をuseSampleで設定したreactiveなstateに代入します。 (この時、toRefsとreactiveの型が異なる為、error回避のためanyでキャストしています。イケてないので良い方法があれば知りたいです。)
  • 関数とreactiveな値をreturnで返却します。

※awaitでgetData()を待つ理由として、api request後にresponseかotherErrorに値が返るのでそうしています。 isLoadigは表示側で常にreactiveな値として変化できるように、最初に代入しております。

Component内での利用

最後にComponent側でuseSampleApi.tsから取得できる各値を利用していきます。

<template>
  <div>
    <h2>Sample</h2>
    <section>
      <form @submit.prevent="apiGetTrigger">
        <button>Getlist</button>
      </form>
    </section>
    <div v-if="otherError">
      <h2>otherError !! {{ otherError }}</h2>
    </div>
    <div v-if="isLoading"><h2>Fetching Data</h2></div>
    <div v-for="list in response" :key="list.id">
      <ul>
        <li>
          <span>{{ list.name }}</span>
          <span>{{ list.street }}</span>
          <span>{{ list.city }}</span>
          <span>{{ list.postal_code }}</span>
        </li>
      </ul>
    </div>
  </div>
</template>
<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'
import useSampleApi from '~/composition/useSampleApi'
export default defineComponent({
  setup(_props, {root}) {
    const { $axios } = root
    const {
      response
      isLoading,
      apiGetTrigger,
      otherError
    } = useSampleApi($axios)

    apiGetTrigger() // こんな感じでsetup内で呼び出すとイベントではなくLifecycle内で呼ぶことも可能です。

    return {
      isLoading,
      response,
      apiGetTrigger,
      otherError
    }
  }
})
  • ComponentをdefineComponetを使って作成
  • setup関数内でrootを指定してpluginとしてinjectされている$axios(axios-module)を取り出す
  • useSampleApi.tsをimportして、setup関数内で各値を取り出す
  • template内で利用したい値をreturnで返す

※apiGetTriggerについてはコメントにある通り、setup関数内で呼び出せばComponentのLifecycle内で呼ぶことも可能です。

まとめ

useXXXの形式のAPI Requestを行う処理を関数単位で外に切り出すことができることで、Component内では返り値を扱うだけで 欲しい情報をtemplate部分に反映させることができる為、Component内にロジックが乗らないので見通しが良くなります。 また、別のComponentにimportして利用すれば同様のRequest処理を行える為、再利用性なども高いと思います。 関数ベースにJestをかければ良いのでJest.mockを利用した疑似API Requestのテストについても簡便に書くことができるので 便利だなぁと恩恵を受けております。 (Jestについては今度、書きます。) まだまだ、できることの把握と理解が追いついてないので手探り状態ではありますが参考になれば嬉しいです。

参考

PlantUMLでAWSのサービスを使う

こんにちは、前回書いた記事からはや2ヶ月ちょっと。 GOTO( Ghost of Tsushima Omoshiroi-zo )はプレイ時間60時間を超え、無事トロコンも完了し、対馬には平和が訪れました。 なので今日はまたPlantUMLの話をします。

PlantUMLでAWSアイコンを出したい

結論から言うと公式がマクロセットを用意してくれているのでこれをincludeすることで実現できます。 CacooやDraw.ioも使うんですが、図自体の可読性や差分の見やすさだとやはりUMLのほうが私は見やすので好きです。 個人のアイデアをまとめたり、説明のために整理するのによく使っています。

@startuml
title AWS

!define AWSPuml https://raw.githubusercontent.com/awslabs/aws-icons-for-plantuml/master/dist
!includeurl AWSPuml/AWSCommon.puml
!includeurl AWSPuml/EndUserComputing/all.puml
!includeurl AWSPuml/Storage/SimpleStorageServiceS3.puml

actor "Person" as personAlias
WorkDocs(desktopAlias, "Label", "Technology", "Optional Description")
SimpleStorageServiceS3(storageAlias, "Label", "Technology", "Optional Description")

personAlias --> desktopAlias
desktopAlias --> storageAlias

@enduml

これで

f:id:jumbos5:20200925111606p:plain

これが出ます。 マクロ一覧もまとめてくれているので参考にしながら書くのが良いと思います。

マクロ一覧

注意点

!includeurl AWSPuml/EndUserComputing/all.puml

仕組み的に「必要なアセットをincludeしてdefineされているマクロに引数を私で描画する」と言う仕組みのため、 includeが必要になりますが、all.pumlは各フォルダ・コンポーネントのまとまりごとに用意されており、まとめてincludeできるのですが、重いので必要なコンポーネントだけincludeするようにしましょう。

どう言う仕組みで実現されているか1 「define」

PlantUMLにはマクロがありこれを定義して実現されています。

!define SampleMacro(v1, v2) [v1] -> [v2]
SampleMacro("value1", "value2")

こんな感じで定義できるので、これを使っています。 あまり自分で書いているときはそこまで再利用性考えて作らないのですが、アイコンつけたりする場合は真似したいですね。

どう言う仕組みで実現されているか2 「sprite」

PlantUMLで画像を読み込む際はグレースケールにした値を定義する必要があります。

sprite $ARVR [64x64/16z] {
xTG3WiH054NHzutP_th7RHkfsmnEdE1HZMZsIn0_DGDuuVsZJwnMVJ-57txuuKrsP4Tv1mjl3Nw43qZlo147VO9xPueyu8j1l3jm7V0GtPFWe8_UKzpL3rzc
TO4l0gZEzufCsDd-rnhoN2zKtKLoWk-bkHq--vabr0TypEy_WiwEmc9K7FATAd_fVDwOZygdU_uEF_pmLgUMA_wChkV1SavCc4LdXNVe2m
}

この機能もPlantUML自体が持っているので自前の画像を定義してAWSのようにマクロを追加することも可能です。

$ java -jar plantuml.jar -encodesprite 16z xxx.png

構造が複雑化してくると、線がカオスになってしまい図が複雑化してくるところを除いてとても気に入っています。 それは良い週末を

Google Chatのbotからスプレッドシートデータを読み出してみる

こんにちは、はじめまして
ITソリューションチームの平川です。

ITソリューションチームはいわゆる「情報システムチーム」でして、
日々PCについての問い合わせや依頼を受けています。
PCの作業依頼はワークフローで届くので、PC管理番号と利用者が正しいかなどの確認を行なって承認→作業となっていきます。

そこで、PC管理番号と利用者の確認はPC台帳なるものを都度確認するわけですが、この作業がめんどくさいんです。

本当に。

いちいち台帳開かなくても、誰かに聞けば教えてくれるようにならないかなと思いGoogle Chatを使って試してみました。

ではやっていきます。

GCPプロジェクトの作成

まずはGoogle Chat botを扱うプロジェクトを作成していきます。
下記リンクに沿って進めていきます。

Publishing bots  |  Google Chat API  |  Google Developers

[Enable API]ボタンをポチッと押して新しいプロジェクトを作成します。
ここでは「ChatbotTEST」と名前をつけて進めます。 

f:id:Peperonciiiiiiino:20200903182145p:plain

JSONファイルはダウンロードしてもしなくても大丈夫です。

f:id:Peperonciiiiiiino:20200903182337p:plain

これでプロジェクトが作成されました。

ボットスクリプトの作成

続いてボットスクリプトを作成していきます。
これもテンプレートが公開されているので利用していきます。

https://script.google.com/create?template=hangoutsChat

 クリックするとスクリプトが作成されますので、名前を変更してデプロイIDを取得します。
[公開] -> [マニフェストからの配置] -> [Get ID] 

f:id:Peperonciiiiiiino:20200903183618p:plain

f:id:Peperonciiiiiiino:20200903183658p:plain

表示された[Deployment ID]を控えておきます。

APIの構成とデプロイ

作成したプロジェクトからボットAPIの構成とデプロイを行なっていきます。

Google Cloud Platform

[ナビゲーションメニュー] -> [APIとサービス] -> [ダッシュボード] 

f:id:Peperonciiiiiiino:20200903184653p:plain

右ペイン最下部に「Hangouts Chat API」があるのでクリックします。

f:id:Peperonciiiiiiino:20200903184748p:plain

ここからボットAPIの設定を行なっていきます。
左メニュー -> [設定]
▼アプリケーション情報
下記情報を入力します
 [ボット名]
 [アバターのURL]
 [説明]
▼機能
 [ボットはダイレクト メッセージ内で機能します]にチェック
▼接続設定
 [App Script Project]を選択し、先の手順で入手している [Deployment ID]を入力します
▼権限
 必要に応じて設定してください

f:id:Peperonciiiiiiino:20200904112851p:plain

続いてサービスアカウントにプロジェクトのオーナ権限を付与していきます。
[ナビゲーションメニュー] -> [IAMと管理] -> [サービスアカウント]

f:id:Peperonciiiiiiino:20200903190302p:plain

表示されたサービスアカウントをコピーします。

f:id:Peperonciiiiiiino:20200903190647p:plain

[ナビゲーションメニュー] -> [IAMと管理] -> [IAM] ->[追加]
サービスアカウント追加して[オーナー]権限を付与します。

f:id:Peperonciiiiiiino:20200903203205p:plain

f:id:Peperonciiiiiiino:20200903203243p:plain

これで、GoogleChatのボットに登録されます。

動作確認その1

作ったボットの動作を確認します。
GoogleChatを開き、登録したbotを追加します。

https://chat.google.com/

[BOT]の横にある+から登録したbotを追加します

f:id:Peperonciiiiiiino:20200904113533p:plain

f:id:Peperonciiiiiiino:20200904113552p:plain

botに話しかけると入力したテキストを返してくれます。

f:id:Peperonciiiiiiino:20200904113836p:plain

テンプレートのスクリプトが動く事を確認できました。

ここまでうまく動いたので、スクリプトを修正してスプレッドシートからデータを読み出しを行なっていきたいと思います。

スプレッドシートの準備

スプレッドシートでPC台帳っぽいものを準備します。
今回はこんな感じの台帳を使います。

f:id:Peperonciiiiiiino:20200904114052p:plain

それからスプレッドシートのURLからIDを取得します。

スクリプトの修正

今回はPC番号[A列]を入力したら、貸出者[B列]が返ってくるようにスクリプトを修正していきます。

こんな感じに修正します。

f:id:Peperonciiiiiiino:20200904115010p:plain

動作確認その2

では、動作を試していきます。
(最初に文字を入力するとスプレッドシートへのアクセスリクエストが行われますので、スプレッドシートのアクセス権があるアカウントで[許可]を行なってください)

f:id:Peperonciiiiiiino:20200904115853p:plain
存在するPC番号の貸与者が正常に表示されました。

これでわざわざ台帳を見なくてもPC番号から利用者(貸与者)がわかるようになりました!


まとめ

GCPやGAS(Google Apps Script)はほとんど使ったことありませんでしたが、テンプレートやらナレッジがたくさんあったので割と簡単に作ることができました。

今回作ったのは単純なルールベース型のボットですが、もう少し作り込めばいくつかの業務改善が行えそうだなと思いました。

 

では、また。

 

 

TypeScript Generics編

APP/UXチームに所属しております、フロントエンドエンジニアの田原です。

皆さん、この夏如何お過ごしでしょうか?
私は夏がとても好きなので本来であれば夏っぽいこと(e.g.海や花火大会や BBQ など)をしたいのですが、 今年はコロナ禍ということもあって外出を極力自粛しているのでこれといって夏らしいことができておりませんでした。

先日、チームのメンバーに「この夏、なにか夏っぽいことしましたか?」と聞いてみたところ 「スイカ食べましたよ ♪」と言われ
なるほど!!どこか外出することやイベントに参加するだけが夏ではないッ...!!
これは盲点だった...っと、とても感銘を受けたと同時にとてもほっこりした気分になりました。

こんな感じで業務以外の話も結構することも多く、チームのメンバーとは仲良しです。
(片思いじゃないって信じてる)
チームだけでなく弊社の開発メンバーは朗らかでとても親切な方々ばかりなので日々とても楽しくお仕事ができております。

さて、少し弊社の雰囲気を感じて頂けたかな?というところで今回の本題に入らせて頂きたいと思います。

弊社でも例の如くTypeScriptを利用して開発を行なっているのですが、今回はその中でも便利な機能であるジェネリクスについて簡単にご紹介できればと思います。

※尚、TypesScriptの基本的な型の説明については一部割愛させて頂いております。

目次

Generics(ジェネリクス)とは?

簡単にいうと型にも引数を設定できるようにして柔軟な取り回しができるようにし、関数やClassの再利用性をあげよう!!というものになります。

これだけでは「お、、、おう、、」
そうなんだなという感想しか出ないと思いますので 以下、簡単な実例で説明します。

引数の内容をそのまま返すだけの簡単な関数があったとします。

const echo = (value) => {
  return value;
};

通常、この関数の引数(value)に型を指定してあげる場合(value:string)(value:number)のように記載すると思います。
引数の型が限定的で明確化している場合についてはその型を指定するで良いのですが複数の型が入る可能性がある時には(value:string|number)というように Union 型で 示したりする場合もあると思います。

const echo = (value: string | number): string | number => {
  return value;
};

Union型のままでも良いのですがこのままではtypeガードやアサーションを設定してあげてvalueの値を関数内の早期に指定してあげないと、 editor上でのインテリセンスがいい感じに効かないという弊害があります。
(つまり、その型の持つJavaScriptの固有のメソッド候補がでない。Union型の場合は共有したものだけがインテリセンスされる。)

const echo = (value: string | number) => {
  // ここではstring型とnumber型に共有したメソッドのみインテリセンスが効く
  // そのため、toUpperCase()などは候補に出ない
  if (typeof value === "string") {
    // ここで初めてstring型だけのインテリセンスが効く
    return value;
  }
};

また、引数の数が限られている場合はこれでも良いのですが以下の様に引数にオブジェクトを渡す場合など、一つ一つ書いていくと冗長になっていくこともあると思います。

const echo = ({ studentId, tutorId, islesson }:{
  studentId: number
  tutorId: number
  isLesson: boolean
}) => {
  return studentId
}

このような時にGenericsを使うとこの様な記述になります。

const echo = <T>(value: T) => {
  return value; // valueの型はTで渡された型になる
};

// 関数呼ぶ時に型を指定してあげる
echo<string>("hello");
echo<number>(2);

Tが型の引数となり関数の引数の型として利用できるようになります。 ※慣例的に大文字のアルファベット(T,S,U)などが使われますがなんでも良いみたいです。

このように柔軟に型を指定できインテリセンスも適切な候補が表示されるようになります。

更にコンパイラが型を推測できる場合には型引数を省略して書けるのでオブジェクトを渡す場合でも冗長にならず柔軟な型指定が可能になります。

const echo = <T>(value: T) => {
  return value; 
};

echo({ studentId: 1, tutorId: 2, isLesson: true });

以下、画像のようにインテリセンス効き、型も適切に判定されていることがわかります。

f:id:ssp0727-lnc:20200828103844p:plain
editor TypeScript

ここまでで簡単に Generics の機能と使い方について理解頂けたかと思います。 以下でもう少し使い方を深掘っていきたいと思います。

■ extends で制約を与える

引数で渡した型に制約を与えたい場合についてはextendsを使います。

const echo = <T extends { lessonSlotNum: number }>(value: T) => {
  // valueに必ずlessonSlotNumが存在する型のメンバとして推測されるためエラーにもならず、インテリセンスも効く。
  console.info(value.lessonSlotNum); // OK
};

echo({ lessonSlotNum: 2 }); // OK
echo({ teacherName: "Mike" }); // Error

このように書くことで関数が呼ばれるときに必ず{ lessonSlotNum: number }の型があるかを確認し、存在しない引数を渡した上で関数を呼ぼうとするとエラーになります。

extendsは以下のようにinterfaceとの組み合わせで指定可能な型を制限させて使うことが多いです。

interface LessonInfoType {
  tutorId: number;
  studentId: number;
}

const echo = <T extends LessonInfoType>(value: T) => {
  return value;
};

echo({ tutorId: 1, studentId: 2 }) // OK
echo({ hobby: "soccer" }); // Error
echo({ tutorId: 1, studentId: 2, hobby: 'soccer' }) // OK

■ keyofを使ってオブジェクトキーの型を動的に型としてプロパディのアクセスに利用する

interface LessonInfoType {
  tutorId: number;
  studentId: number;
}

const echo = <T extends LessonInfoType, U extends keyof T>(
  value: T, // { tutorId: number, studentId: number} の型になる
  key: U // "tutorId" | "studentId" のUnion型になる
) => {
  console.info(value[key])
};

echo({tutorId: 1, studentId: 2}, 'tutorId') // OK
echo({tutorId: 1, studentId: 2}, 'counselingId') // Error

このように型を設定しくことで関数の第一引数に渡したオブジェクトに存在するkey名でのみ、第二引数に渡せるようになります。

■ Vuexで使うとこんな感じで型付けられるよのサンプル

これらを踏まえてVuexでのstoreに簡単に型をつけてみました。
Vuexから提供されている型を拡張する形で型付けを行なっております。
※尚、stateが関数形式なのはNuxt内のVuexであるからです。

何故、これを試してみたかというとVuex4系から入ってくる予定のcreateStoreを使えばVuexの型を CompositionAPIの方法で作成したVueComponentでもTypeSafeに実装ができるようになるみたいなので試してみた次第です。この辺りについてはまたの機会に書きたいと思います。

import { GetterTree, ActionTree, MutationTree, ActionContext } from "vuex";
import { MutationTypes } from "./counterTypes/mutation-types";
import { ActionTypes } from "./counterTypes/action-types";

export const state = () => ({
  counter: 0 as number,
});
export type RootState = ReturnType<typeof state>;

export const getters: GetterTree<RootState, RootState> & Getters = {
  getcounter: (state) => state.counter,
  doubledCounter: (state) => state.counter * 2,
};
export type Getters = {
  getcounter(state: RootState): number;
  doubledCounter(state: RootState): number;
};

export const mutations: MutationTree<RootState> & Mutations = {
  [MutationTypes.SET_COUNTER]: (state, payload: number) => {
    state.counter += payload;
  },
};
export type Mutations<S = RootState> = {
  [MutationTypes.SET_COUNTER](state: S, payload: number): void;
};

export const actions: ActionTree<RootState, RootState> & Actions = {
  [ActionTypes.GET_COUTNER]({ commit }, payload: number) {
    commit(MutationTypes.SET_COUNTER, payload);
  },
};

type AugmentedActionContext = {
  commit<K extends keyof Mutations>(
    key: K,
    payload: Parameters<Mutations[K]>[1]
  ): ReturnType<Mutations[K]>;
} & Omit<ActionContext<RootState, RootState>, "commit">;

export type Actions = {
  [ActionTypes.GET_COUTNER](
    { commit }: AugmentedActionContext,
    payload: number
  ): void;
};

■ まとめ

今更、Genericsの話をするのも情報として鮮度が低いのですが、雰囲気でTypeScriptを使ってしまっていた部分もあったので備忘録も兼ねた内容を書かせて頂きました。 Genericsを使ってでできることについてもまだまだ沢山ありますので、近々第二弾を紹介できればいいなと思います。 まだまだTypeScriptそのものについても全てを理解しきれてはおりませんが面白い機能やテクニカルな方法が沢山あるので適切に使っていけるように更に理解を深めていきたいです。

■ 参考

TypeScript を教えてくれた人

iOS 版レアジョブアプリが Sign in with Apple に対応した話

APP/UX チームの玉置です。
今回は iOS 版レアジョブ アプリの Sign in with Apple 対応が完了しましたので、それについてエモい話をします。
はい、 Sign in with Apple にやっと対応することができました。非常に大変でした。

【目次】

Sign in with Apple とは何か

まずモバイルアプリ界隈では非常に有名ですが、その他の業界ではSign in with Appleの認知度はそこまで普及していないので簡単に説明してみます。
Sign in with AppleApple が開発した Apple アカウント( AppleID )を使ったソーシャルログインの認証システムです。
例えば、 TwitterFacebook のソーシャルログインといった感じのものですね。

レアジョブ が Sign in with Apple に対応した背景と対応後のデザイン

去年、 Apple が突如 iOS13 と一緒に Sign in with Apple を公開して「皆の者、これに対応しなさい!」という号令が発表されました。
全てのアプリがこれに対応しなければならないわけではなく、 FacebookTwitter といった Apple 以外のソーシャルログインを実装しているアプリが対象にされました。

6月末までにソーシャルログインを使う場合は Sign in with Apple 対応が必要になりました。レアジョブは Facebook ログインをサポートしています。
レアジョブ における Facebook ログインユーザーの比率が無視できないぐらい高いので、この Sign in with Apple に対応しなければならなくなりました。

もともとのレアジョブの登録・ログイン画面はこちらになります。

登録画面 ログイン画面
f:id:qed805:20200820135650p:plain
過去の登録画面
f:id:qed805:20200820135712p:plain
ログイン画面

それが Sign in with Apple の対応で iOS 13 以上と iOS 12 以下で見え方が変わるように対応しました。

iOS 13 登録 iOS 13 ログイン画面
f:id:qed805:20200820135924p:plain
リリース後のiOS13での登録画面
f:id:qed805:20200820135956p:plain
リリース後のiOS13でのログイン画面
iOS 12 登録 iOS 12 ログイン画面
f:id:qed805:20200820140048p:plain
リリース後のiOS12以下での登録画面
f:id:qed805:20200820140119p:plain
リリース後のiOS12以下でのログイン画面

スケジュールと見積もりについて

プロダクトオーナーに見積もりを提示してスケジュールを引くために Sign in with Apple の仕組みと Apple サーバーから得られる情報を確認する必要がありました。
Sign in with Apple のサンプルプロジェクトを知ったのは実際に開発が始まってからでしたので、見積もり段階ではどんな情報がどんな風に得られるかわからなかったのですね。

そのため自分のマイマシンで Sign in with Apple を実装する必要がありました。実装すること自体はそんな手間ではありませんでした。

これから Sign in with Apple を実装するのでしたら、先に Apple が公開しているサンプルプロジェクトを確認しておいた方がいいと思います。

Appleのドキュメントとサンプルプロジェクトファイルのダウンロードページ

https://developer.apple.com/documentation/authenticationservices/implementing_user_authentication_with_sign_in_with_apple

見積もりは正確な情報が分かりませんでしたので、 Sign in with Apple 自体のシステムの調査として

  1. 実装方法
  2. 得られるデータの確認
  3. 仕様の調査

の3つの項目を2回に分けて見積もりしました。

アプリ側の実装自体は5人日ぐらいのボリューム感でしたが、 Web 版や API との連携も含めた見積もりで2週間に膨れ上がってしまいました。

開発前に不明であった内容について

  1. ログイン後の挙動 (email, fullnameの扱い)
  2. メールを非公開の内容
  3. ログアウトの方法

1. ログイン後の挙動 (email, fullnameの扱い)

Sign in with Apple で有名なのがログイン後に取得できるデータについてです。
Apple サーバーから取得できるユーザー情報の一部に email と fullname の情報がありますが、この情報は最初の1回目しか取得できず2回目以降は空が渡ってくることです。

再度 email と fullname を取得する手段は残されていて、端末の設定アプリから

Apple ID > パスワードとセキュリティ > Apple IDを使用中のApp > 特定のアプリ

で表示される「Apple IDの使用を停止する」をタップして停止させるとアプリでの Sign in with Apple の使用が止まって 再度 email と fullname を取得できるようになります。

2. メールを非公開の内容

次に Apple 認証の際に Apple ID に紐づいているメールアドレスを公開するかどうかの選択できる部分の仕様について紹介します。

Apple 認証を用いるとアプリで使用する際にメールアドレスを公開するかどうかを選択できます。

  • 「メールを共有」
  • 「メールを非公開」

の2つのオプションがあります。

「メールを共有」して Sign in with Apple するとアプリ側でユーザーのメールアドレスが公開されます。
「メールを非公開」して Sign in with Apple すると Apple 側で生成されたプライベート用のメールアドレスが発行されます。

abcde12345@privaterelay.appleid.com
alk32943dw@privaterelay.appleid.com
bl31dkr03e@privaterelay.appleid.com

このような形式です。
アットマーク前は10文字の固定長のランダム文字列というのが特徴的です。

3. ログアウトの方法

こちらは Sign in with Apple のログアウト方法です。
1でApple IDの使用を停止する」をタップして使用を停止させるとそれがログアウトになる仕様のようでした。

フロント側で必要な対応

  • iOS 12 以下と iOS 13 以降での画面の切り分け
  • ユーザー情報のキーチェーンへの保存
  • アプリ起動時に Apple 認証しているかどうかのチェック

iOS 12 以下と iOS 13 以降での画面の切り分け

Sign in with AppleiOS 13 以降でした使えないので端末バージョンで処理を分けるしかありません。
iOS に ContainerView というコンポーネントがありますので ViewControler を分離させました。

    override func viewDidLoad() {
        super.viewDidLoad()
        setupSocialLoginView()
    }

    private func setupSocialLoginView() {
        if #available(iOS 13, *) {
            /// iOS 13以降で走る処理
            let socialNewVC = UIStoryboard.load("SocialRegister", idetifier: "SocialNewRegisterViewController") as! SocialNewRegisterViewController
            addSocialContainer(vc: socialNewVC)
        } else {
            /// iOS 12以下で走る処理
            let socialOldVC = UIStoryboard.load("SocialRegister", idetifier: "SocialOldRegisterViewController") as! SocialOldRegisterViewController
            addSocialContainer(vc: socialOldVC)
        }
    }

このように分岐させました。

Container 側のデザインは storyboard でデザインしています。

f:id:qed805:20200721140543p:plain
storyboard

ユーザー情報のキーチェーンへの保存

Apple 認証後に得られるユーザー情報をAPIに送信するのですが、確実に送信させないと行けませんので保存する領域を決めないといけませんでした。

ちょうどレアジョブアプリは iPhone のキーチェーン( Keychain )を使っていますので、得られたユーザー情報の一部をキーチェーンの領域に保存することにしました。

アプリ起動時に Apple 認証しているかどうかのチェック

Sign in with Apple でログイン中のレアジョブユーザーが再度レアジョブ アプリに戻ってきたときにログイン中かどうかを確認する必要がありました。

そこで AppDelegate で user をキーにしてログイン中かどうかを判別することにしました。
ログイン中であればその状態を維持して、ログアウトされていればアプリをログアウトする必要があります。

ソースコード上では次のような対応を行いました。

    private func checkAppleLoginStatus() {
        /// Apple認証後のuserの情報をキーチェーンから取得する
        guard let userIdentifier = UserDataStore.currentUserIdentifier else { return }
        /// Apple ログイン中かどうかキーチェーンの情報を使って確認する
        if #available(iOS 13.0, *) {
            let appleIDProvider = ASAuthorizationAppleIDProvider()
            appleIDProvider.getCredentialState(forUserID: userIdentifier) { (credentialState, error) in
                switch credentialState {
                case .authorized:
                    /// 端末側のApple ログイン中
                    break
                case .revoked, .notFound:
                    /// 端末側のAppleログインセッション切れ
                    // ログアウトする
                default:
                    break
                }
            }
        }
    }

Web フロントへの対応で共有すべき情報

  • Web でのログインに必要な ServiceID と Key
  • Web からのメール送信ができるようにするための対応
  • AppId と ServiceId との関係

Web でのログインに必要な ServiceID と Key

Web 版 Sign in with AppleApple 認証で必要なものがありました。 ServiceID と Key です。どちらも Apple Developer サイトで設定・取得できるものです。
ServiceID とは iOS アプリの AppID と似たものです。

iOS アプリでは BundleID を発行してユニークなIDを発行しますが、Web に該当するものが ServiceID になります。 ServiceID は AppID と同じものは指定できずさらにユニークなものにしなければなりません。 ServiceID はApplD.xxxx みたいに使っている AppID の後にユニークな文字列を追加しました。

さらに Key も設定してファイルをダウンロードして Web エンジニアに共有しました。

Web からのメール送信ができるようにするための対応

レアジョブのサービスに Web サーバーからメール送信する機能があります。例えば、レッスン予約時やレッスン開始前に送信される確認メールだったりします。

実は上で話しました「メールを非公開」にして Apple 認証すると Apple がプライベートなメールアドレスを生成します。
このメールアドレスに何も設定せずにメールを送信しようとするとエラーが発生して「メールが送れません」みたいなエラーメールが返ってきて正常にメールを送信できません。

そこでサービス元から Apple のプライベートメールアドレスにメールを送信するために Apple Developer サイトで設定を行う必要があります。
普段のアプリ開発のリリースでは触らない Certificates ページの「 More 」の項目に進みます。

f:id:qed805:20200721163337p:plain
Moreページ

「 Configure 」ボタンをクリックすると「Configure Sign in with Apple for Email Communication」ページにアクセスできます。
このページで Apple のプライベートメールアドレスに送信する「送信元のメールアドレス」を登録する必要があります。

上限があるのかまでは確認していませんが考えられるメール送信元のメールアドレスを全て登録しておきます。
この設定で Apple のプライベートメールアドレスにメールを送信できるようになります。

詳細について知りたい場合は Apple 公式のドキュメントがありますのでこちらを確認すれば把握できると思います。

Configure Private Email Relay Service

https://help.apple.com/developer-account/?lang=en#/devf822fb8fc

AppId と ServiceId との関係

最後に AppID と ServiceID との関係についてです。

さらっと説明すると
AppID は iOS アプリ側で Apple 認証に必要な ID で、 Web で Apple 認証するのに必要なものが ServiceID です。

AppID と ServiceID は密接に紐づいています。
AppID でApple 認証した AppleID アカウントであれば、それに紐づいている ServiceID を使えば同じ AppleID アカウントで Web 版の Apple 認証に成功することができます。
AppID と ServiceID が紐づいていないもので Apple 認証をしようとすると失敗してしまいます。

そこだけ注意すれば大丈夫でした。

最後に

とても長くなりましたが無事にアプリと Web とで Sign in with Apple に対応できましたので、是非とも使ってみてください。
リリースしてからしばらく経っていることもあって利用者の方も徐々に増え始めています。とても嬉しいことですね!
実際にログインするときは指紋認証か顔認証でログインできるようになりますので文字入力をする必要がないのはとても快適です。

Cypressを試してe2eテストやってみた

お久しぶりです。プラットフォームチームの南です
TSUTAYAで昔のドラマをレンタルするのにハマってます
(最近はラスト・フレンズを観て哀しい気持ちになりました)

今回は自分が個人的に触ってみたe2eテストツールについて紹介したいと思います

なんでe2e

当然ですがリリースをする際には動作確認を各環境で行います
その時、毎度同じ操作を画面で行うのが煩わしく感じることがあると思います(自分はそんなに嫌いじゃないです)

  • リリースを行う際、前後の動作確認は重要
  • 手順書としてドキュメントを用意しているが、人がやることなので抜けやミスがある可能性は否定できない
  • 確認事項は毎回変わるわけではない
    • ある程度手順が決まっている
  • 開発環境・ステージング環境・本番環境とそれぞれでやるのが割とコストとして重い

ざっとこんな感じの問題点があったので、e2eを導入して効率よく自動化できないかと考えました

Cypressとは?

ツールは

  • 王道的なseleniumとかよりもちょっと新しくていい感じのやつ触りたい
  • ciや他サービスとの連携がとりやすい
  • 導入・学習コストは下げたい

このあたりをポイントとして選定し、Cypressにいきつきました

www.cypress.io

Cypressの特徴として

  • javascriptで動作する
  • 軽量さくさく
  • dashboardが使いやすい
  • 導入・実装が容易

このあたりがあるようです
とりあえず使ってみます

導入方法

yarnやnpmでインストールできるので簡単でした

$ yarn add -D cypress

インストールが完了すると

$ yarn run cypress open

上記コマンドで動作確認できます
Cypressにはチュートリアル的なテストファイルがデフォルトで用意されているので、動かしながら使い方を学べます

e2eテストを書いてみる

実際にe2eテストを書いてみます
以前、vueを自習してたときに作ったtodo listに対してテストコードを実装してみます f:id:nannannanan:20200806154750p:plain
我ながらとてもシンプルです

機能としてはこんな感じです

まずcypress.jsonにテスト対象アプリケーションのbaseURlを指定します

{
  "baseUrl": "http://localhost:8080/"
}

テストファイルはintegrationディレクトリの中に作ります
今回はtodo_spec.jsというファイルを作ります
そして完成したコードがこちらです

describe('todo list e2e', function () {
    beforeEach(() => {  // functionの前に必ず実行される
        // baseUrlのrootへ
        cy.visit('/');

        // todoを1つ追加
        cy.get('.todo')
            .type('test todo').should('have.value', 'test todo')
        cy.get('.addBtn').click()
        cy.get('.todo').clear()

        // 2つ目のtodoを追加
        cy.get('.todo')
            .type('test todo2').should('have.value', 'test todo2')
        cy.get('.important').check()
        cy.get('.addBtn').click()
        cy.get('.todo').clear()
    });
    it('should create new todo list', function () {
        // .todoList -> 1行目の.rowTodo => Titleには"test todo"が入ってるよね?ImportantはFalseだよね?のテスト
        cy.get('.todoList')
            .find('.rowTodo:first').should('have.class', 'rowTodo')
            .find('.tIndex').should('have.text', '1')
            .siblings('.tTitle').should('have.text', 'test todo')
            .siblings('.tImportant').should('have.text', 'False')

        // 全部でtodoは2行あるよね?のテスト
        cy.get('.rowTodo').should('have.length', '2')

        // .todoList -> 2行目の.rowTodo => Titleには"test todo2"が入ってるよね?ImportantはTrueだよね?のテスト
        cy.get('.todoList')
            .find('.rowTodo:last').should('have.class', 'rowTodo')
            .find('.tIndex').should('have.text', '2')
            .siblings('.tTitle').should('have.text', 'test todo2')
            .siblings('.tImportant').should('have.text', 'True')
    });
    it('should delete complete todo', function () {
        // 2行目のtodoをCompleteするぞ!の動作
        cy.get('.todoList')
            .find('.rowTodo:last')
            .find('.compTask').check()

        // 全部でtodoは1行あるよね?のテスト
        cy.get('.rowTodo').should('have.length', '1')

        // 残ったtodoの中身は正しいよね?のテスト
        cy.get('.todoList')
            .should('have.length', '1')
            .find('.rowTodo:first').should('have.class', 'rowTodo')
            .find('.tIndex').should('have.text', '1')
            .siblings('.tTitle').should('have.text', 'test todo')
            .siblings('.tImportant').should('have.text', 'False')
    });
});

checkやclickのアクションを使ってDOMを操作し、shouldでassertを行います
siblingsとかのselectorをみるとjQueryを思い出しました
作ったテストを動かすには

$ yarn cypress run --browser chrome --spec=./cypress/integration/todo_spec.js

これで動きます
コマンドをみればわかるように、動作させるブラウザを選ぶことができます
コマンドラインで実行もできますが、 cypress openのコマンドなら動かすテストファイルをGUIで選択することもできます f:id:nannannanan:20200806164213p:plain

Cypress dashboardの紹介

最後にCypressの大きな魅力であるdashboardについて紹介します
CypressにログインしProject IDとRecord Keyを取得します
取得したProject IDをcypress.jsonに追記します

{
  "baseUrl": "http://localhost:8080/",
  "projectId": "xxxxxxxx"
}

設定はこれだけ
あとは実行コマンドにRecord Keyを追記して

yarn cypress run --browser chrome --spec=./cypress/integration/todo_spec.js --record --key 999999999

これでテストの結果がdashboardと連携できます
f:id:nannannanan:20200806170428p:plain
dashboardではテストの結果やciを含む外部サービスとの連携設定、テスト動画・スクショの確認などが行えます
正直、dashboardが使いたいからCypressを使ってる感じもあります

さいごに

今回、試験的にCypressを使ってみましたが

  • 動作はめちゃ速い
  • コードの書き方は覚えやすい
  • dashboardとの連携は楽
  • dashboardきれい

などの利点がありました
ただし

  • DOMを変更したときの対応
  • テストデータの管理
  • dashboardを4人以上で使うには課金が必要

などなど考慮しなければいけない課題もあるにはあると思いました
Cypressだけでなく、Postman -> Newman などツールは世の中に山程あるので、うまく組み合わせられると効率化できそうですね

以上!