はじめに
Virtual DOM の普及や webpack の環境整備などに伴い,最近では Isomorphic な JS 実装をよく見かけるようになりました.
Isomorphic JavaScript では,サーバ・クライアント間のロジックを同期できるところに強みがあります.バリデーションロジックなどがいい例ですが,クライアントサイドでの入力チェックと,サーバサイドでのリクエストチェックで同一のコードを参照することができ,ロバストな設計を実現することができます.
今回は,これらの共通化されたコードの一部そのものを,さらに 仕様書 としても利用するアプローチを考えてみます.これにより,サーバ・クライアント間の同期だけでなく,実装・仕様間の同期もおこなうことができます.
実装例
URL
1 つの例として,複数の URL を 1 ファイルに切り出し,仕様書として利用することを考えてみます.
共通化するコード
以下が共通化するコードです.大きくサイトのエンドポイントと API のエンドポイントの 2 つに分け,エンドポイントごとにドキュメントを記述しています.
export const userPageUrl = {
site: {
/**
* ユーザ一覧ページ
*/
users: '/users',
/**
* 特定のユーザのページ
* @param {number} id - ユーザの id
*/
user: '/user/{id}'
},
api: {
/**
* 特定のユーザの情報を取得する API
* @param {number} id - ユーザの id
*/
user: '/api/user/{id}'
}
}
サーバサイド
以下が Node サーバ (Koa) の実装例です.以下で利用している koa-router では,/user/:id
のような形で URL を登録することでパラメータを取得できるため,{id}
の部分に :id
を指定しています.また,変数展開には string-format というライブラリを利用していますが,ES6 のテンプレートリテラルなどでも同様のことを実現できます.
import format from 'string-format'
import Router from 'koa-router'
import { userPageUrl } from 'urls'
const router = new Router()
router.get(
userPageUrl.site.users,
(ctx, next) => { /* ... */ }
)
router.get(
format(userPageUrl.site.user, { id: ':id' }), // '/user/:id'
(ctx, next) => {
// this.params.id で id を取得
}
)
クライアントサイド
以下がクライアントサイドの実装例です.Virtual DOM のレンダリングも API リクエストも,展開の仕方は Node と同様です.
import format from 'string-format'
import axios from 'axios'
import { userPageUrl } from 'urls'
/**
* Virtual DOM のレンダリング
*/
export const UserLink = ({ user }) => (
<a href={format(userPageUrl.site.user, { id: user.id })}>
{user.name}
</a>
)
/**
* API リクエスト
*/
export const fetchUserById = id => {
return dispatch => {
axios.get(format(userPageUrl.api.user, { id }))
.then(response => /* ... */)
.catch(error => /* ... */)
}
}
このようにすることで,例えば /api/user/{id}
を /api/users/{id}
に変更したいということがあったとしても,仕様書となる urls.js
を 1 行書き換えるだけで,サーバ・クライアントのコードは一切変更することなしに,エンドポイント名の変更を実現できます.
urls.js
は単なるプロパティの定義にすぎないので,このままでも十分ドキュメントの機能を果たしますが,必要であれば html や markdown の簡単なドキュメントを生成してもいいかもしれません.
ステータスコード
もう 1 つの例として,ステータスコードの共通化を挙げてみます.URL と同様に 1 つのファイルに共通化しておくことで,サーバ・クライアントそれぞれでステータスコードを直接記述する必要がなくなると同時に,ステータスコードの仕様としての役割も果たします.
やっていることは URL と同様なので,細かい説明は省略しています.
共通化するコード
export const errorCode = {
/**
* ユーザの認証に失敗した場合
*/
unauthorized: 401,
/**
* ユーザにアクセス権がない場合
*/
forbidden: 403,
/**
* リソースが存在しない場合
*/
notFound: 404
}
サーバサイド
import format from 'string-format'
import Router from 'koa-router'
import { userPageUrl } from 'urls'
import { errorCode } from 'status-code'
const router = new Router()
router.get(
format(userPageUrl.api.user, { id: ':id' }),
async (ctx, next) => {
await userController.fetchUserById(this.params.id)
.then(response => this.body = response)
.catch(error => {
if (error instanceof UserNotFound) {
this.status = errorCode.notFound // 404
} else {
// ...
}
})
}
)
クライアントサイド
import format from 'string-format'
import axios from 'axios'
import { userPageUrl } from 'urls'
import { errorCode } from 'status-code'
export const fetchUserById = id => {
return dispatch => {
axios.get(format(userPageUrl.api.user, { id }))
.then(response => /* ... */)
.catch(error => {
if (error.status = errorCode.notFound /* 404 */) {
// ...
} else {
// ...
}
})
}
}
おわりに
Isomorphic な環境において,サーバ・クライアント・仕様の 3 者間で足並みをそろえてアップデートできる仕組みを実現しました.
実装と仕様書の乖離をなくしたいといったモチベーション自体は, JSON Schema などにも見受けられますが,今回例に挙げた URL 設計などのように,やり方次第で JSON API フォーマットに限らず様々な JS のコードが仕様書を兼ねられるのではないかと思います.