Как мы Neo4j к Helidon прикручивали

Идея поэкспериментировать с интеграцией Neo4j с Helidon возникла вполне естественно.

Neo4j — графовая система управления базами данных с открытым исходным кодом, реализованная на Java. По состоянию на 2015 год считается самой распространённой графовой СУБД. (Википедия, 21.10.2021)

Neo4j написана на Java и доступна из ПО, написанного на других языках с использованием языка запросов Cypher, через HTTP endpoint или через протокол «bolt». Neo4j в настоящее время является стандартом де-факто для графовых баз данных, используемых во многих отраслях.

Вообще, в се началось с небольшого разговора с Michael Simonis, одним из авторов Spring Data Neo4j 6 и сопровождающим Neo4j-OGM. Мы спросили Михаеля, что он думает о том, как Helidon и Neo4J могут работать вместе. Менее чем через час Михаель прислал мне ссылку на этот репозиторий с полнофункциональным примером для Helidon MP и Neo4j SDN.

Когда я начал читать код, я увидел странную зависимость в файле pom.xml:

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-neo4j</artifactId>
</dependency>

Spring? Да ладна! Почему в нашем проекте есть Spring?

Но продолжая изучать код, я был весьма удивлен, увидев расширение CDI 2.0, обрабатывающее инициализацию и подготовку драйвера. Это расширение CDI запустилось без проблем в Helidon, поскольку он полностью поддерживает контейнер CDI - шикарно! Я снова почувствовал красоту и силу стандартов.

На следующий день Михаель прислал мне другое репо. На этот раз он включил свои идеи о том, как использовать Neo4j с Helidon SE. Интеграция тоже получилась очень простой. Было легко использовать Helidon Config для переноса всей конфигурации Neo4j в единую конфигурацию. А поскольку драйвер Neo4j полностью поддерживает native image, все можно скомпилировать в native-image исполняемый файл без каких-либо дополнительных действий с точки зрения программиста.

Это породило дискуссии между командами Helidon и Neo4j о том, какой вид интеграции более подходит для Helidon. В результате мы создали официальную интеграцию!

После некоторых консультаций с представителем Neo4j и моим хорошим другом Михаелем Симонисом мы пришли к выводу, что нам нужно только прдоставлять инициализированный драйвер пользователям Helidon. Метрики и Health Checks из Neo4j должны быть проброшены в метрики Helidon / MicroProfile и должны предоставляться как отдельные модули - отдельные зависимости Maven.

Итак, как же нам написать интеграцию Neo4j с Helidon? существуют две разновидности Helidon - MP и SE. SE представляет собой набор реактивных API, реализованных на чистой Java. Абсолютно никакой «магии» вроде рефлекшана или других хитростей. Helidon MP, по сути, оборачивает SE и приводит его к стандартам MicroProfile. Это означает, что рекомендуется сначала реализовать интеграцию с Neo4j для Helidon SE, а затем обернуть ее как расширения CDI для Helidon MP.

Давайте сделаем это!

Дисклеймер: в этой статье я продемонстрирую только основные фрагменты кода. Поскольку Helidon опенсорс проект под лицензией Apache 2.0, полный код доступен в официальном репозитории Helidon.

В Helidon мы обычно создаем так называемый support объект для реализации интеграции. Этот объект содержит всю информацию о конфигурации и инициализации. Мы следуем Builder pattern для чтения из конфигурации. Это означает, что мы создаем внутренний объект 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, либо из конфигурации MicroProfile. Каждый элемент настраивается следуя шаблону Builder:

...
public Builder password(String password) {
   Objects.requireNonNull(password);
   this.password = password;
   return this;
}
... 

Когда все поля установлены, мы можем сбилдить support объект:

@Override
public Neo4j build() {
   if (driver == null) {
       driver = initDriver();
   }
   return new Neo4j(this);
} 

Таким образом мы гарантируем, что все значения не будут нулевыми. Фактическая инициализация драйвера довольно проста (некоторые методы опущены):

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());
} 

Support объект далее просто возвращает драйвер:

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 - это действительно довольно просто:

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); 

Затем просто повторно используем наш support объект Neo4j SE:

ConfigValue<Neo4j> configValue = helidonConfig.as(Neo4j::create);

… и возвращаем драйвер:

return configValue.get().driver();

И тоже собственно все - просто инжектим драйвер:

@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 MP может работать с Neo4j!

Но это еще не все!

Метрики

Как я уже упоминал, для надлежащей работе в облаках нам также необходимо пробросить метрики и health checks из Neo4j.

Сделаем как и раньше - сначала для SE, а потом все обернем в MP.

Начнем с метрик! Сделаем отдельный модуль для метрик Neo4j.

Как и в Helidon SE, в support объекте Neo4j мы будем следовать шаблону Builder для настройки поддержки метрик. На самом деле нам нужен только драйвер 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;
   }
} 

Затем мы должны обернуть counter–ы и gauge–ы Neo4j:

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();
   }
} 

Далее нужно зарегистрировать эти счетчики в 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 + "-";
       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();
       }
   }
} 

Нам нужно только предоставить драйвер Neo4j для предоставления нам информации о метриках в нашем файле application.yaml:

neo4j:
 pool:
   metricsEnabled: true 

… Или в нашем microprofile-config.properties :

neo4j.pool.metricsEnabled = true 

Теперь, если вы пойдете по «/health», вы также получите показания от Neo4j.

Для MP, нам нужно только обернуть регистрацию метрик как расширение CDI. Событие это должно произойти после того, как драйвер уже инициализирован:

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 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;
   }
} 

Технически для health check-a, мы выполняем простой запрос на Cypher, и если он работает, значит, Neo4j жив, этого достаточно!

Нам нужно только добавить Maven зависимость в наш проект:

<dependency>
   <groupId>io.helidon.integrations.neo4j</groupId>
   <artifactId>helidon-integrations-neo4j-health</artifactId>
</dependency> 

И снова - Вуаля, все работает!

Что касается SE, поскольку это чистая Java, нам просто нужно все инициализировать:

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();
} 

Теперь наше Helidon приложение, как MP, так и SE, может работать с Neo4j, читать его метрики и выполнять health checks. 

Кстати, поскольку драйвер Neo4j полностью поддерживает GraalVM native-image, приложения на Helidon MP или SE могут быть в него скомпилированы!

В этой статье было продемонстрировано, как создать интеграцию разных технологий с Helidon. Что касается Neo4j, мы уже официально сделали это за вас!

Просто нужно включить в свои Maven проект следующие зависимости:

<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!

Заключение

Как видите, интеграция с Helidon довольно проста. Стандартный способ сделать это - сначала написать supportобъект Helidon SE, следуя шаблону Builder для его инициализации, а затем просто обернуть его в CDI расширение, чтобы MicroProfile мог воспользоваться этой "магией"!

Вы можете поиграть с примерами Helidon ин Neo4j в нашем официальном репозитории интеграции Helidon Neo4j .

Что касается Neo4j, вас также может заинтересовать CypherDsl и пример с ним.

Следующие шаги

В этой статье я хотел показать вам не только, как Neo4j работает с Helidon, но и как вы можете писать свои собственные расширения. Поскольку Helidon опенсорсный продукт, мы приглашаем вас в него контрибьютить!