Introduction
Writing distributed applications, especially deployed across a network, tends to be a challenge, not only due to the trickiness of network programming but more so because your code, business logic messed up with communication details, is probably not flexible, hard to reuse and test in isolation.
Meanwhile, most programmers already know how to make their code flexible, reusable, and testable. Yes, reducing code coupling, often achieved by introducing additional level of indirection, is the definite way to go. Then why don’t we apply the same technique to overall application architecture? Simply decoupling communication details from application logic will help us to build a flexibly distributable, fully testable application consisting of reusable modules.
In this article, we will get through a few simple examples of x2net application and see how distribution works in x2 way.
Background
x2
x2 is a set of concepts and specifications that facilitates the development of highly flexible cross-platform, cross-language distributed systems. Before further going on, it is recommended to give a look to its README.md and concepts.md.
x2net
x2net is the reference port of x2 written in C# targeting universal .NET environments.
Using the Code
In order to focus on the structural aspect, we begin with an extremely simple application, Hello
:
public class Hello
{
public static void Main()
{
while (true)
{
var input = Console.ReadLine();
if (input == "bye")
{
break;
}
var greeting = String.Format("Hello, {0}!", input);
Console.WriteLine(greeting);
}
}
}
Defining Events
An x2 application is composed of logic cases (or flows) which communicate only with events one another. So defining shared event hierarchy is the key activity in design time. In this simple example, we can grab the key feature that makes up a greeting sentence out of the name input. We define a request/response event pair for this feature as follows:
="1.0"="utf-8"
<x2 namespace="hello">
<definitions>
<event name="HelloReq" id="1">
<property name="Name" type="string"/>
</event>
<event name="HelloResp" id="2">
<property name="Greeting" type="string"/>
</event>
</definitions>
</x2>
Running x2net.xpiler on this XML definition file will yield a corresponding C# source file we can include into our project.
Preparing Core Logic Modules
Once we define events, we can write the application logic cases to handle those events. Here, we write a simple case which creates the hello sentence:
public class HelloCase : Case
{
protected override void Setup()
{
Bind(new HelloReq(), OnHelloReq);
}
void OnHelloReq(HelloReq req)
{
new HelloResp {
Greeting = String.Format("Hello, {0}!", req.Name)
}
.InResponseOf(req)
.Post();
}
}
Please note that logic cases react to their interested events by posting another event in return. They know nothing about the communication details: where request events come from or where response events are headed for. Consequently, these logic cases may be freely located, without any change, at any point of the entire distributed application. And they can also be easily tested in isolation.
First x2net Application
Having relevant events and cases, now we are ready to set up our first x2net application with these constructs.
public class HelloStandalone
{
class LocalCase : Case
{
protected override void Setup()
{
Bind(new HelloResp(), (e) => {
Console.WriteLine(e.Greeting);
});
}
}
public static void Main()
{
Hub.Instance
.Attach(new SingleThreadFlow()
.Add(new HelloCase())
.Add(new LocalCase()));
using (new Hub.Flows().Startup())
{
while (true)
{
var input = Console.ReadLine();
if (input == "bye")
{
break;
}
new HelloReq { Name = input }.Post();
}
}
}
}
This works exactly the same as our original console application, but in x2 way:
- A console input generates a
HelloReq
event. HelloCase
takes the HelloReq
event and posts a HelloResp
event in return, with the generated greeting sentence. LocalCase
takes the HelloResp
event and prints its content to console output.
Now that we have an x2 application, we can easily change the threading model or distribution topology of our application. For example, applying the following change will let our every case run in a separate thread:
...
public static void Main()
{
Hub.Instance
.Attach(new SingleThreadFlow()
.Add(new HelloCase()))
.Attach(new SingleThreadFlow()
.Add(new LocalCase()));
...
Changing its threading model may not be so interesting. But how about making it a client/server application in minutes?
2-Tier Distribution: Client/Server
First, we prepare a server which runs the HelloCase
as its main logic case:
public class HelloTcpServer : AsyncTcpServer
{
public HelloTcpServer() : base("HelloServer")
{
}
protected override void Setup()
{
EventFactory.Register<HelloReq>();
Bind(new HelloResp(), Send);
Listen(6789);
}
public static void Main()
{
Hub.Instance
.Attach(new SingleThreadFlow()
.Add(new HelloCase())
.Add(new HelloTcpServer()));
using (new Hub.Flows().Startup())
{
while (true)
{
var input = Console.ReadLine();
if (input == "bye")
{
break;
}
}
}
}
}
Then we can write a simple client to connect to the server to get things done:
public class HelloTcpClient : TcpClient
{
class LocalCase : Case
{
protected override void Setup()
{
Bind(new HelloResp(), (e) => {
Console.WriteLine(e.Greeting);
});
}
}
public HelloTcpClient() : base("HelloClient")
{
}
protected override void Setup()
{
EventFactory.Register<HelloResp>();
Bind(new HelloReq(), Send);
Connect("127.0.0.1", 6789);
}
public static void Main()
{
Hub.Instance
.Attach(new SingleThreadFlow()
.Add(new LocalCase())
.Add(new HelloTcpClient()));
using (new Hub.Flows().Startup())
{
while (true)
{
var input = Console.ReadLine();
if (input == "bye")
{
break;
}
new HelloReq { Name = input }.Post();
}
}
}
}
Please note that the HelloCase
does not change whether it is run in a standalone application or in a server.
In the above server link, you might wonder how we send a response event to the very client who issued the original request. The built-in event property _Handle
does the trick. When an x2net link receives an event from network, its _Handle
property is set as the link session handle. If the _Handle
property of the response event is the same as the original request, which is done by the InResponseOf
extension method, the server can locate the target link session with the _Handle
property.
Adding Features
Let's say that we are to add a new feature that converts the result string to uppercase letters. We append two more events to the definition file as follows:
="1.0"="utf-8"
<x2 namespace="hello">
<definitions>
<event name="HelloReq" id="1">
<property name="Name" type="string"/>
</event>
<event name="HelloResp" id="2">
<property name="Greeting" type="string"/>
</event>
<event name="CapitalizeReq" id="3">
<property name="Input" type="string"/>
</event>
<event name="CapitalizeResp" id="4">
<property name="Output" type="string"/>
</event>
</definitions>
</x2>
And we add a new logic case to our shared module:
public class CapitalizerCase : Case
{
protected override void Setup()
{
Bind(new CapitalizeReq(), OnCapitalizeReq);
}
void OnCapitalizeReq(CapitalizeReq req)
{
new CapitalizeResp {
Output = req.Input.ToUpper()
}
.InResponseOf(req)
.Post();
}
}
Then we can rewrite our standalone application as follows:
public class HelloStandalone
{
class LocalCase : Case
{
protected override void Setup()
{
Bind(new HelloResp(), (e) => {
new CapitalizeReq {
Input = e.Greeting
}.InResponseOf(e).Post();
});
Bind(new CapitalizeResp(), (e) => {
Console.WriteLine(e.Output);
});
}
}
public static void Main()
{
Hub.Instance
.Attach(new SingleThreadFlow()
.Add(new HelloCase())
.Add(new CapitalizerCase())
.Add(new LocalCase()));
using (new Hub.Flows().Startup())
{
while (true)
{
var input = Console.ReadLine();
if (input == "bye")
{
break;
}
new HelloReq { Name = input }.Post();
}
}
}
}
3-Tier Distribution: Client/FrontendServer/BackendServer
In an x2 application, adding or removing a distribution layer is not a big deal. All you need to do is setting up the required links to properly send/receive events.
Here is our backend server which runs the CapitalizerCase
as its main logic case:
public class HelloTcpBackend : AsyncTcpServer
{
public HelloTcpBackend() : base("HelloBackend")
{
}
protected override void Setup()
{
EventFactory.Register<CapitalizeReq>();
Bind(new CapitalizeResp(), Send);
Listen(7890);
}
public static void Main()
{
Hub.Instance
.Attach(new SingleThreadFlow()
.Add(new CapitalizerCase())
.Add(new HelloTcpBackend()));
using (new Hub.Flows().Startup())
{
while (true)
{
var input = Console.ReadLine();
if (input == "bye")
{
break;
}
}
}
}
}
We also build a frontend server that runs the HelloCase
as its main logic case and delegate the capitalization task to the backend server:
class BackendClient : AsyncTcpClient
{
public BackendClient() : base("BackendClient") {}
protected override void Setup()
{
EventFactory.Register<CapitalizeResp>();
Bind(new CapitalizeReq(), Send);
Connect("127.0.0.1", 7890);
}
}
public class HelloTcpFrontend : AsyncTcpServer
{
public HelloTcpFrontend() : base("HelloFrontend")
{
}
protected override void Setup()
{
EventFactory.Register<HelloReq>();
Bind(new HelloResp(), OnHelloResp);
Listen(6789);
}
public static void Main()
{
Hub.Instance
.Attach(new SingleThreadFlow()
.Add(new HelloCase())
.Add(new BackendClient())
.Add(new HelloTcpFrontend()));
using (new Hub.Flows().Startup())
{
while (true)
{
var input = Console.ReadLine();
if (input == "bye")
{
break;
}
}
}
}
IEnumerator OnHelloResp(Coroutine coroutine, HelloResp e)
{
int handle = e._Handle;
yield return coroutine.WaitForSingleResponse(
new CapitalizeReq { Input = e.Greeting },
new CapitalizeResp());
var result = coroutine.Result as CapitalizeResp;
if (result == null)
{
yield break;
}
result._Handle = handle;
Send(result);
}
}
In the previous client/server distribution, we relied on the built-in event property _Handle
to dispatch the response event to the appropriate session. But in this topology, we cannot do the same. If it was an authentication-based real-world application, we might bind events by authenticated user identifiers. However, in order to handle the case in this simple example, we bring up a special x2net coroutine handler as shown above.
Then we can use a similar client to connect to the frontend server to get things done:
public class HelloTcpClient : TcpClient
{
class LocalCase : Case
{
protected override void Setup()
{
Bind(new CapitalizeResp(), (e) => {
Console.WriteLine(e.Output);
});
}
}
public HelloTcpClient() : base("HelloClient")
{
}
protected override void Setup()
{
EventFactory.Register<CapitalizeResp>();
Bind(new HelloReq(), Send);
Connect("127.0.0.1", 6789);
}
public static void Main()
{
Hub.Instance
.Attach(new SingleThreadFlow()
.Add(new LocalCase())
.Add(new HelloTcpClient()));
using (new Hub.Flows().Startup())
{
while (true)
{
var input = Console.ReadLine();
if (input == "bye")
{
break;
}
new HelloReq { Name = input }.Post();
}
}
}
}
Points of Interest
The logic-communication decoupling itself is neither a new nor a popular concept. If you’re accustomed to SendPacket-like communication, it may take some time until you feel comfortable with x2-style distribution. This shift is somewhat like moving from message passing to generative communication, and it surely worth a try.