Vuex ORMの闇を紹介しようと思ったが、v0.35.0で治っていた。と思ったら別の闇に遭遇。そして、Vuex ORM graphql pluginの闇にも遭遇。
まとめ
- Nuxt + Vuex ORM graphql pluginをそのままSSRで使うのは難しい -> 後述の解決策1 or 4
- Nuxt + Vuex ORMをSSRで使うのは可能 -> 詳細は後述
俺氏の選択は、解決策1 + シングルトンVuex ORM + Vuex ORM graphql plugin
Vuex ORM graphql pluginをシングルトンじゃないように修正するのは辛すぎる。
Vuex ORM graphql pluginの闇
これはVuex ORMのSSR対応版(v0.35.0)に対応していない気がする。
少なくとも正しく動くのかかんたんには検証できない。
結論、Vuex ORM graphql pluginを使いたいなら、後述の解決策1を使うしかない。
パフォーマンスが問題になったら、解決策1を解決策4に昇華させるルートもある。
Vuex ORMの別の闇
以下の問題に遭遇。v0.34.1に戻せば良いと書いてあるが、後述のようにv0.35.0の差分を使いたいので戻せない。
上で言及されている、以下のコミットで治る。
Vuex ORMの正しい使い方
以下に書いてあるように、SSRの場合は、 this.$store.$db().model('users')
の形式で使うと良い。2019年12月に報告があり、2020年1月のv0.35.0でこの差分が入っている。
参考資料
今まで普通にグローバルに使ってて気付かなかった。
ぎりぎり動いていたんだろう。
インターフェースの変更で修正量が多いので、一旦は後述の解決策1などでも良いかもしれない。
どこにも言及されていないが、初期化はvuex pluginでstore初期化のたびにデータベースを作るようにしないといけないと思う。シングルトンの名残がまだ残っているから、複雑。
Vuex ORMの闇
サーバーサイドレンダリングで、asyncData(nuxtのやつ)で、Vuex ORMをいじると、リクエスト間でVuexのstateが混じる可能性がある。
集めたファクト
- Vuex ORMではコンテクスト管理にシングルトンが使われている
- 本番のnuxtでは、runInNewContextがfalseになるので、リクエスト間でコンテクストが共有される
- nuxt、connect(nuxtで使われているライブラリ)、vue-server-rendererでは、直列化のコードは無い
- nuxtのasyncDataでログ出力させたら、リクエストが返るまでにVuex ORMが参照するstoreが置き換わっていることを確認
詳細
1. Vuex ORMではコンテクスト管理にシングルトンが使われている
Vuex ORMの実装の中では、storeに以下のようにアクセスできる。
同時に2つのstoreを扱えないことを示唆している。
Container.database.store
また、以下のようなVuex ORMのインターフェースから考えても、コンテクストを渡せないので、インターフェースを変えない限り、同時に2つのstoreを扱うのは難しい。
User.create({
data: { id: 1, name: 'John' }
})
2. 本番のnuxtでは、runInNewContextがfalseになるので、リクエスト間でコンテクストが共有される
nuxtのコードを読んだが、runInNewContextがfalseになる。
3. nuxt、connect(nuxtで使われているライブラリ)、vue-server-rendererでは、直列化のコードは無い
それぞれのコードを読んだが、直列化しているコードはない。
以下、上から順に呼ばれる
- https://github.com/nuxt/nuxt.js/blob/e8aca9eb117851047e82e94948ff8b4bcb464b1a/packages/server/src/server.js
- https://github.com/nuxt/nuxt.js/blob/e8aca9eb117851047e82e94948ff8b4bcb464b1a/packages/server/src/middleware/nuxt.js
- https://github.com/nuxt/nuxt.js/blob/e8aca9eb117851047e82e94948ff8b4bcb464b1a/packages/vue-renderer/src/renderer.js
- https://github.com/nuxt/nuxt.js/blob/e8aca9eb117851047e82e94948ff8b4bcb464b1a/packages/vue-renderer/src/renderers/ssr.js
- https://github.com/vuejs/vue/blob/6390f70c2e4ec7e51cce1d2a4ba35e2d0d328205/packages/vue-server-renderer/build.dev.js
4. nuxtのasyncDataでhttpリクエストのところでログ出力させたら、リクエストが返るまでにVuex ORMが参照するstoreが置き換わっていることを確認
確認方法は、Vuex ORM graphql pluginでhttpリクエストのところに以下のようなコードを仕込んで確認。「同じリクエストIDである、ならば、同じstoreアドレスである」成立すれば問題ないが、
結果、成立しなかった。abコマンドなどで並行リクエストを送ると、store == store2 != store3となってしまうことがあった。
let seq = 1
const addressToId = new WeakMap()
async asyncData({ store }) {
const reqId = seq++
const store2 = Project.store()
if (!addressToId.get(store)) addressToId.set(store, seq++)
if (!addressToId.get(store2)) addressToId.set(store2, seq++)
console.log('request started ' + reqId + ' ' + addressToId.get(store) + ' ' + addressToId.get(store2))
// await http request
const store3 = Project.store()
if (!addressToId.get(store3)) addressToId.set(store3, seq++)
console.log('request finished ' + reqId+ ' ' + addressToId.get(store) + ' ' + addressToId.get(store3))
},
解決策1
nuxtのモジュールで、vueのrenderの前後にフック(幸い、vueの実装ではawaitされている)を仕掛けて、直列実行させる。
並行実行されなくなるので、サーバーのスループットが犠牲になるが、nuxtなら一人のユーザーが一度に行うSSRリクエストは一回だけなので、ユーザーから見たパフォーマンスはあまり劣化しないはず。
以下のフックを使う。
nuxt module例 (moduleの使い方は https://ja.nuxtjs.org/guide/modules/ )
サーバーサイドのみなのでWeakMapを使ってみた。
let seq = 1
let lockPromise
const auxData = new WeakMap()
const lock = async (data) => {
while (lockPromise) {
console.log('serial-render waiting')
await lockPromise
}
lockPromise = new Promise((resolve) => {
data.lockPromiseResolve = resolve
})
}
const unlock = (data) => {
if (data.lockPromiseResolve) {
lockPromise = void 0
const resolve = data.lockPromiseResolve
data.lockPromiseResolve = void 0
resolve()
}
}
export default function () {
this.nuxt.hook('vue-renderer:ssr:prepareContext', async ({ req }) => {
const data = { id: seq++ }
auxData.set(req, data)
await lock(data)
console.log('serial-render start ' + data.id)
})
this.nuxt.hook('vue-renderer:ssr:context', async ({ req }) => {
const data = auxData.get(req)
console.log('serial-render finished ' + data.id)
unlock(data)
})
this.nuxt.hook('render:errorMiddleware', async (connectInstance) => {
connectInstance.use((err, req, res, next) => {
const data = auxData.get(req)
if (data) {
console.log('serial-render error ' + data.id)
unlock(data)
}
next(err)
})
})
}
懸念: ↑のフックの間でthrowすることは無いか? -> asyncDataとかでthrowしたらthrowした。そして、vue-renderer:ssr:contextは呼ばれなかった。
throwしたら以下に行く
そしてnextで以下に行く。ここで独自の例外補足用のエラーmiddlewareを入れておけば良い。
解決策2
vue-server-renderのrunInNewContextに'fork'モードを追加する。
railsのunicornやpumaみたいに、ライブラリを読み込んだあとのプロセスを用意しておいて、そこからforkでサーバープロセスを作るパターンを使う。
linux上のnodeでnative moduleでforkが使えることは過去に確認したことがあるが、OSに依存するので不安定かもしれない。
解決策3
解決策2と似ているがZone.jsを使う。thread local storage的なものらしい。
nuxtのモジュールを作るのと、Vuex ORMの実装を変更する必要があるが、Vuex ORMのインターフェースは変更する必要がないのがメリット。いろいろ関係ないところまで影響しそうなのがデメリット。
解決策4
解決策1の進化版。
vue-server-renderのrunInNewContextに'pool'モードを追加する。
contextプールを用意して空いているものに投げる。すでにtrueと'once'が実現できているから、実現できるはず。
https://github.com/vuejs/vue/blob/52719ccab8fccffbdf497b96d3731dc86f04c1ce/src/server/bundle-renderer/create-bundle-runner.js#L107
このrunnerを複数にして、直列実行にすれば良い。
メモリ消費量が問題になるかもしれないけど。
解決策5
かんたんで安全だけど、レイテンシーとスループット低下。in memory cacheが使えない。
以下の設定をnuxt.config.jsに追加。
参考: https://github.com/nuxt/nuxt.js/blob/21aaef3b4825e9b11aeecf7eaa8cd2c3d24fc3ce/packages/config/src/config/render.js#L4
render: {
bundleRenderer: {
shouldPrefetch: () => false,
shouldPreload: (fileWithoutQuery, asType) => ['script', 'style'].includes(asType),
runInNewContext: true
},
}
俺のアプリだとnuxtの処理時間(response headerに書かれるやつ)が2倍〜3倍になった。
解決策6
bundleRendererにpreevaluate的なオプションを用意する。
runInNewContext: trueだけど、アイドル時に事前にevaluateしておく。
runInNewContext: trueのメリットと、レイテンシーが小さいメリットはあるが、スループットが下がる。in memoryキャッシュが使えない。