Edited at

Express のレスポンスで JSON を返すときは循環オブジェクト参照構造体に気をつけよう


TL; DR



  • res.json() で返すオブジェクトに循環オブジェクト参照構造体が含まれるとエラーが出る

  • オブジェクトから循環オブジェクト参照構造体を除けば解決する


はじめに

Express で API サーバーを作っているときに、数時間同じエラーにハマってしまいました。

躓いていたのは基本的な内容でしたが、意外と引っかかることもありそうだったので、他の方が悩まないように記事に残します。


エラーの内容

TypeError: cyclic object value (Firefox)

TypeError: Converting circular structure to JSON (Chrome)

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value

MDN によると、JSON.stringify() を呼び出したときに、循環オブジェクト参照構造体(以下 cyclic object)を文字列に変換できないのが問題でした。

それでは、そもそも cyclic object とは何でしょうか?以下のコードを見るとわかりやすいです。

var a = {}

var b = {}
a.child = b
b.child = a

正しく名前の通りで、中身が循環している構造を持ったオブジェクトのことです。

つまり、ソースコード内で cyclic object に JSON.stringify() を使用している部分に着目して修正すれば解決するはず…!


エラーが発生したコード(一部省略)

const axios = require('axios')

const express = require('express')
const firebase = require('../lib/firebase')

const app = express()
const bucket = firebase.storage().bucket()

// 今回実装していた部分
const hoge = async req => {
const { name } = req.params
const signedUrl = await bucket.file(name).getSignedUrl({ action: 'read', expires: 'XX-XX-XXXX' })
const url = await axios.post(
`https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=${FIREBASE_API_KEY}`,
{
dynamicLinkInfo: {
domainUriPrefix: 'https://example.page.link',
link: signedUrl[0]
}
}
)
return { url }
}

// 非同期関数をラッピングする関数
const wrap => fn => (req, res, next) => {
fn(req, res, next)
.then(data => {
res.status(200).json(data)
})
.catch(next)
}

// エンドポイント
app.get('file/:name', wrap(hoge))

やろうとしていたことを簡単に説明すると


  • クライアントから渡されたファイル名で Cloud Storage for Firebase(GCS 互換)から signedUrl を取得する

  • Firebase Dynamic Links に生えている API を叩いて signedUrl を短縮する

  • 短縮された URL をクライアントに送る

という感じです。

一見すると、どこにも JSON.stringify() がないので問題なさそうですが、実はある関数に潜んでいるのです…。


解決策

Express を触ったことがある方ならすぐわかるかもしれませんが、実は wrap 関数内で使用されている res.json() には JSON.stringify() が含まれているのです。

Express の公式ドキュメントにもはっきりと書かれています。


res.json([body])

Sends a JSON response. This method sends a response (with the correct content-type) that is the parameter converted to a JSON string using JSON.stringify().

The parameter can be any JSON type, including object, array, string, Boolean, number, or null, and you can also use it to convert other values to JSON.


そこで、レスポンスに注目して console.log() で確認しました。

すると、次のようなオブジェクトが表示されました。

// 省略

_requestBodyLength: 649,
_requestBodyBuffers: [],
_onNativeResponse: [Function],
_currentRequest: [Circular], // 不要
_currentUrl:
'https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=FIREBASE_ADMIN_KEY' },
[Symbol(isCorked)]: false,
[Symbol(outHeadersKey)]:
{ accept: [Array],
'content-type': [Array],
'user-agent': [Array],
'content-length': [Array],
host: [Array] } },
data:
{ shortLink: 'https://example.page.link/hogehogehoge', // 必要
previewLink: 'https://example.page.link/hogehogehoge?d=1' } }

確かに _currentRequest: [Circular] に cyclic object が含まれています。

今回の実装では、data に含まれている shortLink さえあればいいので、_currentRequest をレスポンスから除くことで解決しました。

具体的には、以下のコードを参照してください。

const hoge = async req => {

const { name } = req.params
const signedUrl = await bucket.file(name).getSignedUrl({ action: 'read', expires: 'XX-XX-XXXX' })
const { data } = await axios.post(
`https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=${FIREBASE_API_KEY}`,
{
dynamicLinkInfo: {
domainUriPrefix: 'https://example.page.link',
link: signedUrl[0]
}
}
)
return { url: data.shortLink } // ココ
}


まとめ


  • 循環オブジェクト参照構造体は JSON.stringify() で JavaScript の値を JSON 文字列にできない


補足

調べてみると cycle.js を使ってもなんとかできるらしい(試してないからわからない)

MDN には次のように書かれていたので、興味がある方は調べてみてください。


JSON.decycle と JSON.retrocycle という 2 つの関数を導入し、循環構造と dag を JSON でエンコードしてからリカバリーできます