[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))
            {
                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管理服务的生存周期,应该是生效了,后面几天都没有看到类似断开连接或不在服务器上的异常。

posted @ 2022-03-19 18:12  孤独成派  阅读(972)  评论(0编辑  收藏  举报