假设你有一个接口 IFoo,有 A、B 两个进程想做进程间通信。通过 IPC 的「远程对象调用」的方式,你可以让 A 进程调用 B 进程的 IFoo 接口方法,就像调用本地对象一样。
先定义好一个接口,这个接口将可被远程调用:
/// <summary>
/// 可跨进程调用的接口演示。
/// </summary>
[IpcPublic]
public interface IFoo
{
/// <summary>
/// 属性演示。支持 get/set 属性、get 只读属性,支持跨进程报告异常。
/// </summary>
string Name { get; set; }
/// <summary>
/// 方法演示。支持参数、返回值,支持跨进程报告异常。
/// </summary>
int Add(int a, int b);
/// <summary>
/// 异步方法(更推荐)演示。支持参数、返回值,支持跨进程报告异常。
/// </summary>
Task<string> AddAsync(string a, int b);
}现在,我们有两个进程 A 和 B:
- A 进程是调用端,想调用 B 进程的
IFoo接口方法。 - B 进程是被调用端,提供
IFoo接口的实现。
为了实现这样的跨进程调用,我们需要在 A 进程和 B 进程分别进行一些 IPC 的初始化。
// A 进程 Program.cs
// 1. 初始化 IPC
var ipcProvider = new IpcProvider("IPC-A");
// 2. 启动 IPC(以支持双向通信)
ipcProvider.StartServer();
// 3. 连接进程 B
var peer = await ipcProvider.GetAndConnectToPeerAsync("IPC-B");
// 获取来自 B 进程的 IFoo 接口的「代理」(Proxy)
var foo = ipcProvider.CreateIpcProxy<IFoo>(peer);
// 现在,开始自由地享受 IFoo 的远程调用吧!就好像它从来都是自己进程内的对象一样
Console.WriteLine(foo.Name);
Console.WriteLine(foo.Add(1, 2));
Console.WriteLine(await foo.AddAsync("a", 1));
Console.Read();// B 进程 Program.cs
// 1. 初始化 IPC
var ipcProvider = new IpcProvider("IPC-B");
// 创建 IFoo 的实际对象,然后为其创建一个「对接」(Joint)
ipcProvider.CreateIpcJoint<IFoo>(new Foo());
// 2. 启动 IPC(以支持双向通信)
// 这里,我们提前创建好了「对接」,再启动 IPC;这样 A 进程连接 B 进程时,就能马上使用 IFoo 了
ipcProvider.StartServer();
Console.Read();// B 进程,IFoo 的实际实现
class Foo : IFoo
{
public string Name { get; set; } = "Foo";
public int Add(int a, int b)
{
Console.WriteLine($"a({a})+b({b})={a + b}");
return a + b;
}
public async Task<string> AddAsync(string a, int b)
{
return await Task.Run(() =>
{
Console.WriteLine($"a({a})+b({b})={a + b}");
return a + b;
});
}
}以上,就是「远程对象调用」所需要的所有示例代码了。所有代码来自本仓库 https://github.com/dotnet-campus/dotnetCampus.Ipc/tree/main/demo/IpcRemotingObjectDemo。
上述 IPC 初始化的部分不变,实现不变的情况下,接口 IFoo 上的 [IpcPublic] 接口还有更多玩法。
// IgnoresIpcException = true
// - A 进程调用 IFoo 的「代理」时,忽略所有的 IPC 异常(即对方进程断开、IFoo 接口出现方法签名的变更等)
// - 但 A 进程仍然能收到 B 进程 IFoo 实现类的业务异常(如 ArgumentNullException)
// Timeout = 1000
// - A 进程调用 IFoo 的「代理」时,最多等待 1000 毫秒
// - 超出时间没有返回,则方法会立即返回;如果方法带有返回值,则会返回返回类型的默认值
[IpcPublic(IgnoresIpcException = true, Timeout = 1000)]这些是对 IFoo 这个接口的全局设置。当然,还可以对它内部的每个成员单独设置更多属性:
// DefaultReturn = "Error"
// - A 进程调用 IFoo「代理」的此方法时,如果真发生了 IPC 异常,则会返回指定的默认值
// - 在这里,是返回 "Error",而不是 string 的默认值 null(这可以避免破坏可空性)
[IpcMethod(DefaultReturn = "Error", IgnoresIpcException = true, Timeout = 2000)]
Task<string> AddAsync(string a, int b);特别的,对于 void 返回值的方法,还有一个属性 WaitsVoid:
// WaitsVoid = true
// - 默认情况下,IPC 不会等待 void 方法返回(因为库作者 @walterlv 认为如果你想等待,改用异步方法更好)
// - 这意味着你甚至还收不到 B 进程此方法实现的异常
// - 但如果你确实希望这个 void 像一个本地 void 一样等待,可以设置此属性
[IpcMethod(WaitsVoid = true)]
void Add(int a, int b);哦,对了,属性上也有属性可以设置哦:
// IsReadonly = true
// - 神奇吧,一个 get/set 属性设成只读有什么作用?
// - 这意味着 A 进程通过「代理」获取此属性的值时,会假设此属性不会再变了,于是缓存起来,只拿这一次;以后都使用这次的缓存
[IpcProperty(IsReadonly = true)]
string Name { get; set; }你可能注意到我们还有一个 IpcEvent 可以标在事件上,不过很遗憾地告诉你,目前还没实现事件。所以你会看到我们写了个分析器告诉你不要这么做。
好了,现在更麻烦的事来了。假设你有三个进程 A、B、C:
- A 仍然是调用端
- B 仍然是被调用端
- 新增了一个 C,跟 A 一样是调用端,但希望用不同的方式调用
IFoo这个接口怎么办?
我们前面介绍了 [IpcPublic] 特性,它可以用来标记接口及其成员,以详细定制各个成员的 IPC 行为。但是,它一旦在接口上标记了,就意味着所有进程对这个接口的调用都会遵循这个标记的规则。
有没有什么方法,能够允许我 A 和 C 进程使用不同的规则来调用 IFoo 接口呢?
答案就是 [IpcShape] 特性:
- 你可以在 C 进程里额外定义一个
IFoo接口的空实现 - 然后逐一设置这个空实现中你希望与
IFoo接口中所定义的不同的调用规则
[IpcShape(typeof(IFoo))]
internal class IpcFooShape : IFoo
{
// IFoo 接口上没有设置此属性,所以 A 进程是默认方式访问这个属性的
// 但是 C 进程通过 IpcShape,在不影响 A 进程访问规则的情况下,定制了 C 进程下的访问规则
[IpcProperty(IsReadonly = true)]
public string Name { get; set; } = null!;
public int Add(int a, int b) => throw null!;
public async Task<string> AddAsync(string a, int b) => throw null!;
}那么 C 进程的初始化需要有所变化:
var ipcProvider = new IpcProvider("IpcRemotingObjectClientDemo");
ipcProvider.StartServer();
var peer = await ipcProvider.GetAndConnectToPeerAsync("IpcRemotingObjectServerDemo");
// 获取来自 B 进程的 IFoo 接口的「代理」(Proxy)
-- var foo = ipcProvider.CreateIpcProxy<IFoo>(peer);
++ // 不过这次,我们使用了 IpcFooShape 这个「形状」(Shape)作为「代理」(Proxy)
++ var foo = ipcProvider.CreateIpcProxy<IFoo, IpcFooShape>(peer);
Console.WriteLine(foo.Name);
Console.WriteLine(foo.Add(1, 2));
Console.WriteLine(await foo.AddAsync("a", 1));
Console.Read();DotNetCampus.Ipc「远程对象调用」支持你嵌套 IPC 对象,这意味着你可以实现更加复杂的 IPC 需求。
/// <summary>
/// 嵌套 IPC 类型演示。
/// </summary>
[IpcPublic]
public interface IBar
{
/// <summary>
/// 这是一个超复杂的方法,参数和返回值都是 IPC 对象。
/// </summary>
Task<IBaz> AddAsync(IQux qux, IQuux quux);
}想想看,这里的每个类型,谁是调用方,谁是被调用方(实现方)?
假设 A 进程试图调用 B 进程的 IBar 接口:
| 类型 | A 进程 | B 进程 | 描述 |
|---|---|---|---|
IBar |
代理 | 实现(需要对接) | A 进程调用 IBar 的代理以访问 B 进程 |
IBaz |
代理 | 对接(无需对接) | A 进程拿到了 B 进程的 IBaz 的代理 |
IQux |
实现(无需对接) | 代理 | A 进程有一个 IQux 的实现,会传给 B 进程去用 |
IQuux |
实现(无需对接) | 代理 | A 进程有一个 IQuux 的实现,会传给 B 进程去用 |
这里的 IBaz IQux IQuux 都需要标记 [IpcPublic] 或 [IpcShape](当然,我们的分析器也会提示你需要标的)。但好在你不需要额外编写对接代码;我的意思是 IPC 的初始化代码里,你只需要处理 IBar 这一个接口就够了,剩下的 DotNetCampus.Ipc 会帮你完成。
本 IPC 库有两种类型的异常:
IpcLocalException: 表示异常发生在本地进程中IpcRemoteException: 表示异常发生在远端进程中,或者发生在 IPC 通信过程中
你可能在初始化等过程中收到各种异常,不过「远程对象调用」中只会收到以下这些:
- 本地异常
IpcLocalException- 「远程对象调用」几乎不会发生本地异常
- 代理异常,如果 IPC 接口的实现方法内抛出了以下这几种异常,则会在调用方也代理出相同类型的异常(这个列表详见 这里,你也可以提 PR 修改这个列表)
ArgumentExceptionArgumentNullExceptionBadImageFormatExceptionInvalidCastExceptionInvalidOperationExceptionNotImplementedExceptionNotSupportedExceptionNullReferenceException
- 远端异常
IpcRemoteExceptionIpcInvokingException: 如果 IPC 接口的实现方法内抛出了上述异常之外的其他异常,则会包装成此异常IpcInvokingTimeoutException: 远程对象调用超时(如前面所说,设置Timeout属性后可以支持超时)
所以,大多数情况下,你只需要像一个本地对象一样去处理异常即可。
不过,如果你想更加可靠一些处理异常,我们正计划做「自动代理」功能,以便能更好地用通用的方式来处理「远程对象调用」中发生的远端非业务性异常。功能计划中,文件夹已经建好了,请耐心等待。
- DotNetCampus.Ipc 库使用源生成器生成「代理」(Proxy)和「对接」(Joint)的代码,旨在提升性能和确保 AOT 兼容性。
- 目前还仍有少量代码在使用反射,不过我们计划很快将其完全消除
- DotNetCampus.Ipc 已移除 Newtonsoft.Json 库,完全使用 System.Text.Json 并配合源生成器来做跨进程对象的传输,旨在大幅减少 AOT 之后的大小。
- 想要享受到完全移除 Newtonsoft.Json 库的好处,你的项目框架至少要到 .NET 8
为了更好地发挥 IPC「远程对象调用」的代码编写直观性优势,同时又避免不太喜欢的行为,库作者 @walterlv 推荐:
- IPC 接口中尽量全部使用异步方法
- 除非你希望这个对象用起来更加像一个本地对象一样,拥有属性、同步的方法
- 如果一定要用同步方法,也请避免使用 void 返回值
- 如果真用了 void 返回值,请认真考虑一下要不要设置
WaitsVoid属性
- 如果真用了 void 返回值,请认真考虑一下要不要设置
好了,就这些。其他你随便。