Sometimes you're working between two systems, running in different processes, based on different programming languages. If you want to get instructions or data across those two, you'll need a layer that handles communication.
Naturally, you'd rather spend your time building the actual thing rather than coming up with that layer yourself, so today I'm writing a guide covering my recent experience of sending instructions to, and receiving data from a Java service, while a Go service is in control.
First, we'll prepare the context so you understand why I built it in the first place, then we'll learn about Protocol Buffers (Protobuf) and gRPC, where they come into play, and how they can solve the issue.
Offloading control over the Java environment to Go
In the last days, I've been toying around with Minecraft plugins again, which are written in Java, packaged into a jar, and moved into a specific directory, where the server expects them to be. Then they're loaded into the runtime, and the plugin can hook itself up to what it's interested in. I don't want to focus on the actual plugin in this post, but rather the architecture behind it.
Minecraft servers are notoriously heavy on resources, and so the fewer tasks and logic the plugin needs to contain, the better. I also prefer other systems to Java, which I rarely use. Combining all of this, I realized that I wanted to move all the business logic out of the plugin, into an external operator service (running in the same environment), while the plugin would be the interface to the server internals.
This is an extremely powerful setup, as you can control almost everything related to the server and gameplay happening on it, without touching any internals, rewriting it in another language, or other magic.
When thinking about how I'd solve the issue of connecting the services, I immediately thought about gRPC, and I'll continue to explain what that is, and why it will work wonders here.
The transport layer: Protocol Buffers and gRPC
While you could just expose an HTTP endpoint that allows hooking into the Java process, that would be a lot of code to write. What if we could define actions and data our plugin could expose, and generate code for it? Now we're in a better position because Google has us covered.
Throughout most of the container and Kubernetes ecosystem, gRPC is widely known as the layer that handles communication between services. As with any RPC (remote procedure call) system, it allows invoking specific functionality in a service, providing an interface to do so.
gRPC is not just any interface though, it's based on protocol buffers (Protobuf), which provide a structured way of defining the operations and data passed around, and handling serialization in different languages.
In gRPC, you'd define capabilities a service offers like this
// We'll use v3 of protobuf in this guide
syntax = "proto3";
service HelloService {
rpc SayHello (HelloRequest) returns (HelloResponse);
}
message HelloRequest {
string greeting = 1;
}
message HelloResponse {
string reply = 1;
}
There are different versions of the Protocol Buffer language in circulation, we'll use the newer v3 throughout this guide.
As you can see, we've defined a service that exposes a SayHello
function which will receive a HelloRequest
message and return a HelloResponse
message. For the messages, we include fields that specify the data they include. Each field is assigned a number used while in binary transport, which should never be changed once in use.
There's much more to protocol buffers and gRPC, but for now, we'll stick with the following structure (connector.proto
)
syntax = "proto3";
package com.brunoscheufler.connector;
service Connector {
rpc GetPlayerCount(GetPlayerCountRequest) returns (GetPlayerCountResponse) {}
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse) {}
}
message SendMessageRequest {
string message = 1;
}
message SendMessageResponse {
bool ok = 1;
}
message GetPlayerCountRequest {}
message GetPlayerCountResponse {
int64 count = 1;
}
This service allows to retrieve the current player count and send a message to all players. For retrieving the player count, we don't add any field to the request.
Setting up gRPC
In the following section, we'll cover how to set up gRPC, so that it generates code for client and server usage. We want to access the Java-based gRPC server from the Go-based gRPC client later on.
We'll copy an instance of the connector.proto
file defined before in each service. Usually, it's better to use a single instance so you're sure the schema matches, but for this guide, we'll just duplicate it and keep it in sync.
In the connector plugin (Java)
As my environment is using Maven already, I've set up grpc-java with Maven.
-
Create an empty project with Maven. This should yield a
src/main
directory tree andpom.xml
to exist in the root directory. -
Copy over protobuf file into
src/main/proto/connector.proto
. At the top, we'll declare and paste the rest of the file.option java_multiple_files = true; // Rest of connector.proto from above
-
In our
pom.xml
file, we'll declare the dependencies we need for gRPC and extend the build process by adding our Protobuf compilation, so we receive a generated server interface. The most important parts aredependencies
andbuild
plugins and the extension foros-maven-plugin
. The rest might look different in your environment, but make sure that the required dependencies and plugins are in place.<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.brunoscheufler</groupId> <artifactId>connector</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>16</maven.compiler.source> <maven.compiler.target>16</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>io.grpc</groupId> <artifactId>grpc-netty-shaded</artifactId> <version>1.40.1</version> </dependency> <dependency> <groupId>io.grpc</groupId> <artifactId>grpc-protobuf</artifactId> <version>1.40.1</version> </dependency> <dependency> <groupId>io.grpc</groupId> <artifactId>grpc-stub</artifactId> <version>1.40.1</version> </dependency> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>annotations-api</artifactId> <version>6.0.53</version> <scope>provided</scope> </dependency> </dependencies> <build> <extensions> <extension> <groupId>kr.motd.maven</groupId> <artifactId>os-maven-plugin</artifactId> <version>1.6.2</version> </extension> </extensions> <plugins> <plugin> <groupId>org.xolstice.maven.plugins</groupId> <artifactId>protobuf-maven-plugin</artifactId> <version>0.6.1</version> <configuration> <protocArtifact>com.google.protobuf:protoc:3.17.3:exe:${os.detected.classifier}</protocArtifact> <pluginId>grpc-java</pluginId> <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.40.1:exe:${os.detected.classifier}</pluginArtifact> </configuration> <executions> <execution> <goals> <goal>compile</goal> <goal>compile-custom</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project>
Once we've got it all in place, we can compile (mvn compile
) our source code, to generate the ConnectorGrpc
class.
This includes everything we need to construct our endpoint, and implement the functions we declared.
In the operator service (Go)
You'll have to set up a new service with Go modules enabled (go mod init operator
).
-
Install the Protobuf compiler (
protoc
) to your systembrew install protobuf
-
Install the following plugins for the Protobuf compiler, required for generating the Go representation of our structure and gRPC integration for Go
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26 $ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
-
Create a new package for our
grpc
code. Copy over theconnector.proto
file and make some adjustments to the top, add thego_package
variable to make sure the generated code is placed in thegrpc
package.option go_package = "operator/grpc"; // Copy over the rest of connector.proto from above
-
Add a comment for go generate to invoke the Protobuf compiler. This language toolchain feature comes in handy now, as we can just add the complete command next to our code.
package grpc //go:generate protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative ./connector.proto type Connector struct{} func NewGRPCConnector() *Connector { return &Connector{} }
Now you can run go generate
in your Go service, which will generate connector.pb.go
and connector_grpc.pb.go
in the grpc
package you defined in connector.proto
.
This completes the setup part, now we can continue to implement the gRPC server and client.
Implementing the connector (Java, gRPC server)
For our connector plugin, we'll import the following libraries.
package com.brunoscheufler.connector;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.IOException;
import java.util.logging.Logger;
While we have the generated source code for our gRPC endpoint, we don't yet have any business logic that corresponds to it, so none of our calls are implemented. We can add an implementation by extending the base implementation and overriding the methods that were generated.
You'll notice the responseObserver
parameter of type StreamObserver
, which allows sending back one or more values over time as the response. As our service only deals with unary methods (i.e. 1 request, 1 response), we respond with one response and mark the stream observer as completed.
To retrieve the count of online players, we'll simply fetch that data from the plugin instance passed in our constructor to be a property of the implementation class. Our sendMessage
implementation even includes some basic error handling, as we'll terminate the stream with an INVALID_ARGUMENT
error in case an empty message should be sent.
class ConnectorImpl extends ConnectorGrpc.ConnectorImplBase {
private final JavaPlugin p;
public ConnectorImpl(JavaPlugin p) {
this.p = p;
}
@Override
public void getPlayerCount(GetPlayerCountRequest request, StreamObserver<GetPlayerCountResponse> responseObserver) {
GetPlayerCountResponse res = GetPlayerCountResponse
.newBuilder()
.setCount(p.getServer().getOnlinePlayers().size())
.build();
responseObserver.onNext(res);
responseObserver.onCompleted();
}
@Override
public void sendMessage(SendMessageRequest request, StreamObserver<SendMessageResponse> responseObserver) {
String message = request.getMessage();
if (message.equals("")) {
responseObserver
.onError(
Status
.INVALID_ARGUMENT
.withDescription("Missing message text")
.asException()
);
return;
}
for (Player onlinePlayer : p.getServer().getOnlinePlayers()) {
onlinePlayer.sendMessage(request.getMessage());
}
responseObserver.onNext(SendMessageResponse.newBuilder().setOk(true).build());
responseObserver.onCompleted();
}
}
Once we receive the hook to start our plugin, we'll also start the gRPC endpoint on port 9090
. This server is configured with the implementation of our connector service, added using .addService
.
public class Connector extends JavaPlugin {
private final Logger logger = this.getLogger();
private final Server server = this.getServer();
private final PluginManager pluginManager = server.getPluginManager();
private final io.grpc.Server grpcServer = ServerBuilder
.forPort(9090)
.addService(new ConnectorImpl(this))
.build();
@Override
public void onEnable() {
try {
grpcServer.start();
logger.info(String.format("Running gRPC endpoint at :9090"));
} catch (IOException e) {
logger.severe(e.toString());
}
}
@Override
public void onDisable() {
try {
grpcServer.shutdown().awaitTermination();
} catch (InterruptedException e) {
logger.severe(e.toString());
}
}
}
Once the server is ready, we can start it (usually it's compiled and packaged into a JAR with mvn package
, then moved into the plugins
directory of our test server) by running the server JAR. We can even debug it in IntelliJ that way.
Implementing the operator (Go, gRPC client)
With our gRPC server in place and running, we can use the Go client to invoke remote functions such as sending a message.
First, we'll implement a dial
method on the connector that establishes a gRPC connection, so we can reuse it in other places.
func (c *Connector) dial() (*grpc.ClientConn, error) {
// Set up a connection to the server.
conn, err := grpc.Dial("localhost:9090", grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
return nil, fmt.Errorf("could not dial grpc: %w", err)
}
return conn, nil
}
With the dial function in place, we can now create the client generated by gRPC. The methods our connector struct implements are merely a wrapper that manages the connection lifecycle, which could even be reused in cases where we have to call the function a lot.
func (c *Connector) SendMessage(ctx context.Context, message string) error {
conn, err := c.dial()
if err != nil {
return fmt.Errorf("could not dial: %w", err)
}
defer conn.Close()
client := NewConnectorClient(conn)
_, err = client.SendMessage(ctx, &SendMessageRequest{Message: message})
if err != nil {
responseStatus := status.Convert(err)
if responseStatus == nil {
return fmt.Errorf("could not send message: %w", err)
}
return fmt.Errorf("could not send message, received status %q: %s", responseStatus.Code(), responseStatus.Message())
}
return nil
}
Later on, we can send a message with
message := "hello world"
connector := grpc.NewGRPCConnector()
err := connector.SendMessage(ctx, message)
// Handle error, etc.
When you swap the message to an empty string, you'll receive an error, which we defined in our service implementation in Java. Pretty neat, huh?
could not send message, received status "InvalidArgument": Missing message text
That about does it for our example. We've successfully
- defined our service and message structure in Protobuf
- set up our Java environment to generate Java code given the
.proto
file - set up our Go environment to generate Go code given the
.proto
file - implemented our gRPC server in Java
- implemented our gRPC client in Go
There's more to gRPC, such as performance improvements to add, reusing connections is just one possible optimization.