Intersec Object Packer Part 1 : the basics

This post is an introduction to a useful tool here at Intersec, a tool that we call IOP: the Intersec Object Packer.

IOP is our take on the IDL approach. It is a method to serialize structured data to use in our communication protocols or data storage technologies. It is used to transmit data over the network in a safe manner, to exchange data between different programming languages or to provide a generic interface to store (and load) C data on disk. IOP provides data integrity checking and backward-compatibility.

The concept of IDL is not new. There are a lot of different available languages, such as Google Protocol Buffers or Thrift. IOP itself isn’t new, its initial version was written in 2008 and has seen a lot of evolutions during its, almost decade-long, life. However, IOP has proven itself to be solid and sufficiently well designed for not seeing any backward incompatible changes during that period.

IOP package description

The first thing to do with IOP is to declare the data structures in the IOP description language. With those definitions, our IOP compiler will automatically create all the helpers needed to use these IOP data structures in different languages and to allow serialization and deserialization.

Data stucture declaration is done in a C-like syntax (actually, it is almost the D language syntax) and lives inside a .iop file. As a convention, we use CamelCase in our iop files (which is different from our .c files coding rules).

Let’s look at a quick example:

struct User {
    int    id;
    string name;
};

Here we are. An IOP object with two fields: an id (as an integer) and a name (as a string). Obviously, it is possible to create much more complex structures. To do so, here is the list of available types for our structure fields.

Basic types

IOP allow several low-level types to be used to define object members. One can use the classics:

  • int / uint(32 bits signed/unsigned integer)
  • long / ulong (64 bits signed/unsigned integer)
  • byte / ubyte (8 bits signed/unsigned integer)
  • short / ushort(16 bits signed/unsigned integer)
  • bool
  • double
  • string

and also the types:

  • bytes (a binary blob)
  • xml (for an XML payload)
  • void (to specify a lack of data).

Complex types

Four complex data types are also available for our fields.

Structures

The structure describes a record containing one or more fields. Each field has a name and a type. To see what it looks like, let’s add an address to our user data structure:

struct Address {
    int    number;
    string street;
    int    zipCode;
    string country;
};

struct User {
    int     id;
    string  name;
    Address address;
};

Of course, there is no theoretical limitation on the number of struct “levels”. A struct can have a struct field which also contains a struct field etc.

Classes

A class is an extendable structure type. A class can inherit from another class, creating a new type that adds new fields to the one present in its parent class.

We will see classes in more details in a separate article.

Unions

An union is a list of possibilities. Its description is very similary to a structure: it has typed fields, but only one of the fields is defined at a time. The name union is inherited from C since the concept is very similar to C unions, however IOP unions are tagged, which means we do know which of the field is defined.

Example:

union MyUnion {
    int    wantInt;
    string wantString;
    User   wantUser;
};

Enumeration

The last type that can be used is the enumeration. Here again, an enum is similar to the C-enum. It defines several literal keys associated to integer values. Just like the C enum, the IOP enum supports the whole integer range for its values.

Example:

enum MyEnum {
    VALUE_1 = 1,
    VALUE_2 = 2,
    VALUE_3 = 3,
};

Member constraints

Now that we have all the types we need for our custom data structure fields, it’s time to add some new features to them, in order to gain flexibility. Those features are called constraints. These constraints are qualifiers for IOP fields. For now, we have 4 different constraints: optional, repeated, with a default value and the implicit mandatory constraint.

Mandatory

By default, a member of an IOP structure is mandatory. This means it must be set to a valid value in order for the structure instance to be valid. In particular, you must guarantee the field is set before serializing/deserializing the object. By default, mandatory are value fields in the generated C structure: this means the value is inlined in the structure type and is copied. There are however some exceptions to this rule but we will see that later.

The example is pretty simple:

struct Foo {
    int mandatoryInteger;
};

Optional members

An optional member is indicated by a ? following the data type. The packers/unpackers allow these members to be absent without generating an error.

struct Foo {
    int? optionalMember;
    Bar? optionalMember2;
    int  mandatoryInteger;
};

Repeated members

A repeated member is a field that can appear zero or more times in the structure (often represented by an array in the programming languages). As such a repeated field is optional (can be present 0 times). A repeated member is indicated by a “[]” following the data type.

In the next example, you can consider the repeatedInteger field as a list of integers.

struct Foo {
    int[] repeatedInteger;
    int?  optionalMember;
    Bar?  optionalMember;
    int   mandatoryInteger;
};

With default value

A field with a default value is a kind of mandatory member but allowed to be absent. When the member is absent, the packer/unpacker always sets the member to its default value.

A member with a default value is indicated by setting the default value after the field declaration.

struct Foo {
    int   defaultInteger = 42;
    int[] repeatedInteger;
    int?  optionalMember;
    Bar?  optionalMember;
    int   mandatoryInteger;
};

Moreover, it is allowed to use arithmetic expressions on integer (and enum) member types like this:

struct Foo {
    int   defaultInteger = 2 * (256 << 20) + 42;
    int[] repeatedInteger;
    int?  optionalMember;
    Bar?  optionalMember;
    int   mandatoryInteger;
};

IOP packages

The last thing to know to be able to write our first IOP file is about packages.

An IOP file corresponds to an IOP package. Basically, the package is kind of a namespace for the data structures you are declaring. The filename must match with package name. Every IOP file must define its package name like this:

package foo; /*< package name of the file foo.iop */

struct Foo {
    [...]
};

[...]

A package can also be a sub-package, like this:

package foo.bar; /*< package name of the file foo/bar.iop */

struct Bar {
    [...]
};

[...]

Finally, you can import objects from another package by specifying the package name before the type:

package plop; /*< package name of the file plop.iop */

struct Plop {
    foo.bar.Bar bar;
};

[...]

How to use IOP

Before going to more complicated features on IOP, let’s see a simple example of how to use our new custom data structures that we just declared.

When compiling our code, a first pass is done on our IOP files using our own compiler. This compiler will parse the .iop files and generate the corresponding C sources files that provides helpers to serialize/deserialize our data structures. Here again, we will see it in more details soon 🙂

Let’s see an example of code which is using IOP. First, let’s assume we have declared a new IOP package:

package User;

struct UserAddress {
    string street;
    int?   zipCode;
    string city;
};

struct User {
    ulong       id = 1;
    string      login;
    UserAddress addr;
};

This will create several C files containing the type descriptors used for data serialization/deserialization as well as the C types declarations:

struct user__user_address__t {
    char*     street;  /*< Actually a slightly more complicated type is used for
                        *  strings, but no need to be too specific here :)
                        */
    opt_i32_t zip_code;
    char*     city;
};

struct user__user__t {
    uint64_t                     id;
    char *                       login;
    struct user__user_address__t addr;
};

Not very different from the IOP file right? We can notice some uncommon stuff still:

  • The opt_i32_t type for zip_code. This is how we handle optional field. It is a structure containing a 32 bits integer + a boolean indicating if the field is set or not.
  • The stuctures names are now in snake_case instead of camelCase. The name of the package is added as a prefix of each structures, and there is a __t suffix too. This helps to recognize IOP structures when we meet one in our C code.

All the code generated by our compiler will be available through a user.iop.h file.

Now let’s play with it in our code :

#include "user.iop.h"

[...]

int my_func(void) {
    user__user__t user;

    /* This function will initialize all the fields (and sub-fields) of the
     * structure, according to the IOP declarations. Here, everything will be set
     * to 0/NULL but the field "id" which will contains the value "1". The first
     * argument indicates the package + structure name of our IOP object.
     */
    iop_init(user__user, &user);

    /* This function will pack our IOP structure into an IOP binary format and
     * returns a pointer to the created buffer containing the packed structure.
     * The structures will be packed in order to use as little memory as possible.
     * Let put aside the memory management questions for this post.
     */
    void *binary_data = iop_bpack(user__user, &user);

    /* This call must have failed. Our constraint are not respected, as several
     * mandatory fields were not correctly filled.
     */
    assert(binary_data == NULL);

    user.addr.street = "221B Baker Street";
    user.addr.city   = "London";
    user.login = "SH";

    binary_data = iop_bpack(user__user, &user);

    /* This one should be the good one. Even if "id" field and "addr.zip_code" are
     * not filled, it is not a problem as the first one got a default value and
     * the second one is an optional field.
     */
    assert(binary_data != NULL);

    /* Now we can do whatever we want with these data (writing it on disk for
     * example). But for now, let's just try to unpack it. Here again, put a
     * blindfold about memory management.
     */
    user__user__t user2;
    int res = iop_bunpack(binary_data, user__user, &user2);

    /* Unpacking should have been successful, and we now have a "user2" struct
     * identical to "user" struct.
     */
    assert(res >= 0)
}

Here we are. IOP gave us the superpower of packing/unpacking data structures in a binary format in two simple function calls. These binary packed structures can be used for disk storage. But as we will see in a future article, we also use it for our network communications.

Next time, we will talk about inheritance for our IOP objects!

Author Avatar

Ludovic Demeyer