0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

`status` は `success` なのに値が `null` — e-Stat の統計を組み合わせるときの8つの落とし穴

0
Posted at

住所を入れると公開データから街の状況を返すサービスを個人で作っています。人口・世帯数・将来推計人口・高齢化率・社会増減率・空き家率は、すべて総務省統計局の e-Stat API から取っています。

統計表を1つ叩くだけなら簡単です。問題は複数の統計表を組み合わせて1つの指標を作るときに起きました。

実際に踏んだ8つを、コードつきで書きます。

1. HTTP 200 でもエラーのことがある

e-Stat はレスポンスボディの中にステータスを入れて返します。 HTTP ステータスだけ見ていると気づけません。

const status = payload.GET_STATS_DATA?.RESULT?.STATUS;
if (status && status !== "0") {
  return null;
}

STATUS が "0" 以外ならエラーです。統計表IDの間違い、パラメータの不正、該当データなしが全部ここに出ます。HTTP は 200 のままです。

response.ok を見て安心していると、空の結果を正常として扱ってしまいます。

2. VALUE は1件のとき配列にならない

e-Stat の JSON は XML 由来の構造をしています。そのため要素が1つのときは配列ではなくオブジェクトで返ります。

function toArray<T>(value: T | T[] | undefined): T[] {
  if (!value) return [];
  return Array.isArray(value) ? value : [value];
}

これを挟まずに values.map(...) と書くと、1件のときだけ落ちます。該当データが多い自治体では動いて、少ない自治体で落ちるという出方をするので、気づくのが遅れます。

私のコードでは VALUE だけでなく CLASS_OBJ と CLASS にも必要で、同じ関数を3箇所で使っています。入れ子のどの階層でも起こると考えたほうが安全です。

3. 「その値が何年のものか」は別のツリーにある

DATA_INF.VALUE の各行は、こういう形です。

{ "@tab": "001", "@cat01": "A1101", "@area": "27127", "@time": "2020100000", "$": "95864" }

実際の値は $ ですが、@ で始まる属性はすべてコードです。 その時間コードが何年を指すのか、項目コードが何の指標なのかは、この行だけでは分かりません。

名前は CLASS_INF.CLASS_OBJ 側に別で入っています。突き合わせる処理が必要です。

function buildClassMaps(classObjects) {
  const maps = new Map<string, Map<string, string>>();
  for (const classObject of toArray(classObjects)) {
    if (!classObject["@id"]) continue;
    const classMap = new Map<string, string>();
    for (const entry of toArray(classObject.CLASS)) {
      if (entry["@code"] && entry["@name"]) {
        classMap.set(entry["@code"], entry["@name"]);
      }
    }
    maps.set(classObject["@id"], classMap);
  }
  return maps;
}

これを作ってから、行の属性を名前に解決して、年を取り出します。

const labels = resolveLabels(row, classMaps);
return {
  value,
  year: parseYear(labels),
  yearLabel: labels.find((item) => /(19|20)\d{2}/.test(item)) ?? null,
};

metaGet: "Y" を付けないと CLASS_INF が返ってこないので、年が分からなくなります。

4. 設定が必要なものと、不要なものが混ざる

統計表IDを環境変数で指定する設計にしていますが、メトリックによって既定値を持たせたり持たせなかったりしていました。

既定値があるもの:

const DEFAULT_RENT_STATS_DATA_ID = "0004021532";
const DEFAULT_VACANCY_STATS_DATA_ID = "0004021631";

const vacancyStatsDataId =
  process.env.ESTAT_HOUSING_VACANCY_STATS_DATA_ID?.trim() || DEFAULT_VACANCY_STATS_DATA_ID;

既定値がないもの:

case "population":
  return {
    statsDataId: process.env.ESTAT_POPULATION_STATS_DATA_ID,
    categoryCode: process.env.ESTAT_POPULATION_CD_CAT01,
    label,
  };

コードが読む環境変数は14個ありますが、デプロイの workflow に書いてあるのは4個でした。残りは既定値で動いていたので、不足に気づけませんでした。

「どの環境変数が無いと何が死ぬか」が一覧できない状態は危険です。既定値を持たせるなら全部に持たせる、持たせないなら起動時に検査する。混ぜると把握できなくなります。

5. 「設定されているか」の判定が OR になっていた

これが4番を悪化させました。

export function hasEstatFutureConfig() {
  return Boolean(
    getEstatAppId() &&
      (
        process.env.ESTAT_POPULATION_STATS_DATA_ID ||
        process.env.ESTAT_HOUSEHOLD_STATS_DATA_ID ||
        process.env.ESTAT_FUTURE_POPULATION_STATS_DATA_ID ||
        process.env.ESTAT_CHILDCARE_STATS_DATA_ID ||
        process.env.ESTAT_SOCIAL_INCREASE_STATS_DATA_ID
      ),
  );
}

5つのうち1つでも設定されていれば「設定済み」です。 つまり部分的な設定で機能が動き出し、設定されていないメトリックだけが静かに null になります。

実際にこれで事故が起きました。staging に統計表IDが足りておらず、住宅市場の取得が動かないまま気づかなかった件です。

b27c6f1 ci(staging): inject e-Stat dataset IDs to fix housingMarket fetch

環境変数の空文字は例外になりません。 process.env.FOO が undefined でも空文字でも、落ちずに null が流れていきます。

6. 例外を飲むと「データが無い」と区別できない

取得は失敗しても全体を落とさない設計にしていました。

async function fetchMetricSnapshotSafely(config, areaCode) {
  try {
    return await fetchMetricSnapshot(config, areaCode);
  } catch {
    return null;
  }
}

この null には3つの意味が同居します。

  • 統計表IDが設定されていない
  • API がエラーを返した(403、タイムアウト、不正なJSON)
  • その自治体にデータが無い

どれも null なので、ログを見ても原因が分かりません。 「この自治体はデータが無いのだろう」と解釈して、実は設定漏れだった、というのが5番の事故でした。

最低限、飲んだ例外は記録するべきでした。落とさないことと、黙ることは別です。

7. 複数の統計を割ると、成功判定と値の粒度が合わなくなる

ここが一番厄介でした。空き家率をこう計算していました。

const vacancyRate =
  householdCount && vacancySnapshot?.totalVacantUnits
    ? Number(
        ((vacancySnapshot.totalVacantUnits / (householdCount + vacancySnapshot.totalVacantUnits)) * 100).toFixed(1),
      )
    : null;

分子の totalVacantUnits は住宅・土地統計調査から。分母の householdCount は別の統計表から取っています。つまり2つの統計表が両方そろわないと計算できません。

ところが成功判定はこうでした。

const vacancyAvailable = Boolean(vacancySnapshot?.totalVacantUnits !== null || vacancyRate !== null);
// ...
status: rentAvailable && vacancyAvailable ? "success" : "unavailable",

totalVacantUnits が取れていれば vacancyAvailable は true です。householdCount が欠けて vacancyRate が null でも、status は "success" になります。

status: "success"
totalVacantUnits: 12345
vacancyRate: null      ← 成功しているのに値がない

下流は status を見て「取れている」と判断し、vacancyRate を使おうとして null に当たります。成功/失敗の単位が「統計表ごと」なのに、指標は「統計表をまたいで」作られているのが原因です。

複数のソースから1つの値を作るなら、その値ごとに可否を持たせる必要があります。ソース単位の status では足りません。

8. 年を捨てると、あとから出典として使えない

スナップショットは年ラベルを持っています。

return {
  value: best.value,
  yearLabel: best.yearLabel ?? inferYearLabel(best.year, config.label),
};

ところが、集計結果をファイルに書き出すスクリプトではこうしていました。

futureChangePct: metrics?.futurePopulationChangeRate ?? 0,
agingRatePct: metrics?.agingRate ?? 0,
socialIncreasePct: metrics?.socialIncreaseRate ?? 0,

値だけ保存して、yearLabel を捨てています。

これで実際に困りました。この数字を使った説明を書こうとしたときに、「何年の統計か」が書けませんでした。 統計は改定されるので、年がない数字は出典として弱くなります。

もう1つ、同じ行に問題があります。取得できなかったときに 0 を入れていることです。

取得に失敗すると高齢化率が 0 になります。高齢化率0%の自治体は存在しないので、これは「データなし」を「0%」に化かしています。 型は通りますし、グラフも描けます。気づく方法がありません。

null のままにして、表示側で「データなし」と出す。これが正しい形でした。

まとめ

e-Stat を使うときに最初から入れておきたい8つです。

  1. HTTP 200 でも RESULT.STATUS を見る。 ボディの中にエラーが入る
  2. VALUE などは1件のとき配列にならない。 入れ子のどの階層でも toArray を通す
  3. 値の意味は CLASS_INF 側にある。 metaGet: "Y" を付けて突き合わせないと年も分からない
  4. 既定値を持たせるなら全部に持たせる。 混ぜると「何が無いと死ぬか」が分からなくなる
  5. 設定チェックを OR にしない。 部分設定で動き出して、静かに欠ける
  6. 飲んだ例外は記録する。 「設定漏れ」「APIエラー」「データなし」を同じ null にしない
  7. 複数の統計から作る値は、値ごとに可否を持たせる。 ソース単位の status では足りない
  8. 年ラベルを一緒に保存する。 そして「データなし」を 0 に化かさない

共通しているのは、どれも例外を投げず、型も通り、テストも緑のまま壊れることです。公開データを組み合わせるコードでは、「落ちないこと」より「おかしくなったと分かること」のほうが難しいと感じています。


この記事の実装は、公開データから住所単位の災害・地盤・将来人口を確認する立地診断ツール(SafeLand)を作る中で踏んだものです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?