From e4b61036ceb23e21d6025ac60eabf493cd347542 Mon Sep 17 00:00:00 2001 From: Jon Loucks Date: Sat, 15 Nov 2025 08:03:43 -0500 Subject: [PATCH] Adding client server example --- .idea/gradle.xml | 2 + .idea/protoeditor.xml | 15 +++ build.gradle | 2 + client/build.gradle | 37 ++++++ .../jonloucks/example/client/Client.java | 85 ++++++++++++++ .../example/client/ClientException.java | 31 +++++ .../example/client/ClientFactory.java | 48 ++++++++ .../example/client/ClientFactoryFinder.java | 71 ++++++++++++ .../example/client/ClientFactoryImpl.java | 107 ++++++++++++++++++ .../jonloucks/example/client/ClientImpl.java | 90 +++++++++++++++ .../example/client/ConfigBuilderImpl.java | 63 +++++++++++ client/src/main/java/module-info.java | 18 +++ .../jonloucks/examples/common/Common.java | 2 + .../jonloucks/examples/common/Constants.java | 6 +- .../examples/common/ProgramImpl.java | 4 +- gradle/libs.versions.toml | 17 ++- messages/build.gradle | 43 +++++++ messages/src/main/java/module-info.java | 9 ++ .../src/main/proto/examples/weather.proto | 20 ++++ server/build.gradle | 7 ++ .../example/server/ConfigBuilderImpl.java | 27 +++++ .../jonloucks/example/server/Server.java | 17 +++ .../jonloucks/example/server/ServerImpl.java | 43 +++++++ .../example/server/WeatherServiceImpl.java | 23 ++++ server/src/main/java/module-info.java | 5 + settings.gradle | 4 +- .../jonloucks/examples/ClientCommand.java | 29 +++++ .../io/github/jonloucks/examples/Main.java | 41 ++++++- .../jonloucks/examples/ServerCommand.java | 7 +- src/main/java/module-info.java | 5 +- 30 files changed, 861 insertions(+), 17 deletions(-) create mode 100644 .idea/protoeditor.xml create mode 100644 client/build.gradle create mode 100644 client/src/main/java/io/github/jonloucks/example/client/Client.java create mode 100644 client/src/main/java/io/github/jonloucks/example/client/ClientException.java create mode 100644 client/src/main/java/io/github/jonloucks/example/client/ClientFactory.java create mode 100644 client/src/main/java/io/github/jonloucks/example/client/ClientFactoryFinder.java create mode 100644 client/src/main/java/io/github/jonloucks/example/client/ClientFactoryImpl.java create mode 100644 client/src/main/java/io/github/jonloucks/example/client/ClientImpl.java create mode 100644 client/src/main/java/io/github/jonloucks/example/client/ConfigBuilderImpl.java create mode 100644 client/src/main/java/module-info.java create mode 100644 messages/build.gradle create mode 100644 messages/src/main/java/module-info.java create mode 100644 messages/src/main/proto/examples/weather.proto create mode 100644 server/src/main/java/io/github/jonloucks/example/server/WeatherServiceImpl.java create mode 100644 src/main/java/io/github/jonloucks/examples/ClientCommand.java diff --git a/.idea/gradle.xml b/.idea/gradle.xml index e639131..c4182bf 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -9,7 +9,9 @@ diff --git a/.idea/protoeditor.xml b/.idea/protoeditor.xml new file mode 100644 index 0000000..72a6211 --- /dev/null +++ b/.idea/protoeditor.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 644295f..5794d94 100644 --- a/build.gradle +++ b/build.gradle @@ -47,8 +47,10 @@ repositories { } dependencies { + implementation project(":messages") implementation project(":common") implementation project(":server") + implementation project(":client") implementation libs.contracts implementation libs.concurrency diff --git a/client/build.gradle b/client/build.gradle new file mode 100644 index 0000000..3e8b10c --- /dev/null +++ b/client/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'java-library' +} + +getTasks().withType(JavaCompile.class).configureEach { + getOptions().getRelease().set(9) +} + +repositories { + mavenCentral() + mavenLocal() +} + +dependencies { + api libs.contracts.api + api libs.concurrency.api + api libs.metalog.api + api project(":common") + + implementation libs.contracts + implementation libs.concurrency + implementation libs.metalog + implementation project(":messages") + + implementation libs.grpc.protobuf + implementation libs.grpc.stub + implementation libs.grpc.services + runtimeOnly libs.grpc.netty.shaded + + testImplementation libs.contracts.test + testImplementation libs.concurrency.test + testImplementation libs.metalog.test +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/client/src/main/java/io/github/jonloucks/example/client/Client.java b/client/src/main/java/io/github/jonloucks/example/client/Client.java new file mode 100644 index 0000000..dca2d79 --- /dev/null +++ b/client/src/main/java/io/github/jonloucks/example/client/Client.java @@ -0,0 +1,85 @@ +package io.github.jonloucks.example.client; + +import io.github.jonloucks.concurrency.api.Idempotent; +import io.github.jonloucks.concurrency.api.WaitableNotify; +import io.github.jonloucks.contracts.api.AutoOpen; +import io.github.jonloucks.contracts.api.Contract; +import io.github.jonloucks.contracts.api.Contracts; +import io.github.jonloucks.contracts.api.GlobalContracts; + +import java.time.Duration; +import java.util.function.Supplier; + +public interface Client extends AutoOpen { + Contract CONTRACT = Contract.create(Client.class); + + WaitableNotify lifeCycleNotify(); + + String getWeatherReport(); + + interface Config { + /** + * The default configuration used when creating a new Client instance + */ + Config DEFAULT = new Config() {}; + + /** + * @return the contracts, some use case have their own Contracts instance. + */ + default Contracts contracts() { + return GlobalContracts.getInstance(); + } + + /** + * @return if true, reflection might be used to locate the ClientFactory + */ + default boolean useReflection() { + return true; + } + + /** + * @return the class name to use if reflection is used to find the ClientFactory + */ + default String reflectionClassName() { + return "io.github.jonloucks.example.client.ClientFactoryImpl"; + } + + /** + * @return if true, the ServiceLoader might be used to locate the ClientFactory + */ + default boolean useServiceLoader() { + return true; + } + + /** + * @return the class name to load from the ServiceLoader to find the ClientFactory + */ + default Class serviceLoaderClass() { + return ClientFactory.class; + } + + default int port() { + return 50052; + } + + default String hostname() { + return "localhost"; + } + + default Duration shutdownTimeout() { + return Duration.ofSeconds(60); + } + + interface Builder extends Config { + Contract> FACTORY = Contract.create("Client Config Builder Factory"); + + Builder contracts(Contracts contracts); + + Builder port(int port); + + Builder hostname(String hostname); + + Builder shutdownTimeout(Duration shutdownTimeout); + } + } +} diff --git a/client/src/main/java/io/github/jonloucks/example/client/ClientException.java b/client/src/main/java/io/github/jonloucks/example/client/ClientException.java new file mode 100644 index 0000000..cabc762 --- /dev/null +++ b/client/src/main/java/io/github/jonloucks/example/client/ClientException.java @@ -0,0 +1,31 @@ +package io.github.jonloucks.example.client; + +import static io.github.jonloucks.contracts.api.Checks.messageCheck; + +/** + * Runtime exception thrown for Client related problems. + * For example, when Client fails to initialize. + */ +public class ClientException extends RuntimeException { + + private static final long serialVersionUID = 0L; + + /** + * Passthrough for {@link RuntimeException#RuntimeException(String)} + * + * @param message the message for this exception + */ + public ClientException(String message) { + this(message, null); + } + + /** + * Passthrough for {@link RuntimeException#RuntimeException(String, Throwable)} + * + * @param message the message for this exception + * @param thrown the cause of this exception, null is allowed + */ + public ClientException(String message, Throwable thrown) { + super(messageCheck(message), thrown); + } +} diff --git a/client/src/main/java/io/github/jonloucks/example/client/ClientFactory.java b/client/src/main/java/io/github/jonloucks/example/client/ClientFactory.java new file mode 100644 index 0000000..4797ca2 --- /dev/null +++ b/client/src/main/java/io/github/jonloucks/example/client/ClientFactory.java @@ -0,0 +1,48 @@ +package io.github.jonloucks.example.client; + +import io.github.jonloucks.contracts.api.AutoOpen; +import io.github.jonloucks.contracts.api.Contract; +import io.github.jonloucks.contracts.api.Repository; + +import java.util.function.Consumer; + +/** + * Responsible for creating new instances of Client + */ +public interface ClientFactory { + + /** + * Used to promise and claim the ClientFactory implementation + */ + Contract CONTRACT = Contract.create(ClientFactory.class); + + /** + * Create a new instance of Client + *

+ * Note: caller is responsible for calling {@link AutoOpen#open()} and calling + * the {@link io.github.jonloucks.contracts.api.AutoClose#close() when done} + *

+ * @param config the Client configuration for the new instance + * @return the new Client instance + */ + Client create(Client.Config config); + + /** + * Create a new instance of Client + * + * @param builderConsumer the config builder consumer callback + * @return the new Client instance + * @throws IllegalArgumentException if builderConsumer is null or when configuration is invalid + */ + Client create(Consumer builderConsumer); + + /** + * Install all the requirements and promises to the given Client Repository. + * Include Client#CONTRACT which will private a unique + * + * @param config the Client config + * @param repository the repository to add requirements and promises to + * @throws IllegalArgumentException if config is null, config is invalid, or repository is null + */ + void install(Client.Config config, Repository repository); +} diff --git a/client/src/main/java/io/github/jonloucks/example/client/ClientFactoryFinder.java b/client/src/main/java/io/github/jonloucks/example/client/ClientFactoryFinder.java new file mode 100644 index 0000000..67e3de1 --- /dev/null +++ b/client/src/main/java/io/github/jonloucks/example/client/ClientFactoryFinder.java @@ -0,0 +1,71 @@ +package io.github.jonloucks.example.client; + +import java.util.Optional; +import java.util.ServiceLoader; + +import static io.github.jonloucks.contracts.api.Checks.configCheck; +import static io.github.jonloucks.contracts.api.Checks.nullCheck; +import static java.util.Optional.ofNullable; + +/** + * Responsible for locating and creating the ClientFactory for a deployment. + */ +public final class ClientFactoryFinder { + public ClientFactoryFinder(Client.Config config) { + this.config = configCheck(config); + } + + public ClientFactory get() { + return find().orElseThrow(this::newNotFoundException); + } + + public Optional find() { + final Optional byReflection = createByReflection(); + if (byReflection.isPresent()) { + return byReflection; + } + return createByServiceLoader(); + } + + private Optional createByServiceLoader() { + if (config.useServiceLoader()) { + try { + for (ClientFactory factory : ServiceLoader.load(getServiceFactoryClass())) { + return Optional.of(factory); + } + } catch (Throwable ignored) { + return Optional.empty(); + } + } + return Optional.empty(); + } + + private Class getServiceFactoryClass() { + return nullCheck(config.serviceLoaderClass(), "Client Service Loader class must be present."); + } + + private Optional createByReflection() { + if (config.useReflection()) { + return getReflectionClassName().map(this::createNewInstance); + } + return Optional.empty(); + } + + private ClientFactory createNewInstance(String className) { + try { + return (ClientFactory)Class.forName(className).getConstructor().newInstance(); + } catch (Throwable thrown) { + return null; + } + } + + private Optional getReflectionClassName() { + return ofNullable(config.reflectionClassName()).filter(x -> !x.isEmpty()); + } + + private ClientException newNotFoundException() { + return new ClientException("Unable to find Client factory."); + } + + private final Client.Config config; +} diff --git a/client/src/main/java/io/github/jonloucks/example/client/ClientFactoryImpl.java b/client/src/main/java/io/github/jonloucks/example/client/ClientFactoryImpl.java new file mode 100644 index 0000000..de545bc --- /dev/null +++ b/client/src/main/java/io/github/jonloucks/example/client/ClientFactoryImpl.java @@ -0,0 +1,107 @@ +package io.github.jonloucks.example.client; + +import io.github.jonloucks.concurrency.api.Concurrency; +import io.github.jonloucks.concurrency.api.ConcurrencyFactory; +import io.github.jonloucks.concurrency.api.StateMachineFactory; +import io.github.jonloucks.concurrency.api.WaitableFactory; +import io.github.jonloucks.contracts.api.Contracts; +import io.github.jonloucks.contracts.api.Promisor; +import io.github.jonloucks.contracts.api.Repository; +import io.github.jonloucks.example.client.Client.Config; +import io.github.jonloucks.metalog.api.Metalog; +import io.github.jonloucks.metalog.api.MetalogFactory; + +import java.util.Optional; +import java.util.function.Consumer; + +import static io.github.jonloucks.concurrency.api.GlobalConcurrency.findConcurrencyFactory; +import static io.github.jonloucks.contracts.api.BindStrategy.IF_NOT_BOUND; +import static io.github.jonloucks.contracts.api.Checks.*; +import static io.github.jonloucks.contracts.api.GlobalContracts.lifeCycle; +import static io.github.jonloucks.examples.common.Constants.WEATHER; +import static io.github.jonloucks.metalog.api.GlobalMetalog.findMetalogFactory; + +public class ClientFactoryImpl implements ClientFactory { + @Override + public Client create(Config config) { + final Config validConfig = enhancedConfigCheck(config); + final Repository repository = validConfig.contracts().claim(Repository.FACTORY).get(); + + installConcurrency(validConfig, repository); + installMetalog(validConfig, repository); + installCore(validConfig, repository); + + final Client server = new ClientImpl(validConfig, repository, true); + repository.keep(Client.CONTRACT, () -> server); + return server; + } + + @Override + public Client create(Consumer builderConsumer) { + final Consumer validBuilderConsumer = builderConsumerCheck(builderConsumer); + final ConfigBuilderImpl configBuilder = new ConfigBuilderImpl(); + validBuilderConsumer.accept(configBuilder); + return create(configBuilder); + } + + @Override + public void install(Config config, Repository repository) { + final Config validConfig = enhancedConfigCheck(config); + final Repository validRepository = nullCheck(repository, "Repository must be present."); + + installConcurrency(validConfig, validRepository); + installMetalog(validConfig, validRepository); + installCore(validConfig, validRepository); + + final Promisor serverPromisor = lifeCycle(() -> new ClientImpl(validConfig, validRepository, false)); + + validRepository.keep(Client.CONTRACT, serverPromisor, IF_NOT_BOUND); + } + + private void installConcurrency(Config config, Repository repository) { + final Concurrency.Config concurrencyConfig = new Concurrency.Config() { + @Override + public Contracts contracts() { + return config.contracts(); + } + }; + //noinspection ResultOfMethodCallIgnored + contractsCheck(concurrencyConfig.contracts()); + final Optional optionalFactory = findConcurrencyFactory(concurrencyConfig); + + optionalFactory.ifPresent(f -> f.install(concurrencyConfig, repository)); + } + + private void installMetalog(Config config, Repository repository) { + final Metalog.Config metalogConfig = new Metalog.Config() { + @Override + public Contracts contracts() { + return config.contracts(); + } + }; + //noinspection ResultOfMethodCallIgnored + contractsCheck(metalogConfig.contracts()); + final Optional optionalFactory = findMetalogFactory(metalogConfig); + + optionalFactory.ifPresent(f -> f.install(metalogConfig, repository)); + } + + private Config enhancedConfigCheck(Config config) { + final Config candidateConfig = configCheck(config); + final Contracts contracts = contractsCheck(candidateConfig.contracts()); + + if (contracts.isBound(Client.CONTRACT)) { + throw new ClientException("Client is already bound."); + } + + return candidateConfig; + } + + private void installCore(Config config, Repository repository) { + repository.require(Repository.FACTORY); + repository.require(WaitableFactory.CONTRACT); + repository.require(StateMachineFactory.CONTRACT); + + repository.keep(WEATHER, () -> config.contracts().claim(Client.CONTRACT).getWeatherReport()); + } +} diff --git a/client/src/main/java/io/github/jonloucks/example/client/ClientImpl.java b/client/src/main/java/io/github/jonloucks/example/client/ClientImpl.java new file mode 100644 index 0000000..431917f --- /dev/null +++ b/client/src/main/java/io/github/jonloucks/example/client/ClientImpl.java @@ -0,0 +1,90 @@ +package io.github.jonloucks.example.client; + +import io.github.jonloucks.concurrency.api.Idempotent; +import io.github.jonloucks.concurrency.api.StateMachine; +import io.github.jonloucks.concurrency.api.WaitableNotify; +import io.github.jonloucks.contracts.api.AutoClose; +import io.github.jonloucks.contracts.api.Repository; +import io.github.jonloucks.examples.messages.weather.WeatherGrpc; +import io.github.jonloucks.examples.messages.weather.WeatherOuterClass.WeatherReply; +import io.github.jonloucks.examples.messages.weather.WeatherOuterClass.WeatherRequest; +import io.grpc.*; +import io.grpc.protobuf.services.HealthStatusManager; + +import java.util.concurrent.TimeUnit; + +import static io.github.jonloucks.concurrency.api.Idempotent.withClose; +import static io.github.jonloucks.concurrency.api.Idempotent.withOpen; +import static io.github.jonloucks.contracts.api.Checks.configCheck; +import static io.github.jonloucks.contracts.api.Checks.nullCheck; +import static io.github.jonloucks.metalog.api.GlobalMetalog.publish; +import static java.util.Optional.ofNullable; + +final class ClientImpl implements Client { + + @Override + public AutoClose open() { + return withOpen(stateMachine, this::realOpen); + } + + @Override + public WaitableNotify lifeCycleNotify() { + return stateMachine; + } + + @Override + public String getWeatherReport() { + try { + final WeatherRequest weatherRequest = WeatherRequest.newBuilder() + .addLocation("current") + .build(); + final WeatherReply reply = blockingStub.sayWeather(weatherRequest); + return reply.getReport(); + } catch (StatusRuntimeException thrown) { + publish(() -> "RPC failed: " + thrown.getStatus(), b -> b.thrown(thrown)); + } + return null; + } + + ClientImpl(Config config, Repository repository, boolean openRepository) { + this.config = configCheck(config); + final Repository validRepository = nullCheck(repository, "Repository must be present."); + this.closeRepository = openRepository ? validRepository.open() : AutoClose.NONE; + this.stateMachine = Idempotent.createStateMachine(config.contracts()); + } + + private AutoClose realOpen() { + final String target = config.hostname() + ":" + config.port(); + channel = Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()) + .build(); + + blockingStub = WeatherGrpc.newBlockingStub(channel); + + return this::exposedClose; + } + + private void exposedClose() { + withClose(stateMachine, this::realClose); + } + + private void realClose() { + ofNullable(channel).ifPresent(c -> { + c.shutdown(); + try { + c.awaitTermination(config.shutdownTimeout().toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + }); + closeRepository.close(); + } + + private final Config config; + private final AutoClose closeRepository; + private final StateMachine stateMachine; + + private final HealthStatusManager health = new HealthStatusManager(); + + private ManagedChannel channel; + private WeatherGrpc.WeatherBlockingStub blockingStub; +} diff --git a/client/src/main/java/io/github/jonloucks/example/client/ConfigBuilderImpl.java b/client/src/main/java/io/github/jonloucks/example/client/ConfigBuilderImpl.java new file mode 100644 index 0000000..f013359 --- /dev/null +++ b/client/src/main/java/io/github/jonloucks/example/client/ConfigBuilderImpl.java @@ -0,0 +1,63 @@ +package io.github.jonloucks.example.client; + +import io.github.jonloucks.contracts.api.Contracts; + +import java.time.Duration; + +import static io.github.jonloucks.contracts.api.Checks.contractsCheck; +import static io.github.jonloucks.contracts.api.Checks.nullCheck; + +final class ConfigBuilderImpl implements Client.Config.Builder { + + @Override + public Contracts contracts() { + return contracts; + } + + @Override + public String hostname() { + return hostname; + } + + @Override + public int port() { + return port; + } + + @Override + public Duration shutdownTimeout() { + return shutdownTimeout; + } + + @Override + public Builder contracts(Contracts contracts) { + this.contracts = contractsCheck(contracts); + return this; + } + + @Override + public Builder port(int port) { + this.port = port; + return this; + } + + @Override + public Builder hostname(String hostname) { + this.hostname = nullCheck(hostname, "Hostname must be present."); + return this; + } + + @Override + public Builder shutdownTimeout(Duration timeout) { + this.shutdownTimeout = nullCheck(timeout, "Timeout must be present."); + return this; + } + + ConfigBuilderImpl() { + } + + private Contracts contracts; + private String hostname = Client.Config.DEFAULT.hostname(); + private int port = Client.Config.DEFAULT.port(); + private Duration shutdownTimeout = Client.Config.DEFAULT.shutdownTimeout(); +} diff --git a/client/src/main/java/module-info.java b/client/src/main/java/module-info.java new file mode 100644 index 0000000..85bf584 --- /dev/null +++ b/client/src/main/java/module-info.java @@ -0,0 +1,18 @@ +module io.github.jonloucks.example.client { + requires transitive io.github.jonloucks.contracts; + requires transitive io.github.jonloucks.concurrency; + requires transitive io.github.jonloucks.metalog; + requires transitive io.github.jonloucks.examples.messages; + requires transitive io.github.jonloucks.examples.common; + requires io.grpc.services; + requires io.grpc.stub; + requires io.grpc; + + uses io.github.jonloucks.contracts.api.ContractsFactory; + uses io.github.jonloucks.concurrency.api.ConcurrencyFactory; + uses io.github.jonloucks.metalog.api.MetalogFactory; + + provides io.github.jonloucks.example.client.ClientFactory with io.github.jonloucks.example.client.ClientFactoryImpl; + + exports io.github.jonloucks.example.client; +} \ No newline at end of file diff --git a/common/src/main/java/io/github/jonloucks/examples/common/Common.java b/common/src/main/java/io/github/jonloucks/examples/common/Common.java index d61c7d5..b2d8090 100644 --- a/common/src/main/java/io/github/jonloucks/examples/common/Common.java +++ b/common/src/main/java/io/github/jonloucks/examples/common/Common.java @@ -32,6 +32,8 @@ public static void install(Repository repository) { final BindStrategy strategy = IF_ALLOWED; + repository.keep(WEATHER, () -> "Weather report unavailable."); + // Constant string, but could be changed to a localized value without changing uses repository.keep(PROGRAM_NAME, () -> "Unnamed", strategy); diff --git a/common/src/main/java/io/github/jonloucks/examples/common/Constants.java b/common/src/main/java/io/github/jonloucks/examples/common/Constants.java index 2cfa672..0ba7fed 100644 --- a/common/src/main/java/io/github/jonloucks/examples/common/Constants.java +++ b/common/src/main/java/io/github/jonloucks/examples/common/Constants.java @@ -11,6 +11,11 @@ */ public final class Constants { + /** + * Example of a simple contract + */ + public static Contract WEATHER = Contract.create("Current Weather"); + /** * Constant string, but open for uses cases like localization. */ @@ -21,7 +26,6 @@ public final class Constants { */ public static final Contract PROGRAM = Contract.create("Program contract"); - /** * Main arguments */ diff --git a/common/src/main/java/io/github/jonloucks/examples/common/ProgramImpl.java b/common/src/main/java/io/github/jonloucks/examples/common/ProgramImpl.java index f0929ad..b19cf48 100644 --- a/common/src/main/java/io/github/jonloucks/examples/common/ProgramImpl.java +++ b/common/src/main/java/io/github/jonloucks/examples/common/ProgramImpl.java @@ -35,7 +35,9 @@ public void runCommandLine(OnCompletion onCompletion) { if (arguments.isEmpty()) { runCommand("help", emptyList(), onCompletion); } else { - runCommand(arguments.get(0), arguments.subList(1, arguments.size()), onCompletion); + for (String argument : arguments) { + runCommand(argument, emptyList(), onCompletion); + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7df5a26..1b17775 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,10 @@ [versions] -contracts-version = "[2.5.1,3.0.0)" -concurrency-version = "[1.3.0,2.0.0)" -metalog-version = "[1.2.2,2.0.0)" +contracts-version = "[2.5.2,3.0.0)" +concurrency-version = "[1.3.1,2.0.0)" +metalog-version = "[1.2.3,2.0.0)" +protobuf-plugin-version = "0.9.5" +grpc-version = "1.76.0" +protobuf-version = "3.25.8" [libraries] contracts-api = { module = "io.github.jonloucks.contracts:contracts-api", version.ref = "contracts-version" } @@ -13,3 +16,11 @@ concurrency = { module = "io.github.jonloucks.concurrency:concurrency", version. metalog-api = { module = "io.github.jonloucks.metalog:metalog-api", version.ref = "metalog-version" } metalog-test = { module = "io.github.jonloucks.metalog:metalog-test", version.ref = "metalog-version" } metalog = { module = "io.github.jonloucks.metalog:metalog", version.ref = "metalog-version" } +protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf-version" } +grpc-protoc = { module = "io.grpc:protoc-gen-grpc-java", version.ref = "grpc-version"} +grpc-protobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc-version"} +grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc-version"} +grpc-services = { module = "io.grpc:grpc-services", version.ref = "grpc-version"} +grpc-netty-shaded = { module = "io.grpc:grpc-netty-shaded", version.ref = "grpc-version"} +grpc-testing = { module = "io.grpc:grpc-testing", version.ref = "grpc-version"} +grpc-inprocess = { module = "io.grpc:grpc-inprocess", version.ref = "grpc-version"} diff --git a/messages/build.gradle b/messages/build.gradle new file mode 100644 index 0000000..1aca1e9 --- /dev/null +++ b/messages/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'java-library' + id "com.google.protobuf" version libs.versions.protobuf.plugin.version +} + +getTasks().withType(JavaCompile.class).configureEach { + getOptions().getRelease().set(9) +} + +protobuf { + protoc { + artifact = libs.protoc.get() + } + plugins { + grpc { + artifact = libs.grpc.protoc.get() + } + } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} + +repositories { + mavenCentral() + mavenLocal() +} + +dependencies { + implementation libs.grpc.protobuf + implementation libs.grpc.stub + implementation libs.grpc.services + runtimeOnly libs.grpc.netty.shaded + + testImplementation libs.grpc.testing + testImplementation libs.grpc.inprocess +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/messages/src/main/java/module-info.java b/messages/src/main/java/module-info.java new file mode 100644 index 0000000..410db59 --- /dev/null +++ b/messages/src/main/java/module-info.java @@ -0,0 +1,9 @@ +module io.github.jonloucks.examples.messages { + requires com.google.protobuf; + requires io.grpc; + requires io.grpc.stub; + requires io.grpc.protobuf; + requires com.google.common; + + exports io.github.jonloucks.examples.messages.weather; +} \ No newline at end of file diff --git a/messages/src/main/proto/examples/weather.proto b/messages/src/main/proto/examples/weather.proto new file mode 100644 index 0000000..08f969b --- /dev/null +++ b/messages/src/main/proto/examples/weather.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package io.github.jonloucks.examples.messages.weather; + +// The weather service definition. +service Weather { + // Sends a weather + rpc SayWeather (WeatherRequest) returns (WeatherReply); +} + +message WeatherRequest { + string id = 1; + repeated string location = 2; +} + +message WeatherReply { + string id = 1; + string location = 2; + string report = 4; +} \ No newline at end of file diff --git a/server/build.gradle b/server/build.gradle index d4d57b7..3e8b10c 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -15,10 +15,17 @@ dependencies { api libs.contracts.api api libs.concurrency.api api libs.metalog.api + api project(":common") implementation libs.contracts implementation libs.concurrency implementation libs.metalog + implementation project(":messages") + + implementation libs.grpc.protobuf + implementation libs.grpc.stub + implementation libs.grpc.services + runtimeOnly libs.grpc.netty.shaded testImplementation libs.contracts.test testImplementation libs.concurrency.test diff --git a/server/src/main/java/io/github/jonloucks/example/server/ConfigBuilderImpl.java b/server/src/main/java/io/github/jonloucks/example/server/ConfigBuilderImpl.java index 06deb1d..dee5f48 100644 --- a/server/src/main/java/io/github/jonloucks/example/server/ConfigBuilderImpl.java +++ b/server/src/main/java/io/github/jonloucks/example/server/ConfigBuilderImpl.java @@ -2,7 +2,10 @@ import io.github.jonloucks.contracts.api.Contracts; +import java.time.Duration; + import static io.github.jonloucks.contracts.api.Checks.contractsCheck; +import static io.github.jonloucks.contracts.api.Checks.nullCheck; final class ConfigBuilderImpl implements Server.Config.Builder { @@ -11,15 +14,39 @@ public Contracts contracts() { return contracts; } + @Override + public int port() { + return port; + } + + @Override + public Duration shutdownTimeout() { + return shutdownTimeout; + } + @Override public Builder contracts(Contracts contracts) { this.contracts = contractsCheck(contracts); return this; } + @Override + public Builder port(int port) { + this.port = port; + return this; + } + + @Override + public Builder shutdownTimeout(Duration timeout) { + this.shutdownTimeout = nullCheck(timeout, "Timeout must be present."); + return this; + } + ConfigBuilderImpl() { } private Contracts contracts; + private int port = Server.Config.DEFAULT.port(); + private Duration shutdownTimeout = Server.Config.DEFAULT.shutdownTimeout(); } diff --git a/server/src/main/java/io/github/jonloucks/example/server/Server.java b/server/src/main/java/io/github/jonloucks/example/server/Server.java index 8211b4c..dd203fe 100644 --- a/server/src/main/java/io/github/jonloucks/example/server/Server.java +++ b/server/src/main/java/io/github/jonloucks/example/server/Server.java @@ -7,6 +7,7 @@ import io.github.jonloucks.contracts.api.Contracts; import io.github.jonloucks.contracts.api.GlobalContracts; +import java.time.Duration; import java.util.function.Supplier; public interface Server extends AutoOpen { @@ -14,6 +15,10 @@ public interface Server extends AutoOpen { WaitableNotify lifeCycleNotify(); + Idempotent getLifeCycleState(); + + String getWeatherReport(); + interface Config { /** * The default configuration used when creating a new Server instance @@ -55,10 +60,22 @@ default Class serviceLoaderClass() { return ServerFactory.class; } + default int port() { + return 50052; + } + + default Duration shutdownTimeout() { + return Duration.ofSeconds(60); + } + interface Builder extends Config { Contract> FACTORY = Contract.create("Server Config Builder Factory"); Builder contracts(Contracts contracts); + + Builder port(int port); + + Builder shutdownTimeout(Duration shutdownTimeout); } } } diff --git a/server/src/main/java/io/github/jonloucks/example/server/ServerImpl.java b/server/src/main/java/io/github/jonloucks/example/server/ServerImpl.java index 47b497a..62b2d1b 100644 --- a/server/src/main/java/io/github/jonloucks/example/server/ServerImpl.java +++ b/server/src/main/java/io/github/jonloucks/example/server/ServerImpl.java @@ -5,11 +5,20 @@ import io.github.jonloucks.concurrency.api.WaitableNotify; import io.github.jonloucks.contracts.api.AutoClose; import io.github.jonloucks.contracts.api.Repository; +import io.grpc.Grpc; +import io.grpc.InsecureServerCredentials; +import io.grpc.health.v1.HealthCheckResponse; +import io.grpc.protobuf.services.HealthStatusManager; +import io.grpc.protobuf.services.ProtoReflectionServiceV1; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; import static io.github.jonloucks.concurrency.api.Idempotent.withClose; import static io.github.jonloucks.concurrency.api.Idempotent.withOpen; import static io.github.jonloucks.contracts.api.Checks.configCheck; import static io.github.jonloucks.contracts.api.Checks.nullCheck; +import static java.util.Optional.ofNullable; final class ServerImpl implements Server { @@ -23,6 +32,16 @@ public WaitableNotify lifeCycleNotify() { return stateMachine; } + @Override + public Idempotent getLifeCycleState() { + return stateMachine.getState(); + } + + @Override + public String getWeatherReport() { + return ""; + } + ServerImpl(Server.Config config, Repository repository, boolean openRepository) { this.config = configCheck(config); final Repository validRepository = nullCheck(repository, "Repository must be present."); @@ -31,6 +50,18 @@ public WaitableNotify lifeCycleNotify() { } private AutoClose realOpen() { + try { + grpcServer = Grpc.newServerBuilderForPort(config.port(), InsecureServerCredentials.create()) + .addService(new WeatherServiceImpl()) + .addService(ProtoReflectionServiceV1.newInstance()) + .addService(health.getHealthService()) + .build() + .start(); + health.setStatus("", HealthCheckResponse.ServingStatus.SERVING); + } catch (IOException e) { + throw new ServerException("Failed to start server.", e); + } + return this::exposedClose; } @@ -39,10 +70,22 @@ private void exposedClose() { } private void realClose() { + ofNullable(grpcServer).ifPresent(x -> { + x.shutdown(); + try { + x.awaitTermination(config.shutdownTimeout().toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + x.shutdownNow(); + }); closeRepository.close(); } private final Server.Config config; private final AutoClose closeRepository; private final StateMachine stateMachine; + + private final HealthStatusManager health = new HealthStatusManager(); + private io.grpc.Server grpcServer; } diff --git a/server/src/main/java/io/github/jonloucks/example/server/WeatherServiceImpl.java b/server/src/main/java/io/github/jonloucks/example/server/WeatherServiceImpl.java new file mode 100644 index 0000000..e872cf6 --- /dev/null +++ b/server/src/main/java/io/github/jonloucks/example/server/WeatherServiceImpl.java @@ -0,0 +1,23 @@ +package io.github.jonloucks.example.server; + +import io.github.jonloucks.examples.messages.weather.WeatherOuterClass.WeatherReply; +import io.github.jonloucks.examples.messages.weather.WeatherOuterClass.WeatherRequest; +import io.grpc.stub.StreamObserver; + +import static io.github.jonloucks.examples.messages.weather.WeatherGrpc.*; + +final class WeatherServiceImpl extends WeatherImplBase { + + @Override + public void sayWeather(WeatherRequest request, StreamObserver responseObserver) { + + // Generate a greeting message for the original method + WeatherReply reply = WeatherReply.newBuilder().setReport("Mostly Sunny").build(); + + // Send the reply back to the client. + responseObserver.onNext(reply); + + // Indicate that no further messages will be sent to the client. + responseObserver.onCompleted(); + } +} diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index 4c2f498..888ceb1 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -2,6 +2,11 @@ requires transitive io.github.jonloucks.contracts; requires transitive io.github.jonloucks.concurrency; requires transitive io.github.jonloucks.metalog; + requires transitive io.github.jonloucks.examples.messages; + requires transitive io.github.jonloucks.examples.common; + requires io.grpc.services; + requires io.grpc.stub; + requires io.grpc; uses io.github.jonloucks.contracts.api.ContractsFactory; uses io.github.jonloucks.concurrency.api.ConcurrencyFactory; diff --git a/settings.gradle b/settings.gradle index ff37d8e..c30fce9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,6 @@ rootProject.name = 'examples' +include 'messages' include 'common' +include 'server' -include 'server' \ No newline at end of file +include 'client' \ No newline at end of file diff --git a/src/main/java/io/github/jonloucks/examples/ClientCommand.java b/src/main/java/io/github/jonloucks/examples/ClientCommand.java new file mode 100644 index 0000000..2361d9d --- /dev/null +++ b/src/main/java/io/github/jonloucks/examples/ClientCommand.java @@ -0,0 +1,29 @@ +package io.github.jonloucks.examples; + +import io.github.jonloucks.example.client.Client; +import io.github.jonloucks.examples.common.Command; + +import java.util.List; + +import static io.github.jonloucks.contracts.api.GlobalContracts.claimContract; +import static io.github.jonloucks.examples.common.Constants.WEATHER; + +final class ClientCommand implements Command { + @Override + public String execute(List arguments) { + final Client client = claimContract(Client.CONTRACT); + final String report1 = client.getWeatherReport(); + final String report2 = claimContract(WEATHER); + + if (report1.equals(report2)) { + return report1; + } else { + return "Conflicting reports : " + report1 + " and " + report2; + } + } + + @Override + public String getName() { + return "client"; + } +} diff --git a/src/main/java/io/github/jonloucks/examples/Main.java b/src/main/java/io/github/jonloucks/examples/Main.java index e705ac3..386e2f3 100644 --- a/src/main/java/io/github/jonloucks/examples/Main.java +++ b/src/main/java/io/github/jonloucks/examples/Main.java @@ -2,6 +2,9 @@ import io.github.jonloucks.contracts.api.AutoClose; import io.github.jonloucks.contracts.api.Repository; +import io.github.jonloucks.example.client.Client; +import io.github.jonloucks.example.client.ClientFactory; +import io.github.jonloucks.example.client.ClientFactoryFinder; import io.github.jonloucks.example.server.Server; import io.github.jonloucks.example.server.ServerFactory; import io.github.jonloucks.example.server.ServerFactoryFinder; @@ -9,6 +12,7 @@ import io.github.jonloucks.examples.common.Program; import java.util.Arrays; +import java.util.function.IntConsumer; import static io.github.jonloucks.contracts.api.BindStrategy.ALWAYS; import static io.github.jonloucks.contracts.api.GlobalContracts.claimContract; @@ -17,6 +21,17 @@ public final class Main { + /** + * For testing the smoke application + * @param consumer for the exit code + * @return the previous system exit method + */ + public static IntConsumer setSystemExit(IntConsumer consumer) { + final IntConsumer save = SYSTEM_EXIT; + SYSTEM_EXIT = consumer; + return save; + } + /** * Main entry point. * Note. Entry points are where final decisions on dependency inversions are made. @@ -25,21 +40,28 @@ public final class Main { * @param args the command line arguments */ public static void main(String[] args) { + try { + innerMain(args); + SYSTEM_EXIT.accept(0); + } catch (Exception thrown) { + System.err.println(thrown.getMessage()); + SYSTEM_EXIT.accept(1); + } + } + + private static void innerMain(String[] args) { final Repository repository = createMyRepository(args); try (AutoClose closeRepository = repository.open()) { final Program program = claimContract(PROGRAM); installCommand(program); - program.runCommandLine( c -> {}); + program.runCommandLine(c -> {}); waitForQuitting(); - System.exit(0); - } catch (Exception thrown) { - System.err.println(thrown.getMessage()); - System.exit(1); } } private static void installCommand(Program program) { program.keepCommand(new ServerCommand()); + program.keepCommand(new ClientCommand()); } /** @@ -60,6 +82,7 @@ private static Repository createMyRepository(String[] args) { Common.install(repository); installServer(repository); + installClient(repository); // Save the command line for later use repository.keep(PROGRAM_ARGUMENTS, () -> Arrays.asList(args), ALWAYS); @@ -76,7 +99,15 @@ private static void installServer(Repository repository) { serverFactory.install(Server.Config.DEFAULT, repository); } + private static void installClient(Repository repository) { + final ClientFactoryFinder finder = new ClientFactoryFinder(Client.Config.DEFAULT); + final ClientFactory serverFactory = finder.get(); + serverFactory.install(Client.Config.DEFAULT, repository); + } + private Main() { } + + private static IntConsumer SYSTEM_EXIT = System::exit; } diff --git a/src/main/java/io/github/jonloucks/examples/ServerCommand.java b/src/main/java/io/github/jonloucks/examples/ServerCommand.java index aa1a6c0..160d750 100644 --- a/src/main/java/io/github/jonloucks/examples/ServerCommand.java +++ b/src/main/java/io/github/jonloucks/examples/ServerCommand.java @@ -2,20 +2,17 @@ import io.github.jonloucks.example.server.Server; import io.github.jonloucks.examples.common.Command; -import io.github.jonloucks.examples.common.Common; import java.util.List; -import static io.github.jonloucks.concurrency.api.Idempotent.CLOSED; import static io.github.jonloucks.contracts.api.GlobalContracts.claimContract; final class ServerCommand implements Command { @Override public String execute(List arguments) { final Server server = claimContract(Server.CONTRACT); - //noinspection resource, scope is full life of server - server.lifeCycleNotify().notifyIf(s -> s == CLOSED, s -> Common.setQuitting()); - return "Server started"; + + return "Server is " + (server.getLifeCycleState().isRejecting() ? "rejecting requests" : "excepting requests"); } @Override diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 1f5de32..31072d3 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -4,11 +4,14 @@ requires transitive io.github.jonloucks.metalog; requires transitive io.github.jonloucks.examples.common; requires transitive io.github.jonloucks.example.server; - + requires transitive io.github.jonloucks.example.client; + requires transitive io.github.jonloucks.examples.messages; + uses io.github.jonloucks.contracts.api.ContractsFactory; uses io.github.jonloucks.concurrency.api.ConcurrencyFactory; uses io.github.jonloucks.metalog.api.MetalogFactory; uses io.github.jonloucks.example.server.ServerFactory; + uses io.github.jonloucks.example.client.ClientFactory; exports io.github.jonloucks.examples; } \ No newline at end of file