Posts by Topic: rust

ekxide's iceoryx2 Deep Dive - Service Attributes

Christian Eltzschig - 15/06/2024

With this article, we are starting a new series where we dive deep into the current development progress of iceoryx2. We'll explain the newest features, the problems they solve, and the cool new things you can build with iceoryx2. This series will let you see what our open-source company, ekxide IO GmbH, is currently working on. It also allows us to collect feedback from the community, plan and refine new features, and discover interesting projects using iceoryx2.

For those who are not familiar with iceoryx2: it is an open-source library that handles reliable and incredibly fast inter-process communication, suitable for applications ranging from desktops to mission-critical systems like cars or medical devices. So, if you need to send data or signal events from process A to process B, iceoryx2 is your go-to library.

https://github.com/eclipse-iceoryx/iceoryx2

The Problem

What problem does iceoryx2 solve? It is a service-oriented inter-process middleware where you can create services with a name and send data or signals to other processes.

Assume you are building a robot with multiple camera sensors. You may have several services producing video streams, publishing them on services like "camera:front," "camera:back," "camera:left," and so on. In iceoryx2, you could implement it like this:

let service = zero_copy::Service::new(ServiceName::new("camera:front")?)
    .publish_subscribe::<CameraImage>()
    .create()?;

let publisher = service.publisher().create()?;

loop {
    let sample = publisher.loan_uninit()?;
    sample.write_payload(get_camera_image()).send()?;
}

If you now write a process that requires a video stream to detect obstacles and perform an emergency brake if necessary, you could easily subscribe to such a service like this:

let service = zero_copy::Service::new(ServiceName::new("camera:front")?)
    .publish_subscribe::<CameraImage>()
    .open()?;

let subscriber = service.subscriber().create()?;

loop {
    if let Some(image) = subscriber.receive()? {
        perform_some_processing(*image);
    }
}

But what if you have another service that wants to create high-quality snapshots of the scenes the robot captures? It would be advantageous if the service used a 4k camera. Or, if the robot is moving at high speed, it would be preferable to use only services where the camera produces images at a rate of 60 frames per second.

Where could we store this information for the consumers of the data? We could add this in the header of the message, but for efficiency, this is not the best place to write this information repeatedly, especially when it never changes.

The solution is service attributes.

Service Attributes

Service attributes are key-value pairs that remain constant during the service's lifetime. They can be set when the service is created and can be read by any participant and during service discovery.

let service = zero_copy::Service::new(ServiceName::new("camera:front")?)
    .publish_subscribe::<CameraImage>()
    .create_with_attributes(
        &AttributeSpecifier::new()
            .define("camera-resolution", "1920x1080")
            .define("frames-per-second", "60"),
    )?;

When you perform a service discovery, you immediately see what attributes are set and can select the right service that satisfies all your requirements. It also allows you to acquire additional information about the counterpart.

let service = zero_copy::Service::new(ServiceName::new("camera:front")?)
    .publish_subscribe::<CameraImage>()
    .open()?;

for attribute in service.attributes().iter() {
    println!("{} = {}", attribute.key(), attribute.value());
}

Another option is to define the service attributes as requirements. For instance, it could be important that a specific key is defined without considering the value, or that a specific key-value pair is defined. Let's go back to our example and assume that we do not care about the camera resolution as long as it is defined as an attribute, but we need 60 frames per second to perform an emergency brake.

let service = zero_copy::Service::new(ServiceName::new("camera:front")?)
    .publish_subscribe::<CameraImage>()
    .open_with_attributes(
        &AttributeVerifier::new()
            .require_key("camera-resolution")
            .require("frames-per-second", "60"),
    )?;

One of our internal iceoryx2 use cases for service attributes is gateways. When you want to forward a message from iceoryx2 via the MQTT protocol, you may want to use a different service name. Sometimes it is even mandatory since the protocol does not support the iceoryx2 naming scheme, like Some/IP. With service attributes, we can now define the translation for the gateway directly in the service and specify that the "camera:front" service should map to the MQTT service "camera/front."

let service = zero_copy::Service::new(ServiceName::new("camera:front")?)
    .publish_subscribe::<CameraImage>()
    .create_with_attributes(
        &AttributeSpecifier::new()
            .define("mqtt-service-name", "camera/front"),
    )?;

What's Next?

One of the things that are missing is a mechanism to make the attributes more scalable. We need to come up with a configuration file or another innovative solution that allows us to define attributes, such as the iceoryx2 service to MQTT service mapping, in a more centralized manner rather than hardcoding them in the code. Let's see what we can come up with — we'll keep you posted.

Happy Hacking...

Want to Unlock Performance and Clarity? Use Strong Types!

Christian Eltzschig - 01/06/2024

C++ and Rust are strongly typed languages, meaning whenever you declare a variable, you must either explicitly specify the variable

Rust

C++

let fuu: i32 = 123;
int32_t fuu{123};

or you do it implicitly by assigning a value of a specific type.

Rust

C++

let fuu = "hello world";
auto fuu = "hello world";

The type of the variable also comes with a specific contract. An integer can only contain numbers but not floating point values like 3.14 or a string. The size of the integer also defines the range of numbers it can store. An int8 can store numbers in the range of [-128; 127], and an int16 offers the range of [-32768, 32767].

Strong Types Implementation

With strong types, we have a powerful tool in our hands. When defining a function input argument as uint32_t, we never need to verify that the user accidentally gave us a negative number or a string. We never need to test this case. All subsequent calls can rely on the fact that this is indeed an integer, and the API of the function clearly communicates that it is expecting an integer and nothing else.

But we can also add semantic contracts to the type. Let's take a POSIX user name for example. The POSIX standard states that it is allowed to consist of:

  • lower and upper ASCII letters (a-zA-Z),
  • digits (0-9)
  • and period (.), underscore (_) and hyphen (-)

Furthermore, it is not allowed to start with a hyphen.

All of those constraints can be baked into a type called UserName. The basic idea is that the UserName cannot be created directly with a constructor. However, it comes with a static factory method called create - the Rust idiomatic approach is to call such a method new - which takes a string-literal as input argument and checks whether it meets the above requirements. This method returns an optional value that contains either a valid UserName object or nothing when the user name contract is violated.

Rust

C++

struct UserName {
   value: String,
};

impl UserName {
   pub fn new(value: &str)
                -> Option<UserName>
   {
       //...
   }
}
class UserName {
  private:
    std::string value;

  public:
    static create(const std::string & value)
                -> std::optional<UserName>
    {
        // ...
    }
}

The UserName type now guarantees that it always contains a semantically correct user name since creating a UserName with invalid characters is impossible.

Fewer Bugs, More Expressive APIs

Let's assume we now have a collection of strong types like UserName, GroupName, and FileName, and we can use them directly in our API. We introduce two functions. The first function do_stuff uses the new and shiny strong types, but the second one buggy_stuff uses the underlying string of those types directly.

Rust

C++

fn do_stuff(
    reader: &UserName,
    writer: &GroupName,
    storage: &FileName
)

fn buggy_stuff(
    reader: &String,
    writer: &String,
    storage: &String
)
void do_stuff(
    const UserName& reader,
    const GroupName& writer,
    const FileName& storage
)

void buggy_stuff(
    const std::string& reader,
    const std::string& writer,
    const std::string& storage
)

The first issue of the buggy_stuff function is that the API is not expressive. Should the reader be a group name, a user name, or maybe it is even something completely different? This requires some detailed documentation for the user. And if you know the saying, "The compiler doesn't read comments and neither do I.", you understand this is not a perfect solution.

Furthermore, it can be easily misused. When either variable names are not expressive enough or the function is called directly with values, they can be easily mixed up. Also, what happens when you refactor and swap or replace some arguments? Maybe the storage shall be no longer a file name but now a database name. How do you ensure that all function usages are ported?

Additionally, the implementer of buggy_stuff is now responsible for verifying all arguments! Whenever this function is called, we must check that the reader, writer, and storage are semantically correct. When this is not the case, we must handle it and inform the user. Of course, we could move this check into a free function and use it whenever we expect a type with a semantic contract. However, this can be easily forgotten due to refactoring.

The error handling introduces further overhead! We have to write additional tests to check if the error handling works correctly and the function's users require extra logic to handle potential errors. And this extra logic needs to be tested as well!

Finally, it will cost us performance. Why? Because whenever one has a call chain where those arguments are forwarded to other functions, especially when they are not directly under your control, the same semantical verification has to be performed. Over and over again. And those function calls one uses to implement buggy_stuff may also fail for semantically incorrect values. This has to be handled and tested again. This costs even more performance!

All of those problems, performance costs, additional tests on the user and implementer side, additional error handling, and an unexpressive API can be avoided when we integrate the semantical check into the type itself so that we have a guarantee that it always contains a valid value.

Summary

Using strong types like UserName or FileName comes with a bunch of benefits. Firstly, the API becomes more expressive and we no longer require extensive documentation to convey all the semantic details. Strong types can also prevent parameter mixups in functions with multiple arguments. Furthermore, they also minimize the lines of code by ensuring validity through the type system. With this, they decrease the need for error handling both within the implementation and for the user's code. Even the performance may improve when the semantic content is centrally verified and not in every function repeatedly....