Apache Kafkaを勉強する②

本記事ではApache Kafkaの構築について説明してみようと思います。

想定する構成

Zookeeper3台、Kafka Broker3台のクラスタ環境を構築することを想定します。 各BrokerはLeader replica1つとFollower replica2つ、計3つのpartitionをもちます。
Kafkaの動作環境としてJavaが必要で、現在KafkaがサポートしているJavaのバージョンは1/8/17です。今回はOpenJDK 17を使用します。

  • OS:Red Hat Enterprise Linux 9
  • Java:OpenJDK 17.0.2
  • Zookeeper:192.168.10.11, 192.168.10.12, 192.168.10.13
  • Broker:192.168.10.101, 192.168.10.102, 192.168.10.103

Kafkaセットアップ

OpenJDK17 インストール

① OpenJDK17(今回はopenjdk-17.0.2_linux-x64_bin.tar.gz)をダウンロード
https://jdk.java.net/archive/

ディレクトリ作成

mkdir /usr/lib/jvm/

③ 作成したディレクトリにopenjdk-17.0.2_linux-x64_bin.tar.gzを配置

④ 解凍

cd /usr/lib/jvm/
tar zxvf openjdk-17.0.2_linux-x64_bin.tar.gz --directory /usr/lib/jvm/
rm openjdk-17.0.2_linux-x64_bin.tar.gz

Kafka インストール

① Kafka(今回はkafka_2.13-3.9.0.tgz)をダウンロード

cd /opt
wget https://downloads.apache.org/kafka/3.9.0/kafka_2.13-3.9.0.tgz

② 解凍

tar zxvf kafka_2.13-3.9.0.tgz
rm kafka_2.13-3.9.0.tgz

③ kafkaユーザの作成

useradd kafka -m
passwd kafka
# パスワードを入力

④ kafkaユーザに所有者変更

chown -R kafka:kafka /opt/kafka_2.13-3.9.0

⑤ .bash_profileに環境変数を設定

vim /home/kafka/.bash_profile

export JAVA_HOME=/usr/lib/jvm/jdk-17.0.2
export KAFKA_HOME=/opt/kafka_2.13-3.9.0
export PATH=$PATH:$JAVA_HOME/bin:$KAFKA_HOME/bin

⑥ Brokerの設定ファイルの変更(Brokerのみ)
  各ノードはBrokerを一意に識別するbroker.idをもち、これは以下のとおりとする

  • 192.168.10.101:broker.id=1
  • 192.168.10.102:broker.id=2
  • 192.168.10.103:broker.id=3

※以下は192.168.10.101ノードの設定例

vim /opt/kafka_2.13-3.9.0/config/server.properties

broker.id=1 # brokerを一意に識別するID
listeners=PLAINTEXT://192.168.10.101:9992 # brokerへの接続設定
log.dirs=/var/lib/kafka/data # メッセージデータ(≠動作履歴としてのログ)のパス
num.partitions=3 # partition数
zookeeper.connect=192.168.10.11:2181,192.168.10.12:2181,192.168.10.13:2181 #Zookeeper接続設定

※同一ホストに複数のbrokerを起動する場合、競合を避けるためlisnersに異なるportを設定

⑦ (任意)Broker起動シェルの変更(Brokerのみ)

vim /opt/kafka_2.13-3.9.0/bin/kafka-server-start.sh

export KAFKA_HEAP_OPTS="-Xmx1G -Xms128M" #最小メモリを128MBに変更

⑧ Zookeeperの設定ファイルの変更(Zookeeperのみ)
  各ノードはZookeeperを一意に識別するmyidをもち、これは以下のとおりとする

  • 192.168.10.11:myid=1
  • 192.168.10.12:myid=2
  • 192.168.10.13:myid=3

※以下は192.168.10.11ノードの設定例

vim /opt/kafka_2.13-3.9.0/config/zookeeper.properties

dataDir=/var/lib/zookeeper/data # トランザクションログやスナップショットのパス
clientPort=2181 # Zookeeperサーバがclientから接続されるport
tickTime=2000 # Zookeeperがleaderに接続するタイムアウト時間(ms)
intLimit=10 # followerがleaderに接続するタイムアウト時間(tickTime数)
syncLimit=5 # followerがleaderと同期するタイムアウト時間(tickTime数)

server.1=192.168.10.11:2888:3888 # server.myid=host:クラスタ接続port:leader選出port
server.2=192.168.10.12:2888:3888 
server.3=192.168.10.13:2888:3888 

⑨ myidファイルの作成(Zookeeperのみ) ※以下は192.168.10.11ノードの設定例

echo "1" > /var/lib/zookeeper/data/myid

⑩ (任意)Zookeeper起動シェルの変更(Zookeeperのみ)

vim /opt/kafka_2.13-3.9.0/bin/zookeeper-server-start.sh

export LOG_DIR=/var/logs/zookeeper # Zookeeperログのパス

Kafkaの起動と動作確認

Kafkaを起動する場合はZookeeper→Brokerの順で起動します。

① Zookeeperの起動 (Zookeeperのみ)

sh zookeeper-server-start.sh -daemon /opt/kafka_2.13-3.9.0/config/zookeeper.properties

② Brokerの起動(Brokerのみ)

sh kafka-server-start.sh -daemon /opt/kafka_2.13-3.9.0/config/server.properties

これでKafkaの起動ができました。
以下コマンドで、Zookeeperが認識しているBrokerノードのIDを確認できます。

sh zookeeper-shell.sh localhost:2181 ls /brokers/ids 
[1, 2, 3] # Brokerクラスタのbroker.idのリストを出力

KafkaのProducerとConsumerをコンソールから実行して確認することもできます。
まずはtopicを作成します

kafka-topics.sh --create --topic my-topic --bootstrap-server 192.168.10.101:9092 --partition 3 --replication-factor 3
  • --create:topicを作成
  • --topic mytopic:作成するtopic名
  • --bootstrap-server 192.168.10.101:9092:接続するBroker
  • --partition 3:topicを3つのpartitionに分割
  • --replication-factor 3:topicを3つのpartitionでレプリケーション

topicが作成できたらメッセージを送信します。
以下では「kafka test」と入力して [Enter] を押下してメッセージを確定します。
また、[Ctrl] + [D] で入力を終了します。

kafka-topics.sh --list --bootstrap-server 192.168.10.101:9092
kafka-console-producer.sh --topic my-topic --bootstrap-server 192.168.10.101:9092

> kafka test

最後にkafka内のメッセージを消費します。

kafka-console-consumer.sh --topic my-topic  --from-beginning --bootstrap-server 192.168.10.101:9092

kafka test
  • from-beginning:topicの先頭からメッセージを消費

最後に

今回はKafkaの構築手順を整理してみました。
Kafkaを構築する機会がある方はご参考にして頂けたら幸いです。

AWSのクレデンシャル取得をスクリプト化

ローカル環境からAWS環境に接続する手段として、aws sts get-session-tokenコマンドを使った方法がありますが、このコマンドのデフォルトの出力は、以下のようなJSON形式になります。

{
    "Credentials": {
        "AccessKeyId": "XXXXX",
        "SecretAccessKey": "xxxxx",
        "SessionToken": "xxxxx",
        "Expiration": "2025-02-11T11:52:42+00:00"
    }
}

この出力をCredentialsに記入することでクレデンシャルを取得できるのですが、その際の書式は以下のようになります。

[credential_name]
aws_access_key_id        = XXXXX
aws_secret_access_key    = xxxxx
aws_session_token        = xxxxx

従来はデフォルトのJSON形式の出力から必要な文字列をコピペしていたのですが、この方法だとコピペミスが発生しやすく、また操作自体も面倒です。

aws sts get-session-tokenコマンドのオプションでTEXT形式やYAML形式で出力することはできますが、どちらもコピペミスが起こりやすいのは同様です。

また、AWSの推奨としてMFAデバイスによる二段階認証を実施している場合が多いと思いますが、このパスコードをコマンド内で入力するのも手間がかかります。

そこで、この作業をスクリプト化してみました。
汎用性を持たせるため、ShellScriptでの記述とし、jqコマンドのインストールを行わずに実施できる仕様としてみました。

以下がコードです。

#6桁のワンタイムパスコードを引数に取り、AWSアカウントのクレデンシャルを取得してターミナルに表示するスクリプト

#引数3のアカウントIDを変数に格納
ACCOUNT_ID=$3

#引数4のプロファイル名を変数に格納
PROFILE_NAME=$4

#画面表示
echo "---6桁のワンタイムパスコードを入力し、エンターキーを押下してください---"

#入力した番号を変数MFA_NUMBERに格納
read MFA_NUMBER

#MFAデバイス名を引数1、アクセスキーの書かれたプロファイル名を引数2に取り、TEXT形式でクレデンシャルを取得
#取得したクレデンシャルのうち、key_id、secret_access_key、session_tokenの記載のみを抽出し、一時ファイルtmp.txtに出力
aws sts get-session-token --serial-number arn:aws:iam::${ACCOUNT_ID}:mfa/$1 --token-code ${MFA_NUMBER} --profile $2 --output text | awk -F '\t' '{print $2, $4, $5}' > tmp.txt

#tmp.txtの1番目の要素を変数KEY_IDに格納
KEY_ID=$(awk -F ' ' '{print $1}' tmp.txt)

#tmp.txtの2番目の要素を変数ACCESS_KEYに格納
ACCESS_KEY=$(awk -F ' ' '{print $2}' tmp.txt)

#tmp.txtの3番目の要素を変数SESSION_TOKENに格納
SESSION_TOKEN=$(awk -F ' ' '{print $3}' tmp.txt)

#以下4行にて、./aws/credentialsの形式でクレデンシャルをターミナルに出力
echo "[${PROFILE_NAME}]"
echo "aws_access_key_id        = ${KEY_ID}"
echo "aws_secret_access_key    = ${ACCESS_KEY}"
echo "aws_session_token        = ${SESSION_TOKEN}"

#一時ファイルtmp.txtを削除
rm -f tmp.txt

使用手順は以下のようなイメージです。
ファイル名は仮に「mfa.sh」としています。


  1. Git Bashのターミナル上でこのファイルを保存したディレクトリに移動し、以下のように入力してEnterキーを押す
bash mfa.sh 【登録したMFAデバイス名】 【アクセスキーの書かれたプロファイル名】 【AWSアカウントのアカウント番号】 【AWSアカウントのプロファイル名】

例:bash mfa.sh Authenticator mfa 123456789012 aws

2.以下のように表示されたら、MFAデバイスの数字6桁のワンタイムパスコードを入力してエンターキーを押す

---6桁のワンタイムパスコードを入力し、エンターキーを押下してください---


このスクリプトを使うことで、コピペミスが減り、作業を効率化することができました。
皆様もミスが発生しやすい作業や手間がかかる作業があったら、スクリプト化を検討してみるとよいと思います。

Apache Kafkaを勉強する①

最近Apache Kafkaを勉強する機会があったので、内容の整理をしてみようと思います。
参考にした書籍は以下になります。 www.shoeisha.co.jp

Apache Kafkaとは

Kafkaは大量のデータを高速で受け渡し可能な分散メッセージキューです。
送信されるキーバリュー形式のデータをKafkaに書き込み、そのデータを必要なシステムがいつでも読み込めるようにします。
Kafkaは、LinkedIn社がWebサイトの大量のログ解析を行うために開発し、2011年にオープンソースとしてリリースされました。 www.linkedin.com

ユースケース

データ連携基盤/マイクロサービスの構築
Kafkaによりデータ連携処理と送信元/受信元システムの各処理を非同期化します。 これにより各処理が疎結合化され、マイクロサービスの実現につながります。
またKafkaがデータ連携の窓口となることで各システム間の接続経路が不要になり、接続経路を単純化します。

ストリーム処理の構築
継続的に生成され、リアルタイムに処理されるデータをストリーミングデータといいます。
Kafkaは大量のストリーミングデータを分散並列処理により高スループットで処理します。 リアルタイムにデータを集約する機能などもあり、さらなる処理の高速化を実現します。

特徴

Kafkaの特徴として以下の4点があげられます。

  • スループット
    ネットワーク転送とストレージ処理にかかるオーバーヘッドを低減することで高スループットを実現

  • スケーラビリティ
    Kafkaはクラスタを構成し、サーバを増やすことで簡単にスケールアウトすることが可能 スケールアウトによるディスク容量や処理性能の向上を実現

  • 多様なAPI
    書き込み、読み出し、トランザクション、 DB/KVS/ファイルシステムなどの外部システムと接続するKafka Connectやストリーム処理を行うKafka Streamなどを実装

  • 耐障害性
    レプリケーションによる耐障害性を実現し、データのロストを防止

アーキテクチャ

Kafkaのアーキテクチャ概要図は以下の通りです。

アーキテクチャ構成要素について説明します。

  • Producer:メッセージをKafkaに書き込むためのAPI
  • Consumer:メッセージをKafkaから読み込むためのAPI
  • Broker:メッセージキューのサーバ。クラスタを構成することが可能
  • Topic:Brokerクラスタ内でメッセージの種類を管理するためのカテゴリ
  • Partition:メッセージキュー。Topic内に複数分散して存在
  • Replica:LeaderのPartitionのメッセージをFollowerにレプリケーション
        ※同期したReplicaをIn Sync Replica (ISR) と呼ぶ
  • Offset:メッセージに付与される連番でメッセージの位置を示す
種類 説明
Long-endOffset Partitionに書き込まれたデータの末尾
Current Offset Consumerがメッセージを読み込んだ位置
Commit Offset Consumerがメッセージ読込を確定 (コミット) した位置
High Watermark レプリケーションが完了した位置
  • Zookeeper:Leader Replicaの選出、メッセージの分散などBrokerの管理を行う

最後に

Kafkaは大量のデータを高速で受け渡し可能な分散メッセージキューです。
スループット、スケーラビリティ、多様なAPI、耐障害性などの優れた特徴を持ち、シェア率が高くナレッジも豊富です。
マイクロサービスの実現や処理の高速化が必要な場面での利用を検討してみてください。
次回以降でkafkaのセットアップやAPIの利用についてまとめていければと思います。

Ansibleの概要②

今回はAnsibleの概要①の続編として、InventoryとPlaybookについて説明します。

想定する構成

AnsibleではPlaybookというファイルにYAML形式で自動化するタスクを記述します。
また、自動化処理の対象ノードはInventoryファイルに記述されてPlaybookから参照されます。 今回は最小構成として以下のInventoryとPlaybookのみを想定します。

ansible
   ├─ hosts             ・・・Inventoryファイル
   └─ site.yml          ・・・Playbook

YAML

InventoryとPlaybookの説明をする前に簡単にYAMLについて触れていきます。
YAMLはデータのシリアライズ言語のひとつです。 シリアライズ言語とはデータをコンピュータが理解できる形に変換するための言語で、YAMLの他だとCSVXMLJSONなどがこれにあたります。

YAMLの文法としてハッシュ、シーケンス、ネスト、コメントがあります。

  • ハッシュ
    キーとバリューの組み合わせを ":" (コロン) で区切って表現します
key:value
  • シーケンス
    シーケンス (配列) は先頭に”-" (ハイフン) + 半角スペースで表現します
    キーの配列、バリューの配列のどちらも可能です
    キーにたいしてバリューが配列となる場合は以下のようになります
key:
- value1
- value2
- value3
  • ネスト
    要素の入れ子構造を表現する場合はネストを使います
    ネストは先頭半角スペース2桁で表現します
key1:
  nest_key1:value1
key2:
  - key2_1:value2_1
  - 
    - key2_2_1:value2_2_1
    - key2_2_2:value2_2_2
  • コメント
    コメントは先頭に# (シャープ) をつけて表現します
    行の途中からでもコメントできます
# comment1
key:value  # comment2

Inventory

Inventoryには自動化タスクの対象ノードを記述します。 同じ自動化タスクを実行したいノードが複数ある場合は、任意のグループ名でまとめることができます。
以下のWEBサーバ、DBサーバの場合のInventoryの例を示します。
webserverグループに属するホストを web1, web2という名前で識別、
dbserverグループに属するホストを db1, db2という名前で識別しています。

  • WEBサーバ:192.168.10.11, 192.168.10.11.12
  • DBサーバ:192.168.10.13, 192.168.10.11.14
[webserver]
web1 ansible_host=192.168.10.11
web2 ansible_host=192.168.10.12

[dbserver]
db1 ansible_host=192.168.10.13
db2 ansible_host=192.168.10.14

InventoryはYAML形式でも記述できます。
今回はhostsキーを使用してホストのみを定義していますが、varsキーを使用して環境変数を定義することも可能です。

all:
  children:
    webserver:   # webserver グループ
      hosts: # webserver グループのホスト定義
        web1: # web1 ホスト
          ansible_host: 192.168.10.11
        web2: # web2 ホスト
          ansible_host: 192.168.10.12
    dbserver:   # dbserver グループ
      hosts: # dbserver グループのホスト定義
        db1: # db1 ホスト
          ansible_host: 192.168.10.13
        db2: # db2 ホスト
          ansible_host: 192.168.10.14

Playbook

PlaybookはTargets, Vars, Tasks, Handlersの4つのセクションで構成され、この4セクションのひとまとまりを1Playと数えます。

  • Targetsセクション
    自動化タスクを実行する対象ノードとしてInventoryのグループ名を指定します。
    すべてのノードを対象にする場合はhosts:allとします。

  • Varsセクション
    使用する変数とその値を定義します。
    変数を使用しない場合は不要なセクションです。

  • Tasksセクション
    自動化タスクを定義します。
    記述した処理は上から実行されるため、処理に前後関係がある場合は前提処理を先に記述します。 Playbookのタスクは専用のモジュールとよばれるプログラムを使って記述します。

  • Handlerセクション
    Ansibleは冪等性を担保しますが、タスクの実行前後で状態が変わった場合、そのステータスをchangedと表現します。
    Handlerセクションでは、ステータスがchangedになったときにだけ実行したいタスクを記述します。 TasksセクションのchangedステータスはTasksセクション内のnotifyというモジュールでキャッチしてHandlerセクションに渡します。
    changedの時にだけ実行したいタスクがない場合は不要なセクションです。

モジュール

Ansibleのモジュール一覧は以下リンクにまとめられています。

docs.ansible.com

よく使われると思うモジュール群を以下に示します。

  • Files Modules
    ファイル操作系のモジュール
    ファイル、ディレクトリ、リンクの作成や、移動、コピーなどの操作を行います
  • Commands Modules
    コマンド系のモジュール
    コマンドやシェルを実行します
  • Net Tools Modules
    ネットワーク操作系のモジュール
    ネットワーク情報を取得したり、HTTP/HTTPSFTPでリモートからファイルをダウンロードしたりします
  • Source Control Modules
    ソース管理のモジュール
    gitの操作などを行います
  • System Modules
    システムコマンド系のモジュール
    サービス管理やユーザ、グループの管理を行います
  • Utilities Modules
    Playbook操作系のモジュール
    Playbookのよく使うタスクや環境変数を別ファイルで作成しておいて、インポートして使用します

最後に

今回はAnsibleの最小構成としてInventoryとPlaybookのみの構成で記述のしかたを説明をしました。 これからAnsibleを使い始める人がInventoryやPlaybookを読めようになる手助けになれば幸いです。
実際の現場だとInventoryでホスト以外に環境変数を定義したり、roleといってよく使うタスクをメソッドのように用意してインポートして使用したりします。
それらを含めてInventoryとPlaybookをどのように記述・構成するとよいかはAnsibleのドキュメントでベストプラクティスとしてまとめられていますので、次回の記事で触れようと思います。

EC2インスタンスの休止(ハイバネーション)について。

参考
https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/Hibernate.html

●EC2インスタンスの休止(ハイバネーション)について調べてみた。
EC2インスタンスの休止について、某リクエストがあったのでまとめてみました。
ちなみに横文字に馴染みがなさ過ぎて"ハイパーネーション"としばらく発音していて恥をかきました。最後に恥をかくのは私でいい。

hibernate -冬眠する/ひきこもる

皆さんは「休止」使っているでしょうか?そう、Windowsのパソコンにあるアレです。
作業中のデータをディスクに保存することで、再開時に休止前の状態に復帰できるようにしている代物です。
似たようなものに「スリープ」がありますが、そちらは作業中データをメモリに保存するため電源の供給が枯渇すると復帰できなくなる可能性がある、という違いがあるそうです。

EC2にも2018年から「休止」という機能が追加されました。今回はそちらのご紹介となります。
EC2インスタンスの休止のドキュメントにも、以下のように記載されています。

インスタンスを休止すると、Amazon EC2 によってオペレーティングシステムに休止の実行 (suspend-to-disk) が指示されます。休止状態に入ると、インスタンスメモリ (RAM) に置かれていた内容が、Amazon Elastic Block Store (Amazon EBS) のルートボリュームに保存されます。インスタンスの EBS ルートボリュームとアタッチされた EBS データボリュームは、Amazon EC2 により保持されます。

さて、AWSのいつものノリで雑におためししてみましょう。
・・・と思いきや、休止ができませんでした。休止を使うにはいくつか前提条件をクリアする必要があったのです。

ハイバネーションの前提条件
https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/hibernating-prerequisites.html
詳しくはリンク先で整理されているのでそちらを確認頂きたいのですが、いくつかかいつまむ(2024年6月時点)と
インスタンスファミリーに制約がある
インスタンスのRAMのサイズに制約がある
・ルートボリュームにはEBSを使い、暗号化されている必要がある
あとは別建てされていますが、インスタンスの休止オプションを有効にチェックする必要があります。
https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/enabling-hibernation.html
インスタンス作成"時"に「インスタンスの休止」を有効化すること
などと、適当に検証インスタンスを作ってもおためしすらできない理由が書いてあります。

●試してみた
適当ではダメな部分に手を加えます。
※ここでは「暗号化済み:暗号化済み」の部分と「停止 - 休止動作:有効化」が手を加えた部分となります。

●おまけ
ステータスはStopped扱いになります。納得感がありますね。

インスタンスはstopping状態に移行します。
https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/instance-hibernate-overview.html

あとは、「ディスクに空きがなかったらメモリの格納ができずきっと休止失敗するだろう」も試しました。 が、これは予想が外れ、休止に成功しました。
その替わりというわけではないのですが、(何としても失敗させようと)インスタンスのメモリサイズをディスクサイズより大きくすれば休止が失敗するだろうと思い試したところ、そもそもそのようなインスタンスは作成に失敗する、という結果を得ました。 ドキュメントに記載されておりました。あらかじめRAMデータを保存するスペースを確保するように起動するから、とのことです。

ルートボリュームは、RAM の内容を保存し、OS やアプリケーションなどの予想される使用量に対応できる大きさにする必要があります。休止を有効にすると、RAM を保存するために起動時にルートボリュームでスペースが割り当てられます。 https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/hibernating-prerequisites.html

●あとがき
OS起動からシステム再開に時間がかかるシステムだと便利なんでしょうかね?EBSからRAMにデータを戻して起動するので、通常の起動よりは時間がかかるとされていますが。
起動にプラスでどれくらい時間がかかるのかはイマイチよめておりません。
運用されている方、ユースケースを知っているどなたか、是非コメントください!

Javaのコレクションフレームワークについて Deque編 & StreamAPIの紹介

今回はJavaのコレクションフレームワークの中からDequeについて解説します。また、コレクションフレームワークと関係の深いStreamAPIについても軽く触れたいと思います。

JavaのコレクションフレームワークのうちList,Set,Mapについては以下の記事で解説しているのでぜひそちらをご覧ください。
Javaのコレクションフレームワークについて List編
Javaのコレクションフレームワークについて Set編
Javaのコレクションフレームワークについて Map編

Dequeについて

Dequeキューとスタックを組み合わせたコレクションです。Dequeはしばしばデックと発音されますが、まさしくトランプの束のように先頭または末尾にのみ追加と取得の操作が行えるリストです。余談ですが「Deque」は「Double Ended Queue」を意味し、日本語では「両端キュー」と訳しています。ちなみにトランプのデッキは「Deck」です。
キュー(Queue)はFIFO(先入れ先出し)のコレクションを指す言葉で、スタック(Stack)はLIFO(後入れ先出し)のコレクションを指す言葉です。どちらからも追加でき、どちらからも取り出せるため、Dequeはそれらを組み合わせたコレクションと言えるわけです。

Dequeの操作を聞いてListでも同じことができると考えたのではないでしょうか。それは半分正しく、確かに操作自体はArrayListでも実現出来ます。しかし、処理速度可読性メンテナンス性の3つの観点から考えると先頭と末尾のみを操作するコレクションはDequeを使うのが最適と言えます。

・・・ではLinkedListは?たしか、先頭・末尾への操作が早いという説明ではなかったか?そこに気がついた貴方は大変鋭いです。LinkedListは実はListの実装であるとともにDequeの実装でもあります。そのためDequeには2つの実装クラスが存在します。

主な実装クラス

Dequeの実装からArrayDequeと先ほど紹介したLinkedListを紹介します。LinkedListListの記事で紹介していますが、Dequeとしての観点も含めて改めてそれぞれの特徴と使い方を見ていきましょう。

ArrayDeque

ArrayDequeは一般的なDequeの配列実装です。スタックとして使用する場合はStackよりも高速で、キューとして使われる場合もLinkedListよりも高速であるため、先頭と末尾のみを操作する場合は基本的にこちらを使用します。 初期容量は指定しない場合16ですが、コンストラクタ呼び出し時に変更可能です。

◆ 使用場面

  • スタック、またはキューとして使用する場合
  • 頻繁に先頭および末尾の操作が行われる場合

◆ 実装

Deque<String> deque = new ArrayDeque<>();

// 末尾に要素を追加
deque.addLast("Apple"); // ["Apple"]
deque.addLast("Banana"); // ["Apple", "Banana"]

// 先頭に要素を追加
deque.addFirst("Orange"); // ["Orange", "Apple", "Banana"]

// 先頭の要素を取得
System.out.println(deque.peekFirst()); // Orange

// 末尾の要素を取得
System.out.println(deque.peekLast()); // Banana

// 先頭の要素を削除
deque.removeFirst(); // ["Apple", "Banana"]

// 末尾の要素を削除
deque.removeLast(); // ["Apple"]

LinkedList

Dequeとしてみなした場合のLinkedListは、途中への挿入や削除も行えるDequeです。ただし、先頭や末尾を操作する場合と比較すると速度は低下するため、頻繁に途中の要素を操作する場合はDequeではなくListを採用することも検討したほうがよいでしょう。

◆ 使用場面

  • Dequeとして使用しつつ、インデックスによる途中アクセスが必要な場合

◆ 実装

Deque<String> dequeLinkedList = new LinkedList<>();

// 末尾に要素を追加
dequeLinkedList.addLast("Melon"); // ["Melon"]
dequeLinkedList.addLast("Grape"); // ["Melon", "Grape"]

// 先頭に要素を追加
dequeLinkedList.addFirst("Peach"); // ["Peach", "Melon", "Grape"]

// 先頭の要素を取得
System.out.println(dequeLinkedList.peekFirst()); // Peach

// 末尾の要素を取得
System.out.println(dequeLinkedList.peekLast()); // Grape

// 先頭の要素を削除
dequeLinkedList.removeFirst(); // ["Melon", "Grape"]

// 途中の要素を挿入
dequeLinkedList.add(1, "Kiwi"); //["Melon", "Kiwi", "Grape"]

// 末尾の要素を削除
dequeLinkedList.removeLast(); // ["Melon", "Kiwi"]

ArrayDeque と LinkedList の比較

ArrayDeque は、先頭および末尾の操作が高速ですが、途中の要素にアクセスする操作はサポートしていません。LinkedList は、先頭や末尾だけでなく途中の要素に対するアクセスも行うことができますが、これらの操作は ArrayDeque に比べて遅くなります。

選択基準:

  • 先頭や末尾の操作のみを行う場合は ArrayDeque
  • 途中の要素にアクセスしたい場合は LinkedList

StreamAPIについて

以上がコレクションフレームワークに関する説明ですが、少し余白があるのでコレクションフレームワークに合わせてよく使用されるStreamAPIについても少し紹介いたします。

概要・コレクションフレームワークとの関係

Stream APIは、Javaにおけるデータストリーム(データの流れ)を扱うAPIです。コレクションフレームワークを扱う際の古典的なforループや拡張forループの書き方よりも、より直感的なコード記述を可能にしてくれます。Stream APIを利用することで、コレクションフレームワークを通じて生成されたデータに対して、効率的で直感的な操作を行うことができ、データの加工や抽出、集約がJavaのforeachやforループを使うよりもシンプルに記述できます。 説明よりも実装を見たほうが早いため早速実装をみていきましょう。

よく使用されるメソッド、場面

ここで全てを紹介するには余白が足りないため、代表的なメソッドを実装とともにいくつか紹介します。

Java初心者の方はもしかしたら見慣れない記述があるかと思いますが、これはラムダ式とよばれる短いメソッドを書く方法です。説明は省略しますが、ここではメソッドが短縮された形として理解してください。

// 通常のメソッド
int add(int a, int b) {
    return a + b;
}
// ラムダ式で書く場合
(a, b) -> a + b

filter

filter()メソッドは指定した条件に一致する要素だけを含む新しいストリームを返します。コレクションに含まれる要素を絞り込む際に便利です。

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// numbersコレクションから偶数だけに絞り込む
List<Integer> evenNumbers = numbers.stream()
                                   .filter(n -> n % 2 == 0)
                                   .collect(Collectors.toList());
System.out.println(evenNumbers); // [2, 4, 6, 8, 10]

map

map()メソッドは各要素に対して関数を適用し、その結果で新しいストリームを生成します。すべての要素に特定の操作をしたい場合に便利です。

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// numbersコレクションのすべての要素を二倍にする
List<Integer> doubledNumbers = numbers.stream()
                                      .map(n -> n * 2)
                                      .collect(Collectors.toList());
System.out.println(doubledNumbers); // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

forEach

forEach()メソッドは各要素に対して指定したアクションを実行します。map()とは異なり内部の要素を変更しません。

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// numbersコレクションのすべての要素をprintする
numbers.stream()
       .forEach(System.out::print);  // 1 2 3 4 5 6 7 8 9 10

collect

既に上の例で使用していますが、collect()メソッドはストリーム要素を集約してコレクションなどのオブジェクトを生成します。すこしややこしいのですが、ストリームの状態はあくまでコレクションではなくストリームであるため、変換が必要になります。それを行えるのがcollect()メソッドです。

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// numbersコレクションのすべての要素を二倍にする
List<Integer> doubledNumbers = numbers.stream()
                                      .map(n -> n * 2)
                                      .collect(Collectors.toList());
System.out.println(doubledNumbers); // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

まとめ

JavaDequeは、先頭と末尾の動作が高性能であるため、スタックやキューの操作がある場合に使用します。そのうえ、Dequeを使用することでコードはより可読性が高くなります。

また、StreamAPIを利用することでコレクションフレームワークによって作成された塊をより効率的かつ明確に処理することが可能です。どの場面で何が有効かを考え、可読性、保守性の高いコードを書けるようになってください。

Javaのコレクションフレームワークについて Map編

今回はJavaのコレクションフレームワークの中からMapについて解説します。

Javaのコレクションフレームワークは、データ構造を効率的に扱うための強力なツールセットです。この記事では、Mapについて使い方やメリットを解説していきます。 List,Setについては以下の記事で解説しているのでぜひそちらをご覧ください。
Javaのコレクションフレームワークについて List編
Javaのコレクションフレームワークについて Set編

Mapについて

Mapキーに値を関係付けた要素のコレクションです。キーと値は常に1対1マッピングされるため、当然ながらキーの重複登録は出来ません。重複登録が出来ないSetと異なる点として、重複したキーを用いて登録を行った場合Mapでは後から登録された値で上書きされます。

他のコレクションと違いコレクションに登録された値をインデックスではなく任意のキーで取り出すことが可能であるため、設定値の管理や集計処理などに向いています。

主な実装クラス

今回はMapからHashMapTreeMapLinkedHashMapの3つを紹介します。それぞれの特徴と使い方を見ていきましょう。

HashMap

HashMapはキーと値を持つ基本的なMapです。内部的にはSetの一つであるHashSet同様ハッシュテーブルを用いて実装されており、項目の順序は保証されません。
Mapが必要な殆どの場面ではHashMapが使用されると思います。

◆ 使用場面

  • キーから値の高速な取得が必要な場合
  • 項目の順序が重要ではない場合

◆ 実装

Map<String, Integer> hashMap = new HashMap<>();
hashMap.put("one", 1);
hashMap.put("two", 2);
System.out.println(hashMap.get("one")); // 出力: 1
Tips: 初期容量と負荷係数について

ここまで紹介してきたListSetMapの実装は当たり前のように可変長(上限を意識しない)実装となっていましたが、当然ながらメモリ内に無限の空間が用意される魔法が使われているなんてことはなく、巧妙なロジックによって無限であるかのように振る舞わせているだけです。

基本的な考え方として過剰な保存領域の確保はしたくないため、初期容量initialCapacityという設定値で初期化時の領域確保数を決めています。ArrayListであれば10HashSet,HashMapであれば16という値が初期値となっており、ArrayListHashSet,HashMapでは振る舞いは異なりますが、ある一定の数がコレクションに登録された場合初期設定されたサイズから拡張され、データが再整理されることになります。

ArrayListHashSet,HashMapで振る舞いが異なる理由は値の管理方法の違いにあります。
ArrayListは内部的にはObjectの配列をもち、初期容量を超過する場合に現在の容量の1.5倍(※1)のサイズの配列を作り出し、現在保持している要素をコピーするという比較的シンプルな管理方法を取っています。
一方、HashSet,HashMapはハッシュテーブルというハッシュ関数によって生成されたキーで値を管理する構造でデータを保持しており、ハッシュテーブルのデータ管理手段の関係でテーブルが埋まるにつれて性能が基本的に悪化するため、上限に達する前に拡張の処理を行う手段を取っています。
※1: jdkのバージョンによって異なる場合があります

そこで登場するのが、負荷係数loadFactorという値です。HashSet,HashMapは[容量*負荷係数]個目の要素が追加されたタイミングで拡張を行います。初期値はHashSet,HashMapともに0.75なので、初期容量と負荷係数をどちらも指定しない場合は[16*0.75=12]個目の要素が追加されたタイミングで最初の拡張が行われることになります。

初期容量と負荷係数は基本的に初期設定のまま使用してもさほど大きな問題にはなりませんが、適切に設定することで過剰な容量確保を抑えメモリの節約が見込める上、容量拡張時の負荷を軽減できるため、コレクションのサイズが見積もれる場合は適切な値を設定することでパフォーマンス向上を行えます。覚えておいて損はありません。

TreeMap

TreeMapはキーに基づいて項目をソートします。内部的には赤黒木というバランス木の一種を用いて実装されています。
木構造についてはここでは触れません。探索を効率的に行うためのアルゴリズムを用いてキーの管理をしていると理解しておいてください。

TreeSet同様にセットを構築する際に比較基準となるComparatorを渡すことで任意のキー順序に設定可能です。Comparatorを渡さなかった場合はクラスが持つcompareTo()メソッドを用いて比較を行うことになります(自然順序でのソート)。自作クラスがComparableインターフェースを実装していない場合ClassCastExceptionが発生します。

◆ 使用場面

  • キーの順序に意味がある項目を必要とする場合
  • 項目間の順序関係に基づいて項目を取得する場合

◆ 実装

Map<String, Integer> treeMap = new TreeMap<>();
treeMap.put("one", 1);
treeMap.put("two", 2);
treeMap.put("three", 3);
// 文字列の自然順序でソートされるため辞書順[one > three > two]となる
System.out.println(treeMap); // 出力: {one=1, three=3, two=2}

LinkedHashMap

LinkedHashMapは挿入順またはアクセス順で項目を保持します。初期化時のaccessOrdertrueに設定することでアクセス順(最後にアクセスした項目が一番最後になる)保持を行えます。
実装例の通り、重複キーへの再挿入時には順序の入れ替えは発生しません

◆ 使用場面

  • 項目の挿入順序を保持したい場合
  • 最近アクセスした項目が一番後ろにくる順序を維持したい場合
    • 使用したデータを古い順に捨てるキャッシュ処理の作成などに使える

◆ 実装

Map<String, Integer> linkedHashMap = new LinkedHashMap<>();
linkedHashMap.put("one", 3);
linkedHashMap.put("two", 2);
linkedHashMap.put("one", 1); // 再挿入時に順序は入れ替わらない
linkedHashMap.put("three", 3);
System.out.println(linkedHashMap); // 出力: {one=1, two=2, three=3}

・アクセス順で使用する場合

// accessOrderでLinkedHashMapを生成
Map<String, Integer> linkedHashMap = new LinkedHashMap<>(16, 0.75F, true);
linkedHashMap.put("one", 1);
linkedHashMap.put("two", 2);
linkedHashMap.put("three", 3);
System.out.println(linkedHashMap); // 出力: {one=1, two=2, three=3}
// twoにアクセス
linkedHashMap.get("two");
// アクセスしたtwoが最後尾に移動する
System.out.println(linkedHashMap); // 出力: {one=1, three=3, two=2}

HashMap、TreeMap、LinkedHashMapの比較

冒頭で述べたとおりですが、基本的にはHashMap、順序に意味がある場合は要件に応じてTreeMap,LinkedHashMapを使用することになります。性能的観点での取捨選択はあまり発生しないため、選択基準は以下のとおりです。

選択基準:

  • 項目の順序を気にしない場合や、高速な検索が必要な場合はHashMap
  • 項目の自然な順序でアクセスが必要な場合はTreeMap
  • 項目の挿入順序やアクセス順でアクセスが必要な場合はLinkedHashMap

まとめ

Mapはキーと値を関連付けて保存し、様々な状況下で有用です。HashMapTreeMapLinkedHashMapをシチュエーションに応じて最適に選択し、要件に応じて初期容量と負荷係数を設定することでパフォーマンスを最適化出来ます。ぜひ活用してください。