Lean Baseball

No Engineering, No Baseball.

Vue.jsとDjango REST FrameworkでSPAなWebをやる時の勘どころ - HATEOASと非同期処理(の触り)

f:id:shinyorke:20181029214247p:plain

ボストン・レッドソックスの皆さん、世界一おめでとうございます!*1

野球ってほんと面白いですね、こんばんは野球エンジニアです.*2

このエントリーでは楽しい野球技術、Vue.jsとDjango(Django REST Framework、以下DRFと略す)の話をサクッと書きたいと思います.

なお、このエントリーは先日開催されたPyLadies Tokyo - 4周年記念パーティのLTでやったことの自分メモをブログにしたものです.*3

元ネタ

PyLadies Tokyo - 4周年記念パーティのLTで話した内容がベースです.

speakerdeck.com

DjangoとVue.jsそしてOhtani-San - Pythonで二刀流しよう #PyLadiesTokyo 4周年

TL;DR

  • 複数カテゴリのデータをSPA + REST APIで扱うなら、最初からHATEOAS(Hypermedia As The Engine Of Application State)を考慮に入れて設計・実装したほうが幸せ
  • DRFの場合、「HyperlinkedIdentityField」で実現可能...だが、制約があるのでひと工夫が必要
  • Vue.js(に限らずJavaScriptのSingle Page Application)からHATEOASなAPIを使うときは非同期処理を配慮に入れるべき. async / awaitで比較的ラクに実現できる

おしながき

REST APIの設計パターン「HATEOAS」とは?

一言で言うと、「APIの戻り値にURIを含むことにより、次の行動を教えてあげる」APIの設計パターンのことです.

HATEOASはAPIの返すデータの中に、次に行う行動、取得するデータ等のURIをリンクとして含めることで、そのデータを見ればつぎにどのエンドポイントにアクセスをすればよいかがわかるような設計です。

※「Web API The Good Parts」第二章より引用.

なぜそれがいいのか、必要なのか?の説明はWeb API The Good Partsもしくは元の論文をご覧いただくのが良いかなと思います.

雰囲気的な例ですが、こういうAPIが欲しい時に有効です.

【例】とある競走馬の日本の成績と海外の成績のURIを返すAPI

{
    "horse_id": 12345678,
    "horse_name": "レイデオロ",
    "stats_japan": "http://example.com/stats/japan/12345678/",
    "stats_other": "http://example.com/stats/other/12345678/"
}

こういうのをDRFでなんとかします.

「HATEOAS」をDRFで実装する

結論から言うと、

パラメータが一個の場合

「HyperlinkedIdentityField」というそのまんまな名前・役割を果たすクラスを用いて実装可能です.

例えば生成したいURIが

example.com/stats/japan/{horse_id} ※horse_idはunique key

といった場合、

class HorseStatsSerializer(serializers.HyperlinkedModelSerializer):
    stats_japan = serializers.HyperlinkedIdentityField(view_name='japan')
    stats_other = serializers.HyperlinkedIdentityField(view_name='other')

    class Meta:
        model = HorseModel
        fields = ('horse_id', 'horse_name', 'stats_japan', 'stats_other')

DRFだとこんな感じで書けます、シンプルですね.

パラメータがN個(1..n)の場合

パラメータが一個のときはHyperlinkedIdentityFieldで事足りますが、以下のようなパターンだとちょっと手こずります.

example.com/stats/japan/{year}/{horse_id} ※とある🐎(horse_id)の年度別(year)成績がほしい

yearとhorse_id、パラメータが2つあります.

上記パターン(パラメータ2つ)はDRFでは標準対応していません(2018/10/29現在)

このような場合は「複数パラメータを扱うHyperlinkedIdentityField的な奴」を自前で用意する必要があります.

(同じような問題は結構起こり得るせいなのか)既にDRFのISSUEでこの話題があります.

Support multiple parametersfor Hyperlinked fields?

HyperlinkedIdentityFieldを継承して、複数パラメータで対応できるよう書き直すのが近道っぽいです.

# 元のISSUEからまんま引用しています.

from rest_framework.relations import HyperlinkedIdentityField
from rest_framework.reverse import reverse

class ParameterisedHyperlinkedIdentityField(HyperlinkedIdentityField):
    """
    Represents the instance, or a property on the instance, using hyperlinking.

    lookup_fields is a tuple of tuples of the form:
        ('model_field', 'url_parameter')
    """
    lookup_fields = (('pk', 'pk'),)

    def __init__(self, *args, **kwargs):
        self.lookup_fields = kwargs.pop('lookup_fields', self.lookup_fields)
        super(ParameterisedHyperlinkedIdentityField, self).__init__(*args, **kwargs)

    def get_url(self, obj, view_name, request, format):
        """
        Given an object, return the URL that hyperlinks to the object.

        May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
        attributes are not configured to correctly match the URL conf.
        """
        kwargs = {}
        for model_field, url_param in self.lookup_fields:
            attr = obj
            for field in model_field.split('.'):
                attr = getattr(attr,field)
            kwargs[url_param] = attr

        try:
            return reverse(view_name, kwargs=kwargs, request=request, format=format)
        except NoReverseMatch:
            pass

        raise NoReverseMatch()

利用する側はこんな感じになります(これも雰囲気コード).

from rest_framework import serializers

from sample_app import ParameterisedHyperlinkedIdentityField as UrlField

from .models import Horse


APP_NAME = 'keiba'


class HorseSerializer(serializers.HyperlinkedModelSerializer):
    stats_japan = UrlField(
        view_name=f'{APP_NAME}:stats_japan_year',
        lookup_fields=(('year', 'year'), ('horse_id', 'horse_id')),
        read_only=True
    )
    stats_other = UrlField(
        view_name=f'{APP_NAME}:stats_ohter_year',
        lookup_fields=(('year', 'year'), ('horse_id', 'horse_id')),
        read_only=True
    )

    class Meta:
        model = Horse
        fields = (
            'year',
            'horse_id',
            'horse_name',
            'stats_japan',
            'stats_other',
        )

これでDRFでHATEOASなAPIができます、あとは呼び出す側をなんとかしましょう.

HATEOASなAPIをVue.jsから使いこなす

一言で言うと、

async / awaitを使った非同期処理で解決可能です.

私がやった野球アプリの場合、

  • 最初に投手・打者の成績を呼ぶAPI...のURIを返す
  • それぞれのURIを別々に呼び出す、お互い関係はないので非同期にしちゃう(操作性・ストレス軽減のため)

という方針がシンプルに纏まっていたので、

  • Vue.jsのcreateもしくは画面のボタン(検索)を押したタイミングで最初のAPI(投手・打者成績のURIを返すAPI)を呼ぶ
  • 投手・打者のURIを別々に呼び出す

というのをasync / awaitで解決しました.

<script>
  import axios from 'axios'

  const HTTP_HEADER = {headers: {'Content-Type': 'application/json'}}

  export default {
    name: "hourse",
    data() {
      return {
        // 省略
      }
    },
    methods: {
      search: async function (url) {
        const response = await axios.get(url, HTTP_HEADER)
        if (response.status !== 200) {
          console.error('エラー時の処理')
          process.exit()
        }
        const body = response.data
        this.getStatsJapan(body.stats_japan)
        this.getStatsOther(body.stats_other)
      },
      getStatsJapan: async function (url) {
        const response = await axios.get(url, HTTP_HEADER)
        if (response.status !== 200) {
          console.error('エラー時の処理')
          process.exit()
        }
        const body = response.data
        // TODO データを埋め込む
      },
      getStatsOther: async function (url) {
        const response = await axios.get(url, HTTP_HEADER)
        if (response.status !== 200) {
          console.error('エラー時の処理')
          process.exit()
        }
        const body = response.data
        // TODO データを埋め込む
      }
      // TODO 続く
   }
</script>

Promiseを使っても良さそうですが、async / awaitの方がシンプルに書けるのでオススメです*4

まとめ

一言で言うとコードがキレイになってやりやすくなったのと、(これからやる)ページのキャッシュ実装に向けていい感じに準備できて最高でした.

やってみた感想

  • 元のコード(PyCon JP 2018の発表に向けて作ったやつ)からだいぶコードがスッキリして開発しやすくなった
  • 各データ・エンティティ毎にキャッシュ(API側)を設けるポイントが明確になり、キャッシュ戦略が立てやすくなった*5
  • DRFの複数パラメータ実装(ParameterisedHyperlinkedIdentityField)が若干イケてない気がする、パッケージないかな? or そもそもパラメータ2つ要するAPIがイケてないという説も*6
  • まだ実装してない、ユーザー認証とか入れる時に色々ありそうで怖い

これからやること

  • Redisベースのキャッシュ実装および、処理コスト高いAPI(月次・年次の平均処理とか)の高速化を進める.*7
  • そろそろユーザー認証を入れる

続きはちょこっとテーマを別けつつ、PythonやDjango、Vue.jsあたりのアドベントカレンダーで何かしら披露したいと思います.

久々の技術ネタ、これにて以上ですありがとうございました!

【Appendix】参考資料

HATEOASというワードとDRFでもいけるよ!という情報はc-bataさんの発表で知りました&参考にさせてもらいました.

www.slideshare.net

ちゃんとしたWeb APIの復習...という意味で、Web API The Good Partsを久々に読み返したりもしました.

Web API: The Good Parts

Web API: The Good Parts

*1:今年に合わせて戦力集めて運用したボストンのやり方が上手かったなあという印象、ドジャースはカーショウ(ry

*2:ドラフト、日本シリーズそして東京六大学野球(早慶戦)と満喫しています、仕事しながら⚾

*3:最近ちゃんと技術のブログを書いてないので、たまには書こうかと.アドベントカレンダーの素振りも兼ねて(今年もあと二ヶ月!)

*4:Promiseも試しましたがコードがごっちゃになって結局 async / awaitに落ち着きました

*5:キャッシュ大事なんだけど、無計画にやると後々面倒くさいバグが出たり作りがアレになるので...ここ数年嫌な思いしたのもありこの辺はトラウマですw

*6:真似して書いてる時点で悪い方の車輪の再実装なので...

*7:PyLadies Tokyo 4周年LTでAPI作ってテストした時、あまりにもAPI処理にコストが掛かってたので慌ててキャッシュを取り入れました、そのときはなんとかなったけど変なコードなのでやり直したい