Turi Create  4.0
CPPIPC Basic Concepts

CPPIPC is a client-server architecture, where

  1. Server offers a list of types that can be instantiated
  2. Client connects to the server
  3. Client asks for an object of a particular type to be created.
  4. Server creates the object and client creates a "proxy" for the object.
  5. Client uses the proxy object as if it is a local object, except that all calls are evaluated on the server
  6. When client destroys the object, the object on the server is destroyed.

The basic idea of CPPIPC is the triple split of

  • Base Class
  • Implementation Class
  • Proxy Class

Base, Implementation and Proxy Separation

To communicate reliably, it is crucial that both client and server have a common view of an object. To achieve this, we go with the common base class model: a base class is used to define an interface, and an implementation of the interface is provided on the server side. On the client side, a proxy class (which also implements the interface) is provided, except that this proxy class simply forwards all calls to the server.

For instance (this code will not actually work):

// known by both client and server
class print_something_base {
public:
void print(std::string message);
};
// known only by client
class print_something_proxy: public print_something_base {
public:
void print(std::string message) {
// forward 'message' to server
}
}
// known only by server
class print_something: public print_something_base {
public:
void print(std::string message) {
std::cout << message;
}
}

Base and Proxy classes

However, implementing a proxy class is hugely annoying (you have to implement the class all over again), easy to get wrong (since there will be a decent amount of copy and pasting), and there are several rules and stuff (registration must be called, etc). We therefore provide a collection of magic macros that make the implementation effort easier, though at the cost of some of the most annoying looking compiler messages at minor syntax errors.

Here, we are going to implement a basic "counter" service. i.e. the server is going to maintain an integer counter which we can read, and add to.

Begin by #including the appropriate headers

#include <core/system/cppipc/cppipc.hpp>
#include <core/system/cppipc/magic_macros.hpp>

We can then generate the interface and proxy objects using the GENERATE_INTERFACE_AND_PROXY macro.

GENERATE_INTERFACE_AND_PROXY(basic_counter_base, basic_counter_proxy,
(int, add, (int))
(int, add_multiple, (int)(int))
(int, get_val, )
)

For instance, here, we created an interface class called "basic_counter_base", and a proxy class called "basic_counter_proxy" which extends the base class. The base class defines 3 functions,

  • a function called "add" which takes one integer and returns an integer. This function will add the integer to the counter, and return the new counter value.
  • a function called "add_multiple" which takes two integers and returns an integer. This function will add the product of the input integers to the counter, and return the new counter value.
  • a function called "get_val" which takes no arguments and returns an integer. This function will return the current counter value.

A few observations.

  • Each "function definition" is not separated by commas, but is just consecutive parenthesis blocks.
  • Each input argument type of the function is in its own parentheses block.
  • Even if a function takes no arguments, a final "comma" is necessary in the function definition
  • There must be no variable names in the arguments.

The calls are serialized using Turi Create's serialization interface. i.e. all arguments and return types must be serializable. See serialization for details.

Known Limitations:

  • No function overloads permitted
  • This is a limitation of the C++ preprocessor. type names cannot have commas in them. If you have a comma in a type, for instance "std::map<std::string, size_t>" you will need to typedef it to a type name with no commas before using it in the macro. For instance:
// BAD!
GENERATE_INTERFACE_AND_PROXY(basic_counter_base, basic_counter_proxy,
(std::map<std::string, size_t>, thingee, )
)
// GOOD!
typedef std::map<std::string, size_t> str_int_map_type;
GENERATE_INTERFACE_AND_PROXY(basic_counter_base, basic_counter_proxy,
(str_int_map_type, thingee, )
)

To see why you should always use the magic macros and not write your own proxy/base object, see the documentation for GENERATE_INTERFACE_AND_PROXY

Implementation Classes

Now, the macro defines the base and proxy objects, but you have to define your own implementation object. There are no rules for the implementation object besides that you must inherit from the base, and implement all the functions defined in the base.

class basic_counter: public basic_counter_base {
private:
int value;
public:
basic_counter():value(0) {}
int add(int a) {
value += a;
return value;
}
int add_multiple(int val, int count) {
value += val * count;
return value;
}
int get_val() {
return value;
}
};

And you are done!

Implementing a Server and a Client Program

Put it all in a header (for simplicity). In a real system the implementation will be placed separately from the proxy so the client compilation does not inherit all the server's dependencies.

Next create 2 cpp files, one server, and one client.

// Server Example
#include <iostream>
#include <core/system/cppipc/cppipc.hpp>
#include "basic_counter.hpp"
basic_counter_base* basic_counter_factory() {
return new basic_counter;
}
int main(int argc, char** argv) {
cppipc::comm_server server({}, "ipc:///tmp/cppipc_server_test");
server.register_type<basic_counter_base>(basic_counter_factory);
server.start();
getchar();
}

The "ipc:///tmp/cppipc_server_test" is a ZeroMQ endpoint address and is the address the server will listen and wait on for connections. ipc:// is an "interprocess socket" endpoint and should point to a filename (preferably non-existent, and unused). It can also be tcp://[ip address]:[portnumber] to have the server wait on a TCP/IP connection. Note that it must be an IP address in this case. ZeroMQ does not do hostname resolution.

The register_type call is used to register the "basic_counter_base" type with the server, and also inform the server how to construct an instance of the object when asked by a client. (As an alternative to defining a factory function, a lambda can be used as well).

// Client Example
#include <iostream>
#include <core/system/cppipc/cppipc.hpp>
#include "basic_counter.hpp"
int main(int argc, char** argv) {
// Connects to the server
cppipc::comm_client client({}, "ipc:///tmp/cppipc_server_test");
// Creates a proxy object. This calls the factory on the server to create
// a basic_counter on the server. The proxy object on the client only
// maintains an object ID.
basic_counter_proxy proxy(client);
//adds 50 to the counter
proxy.add(50);
//adds 12 * 5 to the counter
proxy.add_multiple(12, 5);
// prints the counter value
std::cout << "Counter Value: " << proxy.get_val() << "\n";
// when proxy is destroyed, it destroys the object on the server
}