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:
- JDK 11
- Apache Maven 3.6+
- Python 3.6+
- Exonum Java 0.10
- cURL
- An editor or IDE for Java.
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
forgroupId
car-registry
forartifactId
1.0.0
forversion
.
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. ForVehicle
s, which are Protocol Buffers messages, we use a corresponding serializer for messages ofVehicle
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:
- Open context menu (right click on the directory)
- "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:
- Change the owner of vehicle "V1" to "John Doe"
- Change the owner of vehicle "V1" to yourself
- 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¶
- The Exonum Java User Guide
- Various articles on Exonum architecture: Services, Transactions, MerkleDB, etc.