1. 程式人生 > 其它 >[C#] IpcChannel雙向通訊,參考MAF的AddInProcess開發外掛,服務斷開重新開啟及服務生存週期管理

[C#] IpcChannel雙向通訊,參考MAF的AddInProcess開發外掛,服務斷開重新開啟及服務生存週期管理

先前專案太忙了,沒時間寫部落格,發現了一個有趣的東西,匆匆忙忙就寫完了,先描述一下需求背景:客戶端有幾張百萬級別的表需要聯合統計(如果是最大許可權的賬號),改變查詢條件又要重新統計,因此常常sql執行還沒結束就取消了,但不管關閉資料庫還是結束執行緒都必須等到sql執行結束,無奈之下只能考慮程序通訊,取消就直接殺掉程序,首先考慮的是現成的MAF,於是有了下面的程式碼:

 僅僅一個介面就需要建這麼多個專案,DLL多點也就算了,相關資料夾還必須如上圖這麼放,這麼放就算了,但是公共的DLL(如System.Data.SQLite.dll)還不能一起用,程序隔離的話公共DLL必須外掛、主程式各自一份,這不是打包的時候莫名增加了體積嗎?這肯定不能接受啊,但我還沒有放棄,因為ProcessStartInfo可以指定工作目錄,既我只要指定主程式根目錄為工作目錄,還是可以實現DLL共享,但是絕望的是AddInProcess是密封的,無法控制它是如何啟動的。雖然不滿足期望,好處是在看它的原始碼時發現是基於IpcChannel通訊的,去

官網瞭解之後,意外地發現使用很簡單,再結合MAF裡AddInProcess的思想,我的實現如下:

服務端提供什麼服務應有客服端指定,既需要一個空白程序,負責載入服務所在程式集,並提供服務,經過一段時間磨礪,發現不需要建立客戶端,下面的程式碼就可以實現:

// 客服端啟動代理類
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 = new
Dictionary<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管理服務的生存週期,應該是生效了,後面幾天都沒有看到類似斷開連線或不在伺服器上的異常。