[C#] IpcChannel雙向通訊,參考MAF的AddInProcess開發外掛,服務斷開重新開啟及服務生存週期管理
先前專案太忙了,沒時間寫部落格,發現了一個有趣的東西,匆匆忙忙就寫完了,先描述一下需求背景:客戶端有幾張百萬級別的表需要聯合統計(如果是最大許可權的賬號),改變查詢條件又要重新統計,因此常常sql執行還沒結束就取消了,但不管關閉資料庫還是結束執行緒都必須等到sql執行結束,無奈之下只能考慮程序通訊,取消就直接殺掉程序,首先考慮的是現成的MAF,於是有了下面的程式碼:
僅僅一個介面就需要建這麼多個專案,DLL多點也就算了,相關資料夾還必須如上圖這麼放,這麼放就算了,但是公共的DLL(如System.Data.SQLite.dll)還不能一起用,程序隔離的話公共DLL必須外掛、主程式各自一份,這不是打包的時候莫名增加了體積嗎?這肯定不能接受啊,但我還沒有放棄,因為ProcessStartInfo可以指定工作目錄,既我只要指定主程式根目錄為工作目錄,還是可以實現DLL共享,但是絕望的是AddInProcess是密封的,無法控制它是如何啟動的。雖然不滿足期望,好處是在看它的原始碼時發現是基於IpcChannel通訊的,去
服務端提供什麼服務應有客服端指定,既需要一個空白程序,負責載入服務所在程式集,並提供服務,經過一段時間磨礪,發現不需要建立客戶端,下面的程式碼就可以實現:
// 客服端啟動代理類 public sealed class IpcProcessProxy<T> { public T Proxy => proxy; public event EventHandler Exited; private Process process; private T proxy;public bool Start(int startUpTimeout = 3000) { var type = typeof(T); // 鍥約型別 var ipcServerDic = new Dictionary<string, string> { ["name"] = "ipcServer", // 服務端名稱 ["portName"] = Guid.NewGuid().ToString("N") // 服務端地址 }; var contractDic = newDictionary<string, string> { ["assemblyName"] = type.Assembly.GetName().Name, // 程式集 ["typeName"] = typeName, // 型別名稱 ["objectUri"] = objectUri // Ipc提供的服務名稱 }; var arg1 = String.Join("&", ipcServerDic.Select(q => $"{q.Key}={q.Value}")); var arg2 = String.Join("&", contractDic.Select(q => $"{q.Key}={q.Value}")); var startInfo = new ProcessStartInfo { CreateNoWindow = true, UseShellExecute = false, FileName = "IpcProcess.exe", Arguments = $"{arg1} {arg2}", WorkingDirectory = Environment.CurrentDirectory }; process = Process.Start(startInfo); // 啟動程序,並根據引數啟動服務 process.Exited += Exited; // 等待服務端服務啟動 using (var readyEvent = new EventWaitHandle(false, EventResetMode.ManualReset, "IpcProxy:" + ipcServerDic["portName"])) { if (readyEvent.WaitOne(startUpTimeout, false)) { if (!token.IsCancellationRequested && !isDisposed) { var url = $"ipc://{ipcServerDic["portName"]}/{contractDic["objectUri"]}"; proxy = (T)Activator.GetObject(typeof(T), url); // 通過約定url,獲取服務端服務代理物件 return proxy != null; } } } } } // 服務端 static void Main(string[] args) { var arg1 = args[0]; var arg2 = args[1]; var ipcServerDic = new Dictionary<string, string>(); var contractDic = new Dictionary<string, string>() foreach (var kvStr in arg1.Split('&')) { var kv = kvStr.Split('='); ipcServerDic.Add(kv[0], kv[1]); } foreach (var kvStr in arg2.Split('&')) { var kv = kvStr.Split('='); contractDic.Add(kv[0], kv[1]); } var assembly = Assembly.Load(contractDic["assemblyName"]); // 載入程式集 var type = assembly.GetType(contractDic["typeName"], true, false) // 註冊Ipc服務 var channel = new IpcServerChannel(ipcServerDic, new BinaryServerFormatterSinkProvider()); ChannelServices.RegisterChannel(channel, false) // 建立服務物件,並建立代理 var serverObj = (MarshalByRefObject)Activator.CreateInstance(type); RemotingServices.Marshal(serverObj, contractDic["objectUri"]); // 通知客服端,服務已經啟動了 var readyEvent = new EventWaitHandle(false, EventResetMode.ManualReset, "IpcProxy:" + ipcServerDic["portName"]); readyEvent.Set(); Console.ReadLine(); }
這裡和官方最大的區別是沒有在服務端使用RegisterWellKnownServiceType,也沒有建立客服端,更沒有在客服端使用WellKnownClientTypeEntry,而是在服務端使用RemotingServices.Marshal開啟一個服務代理,在客服端使用Activator.GetObject獲取服務代理物件,這樣做的好處是:在服務端,先拿到物件(如果繼承特定型別,可進行特殊處理,後續雙向通訊會用到),在客服端,不需要通過new()獲取服務,而且說不定其他地方new()這個型別並不想用代理服務,再就是Activator.GetObject不需要直接引用型別,可以是繼承的介面,這樣使程式碼更低耦合。
以上就是簡單的IpcChannel單向通訊,適用於呼叫服務並返回結果,但不適用於大多數外掛場景,比如分批返回多個結果,或者某個方法傳入回撥函式通知進度;一個功能複雜的外掛往往需要雙向通訊,我就有這方面的需求,因此開始不斷地探索...
嘗試一:給遠端物件新增一個事件Event,這樣客服端監聽這個事件就可以得到通知,但發現只要某個物件監聽了這個事件,那麼它也會被視為服務端物件,最後發現客服端永遠也不可能拿到這個事件。
嘗試二:在服務端註冊一個IpcServerChannel,在客服端註冊一個IpcClientChannel,然後研究他們之間是怎麼通訊的,突破口應該就在他們的建構函式的第二個引數,分別實現了發生訊息和處理請求的方法,但是已經超出我的理解範圍了。
嘗試三:在服務端和客服端分別註冊一個IpcServerChannel,當服務端想要回調給客戶端時,就向客服端傳送訊息,這就必須要求服務端的代理服務物件能拿到客服端的代理服務物件,這樣才能呼叫客服端代理服務物件方法,思路是這樣的:
1、首選在客服端註冊Ipc服務,並提供服務代理
2、啟動服務端程序,並啟動自身的服務(繼承特定型別,新增一個事件,當需要回調時觸發),根據約定,找到客服端提供的代理服務,監聽自身服務回撥事件,事件觸發時呼叫客服端代理服務方法
3、客服端代理服務收到請求時,再觸發類似事件,提供給外部呼叫者使用
下面是啟動代理服務的核心程式碼:
public class IpcProcessProxy : ISponsor { static IpcProcessProxy() { // 客服端服務地址,將此作為引數傳遞給服務程序 ipcClientDic = new Dictionary<string, string> { ["name"] = "ipcClient", ["portName"] = Guid.NewGuid().ToString("N") }; // 客服端的代理物件 callRemoteObj = new CallRemoteObject(ipcClientDic["portName"]); callRemoteObj.Called += OnCalled; // 當收到回撥時,觸發此事件 } [SecurityPermission(SecurityAction.LinkDemand, Flags = SecurityPermissionFlag.Infrastructure)] protected void Start() { try { lock (locker) { // 建立Ipc服務 if (channel == null) channel = new IpcServerChannel(ipcClientDic, new BinaryServerFormatterSinkProvider()); // 註冊服務 if (ChannelServices.GetChannel(channel.ChannelName) == null) ChannelServices.RegisterChannel(channel, false); // 啟動代理服務 objRef = RemotingServices.Marshal(callRemoteObj, "CallRemoteObject"); // 管理服務的生存週期 if (lease == null) { lease = (ILease)RemotingServices.GetLifetimeService(callRemoteObj); lease.Register(this); } } } catch (Exception ex) { Console.WriteLine(ex); Console.WriteLine("啟動Ipc通訊服務失敗!"); } } public TimeSpan Renewal(ILease lease) { Console.WriteLine("租約到期,生存狀態:" + lease.CurrentState); return TimeSpan.FromSeconds(30); } /// <summary> /// 註冊要監聽的回撥方法 /// </summary> /// <param name="url">服務端Ipc地址</param> /// <param name="methodName">回撥方法名稱</param> /// <param name="callback">監聽回撥方法的委託</param> protected void RegisterCallback(string url, string methodName, Delegate callback) { if (callback == null) return; lock (locker) { var callbacks = urls.GetOrAdd(url, new Dictionary<string, Delegate>()); callbacks.Set(methodName, callback); } } protected void UnRegisterCallback(string url) { lock (locker) { urls.Remove(url); } } /// <summary> /// 收到服務端的回撥 /// </summary> /// <param name="url">服務端Ipc地址</param> /// <param name="methodName">回撥方法名稱</param> /// <param name="parameters">回撥方法引數</param> private static void OnCalled(string url, string methodName, object[] parameters) { lock (locker) { // 找到監聽該地址且指定方法的委託 if (!urls.TryGetValue(url, out Dictionary<string, Delegate> callbacks) || !callbacks.TryGetValue(methodName, out Delegate callback) || callback == null) return; // 執行委託 callback.DynamicInvoke(parameters); } } protected void Close() { if (lease != null) { lease.Unregister(this); lease = null; } if (objRef != null) { RemotingServices.Unmarshal(objRef); objRef = null; } if (callRemoteObj != null) { RemotingServices.Disconnect(callRemoteObj); callRemoteObj = null; } if (channel != null) { ChannelServices.UnregisterChannel(channel); channel = null; } } private static IpcServerChannel channel; private static CallRemoteObject callRemoteObj; private static ObjRef objRef; private static Dictionary<string, Dictionary<string, Delegate>> urls = new Dictionary<string, Dictionary<string, Delegate>>(); protected static Dictionary<string, string> ipcClientDic; private static object locker = new object(); private static ILease lease; }
專案資源我上傳到了CSDN(IpcChannel雙向通訊,參考MAF的AddInProcess開發外掛,服務斷開重新開啟及服務生存週期管理),本人部落格小白一個,有時需要下載CSDN的資源卻沒有積分,所以想到這個方法,理解萬歲!
使用這套IpcChannel雙向通訊也有一個星期,遇到了幾個問題,比如異常型別:System.Runtime.Remoting.RemotingException,異常資訊:物件“/CallRemoteObject”已經斷開連線或不在伺服器上。對於此問題可能是我總是強制結束程序,在傳輸過程中斷開導致的服務掛掉,但測試發現,無論在傳送時結束程序,還是在接收時,都無法復現這個異常,希望知道的留言告訴我怎麼必然復現,因此只能把希望寄託於如何重新開啟服務,經過嘗試發現只要RemotingServices.Marshal就行,代理的服務還是那個物件,如果在建立一個新的,就會提示:System.Runtime.Remoting.RemotingException: 找到與同一 URI“/49cb660d_9357_4a1a_9732_4488fbad07eb/CallRemoteObject”關聯 的兩個不同物件。
為了更穩妥,因為還是無法找到服務關閉的原因,使用了RemotingServices.GetLifetimeService管理服務的生存週期,應該是生效了,後面幾天都沒有看到類似斷開連線或不在伺服器上的異常。