I'll reproduce the full 'dev introduction' document here - I wrote the thing, and there's nothing sensitive here. It'll eventually end up in the SDK docs, but no idea when.
This is more general context about the whole thing and the technical details than purely a migration guide from 8.1 -> 8.3. The closest thing to a 'tl;dr' is the last ~third of part 2.
Part 1
Context
Ignition has, since its inception, used a relatively straightforward RPC mechanism that uses XML and Java serialization over HTTP.
In 8.3, we’re taking advantage of lessons learned and significantly revamping this system. The new RPC system will be serialization format agnostic (pushing the choice of technologies down to module authors, with a first party emphasis on Protobuf and GSON), and continue to use HTTP as the primary transport mechanism, with the addition of a WebSocket layer for continuous health checks and gateway pushed events.
Implementation
The roots of the new system should be broadly familiar to anyone used to implementing RPC in modules, but with some significant changes.
Core
RpcCall
public record RpcCall(
@NotNull String moduleId,
@NotNull String packageId,
@NotNull String function
) {}
The most basic unit in the RPC system is com.inductiveautomation.ignition.common.rpc.RpcCall
- a simple nominal triple of (moduleId, packageId, functionName)
, designed to act as a common locating layer for everything else to build upon. It’s extremely important to note that the packageId
is an entirely arbitrary string, only intended to serve as a namespace within a particular module - e.g. the Ignition platform might define packages like images
, databases
, projects
, etc, and it need not have any correlation to any actual Java package ID or otherwise. Its only purpose is to act as a namespace within a module.
This is possible because of an explicit separation of concerns in the new RPC system - as long as you’re using established channels, Ignition owns the transport layer and the headers, but the entire request/response body is the responsibility of your module. More specifically - an outgoing RPC call from the designer will pack the module ID, package ID, and function name into HTTP headers. Then, on the gateway, a common handler will unpack those headers, locate the appropriate module handler, and immediately delegate to it for handling. This allows modules the flexibility to use whatever serialization format they want and ensures that the platform is not being overly proscriptive.
RpcInterface
public @interface RpcInterface {
@NotNull String packageId();
}
A simple marker annotation to be added to your interfaces in common. The PackageID is an arbitrary key to map to a specific RPC implementation within your module, which is important if you plan to have multiple RPC interfaces defined. Even if you only have one, it cannot be an empty string and should be something human readable for logging/diagnostic purposes.
RpcException
RpcException
is a simple exception wrapper defined in common
that allows you pass an int constant from GatewayConstants
. This should generally not be necessary, but is available as a backwards compatibility shim to minimize rewriting of exception handling in the client/designer.
Note: RpcException
can also be thrown implicitly during initial RPC capability checks; see RpcHandler for more detail.
GatewayRpcSerializer
public interface GatewayRpcSerializer {
void writeReturnValue(OutputStream stream,
RpcCall call,
Object value) throws IOException;
@Nonnull
List<Object> readParameters(InputStream stream,
RpcCall call) throws IOException;
}
This is a shared instance of a class that’s expected to be able to read incoming parameters directly from the incoming request, translate them to a simple list of objects, and perform the corresponding operation in reverse - writing the value returned from an RpcHandler
back to the request as output. The RpcCall
parameter is provided purely for informational purposes, such as for logging, or in case a given serialization scenario requires differentiation. While it’s certainly possible to implement manually, the recommended pattern is to take advantage of the work registering serializers for common types we’ve already done - for more info on that, see the Protobuf section below.
ClientRpcSerializer
public interface ClientRpcSerializer {
void writeParameters(OutputStream stream, RpcCall call, Object... parameters) throws IOException;
Object readReturnValue(InputStream stream, RpcCall call) throws Exception;
}
The inverse interface to GatewayRpcSerializer
- responsible for serializing outgoing parameters on a call, and parsing the return value from the gateway. Both of these interfaces are defined in common scope so that a shared class suitable for RPC exchange can be defined in your module’s common scope, and then separately provided to calls on the gateway and client/designer.
RpcSerializer
RpcSerializer
is a simple meta-interface combining Gateway and Client, for situations where you want to easily declare a field/parameter/etc as performing both.
Gateway
RpcHandler
@FunctionalInterface
public interface RpcHandler {
@Nullable
Object handle(@Nonnull RpcContext context,
@Nonnull List<Object> parameters) throws Throwable;
default boolean isActiveNodeRequired() {
return true;
}
default @Nullable String clientPermissionId() {
return null;
}
default MutabilityMode requiredMutabilityMode() {
return MutabilityMode.OFF;
}
}
RpcHandler
is the simple functional interface that forms the basis of RPC handling on the gateway. However, for purposes of code navigation and type safety, it will not usually be implemented directly. The list of parameters delivered to your handler is guaranteed to be deserialized by your module’s serializer, and the context object will be provided for you.
Implementations can opt in to various global checks on client state by overriding the default methods.
isActiveNodeRequired()
must return false to allow the handler to run on backup nodes
clientPermissionId()
is required to allow the function to run on clients; null
means that only designers can run handlers. ClientPermissionsConstants
has a new UNRESTRICTED
constant that means no permissions are expected of clients, the previous default behavior.
requiredMutabilityMode()
allows RPC methods to opt in to behavior restrictions based on the client/designer’s “Comm Mode” setting. Most commonly, a particular function could require a READ_WRITE
mutability mode, so a client/designer in READ_ONLY mode will not be allowed to invoke the function.
Note: If any of the above checks fail, an RpcException
will be thrown and handed to your serializer(s) to write back to the caller. If you are following the recommended proxy based approach, this will throw an UndeclaredThrowableException
that contains the RpcException
as its cause, unless you have your RPC interface methods throw RpcException
directly.
Note: If the handle
method returns null, nothing will be written out to the response stream; this allows for streaming responses to write out their response lazily as results are returned. See com.inductiveautomation.ignition.gateway.servlets.gateway.ProtoStreamingDatasetWriter
for a possible use case for this.
RpcContext
public interface RpcContext {
RequestContext request();
HttpServletResponse response();
ClientReqSession session();
/**
* The name of the project the RPC call is being made from,
* or null if the caller is not (yet) associated with a project.
*/
@Nullable String projectName();
RpcCall rpcCall();
}
RpcContext
is a basic interface that provides metadata to a handler, as well as direct access “escape hatches” to the underlying request and response - this is how you could actually write out a streaming response as mentioned above.
RpcImplementation
public interface GatewayRpcImplementation {
GatewayRpcSerializer getSerializer();
RpcRouter getRpcRouter();
static GatewayRpcImplementation of(GatewayRpcSerializer serializer, Object... interfaces) {
return new GatewayRpcImplementation() {
@Override
public GatewayRpcSerializer getSerializer() {
return serializer;
}
@Override
public RpcRouter getRpcRouter() {
return new RpcDelegate(interfaces);
}
};
}
static GatewayRpcImplementation.Builder newBuilder(GatewayRpcSerializer defaultSerializer) {
return new Builder(defaultSerializer);
}
}
On the Gateway, your entrypoint into the new RPC system will be the getRpcImplementation()
function on your GatewayHook
. This completely replaces the getRPCHandler
method. Your module’s RPC implementation must do two things:
- Return a serializer that’s capable of
- Deserializing incoming parameters from the client/designer
- Serializing the outgoing return value to send to the client/designer
- Return an
RpcRouter
that can be used to locate an individual RPC function implementation.
Typically, the easiest path forward for this is to define one using the GatewayRpcImplementation.of()
method to build (during module setup) the implementation which is returned by your module hook.
In rare cases (or for the Ignition platform itself), it makes sense to have dedicated serialization handlers for specific RPC ‘packages’, rather than one overloaded mega-serializer. To that end, newBuilder
exists; you register a default serializer, and then add packages to your implementation, optionally providing a more specific serializer for each package.
RpcRouter
public interface RpcRouter {
@Nonnull
Optional<RpcHandler> getRpcHandler(RpcCall call);
}
RpcRouter
is a simple indirection interface, representing the task of locating a particular RpcHandler
for a given RpcCall
.
In most cases, it’s not necessary to implement directly, because of the following two helper classes:
RpcDelegate
public class RpcDelegate implements RpcRouter {
public RpcDelegate(Object delegate) {
RpcDelegate
, meanwhile, completes the locating tree. When constructing an RpcDelegate
, you pass in any number of implementation classes which each implement one shared common-scoped Java Interface which is annotated with @RpcInterface(packageId = “some-package”)
. All methods on those interface(s) will be pulled out reflectively and wrapped as RpcHandler
s, allowing RpcDelegate
to act as an RpcRouter
by package ID (from the interface annotation) and function name (by the explicit function’s name).
Note: overloads are not supported - there is no differentiation by types in the RPC system. If you need two similar methods, you must give them distinct names.
RpcDelegate
also conveys the RpcContext
of the current call via the CURRENT_CONTEXT ThreadLocal
, allowing implementations of an otherwise common interface access to the extra features of the RPC system. RpcDelegate
also exposes simple static methods to retrieve the individual members of RpcContext
directly:
public static RequestContext getRequest()
public static HttpServletResponse getResponse()
public static ClientReqSession session()
public static String projectName()
public static RpcCall rpcCall()
Finally, RpcDelegate
also defines annotations to attach capability restrictions/additional behavior to the generated RpcHandler
:
/**
* Annotate a particular method on your RPC <b>implementation</b> with {@code RunsOnBackup} to allow it to be
* invoked on a backup node.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RunsOnBackup {}
/**
* Annotate a particular method or your entire RPC <b>implementation</b> with {@code RunsOnClient} to allow it to be
* invoked by a client.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RunsOnClient {
/**
* @return A non-null, non-empty string identifying the client permission required to invoke this method.
* Use {@link ClientPermissionsConstants#UNRESTRICTED} to allow any client to invoke the method.
*
* @see ClientPermissionsConstants
*/
String clientPermissionId();
}
/**
* Annotate a particular method on your RPC <b>implementation</b> with {@code RequiredMutabilityMode} to indicate
* that the client must have at least the given {@link MutabilityMode}, or the call will be rejected.
*
* @see RpcHandler#requiredMutabilityMode()
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequiredMutabilityMode {
MutabilityMode value();
}
Client/Designer
GatewayConnection
The same GatewayConnection
interface can be obtained and used as in the old system, via GatewayConnectionManager.getInstance()
, and this exposes an invoke
entrypoint much like the old system. However, there is also a new static method on GatewayConnection
used to obtain a proxy instance of an interface. This should be broadly familiar to anyone used to the ModuleRPCFactory
class that previously existed:
<T> T getRpcInterface(ClientRpcSerializer serializer,
String moduleId,
Class<T> rpcInterface,
int timeoutMillis); // an overload is also available with a default timeout
This allows any component in your UI to obtain a direct instance of an interface; more on this in Best Practices later.
Note that this ‘shorthand’ method requires that the class you pass in is an interface annotated with @RpcInterface
, to obtain a package ID.
Ignition platform RPC proxy instances, for ease of reuse, are available in com.inductiveautomation.ignition.client.rpc.PlatformRpcInstances
Protobuf
Throughout the Ignition platform, we’ve decided to drop Java serialization as much as possible (backwards compatibility constraints excluded). As a result, we’ve settled on a hybrid model for first party RPC serialization that uses Protobuf as the wire format, with support for nested JSON encoding via Gson, since many first party classes already had Gson serializers set up for other work.
RpcMessage
/**
An RpcMessage is always either an actual value, or an error thrown on the gateway; nothing more and nothing less.
*/
message RpcMessage {
oneof value {
Value actual = 1;
Error error = 3;
}
}
RpcMessage
is the overall container of a value coming over RPC - it is essentially an Either<Value, Throwable>
Protobuf message.
Value
message Value {
// An associated identifier that will be used to pick up custom deserialization logic on the receiving side.
// If no identifier is supplied, the value is decoded as whatever underlying Java type.
optional string identifier = 1;
oneof value {
bool bool_value = 2;
sint32 int_value = 3;
sint64 long_value = 4;
float float_value = 5;
double double_value = 6;
string string_value = 7;
bytes binary_value = 8;
ValueCollection collection_value = 9;
}
message ValueCollection {
repeated Value value = 1;
optional Implementation implementation = 2;
enum Implementation {
LIST = 0;
SET = 1;
MAP = 2;
BOOL_ARRAY = 3;
INT_ARRAY = 4;
LONG_ARRAY = 5;
FLOAT_ARRAY = 6;
DOUBLE_ARRAY = 7;
BOXED_ARRAY = 8;
}
}
}
Value
is the atomic unit of serialization. A Value
is either a basic Protobuf primitive, raw binary bytes, or a sequence of (recursive) Value
objects. Each Value
object has an optional associated identifier; if supplied, this will be used to look up a special deserialization handler on the receiving side. In this way, a class can be ‘decomposed’ down to a primitive type (e.g. a java.util.Date
can be sent as long_value
, but decoded as a Date
on the receiving side).
binary_value
is intended as an “escape hatch” of sorts, or a means to gain greater flexibility. It can be used in a scenario where the platform or your module’s overall Protobuf support is adequate for all but a few special cases which might need their own special serialization handling. You can serialize and deserialize your object using any arbitrary strategy, and only have to provide a stable identifier that can be passed to the other side of the RPC connection and understood.
See also ObjectSerializers
for a Java-serialization oriented path.
ValueCollection
, meanwhile, is a repeated sequence of Value
s, allowing recursive self-definition, along with an Implementation
enum hint. This allows complex heterogeneous structures like JsonObject
/JsonArray
to be sent, as well as standard primitive arrays and collection types.