最近のプロジェクトでは、データ処理に Google Cloud Platform の Dataflow を使用する必要があるため、関連ドキュメントを調べたところ、Dataflow は処理の配置に Apache ビームを使用していることを知りました。Beam はさまざまな Runner をサポートしています. Dataflow に加えて、Beam は Spark または Flink も Runner としてサポートしています. Beam は、ストリーミング バッチ タスクを調整し、さまざまなデータ処理プラットフォームで実行できる統合プログラミング モデルであることがわかります。
以下では、シナリオを使用して、Beam を使用してストリーム処理タスクを定義する方法を示します。
車両から報告された走行距離データを処理したい場合. 車両は不定期にデータを報告します. プラットフォームがデータを受信した後, プラットフォームは元のデータを保存し, 毎分集計処理を実行して, この期間内に車両が移動した距離を計算します.分、データベースに保存されます。その後、レポートは、ユーザーのクエリ条件に従って、クエリ期間内に特定の車両が走行したマイレージを取得できます。
車両から報告されたデータは、プラットフォーム上の Kafka を介して転送され、データ処理モジュールが関連するトピックにサブスクライブし、データを取得して処理します。車両から報告されるデータは、次のように単純な Json 形式です。
{
"telemetry": {
"odometer": {
"odometer": 1234,
}
},
"timestamp": 1682563540419,
"deviceId": "abc123",
}
Java に基づいて Beam ストリーム処理パイプラインを作成します。最初はmavenでプロジェクトを構築することです
mvn archetype:generate -DgroupId=com.example -DartifactId=analytics-pipeline -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
1.パイプラインを生成する
MileageCalculate という名前の新しいクラスを作成し、その中にオプション インターフェイスを定義します。このインターフェイスは StreamingOptions を拡張し、主に、Kafka トピックの取得など、パイプラインの実行時にいくつかのパラメーターを取得および設定するためのいくつかのメソッドを提供します。
public interface Options extends StreamingOptions {
@Description("Apache Kafka topic to read from.")
@Validation.Required
String getInputTopic();
void setInputTopic(String value);
@Description("BigQuery table to write to, in the form "
+ "'project:dataset.table' or 'dataset.table'.")
@Default.String("beam_samples.streaming_beam_sql")
String getOutputTable();
void setOutputTable(String value);
@Description("Apache Kafka bootstrap servers in the form 'hostname:port'.")
@Default.String("localhost:9092")
String getBootstrapServer();
void setBootstrapServer(String value);
@Description("Define max_speed for distance abnormal.")
@Default.Integer(100)
Integer getMaxSpeed();
void setMaxSpeed(Integer value);
}
クラスのメイン関数では、新しいパイプラインを作成してオプションを渡すことができます
Options options = PipelineOptionsFactory.fromArgs(args).withValidation().as(Options.class);
options.setStreaming(true);
Pipeline pipeline = Pipeline.create(options);
2. Kafka メッセージを読む
パイプラインを構築した後の最初のステップは、入力として Kafka からデータを取得することです。ここでは、Beam が提供する KafkaIO クラスのメソッドを使用して、Kafka に接続し、メッセージ トピックをサブスクライブします。メッセージの形式は JSON であるため、新しいクラスを作成して JSON データの形式をマッピングし、Google の GSON を使用してメッセージを Java クラスに変換する必要があります。次の内容で TelemetryMsg.java という名前の新しいファイルを作成します。
import org.apache.beam.sdk.coders.DefaultCoder;
import org.apache.beam.sdk.extensions.avro.coders.AvroCoder;
public class TelemetryMsg {
@DefaultCoder(AvroCoder.class)
public static class UtilizationMsg {
public long timestamp;
public String deviceId;
public Telemetry telemetry;
}
public static class Odometer {
public int usageMode;
public float odometer;
}
public static class Telemetry {
public Odometer odometer;
}
}
Kafka メッセージ コンテンツには既にタイムスタンプ情報が含まれており、KafkaIO はデフォルトでタイムスタンプとウォーターマークとしてメッセージを受信した時刻を使用するため、メッセージ コンテンツのタイムスタンプを読み取るカスタム Timepolicy を設定する必要があります。次のコードを含む CustomFieldTimePolicy:
import com.examples.TelemetryMsg.UtilizationMsg;
import org.apache.beam.sdk.io.kafka.KafkaRecord;
import org.apache.beam.sdk.io.kafka.TimestampPolicy;
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
import org.joda.time.Instant;
import java.util.Optional;
import com.google.gson.Gson;
public class CustomFieldTimePolicy extends TimestampPolicy<String, String> {
private static final Gson GSON = new Gson();
protected Instant currentWatermark;
public CustomFieldTimePolicy(Optional<Instant> previousWatermark) {
currentWatermark = previousWatermark.orElse(BoundedWindow.TIMESTAMP_MIN_VALUE);
}
@Override
public Instant getTimestampForRecord(PartitionContext ctx, KafkaRecord<String, String> record) {
UtilizationMsg msg = GSON.fromJson(record.getKV().getValue(), UtilizationMsg.class);
currentWatermark = new Instant(msg.timestamp);
return currentWatermark;
}
@Override
public Instant getWatermark(PartitionContext ctx) {
return currentWatermark;
}
}
次に、パイプラインに Kafka メッセージを読み取るステップを追加できます。コードは次のとおりです。
PCollection<UtilizationMsg> input =
pipeline
.apply("Read messages from Kafka",
KafkaIO.<String, String>read()
.withBootstrapServers(options.getBootstrapServer())
.withTopic(options.getInputTopic())
.withKeyDeserializer(StringDeserializer.class)
.withValueDeserializer(StringDeserializer.class)
.withTimestampPolicyFactory((tp, previousWaterMark) -> new CustomFieldTimePolicy(previousWaterMark))
.withoutMetadata())
.apply("Get message contents", Values.<String>create())
.apply("Log messages", MapElements.into(TypeDescriptor.of(String.class))
.via(message -> {
LOG.info("Received: {}", message);
return message;
}))
.apply("Parse JSON", MapElements.into(TypeDescriptor.of(UtilizationMsg.class))
.via(message -> GSON.fromJson(message, UtilizationMsg.class)))
.apply("Append event time for PCollection records", WithTimestamps.of((UtilizationMsg msg) -> new Instant(msg.timestamp)));
3. ウィンドウ処理
1分以内にデータを集計する必要があるため、ストリームデータを1分ごとに論理的なグループに分割する必要があり、ここではfixedwindowを使用して分割します。Kafka メッセージは遅れて到着する場合があるため、1 分遅れて到着するように設定し、それを超えた場合は破棄する必要があります。データをリアルタイムで処理するために、トリガー モードを設定できます。たとえば、この時間枠で最初のデータを受信してから最初にデータを計算できるようになるまでにかかる時間です。最後に、時間枠内でデータに対して重複排除操作を実行することもできます。コードは以下のように表示されます:
PCollection<UtilizationMsg> input_window =
input
.apply("Fixed-size windows",
Window.<UtilizationMsg>into(FixedWindows.of(Duration.standardMinutes(1)))
.withAllowedLateness(Duration.standardMinutes(1))
.triggering(
Repeatedly.forever(
AfterWatermark
.pastEndOfWindow()
.withEarlyFirings(
AfterProcessingTime
.pastFirstElementInPane()
.plusDelayOf(Duration.standardMinutes(1)))))
.accumulatingFiredPanes())
.apply("Distinct",
Distinct.<UtilizationMsg>create());
4. データ保存
各時間ウィンドウのデータをデータベースに保存できます。ここでは Postgres データベースを使用し、beam の JdbcIO を使用してデータベースに接続できます。PG データベースに telematics という新しいデータベースを作成しました。このデータベースには、telemetry_data データ テーブルが含まれています。コードは次のとおりです。
input_window
.apply(JdbcIO.<UtilizationMsg>write()
.withDataSourceConfiguration(JdbcIO.DataSourceConfiguration.create("org.postgresql.Driver", "jdbc:postgresql://127.0.0.1:5432/telematics")
.withUsername("postgres")
.withPassword("postgres"))
.withStatement("insert into regular_data_utilization Values (?, ?, ?, ?);")
.withPreparedStatementSetter(new JdbcIO.PreparedStatementSetter<UtilizationMsg>() {
public void setParameters(UtilizationMsg element, PreparedStatement query) throws SQLException {
query.setString(1, element.deviceId);
query.setString(2, Instant.ofEpochMilli(element.timestamp).toString());
query.setInt(3, element.telemetry.odometer.usageMode);
query.setFloat(4, element.telemetry.odometer.odometer);
}
}));
5. データのグループ化
For the data in the timewindow, we can group group by deviceId. グループ化されたデータは Key-Value の形式であり、Key は deviceId で、Value はメッセージの内容であり、各 deviceId のマイレージのその後の計算を容易にすることができます。
PCollection<KV<String, Iterable<UtilizationMsg>>> grouped_records =
input_window
.apply("Add DeviceID as Key", ParDo.of(new DoFn<UtilizationMsg, KV<String, UtilizationMsg>>() {
@ProcessElement
public void processElement(@Element UtilizationMsg element, OutputReceiver<KV<String, UtilizationMsg>> out) {
out.output(KV.of(element.deviceId, element));
}
}))
.apply(GroupByKey.<String, UtilizationMsg>create());
6.マイレージを計算する
グループ化した後、各 deviceId の毎分走行距離を計算できます。オドメーターのデータが異常である可能性を考慮して、正常なデータは処理後に PCollection に送信され、異常なデータは別の PCollection に送信されるマルチ出力メソッドを使用して処理できます。これを行うには、正常なデータと異常なデータにそれぞれ対応する 2 つの TupleTag オブジェクトを定義する必要があります。
private static final TupleTag<DistanceObj> normalDistanceTag = new TupleTag<DistanceObj>(){};
private static final TupleTag<String> abnormalDistanceTag = new TupleTag<String>(){};
次に、データに対して走行距離計算を実行します.正常なデータと異常なデータについては、異なるタグの出力に送信されます. 次のコードのように:
PCollectionTuple distance = grouped_records
.apply("Calculate distance", ParDo.of(new DoFn<KV<String, Iterable<UtilizationMsg>>, DistanceObj>() {
@ProcessElement
public void processElement(@Element KV<String, Iterable<UtilizationMsg>> element, IntervalWindow window, MultiOutputReceiver out) {
Iterator<UtilizationMsg> iterator = element.getValue().iterator();
List<UtilizationMsg> records = new ArrayList<UtilizationMsg>();
while(iterator.hasNext()) {
records.add(iterator.next());
}
Collections.sort(records, new UtilizationMsgCompare());
Iterator<UtilizationMsg> iter = records.iterator();
int total_distance = 0;
float pre_odometer = 0f;
long pre_timestamp = 0L;
Boolean has_abnormal_data = false;
while(iter.hasNext()) {
UtilizationMsg record = (UtilizationMsg) iter.next();
float odometer = record.telemetry.odometer.odometer;
if (pre_odometer==0) {
pre_odometer = odometer;
pre_timestamp = record.timestamp;
continue;
}
if (odometer >= pre_odometer) {
int distance = (int) (record.telemetry.odometer.odometer - pre_odometer);
int duration = (int) ((record.timestamp - pre_timestamp)/1000); //seconds
if(distance <= duration * max_speed) {
total_distance += distance;
} else {
has_abnormal_data = true;
}
} else {
has_abnormal_data = true;
}
pre_odometer = odometer;
pre_timestamp = record.timestamp;
}
DistanceObj d = new DistanceObj(element.getKey(), DateFormat.format(pre_timestamp), total_distance);
if (!has_abnormal_data) {
out.get(normalDistanceTag).output(d);
}
else {
Instant startWindow = window.start();
Instant endWindow = window.end();
String errorMsg = String.format(
"Abnormal distance found for device: %s, period: %s - %s",
element.getKey(), startWindow.toDateTime().toString(), endWindow.toDateTime().toString());
out.get(abnormalDistanceTag).output(errorMsg);
}
}
})
.withOutputTags(normalDistanceTag, TupleTagList.of(abnormalDistanceTag)));
7.通常の走行距離データを保存する
通常の走行距離データについては、データベースに保存できます
PCollection<DistanceObj> normalDistance = distance.get(normalDistanceTag);
normalDistance
.apply(JdbcIO.<DistanceObj>write()
.withDataSourceConfiguration(JdbcIO.DataSourceConfiguration.create("org.postgresql.Driver", "jdbc:postgresql://127.0.0.1:5432/telematics")
.withUsername("postgres")
.withPassword("postgres"))
.withStatement("insert into distance Values (?, ?, ?, ?) ON CONFLICT (deviceId, hour) DO UPDATE SET (distance, process_time) = (excluded.distance, excluded.process_time);")
.withPreparedStatementSetter(new JdbcIO.PreparedStatementSetter<DistanceObj>() {
public void setParameters(DistanceObj element, PreparedStatement query) throws SQLException {
Timestamp process_time = new Timestamp(new Date().getTime());
query.setString(1, element.getDeviceId());
query.setString(2, element.getHour());
query.setInt(3, element.getDistance());
query.setString(4, process_time.toString());
}
}));
8.異常走行距離データをログに出力
異常データについては、ここでは単純な処理のみ、エラーログに出力
PCollection<String> abnormalDistance = distance.get(abnormalDistanceTag);
abnormalDistance
.apply("Log abnormal distance", MapElements.into(TypeDescriptor.of(String.class))
.via(message -> {
LOG.error(message);
return message;
}));
9. パイプラインを実行する
この時点で、パイプライン全体が構築され、それを実行するためのコード pipeline.run() をもう 1 行追加するだけで済みます。
最後に、DirectRunner を使用して実行する次の maven コマンドで開始できます. データフロー、スパーク、または Flink を使用する場合は、対応する依存関係を POM ファイルに追加し、コマンドでランナーを指定する必要があります。
mvn compile exec:java -Dexec.mainClass=com.examples.MileaCalculate -Dexec.args="--inputTopic=TELEMATICS"
10.パイプラインをテストする
ここでは、単純に Python スクリプトを使用してデータをシミュレートおよび生成し、パイプラインが設計どおりに実行されているかどうかを確認します。
from confluent_kafka import Producer
import json
import time
import random
conf = {'bootstrap.servers': "127.0.0.1:9092",
'client.id': "test123"}
producer = Producer(conf)
topic = "TELEMATICS"
msg_temp = {
"telemetry": {
"odometer": {
"odometer": 1234,
"usageMode": 0
}
},
"timestamp": 1682563540419,
"deviceId": "abc123"
}
start_time = int(time.time()*1000)
start_odometer = 100
delta_time = [10, 15, 40, 150, 50]
sleep_time = [10, 5, 25, 110, 1]
delta_odometer = [100, 80, 400, 1500, 500]
for i in range(len(delta_time)):
time.sleep(sleep_time[i])
timestamp = start_time + delta_time[i]*1000
odometer = start_odometer + delta_odometer[i]
msg_temp['telemetry']['odometer']['odometer'] = odometer
msg_temp['timestamp'] = timestamp
producer.produce(topic, key="key", value=json.dumps(msg_temp))
ここでは、開始時刻から 10 秒、15 秒、40 秒、150 秒、50 秒後にデータが送信されるようにシミュレートしています。これら 2 つの異常なシナリオのデータ異常とデータ遅延到着を含みます。ログとデータベース データを調べたところ、パイプラインが設計どおりに正常に動作していることがわかります。