メインコンテンツにスキップ

12件の投稿件の投稿が「Java」タグ付き

すべてのタグを見る

マルチコネクションサーバへの旅

· 21分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

概要

複数のクライアントリクエストを同時に処理できるサーバアプリケーションの実装は、今や非常に簡単です。Spring MVCを使うだけで、すぐに実現できます。しかし、エンジニアとして、その基礎原理に興味があります。本記事では、明らかに見えることを問い直しながら、マルチコネクションサーバを実装するための考慮事項について考察していきます。

情報

例のコードはGitHubで確認できます。

ソケット

最初の目的地は「ソケット」です。ネットワークプログラミングの観点から、ソケットはネットワーク上でデータを交換するための通信エンドポイントです。「ファイルのように使用される」という説明が重要です。これは、ファイルディスクリプタ(fd)を通じてアクセスされ、ファイルと同様のI/O操作をサポートするためです。

なぜソケットはポートではなくfdで識別されるのか?

ソケットはIP、ポート、および相手のIPとポートを使用して識別できますが、fdを使用する方が好まれます。これは、接続が受け入れられるまでソケットには情報がなく、単純な整数(fd)以上のデータが必要だからです。

ソケットを使用してサーバアプリケーションを実装するには、次の手順を踏む必要があります:

JavaからHello Worldお出力するまで 3

· 19分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

前の章では、Javaをコンパイルし、バイトコードの構造を調べました。この章では、JVMが「Hello World」コードブロックをどのように実行するかを探ります。

第3章: JVM上でJavaを実行する

  • クラスローダー
  • Java仮想マシン
  • Javaネイティブインターフェース
  • JVMメモリロードプロセス
  • Hello Worldとメモリアリアの相互作用

クラスローダー

Javaクラスがメモリにロードされ、初期化されるタイミング、場所、方法を理解するためには、まずJVMのクラスローダーを見てみる必要があります。

クラスローダーは、コンパイルされたJavaクラスファイル(.class)を動的にロードし、それをJVMのメモリアリアであるランタイムデータエリアに配置します。

クラスローダーによるクラスファイルのロードプロセスは、以下の3つのステージで構成されます:

  1. ロード: クラスファイルをJVMメモリに取り込む。
  2. リンク: クラスファイルを検証して使用可能にするプロセス。
  3. 初期化: クラスファイルを適切な値で初期化する。

重要なのは、クラスファイルは一度にすべてメモリにロードされるのではなく、アプリケーションが必要とするタイミングで動的にメモリにロードされるということです。

多くの人が誤解しているのは、クラスやクラス内の静的メンバーがメモリにロードされるタイミングです。多くの人は、ソースが実行されるとすぐにすべてのクラスと静的メンバーがメモリにロードされると誤解しています。しかし、静的メンバーは、クラスがメモリに動的にロードされ、そのクラス内のメンバーが呼び出されたときにのみメモリにロードされます。

verboseオプションを使用すると、メモリへのロードプロセスを観察できます。

java -verbose:class VerboseLanguage

image

VerboseLanguageクラスが「Hello World」が印刷される前にロードされていることがわかります。

情報

Java 1.8とJava 21では、コンパイル結果からログ出力形式が異なります。バージョンが進むにつれて最適化が行われ、コンパイラの動作が若干変わるため、バージョンを確認することが重要です。この記事では、デフォルトバージョンとしてJava 21を使用し、他のバージョンについては別途指定します。

ランタイムデータエリア

ランタイムデータエリアは、プログラムの実行中にデータが保存される空間です。共有データエリアとスレッドごとのデータエリアに分かれています。

共有データエリア

JVM内には、JVM内で実行される複数のスレッド間でデータを共有できるエリアがいくつかあります。これにより、さまざまなスレッドが同時にこれらのエリアにアクセスできます。

ヒープ

VerboseLanguageクラスのインスタンスが存在する場所

ヒープエリアは、Javaオブジェクトや配列が作成されるときに割り当てられる場所です。JVMが起動するときに作成され、JVMが終了するときに破棄されます。

Java仕様によると、この空間は自動的に管理されるべきです。この役割はガベージコレクタ(GC)と呼ばれるツールによって実行されます。

JVM仕様にはヒープのサイズに制約はありません。メモリ管理もJVMの実装に任されています。しかし、ガベージコレクタが新しいオブジェクトを作成するための十分なスペースを確保できない場合、JVMはOutOfMemoryエラーをスローします。

メソッドエリア

メソッドエリアは、クラスやインターフェースの定義を保存する共有データエリアです。ヒープと同様に、JVMが起動するときに作成され、JVMが終了するときに破棄されます。

クラスのグローバル変数や静的変数はこのエリアに保存され、プログラムの開始から終了までどこからでもアクセス可能です。(= ランタイム定数プール)

具体的には、クラスローダーがクラスのバイトコード(.class)をロードし、それをJVMに渡します。JVMはオブジェクトの作成やメソッドの呼び出しに使用されるクラスの内部表現を生成します。この内部表現は、クラスやインターフェースのフィールド、メソッド、コンストラクタに関する情報を収集します。

実際、JVM仕様によると、メソッドエリアは「どのようにあるべきか」の明確な定義がないエリアです。これは論理的なエリアであり、実装によってはヒープの一部として存在することもあります。単純な実装では、GCや圧縮を行わずにヒープの一部として存在することもあります。

ランタイム定数プール

ランタイム定数プールはメソッドエリアの一部であり、クラスやインターフェースの名前、フィールド名、メソッド名へのシンボリック参照を含みます。JVMはランタイム定数プールを使用して、参照の実際のメモリアドレスを見つけます。

バイトコードを解析するときに見たように、定数プールはクラスファイルの中にありました。実行時には、クラスファイル構造の一部であった定数プールが読み取られ、クラスローダーによってメモリにロードされます。

文字列定数プール

「Hello World」文字列が保存される場所

前述のように、ランタイム定数プールはメソッドエリアの一部です。しかし、ヒープにも定数プールがあり、これを文字列定数プールと呼びます。

new String("Hello World")を使用して文字列を作成すると、その文字列はオブジェクトとして扱われ、ヒープで管理されます。例を見てみましょう:

String s1 = "Hello World";
String s2 = new String("Hello World");

コンストラクタ内で使用される文字列リテラルは文字列プールから取得されますが、newキーワードは新しい一意の文字列の作成を保証します。

0: ldc           #7                  // String Hello World
2: astore_1
3: new #9 // class java/lang/String
6: dup
7: ldc #7 // String Hello World
9: invokespecial #11 // Method java/lang/String."<init>":(Ljava/lang/String;)V
12: astore_2
13: return

バイトコードを調べると、invokespecial命令を使用して文字列が「作成」されていることがわかります。

invokespecial命令は、オブジェクトの初期化メソッドが直接呼び出されることを意味します。

なぜ文字列定数プールはメソッドエリアのランタイム定数プールとは異なり、ヒープに存在するのでしょうか?🤔

  • 文字列は非常に大きなオブジェクトに属します。また、どれだけの文字列が作成されるか予測が難しいため、未使用の文字列をクリーンアップしてメモリ空間を効率的に使用するプロセスが必要です。これは、文字列定数プールがヒープに存在する必要があることを意味します。
    • スタックに保存すると、スペースを見つけるのが難しくなり、文字列の宣言が失敗する可能性があります。
    • スタックサイズは通常、32ビットシステムで約320kb〜1MB、64ビットシステムで1MB〜2MBです。
  • 文字列は不変として管理されます。変更することはできず、常に新しく作成されます。既に作成された文字列を再利用することで、メモリ空間を節約します(インターン)。しかし、アプリケーションのライフサイクル中に未使用(到達不能)な文字列が蓄積する可能性があります。メモリを効率的に利用するためには、参照されていない文字列をクリーンアップする必要があり、これもGCの影響下にある必要があります。

結論として、文字列定数プールはGCの影響下にあるため、ヒープに存在する必要があります。

文字列比較操作は、完全一致のために長さがNの場合、N回の操作が必要です。一方、プールを使用すると、equals比較は参照をチェックするだけで済み、コストはO(1)O(1)です。

newを使用して文字列を作成することで、文字列定数プール外の文字列を文字列定数プールに移動することができます。

String greeting = new String("Hello World");
greeting.intern(); // 定数プールを使用

// これで、SCP内の文字列リテラルとの比較が可能になります。
assertThat(greeting).isEqualTo("Hello World"); // true

これは過去にはメモリを節約するためのトリックとして提供されていましたが、現在では必要ありませんので、文字列はリテラルとして使用するのが最善です。

要約すると:

  1. 数値には最大値がありますが、文字列はその性質上、最大サイズが不明確です。
  2. 文字列は非常に大きくなる可能性があり、他の型に比べて作成後に頻繁に使用される可能性が高いです。
  3. 自然に高いメモリ効率が求められます。これを実現しながら使いやすさを向上させるためには、グローバルに参照可能であるべきです。
  4. スタック内のスレッドごとのデータエリアに配置すると、他のスレッドによって再利用できず、サイズが大きい場合は割り当てスペースを見つけるのが難しくなります。
  5. 共有データエリア+ヒープ内に配置するのが合理的ですが、JVMレベルで不変として扱う必要があるため、ヒープ内に専用の定数プールを作成して別々に管理します。
ヒント

コンストラクタ内の文字列リテラルは文字列定数プールから取得されますが、newキーワードは独立した文字列の作成を保証します。その結果、文字列定数プール内の文字列とヒープ内の文字列の2つが存在します。

スレッドごとのデータエリア

共有データエリアに加えて、JVMは個々のスレッドのデータを別々に管理します。JVMは実際にかなり多くのスレッドの同時実行をサポートしています

PCレジスタ

各JVMスレッドにはPC(プログラムカウンタ)レジスタがあります。

PCレジスタは、CPUが命令の実行を続けるために現在の命令の位置を保存します。また、次に実行される命令のメモリアドレスを保持し、命令の実行を最適化するのに役立ちます。

PCの動作はメソッドの性質によって異なります:

  • 非ネイティブメソッドの場合、PCレジスタは現在実行中の命令のアドレスを保存します。
  • ネイティブメソッドの場合、PCレジスタは未定義の値を保持します。

PCレジスタのライフサイクルは基本的にスレッドのライフサイクルと同じです。

JVMスタック

各JVMスレッドには独自のスタックがあります。JVMスタックはメソッド呼び出し情報を保存するデータ構造です。各メソッド呼び出しごとにスタックに新しいフレームが作成され、そのフレームにはメソッドのローカル変数と戻り値のアドレスが含まれます。プリミティブ型の場合はスタックに直接保存され、ラッパー型の場合はヒープに作成されたインスタンスへの参照を保持します。これにより、intやdouble型はIntegerやDoubleに比べてわずかにパフォーマンスが優れています。

JVMスタックのおかげで、JVMはプログラムの実行をトレースし、必要に応じてスタックトレースを記録できます。

  • これはスタックトレースとして知られています。printStackTraceはその一例です。
  • 単一の操作が複数のスレッドを横断するwebfluxのイベントループのようなシナリオでは、スタックトレースの重要性を理解するのが難しいかもしれません。

スタックのメモリサイズと割り当て方法はJVMの実装によって決定できます。通常、スレッドが開始されるときに約1MBのスペースが割り当てられます。

JVMメモリ割り当てエラーはスタックオーバーフローエラーを引き起こす可能性があります。しかし、JVMの実装がJVMスタックサイズの動的拡張を許可し、拡張中にメモリエラーが発生した場合、JVMはOutOfMemoryエラーをスローすることがあります。

ネイティブメソッドスタック

ネイティブメソッドはJava以外の言語で書かれたメソッドです。これらのメソッドはバイトコードにコンパイルできないため(Javaではないため、javacを使用できません)、別のメモリアリアが必要です。

  • ネイティブメソッドスタックはJVMスタックと非常に似ていますが、ネイティブメソッド専用です。
  • ネイティブメソッドスタックの目的は、ネイティブメソッドの実行を追跡することです。

JVMの実装は、ネイティブメソッドスタックのサイズとメモリブロックの操作方法を決定できます。

ネイティブメソッドスタックに起因するメモリ割り当てエラーの場合、スタックオーバーフローエラーが発生します。しかし、ネイティブメソッドスタックのサイズを増やす試みが失敗した場合、OutOfMemoryエラーが発生します。

結論として、JVMの実装はネイティブメソッドの呼び出しをサポートしないことを決定でき、そのような実装はネイティブメソッドスタックを必要としないことを強調しています。

Javaネイティブインターフェースの使用については別の記事で取り上げます。

実行エンジン

ロードとストレージのステージが完了すると、JVMはクラスファイルを実行します。これには3つの要素が含まれます:

  • インタープリタ
  • JITコンパイラ
  • ガベージコレクタ

インタープリタ

プログラムが開始されると、インタープリタはバイトコードを1行ずつ読み取り、マシンが理解できる機械語に変換します。

インタープリタは一般的に遅いです。なぜでしょうか?

コンパイルされた言語は、実行前のコンパイルプロセス中にプログラムの実行に必要なリソースや型を定義できます。しかし、インタープリタ言語では、必要なリソースや変数の型は実行時までわからないため、最適化が難しくなります。

JITコンパイラ

JIT(Just In Time)コンパイラは、インタープリタの欠点を克服するためにJava 1.1で導入されました。

JITコンパイラは、バイトコードを実行時に機械語にコンパイルし、Javaアプリケーションの実行速度を向上させます。頻繁に実行される部分(ホットコード)を検出してコンパイルします。

JIT関連の動作を確認する際には、以下のキーワードを使用できます。

-XX:+PrintCompilation: JIT関連のログを出力します -Djava.compiler=NONE: JITを無効にします。パフォーマンスの低下を観察できます。

ガベージコレクター ガベージコレクターは重要なコンポーネントであり、別のドキュメントに記載されていますので、今回は省略します。

GCの最適化は一般的ではありません。 しかし、GC操作による500ms以上の遅延が発生する場合があり、高トラフィックやキャッシュの厳しいTTLを扱うシナリオでは、500msの遅延が重大な問題となることがあります。

結論

Javaは間違いなく複雑な言語です。

面接では、次のような質問をよく受けます。

Javaについてどのくらい詳しいですか?

これで、もっと自信を持って答えられるようになるでしょう。

えっと…🤔 ちょうど「Hello World」くらいです。

参考文献

JavaからHello Worldお出力するまで 2

· 13分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

前回の記事から続けて、コードが「Hello World」を表示するまでの進化を探っていきましょう。

第2章 コンパイルと逆アセンブル

プログラミング言語にはレベルがあります。

プログラミング言語が人間の言語に近いほど高水準言語とされ、コンピュータが理解できる言語(機械語)に近いほど低水準言語とされます。高水準言語でプログラムを書くことは、人間にとって理解しやすく、生産性を向上させますが、機械語との間にギャップが生じ、そのギャップを埋めるプロセスが必要です。

高水準言語が低水準に降りていくプロセスをコンパイルと呼びます。

Javaは低水準言語ではないため、コンパイルプロセスが存在します。Javaのコンパイルプロセスがどのように機能するかを見てみましょう。

コンパイル

前述のように、Javaコードはコンピュータによって直接実行されることはできません。Javaコードを実行するためには、コンピュータが読み取り、解釈できる形に変換する必要があります。この変換には以下の主要なステップが含まれます:

コンパイルの結果得られる.classファイルはバイトコードです。しかし、これはまだコンピュータが実行できる機械語ではありません。Java仮想マシン(JVM)はこのバイトコードを読み取り、さらに機械語に変換します。このプロセスについては最終章で取り上げます。

まず、.javaファイルをコンパイルして.classファイルを作成しましょう。javacコマンドを使用してコンパイルできます。

// VerboseLanguage.java
public class VerboseLanguage {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
javac VerboseLanguage.java

クラスファイルが作成されたことが確認できます。javaコマンドを使用してクラスファイルを実行できます。これがJavaプログラムを実行する基本的な流れです。

java VerboseLanguage
// Hello World

クラスファイルの内容が気になりますか?コンピュータがどのように言語を読み取り、実行するのか疑問に思いますか?このファイルにはどんな秘密が隠されているのでしょうか?まるでパンドラの箱を開けるような気分です。

期待しながら開けてみると...

なんてこった!

ほんの少しのバイナリ内容が表示されるだけです。

待って、コンパイルの結果はバイトコードじゃなかったの?

そうです、それはバイトコードです。同時に、それはバイナリコードでもあります。この時点で、バイトコードとバイナリコードの違いについて簡単に触れておきましょう。

バイナリコード : 0と1で構成されたコード。機械語はバイナリコードで構成されていますが、すべてのバイナリコードが機械語というわけではありません。

バイトコード : 0と1で構成されたコード。ただし、バイトコードは機械ではなくVMを対象としています。JITコンパイラなどのプロセスを通じてVMによって機械語に変換されます。

それでも、この記事が深掘りを謳っている以上、私たちはこの変換を読み取ることに挑戦しました。

幸いなことに、私たちのパンドラの箱には0と1だけが含まれており、他の困難や挑戦はありません。

読み取ることには成功しましたが、0と1だけでは内容を理解するのは非常に難しいです 🤔

さて、このコードを解読してみましょう。

逆アセンブル

コンパイルプロセス中に、コードは0と1で構成されたバイトコードに変換されます。前述のように、バイトコードを直接解釈するのは非常に難しいです。幸いなことに、JDKには開発者がコンパイルされたバイトコードを読み取るのに役立つツールが含まれており、デバッグに役立ちます。

バイトコードを開発者にとってより読みやすい形に変換するプロセスを逆アセンブルと呼びます。このプロセスは時々デコンパイルと混同されることがありますが、デコンパイルはアセンブリ言語ではなく、より高水準のプログラミング言語に変換されます。また、javapのドキュメントでは明確に逆アセンブルという用語が使用されているため、ここでもそれに従います。

情報

デコンパイルは、バイナリコードを比較的高水準の言語で表現することを指します。一方、逆アセンブルはバイナリコードを最小限の人間が読みやすい形(アセンブリ言語)で表現します。

仮想マシンアセンブリ言語

javapを使用してバイトコードを逆アセンブルしてみましょう。出力は0と1だけよりもはるかに読みやすいです。

javap -c VerboseLanguage.class
Compiled from "VerboseLanguage.java"
public class VerboseLanguage {
public VerboseLanguage();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}

ここから何が学べるでしょうか?

まず、この言語は仮想マシンアセンブリ言語と呼ばれます。

Java仮想マシンコードは、JDKリリースに含まれるOracleのjavapユーティリティによって出力される非公式の「仮想マシンアセンブリ言語」で書かれています。 - JVM Spec

フォーマットは次の通りです:

<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]

index : JVMコードバイト配列のインデックス。メソッドの開始オフセットと考えることができます。

opcode : 命令セットのオペコードを表すニーモニックシンボル。虹の色の順番を'ROYGBIV'として覚えるように、命令セットを区別するためのニーモニックシンボルです。虹の色が命令セットを表すとすれば、'ROYGBIV'の各音節がそれらを区別するために定義されたニーモニックシンボルと考えることができます。

operandN : 命令のオペランド。コンピュータ命令のオペランドはアドレスフィールドです。定数プールに格納されているデータの場所を指します。

逆アセンブル結果のmainメソッド部分を詳しく見てみましょう。

Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
  • invokevirtual: インスタンスメソッドを呼び出す
  • getstatic: クラスから静的フィールドを取得する
  • ldc: 実行時定数プールにデータをロードする

3行目の3: ldc #13は、インデックス13の項目をプールにロードすることを意味し、親切にもコメントでその項目が示されています。

Hello World

バイトコード命令であるgetstaticinvokevirtualは、1バイトのオペコード番号で表されます。例えば、getstatic=0xb2invokevirtual=0xb6などです。Javaバイトコード命令も最大で256種類のオペコードを持つことが理解できます。

invokevirtualのバイトコードを示すJVM命令セット

mainメソッドのバイトコードを16進数で見ると、次のようになります:

b2 00 07 12 0d b6

まだパターンがわかりにくいかもしれません。ヒントとして、前述のようにオペコードの前の数値はJVM配列のインデックスであることを思い出してください。表現を少し変えてみましょう。

arr = [b2, 00, 07, 12, 0d, b6]
  • arr[0] = b2 = getstatic
  • arr[3] = 12 = ldc
  • arr[5] = b6 = invokevirtual

インデックスの意味が少し明確になります。いくつかのインデックスをスキップしている理由は非常に簡単です:getstaticは2バイトのオペランドを必要とし、ldcは1バイトのオペランドを必要とします。したがって、getstaticの次の命令であるldcはインデックス3に記録され、1と2をスキップします。同様に、4をスキップしてinvokevirtual命令はインデックス5に記録されます。

最後に、4行目のコメント(Ljava/lang/String;)Vに注目してください。このコメントを通じて、JavaバイトコードではクラスがL;として表され、voidがVとして表されることがわかります。他の型もそれぞれ独自の表現を持ち、次のようにまとめられます:

Javaバイトコード説明
Bbyte符号付きバイト
CcharUnicode文字
Ddouble倍精度浮動小数点値
Ffloat単精度浮動小数点値
Iint整数
Jlong長整数
L<classname>;referenceクラス<classname>のインスタンス
Sshort符号付きショート
Zboolean真または偽
[reference一次元配列

-verboseオプションを使用すると、定数プールを含むより詳細な逆アセンブル結果を見ることができます。オペランドと定数プールを一緒に調べると興味深いでしょう。

  "VerboseLanguage.java"からコンパイル
public class VerboseLanguage
minor version: 0
major version: 65
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // VerboseLanguage
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World
#14 = Utf8 Hello World
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // VerboseLanguage
#22 = Utf8 VerboseLanguage
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 VerboseLanguage.java
{
public VerboseLanguage();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "VerboseLanguage.java"

結論

前の章では、Hello Worldを出力するために冗長なプロセスが必要な理由を探りました。この章では、Hello Worldを出力する前のコンパイルと逆アセンブルのプロセスを見てきました。次に、JVMを使用してHello Worldを出力するメソッドの実行フローを最終的に調べます。

参考文献

JavaからHello Worldお出力するまで 1

· 16分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

プログラミングの世界では、常に「Hello World」という文を出力することから始まります。それはまるで不文律のようです。

# hello.py
print("Hello World")
python hello.py
// Hello World

Python?素晴らしい。

// hello.js
console.log("Hello World");
node hello.js
// Hello World

JavaScript?悪くない。

public class VerboseLanguage {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
javac VerboseLanguage.java
java VerboseLanguage
// Hello World

しかし、Javaはまるで別の世界から来たように感じます。クラス名がファイル名と一致しなければならないことさえまだ触れていません。

publicとは何か、classとは何か、staticとは何か、voidmainString[]、そしてSystem.out.printlnを経て、ようやく文字列「Hello World」にたどり着きます。さあ、別の言語を学びましょう。1

単に「Hello World」を出力するだけでも、Javaはかなりの背景知識を要求します。なぜJavaはこんなにも冗長なプロセスを必要とするのでしょうか?

このシリーズは3つの章に分かれています。目標は、2つの単語「Hello World」を出力するために裏で何が起こっているのかを詳しく探ることです。各章の具体的な内容は以下の通りです:

  • 第1章では、Hello Worldを出発点とする理由を紹介します。
  • 第2章では、コンパイルされたクラスファイルとコンピュータがJavaコードを解釈し実行する方法を検討します。
  • 最後に、JVMがpublic static void mainをロードして実行する方法とその動作原理を探ります。

3つの章の内容を組み合わせることで、ようやく「Hello World」の概念を理解することができます。かなり長い旅ですが、深呼吸して始めましょう。

第1章. なぜ?

JavaでHello Worldを出力する前に、いくつかの「なぜ」の瞬間を考慮する必要があります。

なぜクラス名はファイル名と一致しなければならないのか?

より正確には、publicクラスの名前がファイル名と一致しなければならないのです。なぜでしょうか?

Javaプログラムはコンピュータに直接理解されるものではありません。JVMという仮想マシンがプログラムの実行を助けます。Javaプログラムをコンピュータで実行可能にするためには、いくつかのステップを経てJVMが解釈できる機械語に変換する必要があります。最初のステップは、コンパイラを使用してプログラムをJVMが解釈できるバイトコードに変換することです。変換されたバイトコードはJVM内部のインタープリタを通じて機械語に翻訳され、実行されます。

コンパイルプロセスを簡単に見てみましょう。

public class Outer {
public static void main(String[] args) {
System.out.println("This is Outer class");
}

private class Inner {
}
}
javac Outer.java
Permissions Size User   Date Modified Name
.rw-r--r-- 302 haril 30 Nov 16:09 Outer$Inner.class
.rw-r--r-- 503 haril 30 Nov 16:09 Outer.class
.rw-r--r-- 159 haril 30 Nov 16:09 Outer.java

Javaはコンパイル時に各クラスごとに.classファイルを生成します

さて、JVMはプログラムを実行するためにmainメソッドを見つける必要があります。どうやってmainメソッドを見つけるのでしょうか?

なぜmain()を見つける必要があるのか?もう少し待ってください。

もしJavaファイル名がパブリッククラス名と一致しない場合、Javaインタープリタはmainメソッドを見つけるためにすべてのクラスファイルを読み込む必要があります。ファイル名がパブリッククラス名と一致している場合、Javaインタープリタは解釈する必要のあるファイルをよりよく特定できます。

1000クラスが含まれるJava1000というファイルを想像してみてください。1000クラスの中からmain()がどこにあるのかを特定するために、インタープリタはすべてのクラスファイルを調べる必要があります。

しかし、ファイル名がパブリッククラス名と一致している場合、main()により迅速にアクセスでき(mainはパブリッククラスに存在するため)、すべてのロジックがmain()から始まるため、他のクラスにも簡単にアクセスできます。

なぜpublicでなければならないのか?

JVMはクラス内のmainメソッドを見つける必要があります。クラスの外部からクラスにアクセスするJVMがクラス内のメソッドを見つけるためには、そのメソッドがpublicでなければなりません。実際、アクセス修飾子をprivateに変更すると、mainpublicとして宣言するように指示するエラーメッセージが表示されます。

Error: Main method not found in class VerboseLanguage, please define the main method as:
public static void main(String[] args)

なぜstaticでなければならないのか?

JVMはpublic main()メソッドを見つけました。しかし、このメソッドを呼び出すためには、まずオブジェクトを作成する必要があります。JVMはこのオブジェクトを必要とするのでしょうか?いいえ、JVMはただmainを呼び出すだけでよいのです。staticとして宣言することで、JVMは不要なオブジェクトを作成する必要がなくなり、メモリを節約できます。

なぜvoidでなければならないのか?

mainメソッドの終了はJavaの実行の終了を意味します。JVMはmainの戻り値を使用することができないため、戻り値の存在は意味がありません。したがって、voidとして宣言するのが自然です。

なぜmainという名前でなければならないのか?

mainというメソッド名は、JVMがアプリケーションの実行エントリーポイントを見つけるために設計されたものです。

「設計」という言葉は大げさに聞こえますが、実際にはmainという名前のメソッドを見つけるようにハードコーディングされています。もし見つける名前がmainではなくharilだった場合、harilという名前のメソッドを探していたでしょう。もちろん、Javaの作成者たちはmainを選んだ理由があったのでしょうが、それがすべてです。

mainClassName = GetMainClassName(env, jarfile);
mainClass = LoadClass(env, classname);

// Find the main method
mainID = (*env)->GetStaticMethodID(env, mainClass, "main", "([Ljava/lang/String;)V");

jbject obj = (*env)->ToReflectedMethod(env, mainClass, mainID, JNI_TRUE);

なぜargsが必要なのか?

これまで、main()String[] argsについては触れていませんでした。なぜこの引数が必要で、なぜ省略するとエラーが発生するのでしょうか?

public static void main(String[] args)はJavaアプリケーションのエントリーポイントであるため、この引数はJavaアプリケーションの外部から渡される必要があります。

標準入力のすべての型は文字列として入力されます。

これがargsが文字列配列として宣言される理由です。考えてみれば納得です。Javaアプリケーションが実行される前に、カスタムオブジェクトタイプを直接作成できますか?🤔

では、なぜargsが必要なのでしょうか?

外部から内部に引数を簡単に渡すことで、Javaアプリケーションの動作を変更することができます。このメカニズムは、Cプログラミングの初期からプログラムの動作を制御するために広く使用されてきました。特にシンプルなアプリケーションにとって、この方法は非常に効果的です。Javaはこの広く使用されている方法を採用しただけです

String[] argsを省略できない理由は、Javaがエントリーポイントとしてpublic static void main(String[] args)のみを許可しているためです。Javaの作成者たちは、argsを宣言して使用しない方が、省略を許可するよりも混乱が少ないと考えたのです。

System.out.println

最後に、出力に関連するメソッドについて話し始めることができます。

もう一度言いますが、Pythonではprint("Hello World")でした。2

Javaプログラムはオペレーティングシステム上で直接実行されるのではなく、JVMという仮想マシン上で実行されます。これにより、Javaプログラムはオペレーティングシステムに関係なくどこでも実行できるようになりますが、オペレーティングシステムが提供する特定の機能を使用するのが難しくなります。これが、JavaでCLIを作成したり、OSのメトリクスを収集したりするのが難しい理由です。

しかし、限られたOS機能を活用する方法(JNI)があり、Systemはこの機能を提供します。主な機能のいくつかは次のとおりです:

  • 標準入力
  • 標準出力
  • 環境変数の設定
  • 実行中のアプリケーションを終了し、ステータスコードを返す

Hello Worldを出力するために、Systemの標準出力機能を使用しています。

実際、System.out.printlnの流れを追うと、nativeキーワードが付いたwriteBytesメソッドに出会い、この操作をCコードに委譲し、標準出力に転送することがわかります。

// FileOutputStream.java
private native void writeBytes(byte b[], int off, int len, boolean append)
throws IOException;

nativeキーワードが付いたメソッドの呼び出しは、Java Native Interface(JNI)を通じて動作します。これは後の章で取り上げます。

String

Javaの文字列は少し特別です。いや、かなり特別なようです。文字列は別のメモリ空間に割り当てられ、特別に扱われていることがわかります。なぜでしょうか?

文字列の次の特性に注目することが重要です:

  • 非常に大きくなる可能性がある。
  • 比較的頻繁に再利用される。

したがって、文字列は一度作成されたら再利用することに重点を置いて設計されています。大きな文字列データがメモリ内でどのように管理されるかを完全に理解するには、後で取り上げるトピックの理解が必要です。ここでは、メモリ空間の節約の原則について簡単に触れておきます。

まず、Javaで文字列がどのように宣言されるかを見てみましょう。

String greeting = "Hello World";

内部的には次のように動作します:

文字列はString Constant Poolに作成され、不変の特性を持っています。一度文字列が作成されると変更されず、新しい文字列を作成する際にConstant Poolに同じ文字列が見つかると、それが再利用されます。

次の章では、JVMスタック、フレーム、ヒープについて取り上げます。

もう一つの文字列の宣言方法はインスタンス化です。

String greeting = new String("Hello World");

この方法は内部動作に違いがあるため、あまり使用されません。以下のように動作します。

newキーワードを使用せずに文字列を直接使用すると、String Constant Poolに作成され、再利用されます。しかし、newキーワードでインスタンス化すると、Constant Poolには作成されません。これにより、同じ文字列が複数回作成され、メモリ空間が無駄になる可能性があります。

まとめ

この章では、次の質問に答えました:

  • なぜ.javaファイル名はクラス名と一致しなければならないのか?
  • なぜpublic static void main(String[] args)でなければならないのか?
  • 出力操作の流れ
  • 文字列の特性とその作成および使用の基本原則

次の章では、Javaコードを自分でコンパイルし、バイトコードがどのように生成されるか、そのメモリアリアとの関係などを探ります。

参考文献

Footnotes

  1. Life Coding Python

  2. Life Coding Python

テスト実行の高速化、Springコンテキストのモッキング

· 5分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

プロジェクトごとにテストコードを書くことは一般的になっています。プロジェクトが成長するにつれて、テストの数も増え、全体のテスト実行時間が長くなります。特にSpringフレームワークに基づいたプロジェクトでは、Spring Beanコンテキストの読み込みによってテスト実行が大幅に遅くなることがあります。この記事では、この問題に対処する方法を紹介します。

すべてのテストをユニットテストとして書く

テストは速くなければなりません。テストが速ければ速いほど、頻繁に実行することに躊躇しなくなります。すべてのテストを一度に実行するのに10分かかる場合、フィードバックは10分後にしか得られません。

Springでテストを高速化するためには、@SpringBootTestを使用しないことが重要です。すべてのBeanを読み込むと、必要なBeanを読み込む時間が圧倒的に長くなり、ビジネスロジックをテストするコードの実行時間よりも長くなります。

@SpringBootTest
class SpringApplicationTest {

@Test
void main() {
}
}

上記のコードは、Springアプリケーションを実行するための基本的なテストコードです。@SpringBootTestによって構成されたすべてのBeanが読み込まれます。では、テストに必要なBeanだけをどのように注入するのでしょうか?

アノテーションやMockitoの活用

特定のアノテーションを使用することで、関連するテストに必要なBeanだけが自動的に読み込まれます。これにより、コンテキスト読み込みを通じてすべてのBeanを読み込むのではなく、本当に必要なBeanだけを読み込むことで、テスト実行時間を最小限に抑えることができます。

いくつかのアノテーションを簡単に見てみましょう。

  • @WebMvcTest: Web MVC関連のBeanのみを読み込みます。
  • @WebFluxTest: Web Flux関連のBeanのみを読み込みます。WebTestClientを使用できます。
  • @DataJpaTest: JPAリポジトリ関連のBeanのみを読み込みます。
  • @WithMockUser: Spring Securityを使用する場合、偽のユーザーを作成し、不要な認証プロセスをスキップします。

さらに、Mockitoを使用することで、複雑な依存関係を簡単に解決してテストを書くことができます。これらの2つの概念を適切に活用することで、ほとんどのユニットテストはそれほど難しくありません。

警告

過度なモッキングが必要な場合、依存関係の設計に問題がある可能性が高いです。モッキングの多用には注意が必要です。

SpringApplicationはどうする?

SpringApplicationを実行するには、SpringApplication.run()を実行する必要があります。このメソッドの実行を確認するためにすべてのSpringコンテキストを非効率的に読み込むのではなく、コンテキスト読み込みが発生するSpringApplicationをモックし、run()が呼び出されるかどうかだけを確認することができます。

class DemoApplicationTests {  

@Test
void main() {
try (MockedStatic<SpringApplication> springApplication = mockStatic(SpringApplication.class)) {
when(SpringApplication.run(DemoApplication.class)).thenReturn(null);

DemoApplication.main(new String[]{});

springApplication.verify(
() -> SpringApplication.run(DemoApplication.class), only()
);
}
}
}

結論

ロバート・C・マーティンの『Clean Code』の第9章では「FIRST原則」について議論しています。

この記事で述べたように、最初の文字FはFast(速い)を意味します。テストの速さの重要性を再度強調し、次の引用で締めくくります。

テストは十分に速くなければならない。 - ロバート・C・マーティン

参考

ZonedDateTimeを使用する際の注意点 - Object.equals vs Assertions.isEqualTo

· 4分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

Javaには時間を表現するためのオブジェクトがいくつか存在します。この記事では、その中でも最も情報量が多いZonedDateTimeを使用した時間の比較方法について説明します。

異なるが同じ時間?

まず、簡単なテストコードを書いて、何か特異な点がないか確認してみましょう。

ZonedDateTime seoulZonedTime = ZonedDateTime.parse("2021-10-10T10:00:00+09:00[Asia/Seoul]");
ZonedDateTime utcTime = ZonedDateTime.parse("2021-10-10T01:00:00Z[UTC]");

assertThat(seoulZonedTime.equals(utcTime)).isFalse();
assertThat(seoulZonedTime).isEqualTo(utcTime);

このコードはテストに合格します。equalsfalseを返す一方で、isEqualToは合格します。なぜでしょうか?

実際には、上記のコードにおける2つのZonedDateTimeオブジェクトは同じ時間を表しています。しかし、ZonedDateTimeは内部的にLocalDateTimeZoneOffset、およびZoneIdを含んでいるため、equalsを使用して比較すると、絶対時間ではなくオブジェクトの値が同じかどうかをチェックします。

そのため、equalsfalseを返します。

image1 ZonedDateTime#equals

しかし、isEqualToは時間オブジェクトの操作に関して異なる動作をするようです。

実際、ZonedDateTimeを比較する際、isEqualToZonedDateTimeequalsを呼び出すのではなく、ChronoZonedDateTimeByInstantComparator#compareを呼び出します。

image2

image3 Comparator#compareが呼び出される。

内部実装を見ると、toEpochSecond()を使用して秒に変換することで比較が行われていることがわかります。つまり、equalsを通じてオブジェクトを比較するのではなく、compareを通じて絶対時間を比較しています。

これに基づいて、ZonedDateTimeの比較を以下のようにまとめることができます:

equals : オブジェクトを比較

isEqualTo : 絶対時間を比較

したがって、ZonedDateTimeを間接的に含むオブジェクトを比較する場合、equalsが呼び出されるため、ZonedDateTimeの絶対値に基づいて比較したい場合は、オブジェクト内でequalsメソッドをオーバーライドする必要があります。

public record Event(
String name,
ZonedDateTime eventDateTime
) {
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Event event = (Event) o;
return Objects.equals(name, event.name)
&& Objects.equals(eventDateTime.toEpochSecond(), event.eventDateTime.toEpochSecond());
}

@Override
public int hashCode() {
return Objects.hash(name, eventDateTime.toEpochSecond());
}
}
@Test
void equals() {
ZonedDateTime time1 = ZonedDateTime.parse("2021-10-10T10:00:00+09:00[Asia/Seoul]");
ZonedDateTime time2 = ZonedDateTime.parse("2021-10-10T01:00:00Z[UTC]");

Event event1 = new Event("event", time1);
Event event2 = new Event("event", time2);

assertThat(event1).isEqualTo(event2); // pass
}

結論

  • ZonedDateTime間でequalsが呼び出される際に絶対時間を比較したい場合は、toEpochSecond()などを使用して変換する必要があります。
  • テストコードや類似のシナリオでisEqualToを使用して直接ZonedDateTimeを比較する場合、equalsは呼び出されず、内部変換が行われるため、別途変換する必要はありません。
  • オブジェクト内にZonedDateTimeが含まれている場合、必要に応じてオブジェクトのequalsメソッドをオーバーライドする必要があります。

[Jacoco] マルチモジュールプロジェクトのためのJacocoレポートの集約

· 3分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

Gradle 7.4から、複数のJacocoテストレポートを1つの統合レポートに集約する機能が追加されました。以前は、複数のモジュールにまたがるテスト結果を1つのファイルで見るのは非常に困難でしたが、今ではこれらのレポートを統合することが非常に便利になりました。

使用方法

レポートを収集するためのサブモジュールの作成

現在のプロジェクト構成は、applicationというモジュールと、applicationモジュールで使用されるlistutilsといった他のモジュールで構成されています。

code-coverage-reportモジュールを追加することで、applicationlistutilsモジュールからテストレポートを収集できます。

プロジェクト構成は次のようになります:

  • application
  • utils
  • list
  • code-coverage-report

jacoco-report-aggregationプラグインの追加

// code-coverage-report/build.gradle
plugins {
id 'base'
id 'jacoco-report-aggregation'
}

repositories {
mavenCentral()
}

dependencies {
jacocoAggregation project(":application")
}

これで、./gradlew testCodeCoverageReportを実行することで、すべてのモジュールのテスト結果を集約したJacocoレポートを生成できます。

jacoco-directory

警告

集約機能を使用するには、jarファイルが必要です。jar { enable = false }と設定している場合は、trueに変更する必要があります。

更新 22-09-28

Gradleのマルチプロジェクトセットアップの場合、単一プロジェクトで正しく除外されたパッケージが集約レポートでは除外されない問題があります。

次の設定を追加することで、特定のパッケージを除外したレポートを生成できます。

testCodeCoverageReport {
reports {
csv.required = true
xml.required = false
}
getClassDirectories().setFrom(files(
[project(':api'), project(':utils'), project(':core')].collect {
it.fileTree(dir: "${it.buildDir}/classes/java/main", exclude: [
'**/dto/**',
'**/config/**',
'**/output/**',
])
}
))
}

次のステップ

Gradleでjacoco-aggregation-reportと一緒に導入されたjvm-test-suiteプラグインも非常に有用です。これらのプラグインは補完的な関係にあるため、一緒に使用することをお勧めします。

参考

[Java] コレクションをよりコレクションらしくする - Iterable

· 4分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

// Iterableを実装するJavaのコレクション。
public interface Collection<E> extends Iterable<E>

ファーストクラスコレクションはオブジェクトを扱う上で非常に便利な方法です。しかし、「ファーストクラスコレクション」という名前にもかかわらず、実際にはCollectionをフィールドとして保持しているだけで、実際にはCollectionではないため、Collectionが提供するさまざまなメソッドを使用することはできません。この記事では、Iterableを使用してファーストクラスコレクションをより実際のCollectionに近づける方法を紹介します。

簡単な例を見てみましょう。

@Value
public class LottoNumber {
int value;

public static LottoNumber create(int value) {
return new LottoNumber(value);
}
}
public class LottoNumbers {

private final List<LottoNumber> lottoNumbers;

private LottoNumbers(List<LottoNumber> lottoNumbers) {
this.lottoNumbers = lottoNumbers;
}

public static LottoNumbers create(LottoNumber... numbers) {
return new LottoNumbers(List.of(numbers));
}

// Listのメソッドを使用するためにisEmpty()メソッドを委譲します。
public boolean isEmpty() {
return lottoNumbers.isEmpty();
}
}

LottoNumbersLottoNumberをリストとして保持するファーストクラスコレクションです。リストが空かどうかを確認するために、isEmpty()を実装しています。

isEmpty()の簡単なテストを書いてみましょう。

@Test
void isEmpty() {
LottoNumber lottoNumber = LottoNumber.create(7);
LottoNumbers lottoNumbers = LottoNumbers.create(lottoNumber);

assertThat(lottoNumbers.isEmpty()).isFalse();
}

悪くはありませんが、AssertJはコレクションをテストするためのさまざまなメソッドを提供しています。

  • has..
  • contains...
  • isEmpty()

ファーストクラスコレクションはCollectionではないため、これらの便利なアサートメソッドを使用することはできません。

より正確には、iterator()がないと要素を反復処理できないため、これらを使用することができません。iterator()を使用するには、Iterableを実装するだけです。

実装は非常に簡単です。

public class LottoNumbers implements Iterable<LottoNumber> {

//...

@Override
public Iterator<LottoNumber> iterator() {
return lottoNumbers.iterator();
}
}

ファーストクラスコレクションはすでにCollectionを持っているので、isEmpty()を委譲したのと同じように、単にそれを返すだけです。

@Test
void isEmpty_iterable() {
LottoNumber lottoNumber = LottoNumber.create(7);
LottoNumbers lottoNumbers = LottoNumbers.create(lottoNumber);

assertThat(lottoNumbers).containsExactly(lottoNumber);
assertThat(lottoNumbers).isNotEmpty();
assertThat(lottoNumbers).hasSize(1);
}

これでさまざまなテストメソッドを使用できるようになりました。

テストだけでなく、機能の実装においても便利に使用できます。

for (LottoNumber lottoNumber : lottoNumbers) {
System.out.println("lottoNumber: " + lottoNumber);
}

これはforループがiterator()を使用するため可能です。

結論

Iterableを実装することで、より豊富な機能を使用することができます。実装は難しくなく、機能拡張に近いので、ファーストクラスコレクションを持っている場合は積極的にIterableを活用しましょう。

ゲッターとセッターに関する真実と誤解

· 6分の読み時間
Haril Song
Owner, Software Engineer at 42dot

Googleで「getter/setter」を検索すると、数多くの記事が見つかります。その多くは、カプセル化や情報隠蔽といったキーワードに焦点を当てて、getter/setterを使用する理由を説明しています。

一般的な説明では、フィールド変数をprivateとして宣言し、外部からのアクセスを防ぎ、getter/setterを通じてのみ公開することでカプセル化が達成されるとされています。

しかし、getter/setterを使用することで本当にデータをカプセル化できるのでしょうか?

実際には、getter/setterではカプセル化を全く達成できません。 カプセル化を達成するためには、ゲッターとセッターの使用を避けるべきです。これを理解するためには、カプセル化の明確な理解が必要です。

カプセル化とは?

オブジェクト指向プログラミングにおけるカプセル化には、オブジェクトの属性(データフィールド)と動作(メソッド)を一緒にまとめることと、オブジェクトの実装の詳細を内部に隠すことの二つの側面があります。 - Wikipedia

カプセル化とは、外部のエンティティがオブジェクトの内部属性を完全に知ることができないようにすることを意味します。

なぜゲッターとセッターはカプセル化を達成できないのか

学んだように、カプセル化は外部のエンティティがオブジェクトの内部属性を知ることができないようにすることを指します。しかし、getter/setterは特定のフィールドが存在することを外部に露呈しています。例を見てみましょう。

public class Student {

private String name;
private int age;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

public String introduce() {
return String.format("私の名前は%sで、年齢は%d歳です。", name, age);
}
}
class StudentTest {

@Test
void student() {
Student student = new Student();
student.setName("ジョン");
student.setAge(20);
String introduce = student.introduce();

assertThat(student.getName()).isEqualTo("ジョン");
assertThat(student.getAge()).isEqualTo(20);
assertThat(introduce).isEqualTo("私の名前はジョンで、年齢は20歳です。");
}
}

Studentクラスの外部から、そのクラスにはnameageという属性があることが明らかです。この状態をカプセル化されていると考えられるでしょうか?

もしStudentからage属性を削除した場合、getter/setterを使用しているすべての場所で変更が必要になります。これにより強い結合が生じます。

真のカプセル化とは、オブジェクトの内部構造の変更が外部のエンティティに影響を与えないことを意味します。公開インターフェースを除いて。

内部実装を隠してみましょう。

public class Student {

private String name;
private int age;

public Student(String name, int age) {
this.name = name;
this.age = age;
}

public String introduce() {
return String.format("私の名前は%sで、年齢は%d歳です。", name, age);
}
}
class StudentTest {

@Test
void student() {
Student student = new Student("ジョン", 20);
String introduce = student.introduce();

assertThat(introduce).isEqualTo("私の名前はジョンで、年齢は20歳です。");
}
}

このように、オブジェクトは公開インターフェースを通じて内部実装を露呈しません。どのようなデータを保持しているかを知ることができず、変更も防ぎ、メッセージを通じてのみ通信します。

結論

カプセル化はオブジェクト指向設計において重要なトピックであり、外部要因に依存しない設計を強調します。カプセル化のレベルについては意見が分かれ、gettersetterの両方を使用しないことを推奨する人もいれば、getterの使用は許容されるとする人もいます。

個人的には、可能な限りgetterの使用を避けるべきだと考えていますが、特にテストにおいては、ゲッターやセッターがあるとテストコードの記述が容易になる場合があります。カプセル化のレベルを決定するには、現在の状況や開発中のコードの目的に依存します。

良い設計は常にトレードオフの過程を経て生まれます。

情報

すべてのサンプルコードはGitHubで確認できます。

[Spring Batch] KafkaItemReader

· 3分の読み時間
Haril Song
Owner, Software Engineer at 42dot
情報

この記事を書く前にDockerを使ってKafkaをインストールしましたが、その内容はここでは扱いません。

KafkaItemReaderとは..?

Spring Batchでは、Kafkaトピックからデータを処理するためにKafkaItemReaderが提供されています。

簡単なバッチジョブを作成してみましょう。

まず、必要な依存関係を追加します。

dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-batch'
implementation 'org.springframework.kafka:spring-kafka'
...
}

application.ymlにKafkaの設定を行います。

spring:
kafka:
bootstrap-servers:
- localhost:9092
consumer:
group-id: batch
@Slf4j
@Configuration
@RequiredArgsConstructor
public class KafkaSubscribeJobConfig {

private final JobBuilderFactory jobBuilderFactory;
private final StepBuilderFactory stepBuilderFactory;
private final KafkaProperties kafkaProperties;

@Bean
Job kafkaJob() {
return jobBuilderFactory.get("kafkaJob")
.incrementer(new RunIdIncrementer())
.start(step1())
.build();
}

@Bean
Step step1() {
return stepBuilderFactory.get("step1")
.<String, String>chunk(5)
.reader(kafkaItemReader())
.writer(items -> log.info("items: {}", items))
.build();
}

@Bean
KafkaItemReader<String, String> kafkaItemReader() {
Properties properties = new Properties();
properties.putAll(kafkaProperties.buildConsumerProperties());

return new KafkaItemReaderBuilder<String, String>()
.name("kafkaItemReader")
.topic("test") // 1.
.partitions(0) // 2.
.partitionOffsets(new HashMap<>()) // 3.
.consumerProperties(properties) // 4.
.build();
}
}
  1. データを読み取るトピックを指定します。
  2. トピックのパーティションを指定します。複数のパーティションを指定することも可能です。
  3. KafkaItemReaderでオフセットを指定しない場合、オフセット0から読み取ります。空のマップを提供すると、最後のオフセットから読み取ります。
  4. 実行に必要なプロパティを設定します。
ヒント

KafkaPropertiesは、SpringでKafkaを便利に使用するためのさまざまな公開インターフェースを提供します。

試してみる

さて、バッチジョブを実行すると、application.ymlの情報に基づいてconsumer groupsが自動的に作成され、ジョブがトピックの購読を開始します。

kafka console producerを使って、testトピックに1から10までのデータを追加してみましょう。

kafka-console-producer.sh --bootstrap-server localhost:9092 --topic test

produce-topic

バッチジョブがトピックを正常に購読していることがわかります。

subscribe-batch

chunkSizeを5に設定したので、データは5件ずつバッチ処理されます。

ここまで、Spring BatchでのKafkaItemReaderの基本的な使い方を見てきました。次に、テストコードの書き方を見てみましょう。