💬

マナリンクにおけるTypeScriptライブラリ「aspida」の活用

2022/03/16に公開

マナリンクにおけるTypeScriptライブラリ「aspida」の活用事例について説明します。

  • aspidaとは
  • マナリンクとaspidaの出会い
  • Vue(Nuxt.js)での活用
  • React(React Native)での活用
  • aspidaを導入した意義
  • 開発メンバーの学習体制
  • 今後について

aspidaとは

以下の記事にaspida入門のための内容をまとめているのでご覧ください。

https://zenn.dev/manalink/articles/manalink-intro-aspida

マナリンクとaspidaの出会い

マナリンクとaspidaの出会いは、2019年秋、まだマナリンクの前身となるNoSchoolというQ&Aサービスを運営していた頃にさかのぼります。

CTO の名人がとあるIT勉強会(当時はまだオフライン勉強会が盛んでした)に参加したところ、aspida開発者のSolufaさんがLTしており、型の割れ窓になってしまうREST APIのレスポンスの型を安全に扱うことができるクライアントライブラリの思想に共感し、GitHub Repositoryのページを開いて速攻で初Starを押しました。

そしてイベント終了後にSolufaさんのもとに駆け寄り、 「自分はスタートアップのCTOである※要は技術選定の裁量がある」「あなたのライブラリに早速Starさせていただいた」「すぐにでも使いたいからオフィスに来てハンズオンして欲しい」 という話をしたところ、Solufaさんも快諾していただいて翌週にはオフィスにお越しいただいてaspidaをNuxt.jsに組み込みました。

以後、Nuxt.jsアプリケーションだけでなく、React Nativeアプリに至るまで活用させていただいている次第です。

Vue(Nuxt.js)での活用

マナリンクのフロントエンドは2022年3月時点ではNuxt.js(2系)で開発されています。

バックエンドへのAPIは以下の3通りのユースケースで呼び出します。

  • Laravel APIサーバー(Cookieを使ったセッション管理)
    • 通常の用途
  • Laravel APIサーバー(Webview経由での認証)
    • ネイティブアプリ内で一部Webviewを使った画面があり、そこではネイティブアプリ経由でトークンをフォーワーディングしているので認証ロジックが異なる
  • microCMS
    • API付きの管理画面がノーコードで実装できるサービス

3通りのAPIクライアントを、NuxtのPluginとしてInjectしています。

declare module '@nuxt/types/app' {
  interface NuxtAppOptions {
    $api: ApiInstance
    $appWebViewApi: WebviewApiInstance
    $microcms: MicroApiInstance
  }
}

// 中略
const plugin: Plugin = function ({ $axios }, inject) {
  inject('api', api(axiosClient($axios)))
  inject('microcms', $microcms)
  inject(
    'appWebViewApi',
    webviewApi(
      axiosClient(axios, {
        baseURL: // 割愛,
      })
    )
  )
}

asyncDataで以下のようにContextからAPIクライアントを取り出して使っています。

  asyncData({ app, error, params, $sentry, route }): Promise<any> {
    return Promise.all([
      app.$api.teachers._teacherId(parseInt(params.id)).$get(),
      app.$api.teachers._teacherId(parseInt(params.id)).meta.$get(),

React(React Native)での活用

マナリンクではReact Native製のネイティブアプリでもaspidaを活用しています。

aspidaとSWRを組み合せたuseAspidaSWRを使って、React FriendlyにAPIを叩けるようにしています。

https://www.npmjs.com/package/@aspida/swr

SWRはAPIからのデータ取得を宣言的に実装できるライブラリです。APIからのデータ取得は手続き的になりがちですがHookを使って手続き的な処理をカプセル化しています。

  const { data, error, isValidating, mutate } = useAspidaSWR(apiClient.feature.available);

返り値は普通のSWRと同じですが、引数にaspidaのAPIを渡すことで、dataが型安全になるのがポイントです。

例えば、apiClient.feature.available.$get()Promise<A>という型の値を返すとき、useAspidaSWR(apiClient.feature.available)が返すdataの型はAになります。

ですので、基本的には以下のようなコンポーネント設計で実装しています(React18以降は変わるかも)。

export const HogeContainer = () => {
  const { data, error, isValidating, mutate } = useAspidaSWR(apiClient.hoge);

  if (!data) {
    return <Loading />
  }
  if (error) {
    return <Error error={error} />
  }

  return <HogePresentation hoge={data} />
}

export const HogePresentation: VFC<{hoge: Hoge}> = ({hoge}) => (
  <Card>
    <View>
      <Text>{hoge.title}</Text>
    </View>
  </Card>
)

HogePresentationのほうをStorybookで管理するイメージです。

aspidaを導入した意義

さて、導入して2年以上経過していますが、あらためてaspidaを導入した意義を書いていきます。

既存のアプリケーションに途中から導入可能

最初にaspidaを導入したとき、Nuxt2.9系のアプリケーションを素のJavaScriptで実装していました。

そのためaspidaを全APIに適用するところからスタートしたわけではありませんが、新しく実装するAPIはaspidaで実装する、と決めて導入できたことは良かった点です。

aspida本体は軽量なライブラリでnpm scriptsへの組み込みも容易だったので、途中から導入も可能なライブラリです。

APIパスのTypoを防げる

基本的なところですが、APIのパスのTypoを防ぐことができます。TypeScriptの巨大なオブジェクトとして定義されたAPIクライアントを使ってAPIを叩くので、途中でTypoすると型エラーになります。

また、運用中のAPIのパスが途中で変わったときも、もとのaspidaの型定義を書き換えると、現時点で使っている処理がすべて型エラーになるため自動的に検知できます。

フロントエンドのアーキテクチャを簡素化できる

ここは意見の分かれるところだと思いますが、個人的な見解を書いておきます。

aspidaを使わずにREST APIの型定義を統一しようと試みる場合、独自でServiceモジュールやRepositoryモジュールを実装することが多いと思います。

REST APIをフロント向けに組み替える処理が必要なことは多いと思いますので、そういったView Modelを構築する層は必要かもしれませんが、単にAPIを呼ぶためだけに専用のレイヤーを実装するのは、aspidaを使えば不要になるので冗長だと思います。

冗長な例
export const UserRepository = {
  find: (userId: string) => axios.get<User>(`/users/${userId}`).then(res => res.data)
}

export const useFindUser = (userId: string) => {
  const [data, setData] = useState<User>()
  useEffect(() => {
    async (() => {
      setData(await UserRepository.find(userId))
    })()
  }, [])
  return {
    data
  }
}

// ~~~
export const UserItem: FC<{userId: string}> = ({userId}) => {
  const { data: user } = useFindUser(userId)

  // 以下、Viewを実装
}
aspidaを使った例
export const UserItem: FC<{userId: string}> = ({userId}) => {
  const { data: user } = useAspidaSWR(apiClient.users._userId(userId))

  // 以下、Viewを実装
}

aspidaを使うと、薄いアーキテクチャで型安全な実装を実現できることが分かると思います。

※もちろんアーキテクチャはサービス特性に応じて使い分けるべきですが、少なくとも型安全にするために層を増やす、というのはaspidaで不要になりますよ、という話です

開発メンバーの学習体制

aspidaは一度覚えるとめちゃめちゃ便利なライブラリですが、そもそも非同期通信を実装する課題をもとにしていることもあって、キャッチアップコストは少々高いと考えます。

マナリンクでは2022年1月からエンジニアインターンの受け入れを実施していますので、実際にどのようにaspidaを学んでもらったかをざっくり記そうと思います。

参考▼
https://zenn.dev/manalink/articles/react-native-skillmap

大まかに言うと以下の手順で学んでいってもらいました(SWRもセットなのでヘビーにはなってます)。

  1. https://developer.mozilla.org/ja/docs/Web/HTTP/Methods でGETとPOSTとPUTの違いを調べる
  2. https://azu.github.io/promises-book/#chapter1-what-is-promise だけ一通り読む(Promiseの理解は相当長い道のりなので一旦これだけでOK)
  3. ターミナルでcurl -k 'https://api.localhost-manalink.jp/course-features/52' -H 'accept: application/json, text/plain, */*' -H 'origin: https://localhost-manalink.jp'を実行する。IDの52を変えてみて他のIDで結果が返ってくるか見てみる。curlコマンドについて調べて、-Hオプションについて調べる
  4. 適当な画面(src/components/screens/HogeScreen.tsxとか)を選んで、中身を消して、axios.get('https://swapi.dev/api/people/1')した内容をconsole.logしてみる
  5. getした内容をuseStateで作った状態に保存するなどして、Viewで表示してみる
  6. 現状のReact Nativeのコンポーネント実装で、APIからデータを取ってきている箇所はどこか?推察してみる
  7. SWRについての以下の記事をざっと読む
    1. https://zenn.dev/mast1ff/articles/40b3ea4e221c36
  8. aspidaについてのREADMEを読む
    1. https://github.com/aspida/aspida
    2. /{弊社のアプリケーションリポジトリ}/README_aspida.md

aspidaですでに実装済みのコードはたくさんあるので、ある程度バックグラウンドがあるエンジニアだと見様見真似で実装できるのですが、今回は非同期通信ってなんですか?Promiseってなんですか?みたいな状態からスタートだったので、上記のようにesaにタスクを書いてイチから学んでいってもらいました。

一言でいえば、やっぱりaspidaを勉強してもらうにあたっては素のJSなり、axiosなりでリクエスト処理を書いてもらって、そのあとaspidaで書き直すのが最も勉強になるかなと思っています。

以下は宣伝ですが、aspidaのハンズオンをしつつお互いの自己紹介とかを雑談するカジュアル面談を企画しているので、よかったら応募してください!

https://meety.net/matches/HANUvXfzWitg

大半の実務経験のあるエンジニアであれば、最後のREADME_aspida.mdさえ読めば意味が読み取れるようにできているかなと思っています。

内容はおおむね以下の記事に近いです。というか以下の記事を読んでもらえればよさそうです。

https://zenn.dev/manalink/articles/manalink-intro-aspida

今後について

今後のaspidaに期待することをざっくり書いておきます。

更新系の処理も型安全/宣言的にしたい

GETに関してはSWRによって型安全に、かつモダンフロントエンドの指向に沿った宣言的なデータフェッチが実現できますが、POST/PUTに関しては愚直にメソッドで実装しているのが現状です。
※一応、useAPICallerみたいな命名のフックをWebフロントでもアプリでも自作しているが、これがベストプラクティスなのかはわからない。

イメージで言うと、以下のようなインターフェースのライブラリがあると嬉しい感じです(気が向いたら自作のコードを添えてFeature Request出すかも?→書いてみた)。

import { useAspidaSender } from '@aspida/sender'

// ...

const { send, isSending, error, isSubmitSucceed } = useAspidaSender(apiClient.hoge._hogeId(hoge.id), '$put', {
  onSuccess: () => alert('保存に成功しました')
})
const onSubmit = useCallback(async () => {
  await send({
    body: {
      title: hoge.title
    }
  })
})

// ...

return (
  <View>
    <Text>{error.message}</Text>
    <Button isLoading={isSending} isDisabled={isSending || isSubmitSucceed} onClick={onSubmit}>送信する</Button>
  </View>
)

例外処理も型安全にしたい

useAspidaSWRにせよ、上記のuseAspidaSenderにせよ、エラーも型安全にできると嬉しいなと思っています。エラーのステータスコードでどういうものがあるのか、エラーメッセージはどういう形式で返ってくるのかも含めて型安全にできて、関連ライブラリもそれに沿って実装できると非常に素敵だなと感じています。

前述のインターン生も、useAspidaSWRまではキャッチアップできたものの、例外処理で四苦八苦しているので、例外処理は人類にとって難しい=ライブラリにするとレバレッジが利く、ということかなと思っている次第です。

マナリンク Tech Blog

Discussion