🍍

5.2 java.ioパッケージのその他の主要なクラス(ByteArray系Stream、シリアライズなど)~Java Advanced編

2023/11/05に公開

はじめに

自己紹介

皆さん、こんにちは、斉藤賢哉と申します。私はこれまで、25年以上に渡って企業システムの開発に携わってきました。特にアーキテクトとして、ミッションクリティカルなシステムの技術設計や、Javaフレームワーク開発などの豊富な経験を有しています。
様々なセミナーでの登壇や雑誌への技術記事寄稿の実績があり、また以下のような書籍も執筆しています。

いずれもJava EEJakarta EE)を中心にした企業システム開発のための書籍です。中でも 「アプリケーションアーキテクチャ設計パターン」は、(Javaに限定されない)比較的普遍的なテーマを扱っており、内容的にはまだまだ陳腐化していないため、興味のある方は是非手に取っていただけると幸いです(中級者向け)。

Udemy講座のご紹介

この記事の内容は、私が講師を務めるUdemy講座『Java Advanced編』の一部の範囲をカバーしたものです。『Java Advanced編』はこちらのリンクから購入できます(セールス対象外のためいつも同じ価格)。また定価の約30%OFFで購入可能なクーポンをZenn内で定期的に発行していますので、興味のある方は、ぜひ私の他の記事をチェックしてみてください。

この講座は、以下のような皆様にお薦めします。

  • Javaの基本的なスキルを習得済みで、さらなるレベルアップを目指している方
  • 将来的なキャリアとして、希少性の高い上級エンジニアやアーキテクトを志向している方
  • フリーランスエンジニアとして付加価値の更なる向上を図っている方
  • 「Oracle認定Javaプログラマ」の資格取得を目指している方

この記事を含むシリーズ全体像

この記事はJava SEの一部の機能・仕様を取り上げたものですが、一連のシリーズになっており、シリーズ全体でJava SEを網羅しています。また認定資格である「Oracle認定Javaプログラマ」(Silver、Gold)の範囲もカバーしています。シリーズの全体像および「Oracle認定Javaプログラマ」の範囲との対応関係については、以下を参照ください。

https://zenn.dev/kenya_saitoh/articles/3fe26f51ab001b

5.2 java.ioパッケージのその他の主要なクラス

チャプターの概要

このチャプターでは、ファイル以外のリソースを扱うための、java.ioパッケージのその他の主要なクラスの特徴やAPIについて学びます。

5.2.1 バイト配列からの読み込みと書き込み

バイト配列と入出力ストリーム

前のレッスンでも触れたように、入力ストリームと出力ストリームは、入力から出力へと接続して利用するのが一般的です。
その過程において、メモリ上の一時領域(バイト配列)と接続をしたくなるケースがあります。例えばサーバーサイドのアプリケーションには、Webブラウザからファイルをアップロードしてデータベースに保存する処理や、データベースに保存されたファイルをWebブラウザにダウンロードする処理があります。このような処理でも、メモリ上の一時領域に対して入出力ストリームによって読み込みや書き込みが必要になる可能性があります。
メモリ上の一時領域、すなわちバイト配列に対して、出力ストリームによって書き込みを行うためのクラスがByteArrayOutputStreamです。ByteArrayOutputStreamはjava.io.OutputStreamの実装クラスの1つです。
またバイト配列に対して、入力ストリームによって読み込みを行うためのクラスがByteArrayInputStreamです。ByteArrayInputStreamはjava.io.InputStreamの実装クラスの1つです。

ByteArrayOutputStreamとByteArrayInputStreamは通常、組み合わせて使います。その典型が、前述したファイルアップロードとダウンロードです。ファイルアップロードでは、Webブラウザから送信されたファイルを読み込んだら、ByteArrayOutputStreamによってメモリ上の一時領域に書き込み、その後、ByteArrayInputStreamによってメモリ上の一時領域から読み込んだら、データベースに書き込む、という処理を行います。またファイルダウンロードでは、データベースに保存されたファイルを読み込んだら、ByteArrayOutputStreamによってメモリ上の一時領域に書き込み、その後、ByteArrayInputStreamによってメモリ上の一時領域から読み込んだら、Webブラウザに返送する、という処理を行います。

【図5-2-1】ファイルアップロードとダウンロード
image.png

ByteArrayOutputStreamとByteArrayInputStreamの具体例

それでは、ByteArrayOutputStreamとByteArrayInputStreamの使用方法を、具体的に見てきましょう。
以下のコードを見てください。

snippet (pro.kensait.java.advanced.lsn_5_2_1.Main)
Path src = Paths.get("java_logo1.jpg");
Path dest = Paths.get("java_logo2.jpg");
// メモリ上の一時領域への書き込み
ByteArrayOutputStream baos = new ByteArrayOutputStream(); //【1】
try (InputStream is = Files.newInputStream(src)) {
    BufferedInputStream bis = new BufferedInputStream(is);
    byte[] buf = new byte[10];
    while (bis.read(buf) != -1) { //【2】
        baos.write(buf); //【3】
    }
}
// メモリ上の一時領域からの読み込み
byte[] byteArray = baos.toByteArray(); //【4】
ByteArrayInputStream bais = new ByteArrayInputStream(byteArray); //【5】
try (OutputStream os = Files.newOutputStream(dest)) {
    BufferedOutputStream bos = new BufferedOutputStream(os);
    int c;
    while ((c = bais.read()) != -1) { //【6】
        bos.write(c); //【7】
    }
    bos.flush();
}

このコードは、1つ前のチャプターで取り上げた"java_logo1.jpg"を読み込み、その内容をそのまま"java_logo2.jpg"に書き込む、というコード(Main_BufferedOutputStreamクラス)を拡張したものです。
"java_logo1.jpg"を入力ストリームから読み込んだ後、いったん出力ストリームによって、メモリ上の一時領域に書き込みます。
そして今度は、メモリ上のデータを入力ストリームから読み込んだ後、出力ストリームによって"java_logo2.jpg"に書き込みます。
まず、メモリ上の一時領域への書き込みから見ていきましょう。
メモリ上の一時領域への出力ストリームとして、ByteArrayOutputStreamオブジェクトを生成します【1】。
そして"java_logo1.jpg"の入力ストリームからデータを読み込み【2】、この出力ストリームに対して書き込みを行います【3】。
書き込みが完了したら、ByteArrayOutputStreamのtoByteArray()メソッドを呼び出し、バイト配列を取得します【4】。このバイト配列が、データが書き込まれたメモリ上の一時領域です。
次にこのメモリ上の一時領域からの読み込みです。
メモリ上の一時領域からの入力ストリームとして、ByteArrayInputStreamオブジェクトを生成しますが、このときコンストラクタの引数にデータが格納されたバイト配列を指定します【5】。
そしてwhile文の条件式でByteArrayInputStreamのread()メソッドを呼び出し、1バイトずつデータを読み込みます【6】。
読み込んだデータは、ループ処理の中でBufferedOutputStreamに対して書き込みます【7】。

【図5-2-2】ByteArrayOutputStreamとByteArrayInputStream
image.png

このようにByteArrayOutputStreamおよびByteArrayInputStreamを使うと、入出力ストリームによって、バイト配列に対する書き込みおよび読み込みが可能になります。

5.2.2 シリアライズとデシリアライズ

シリアライズ・デシリアライズとは

Javaのオブジェクト(インスタンス)には、シリアライズ、デシリアライズ、という機能があります。シリアライズとは、メモリ上に存在するJavaのオブジェクトをバイト表現(複数個からなるバイトの列)に変換することで、「直列化」とも呼ばれます。またデシリアライズとはその逆で、バイト表現からオブジェクトを復元することです。
オブジェクトのシリアライズ・デシリアライズには、様々な目的があります。例えばオブジェクトをシリアライズし、変換されたバイト表現をバイナリファイルとして永続化しておけば、オブジェクトを後から復元することが可能です。バイナリファイルは、ファイルシステムではなく、データベースのBLOB型と呼ばれる形式のカラムに格納することもできます。またシリアライズしたバイト表現をネットワーク経由で他のJavaアプリケーションに送信し、そこでデシリアライズしてオブジェクトとして復元する、といったことも技術的には可能です。

【図5-2-3】シリアライズとデシリアライズ
image.png

シリアライズする方法

Javaのオブジェクトをシリアライズするためには、対象となるクラスは必ずjava.io.Serializableインタフェースをimplementsしなければなりません。Serializableインタフェースは「当該クラスがシリアライズ可能であること」を表すインタフェースですが、メソッドは1つも宣言されていません。このように、メソッドを1つも持たず「ある機能を有していることを表す」ために存在するインタフェースを、「マーカーインタフェース」と呼びます。
ここでは、以下のようなPersonクラスをシリアライズの対象にします。

pro.kensait.java.advanced.lsn_5_2_2.Person
public class Person implements Serializable { //【1】
    // フィールド
    private String name;
    private int age;
    private String gender;
    // コンストラクタ
    public Person(String name, int age, String gender) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }
    // アクセサメソッド
    ........
}

Personクラスは、name(名前)、age(年齢)、gender(性別)という3つのフィールドを持ち、コンストラクタとアクセサメソッドも定義されているものとします。またシリアライズの対象にするために、Serializableインタフェースをimplementsしています【1】。
このようなPersonクラスのインスタンスをシリアライズし、バイナリファイルとして保存してみましょう。
以下のコードを見てください。

snippet (pro.kensait.java.advanced.lsn_5_2_2.Main_Ser_1)
Person target = new Person("Alice", 25, "female"); //【1】
Path serPath = Paths.get("alice.ser"); //【2】
try (ObjectOutputStream oos = new ObjectOutputStream(
        Files.newOutputStream(serPath))) { //【3】
    oos.writeObject(target); //【4】
}

まず対象となるPersonクラスのインスタンスを生成し【1】、また保存するファイル"alice.ser"のパスを生成します【2】。
続いてこのオブジェクト(インスタンス)をシリアライズし、ファイルに保存します。オブジェクトのシリアライズには、java.io.ObjectOutputStreamクラスを使います。ObjectOutputStreamのオブジェクトを生成するためには、まずFilesクラスのnewOutputStream()メソッドにファイルのパス(変数serPath)を指定して、OutputStreamを取得します【3】。そして取得したOutputStreamをコンストラクタに渡して、ObjectOutputStreamのインスタンスを生成します。このクラスもAutoCloseableインタフェースをimplementsしているため、try-with-resources文によって自動的にクローズするようにします。
次にObjectOutputStreamのwriteObjectメソッドに、対象となるPersonインスタンス(変数target)を渡します【4】。するとPersonインスタンスがシリアライズされ、"alice.ser"という名前でファイルに保存されます。このファイルには、Personクラスとしての設計情報(フィールドやメソッドなど)や、"Alice"、25、"female"といった属性情報が、一緒に保存されます。

デシリアライズする方法

続いてデシリアライズです。
前項では、Personインスタンスをシリアライズしバイナリファイルとして保存しましたが、そのファイルを読み込んでデシリアライズし、元のPersonインスタンスを復元します。
以下のコードを見てください。

snippet (pro.kensait.java.advanced.lsn_5_2_2.Main_Ser_2)
Path serPath = Paths.get("alice.ser"); //【1】
try (ObjectInputStream ois = new ObjectInputStream(
        Files.newInputStream(serPath))){ //【2】
    Person target = (Person) ois.readObject(); //【3】
    System.out.println(target);
}

まず、読み込むファイル"alice.ser"のパスを生成します【1】。
続いてこのファイル内のバイト表現をデシリアライズし、オブジェクトとして復元します。オブジェクトのデシリアライズには、java.io.ObjectInputStreamクラスを使います。FilesクラスのnewInputStreamメソッド()にファイルのパス(変数serPath)を指定してInputStreamを生成します【2】。そしてそれをコンストラクタに渡して、ObjectInputStreamのオブジェクトを生成します。このクラスもAutoCloseableインタフェースをimplementsしているため、try-with-resources文によって自動的にクローズするようにします。
次にObjectInputStreamのreadObject()メソッドを呼び出すと、Personインスタンスが復元するため、キャストした上で変数targetに代入します【3】。この変数targetをコンソールに表示すると、元のPersonクラスが"Alice"、25、"female"といった属性情報と一緒に復元されていることを、確認することができます。

フィールドごとのシリアライズ可否

クラスにSerializableインタフェースをimplementsしたからといって、そのクラス内のあらゆるフィールドが、自動的にシリアライズの対象になるわけではありません。シリアライズ可能なフィールドは、プリミティブ型や、String型やInteger型のようにその型自体がシリアライズ可能である必要があります。またこれらのシリアライズ可能な型を格納したコレクションも、シリアライズ可能です。
逆に言うと、このチャプターで登場した入出力ストリームや、次のチャプターで紹介するネットワーク通信のためのチャネルなどは(仕組みを考えると自明ですが)シリアライズすることはできません。
オブジェクトをシリアライズしようとしたとき、対象クラスにシリアライズ不可のフィールドがあると、java.io.NotSerializableExceptionが発生してシリアライズは失敗します。ただしそのような場合は、修飾子transientを当該フィールドに付与し、シリアライズ対象外であることを明示すれば、シリアライズは可能です。
なおtransientは、シリアライズ可能なフィールドに対しても付与することができます。例えば前項のPersonクラスにおいて、以下のようにフィールドgenderにtransientを付与すると、シリアライズ対象から除外されます。

snippet
transient private String gender;

そのためPersonインスタンスをデシリアライズすると、このフィールドはnull値になります。

デシリアライズとシリアルバージョンUID

オブジェクトのデシリアライズは、常に成功するとは限りません。なぜならシリアライズした環境における対象クラスと、デシリアライズした環境における対象クラスとが、完全に同じであるという保証はないからです。
例えば既出のPersonインスタンスをシリアライズし、別の環境に持ち運んでデシリアライズしようとしたとき、その環境ではPersonクラスの設計が変わっており、メソッドやフィールドの追加や削除があった、というケースを考えます。このような環境下でデシリアライズすると、2つのクラス間に互換性はないためjava.io.InvalidClassExceptionという例外が発生し、デシリアライズは失敗します。

【図5-2-4】デシリアライズ時に発生する例外
image.png

そのときスタックトレースには、以下のようなエラーメッセージが出力されます。

Exception in thread "main" java.io.InvalidClassException: pro.kensait.java.advanced.lsn_5_2_2.Person;
local class incompatible: stream classdesc serialVersionUID = 353519173072923185,
local class serialVersionUID = -3593269814882424830

このエラーメッセージの意味は「デシリアライズ元のクラスと、デシリアライズ先のクラスと間で、バージョンが一致してない」というものです。ここで言う「クラスのバージョン」とは、シリアルバージョンUID(serialVersionUID)と呼ばれているもので、シリアライズ実行時にフィールドやメソッドの情報から自動計算されたものです。

シリアルバージョンUIDの明示

前項では、デシリアライズのときに自動計算されたシリアルバージョンUIDが、対象オブジェクトと不一致だとデシリアライズに失敗する、という話をしました。
シリアルバージョンUIDは、デフォルトでは自動計算されますが、開発者自身が明示することも可能です。具体的には以下のように、serialVersionUIDという名前のフィールドをlong型で宣言し、任意の値を設定するようにします。

snippet
private static final long serialVersionUID = 2L;

シリアライズ可能なクラスは、このようにして開発者自身がシリアルバージョンUIDを明示することが、一般的に推奨されています。それはシリアルバージョンUIDを自動計算させる場合、その計算方法は環境やコンパイラに依存します。そのため本来は2つのクラス間には互換性があり、デシリアライズが成功するケースであったとしても、想定外のデシリアライズエラーが発生してしまう可能性があるからです。
なおEclipseなどの統合開発環境を使っている場合、シリアルバージョンUIDは、コーディングの支援機能によって自動生成することができます。

このチャプターで学んだこと

このチャプターでは、以下のことを学びました。

  1. バイト配列からの読み込みと書き込みの方法について。
  2. シリアライズおよびデシリアライズの仕組みやその方法について。
  3. デシリアライズ時に発生する問題とシリアルバージョンUIDについて。

Discussion