原文はこちら。
The original article was written by Mitia Alexandrov (a software developer at Oracle, working on Project Helidon).
https://medium.com/helidon/helidon-and-neo4j-7f49e585ba0a
HelidonとNeo4jとの統合を試みるというアイデアは極めて自然なものでした。
Graph Database Platform | Graph Database Management System | Neo4j
https://neo4j.com/
Neo4j is a graph database management system developed by Neo4j, Inc. It is an ACID-compliant transactional database with native graph storage and processing. Neo4j is available in a GPL3-licensed open-source “community edition”. (Neo4jはNeo4j Inc.が開発したグラフデータベース管理システムである。ACID準拠のトランザクショナルデータベースでネイティブのグラフストレージと処理機能を備える。Neo4jはGPL3ライセンスのオープンソース(community edition)で利用可能である)
Wikipediaより(2021/02/25)
Neo4jはJavaで実装されており、他の言語で書かれたソフトウェアからCypherクエリ言語を使って、トランザクションHTTPエンドポイント、またはバイナリのBoltプロトコルを介してアクセスできます。
Neo4jは現在デファクトスタンダードのグラフデータベースで、幅広い業界で使われています。
全ては、Spring Data Neo4j 6の作者の一人であり、Neo4j-OGMのメンテナでもあるMichael Simonsとのちょっとした会話から始まりました。私たちはMichaelに、HelidonとNeo4Jがどのように協力できるかについての考えを尋ねたところ、1時間もしないうちに、Michaelは私にHelidon MPでNeo4j SDNが動作するサンプルを含むこのリポジトリへのリンクを送ってくれました。
Neo4j Examples around SDN, SDN with OGM and general driver usage – Helidon MP
https://github.com/michael-simons/neo4j-examples-and-tips/tree/master/examples/sdn6-on-helidon
コードを読み始めると、pom.xmlファイルに奇妙な依存関係がありました。
<dependency> | |
<groupId>org.springframework.data</groupId> | |
<artifactId>spring-data-neo4j</artifactId> | |
</dependency> |
Spring?本当ですか?一体なぜこのプロジェクトにSpringが?
しかしながらコードを読み進めて、CDI 2.0 extensionがドライバーの初期化や準備を司っているのを見てひどく驚きました。
HelidonはCDI 2.0コンテナーを完全サポートしているため、このCDI extensionはHelidonでシームレスに開始します。すばらしい!再度標準規格の美しさとその力を感じました。
翌日、Michaelは別のリポジトリを送ってくれました。今度はNeo4jをHelidon SEと共に使う方法のアイデアが含まれていました。
Neo4j Examples around SDN, SDN with OGM and general driver usage – Helidon SE
https://github.com/michael-simons/neo4j-examples-and-tips/tree/master/examples/neo4j-and-helidon-se
この統合は極めてシームレスに動作しました。Helidon Configを使ってNeo4jの全ての構成を標準の場所に簡単に外出しできました。そしてNeo4jドライバーが完全にNative Imageをサポートしているので、サンプルをネイティブ実行ファイルにコンパイルできます。プログラマー視点から追加のアクションをする必要はありません。
しかし、これでは十分ではありませんでした。Neo4jドライバーはReactive APIを提供しており、この機会を逃すわけにはいきませんでした。3個目のサンプルリポジトリが完成しつつありました。
Neo4j Examples around SDN, SDN with OGM and general driver usage – Helidon SE reactive API
https://github.com/michael-simons/neo4j-examples-and-tips/tree/master/examples/neo4j-and-helidon-se-reactive
これをきっかけに、HelidonチームとNeo4jチームの間で、Helidonでの実装に適した統合の種類について議論が行われ、その結果、公式の統合が作成されました。
Neo4jの担当者や私の親友であるMichael Simonsと相談した結果、設定したドライバーをHelidonのユーザーに公開するだけでよいという結論に達しました。Neo4JからのMetricsとHealth checksは、Helidon/MicroProfileのMetricsとHealth checksに翻訳し、別のモジュール、つまり別のMaven依存関係として提供するべきです。
では、Helidonではどのように統合を記述すればよいのでしょうか?
Helidonには2種類のフレーバー、MPとSEがあります。SEは実はJavaだけで実装された一連のReactive API群です。リフレクションやその他のトリックのような魔法は全くありません。Helidon MPは基本的にSEをラップしCDIのような「魔法」が追加されています。これはつまり、まずHelidon SE用のNeo4jとの統合を実装し、続いてその成果物をHelidon MP向けにCDI extensionsのようにラップするのがよさそうです。
Let us do it!
Disclaimer: In this article I will demonstrate only the key code snippets. Since Helidon is an Apache 2.0 licensed Open Source, the full code is available in the official Helidon/Neo4j repository.
(免責事項:この記事では重要なコードの一部だけを説明します。HelidonはApache 2.0ライセンスのオープンソースなので、全コードは公式Helidon/Meo4jリポジトリからご覧いただけます)
Helidon/Neo4j
https://github.com/oracle/helidon/tree/master/integrations/neo4j
Helidonでは通常、統合のためにいわゆるサポートオブジェクトを作成します。このオブジェクトは、すべての設定と初期化情報を保持します。
私たちは、Builderパターンに従って、設定(configuration)から読み込みます。つまり、コンフィギュレーションからすべてのデータを読み込む内部のBuilderオブジェクトを作成します。
public Builder config(Config config) { | |
config.get("authentication.username").asString().ifPresent(this::username); | |
config.get("authentication.password").asString().ifPresent(this::password); | |
config.get("authentication.enabled").asBoolean().ifPresent(this::authenticationEnabled); | |
config.get("uri").asString().ifPresent(this::uri); | |
config.get("encrypted").asBoolean().ifPresent(this::encrypted); | |
//pool | |
config.get("pool.metricsEnabled").asBoolean().ifPresent(this::metricsEnabled); | |
config.get("pool.logLeakedSessions").asBoolean().ifPresent(this::logLeakedSessions); | |
config.get("pool.maxConnectionPoolSize").asInt().ifPresent(this::maxConnectionPoolSize); | |
config.get("pool.idleTimeBeforeConnectionTest").as(Duration.class).ifPresent(this::idleTimeBeforeConnectionTest); | |
config.get("pool.maxConnectionLifetime").as(Duration.class).ifPresent(this::maxConnectionLifetime); | |
config.get("pool.connectionAcquisitionTimeout").as(Duration.class).ifPresent(this::connectionAcquisitionTimeout); | |
//trust config.get("trustsettings.trustStrategy").asString().map(TrustStrategy::valueOf).ifPresent(this::trustStrategy); | |
config.get("trustsettings.certificate").as(Path.class).ifPresent(this::certificate); | |
config.get("trustsettings.hostnameVerificationEnabled").asBoolean().ifPresent(this::hostnameVerificationEnabled); | |
return this; | |
} |
キーの付いた文字列が見えると思いますが、これは実際には設定ファイルから取得しています。SE ConfigもしくはMicroProfile Configで取得しています。Builderのパターンに従ってすべての項目を設定します。
... | |
public Builder password(String password) { | |
Objects.requireNonNull(password); | |
this.password = password; | |
return this; | |
} | |
... |
全てのフィールドが設定されると、サポートクラスをビルドできます。
@Override | |
public Neo4j build() { | |
if (driver == null) { | |
driver = initDriver(); | |
} | |
return new Neo4j(this); | |
} |
全ての値がnullでないことをこのように保証します。実際のドライバーの作成は極めて簡単です(一部のメソッドは省略しています)。
private Driver initDriver() { | |
AuthToken authToken = AuthTokens.none(); | |
if (authenticationEnabled) { | |
authToken = AuthTokens.basic(username, password); | |
} | |
org.neo4j.driver.Config.ConfigBuilder configBuilder = createBaseConfig(); | |
configureSsl(configBuilder); | |
configurePoolSettings(configBuilder); | |
return GraphDatabase.driver(uri, authToken, configBuilder.build()); | |
} |
その後、Neo4jサポートオブジェクトはドライバーだけを返します。
public Driver driver() { | |
return driver; | |
} |
これでドライバーを利用できるようになりました。
Neo4jMetricsSupport.builder() | |
.driver(neo4j.driver()) | |
.build() | |
.initialize(); | |
Driver neo4jDriver = neo4j.driver(); |
Helidonはapplication.yaml
ファイルからドライバーを作成、構成します。
neo4j: | |
uri: bolt://localhost:7687 | |
authentication: | |
username: neo4j | |
password: secret | |
pool: | |
metricsEnabled: true |
なので、それを使うだけです。
public List<Movie> findAll(){ | |
try (var session = driver.session()) { | |
var query = "" | |
+ "match (m:Movie) " | |
+ "match (m) <- [:DIRECTED] - (d:Person) " | |
+ "match (m) <- [r:ACTED_IN] - (a:Person) " | |
+ "return m, collect(d) as directors, collect({name:a.name, roles: r.roles}) as actors"; | |
return session.readTransaction(tx -> tx.run(query).list(r -> { | |
var movieNode = r.get("m").asNode(); | |
var directors = r.get("directors").asList(v -> { | |
var personNode = v.asNode(); | |
return new Person(personNode.get("born").asInt(), personNode.get("name").asString()); | |
}); | |
var actors = r.get("actors").asList(v -> { | |
return new Actor(v.get("name").asString(), v.get("roles").asList(Value::asString)); | |
}); | |
var m = new Movie(movieNode.get("title").asString(), movieNode.get("tagline").asString()); | |
m.setReleased(movieNode.get("released").asInt()); | |
m.setDirectorss(directors); | |
m.setActors(actors); | |
return m; | |
})); | |
} | |
} |
出来上がりです。HelidonとNeo4jが一緒に使えるようになりました。
しかし、MPについてはどうでしょうか?CDI extensionでラップすればいいだけです。本当に簡単ですね。
public class Neo4jCdiExtension implements Extension { | |
private static final String NEO4J_METRIC_NAME_PREFIX = "neo4j"; | |
void afterBeanDiscovery(@Observes AfterBeanDiscovery addEvent) { | |
addEvent.addBean() | |
.types(Driver.class) | |
.qualifiers(Default.Literal.INSTANCE, Any.Literal.INSTANCE) | |
.scope(ApplicationScoped.class) | |
.name(Driver.class.getName()) | |
.beanClass(Driver.class) | |
.createWith(creationContext -> { | |
org.eclipse.microprofile.config.Config config = ConfigProvider.getConfig(); | |
Config helidonConfig = MpConfig.toHelidonConfig(config).get(NEO4J_METRIC_NAME_PREFIX); | |
ConfigValue<Neo4j> configValue = helidonConfig.as(Neo4j::create); | |
if (configValue.isPresent()) { | |
return configValue.get().driver(); | |
} | |
throw new Neo4jException("There is no Neo4j driver configured in configuration under key 'neo4j"); | |
}); | |
} | |
} |
ご覧の通り、SEから構成を取得しています。
Config helidonConfig = MpConfig.toHelidonConfig(config).get(NEO4J_METRIC_NAME_PREFIX); |
続いて、Neo4j SEサポートオブジェクトを再利用するだけです。
ConfigValue<Neo4j> configValue = helidonConfig.as(Neo4j::create); |
そしてドライバーを返すと…
return configValue.get().driver(); |
ふつうのMicroProfileのCDIのやり方でドライバーを利用できます。
@Inject | |
public MovieRepository(Driver driver) { | |
this.driver = driver; | |
} |
構成をmicroprofile-config.properties
ファイルから読み取ります。
# Neo4j settings | |
neo4j.uri=bolt://localhost:7687 | |
neo4j.authentication.username=neo4j | |
neo4j.authentication.password: secret | |
neo4j.pool.metricsEnabled: true |
すると、SEのサンプルでやったのと同じようにドライバーを利用できます。
これで、Helidon SE、Helidon MPともNeo4jとの統合ができました。
でもこれで終わりじゃありません。
Metrics
先ほど述べたように、適切なクラウドネイティブな体験のために、Neo4jのためにも、メトリクスやヘルスチェックを提供する必要があります。
では同じようにやっていきましょう。まずはSEから、その後MP用にラップします。ではメトリクスから始めます。
Neo4jのメトリクス用に別のモジュールを用意する予定です。
Helidon SEのように、Neo4jサポートクラスでは、メトリクスのサポートを構成するためにBuilderパターンに従います。実際には、Neo4jドライバーからすべてのメトリクスを取得することができるため、必要なのはNeo4jドライバーだけです。
public static class Builder implements io.helidon.common.Builder<Neo4jMetricsSupport> { | |
private Driver driver; | |
private Builder() { | |
} | |
public Neo4jMetricsSupport build() { | |
Objects.requireNonNull(driver, "Must set driver before building"); | |
return new Neo4jMetricsSupport(this); | |
} | |
public Builder driver(Driver driver) { | |
this.driver = driver; | |
return this; | |
} | |
} |
そして、Neo4jからの情報のSupplier
だけでCounter
とGauge
をラップします。
private static class Neo4JCounterWrapper implements Counter { | |
private final Supplier<Long> fn; | |
private Neo4JCounterWrapper(Supplier<Long> fn) { | |
this.fn = fn; | |
} | |
@Override | |
public void inc() { | |
throw new UnsupportedOperationException(); | |
} | |
@Override | |
public void inc(long n) { | |
throw new UnsupportedOperationException(); | |
} | |
@Override | |
public long getCount() { | |
return fn.get(); | |
} | |
} | |
private static class Neo4JGaugeWrapper<T> implements Gauge<T> { | |
private final Supplier<T> supplier; | |
private Neo4JGaugeWrapper(Supplier<T> supplier) { | |
this.supplier = supplier; | |
} | |
@Override | |
public T getValue() { | |
return supplier.get(); | |
} | |
} |
続いて、メトリクス中のこれらのカウンターをSEのMetricsRegistry
に登録する方法が必要です。
private void registerCounter(MetricRegistry metricRegistry, | |
ConnectionPoolMetrics cpm, | |
String poolPrefix, | |
String name, | |
Function<ConnectionPoolMetrics, Long> fn) { | |
String counterName = poolPrefix + name; | |
if (metricRegistry.getCounters().get(new MetricID(counterName)) == null) { | |
Metadata metadata = Metadata.builder() | |
.withName(counterName) | |
.withType(MetricType.COUNTER) | |
.notReusable() | |
.build(); | |
Neo4JCounterWrapper wrapper = new Neo4JCounterWrapper(() -> fn.apply(cpm)); | |
metricRegistry.register(metadata, wrapper); | |
} | |
} | |
private void registerGauge(MetricRegistry metricRegistry, | |
ConnectionPoolMetrics cpm, | |
String poolPrefix, | |
String name, | |
Function<ConnectionPoolMetrics, Integer> fn) { | |
String gaugeName = poolPrefix + name; | |
if (metricRegistry.getGauges().get(new MetricID(gaugeName)) == null) { | |
Metadata metadata = Metadata.builder() | |
.withName(poolPrefix + name) | |
.withType(MetricType.GAUGE) | |
.notReusable() | |
.build(); | |
Neo4JGaugeWrapper<Integer> wrapper = | |
new Neo4JGaugeWrapper<>(() -> fn.apply(cpm)); | |
metricRegistry.register(metadata, wrapper); | |
} | |
} |
ようやくメトリクスを登録できるようになりました。
private void reinit() { | |
Map<String, Function<ConnectionPoolMetrics, Long>> counters = Map.ofEntries( | |
entry("acquired", ConnectionPoolMetrics::acquired), | |
entry("closed", ConnectionPoolMetrics::closed), | |
entry("created", ConnectionPoolMetrics::created), | |
entry("failedToCreate", ConnectionPoolMetrics::failedToCreate), | |
entry("timedOutToAcquire", ConnectionPoolMetrics::timedOutToAcquire), | |
entry("totalAcquisitionTime", ConnectionPoolMetrics::totalAcquisitionTime), | |
entry("totalConnectionTime", ConnectionPoolMetrics::totalConnectionTime), | |
entry("totalInUseCount", ConnectionPoolMetrics::totalInUseCount), | |
entry("totalInUseTime", ConnectionPoolMetrics::totalInUseTime)); | |
Map<String, Function<ConnectionPoolMetrics, Integer>> gauges = Map.ofEntries( | |
entry("acquiring", ConnectionPoolMetrics::acquiring), | |
entry("creating", ConnectionPoolMetrics::creating), | |
entry("idle", ConnectionPoolMetrics::idle), | |
entry("inUse", ConnectionPoolMetrics::inUse) | |
); | |
for (ConnectionPoolMetrics it : lastPoolMetrics.get()) { | |
//String poolPrefix = NEO4J_METRIC_NAME_PREFIX + "-" + it.id() + "-"; | |
String poolPrefix = NEO4J_METRIC_NAME_PREFIX + "-"; | |
counters.forEach((name, supplier) -> registerCounter(metricRegistry.get(), it, poolPrefix, name, supplier)); | |
gauges.forEach((name, supplier) -> registerGauge(metricRegistry.get(), it, poolPrefix, name, supplier)); | |
// we only care about the first one | |
metricsInitialized.set(true); | |
break; | |
} | |
} |
メトリクスを更新することは常に良いアイデアであり、そのための機能があります。
private void refreshMetrics(ScheduledExecutorService executor) { | |
Collection<ConnectionPoolMetrics> currentPoolMetrics = driver.metrics().connectionPoolMetrics(); | |
if (!metricsInitialized.get() && currentPoolMetrics.size() >= 1) { | |
lastPoolMetrics.set(currentPoolMetrics); | |
reinit(); | |
if (metricsInitialized.get()) { | |
reinitFuture.get().cancel(false); | |
executor.shutdown(); | |
} | |
} | |
} |
application.yaml
ファイル、もしくは
neo4j: | |
pool: | |
metricsEnabled: true |
microprofile-config.properties
ファイルで、
neo4j.pool.metricsEnabled=true
メトリクスを配信するようにNeo4jドライバーを有効にするだけです。
これで、アプリのクエリ/health
を起動すると、Neo4j のメトリクスも取得できるようになります。
SEで完全に動作すれば、Metrics登録をCDI extensionとしてラップするだけです。このイベントは、ドライバーが初期化後に発生するはずです。
public class Neo4jMetricsCdiExtension implements Extension { | |
private void addMetrics(@Observes @Priority(PLATFORM_AFTER + 101) @Initialized(ApplicationScoped.class) Object event) { | |
Instance<Driver> driver = CDI.current().select(Driver.class); | |
Neo4jMetricsSupport.builder() | |
.driver(driver.get()) | |
.build() | |
.initialize(); | |
} | |
} |
これでおしまいです。Neo4jのメトリクスがHelidon MPでも利用可能になりました。
そして、最後にHealth checkです。
Health Checks
再度、別モジュールにしておきましょう。
以下ではMPからやっていきます。
@Readiness | |
@ApplicationScoped | |
public class Neo4jHealthCheck implements HealthCheck { | |
private static final String CYPHER = "RETURN 1 AS result"; | |
private static final SessionConfig DEFAULT_SESSION_CONFIG = SessionConfig.builder() | |
.withDefaultAccessMode(AccessMode.WRITE) | |
.build(); | |
private final Driver driver; | |
@Inject | |
//will be ignored outside of CDI | |
Neo4jHealthCheck(Driver driver) { | |
this.driver = driver; | |
} | |
public static Neo4jHealthCheck create(Driver driver) { | |
return new Neo4jHealthCheck(driver); | |
} | |
private static HealthCheckResponse buildStatusUp(ResultSummary resultSummary, HealthCheckResponseBuilder builder) { | |
ServerInfo serverInfo = resultSummary.server(); | |
builder.withData("server", serverInfo.version() + "@" + serverInfo.address()); | |
String databaseName = resultSummary.database().name(); | |
if (!(databaseName == null || databaseName.trim().isEmpty())) { | |
builder.withData("database", databaseName.trim()); | |
} | |
return builder.build(); | |
} | |
@Override | |
public HealthCheckResponse call() { | |
HealthCheckResponseBuilder builder = HealthCheckResponse.named("Neo4j connection health check").up(); | |
try { | |
ResultSummary resultSummary; | |
// Retry one time when the session has been expired | |
try { | |
resultSummary = runHealthCheckQuery(); | |
} catch (SessionExpiredException sessionExpiredException) { | |
resultSummary = runHealthCheckQuery(); | |
} | |
return buildStatusUp(resultSummary, builder); | |
} catch (Exception e) { | |
return builder.down().withData("reason", e.getMessage()).build(); | |
} | |
} | |
private ResultSummary runHealthCheckQuery() { | |
// We use WRITE here to make sure UP is returned for a server that supports | |
// all possible workloads | |
if (driver != null) { | |
Session session = this.driver.session(DEFAULT_SESSION_CONFIG); | |
Result run = session.run(CYPHER); | |
return run.consume(); | |
} | |
return null; | |
} | |
} |
技術的には、ヘルスチェックのために単純なCypherリクエストを投げます。それが動作すれば、Neo4jが生きていることを意味します。このコードは、純粋なMicroProfileのコードなのでこれで十分です。
必要なのは、Mavenの依存関係を含めるだけです。
<dependency> | |
<groupId>io.helidon.integrations.neo4j</groupId> | |
<artifactId>helidon-integrations-neo4j-health</artifactId> | |
</dependency> |
これですぐに動作します。
SEについては、純粋なJavaなので、初期化するだけでOKです。
Neo4j neo4j = Neo4j.create(config.get("neo4j")); | |
Neo4jHealthCheck healthCheck = Neo4jHealthCheck.create(neo4j.driver()); | |
Driver neo4jDriver = neo4j.driver(); | |
HealthSupport health = HealthSupport.builder() | |
.addLiveness(HealthChecks.healthChecks()) // Adds a convenient set of checks | |
.addReadiness(healthCheck) | |
.build(); | |
return Routing.builder() | |
.register(health) // Health at "/health" | |
//other services | |
.build(); | |
} | |
これでおしまいです。MPとSEの両方のアプリケーションがNeo4jを使用し、Metricsを読み、Healthチェックを行うことができるようになりました。ところで、Neo4jドライバーはGraalVMのnative-imageユーティリティに完全対応しているので、Helidon MPやSEアプリケーションはnative-imageユーティリティを利用して、AOTコンパイルでネイティブイメージ化できます。
この記事では、Helidonとの統合を作成する方法を紹介しました。しかし、Neo4jに関しては、私たちはすでに公式に実施済みです。
なので、以下のものを依存関係に含めるだけでOKです。
<dependency> | |
<groupId>io.helidon.integrations.neo4j</groupId> | |
<artifactId>helidon-integrations-neo4j</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>io.helidon.integrations.neo4j</groupId> | |
<artifactId>helidon-integrations-neo4j-metrics</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>io.helidon.integrations.neo4j</groupId> | |
<artifactId>helidon-integrations-neo4j-health</artifactId> | |
</dependency> |
そして、HelidonとNeo4jを使い始めるために必要なのはこれだけです。
Conclusion
ご覧の通り、Helidonとの統合は非常に簡単です。通常の方法は、まずHelidon SEサポートオブジェクトをBuilderパターンに従って初期化し、それをCDI extensionでラップするだけで、MicroProfileにその魔法をかけることができます。
Neo4jのサンプルは、Helidon Neo4j integrationsの公式リポジトリでお試しいただけます。
Neo4j + Helidonのサンプル
https://github.com/oracle/helidon/tree/master/examples/integrations/neo4j
Neo4jの機能を紹介した公式動画は、Helidon公式YouTubeチャンネルでご覧いただけます。
Neo4jについて言えば、CypherDslとそれを使った例にも興味をお持ちになるかもしれません。
The Neo4j Cypher-DSL
https://github.com/neo4j-contrib/cypher-dsl
Next Steps
この記事では、HelidonでNeo4jがどのように動作するかだけでなく、独自の拡張機能をどのように書くことができるかについてもご紹介しました。Helidonはオープンソースなので、あなたの作成した統合機能をコントリビュートしてください。