フューチャー技術ブログ

データライフサイクルとトレードオフ

ソフトウェアの中身を大きく2つに分解すると、プログラムとデータに分かれます。コードコンプリートやA Philosophy of Software Designなど、評判の良いソフトウェア設計の本はいくつかありますが、それらはどれもプログラムの説明がメインでデータのライフサイクルについての説明はなかったと思います。しかし、データの表現にもいくつもの方針があって、それによるトレードオフがあるな、というのはもやもやと考えていたので、その考えをまとめて文章にしてみました。

データといっても、処理中の短期間の間では変わらない、いわゆるマスタデータ的なデータです。ジャーナルというか、トランザクション的なデータはここでは触れません。

この記事では、それぞれのトレードオフについて考えていきます。

  • 即値(リテラル)
  • 定数
  • コマンドライン引数
  • 環境変数
  • 設定ファイル
  • ダウンロードコンテンツ
  • オンラインデータベース

データの扱い方が決まると、それを扱うソフトウェアの設計も決まります。むしろ、外部設計に影響を強く与える部分なので、クラス分けをどうしようとか、そういうレベルの話よりも、設計におけるプライオリティはこちらの方が上です。起動時にどこかの情報をパースして読み込むのか、リアルタイムの更新を扱う必要があるのかなど、ソースコードへの影響も大きくなります。

データの表現方式

即値(リテラル)

一番簡単なものはこれでしょう。ソースコード中の利用する箇所に直接データを設定します。ブール型、整数、浮動小数点数、文字列など、プログラミング言語でサポートされているプリミティブ型を入れるのがわかりやすいでしょう。言語によっては構造体リテラルや複合リテラルといった機能があり、複雑なオブジェクトや配列を使った深いデータもリテラルで書けるものもあります。

たとえば、華氏の温度を摂氏に変換するコードを考えてみましょう。

function ftoc(f) {
return (f - 32) / 1.8
}

console.log(`w 0 -> c ${ftoc(0)}`)
console.log(`f 70 -> c ${ftoc(70)}`)
console.log(`f 100 -> c ${ftoc(100)}`)

32や1.8という定数がでていますが、温度の定義が変わるまではここが変化することはありません。プログラム全体を見回して1度しかでてこないのであれば即値でハードコードしても問題ありません。

少し変わり種でいえば、アノテーションの引数やGoの構造体のタグなどもこれに該当します。以下のようなクラスを定義があったとします。ここでは、ウェブサーバーのパスと、Cookieの名前が文字列リテラルで即値でハードコードされています。

@RestController
class MyController {
@PostMapping("/hello-world")
public String doService(@CookieValue("last-login") String rank) {
// 何かしらのロジック
}
}

Goの構造体はJSONなどのデータから読み込んで初期化するときのメタデータをタグとしてバッククオートで書きます。この中もキーの名前もハードコードされていて、後から変更はできないため、即値でのハードコードと変わりません。

type Person struct {
name string `json:"name"`
age int `json:"int"`
}

即値でハードコードというのを聞くと原始的な文明のないコードに思えるかもしれませんが、設定ファイルのキーなど、動的に変わるものがないのであればテクニックに走る必要はありませんし、プリミティブや構造体のタグなどのようにAPIの仕様としてハードコードしかできないケースもあります。

定数

プログラミングを学んだ人が最初に触れる「ソフトウェアエンジニアリング」の要素がこの定数でしょう。リテラルで全部書いても良いのすが、「あとからまとめて変更することや読みやすさを考慮して定数にしましょう」といった感じの文脈で説明されることがよくあります。

多くの言語には一度代入したら変更できない「定数」が機能として提供されています。Javaには final 修飾子がありますし、JavaScript/TypeScript/C/C++にはconstがあります。C++にはさらにコンパイル時に確定し、テンプレート引数にも渡せる constexpr もあります。C言語やC++では、マクロとして実現する手段があります。ただし、プリミティブ型であれば再代入を禁止するだけで問題ありませんが、言語によってはオブジェクト自体の変更を禁止できないこともあります。ゲッターだけを用意してセッターを公開しない、JavaScriptのObject.freeze()、TypeScriptのreadonlyなどを利用するなど、言語によっていくつか選択肢があります。

ソースコード中で何度も参照される場合は即値よりもこちらを使うべきでしょう。基本的にはコンパイルの段階で値が決まってしまい、実行時に変更はできません。円周率みたいなものは定数化するのは良いのですが、たとえば、消費税率などのパラメータなどはルール変更によって後から変わる可能性がありますし、基本的に定数は使わないでしょう。より動的に変更できる方針が必要とされます。

constfinalなどは、処理途中のデータを保持するのにも使われますが本エントリーはあくまでもマスタ的なデータであり、関数の外のパッケージグローバル定義だったり、クラスの静的フィールドだったりのものを想定しています。

言語によっては、コンパイル時にいくつかの定数セットを環境によって切り替えられる機構を持っているものもあります。C/C++などの条件コンパイル、GoのBuild Constraintsなどがあります。定数宣言はこれらの機能と組み合わせると、環境ごとのデータセットの切り替えが実現できます。

コマンドライン引数

CLIやサーバーに外部から値を設定するよくある方法としては、コマンドライン引数があります。これまでの2つと異なり、実行時に値が決まります。同じプログラムでもコンパイルせずに利用者が自由にデータを入れられ、柔軟性が得られます。

各種コマンドラインパーサを使った引数の解釈の実装が必要となります。大量のデータを入れようとすると少々入れにくいなどがありますが、シェルスクリプトなどを使ってまとめておくこともできます。

なお、柔軟であるということはデメリットもあり、要素の有無がコンパイル時には確定しないという問題があります。これ以降に紹介するものではエラーハンドリングの実装が必要となります。デフォルト値を持たせておき、何も設定しない場合にも適当な値を使って動くようにしておくことで致命的なエラーを回避することは可能でしょう。ただし、設定がなかったり、間違った値が設定されるのはデプロイプロセスのバグであり、デフォルト値は発覚を遅らせるし、致命的なエラーとして扱うべき、という考えもあります。ちょっとした開発用ミドルウェアがぱっと起動するのは便利だったりするので、少ない設定で開発者モードでは起動できる、ぐらいが落とし所でしょうか?

環境変数

以前からも使われてきましたが、クラウド時代になってさらに活発に利用されるようになったのが環境変数です。クラウドネイティブなアプリケーションの設計指針のThe 12 Factor Appでも利用が推奨されています。

環境変数は完全に設定を外部から与えられるのが保証されています。設定をすべて環境変数で与えられるようなコンテナイメージやEC2などのイメージを作っておけば、1つのイメージが、開発環境、ステージング、本番環境のどの環境にも使えるようになります。この場合、ファイルシステム上に設定値が残らず重要情報を保持している場所が限定されるようになるため、イメージを見られても重要な情報が漏洩しにくくなるなるというおまけ付きです。

本番環境用、ステージング用などの設定を外部ファイル化し、その選択だけ環境変数で行うといった少し自由度が下がる方法もよく使われています(が、The 12 Factor Appではこれは推奨していない)。

設定ファイル

設定ファイルをアプリケーションとは完全に別のファイルにしておきます。

ネイティブ系のアプリケーション系ではmacOSでは~/Library/Preferences/<APPNAME>、WindowsではC:/Users/<USER>/AppData/Local/<APPNAME>C:/ProgramData/<APPNAME>、Linuxなどの他のPOSIX系OSだと~/.config/<APPNAME>/etc/xdg/<APPNAME>、Androidは<APPROOT>/files/settings、iOSは<APPROOT>/Library/Preferences/<APPNAME>などにあるファイルを読み込むようにすると、行儀の良いアプリケーションになります。

サーバーで動くアプリケーションだと、Ruby on Railsのconfig/database.ymlなど、フレームワークごとに設定ファイルの置き場が決まっていたりするので、そちらに合わせることもあるでしょう。

外部ファイル化しておくと、ハイパーパラメータチューニングを使って、最適化を別システムに行わせるというのも一般的なようです。

ダウンロードコンテンツ方式

これまでは、設定ファイルはコンパイル時、あるいはデプロイ時には設定がもう決まっているという方式でしたが、このダウンロードコンテンツ方式は起動時にサーバーなどに設定を取りにいって読み込む方式です。

たとえば、SQLiteのファイルやJSON、YAMLファイル、Protocol Buffers、MessagePackなどをCDNなどにアップロードしておき、起動時に取得してロードします。起動時にニュースを表示する、週次のイベントを配信するなど、スマートフォンのゲームなどではお馴染みの方法でしょう。これはゲームではマスタデータと呼ばれます。

ゲームというのは、たとえば敵の強さとか、武器の強さといったパラメータはソースコードに直接書くことはありません。よほどリソースに余裕がなかったROMカセットのファミコン時代だと違ったかもしれませんが、今時はプログラマーではなく、ゲームプランナーがこのようなデータを一括で責任をもって作成します(Excelなどで)。オンラインのモバイルのゲームなどの場合は、それを設定ファイルとして配信することで、強さを後から調整したりといったことができるようにしています。ただし、ゲームなどのアプリだと、この手のダウンロードは起動中にしか行えないため、バックグラウンド処理ができないため、ユーザーを長く待たせる原因になったりはします。

オンラインデータベース

ダウンロードコンテンツ方式は、起動時にデータが書き込まれたスナップショットのファイルを読み込んでいましたが、オンラインデータベース方式では、ずっと起動しているRDBにデータを入れて利用します。静的なデータセットではなく、より動的な方式です。

RDBを使うことで、データの変更を即座にシステムに反映できます。また、トランザクションデータも同じRDBにあるのであれば、処理の中でマスタテーブルの情報とトランザクションテーブルの情報をJOINして使うなどができます。たとえば、POSのようなシステムでは商品情報を追加するのに、わざわざシステムを再起動したり、アプリケーションをビルドしなおしてデプロイはしないでしょう。これもマスタデータです。RDBの中には業務で追加されるテーブル(トランザクションテーブル)もあり、それと区別するためにマスタテーブルと呼ばれることもあります。

一括でデータを入れる場合は、ロックの時間を減らすために、別名でがっとインポートし、古いテーブルをドロップしてからリネームして置き換えるというのが時短になります。

トレードオフを考慮する視点

プログラミング言語やビルドツールのサポートによってはもしかしたら柔軟な方が実装しやすい、というところもたまにあるかもしれませんが、紹介した方式は基本的に柔軟性が増えれば増えるほど、だいたい初期の実装は面倒になっていきます。そのため、不必要に高機能な方式を選択するのはよくありませんし、ファイル化したりデータベースをメンテナンスしたり手間暇が余計にかかったりします。たとえば、動的にマスタの更新が必要だ、とか要件に照らして判断する必要があります。

デプロイまでの手順

データの更新をデプロイするまでの手順の多さがまず判断基準になるでしょう。ソースコードに記述する方が実装者の実装の手間は少ないのですが、それをビルドしてデプロイする手間が必要です。また、反映のために再起動するだけでいいとか、再起動すらいらないなど、いろいろな方式があります。

設定ファイル方式やデータベースなどは、それを実装する手間は大きいのですが、一度実装してしまうと、設定の変更で開発者の手を煩わせる必要はない、というところはメリットです。開発者がソースをいじる場合は、どうしても伝言ゲーム&転記作業になってしまうので、プログラマーがなかなか雇えないという状況だと、そこが仕事のボトルネックになってしまう可能性もあります。

最近はライブリロードというものが用意されることもあります。

複数のデータセットのハンドリング

データのセットをまるごと入れ替える必要があるかどうか、というのも判断基準になります。即値や定数ではまるごと設定を置き換えるのはしにくいです。

複数設定が欲しくなるのは、ローカル環境やら、本番環境やら、ステージング環境などの環境ごとの設定、というのが思い浮かぶかもしれませんが、たとえば表示メッセージの日本語、英語のメッセージカタログの切り替えなどもある意味データの置き換えになります。これも、多言語を実現するライブラリごとにも方式がいろいろあります。外部化された設定ファイル群を置き換える(JSONなど)方式だったり、AngularやNext.jsなどのように事前ビルドで各言語ごとの訳語のデータを焼き込んだページを別のパスに生成する方式もあります。オンラインのデータベースを使うこともあります。フロントエンドとバックエンドで別々の方式を選ぶこともあります。

起動時間

ハードコードされているもの、定数として実装されたものは、すでにメモリ上に配置された状態となっています。最速で起動できます。

コマンドライン引数や環境変数はそれらをパースしたり解釈する処理が少し入りますが、これらによって大きく遅くなるということはないでしょう。

設定ファイルを使う方式の場合はファイルのパースが入ることがあります。また、設定ファイルを外出しするケースだと機械学習のモデルだったり、言語モデルだったりすることもあり、データの量によっては起動時間が遅くなることもあります。FaaS系のサーバーレスでの運用には適さないかもしれません。

ダウンロードコンテンツ方式はネットワークアクセスが入りますし、ユーザーに眼に見えるほどの遅延があります。

セキュリティ

データの中には、漏洩されたり、メモリを直接書き換えられることで提供者の意図しない被害が生じるものもあります。一番大きな被害をもたらすものはAPIキーやアクセストークンです。これらをソースコード中にハードコードして、リポジトリにでもプッシュしようものなら、大変なことになります。コマンドライン引数も危険です。シェルスクリプトや、Dockerイメージ、コマンドの実行履歴などには引数も残ってしまうからです。

他のデータでも、改ざんされることでゲームが不正に有利に改変されてしまうなどもあります。ダウンロードコンテンツ方式やファイルは暗号化されることもあります。どうしてもメモリ中に持たないといけないデータは何らかの難読化を加えて、処理するときに復号するということもあります。

ハイブリッド方式

いくつか方式を紹介しましたが、すべて独立しているわけではなく、2つの特徴を持った方式などもあります。

複数のデータソースの透過利用

言語標準のコマンドラインパーサーでは見たことがありませんが、高機能なサードパーティ製のコマンドラインパーサーの中には、環境変数を読み取って、コマンドライン引数に渡したのと同様に処理してくれるものがあります。なお、複数ソースに対応する場合は、優先順位を決める必要があります。

また、寡黙にして僕はそれに対応したライブラリは知らないのですが、JavaScriptの世界では、設定ファイルとコマンドライン引数を透過的に扱えるツールが多い気がします。

Cypressなんかは、デフォルト値、環境変数、CLI、設定ファイルなどに対応しており、どの設定がどこをソースにして読み込まれたのかが一覧で見えたりします。便利ですね。Cypressは、デフォルト値 < 設定ファイル < 環境変数 < コマンドライン引数 < 動的設定という優先度で上書きできるようになっています。他のツールもだいたいこのような優先順位でしょう。

image.png

スマートフォンのゲームだと、最近は初回起動時に大量のデータのダウンロードをさせるのが普通に行われていますが、バンドルされた設定ファイルと、後からダウンロードしたファイルの串刺し検索ができるようにしておいて、初回のチュートリアル突破までは追加ダウンロードでユーザーを待たせることなく行わせ、そのゲームの裏でバックグラウンドでこっそりダウンロードコンテンツをダウンロードさせる、ということをやったことはありました。

ソースコードの自動生成

JSONやYAMLなどのファイルをパースすると処理時間がかかりますし、パーサーもバンドルしないといけませんし、エラーチェックも必要になります。

昔から、行われてきた手法としては、データを解析した結果を、それぞれの言語のリテラルに変換してソースコードとしてバンドルしてしまうというものがあります。CSVを2次元配列として埋め込んだりとかですね。こうすることで、元のデータは外部化でき、プログラマー以外がデータを作ることもできながら、即値や定数と同じような起動時間が得られます。

設定DSL

スクリプト言語の場合はコンパイル不要で必要な時に読み込めばよいので、スクリプト言語自身を設定用DSLとして使うこともよくあります。Pythonのパッケージ情報を記述したsetup.pyとか、Homebrewのパッケージ情報を書いたRubyスクリプトとかありますね。JavaScript界隈だと、設定をコマンドライン引数と、JSONファイルと、YAMLファイルとともに、JSファイルも環境設定フォーマットとして指定できるようになっていたりします。JavaScriptの場合はちょっとしたロジックを書いて、本番環境だと最適化する、といった機能も本体側で対応しなくても実現できます。

Jsonnetcueなど構造化プログラミングを一部サポートするような設定用DSLがあります。また、Luaなどの小さい言語の処理系をバンドルすることもあります。

設定ファイルをバンドル

Javaにはapplication.propertiesapplication.yamlを実行ファイルにバンドルする方法があります。Goにはgo.embedで静的ファイルをバンドルできるので、アプリケーションの中に設定ファイルを焼き込むことができます。zipファイルを実行ファイルに後付けするという方法もあります。

自然言語処理やAIなどでは、少し大きなデータセットが必要になったりします。この辞書ファイル辞書ファイルがバンドルされているツールやライブラリは、インストール後すぐに使えて便利です。たとえば、PythonのJanomeやGoのkagomeもありますし、OSS版のStableDiffutionやその派生モデルについては、配布モデルを組み込んだ、数ギガバイトの圧縮ファイルが配布されています。

ライブリロード

紹介した方式の中には、デプロイまでに手間がかかるものなどもあります。ローカルのテストで頻繁にパラメータを調整したいようなゲームの場合には、ゲームの中に編集画面があったり、PC側からデータセットの更新をスマホ端末に送りつけて検証する、といったことが行われます。それ用のツールや、編集画面といったものの設計もセットで行う必要があるでしょう。

単に再起動して再読み込みさせる、といった方法もあれば、オンメモリで持っているデータを更新し、再起動せずに更新できるようにする、など方式があります。

.envファイル

近年はRuby on Rails由来の環境変数を列挙した.envファイルを使うシステムが増えています。これは最近は高度に発展したエコシステムになっています。これは設定ファイルでもあり、環境変数でもあります。このファイルに対応していないシステムもdirenvを使えば環境変数として読み込めますし、このファイルに対応したライブラリも各言語用にあります。Node.jsだとdotenvですね。また、ウェブのフロントエンドのビルドツールでは、たいてい.envファイルをビルド時にアプリケーションに焼き込む機能を備えています。

名称未設定ファイル.drawio.png
PORT=8000

次のようなコードがあると、ビルドツールがprocess.env.PORTを8000というリテラルに上書きした上で、デプロイ用のプログラムへのトランスパイルを行います。

//ビルド前
app.listen(process.env.PORT, () => {
console.log(`Example app listening on port ${port}`)
})

//ビルド後
app.listen("8000", () => {
console.log(`Example app listening on port ${port}`)
})

これも定数と同様に、リンク時に決定されるのですが、ビルド時のCI設定で、複数のバリエーションを作るというのが比較的やりやすくなります。

まとめ

データをどのようにアプリケーションに読み込ませるかの戦略をいろいろ紹介してきました。それぞれメリットやデメリットがあります。実装の複雑さ、変更頻度がどれぐらいあるのか、誰が変更するのか、どのタイミングでアプリケーションに読み込ませるのかなど、さまざまな要件ごとにトレードオフがあります。

設計というのは意思決定の連続です。単純に「定数化しましょう」というのは設計の指標にはなりません。かならずトレードオフがあります。数箇所しか使われておらず変更されることもない場合はリテラルで直接書いても問題ありませんし、逆に頻繁に変更される場合は外部から読み込ませるなどの仕組みを用意すべきです。

これまでの方式を表にまとめました。こういうのが好きな人向けに。

方式 確定タイミング 扱えるデータ量 切り替え容易性 起動時間 実装の楽さ
即値(リテラル) ビルド時 ★★★★★ ★★★★★★
定数 ビルド時 ★★★★★ ★★★★★
コマンドライン引数 起動時 ★★★ ★★★ ★★★
環境変数 起動時 ★★ ★★★★ ★★★ ★★★
設定ファイル 起動時 ★★★ ★★★ ★★★ ★★★
ダウンロードコンテンツ 起動時 ★★★★ ★★★★
オンラインデータベース リアルタイム ★★★★★ ★★ ★★★

これらの評価はアプリケーションの種類などによっても多少は変動します。例えばコマンドライン引数はサーバーアプリケーションの場合はコンテナイメージ作成時に固定されます。環境変数はデプロイ時に固定されます。スマホアプリのダウンロードコンテンツであればユーザーが手元で起動すれば更新されるので、同じ起動時でも大きく評価が変わる点は要注意です。

設計を事前に全部きちんと決め切るのか、コアの部分でない部分は後回しでいいよ、とかいろいろありますが、「どうしてもこれは譲れない」という要件は最初から見込めるわけで、そういう「わかりきっていること」を無視して手戻りが発生というのは誰も幸せにならないですし、逆に最初からオーバースペックで作ってしまうのも問題ですし、みなさんの残業時間が減って、家族と過ごす時間が増えたり、映画を見にいったり、楽しくなることを期待しています。

本エントリーは多くの人との議論で何度かブラッシュアップしました。@tokoroten, @johtani, @_2F_1, @r_rudi, @_SmallAnimal, @kumagi, @lambda_sakura, @mopemope, @takabow各位に感謝します。