如何优雅地通过windows命名管道让js和csharp建立通信
📝2938 个字
 | ⌛要看完怎么也得8分钟吧
简单介绍一下管道
- 管道是一种在操作系统中,用来解决进程之间进行数据交换的底层机制,是众多进程通信(IPC)方式中的其中一种。
- 由于管道内的数据允许进程进行读写操作,因此不同的进程之间通过建立管道,即可实现数据的传输。
- 比较常见的使用场景,就是日常在cmd或者powershell中输入的
|符号。- 比如当我们查找端口占用的时候,常用的指令
netstat -ano|findstr :80。这里其实就是先通过netstat -ano查询所有的进程网络信息,然后通过|符号建立一个匿名管道,把数据传递给下一个指令findstr :80,并返回最终包含:80字段的条目。
- 比如当我们查找端口占用的时候,常用的指令
- 管道其实具有两种类型,分别是匿名管道和命名管道。
- 匿名管道
- 匿名管道就是没有特殊标识的管道。因为没有特殊标识,进程之间只能通过获取到的管道句柄建立链接,因此主要用于具有亲缘关系的进程之间进行通信。
- 匿名管道使用的是半双工模型,只允许单向的信息传输,因此如果要建立双向通信,就需要分别建立两个匿名管道。
- 匿名管道的生命周期和创建的进程强绑定,主进程销毁则管道也会一起销毁。
- 由于匿名管道的非常轻量,因此单个匿名管道,在传输速率和资源占用上都具有绝对的优势。
- 命名管道
- 顾名思义,命名管道在创建时,允许给管道增加特殊标识。进程之间只需要通过指定的标识,即可连接到目标管道进行通信。因此进程之间不需要有任何亲缘关系,也可以建立连接。
- 命名管道支持全双工模式,只需要一个管道,即可实现进程之间的相互通信。同时命名管道并未明确限制连接管道的客户端数量,因此理论上一个管道,可以同时接收多个进程的消息。
- 命名管道独立于创建的进程,一经创建就一直存在,直到显式的进行销毁操作。
- 同时命名管道支持网络通信,底层可以通过SMB协议建立网络链接,因此只要是相同的操作系统,哪怕位于不同的物理位置,也可以通过指定标识符建立链接。
- 由于命名管道比匿名管道增加了很多特性,因此在资源利用和吞吐量上存在一定的劣势,但仍然是处于非常高效的水平。
- 匿名管道
- 当然除了管道以外,还有很多其他的进程通信方案,类似于Socket、共享内存、消息队列等等。每种技术方案实现原理各不相同,因此也适用不同的使用场景,这里就不再展开赘述了。
- 特别说明
- 这里表述的管道优劣,都是基于windows系统环境下的接口,并不适用于其他的平台,包括linux。也许这时候会有小伙伴反驳了,明明linux里面也有管道的概念啊?为啥就不适用于linux平台了?
- 其实这里面的缘由真要要追溯起来的话,可能会非常的长篇大论。但可以简单概述一下,就是linux中的管道和windows的管道,可以完全理解成是,为了解决同一个问题,而提出的两套完全不同的解决方案。
- linux中的管道是基于文件系统实现的,并不存在网络连接的概念。简单来说,就是通过在指定位置新建一个文件,然后不同的进程,通过相同的文件目录,即可访问到对应文件。而通信的方式,无非就是一个进程写文件,另一个进程读文件。
- 而匿名管道和命名管道的区别,就在于这个指定位置的不同。因此linux中的管道,并不存在windows管道中的那些优势,哪怕是命名管道,也无法实现全双工通信模式,自然也无法支持多客户端。
- 详细的差异细节,网上还有很多文章单独分析对比过,就不再展开了。不过值得一提的是,我在Rust社区文档上还找到了一段相关内容的描述,写的也非常不错。虽然本文跟rust没太大关系,但核心思想大差不差,如果感兴趣的话,也可以点开看看。
- 但还需要明确一点的是,在windows平台下开发的管道应用,仍然支持在linux下跨平台运行。只不过这时候的管道,又跟windows以及linux原生的管道系统,都不相同。
- 这主要就是因为linux原生的管道,和windows的存在非常大的差异。但为了实现跨平台的同时,保持流程和接口的统一,.Net在linux下,只能借助使用Unix Domain Sockets,大致模拟实现要求的功能。
- Unix Domain Sockets可以简单理解为,就是传统意义上的socket,只不过只允许进行本机通信,这也就意味着,无法兼容网络通信的需求。因此使用windows平台开发的管道应用,在linux中的优势将大打折扣。
- 这里表述的管道优劣,都是基于windows系统环境下的接口,并不适用于其他的平台,包括linux。也许这时候会有小伙伴反驳了,明明linux里面也有管道的概念啊?为啥就不适用于linux平台了?
客户端接入(c#)
- 在c#中,管道相关的api主要封装在
System.IO.Pipes命名空间下,包含四个主要使用的接口:- 匿名管道:
AnonymousPipeServerStream与AnonymousPipeClientStream - 命名管道:
NamedPipeServerStream与NamedPipeClientStream - 因为命名管道的功能更强大,限制也更宽泛,所以这里主要使用的是命名管道进行接入。
- 匿名管道:
- 首先就是通过构造函数创建一个命名管道客户端管道对象
NamedPipeClientStream,构造方法主要包含以下参数:string serverName:- 服务器名称,当需要构建网络通信时,这里可以填写对应的IP或者电脑名,本机的话直接使用
"."即可
- 服务器名称,当需要构建网络通信时,这里可以填写对应的IP或者电脑名,本机的话直接使用
string pipeName:- 命名管道的名称,用于和服务器建立链接
PipeDirection direction:- 指定管道数据的传输方向,可以是
In、Out或者InOut
- 指定管道数据的传输方向,可以是
PipeOptions options:- 额外设置管道数据的传输方式,除了默认的
None以外,还可以根据需要开启WriteThrough和Asynchronous选项
- 额外设置管道数据的传输方式,除了默认的
TokenImpersonationLevel impersonationLevel:- 主要用来设置服务器对本机的操作权限,包含
None、Anonymous、Identification、Impersonation和Delegation几个等级。如果没有特殊需求,一般无需指定,使用最高安全等级的None即可
- 主要用来设置服务器对本机的操作权限,包含
HandleInheritability inheritability:- 指定当前管道句柄是否可被子进程继承,包含
None和Inheritable两个选项。一般没啥特殊需求,无需指定,保持默认最高安全等级的None即可。
- 指定当前管道句柄是否可被子进程继承,包含
- 根据本次使用的功能需要,最后的构造参数如下:
_pipeClient = new NamedPipeClientStream( ".", // 本机通信 _pipeName, // 指定管道名称 PipeDirection.InOut, // 双向通信 PipeOptions.Asynchronous // 异步读写 );
- 创建完成后,还需要显式调用管道对象的指定接口建立链接:
- 建立链接的接口主要有两种,分别是
Connect和ConnectAsync:void Connect(int timeout)- 从接口命名上不难看出,该接口是通过同步的方式建立链接,即在建立期间,主线程也会一起被挂起,直到建立成功,或者超过指定的超时时间。未指定超时时间的情况下,则会持续等待,直到建立成功或者出现异常。
Task ConnectAsync(int timeout, CancellationToken cancellationToken)- 通过异步的方式建立链接,同时返回指定的异步任务
Task对象。因为是异步,所以不会影响主线程的正常运行。但也可以根据需要,同样指定超时时间和取消任务的口令对象。
- 通过异步的方式建立链接,同时返回指定的异步任务
- 因为我这里是单独起了一个线程,用来管理管道对象的生命周期,所以就直接使用了
Connect接口。
- 建立链接的接口主要有两种,分别是
- 链接建立完成后,剩下的就是收发消息了。这部分倒也简单,因为windows的管道对象本身支持流式传输,所以只需要构造对应的stream对象即可,具体代码如下:
StreamReader sr = new StreamReader(_pipeClient, Encoding.UTF8); StreamWriter sw = new StreamWriter(_pipeClient, Encoding.UTF8); //写入指令 sw.Write("にかんさ"); sw.Flush(); //读取指令 string command = sr.ReadLine(); - 同时,如果更习惯使用Console接口的话,也可以通过指定接口,让Console直接向管道写入数据:
//设置Console的输出流 sw.AutoFlush = true; Console.SetOut(sw); Console.WriteLine("にかんさ"); //恢复Console的输出流 StreamWriter standardOutput = new StreamWriter(Console.OpenStandardOutput()); standardOutput.AutoFlush = true; Console.SetOut(standardOutput); - 最后在程序关闭时,通过指定接口关闭并释放对应管道:
try { pipeServer.Close(); if (sr != null) { sr.Dispose(); } if (sw != null) { sw.Dispose(); } } catch (Exception e) { }- 细心的小伙伴可能注意到了,这里使用了
try进行异常捕获。之所以这么做,其实也是因为这里有个非常烦人的异常。因为咱们使用的是StreamWriter对象进行数据写入操作,所以在我们调用Dispose接口的时候,他也会非常好心的帮我们去释放管道对象。 - 这就导致了一个问题,当服务器主动关闭链接的时候,此时的管道对象已经被动地处于关闭状态。因此这时候再调用
Dispose接口,就会非常不情愿的收到一个管道已经关闭的异常。我虽然也有想过各种方法,尝试能避免这个异常,但最终都失败了,因此无奈只能把通过try的方式,把异常的影响最小化。
- 细心的小伙伴可能注意到了,这里使用了
服务器接入(nodejs)
- 在nodejs中,用于建立管道的接口,统一包含在了
net库中,因此使用方式和平常并无太大差异,直接使用net.createServer创建服务器即可。但创建完成后,还需要使用指定的管道名监听管道,具体代码如下:pipeServer = net.createServer((client) => { console.log(`客户端已链接`); }); // 启动服务器监听命名管道 pipeServer.listen(`\\\\.\\pipe\\${pipeName}`, () => { console.log(`服务器启动成功已链接`); }); - 消息的收发方式也非常简单,和原有的用法基本没有区别:
client.on('data', (data) => { const msg = data.toString().trim(); console.log(`收到管道消息 ${msg}`); }); client.on('end', () => { console.log(`管道已关闭`); }); client.on('error', (err) => { console.error(`管道错误 ${err}`); }); //发送消息 client.write(`かんにざディ`); //关闭链接 client.end(); - 值得一提的是,虽然nodejs支持windows的管道,但并没有像c#那样,提供详细的配置参数,因此使用的都是windows管道的默认配置。因此,如果对服务器配置有特殊需求,则可以尝试通过c#建立服务器,nodejs建立客户端,并主动发起链接的方式。基本流程没有太大的区别,这里就不再单独展开了。
- 这里选择使用nodejs作为服务器,其实主要是为了最大化的利用同一个管道。因为同一个管道可以允许若干的客户端进行链接,因此也就意味着,nodejs作为服务器,可以同时控制若干个c#应用程序,这为之后进行诸如大任务批量派发等功能奠定了基础。如果不考虑这方面功能的使用话,那其实谁做服务器都没太大差距,根据需要各取所需即可。