Ten minutes, three projects, one working RPC call. Everything below is copy-pasteable.
The contract has to be the same assembly on both sides, so it lives in its own class library that the host and the client both reference.
dotnet new sln -n MathDemo
dotnet new classlib -n MathDemo.Contracts
dotnet new console -n MathDemo.Host
dotnet new console -n MathDemo.Client
dotnet sln add MathDemo.Contracts MathDemo.Host MathDemo.Client
dotnet add MathDemo.Host reference MathDemo.Contracts
dotnet add MathDemo.Client reference MathDemo.Contracts
dotnet add MathDemo.Host package ServiceWire
dotnet add MathDemo.Client package ServiceWireMathDemo.Contracts does not need a ServiceWire reference. It is plain interfaces and data types.
Important: build for
AnyCPUorx64. The runtime-generated proxy will not run as x86 on an x64 machine, and Visual Studio's console template has Prefer 32-bit on by default. In SDK-style projects this is<PlatformTarget>AnyCPU</PlatformTarget>and<Prefer32Bit>false</Prefer32Bit>.
MathDemo.Contracts/IMath.cs:
using System;
using System.Collections.Generic;
namespace MathDemo.Contracts
{
public interface IMath
{
int Add(int a, int b);
// out and ref parameters come back filled in
bool TryDivide(int numerator, int denominator, out int quotient);
// your own types travel too
Statistics Summarize(IList<double> samples);
}
public class Statistics
{
public int Count { get; set; }
public double Mean { get; set; }
public double Max { get; set; }
}
}No attributes, no base class, no [Serializable]. See Designing contracts for what a signature may and may not contain.
MathDemo.Host/Program.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using MathDemo.Contracts;
using ServiceWire.TcpIp;
var host = new TcpHost(8098);
host.AddService<IMath>(new MathService());
host.Open();
Console.WriteLine($"Listening on {host.EndPoint}. Press Enter to stop.");
Console.ReadLine();
host.Close();
public class MathService : IMath
{
public int Add(int a, int b) => a + b;
public bool TryDivide(int numerator, int denominator, out int quotient)
{
if (denominator == 0)
{
quotient = 0;
return false;
}
quotient = numerator / denominator;
return true;
}
public Statistics Summarize(IList<double> samples)
{
if (samples == null || samples.Count == 0)
throw new ArgumentException("No samples supplied.", nameof(samples));
return new Statistics
{
Count = samples.Count,
Mean = samples.Average(),
Max = samples.Max()
};
}
}Three rules for the host:
- Add every service before
Open().AddServicethrows once the host is open. - The instance you pass is a singleton. Every client shares it, so make it thread-safe.
- Closing the host disposes your service if it implements
IDisposable.
MathDemo.Client/Program.cs:
using System;
using System.Collections.Generic;
using System.Net;
using MathDemo.Contracts;
using ServiceWire.TcpIp;
var endPoint = new TcpEndPoint(new IPEndPoint(IPAddress.Loopback, 8098));
using var client = new TcpClient<IMath>(endPoint);
Console.WriteLine(client.Proxy.Add(2, 3)); // 5
if (client.Proxy.TryDivide(22, 7, out int quotient))
Console.WriteLine(quotient); // 3
var stats = client.Proxy.Summarize(new List<double> { 1.5, 2.5, 9.0 });
Console.WriteLine($"{stats.Count} samples, mean {stats.Mean:F2}, max {stats.Max}");
try
{
client.Proxy.Summarize(new List<double>());
}
catch (ArgumentException ex)
{
// thrown on the host, rethrown here, with the server stack trace preserved
Console.WriteLine($"server said: {ex.Message}");
}
ServiceWire.TcpIp.TcpClient<T>andSystem.Net.Sockets.TcpClienthave the same name. If you need both namespaces in one file, alias one of them:using SwTcpClient = ServiceWire.TcpIp.TcpClient<MathDemo.Contracts.IMath>;
Two terminals:
dotnet run --project MathDemo.Hostdotnet run --project MathDemo.ClientExpected client output:
5
3
3 samples, mean 4.33, max 9
server said: No samples supplied.
Both processes on one machine? Change two type names and drop the port.
// host
using var host = new NpHost("mathdemo");
host.AddService<IMath>(new MathService());
host.Open();
// client
using var client = new NpClient<IMath>(new NpEndPoint("mathdemo"));
Console.WriteLine(client.Proxy.Add(2, 3));Named pipes are roughly 2–3× cheaper per call than loopback TCP and need no port or firewall rule. Transports and endpoints covers the trade-offs.
Hold the client open. Creating a TcpClient<T> opens a socket and fetches the method map. Do that once and reuse it — a proxy is safe to share across threads, and on TCP concurrent calls pipeline over the single connection.
// registered as a singleton in DI, disposed on shutdown
services.AddSingleton(_ => new TcpClient<IMath>(endPoint));
services.AddSingleton(sp => sp.GetRequiredService<TcpClient<IMath>>().Proxy);Host several interfaces on one endpoint. Each AddService<T> call adds another contract to the same listener; clients pick one by the type argument they ask for.
host.AddService<IMath>(new MathService());
host.AddService<IInventory>(new InventoryService());
host.Open();Host one implementation on several endpoints. The same singleton can be added to a TcpHost and an NpHost at once, so local callers get the fast pipe and remote callers get the socket.
- Designing contracts — what a method signature may contain
- Serialization and compression — your own types and your own serializer
- Logging and diagnostics — see what the host is doing
- Security — authenticate callers and encrypt the link