First Exonum Java Service Tutorial

This tutorial is aimed at beginners in Exonum Java. It is an introduction into service development in Java. You will learn how to create an Exonum service, start a test network, and deploy your service in it. This introductory tutorial, however, omits some important to real-world services topics, like authorization, or proofs of authenticity for service data, or testing, but gives a solid foundation to learn about that in subsequent materials.

The full tutorial code is available in our Git repository.

Prerequisites

It is recommended to read an introduction into Exonum and its design overview before proceeding with this tutorial. The reader is expected to be familiar with the Java programming language.

The following software must be installed:

Service Overview

Blockchains are often used to implement secure registries. In this tutorial, we will implement a vehicle registry. The registry will keep a record of vehicles; and allow adding new vehicles and changing their owner. It will also provide REST API to query the registry.

Service Implementation

Creating a Project

Generate from Template

First, open a terminal and run the following command to create a new service project with a Maven archetype:

mvn archetype:generate \
    -DinteractiveMode=false \
    -DarchetypeGroupId=com.exonum.binding \
    -DarchetypeArtifactId=exonum-java-binding-service-archetype \
    -DarchetypeVersion=0.10.0 \
    -DgroupId=com.example.car \
    -DartifactId=car-registry \
    -Dversion=1.0.0

We use the following values for service project properties:

  • com.example.car for groupId
  • car-registry for artifactId
  • 1.0.0 for version.

Verify that the project has been generated correctly and the dependencies are installed by running its integration tests:

cd car-registry/
mvn verify

You shall see in the output that com.example.car.MyServiceIntegrationTest completed successfully and the build passes.

Getting a build error?

If you get java.lang.LinkageError in the test, check that Exonum Java is installed correctly. Particularly, check that EXONUM_HOME environment variable is set to the Exonum Java installation location. See the installation instructions for details.

If you get a compilation error invalid flag: --release, Maven likely uses Java 8 to compile the project. Check:

  • That the Java on PATH is 11 or above: java -version
  • That the JAVA_HOME environment variable is unset; or points to a JDK installation 11 or above: echo "$JAVA_HOME"

Skeleton Project Overview

The generated project is a mere "skeleton". It consists of two modules:

  • car-registry-messages for the definitions of service messages.
  • car-registry-service for the service business logic.

Declare Service Persistent Data

Tip

Import the project into your IDE if you haven't already.

Our vehicle registry needs to store the following information about a vehicle:

  • An identifier
  • A manufacturer (e.g., "Ford")
  • A model (e.g., "Focus")
  • Its owner (e.g., "Dave").

Let's define the car object. We will use a protobuf message, because Exonum stores objects in a serialized form. Create a vehicle.proto in car-registry-messages (car-registry-messages/src/main/proto/example/vehicle):

syntax = "proto3";

package example.vehicle;

option java_package = "com.example.car.messages";

message Vehicle {
  string id = 1;
  string make = 2;
  string model = 3;
  string owner = 4;
}

Run mvn generate-sources to compile the message.


Next, we will define the persistent data of our service. Exonum Services define their persistent data in a schema: a set of named, persistent, typed collections, also known as indexes. Our project already has a template schema MySchema — navigate to it.

The MySchema has a field access of type Prefixed, initialized in its constructor. It is a database access object, which allows to access the indexes of this service.

To keep a registry of vehicles indexed by their IDs, we will use a ProofMap index with String keys and Vehicle values, named vehicles. The ProofMap ensures that the data is the same on each node in the network. We will expose our index through a factory method — a method that provides access to the ProofMap. Use Access.getProofMap method to access the vehicles index:

/**
 * Provides access to the current state of the vehicles registry.
 */
public ProofMapIndexProxy<String, Vehicle> vehicles() {
  var address = IndexAddress.valueOf("vehicles");
  var keySerializer = StandardSerializers.string();
  var valueSerializer = StandardSerializers.protobuf(Vehicle.class);
  return access.getProofMap(address, keySerializer, valueSerializer);
}

Notice that the access.getProofMap accepts three parameters:

  • an index address identifying this index in the blockchain
  • two serializers: one for keys and one for values. Exonum needs the serializers to convert objects into bytes and back, as it stores the objects as bytes. For String keys, we use a standard serializer. For Vehicles, which are Protocol Buffers messages, we use a corresponding serializer for messages of Vehicle type.

Tip

As the StandardSerializers.protobuf uses reflection to look up the needed methods in the message class, it is recommended to instantiate a protobuf serializer once for each type and keep it in a static field, e.g.:

private static final Serializer<Vehicle> VEHICLE_SERIALIZER =
    StandardSerializers.protobuf(Vehicle.class);
Cannot import Vehicle class?

In some IDEs, e.g. IntelliJ IDEA, it might be needed to manually mark car-registry-messages/target/generated-sources/protobuf/java directory as "Generated Sources Root" to be able to import the generated classes:

  1. Open context menu (right click on the directory)
  2. "Mark Directory As" > "Generated Sources Root"

Compile the project:

mvn compile

Service Transactions

Our service needs two operations updating its state:

  • Add a new vehicle entry to the registry
  • Transfer the ownership over the vehicle to another user.

Modifying operations in Exonum are called transactions. Transactions are implemented as methods in a service class — a class implementing Service interface. A transaction method must be annotated with @Transaction annotation.

Our project already has a template service named MyService.

Add Vehicle Transaction

First, let's define the transaction arguments. As Exonum expects transaction arguments in a serialized form, we will define the arguments as a protobuf message.

Add a new file transactions.proto in car-registry-messages (car-registry-messages/src/main/proto/example/vehicle).

Then add the message definition of the Add Vehicle transaction:

import "example/vehicle/vehicle.proto";

message AddVehicle {
  Vehicle new_vehicle = 1;
}

Compile the message:

mvn generate-sources

Next, let's write the transaction method. Navigate to MyService and add the following method:

@Transaction(ADD_VEHICLE_TX_ID)
public void addVehicle(Transactions.AddVehicle args, ExecutionContext context) {
  var serviceData = context.getServiceData();
  var schema = new MySchema(serviceData);
  ProofMapIndexProxy<String, Vehicle> vehicles = schema.vehicles();

  // Check there is no vehicle with such id in the registry yet
  var newVehicle = args.getNewVehicle();
  var id = newVehicle.getId();
  if (vehicles.containsKey(id)) {
    var existingVehicle = vehicles.get(id);
    var errorDescription = String
        .format("The registry already contains a vehicle with id (%s): "
            + "existing=%s, new=%s", id, existingVehicle, newVehicle);
    throw new ExecutionException(ID_ALREADY_EXISTS_ERROR_CODE, errorDescription);
  }

  // Add the vehicle to the registry
  vehicles.put(id, newVehicle);
}

The annotation accepts an integral transaction ID: an identifier of the transaction that service clients use to invoke it.

The method accepts AddVehicle class: an auto-generated class from our protobuf definition. It also accepts the ExecutionContext allowing the transaction to access the database.

The transaction implementation uses the service data schema MySchema to access the service data.

Note that the transaction throws an ExecutionException in case of a precondition failure: in this case, an attempt to add a vehicle with an existing ID. When Exonum catches an ExecutionException, it rolls back any changes made by this transaction and records its execution status as erroneous.

Compile the code:

mvn compile

Transfer the Ownership Transaction

Let's add the second transaction which will change the owner of a vehicle in the registry. It needs as its arguments the ID of the vehicle; and a new owner.

Navigate to transactions.proto and add a message with the arguments:

message ChangeOwner {
  string id = 1;
  string new_owner = 2;
}

Compile the message:

mvn generate-sources

Then navigate to the service MyService and add an implementation with appropriate constants:

@Transaction(CHANGE_OWNER_TX_ID)
public void changeOwner(Transactions.ChangeOwner args,
    ExecutionContext context) {
  var serviceData = context.getServiceData();
  var schema = new MySchema(serviceData);
  ProofMapIndexProxy<String, Vehicle> vehicles = schema.vehicles();

  // Check the vehicle with such ID exists
  var id = args.getId();
  if (!vehicles.containsKey(id)) {
    throw new ExecutionException(NO_VEHICLE_ERROR_CODE,
        "No vehicle with such id: " + id);
  }

  // Update the vehicle entry
  // Get the current entry
  var vehicleEntry = vehicles.get(id);
  // Update the owner
  var newOwner = args.getNewOwner();
  var updatedVehicleEntry = Vehicle.newBuilder(vehicleEntry)
      .setOwner(newOwner)
      .build();
  // Write it back to the registry
  vehicles.put(id, updatedVehicleEntry);
}

This transaction is similar to the first one.

Notice how an update of the owner field in an existing Vehicle value is performed. It creates a builder from the existing object with Vehicle.newBuilder(templateVehicle) method, updates the field, and builds a new object.

Compile the code:

mvn compile

Service Constructor

Finally, we will add a service constructor — a service method that is invoked once when the service is instantiated. We will use it to populate our registry with some test data.

It is implemented as the Service#initialize method. By default, it has an empty implementation in the interface, hence it is not yet present in MyService.

Override the Service#initialize with the following implementation:

@Override
public void initialize(ExecutionContext context,
    Configuration configuration) {
  var testVehicles =
      List.of(vehicleArgs("V1", "Ford", "Focus", "Dave"),
          vehicleArgs("V2", "DMC", "DeLorean", "Emmett Brown"),
          vehicleArgs("V3", "Peugeot", "406", "Daniel Morales"),
          vehicleArgs("V4", "McLaren", "P1", "Weeknd"));
  for (var vehicle : testVehicles) {
    addVehicle(vehicle, context);
  }
}

private static Transactions.AddVehicle vehicleArgs(String id, String make,
    String model, String owner) {
  return Transactions.AddVehicle.newBuilder()
      .setNewVehicle(
          Vehicle.newBuilder()
              .setId(id)
              .setMake(make)
              .setModel(model)
              .setOwner(owner)
              .build())
      .build();
}

Note that this method delegates to the addVehicle transaction method we have added earlier.

Success

In this section we have learned how to implement operations modifying the blockchain state: transactions and the service constructor. There are more operations of such type described in Service Hooks section.

Service API

In the previous section we have implemented operations modifying the blockchain state. Applications usually also need a means to query data. In this section, we will add an operation to retrieve a vehicle entry by its ID from the registry. This operation will be exposed through REST API.

Find Vehicle Service Operation

First, we need to add a query operation to the Service:

/**
 * Returns a vehicle with the given id, if it exists; or {@code Optional.empty()}
 * if it does not.
 */
public Optional<Vehicle> findVehicle(String id, BlockchainData blockchainData) {
  var schema = new MySchema(blockchainData.getExecutingServiceData());
  var vehicles = schema.vehicles();
  return Optional.ofNullable(vehicles.get(id));
}

Although this query method will be invoked by our code, hence the signature we use may be arbitrary, the signature is similar to the transaction methods: it takes the operation arguments and the context (here: String ID and BlockchainData context).

API Controller

Next, we will add a class implementing the REST API of the service. It will expose our "find vehicle" operation as a GET method.

Create a new ApiController class:

final class ApiController {

  private final MyService service;
  private final Node node;

  ApiController(MyService service, Node node) {
    this.service = service;
    this.node = node;
  }

  void mount(Router router) {
    router.get("/vehicle/:id").handler(this::findVehicle);
  }

  private void findVehicle(RoutingContext routingContext) {
    // Extract the requested vehicle ID
    var vehicleId = routingContext.pathParam("id");
    // Find it in the registry. The Node#withBlockchainData provides
    // the required context with the current, immutable database state.
    var vehicleOpt = node.withBlockchainData(
        (blockchainData) -> service.findVehicle(vehicleId, blockchainData));
    if (vehicleOpt.isPresent()) {
      // Respond with the vehicle details
      var vehicle = vehicleOpt.get();
      routingContext.response()
          .putHeader("Content-Type", "application/octet-stream")
          .end(Buffer.buffer(vehicle.toByteArray()));
    } else {
      // Respond that the vehicle with such ID is not found
      routingContext.response()
          .setStatusCode(HTTP_NOT_FOUND)
          .end();
    }
  }
}

The ApiController needs a MyService object to query data; and a Node object to obtain the needed context: BlockchainData. It uses Vert.x Web to define the endpoints.

Note

We encode the response in Protocol Buffers binary format for brevity. The controller may encode it in any suitable format (e.g., JSON).

Finally, connect the controller to the service. MyService already has an empty createPublicApiHandlers method, modify it to have:

@Override
public void createPublicApiHandlers(Node node, Router router) {
  var apiController = new ApiController(this, node);
  apiController.mount(router);
}

Success

That's it with the service implementation! Package the service artifact and run the integration tests:

mvn verify

and then proceed to the next section, where we will test its operation.

Test Network

Let's now launch a test network in which we can see our service operation. Our project already has a script launching a test network with a single validator node: start-testnet.sh.

Run the script:

chmod 744 start-testnet.sh # Allow the script execution, needed once
./start-testnet.sh

When you see messages like the following, the network is active:

[2020-03-05T10:36:24Z INFO  exonum_node::consensus] COMMIT ====== height=4, proposer=0, round=1, committed=0, pool=0, hash=43ac20f8b...

Open a separate shell session and check the active services:

# You may pipe the response into `jq` to pretty-print it, if you have it
# installed:
curl -s http://127.0.0.1:3000/api/services/supervisor/services # | jq

You can see in the output the lists of deployed service artifacts and service instances. However, the network has neither our service artifact nor an instance of our service. That is natural, because the service must be registered in the network first, and then it may be instantiated.

Service Instantiation

Install the Java Launcher

To register a service artifact that we built previously in the network, we will need exonum-launcher tool. It is a Python application which we recommend to install in a virtual environment:

python3 -m venv .venv
source .venv/bin/activate

Then install the exonum-launcher with the Java runtime support:

pip install exonum-launcher-java-plugins

Check it works:

python -m exonum_launcher --help

Start a Test Instance

Next, we shall place the service artifact into an artifacts directory of the node: testnet/artifacts.

# Create the artifacts directory
mkdir testnet/artifacts
# Copy the artifact
cp car-registry-service/target/car-registry-service-1.0.0-artifact.jar \
   testnet/artifacts/

Launch the service:

python -m exonum_launcher -i deploy-start-config.yml

Launcher will take the service instance name and other parameters from the configuration file, and submit the request to the node. The launcher must print the status of the service artifact deploy and the service instance start. We can also verify that both operations succeeded via the node API:

curl -s http://127.0.0.1:3000/api/services/supervisor/services | jq

Invoke the Service Operations

We will use a light client application to invoke the service operations. Development of service client applications is not covered in this tutorial, but the client for the car registry is provided in the tutorial repository as a third module, car-registry-client.

If you have not cloned the repository already, clone it and build the client:

git clone [email protected]:exonum/exonum-java-binding.git
cd exonum-java-binding/exonum-java-binding/tutorials/car-registry
mvn package -pl car-registry-client -am

Check it is built successfully:

java -jar car-registry-client/target/car-registry-client-1.0.0.jar -h

First, generate an Ed25519 key pair, that the client will use to sign the transactions to our service:

java -jar car-registry-client/target/car-registry-client-1.0.0.jar \
  keygen

Now, try to submit transactions adding your own vehicles to the blockchain:

java -jar car-registry-client/target/car-registry-client-1.0.0.jar \
  add-vehicle -a -n=test-car-registry "My car" "VW" "Polo" "$USER"

-a option requests the client to await the transaction commitment, so that we can see its execution result; -n specifies the service instance name that we assigned on its start.

Check they are in the registry:

java -jar car-registry-client/target/car-registry-client-1.0.0.jar \
  find-vehicle -n=test-car-registry "My car"

Suppose for a minute, that Emmett Brown has got tired of time travels, and decided to transfer his DeLorean to you:

# Check the entry beforehand
java -jar car-registry-client/target/car-registry-client-1.0.0.jar \
  find-vehicle -n=test-car-registry "V2"

# Change the owner to the current user
java -jar car-registry-client/target/car-registry-client-1.0.0.jar \
  change-owner -a -n=test-car-registry "V2" "$USER"

# See the updated entry
java -jar car-registry-client/target/car-registry-client-1.0.0.jar \
  find-vehicle -n=test-car-registry "V2"

Success

Congratulations! You have successfully implemented a simple Exonum service, started a network of nodes, and deployed your application in it!

Exercises

E1. The transaction transferring the ownership over a vehicle currently allows transferring it to the same owner (e.g., "John Doe" > "John Doe"). It also accepts empty owner field. Modify its code to forbid such input arguments.

E2. Try the following sequence of operations with a fresh service state:

  1. Change the owner of vehicle "V1" to "John Doe"
  2. Change the owner of vehicle "V1" to yourself
  3. Change the owner of vehicle "V1" back to "John Doe".

The third operation is expected to be rejected at submission because the corresponding transaction message is equal to the first transaction message, which is already committed. Exonum rejects transactions with the same messages (basically, with same arguments and coming from the same author) to prevent their replication by another user. Modify the transaction so that such operation is possible.

Hint

A common approach to make transactions with the same arguments from the same author have different messages is to include a seed field. Each transaction author will have to set it to a unique value (to that author and set of arguments). As a seed, each author may use a counter of submitted transactions, or a random value.

You will also have to modify the client application to test the modified service.

E3. Add an operation returning all vehicles in the registry.

Note

You will have to modify the service, its API and the client application.

See Also