mmag

ハマったことメモなど

Phoenixでresourcesをネストさせるときにaliasも設定できる

普通にやるならこうする。

scope "/v1", MyApp do
  pipe_through :api

  resources "/users", UserController, only: [:index] do
    resources "/articles", ArticleController, only: [:index]
  end
end

生成されるルーティングはこれ。

$ mix phx.routes
        user_path  GET  /v1/users                    MyApp.UserController :index
user_article_path  GET  /v1/users/:user_id/articles  MyApp.ArticleController :index

ただしこれだと/v1/articlesを入れたときに

scope "/v1", MyApp do
  pipe_through :api

  resources "/users", UserController, only: [:index] do
    resources "/articles", ArticleController, only: [:index]
  end
  resources "/articles", ArticleController, only: [:index]
end
$ mix phx.routes
        user_path  GET  /v1/users                    MyApp.UserController :index
user_article_path  GET  /v1/users/:user_id/articles  MyApp.ArticleController :index
     article_path  GET  /v1/articles                 MyApp.ArticleController :index

というルーティングになるので、/v1/users/:user_id/articlesと同じコントローラを使うことになる。それでいいならOKだけど、別にしたいときはscope/2でaliasを入れてあげる。

scope "/v1", MyApp do
  pipe_through :api

  resources "/users", UserController, only: [:index] do
    scope alias: User do
      resources "/articles", ArticleController, only: [:index]
    end
  end
  resources "/articles", ArticleController, only: [:index]
end
$ mix phx.routes
        user_path  GET  /v1/users                    MyApp.UserController :index
user_article_path  GET  /v1/users/:user_id/articles  MyApp.User.ArticleController :index
     article_path  GET  /v1/articles                 MyApp.ArticleController :index

MyApp.User.ArticleControllerMyApp.ArticleControllerに分けられる。ここまでは何も特別なことは言ってなくて、ドキュメントに書いてあるしわざわざブログ書くようなことじゃない。自分もこんな風にscope/2使うのが普通と思っていたんだけど、最近うろ覚えで「こうだっけ?」って↓のように書いたら同じ動きをした。

scope "/v1", MyApp do
  pipe_through :api

  resources "/users", UserController, only: [:index], alias: User do # ここに alias
    resources "/articles", ArticleController, only: [:index]
  end
  resources "/articles", ArticleController, only: [:index]
end
$ mix phx.routes
        user_path  GET  /v1/users                    MyApp.UserController :index
user_article_path  GET  /v1/users/:user_id/articles  MyApp.User.ArticleController :index
     article_path  GET  /v1/articles                 MyApp.ArticleController :index

ソースを追っていくとhttps://github.com/phoenixframework/phoenix/blob/v1.4.1/lib/phoenix/router/resource.ex#L32にたどり着いて、確かにresourcesaliasオプション受け入れてくれるんだなーって感じ。でもhttps://hexdocs.pm/phoenix/1.4.1/Phoenix.Router.html#resources/4には言及がなかったので、とりあえずプルリク投げといた。

PipenvとDocker Compose

最近Djangoのプロジェクトをつくっているのですが、docker-compose upで立ち上がるようにしておこうとググったところ、なんかイマイチじゃない?ってのが多く出てきたので、自分なりのやつを書いておきます。

Dockerfile

FROM python:3.7-slim

RUN apt-get update -qq

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIPENV_VENV_IN_PROJECT 1

WORKDIR /app

RUN pip install pipenv

docker-compose.yml

version: '3.7'
services:
  db:
    image: postgres:11.1-alpine
    volumes:
      - postgresql:/var/lib/postgresql/data
  app:
    build: .
    command: pipenv run server
    volumes:
      - .:/app:cached
    ports:
      - 7000:7000
    depends_on:
      - db
volumes:
  postgresql:

Pipfile

[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]

[packages]

[requires]
python_version = "3.7"

[scripts]
server = "python manage.py runserver 0.0.0.0:7000"
test = "python manage.py test"

ポイントとしてはPIPENV_VENV_IN_PROJECT=1にして、.venvをDocker Composeのvolumesに入るようにするとこ。ググって出てきたイマイチっぽいやつは、Dockerfileの中でPipfileとPipfile.lockをCOPYしてpip installをやっちゃうやつ。これだと新しい依存を追加するたびにbuildしないといけないんじゃないのって感じでお見送り。

ハチャメチャに速いサイトをつくりたい

ということを思って、もうだいぶ前だけどhttps://surisuri.ninjaっていうのをつくった。半端になってるページもあるけど、やりたいことはざっとできたので趣味としては満足。Herokuで動いてます。ソースはこちら。いま仕事で開発してるサービスの公開APIで本家をマネする企画がことの始まり。

やったこと

CDNのエッジキャッシュを使う

まず前提として、HTMLは全部CDNにキャッシュする。後述するように動的に変わるコンテンツはキャッシュに載せなかったので、そういうコンテンツは表示されなかったとしても、ヘッダとか静的な部分まで表示されず画面真っ白というのはハチャメチャに速いとは言えない。なのでオリジンのサーバはなるたけコンテンツを載せたtext/htmlを返すようにする。ほぼ空っぽのHTMLに<script>がぽつんとあるSPAみたいにはしない。

APIとフロントを分割する

いわゆるモノリシックなWebアプリにはしなかった。JSON APIとフロントが分かれていたほうが個人的に好みだし、チームで開発する場合も適していると思うのでそうした。ただそうなるとtext/htmlをつくるのはフロント側になるので、SSRする仕組みが必要になる。SSRをサポートしてくれるフレームワークはNuxtとかNextとかこの頃興味を持っていたSvelteのSapperなどがあるけど、Nextはpathパラメータの扱いが苦手そう?に見えたのでやめて、Sapperは後述するaタグでの遷移ができなかったのでやめてNuxtを選んだ。仕事でもVueを使っていて手に馴染んでるのでちょうどよかった。

普通のaタグを使う

普通じゃないaタグって何だよって感じだけど、SPA的な画面遷移をしないということ。リンクをタップしてからAPI叩いて必要な情報が揃ってから画面書き換え、ということをするよりも、CDNのエッジキャッシュに載ってるHTMLを取ってくる方が速いんじゃねーのと考えた。もちろん代替案はいくつも考えられて、

  • SPA的な遷移でも、遷移してからAPI叩けばいいじゃん
  • 次に遷移しそうな先で必要になるAPI叩いておいて、ブラウザとかService Workerにキャッシュしとけばいいじゃん

など。前者はちょっと記憶が曖昧だけど、Vueのmountedを使えば遷移してからAPIを叩けるけど、そのページをSSRしたときにコンテンツが載ってこない。だからと言ってNuxtのasyncDatafetchを使うと、こいつらは遷移前に呼ばれるので、リンクをタップしてから遷移するまで時間がかかってしまう。そんな具合にサーバサイドとクライアントサイド両方でいい感じに叩く手段がなかったので、サーバサイドに振り切ったということ。後者は無駄な通信が発生する可能性がありギガに優しくないなと思ったのでやめた。

その他

CSSはheadタグの中にstyleタグで書いてCSSOMの準備が早くできるようにするとか、assetなどURLと内容が対応してて変化しないものはService WorkerでNetwork Firstなキャッシュをしてオフライン対応するとか、いくつか考えていたけど、この辺はNuxtがやってくれちゃったので頑張った感じはない。詳しくはhttps://github.com/nuxt-community/pwa-moduleなど参照のこと。

やらなかったこと

動的なコンテンツのキャッシュはしなかった。CDNによってはクッキーの値をキャッシュキーに含めることができるらしいけど、こちらが意図していない、また把握できないキャッシュをつくられる可能性があるのでやめた。なのでユーザごとのコンテンツやユーザによって作成・更新されるものはブラウザからAPIを叩いて取得する。dev.toなんかはそういうコンテンツもキャッシュに載せていて、ユーザによって更新されたタイミングでCDNのキャッシュを消している。なので絶対無理ってわけでは無いのだけど、これはこれで大変なのでやらなかった。

まとめ

そんなところで、趣味レベルだけど一度キャッシュに載ってくれればそこそこ速いものはできた。ただ例えばトップは時間によって内容が変わるので3時間でキャッシュ消すようにしてるので、キャッシュ切れててHerokuのdynoが寝てるときの描画はかなり遅いし、やっぱりAPI叩くところはどうしてもモッサリするので、もうひと押しって感じですね。