ソフトウェアでは、データを一時的に保存する、外部とデータをやりとりするといった機会、多くあります。
そんなときに使うと便利なのが、シリアライズ。ファイル出力や通信データ作成を、簡単に作れる仕掛けです。
多くのプログラミング言語が、シリアライズの機構を備えていて、簡易に使うことができます。
適切に使えば、効果の高い「妙薬」となる、シリアライズ。
しかし、使い方を間違れば、危険な「劇薬」になる代物でもあります。
どのようなことに気をつけるべきなのか、大事な4つの処方箋を紹介しましょう。
シリアライズのコードを見てみよう
シリアライズは主に、データをファイルに保存したり、外部とデータを送受診するとき、に使います。
シリアライズってどういう意味?どんなことをしてくれるの?いろいろ疑問はありますが、まずはサンプルコードを見てみましょう。
Javaで作ってみます。病院の管理システムです。「患者データ」を保存することを考えましょう。
まずは保存対象となる、患者クラスを作ります。ID,名前、年齢などを管理します。こんな感じでしょうか。(注:簡略化のため全てPublicメンバとしています)
/**
* 患者クラス
*/
public class PatientRecord {
// 患者ID
public String patientId;
// 名前
public String name;
// 年齢
public int age;
// 性別
public String gender;
// 血液型
public String bloodType;
public PatientRecord(String patientId, String name, int age, String gender, String bloodType) {
this.patientId = patientId;
this.name = name;
this.age = age;
this.gender = gender;
this.bloodType = bloodType;
}
}
この患者データを、ファイルに出力して保存するケースを考えましょう。
ケース1:単純なファイル入出力を使う
やり方として、すぐに思いつくのが、こんなもの。
- クラスメンバの内容を、順番にファイルへ出力
- ファイルから入力するときは、同じ順番で読みだす
実装してみましょう。
import java.io.*;
public class Main {
// オブジェクトをバイナリ形式でファイルに保存するメソッド
public static void writePatientRecordToFile(PatientRecord patient, String filename) {
try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(filename))) {
dos.writeUTF(patient.patientId);
dos.writeUTF(patient.name);
dos.writeInt(patient.age);
dos.writeUTF(patient.gender);
dos.writeUTF(patient.bloodType);
} catch (IOException e) {
e.printStackTrace();
}
}
// バイナリファイルからオブジェクトを復元するメソッド
public static PatientRecord readPatientRecordFromFile(String filename) {
try (DataInputStream dis = new DataInputStream(new FileInputStream(filename))) {
String patientId = dis.readUTF();
String name = dis.readUTF();
int age = dis.readInt();
String gender = dis.readUTF();
String bloodType = dis.readUTF();
PatientRecord patient = new PatientRecord(patientId, name, age, gender, bloodType);
return patient;
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
public static void main(String[] args) {
// 患者のインスタンスを作成
PatientRecord patient = new PatientRecord("P001", "山田太郎", 30, "男性", "A型");
// ファイル名を指定
String filename = "patientRecord.bin";
// ファイルに書き出し
writePatientRecordToFile(patient, filename);
// ファイルから読み込み
PatientRecord restoredPatient = readPatientRecordFromFile(filename);
// 復元されたオブジェクトの内容を表示
if (restoredPatient != null) {
System.out.println("患者ID: " + restoredPatient.patientId);
System.out.println("名前 : " + restoredPatient.name);
System.out.println("年齢 : " + restoredPatient.age);
System.out.println("性別 : " + restoredPatient.gender);
System.out.println("血液型: " + restoredPatient.bloodType);
}
}
}
やってることは分かりますね。特に問題なく実現できます。
ケース2:シリアライゼーション機構を使う
では、Javaが持つシリアライゼーションの機構を使って、このコードを書き換えてみましょう。
注:Javaシリアライゼーションを使うときは、あらかじめ対象クラスにimplememts Serializable
を定義しておく必要があります
import java.io.Serializable;
/**
* 患者クラス
*/
public class PatientRecord implements Serializable {
private static final long serialVersionUID = 1L;
// 患者ID
public String patientId;
// 名前
public String name;
// 年齢
public int age;
// 性別
public String gender;
// 血液型
public String bloodType;
public PatientRecord(String patientId, String name, int age, String gender, String bloodType) {
this.patientId = patientId;
this.name = name;
this.age = age;
this.gender = gender;
this.bloodType = bloodType;
}
}
実際の入出力コードです。
import java.io.*;
public class Main {
// オブジェクトをバイナリ形式でファイルに保存するメソッド
public static void serializePatientRecord(PatientRecord patient, String filename) {
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(filename))) {
oos.writeObject(patient);
} catch (IOException e) {
e.printStackTrace();
}
}
// バイナリファイルからオブジェクトを復元するメソッド
public static PatientRecord deserializePatientRecord(String filename) {
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filename))) {
return (PatientRecord) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}
public static void main(String[] args) {
// 患者のインスタンスを作成
PatientRecord patient = new PatientRecord("P001", "山田太郎", 30, "男性", "A型");
// ファイル名を指定
String filename = "patientRecord.dat";
// シリアライズしてファイルに保存
serializePatientRecord(patient, filename);
// デシリアライズしてファイルから復元
PatientRecord restoredPatient = deserializePatientRecord(filename);
// 復元されたオブジェクトの内容を表示
if (restoredPatient != null) {
System.out.println("患者ID: " + restoredPatient.patientId);
System.out.println("名前 : " + restoredPatient.name);
System.out.println("年齢 : " + restoredPatient.age);
System.out.println("性別 : " + restoredPatient.gender);
System.out.println("血液型: " + restoredPatient.bloodType);
}
}
}
なんか…シンプルになりましたね。
シリアライズを使ってどう変わったか
シリアライズの適用で、大きく変わったのは、ここの部分。
try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(filename))) {
dos.writeUTF(patient.patientId);
dos.writeUTF(patient.name);
dos.writeInt(patient.age);
dos.writeUTF(patient.gender);
dos.writeUTF(patient.bloodType);
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(filename))) {
oos.writeObject(patient);
シリアライゼーションを使ったほうは、クラスの各メンバに対する操作が無くなっています。
これは、シリアライズの仕掛けが、オブジェクトの各メンバを読み取って、出力してくれるからです。
このように、入力されたオブジェクトの内容を自動的に判定して、入出力してくれる仕掛けが、シリアライズです。
この仕掛けで、クラスごとの操作を実装することなしに、簡易に入出力処理を組み込むことができます。
ケース3:テキスト入出力を使う
ところで、シリアライズをする方法は、もうひとつあります。
今度は、JavaのJacksonライブラリを使ってみましょう。
注:Jacksonを使うときは、対象クラスがデフォルトコンストラクタを持っている必要があります
/**
* 患者クラス
*/
public class PatientRecord {
// 患者ID
public String patientId;
// 名前
public String name;
// 年齢
public int age;
// 性別
public String gender;
// 血液型
public String bloodType;
// デフォルトコンストラクタ(Jackson用)
public PatientRecord() {
}
public PatientRecord(String patientId, String name, int age, String gender, String bloodType) {
this.patientId = patientId;
this.name = name;
this.age = age;
this.gender = gender;
this.bloodType = bloodType;
}
}
Jacksonを使った入出力のコードです。
import java.io.File;
import java.io.IOException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.*;
public class Main {
// オブジェクトをJSON形式でファイルに保存するメソッド
public static void writePatientRecordToFile(PatientRecord patient, String filename) {
ObjectMapper objectMapper = new ObjectMapper();
try {
objectMapper.writeValue(new File(filename), patient);
} catch (IOException e) {
e.printStackTrace();
}
}
// JSONファイルからオブジェクトを復元するメソッド
public static PatientRecord readPatientRecordFromFile(String filename) {
ObjectMapper objectMapper = new ObjectMapper();
try {
return objectMapper.readValue(new File(filename), PatientRecord.class);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
public static void main(String[] args) {
// 患者のインスタンスを作成
PatientRecord patient = new PatientRecord("P001", "山田太郎", 30, "男性", "A型");
// ファイル名を指定
String filename = "patientRecord.json";
// JSONファイルに書き出し
writePatientRecordToFile(patient, filename);
// JSONファイルから読み込み
PatientRecord restoredPatient = readPatientRecordFromFile(filename);
// 復元されたオブジェクトの内容を表示
if (restoredPatient != null) {
System.out.println("患者ID: " + restoredPatient.patientId);
System.out.println("名前 : " + restoredPatient.name);
System.out.println("年齢 : " + restoredPatient.age);
System.out.println("性別 : " + restoredPatient.gender);
System.out.println("血液型: " + restoredPatient.bloodType);
}
}
}
これも、クラスの各メンバに対する操作が無くなっていますね。
さきほどのシリアライゼーションとの違いは、出力されるファイルの形式です。
前述のシリアライゼーションでは、バイナリ形式で出力されます。出力された中身を覗いても、何が書いてあるか分かりません。
Jacksonの場合は、ファイルがテキスト形式で出力されています。なのでファイルの中身を見ることができます。
{"patientId":"P001","name":"山田太郎","age":30,"gender":"男性","bloodType":"A型"}
シリアライズとはなんなのか?
実例を見たところで、改めてシリアライズについて考えましょう。
アプリ外部とのデータ入出力に使う
シリアライズを使うのは、主にこの2つのシチュエーション。
- 保存データの作成(ファイルの保存と読み出し)
- 転送データの作成(通信データの作成、外部からのデータ受信)
どちらも用途は、アプリケーション内部から外部への、データ出し入れに使うものです。
アプリケーション内部でのデータのやりとりは、メモリを参照すればよいだけ。メソッドの引数や戻り値でできますね。
しかし外部に出すときは、メモリは参照できません。取り出したデータを、ファイルなどなにかしら見れる形にする必要があります。
こういうときに、シリアライズを使います。
シリアライズで、外部用データ作成が簡単に
外部に出すファイルや通信データは、バイトデータの並びで表現されます。
単純に考えれば、さきほどのケース1のように、データを1つ1つ書いたり読んだりすることになります。
これを簡単な操作でできるようにしたのが、シリアライズです。ケース2や3で実装したものですね。
つまるところ、シリアライズは、データ入出力をラクに実装する仕掛け、と言えます。
シリアライズ=直列化(データを並べる)のこと
シリアライズは、日本語で直訳すると「直列化」です。メモリの内容を、バイトデータに一列に並べていくので、直列化というわけです。
プログラミング言語ごとのシリアライズ実装
サンプルコードではJavaを扱いました。
他の多くのプログラミング言語でも、シリアライズの機構をサポートしています。
言語 | バイナリ形式 | テキスト形式 |
---|---|---|
C++ | Boost.Serialization | nlohmann-json |
Java | java.io.Serializable | Jackson |
C# | System.Runtime.Serialization | System.Text.Json |
Python | pickle | json |
JavaScript | BSON | JSON.stringify |
PHP | serialize | json_encode |
Ruby | Marshal | JSON |
Kotlin | kotlinx.serialization | Gson |
Swift | Codable | JSONEncoder JSONDecoder |
Go | encoding/gob | encoding/json |
Rust | serde | serde |
仕掛けの呼び方や実装方法はさまざまですが、データ入出力をラクに実装するという思想は、どの言語でも同じです。
シリアライズが「劇薬」である理由
データ入出力をラクに実装できる、いろいろな言語でも実装されている、シリアライズ。
一見、なにも問題はなさそう。いいことだらけ。
しかしシリアライズは、扱い方を間違えれば劇薬になります。なにが危険なのでしょう?
考えてみれば不思議な、シリアライズの仕掛け
このシリアライズの仕掛け、自分で実装してみることを、ちょっと考えてみましょう。
前段の例でいうと、こんなコードが、
try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(filename))) {
dos.writeUTF(patient.patientId);
dos.writeUTF(patient.name);
dos.writeInt(patient.age);
dos.writeUTF(patient.gender);
dos.writeUTF(patient.bloodType);
シリアライズでこうなるんでしたね。
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(filename))) {
oos.writeObject(patient);
どう実装すればいいでしょうか?
えーと、オブジェクトが与えられたら、存在するメンバを順に取り出して…
…はて?どうやって実装するんだ?
「そのオブジェクトになんのメンバがあるか」なんて、プログラムから判別しようがなくないか?
禁断の秘技、リフレクション
そう、シリアライゼーションは、教科書に載っているような普通の技術では、実現できないのです。
ではどうするのか。言語に組み込まれている秘密の仕掛け「リフレクション」を使います。
リフレクションとは、プログラムが実行時に、自身のプログラム構造や動作を検査、分析、修正する仕掛け。
リフレクションを使うと、こんなことができます。
- 型情報の取得 実行時にオブジェクトの型を調べる
- メンバーアクセス クラスのメソッドやプロパティに動的にアクセスする
- オブジェクト生成 型名を指定して動的にオブジェクトを生成する
- メソッド呼び出し メソッド名を文字列として指定し、動的に呼び出す
シリアライズは、この仕掛けを使って、実現しているのです。
リフレクションに潜む危険性
自身の構造を修正するリフレクション。強力な仕掛けです。
ただこれは、プログラムが動いている最中に、自身のプログラムを改造できるということ。
その作用は強力すぎるのです。
プログラミング言語は、型定義により安全性を確保する、という基本的な考えがあります。動的な型情報操作は、その安全性を簡単に突破してしまいます。
それでも、自身でコントロールしている分には、まだいいです。
シリアライズは「外部とのデータ入出力に使う」ということを思い出してください。
シリアライズを使うと、型定義の操作が、外部情報に依存することになります。危険性が飛躍的に高まるのです。
シリアライゼーションは「大きな間違いであった」
Devoxx UK 2018カンファレンスにおいて、Javaの開発元Oracle のチーフアーキテクトMark Reinhold 氏は、次のように述べています。
Oracle Calls Java Serialization ‘A Horrible Mistake’
Javaのシリアライゼーションは、「大きな間違い」であった
シリアライゼーションは、 1997 年における大きな間違いでした。なんとかしようとしたものの、結局そのままになってしまっています。
我々はシリアライゼーションを『与え続けられるギフト』と呼んでいます。
それが意味するものは、セキュリティの脆弱性です。おそらく Java の脆弱性の 3 分の 1 はシリアライゼーションに関係しています。シリアライゼーションは、驚くほど脆弱性の発生源となっています。
実際に、JavaのWebフレームワークのひとつApache Strutsで指摘された脆弱性は、シリアライズに起因しています。
オラクルは、最終的には Java からシリアライゼーションを削除する予定である、と述べています。
シリアライズを「安全」に使うための処方箋4つ
シリアライズは、そのあまりの手軽さゆえ、ついつい無造作に使ってしまいがち。
一方、使い方を間違えれば、劇薬にもなる代物でもある。
では、
- シリアライズは使ってもいいの?
- それとも使わないべきなの?
結局のところ、何事もトレードオフです。用法・用量を守って正しく使うのが肝心です。
シリアライズを安全に使うための処方箋が、この4つです。
- シリアライズ専用のクラスを作る
- 変更に耐えうるシンプルなクラスにする
- JSONテキスト形式でシリアライズする
- デシリアライズ時のバリデーションに対処する
(1)シリアライズ専用のクラスを作る
シリアライズするデータは、当然、システムの中でも重要な情報になります。病院システムの患者データなんて、まさにコアとなるデータですね。
単純に考えれば、患者データをシリアライズするのは当然のように思えます。
でも、コアとなるクラスのデータを、そのままシリアライズするのは避けるべきです。
コアなクラスには、様々な情報が集約されます。その中には外部に公開すべきではない情報も多数含まれます。
シリアライズは、そんな公開すべきでない情報まで、まるごと保存してしまいます。
ではどうするか、シリアライズに使うための、専用のクラスを準備するのです。そこに、保存する必要がありかつ十分なデータのみを定義します。
(2)変更に絶えうるシンプルなクラスにする
ソフトウェアは、ずっと変更し続けられます。
シリアライズするデータも、追加や変更など起こる可能性があるわけです。
とはいっても、将来どのような改修が起こるかは、今の時点では分かりません。
ではどうするか。変更に対応しやすいよう、できるだけシンプルにしておきます。
特にコアとなるクラスは、変更の頻度も多くなります。そのような変更に影響されにくいように、シリアライズクラスを構成します。
特に効果が高いのは、この2つ。
- 各メンバはプリミティブ型とする
- データ階層をできるだけ少なくする
できるだけ単純な型を維持し続けましょう。
(3)JSONテキスト形式でシリアライズする
シリアライズするクラスが出来たとして、次はシリアライズの方式です。
前述したように、バイナリ形式とテキスト形式があります。できるだけテキスト形式で扱いましょう。
バイナリ形式は、ほぼ完全にクラスを復元できるメリットがあります。しかし変換内容がブラックボックス(見た目で分からない)なので、未知の脅威への脆弱性が高くなります。
さらに、テキスト形式にも色々なフォーマットがあります。代表的なものは以下。
- XML
- Protocol Buffer
- JSON
- YAML
システムに特別な制約がない限り、推奨はJSONフォーマットです。
- WebAPIと親和性が高い WebAPI規格の事実上標準であるRESTでは、JSONが使われる
- 多くのプログラミング言語がサポートしている 先にあげた11種の言語すべてでJSONを取扱い可能
ただ、バイナリ形式にはデータのコンパクトさなどの利点はあります。どうしてもダメなわけではありません。以下の条件のうちならバイナリ形式もありでしょう。
- データの利用がアプリ内で完結する
- データの有効期間が短期間
(4)デシリアライズ時のバリデーションに対処する
シリアライズと逆に、デシリアライズは外部からデータを入力します。
外部からやってくるデータは、
- データ数が不足していたり
- ルールに則っていなかったり
- 悪意を持ったデータであったり
と、信用のおけないものであるかも。危険な情報は、取り込む前に遮断する必要があります。
つまり、デシリアライズ時に、正しい情報であるかの妥当性チェック(=バリデーション)を行うのです。
主要なバリデーション内容として、以下のようなものがあります。
- データ型チェック 整数、文字列、リストなど、期待される型であるか
- 必須フィールドチェック 必須項目がすべて存在しているか
- 値域チェック 数値や文字列長が、特定の範囲内にあるか
- 一貫性チェック 関連フィールドの整合性が保たれているか(開始日が終了日より前かなど)
たいていの言語は、デシリアライズ時の便利なバリデーション機構を備えています。アノテーション付与などにより、簡単に制約を加えられます。言語の機構も活かして、妥当性チェックを確実に行いましょう。
また場合によっては、バージョニングのチェックも重要です。
ソフトウェアに変更があり、対象フィールドが1つ追加されたとします。1世代前に保存したデータには、それが含まれていません。
そんなデータを読み込んだらどうするか?受け付けないのか、それともなにかしら解釈して扱うのか?こういったこともバリデーションで考慮する必要があります。
まとめ
シリアライズは、実装をラクにする強力な仕掛けであるのは、間違いないです。
特に、近年のWebAPIの実装において、シリアライズは不可欠なものとなっています。
その危険性もちゃんと理解しつつ、有効に活用するのがよいでしょう。
最後にひとこと
シリアライズは、用法をちゃんと理解していれば使うのはOKです。ただ、
リフレクションは、自分の体を切り刻むメスのようなもの。危険極まりない代物です。プロジェクトが混乱の極みに陥ります(体験談)。
コメント