|
| 1 | +# 使用「远程对象调用」的方式做进程间通信 |
| 2 | + |
| 3 | +假设你有一个接口 `IFoo`,有 A、B 两个进程想做进程间通信。通过 IPC 的「远程对象调用」的方式,你可以让 A 进程调用 B 进程的 `IFoo` 接口方法,就像调用本地对象一样。 |
| 4 | + |
| 5 | +## 快速入门 |
| 6 | + |
| 7 | +先定义好一个接口,这个接口将可被远程调用: |
| 8 | + |
| 9 | +```csharp |
| 10 | +/// <summary> |
| 11 | +/// 可跨进程调用的接口演示。 |
| 12 | +/// </summary> |
| 13 | +[IpcPublic] |
| 14 | +public interface IFoo |
| 15 | +{ |
| 16 | + /// <summary> |
| 17 | + /// 属性演示。支持 get/set 属性、get 只读属性,支持跨进程报告异常。 |
| 18 | + /// </summary> |
| 19 | + string Name { get; set; } |
| 20 | + |
| 21 | + /// <summary> |
| 22 | + /// 方法演示。支持参数、返回值,支持跨进程报告异常。 |
| 23 | + /// </summary> |
| 24 | + int Add(int a, int b); |
| 25 | + |
| 26 | + /// <summary> |
| 27 | + /// 异步方法(更推荐)演示。支持参数、返回值,支持跨进程报告异常。 |
| 28 | + /// </summary> |
| 29 | + Task<string> AddAsync(string a, int b); |
| 30 | +} |
| 31 | +``` |
| 32 | + |
| 33 | +现在,我们有两个进程 A 和 B: |
| 34 | + |
| 35 | +- A 进程是调用端,想调用 B 进程的 `IFoo` 接口方法。 |
| 36 | +- B 进程是被调用端,提供 `IFoo` 接口的实现。 |
| 37 | + |
| 38 | +为了实现这样的跨进程调用,我们需要在 A 进程和 B 进程分别进行一些 IPC 的初始化。 |
| 39 | + |
| 40 | +```csharp |
| 41 | +// A 进程 Program.cs |
| 42 | +
|
| 43 | +// 1. 初始化 IPC |
| 44 | +var ipcProvider = new IpcProvider("IPC-A"); |
| 45 | +// 2. 启动 IPC(以支持双向通信) |
| 46 | +ipcProvider.StartServer(); |
| 47 | +// 3. 连接进程 B |
| 48 | +var peer = await ipcProvider.GetAndConnectToPeerAsync("IPC-B"); |
| 49 | + |
| 50 | +// 获取来自 B 进程的 IFoo 接口的「代理」(Proxy) |
| 51 | +var foo = ipcProvider.CreateIpcProxy<IFoo>(peer); |
| 52 | + |
| 53 | +// 现在,开始自由地享受 IFoo 的远程调用吧!就好像它从来都是自己进程内的对象一样 |
| 54 | +Console.WriteLine(foo.Name); |
| 55 | +Console.WriteLine(foo.Add(1, 2)); |
| 56 | +Console.WriteLine(await foo.AddAsync("a", 1)); |
| 57 | +Console.Read(); |
| 58 | +``` |
| 59 | + |
| 60 | +```csharp |
| 61 | +// B 进程 Program.cs |
| 62 | +
|
| 63 | +// 1. 初始化 IPC |
| 64 | +var ipcProvider = new IpcProvider("IPC-B"); |
| 65 | + |
| 66 | +// 创建 IFoo 的实际对象,然后为其创建一个「对接」(Joint) |
| 67 | +ipcProvider.CreateIpcJoint<IFoo>(new Foo()); |
| 68 | + |
| 69 | +// 2. 启动 IPC(以支持双向通信) |
| 70 | +// 这里,我们提前创建好了「对接」,再启动 IPC;这样 A 进程连接 B 进程时,就能马上使用 IFoo 了 |
| 71 | +ipcProvider.StartServer(); |
| 72 | + |
| 73 | +Console.Read(); |
| 74 | +``` |
| 75 | + |
| 76 | +```csharp |
| 77 | +// B 进程,IFoo 的实际实现 |
| 78 | +class Foo : IFoo |
| 79 | +{ |
| 80 | + public string Name { get; set; } = "Foo"; |
| 81 | + |
| 82 | + public int Add(int a, int b) |
| 83 | + { |
| 84 | + Console.WriteLine($"a({a})+b({b})={a + b}"); |
| 85 | + return a + b; |
| 86 | + } |
| 87 | + |
| 88 | + public async Task<string> AddAsync(string a, int b) |
| 89 | + { |
| 90 | + return await Task.Run(() => |
| 91 | + { |
| 92 | + Console.WriteLine($"a({a})+b({b})={a + b}"); |
| 93 | + return a + b; |
| 94 | + }); |
| 95 | + } |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +以上,就是「远程对象调用」所需要的所有示例代码了。所有代码来自本仓库 <https://github.com/dotnet-campus/dotnetCampus.Ipc/tree/main/demo/IpcRemotingObjectDemo>。 |
| 100 | + |
| 101 | +## 进阶用法 |
| 102 | + |
| 103 | +### `IpcPublic` |
| 104 | + |
| 105 | +上述 IPC 初始化的部分不变,实现不变的情况下,接口 `IFoo` 上的 `[IpcPublic]` 接口还有更多玩法。 |
| 106 | + |
| 107 | +```csharp |
| 108 | +// IgnoresIpcException = true |
| 109 | +// - A 进程调用 IFoo 的「代理」时,忽略所有的 IPC 异常(即对方进程断开、IFoo 接口出现方法签名的变更等) |
| 110 | +// - 但 A 进程仍然能收到 B 进程 IFoo 实现类的业务异常(如 ArgumentNullException) |
| 111 | +// Timeout = 1000 |
| 112 | +// - A 进程调用 IFoo 的「代理」时,最多等待 1000 毫秒 |
| 113 | +// - 超出时间没有返回,则方法会立即返回;如果方法带有返回值,则会返回返回类型的默认值 |
| 114 | +[IpcPublic(IgnoresIpcException = true, Timeout = 1000)] |
| 115 | +``` |
| 116 | + |
| 117 | +这些是对 `IFoo` 这个接口的全局设置。当然,还可以对它内部的每个成员单独设置更多属性: |
| 118 | + |
| 119 | +```csharp |
| 120 | +// DefaultReturn = "Error" |
| 121 | +// - A 进程调用 IFoo「代理」的此方法时,如果真发生了 IPC 异常,则会返回指定的默认值 |
| 122 | +// - 在这里,是返回 "Error",而不是 string 的默认值 null(这可以避免破坏可空性) |
| 123 | +[IpcMethod(DefaultReturn = "Error", IgnoresIpcException = true, Timeout = 2000)] |
| 124 | +Task<string> AddAsync(string a, int b); |
| 125 | +``` |
| 126 | + |
| 127 | +特别的,对于 void 返回值的方法,还有一个属性 `WaitsVoid`: |
| 128 | + |
| 129 | +```csharp |
| 130 | +// WaitsVoid = true |
| 131 | +// - 默认情况下,IPC 不会等待 void 方法返回(因为库作者 @walterlv 认为如果你想等待,改用异步方法更好) |
| 132 | +// - 这意味着你甚至还收不到 B 进程此方法实现的异常 |
| 133 | +// - 但如果你确实希望这个 void 像一个本地 void 一样等待,可以设置此属性 |
| 134 | +[IpcMethod(WaitsVoid = true)] |
| 135 | +void Add(int a, int b); |
| 136 | +``` |
| 137 | + |
| 138 | +哦,对了,属性上也有属性可以设置哦: |
| 139 | + |
| 140 | +```csharp |
| 141 | +// IsReadonly = true |
| 142 | +// - 神奇吧,一个 get/set 属性设成只读有什么作用? |
| 143 | +// - 这意味着 A 进程通过「代理」获取此属性的值时,会假设此属性不会再变了,于是缓存起来,只拿这一次;以后都使用这次的缓存 |
| 144 | +[IpcProperty(IsReadonly = true)] |
| 145 | +string Name { get; set; } |
| 146 | +``` |
| 147 | + |
| 148 | +你可能注意到我们还有一个 `IpcEvent` 可以标在事件上,不过很遗憾地告诉你,目前还没实现事件。所以你会看到我们写了个分析器告诉你不要这么做。 |
| 149 | + |
| 150 | +### `IpcShape` |
| 151 | + |
| 152 | +好了,现在更麻烦的事来了。假设你有三个进程 A、B、C: |
| 153 | + |
| 154 | +- A 仍然是调用端 |
| 155 | +- B 仍然是被调用端 |
| 156 | +- 新增了一个 C,跟 A 一样是调用端,但希望用不同的方式调用 `IFoo` 这个接口怎么办? |
| 157 | + |
| 158 | +我们前面介绍了 `[IpcPublic]` 特性,它可以用来标记接口及其成员,以详细定制各个成员的 IPC 行为。但是,它一旦在接口上标记了,就意味着所有进程对这个接口的调用都会遵循这个标记的规则。 |
| 159 | + |
| 160 | +有没有什么方法,能够允许我 A 和 C 进程使用不同的规则来调用 `IFoo` 接口呢? |
| 161 | + |
| 162 | +答案就是 `[IpcShape]` 特性: |
| 163 | + |
| 164 | +- 你可以在 C 进程里额外定义一个 `IFoo` 接口的空实现 |
| 165 | +- 然后逐一设置这个空实现中你希望与 `IFoo` 接口中所定义的不同的调用规则 |
| 166 | + |
| 167 | +```csharp |
| 168 | +[IpcShape(typeof(IFoo))] |
| 169 | +internal class IpcFooShape : IFoo |
| 170 | +{ |
| 171 | + // IFoo 接口上没有设置此属性,所以 A 进程是默认方式访问这个属性的 |
| 172 | + // 但是 C 进程通过 IpcShape,在不影响 A 进程访问规则的情况下,定制了 C 进程下的访问规则 |
| 173 | + [IpcProperty(IsReadonly = true)] |
| 174 | + public string Name { get; set; } = null!; |
| 175 | + |
| 176 | + public int Add(int a, int b) => throw null!; |
| 177 | + public async Task<string> AddAsync(string a, int b) => throw null!; |
| 178 | +} |
| 179 | +``` |
| 180 | + |
| 181 | +那么 C 进程的初始化需要有所变化: |
| 182 | + |
| 183 | +```diff |
| 184 | + var ipcProvider = new IpcProvider("IpcRemotingObjectClientDemo"); |
| 185 | + ipcProvider.StartServer(); |
| 186 | + var peer = await ipcProvider.GetAndConnectToPeerAsync("IpcRemotingObjectServerDemo"); |
| 187 | + |
| 188 | + // 获取来自 B 进程的 IFoo 接口的「代理」(Proxy) |
| 189 | +-- var foo = ipcProvider.CreateIpcProxy<IFoo>(peer); |
| 190 | +++ // 不过这次,我们使用了 IpcFooShape 这个「形状」(Shape)作为「代理」(Proxy) |
| 191 | +++ var foo = ipcProvider.CreateIpcProxy<IpcFooShape>(peer); |
| 192 | + |
| 193 | + Console.WriteLine(foo.Name); |
| 194 | + Console.WriteLine(foo.Add(1, 2)); |
| 195 | + Console.WriteLine(await foo.AddAsync("a", 1)); |
| 196 | + Console.Read(); |
| 197 | +``` |
| 198 | + |
| 199 | +## 高级用法 |
| 200 | + |
| 201 | +DotNetCampus.Ipc「远程对象调用」支持你嵌套 IPC 对象,这意味着你可以实现更加复杂的 IPC 需求。 |
| 202 | + |
| 203 | +```csharp |
| 204 | +/// <summary> |
| 205 | +/// 嵌套 IPC 类型演示。 |
| 206 | +/// </summary> |
| 207 | +[IpcPublic] |
| 208 | +public interface IBar |
| 209 | +{ |
| 210 | + /// <summary> |
| 211 | + /// 这是一个超复杂的方法,参数和返回值都是 IPC 对象。 |
| 212 | + /// </summary> |
| 213 | + Task<IBaz> AddAsync(IQux qux, IQuux quux); |
| 214 | +} |
| 215 | +``` |
| 216 | + |
| 217 | +想想看,这里的每个类型,谁是调用方,谁是被调用方(实现方)? |
| 218 | + |
| 219 | +假设 A 进程试图调用 B 进程的 `IBar` 接口: |
| 220 | + |
| 221 | +| 类型 | A 进程 | B 进程 | 描述 | |
| 222 | +| ------- | ---------------- | ---------------- | -------------------------------------------- | |
| 223 | +| `IBar` | 代理 | 实现(需要对接) | A 进程调用 `IBar` 的代理以访问 B 进程 | |
| 224 | +| `IBaz` | 代理 | 对接(无需对接) | A 进程拿到了 B 进程的 `IBaz` 的代理 | |
| 225 | +| `IQux` | 实现(无需对接) | 代理 | A 进程有一个 IQux 的实现,会传给 B 进程去用 | |
| 226 | +| `IQuux` | 实现(无需对接) | 代理 | A 进程有一个 IQuux 的实现,会传给 B 进程去用 | |
| 227 | + |
| 228 | +这里的 `IBaz` `IQux` `IQuux` 都需要标记 `[IpcPublic]` 或 `[IpcShape]`(当然,我们的分析器也会提示你需要标的)。但好在你不需要额外编写对接代码;我的意思是 IPC 的初始化代码里,你只需要处理 `IBar` 这一个接口就够了,剩下的 DotNetCampus.Ipc 会帮你完成。 |
| 229 | + |
| 230 | +## 异常处理 |
| 231 | + |
| 232 | +本 IPC 库有两种类型的异常: |
| 233 | + |
| 234 | +- `IpcLocalException`: 表示异常发生在本地进程中 |
| 235 | +- `IpcRemoteException`: 表示异常发生在远端进程中,或者发生在 IPC 通信过程中 |
| 236 | + |
| 237 | +你可能在初始化等过程中收到各种异常,不过「远程对象调用」中只会收到以下这些: |
| 238 | + |
| 239 | +- 本地异常 `IpcLocalException` |
| 240 | + - 「远程对象调用」几乎不会发生本地异常 |
| 241 | +- 代理异常,如果 IPC 接口的实现方法内抛出了以下这几种异常,则会在调用方也代理出相同类型的异常(这个列表详见 [这里](../src/dotnetCampus.Ipc/CompilerServices/GeneratedProxies/Models/GeneratedProxyExceptionModel.cs),你也可以提 PR 修改这个列表) |
| 242 | + - `ArgumentException` |
| 243 | + - `ArgumentNullException` |
| 244 | + - `BadImageFormatException` |
| 245 | + - `InvalidCastException` |
| 246 | + - `InvalidOperationException` |
| 247 | + - `NotImplementedException` |
| 248 | + - `NotSupportedException` |
| 249 | + - `NullReferenceException` |
| 250 | +- 远端异常 `IpcRemoteException` |
| 251 | + - `IpcInvokingException`: 如果 IPC 接口的实现方法内抛出了上述异常之外的其他异常,则会包装成此异常 |
| 252 | + - `IpcInvokingTimeoutException`: 远程对象调用超时(如前面所说,设置 `Timeout` 属性后可以支持超时) |
| 253 | + |
| 254 | +所以,大多数情况下,你只需要像一个本地对象一样去处理异常即可。 |
| 255 | + |
| 256 | +不过,如果你想更加可靠一些处理异常,我们正计划做「自动代理」功能,以便能更好地用通用的方式来处理「远程对象调用」中发生的远端**非业务性**异常。功能计划中,文件夹已经建好了,请耐心等待。 |
| 257 | + |
| 258 | +## 性能和 AOT 兼容性 |
| 259 | + |
| 260 | +1. DotNetCampus.Ipc 库使用源生成器生成「代理」(Proxy)和「对接」(Joint)的代码,旨在提升性能和确保 AOT 兼容性。 |
| 261 | + - 目前还仍有少量代码在使用反射,不过我们计划很快将其完全消除 |
| 262 | +2. DotNetCampus.Ipc 已移除 Newtonsoft.Json 库,完全使用 System.Text.Json 并配合源生成器来做跨进程对象的传输,旨在大幅减少 AOT 之后的大小。 |
| 263 | + - **想要享受到完全移除 Newtonsoft.Json 库的好处,你的项目框架至少要到 .NET 8** |
| 264 | + |
| 265 | +## 最佳实践 |
| 266 | + |
| 267 | +为了更好地发挥 IPC「远程对象调用」的代码编写直观性优势,同时又避免不太喜欢的行为,库作者 @walterlv 推荐: |
| 268 | + |
| 269 | +1. IPC 接口中尽量全部使用异步方法 |
| 270 | + - 除非你希望这个对象用起来更加像一个本地对象一样,拥有属性、同步的方法 |
| 271 | +2. 如果一定要用同步方法,也请避免使用 void 返回值 |
| 272 | + - 如果真用了 void 返回值,请认真考虑一下要不要设置 `WaitsVoid` 属性 |
| 273 | + |
| 274 | +好了,就这些。其他你随便。 |
0 commit comments