ZeroMQ

Content:

  1. Introduction
  2. What is ZeroMQ
  3. How to use ZeroMQ
  4. Libraries
  5. Request-Reply
  6. Push-Pull
  7. Publisher-Subscriber
  8. Conclusion

Introduction:

The purpose of this article is to give an introduction on how to use ZeroMQ with a little background information and some code examples.

ZeroMQ is not a traditional Message Queue like IBM MQSeries, Apache ActiveMQ, RabbitMQ etc..

Disclaimer: I am not an experienced ZeroMQ user, so I may have missed some things.

What is ZeroMQ:

Depending on point of view one can consider ZeroMQ one of:

I am most inclined towards the second view.

ZeroMQ project themselves say:

ZeroMQ (also known as ØMQ, 0MQ, or zmq) looks like an embeddable networking library but acts like a concurrency framework. It gives you sockets that carry atomic messages across various transports like in-process, inter-process, TCP, and multicast. You can connect sockets N-to-N with patterns like fan-out, pub-sub, task distribution, and request-reply. It's fast enough to be the fabric for clustered products. Its asynchronous I/O model gives you scalable multicore applications, built as asynchronous message-processing tasks. It has a score of language APIs and runs on most operating systems.

How to use ZeroMQ:

The main concepts in ZeroMQ are:

ZeroMQ support multiple transports;

tcp
TCP socket
inproc
intern within process using memory
ipc
IPC using Unix domain sockets [not available on all OS]
pgm
PGM datagrams [not available on all OS]
epgm
encapsulated PGM within UDP datagrams [not available on all OS]

The ZeroMQ API is transport agnostic - the transport is specified in the endpoint name. Endpoint names follow the syntax:

transport://name

Example:

All the examples shown will use tcp transport.

ZeroMQ are designed for specific message patterns that requires different socket types (note that socket type here refers to ZeroMQ sockets and include all transports not just to TCP sockets):

Message pattern socket type one end socket type other end
Request-Reply REQ [sync]
DEALER [async]
REP [sync]
ROUTER [async]
Publish-Subscribe PUB
XPUB
SUB
XSUB
Pipeline PUSH PULL
Client-Server [draft] CLIENT SERVER
Radio-Dish [draft] RADIO DISH
Integration with non ZeroMQ STREAM N/A

Note that the message pattern socket type relationship is enforced by the software. Trying to do a message pattern with socket types intended for a different message pattern will not work.

Internally ZeroMQ use a protocol ZMTP (ZeroMQ Message Transport Protocol, but that is encapsulated by the libraries.

Libraries:

ZeroMQ is usable from many programming languages and for some of them there are multiple libraries to choose from:

Above list is not complete. Check ZeroMQ documentation for complete list.

Request-Reply:

This is the message pattern where the request side does send-recv-send-recv-send-recv-... and the reply side does recv-send-recv-send-recv-send-...:

Request Reply model

This is the equivalent of a web service call.

Client:

#include <stdio.h>

#include <czmq.h>

void test(const char *addr)
{   
    zsock_t *s;
    char *reqmsg;
    char *respmsg;
    s = zsock_new_req(addr);
    reqmsg = "C (high) client";
    zstr_send(s, reqmsg);
    respmsg = zstr_recv(s);
    printf("%s\n", respmsg);
    zstr_free(&respmsg);
    zsock_destroy(&s);
}

int main(void)
{
    test("tcp://localhost:10001");
    test("tcp://localhost:10002");
    test("tcp://localhost:10003");
    test("tcp://localhost:10004");
    test("tcp://localhost:10005");
    test("tcp://localhost:10006");
    return 0;
}
#include <stdio.h>
#include <string.h>

#include <zmq.h>

void test(const char *addr)
{   
    void *ctx;
    void *s;
    char *reqmsg;
    char respmsg[10000];
    int msglen;
    ctx = zmq_ctx_new();
    s = zmq_socket(ctx, ZMQ_REQ);;
    zmq_connect(s, addr);
    reqmsg = "C (low) client";
    zmq_send(s, reqmsg, strlen(reqmsg), 0);
    msglen = zmq_recv(s, respmsg, sizeof(respmsg), 0);
    respmsg[msglen] = 0;
    printf("%s\n", respmsg);
    zmq_close(s);
    zmq_ctx_destroy(ctx);
}

int main(void)
{
    test("tcp://localhost:10001");
    test("tcp://localhost:10002");
    test("tcp://localhost:10003");
    test("tcp://localhost:10004");
    test("tcp://localhost:10005");
    test("tcp://localhost:10006");
    return 0;
}
#include <iostream>
#include <string>

using namespace std;

#include <zmq.hpp>

using namespace zmq;

void test(string addr)
{
    context_t ctx;
    socket_t s(ctx, socket_type::req);
    s.connect(addr);
    string reqmsg = "C++ client";
    s.send(buffer(reqmsg), send_flags::none);
    message_t temp;
    s.recv(temp, recv_flags::none);
    string respmsg = temp.to_string();
    cout << respmsg << endl;
}

int main(void)
{
    test("tcp://localhost:10001");
    test("tcp://localhost:10002");
    test("tcp://localhost:10003");
    test("tcp://localhost:10004");
    test("tcp://localhost:10005");
    test("tcp://localhost:10006");
    return 0;
}
import org.zeromq.SocketType;
import org.zeromq.ZMQ;
import org.zeromq.ZContext;

public class Cli4
{
    private static void test(String addr) {
        try (ZContext ctx = new ZContext()) {
            ZMQ.Socket s = ctx.createSocket(SocketType.REQ);
            s.connect(addr);
            String reqmsg = "Java client";
            s.send(reqmsg);
            String respmsg = s.recvStr();
            System.out.println(respmsg);
            s.close();
        }
    }
    public static void main(String[] args) throws Exception
    {
        test("tcp://localhost:10001");
        test("tcp://localhost:10002");
        test("tcp://localhost:10003");
        test("tcp://localhost:10004");
        test("tcp://localhost:10005");
        test("tcp://localhost:10006");
    }
}
import zmq

def test(addr):
    ctx = zmq.Context()
    s = ctx.socket(zmq.REQ)
    s.connect(addr)
    reqmsg = "Python client"
    s.send_string(reqmsg)
    respmsg = s.recv_string()
    print(respmsg)

test("tcp://localhost:10001")
test("tcp://localhost:10002")
test("tcp://localhost:10003")
test("tcp://localhost:10004")
test("tcp://localhost:10005")
test("tcp://localhost:10006")
using System;

using NetMQ;
using NetMQ.Sockets;

public class Cli
{
    private static void Test(string addr)
    {
        using (RequestSocket s = new RequestSocket())
        {
            s.Connect(addr);
            string reqmsg = "C# Client";
            s.SendFrame(reqmsg);
            string respmsg = s.ReceiveFrameString();
            Console.WriteLine(respmsg);
        }
    }
    public static void Main(string[] args)
    {
        Test("tcp://localhost:10001");
        Test("tcp://localhost:10002");
        Test("tcp://localhost:10003");
        Test("tcp://localhost:10004");
        Test("tcp://localhost:10005");
        Test("tcp://localhost:10006");
    }
}

Server:

#include <string.h>

#include <czmq.h>

int main(void)
{
    zsock_t *s;
    char *reqmsg;
    char respmsg[10000];
    s = zsock_new_rep("tcp://*:10001");
    for(;;)
    {
        reqmsg = zstr_recv(s);
        strcpy(respmsg, reqmsg);
        strcat(respmsg, " - C (high) server");
        zstr_free(&reqmsg);
        zstr_send(s, respmsg);
    }
    return 0;
}
#include <string.h>

#include <zmq.h>

int main(void)
{
    void *ctx;
    void *s;
    char reqmsg[10000];
    char respmsg[10000];
    int msglen;
    ctx = zmq_ctx_new();
    s = zmq_socket(ctx, ZMQ_REP);
    zmq_bind(s, "tcp://*:10002");
    for(;;)
    {
        msglen = zmq_recv(s, reqmsg, sizeof(reqmsg), 0);
        reqmsg[msglen] = 0;
        strcpy(respmsg, reqmsg);
        strcat(respmsg, " - C (low) server");
        zmq_send(s, respmsg, strlen(respmsg), 0);
    }
    return 0;
}
#include <string>

using namespace std;

#include <zmq.hpp>

using namespace zmq;

int main(void)
{
    context_t ctx;
    socket_t s(ctx, socket_type::rep);
    s.bind("tcp://*:10003");
    for(;;)
    {
        message_t temp;
        s.recv(temp, recv_flags::none);
        string reqmsg = temp.to_string();
        string respmsg = reqmsg + " - C++ server";
        s.send(buffer(respmsg), send_flags::none);
    }
    return 0;
}
import org.zeromq.SocketType;
import org.zeromq.ZMQ;
import org.zeromq.ZContext;

public class Srv4
{
    public static void main(String[] args) throws Exception
    {
        try (ZContext ctx = new ZContext()) {
            ZMQ.Socket s = ctx.createSocket(SocketType.REP);
            s.bind("tcp://*:10004");
            while(true) {
                String reqmsg = s.recvStr();
                String respmsg = reqmsg + " - Java server";
                s.send(respmsg);
            }
        }
    }
}
import zmq

ctx = zmq.Context()
s = ctx.socket(zmq.REP)
s.bind("tcp://*:10005")
while True:
    reqmsg = s.recv_string()
    respmsg = reqmsg + " - Python server"
    s.send_string(respmsg)
using System;

using NetMQ;
using NetMQ.Sockets;

public class Srv6
{
    public static void Main(string[] args)
    {
        using (ResponseSocket s = new ResponseSocket())
        {
            s.Bind("tcp://*:10006");
            while(true)
            {
                string reqmsg = s.ReceiveFrameString();
                string respmsg = reqmsg + " - C# server";
                s.SendFrame(respmsg);
            }
        }
    }
}

Push-Pull:

This is the message pattern where the push side does send-send-send-send-send-... and the pull side does recv-recv-recv-recv-recv-...:

Push Pull model

This is the equivalent of a traditional message queue send to queue and receive from queue.

Client:

#include <czmq.h>

void test(const char *addr)
{   
    zsock_t *s;
    char *reqmsg;
    int i;
    s = zsock_new_push(addr);
    for(i = 0; i < 3; i++)
    {
        reqmsg = "C (high) client";
        zstr_send(s, reqmsg);
    }
    zmq_sleep(1);
    zsock_destroy(&s);
}

int main(void)
{
    test("tcp://localhost:10001");
    test("tcp://localhost:10002");
    test("tcp://localhost:10003");
    test("tcp://localhost:10004");
    test("tcp://localhost:10005");
    test("tcp://localhost:10006");
    return 0;
}
#include <string.h>

#include <zmq.h>

void test(const char *addr)
{   
    void *ctx;
    void *s;
    char *reqmsg;
    int i;
    ctx = zmq_ctx_new();
    s = zmq_socket(ctx, ZMQ_PUSH);;
    zmq_connect(s, addr);
    for(i = 0; i < 3; i++)
    {
        reqmsg = "C (low) client";
        zmq_send(s, reqmsg, strlen(reqmsg), 0);
    }
    zmq_sleep(1);
    zmq_close(s);
    zmq_ctx_destroy(ctx);
}

int main(void)
{
    test("tcp://localhost:10001");
    test("tcp://localhost:10002");
    test("tcp://localhost:10003");
    test("tcp://localhost:10004");
    test("tcp://localhost:10005");
    test("tcp://localhost:10006");
    return 0;
}
#include <iostream>
#include <string>

using namespace std;

#include <zmq.hpp>

using namespace zmq;

void test(string addr)
{
    context_t ctx;
    socket_t s(ctx, socket_type::push);
    s.connect(addr);
    for(int i = 0; i < 3; i++) 
    {
        string reqmsg = "C++ client";
        s.send(buffer(reqmsg), send_flags::none);
    }
    zmq_sleep(1);
}

int main(void)
{
    test("tcp://localhost:10001");
    test("tcp://localhost:10002");
    test("tcp://localhost:10003");
    test("tcp://localhost:10004");
    test("tcp://localhost:10005");
    test("tcp://localhost:10006");
    return 0;
}
import org.zeromq.SocketType;
import org.zeromq.ZMQ;
import org.zeromq.ZContext;

public class Cli4
{
    private static void test(String addr) {
        try (ZContext ctx = new ZContext()) {
            ZMQ.Socket s = ctx.createSocket(SocketType.PUSH);
            s.connect(addr);
            for(int i = 0; i < 3; i++) {
                String reqmsg = "Java client";
                s.send(reqmsg);
            }
            try {
                Thread.sleep(1000);
            } catch(InterruptedException ex) {
            }
            s.close();
        }
    }
    public static void main(String[] args) throws Exception
    {
        test("tcp://localhost:10001");
        test("tcp://localhost:10002");
        test("tcp://localhost:10003");
        test("tcp://localhost:10004");
        test("tcp://localhost:10005");
        test("tcp://localhost:10006");
    }
}
import time

import zmq

def test(addr):
    ctx = zmq.Context()
    s = ctx.socket(zmq.PUSH)
    s.connect(addr)
    for i in range(3):
        reqmsg = "Python client"
        s.send_string(reqmsg)
    time.sleep(1)

test("tcp://localhost:10001")
test("tcp://localhost:10002")
test("tcp://localhost:10003")
test("tcp://localhost:10004")
test("tcp://localhost:10005")
test("tcp://localhost:10006")
using System;
using System.Threading;

using NetMQ;
using NetMQ.Sockets;

public class Cli
{
    private static void Test(string addr)
    {
        using (PushSocket s = new PushSocket())
        {
            s.Connect(addr);
            for(int i = 0; i < 3; i++)
            {
                string reqmsg = "C# Client";
                s.SendFrame(reqmsg);
            }
            Thread.Sleep(1000);
        }
    }
    public static void Main(string[] args)
    {
        Test("tcp://localhost:10001");
        Test("tcp://localhost:10002");
        Test("tcp://localhost:10003");
        Test("tcp://localhost:10004");
        Test("tcp://localhost:10005");
        Test("tcp://localhost:10006");
    }
}

Server:

#include <stdio.h>

#include <czmq.h>

int main(void)
{
    zsock_t *s;
    char *reqmsg;
    s = zsock_new_pull("tcp://*:10001");
    for(;;)
    {
        reqmsg = zstr_recv(s);
        printf("C (high) server : %s\n", reqmsg);
        zstr_free(&reqmsg);
    }
    return 0;
}
#include <stdio.h>

#include <zmq.h>

int main(void)
{
    void *ctx;
    void *s;
    char reqmsg[10000];
    int msglen;
    ctx = zmq_ctx_new();
    s = zmq_socket(ctx, ZMQ_PULL);
    zmq_bind(s, "tcp://*:10002");
    for(;;)
    {
        msglen = zmq_recv(s, reqmsg, sizeof(reqmsg), 0);
        reqmsg[msglen] = 0;
        printf("C (low) server : %s\n", reqmsg);
    }
    return 0;
}
#include <iostream>
#include <string>

using namespace std;

#include <zmq.hpp>

using namespace zmq;

int main(void)
{
    context_t ctx;
    socket_t s(ctx, socket_type::pull);
    s.bind("tcp://*:10003");
    for(;;)
    {
        message_t temp;
        s.recv(temp, recv_flags::none);
        string reqmsg = temp.to_string();
        cout << "C++ server : " << reqmsg << endl;
    }
    return 0;
}
import org.zeromq.SocketType;
import org.zeromq.ZMQ;
import org.zeromq.ZContext;

public class Srv4
{
    public static void main(String[] args) throws Exception
    {
        try (ZContext ctx = new ZContext()) {
            ZMQ.Socket s = ctx.createSocket(SocketType.PULL);
            s.bind("tcp://*:10004");
            while(true) {
                String reqmsg = s.recvStr();
                System.out.println("Java server : " + reqmsg);
            }
        }
    }
}
import zmq

ctx = zmq.Context()
s = ctx.socket(zmq.PULL)
s.bind("tcp://*:10005")
while True:
    reqmsg = s.recv_string()
    print("Python server : " + reqmsg)
using System;

using NetMQ;
using NetMQ.Sockets;

public class Srv6
{
    public static void Main(string[] args)
    {
        using (PullSocket s = new PullSocket())
        {
            s.Bind("tcp://*:10006");
            while(true)
            {
                string reqmsg = s.ReceiveFrameString();
                Console.WriteLine("C# server : " + reqmsg);
            }
        }
    }
}

Publisher-Subscriber:

This is the message pattern where one publisher side does send-send-send-... and multiple subscriber sides does recv-rev-recv-...:

Publisher Subscriber model

This is the equivalent of a traditional message queue publish to topic and subscribe to topic.

Client:

#include <stdio.h>
#include <string.h>

#include <czmq.h>

#define TOPIC "1234"

void test(const char *addr)
{   
    zsock_t *s;
    char *respmsg, *respmsg2;
    int n;
    s = zsock_new_sub(addr, "");
    zsock_set_subscribe(s, TOPIC);
    n = 0;
    while(n < 3)
    {
        respmsg = zstr_recv(s);
        respmsg2 = respmsg + strlen(TOPIC) + 1;
        printf("C (high) client : %s\n", respmsg2);
        n++;
    }
    zstr_free(&respmsg);
    zsock_destroy(&s);
}

int main(void)
{
    test("tcp://localhost:10001");
    test("tcp://localhost:10002");
    test("tcp://localhost:10003");
    test("tcp://localhost:10004");
    test("tcp://localhost:10005");
    test("tcp://localhost:10006");
    return 0;
}
#include <stdio.h>
#include <string.h>

#include <zmq.h>

#define TOPIC "1234"

void test(const char *addr)
{   
    void *ctx;
    void *s;
    char respmsg[10000], *respmsg2;
    int n, msglen;
    ctx = zmq_ctx_new();
    s = zmq_socket(ctx, ZMQ_SUB);;
    zmq_connect(s, addr);
    zmq_setsockopt(s, ZMQ_SUBSCRIBE, TOPIC, 0);
    n = 0;
    while(n < 3)
    {
        msglen = zmq_recv(s, respmsg, sizeof(respmsg), 0);
        respmsg[msglen] = 0;
        respmsg2 = respmsg + strlen(TOPIC) + 1;
        printf("C (low) client : %s\n", respmsg2);
        n++;
    }
    zmq_close(s);
    zmq_ctx_destroy(ctx);
}

int main(void)
{
    test("tcp://localhost:10001");
    test("tcp://localhost:10002");
    test("tcp://localhost:10003");
    test("tcp://localhost:10004");
    test("tcp://localhost:10005");
    test("tcp://localhost:10006");
    return 0;
}
#include <iostream>
#include <string>

using namespace std;

#include <zmq.hpp>

using namespace zmq;

const string TOPIC = "1234";

void test(string addr)
{
    context_t ctx;
    socket_t s(ctx, socket_type::sub);
    s.connect(addr);
    s.set(sockopt::subscribe, TOPIC);
    int n = 0;
    while(n < 3) 
    {
        message_t temp;
        s.recv(temp, recv_flags::none);
        string respmsg = temp.to_string();
        respmsg = respmsg.substr(TOPIC.size() + 1);
        cout << "C++ client : " << respmsg << endl;
        n++;
    }
}

int main(void)
{
    test("tcp://localhost:10001");
    test("tcp://localhost:10002");
    test("tcp://localhost:10003");
    test("tcp://localhost:10004");
    test("tcp://localhost:10005");
    test("tcp://localhost:10006");
    return 0;
}
import org.zeromq.SocketType;
import org.zeromq.ZMQ;
import org.zeromq.ZContext;

public class Cli4
{
    private static final String TOPIC = "1234";
    private static void test(String addr) {
        try (ZContext ctx = new ZContext()) {
            ZMQ.Socket s = ctx.createSocket(SocketType.SUB);
            s.connect(addr);
            s.subscribe(TOPIC);
            int n = 0;
            while(n < 3) {
                String respmsg = s.recvStr();
                respmsg = respmsg.substring(TOPIC.length() + 1);
                System.out.println("Java client : " + respmsg);
                n++;
            }
            s.close();
        }
    }
    public static void main(String[] args) throws Exception
    {
        test("tcp://localhost:10001");
        test("tcp://localhost:10002");
        test("tcp://localhost:10003");
        test("tcp://localhost:10004");
        test("tcp://localhost:10005");
        test("tcp://localhost:10006");
    }
}
import zmq

TOPIC = '1234'

def test(addr):
    ctx = zmq.Context()
    s = ctx.socket(zmq.SUB)
    s.connect(addr)
    s.subscribe(TOPIC)
    for i in range(3):
        respmsg = s.recv_string()
        respmsg = respmsg[len(TOPIC):]
        print('Python client : ' + respmsg)

test("tcp://localhost:10001")
test("tcp://localhost:10002")
test("tcp://localhost:10003")
test("tcp://localhost:10004")
test("tcp://localhost:10005")
test("tcp://localhost:10006")
using System;

using NetMQ;
using NetMQ.Sockets;

public class Cli
{
    private const string TOPIC = "1234";
    private static void Test(string addr)
    {
        using (SubscriberSocket s = new SubscriberSocket())
        {
            s.Connect(addr);
            s.Subscribe(TOPIC);
            for(int i = 0; i < 3; i++) {
                string respmsg = s.ReceiveFrameString();
                respmsg = respmsg.Substring(TOPIC.Length + 1);
                Console.WriteLine("C# Client : " + respmsg);
            }
        }
    }
    public static void Main(string[] args)
    {
        Test("tcp://localhost:10001");
        Test("tcp://localhost:10002");
        Test("tcp://localhost:10003");
        Test("tcp://localhost:10004");
        Test("tcp://localhost:10005");
        Test("tcp://localhost:10006");
    }
}

Server:

#include <stdio.h>

#include <czmq.h>

#define TOPIC "1234"

int main(void)
{
    zsock_t *s;
    char respmsg[10000];
    int count;
    s = zsock_new_pub("tcp://*:10001");
    count = 0;
    for(;;)
    {
        count++;
        sprintf(respmsg, "%s C (high) server (%d)", TOPIC, count);
        zstr_send(s, respmsg);
        zmq_sleep(3);
    }
    return 0;
}
#include <stdio.h>
#include <string.h>

#include <zmq.h>

#define TOPIC "1234"

int main(void)
{
    void *ctx;
    void *s;
    char respmsg[10000];
    int count;
    ctx = zmq_ctx_new();
    s = zmq_socket(ctx, ZMQ_PUB);
    zmq_bind(s, "tcp://*:10002");
    count = 0;
    for(;;)
    {
        count++;
        sprintf(respmsg, "%s C (low) server (%d)", TOPIC, count);
        zmq_send(s, respmsg, strlen(respmsg), 0);
        zmq_sleep(3);
    }
    return 0;
}
#include <sstream>
#include <string>

using namespace std;

#include <zmq.hpp>

using namespace zmq;

const string TOPIC = "1234";

int main(void)
{
    context_t ctx;
    socket_t s(ctx, socket_type::pub);
    s.bind("tcp://*:10003");
    int count = 0;
    for(;;)
    {
        count++;
        stringstream temp;
        temp << TOPIC << " C++ server (" << count << ")";
        string respmsg = temp.str();
        s.send(buffer(respmsg), send_flags::none);
        zmq_sleep(3);
    }
    return 0;
}
import org.zeromq.SocketType;
import org.zeromq.ZMQ;
import org.zeromq.ZContext;

public class Srv4
{
    private static final String TOPIC = "1234";
    public static void main(String[] args) throws Exception
    {
        try (ZContext ctx = new ZContext()) {
            ZMQ.Socket s = ctx.createSocket(SocketType.PUB);
            s.bind("tcp://*:10004");
            int count = 0;
            while(true) {
                count++;
                String respmsg = String.format("%s Java server (%d)", TOPIC, count);
                s.send(respmsg);
                try {
                   Thread.sleep(3000);
                } catch(InterruptedException ex) {
                }
            }
        }
    }
}
import time

import zmq

TOPIC = '1234'

ctx = zmq.Context()
s = ctx.socket(zmq.PUB)
s.bind("tcp://*:10005")
count = 0
while True:
    count = count + 1
    respmsg = "%s Python server (%d)" % (TOPIC, count)
    s.send_string(respmsg)
    time.sleep(3)
using System;
using System.Threading;

using NetMQ;
using NetMQ.Sockets;

public class Srv6
{
    private const string TOPIC = "1234";
    public static void Main(string[] args)
    {
        using (PublisherSocket s = new PublisherSocket())
        {
            s.Bind("tcp://*:10006");
            int count = 0;
            while(true)
            {
                count++;
                string respmsg = String.Format("{0} C# server ({1})", TOPIC, count);
                s.SendFrame(respmsg);
                Thread.Sleep(3000);
            }
        }
    }
}

Conclusion:

ZeroMQ works great for what is intended for. And the library API's are easy to use.

But one need to understand how to use it correct because otherwise it won't work and it can be very difficult to troubleshoot.

So read some documentationa and view some examples before trying to get some code working.

Article history:

Version Date Description
1.0 July 16th 2023 Initial version

Other articles:

See list of all articles here

Comments:

Please send comments to Arne Vajhøj