Skip to content

11. 互联网编程

11.1. 概述

11.1.1. 互联网协议

本文将介绍互联网通信协议,即通常所说的TCP/IP协议套件(传输控制协议/互联网协议),其名称源自其中的两个主要协议。在着手构建分布式应用程序之前,读者若能对网络的工作原理,特别是TCP/IP协议有大致的了解,将会大有裨益。 下文部分内容译自NOVELL公司90年代初发布的文档《Lan Workplace for Dos - 管理员指南》


构建异构计算机网络这一基本概念源于美国国防高级研究计划局(DARPA)的研究。DARPA开发了名为TCP/IP的协议套件,该套件使异构机器能够相互通信。这些协议曾在名为ARPAnet的网络上经过测试,该网络后来演变为互联网。 TCP/IP协议定义了传输和接收的格式及规则,这些定义独立于网络组织结构和所使用的硬件。

由DARPA设计并由TCP/IP协议管理的网络是一种DARPA分组交换网络。此类网络通过将信息拆分为称为“数据包”的小块在网络中进行传输。因此,如果一台计算机传输一个大文件,该文件将被拆分为较小的片段,这些片段将通过网络发送,并在目的地重新组合。TCP/IP定义了这些数据包的格式,即:

  • 数据包来源
  • 目的地
  • 长度
  • 类型

11.1.2. OSI模型

TCP/IP协议大致遵循由国际标准化组织(ISO)定义的开放网络模型,即OSI(开放系统互连参考模型)。该模型描述了一个理想的网络,其中机器之间的通信可以通过七层模型来表示:

每一层从下层接收服务,并向上层提供自己的服务。假设位于不同机器 A 和 B 上的两个应用程序想要进行通信:它们在应用层进行通信。它们无需了解网络运作的所有细节:每个应用程序将其希望传输的信息交给下层——表示层。应用程序只需了解与表示层进行交互的规则即可。

一旦信息进入“呈现”阶段,它就会被传输到“会话”阶段,依此类推,直到信息到达物理介质并被物理传输到目标机器。在那里,它将经历与发送机器上所经历的相反的处理过程。

在每一层,负责发送信息的发送进程都会将其发送给另一台机器上属于同一层的接收进程。这一过程遵循被称为协议层的特定规则。由此,我们得到如下最终通信示意图:

各层的作用如下:

物理层
确保在物理介质上传输比特。该层包括数据处理终端设备(E.T.T.D.),例如终端或计算机,以及数据电路终端设备(E.T.C.D.),例如调制解调器、复用器或集中器。该层的主要关注点包括:
  • 信息编码方式的选择(模拟或数字)
  • 传输模式的选择(同步或异步)。
数据链路层
掩盖物理层的物理特性。检测并纠正传输错误。
网络
管理通过网络发送的信息所经过的路径。这被称为路由:确定一条信息必须经过的路线以到达其目的地。
传输层
允许两个应用程序之间进行通信,而之前的层仅支持机器间通信。该层提供的一项服务是多路复用:传输层可以利用同一条网络连接(机器间连接)来传输属于多个应用程序的信息。
会话
该层包含使应用程序能够在远程机器上建立并维持工作会话的服务。
表示层
其目的是标准化不同机器上的数据表示形式。这样,来自机器A的数据在通过网络发送之前,会由机器A的表示层以标准格式进行“包装”。当数据到达机器B的表示层时,该层将根据标准格式识别数据,并以另一种方式进行包装,以便机器B的应用程序能够识别它们。
应用
在此层级,我们通常会看到与用户密切相关的应用程序,例如电子邮件和文件传输。

11.1.3. TCP/IP模型

OSI模型是一个理想模型,至今尚未实现。TCP/IP协议套件以如下形式接近该模型:

物理层

对于局域网,我们通常使用以太网令牌环。本文仅介绍以太网技术。

以太网

这是20世纪70年代初由施乐帕洛阿尔托研究中心(PARC Xerox)发明、并于1978年由施乐、英特尔和数字设备公司(Digital Equipment)共同制定的分组交换局域网技术的名称。该网络在物理层由直径约1.27厘米、最长500米的同轴电缆构成。可通过中继器延长网络,但两台设备之间最多只能隔两个中继器。 该电缆为无源线缆:所有有源元件均位于连接至电缆的设备上。每台设备通过一张网络接口卡连接至电缆,该卡包含:

  • 一个发射器(收发器),用于检测电缆上的信号,并将模拟信号转换为数字信号,反之亦然。
  • 耦合器,用于接收来自发射器的数字信号并将其传输至计算机进行处理,或反之。

以太网技术的主要特点如下:

  • 10兆比特/秒的传输速率。
  • 总线拓扑:所有设备均连接至同一根电缆
  • 广播网络——发送设备通过电缆传输信息时附带目标设备的地址。所有连接的设备都会接收该信息,但只有该信息的接收方才会保留它。
  • 访问方式如下:希望发送数据的发送器会监听电缆——随后检测载波的存在与否,若检测到载波则表示正在进行传输。这就是 CSMA载波侦听多路访问)。若未检测到载波,发送器可决定依次进行传输。 可能有多个发送器做出这一决定。此时,各发送器的信号会发生混叠:我们称之为“碰撞”。发送器能够检测到这种情况:在向电缆发送数据的同时,它也会监听电缆上实际传输的内容。如果检测到电缆上传输的信息并非自己发送的数据,它便会推断出发生了碰撞并停止发送。其他发送器也会采取同样的措施。 每个发射器将在随机的时延后恢复传输,具体时长取决于各发射器自身。该技术称为CD碰撞检测)。这种访问方式被称为CSMA/CD
  • 48位地址。每台设备都有一个地址,称为物理地址,该地址写在连接设备与网线的网卡上。该地址被称为设备的以太网地址。

网络层

该层包含IP、ICMP、ARP和RARP协议。

IP(互联网协议)
在两个网络节点之间传输数据包
ICMP
(互联网控制消息协议)
ICMP 使一台机器的 IP 协议程序与另一台机器的 IP 协议程序之间能够进行通信。因此,它是一种在 IP 协议内部进行消息交换的协议。
ARP
(地址解析协议)
将互联网主机地址映射到物理主机地址
RARP
(反向地址解析协议)
将物理主机地址映射到互联网主机地址

传输层/会话层

该层包括以下协议:

TCP(传输控制协议)
确保两个用户之间可靠的信息传输
UDP(用户数据报协议)
确保两个用户之间信息传输的非可靠性

应用层/表示层/会话层

这里包含多种协议:

TELNET
一种终端仿真协议,允许主机A以终端身份连接到主机B
FTP(文件传输协议)
支持文件传输
TFTP(简易文件传输协议)
支持文件传输
SMTP(简单邮件传输协议)
允许网络用户之间交换消息
DNS(域名系统)
将主机名转换为互联网主机地址
XDR(外部数据表示法)
由 Sun Microsystems 创建,它规定了一种标准的、与机器无关的数据表示法
RPC(远程过程调用)
同样由 Sun 定义,是一种独立于传输层的远程应用程序间通信协议。该协议至关重要:它使程序员无需了解传输层的细节,并确保应用程序的可移植性。该协议基于 XDR 协议
NFS(网络文件系统)
同样由Sun定义,该协议允许一台机器“看到”另一台机器的文件系统。它基于前面的RPC协议

11.1.4. 互联网协议的工作原理

在TCP/IP环境中开发的应用程序通常会使用该环境中的多种协议。应用程序与最高层协议进行通信。该层将信息传递给下一层,依此类推,直至到达物理介质。在此,信息被物理传输至目标机器,并在该机器上再次经过相同的协议层,只是方向相反,直至到达接收该信息的应用程序。下图展示了信息传输路径:

让我们举个例子:FTP应用程序,它定义在应用程序层,用于实现机器之间的文件传输。

  • 应用程序将待传输的一串字节传递给传输
  • 传输层将这串字节切分为TCP分段,并在每个分段的开头添加分段号。分段被传递给由IP协议管理的网络层。
  • IP 层会创建一个数据包,将接收到的 TCP 分段封装其中。在该数据包的头部,它会放置源机和目标机的互联网地址。它还会确定目标机的物理地址。所有这些信息都会传递给数据链路层和物理层,即连接计算机与物理网络的网卡。
  • 在此,IP数据包会被封装进帧中,并通过电缆发送给接收方。
  • 在接收端机器上,数据链路与物理层执行相反的操作:它将IP数据包从物理帧中解封装,并将其传递给IP层。
  • IP层会检查数据包是否正确:它根据接收到的位计算一个和(校验和),该值必须与数据包头中的校验和一致。如果不一致,数据包将被拒绝。
  • 如果数据包被判定为正确,IP 层将解封装其中包含的 TCP 分段,并将其传递给 IP 层传输层
  • 传输层(在本例中为 TCP 层)会检查分段号以恢复正确的分段顺序。
  • 它还会为 TCP 分段计算校验和。如果校验和正确,TCP 层会向源机发送确认;否则,该 TCP 分段将被拒绝。
  • TCP 层剩下的工作就是将分段的数据部分传输给上层的目标应用程序。

11.1.5. 解决互联网中的地址问题

网络中的节点可以是计算机、智能打印机、文件服务器,实际上是任何能够使用 TCP/IP 协议进行通信的设备。每个节点都有一个物理地址,其格式取决于网络类型。在以太网中,物理地址由 6 个字节编码。X.25 网络地址是一个 14 位数字。

节点的互联网地址是一种逻辑地址:它与所使用的硬件和网络无关。这是一个4字节的地址,既标识本地网络,也标识该网络上的节点。互联网地址通常表示为4个数字,即4个字节的值,用点分隔。 例如,昂热大学理学院的Lagaffe机器的地址是193.49.144.1,而Liny机器的地址是193.49.144.9。由此可推断,该本地网络的互联网地址为193.49.144.0。该网络最多可容纳254个节点。

由于互联网地址或IP地址是网络无关的,因此网络A上的机器可以与网络B上的机器进行通信,无论其所在网络的类型如何:它只需要知道对方的IP地址即可。每个网络上的IP协议负责处理IP地址与物理地址之间的双向转换。

IP 地址必须各不相同。在法国,INRIA 负责分配 IP 地址。实际上,该机构会为您的本地网络分配一个地址,例如为昂热大学理学院的网络分配 193.49.144.0。 网络管理员随后可以根据需要分配 193.49.144.1 到 193.49.144.254 之间的 IP 地址。该地址通常会被写入网络中每台机器上的一个特殊文件中。

11.1.5.1. IP地址类

IP 地址是由 4 个字节组成的序列,通常表示为 I1.I2.I3.I4,实际上包含两个地址:

  • 网络地址
  • 该网络中某个节点的地址

根据这两个字段的大小,IP地址分为三类:A类、B类和C类。

A类

IP地址 I1.I2.I3.I4 的形式为 R1.N1.N2.N3,其中

R1 是网络地址

N1.N2.N3 是该网络中某台机器的地址

更准确地说,A类IP地址的形式如下:

网络地址为7位,节点地址为24位。因此,我们可以拥有127个A类网络,每个网络最多可包含2²⁴个节点。

B类

在此,IP 地址 I1.I2.I3.I4 的形式为 R1.R2.N1.N2,其中

R1.R2 是网络地址

N1.N2 是该网络中某台机器的地址

更准确地说,B类IP地址的形式如下:

网络地址和节点地址各占2字节(确切地说,是14位)。因此,我们可以拥有2¹⁴个B类网络,每个网络最多包含2¹⁶个节点。

C类

在此类中,IP 地址 I1.I2.I3.I4 的形式为 R1.R2.R3.N1,其中

R1.R2.R3 是网络地址

N1 是该网络中某台机器的地址

更准确地说,C类IP地址的形式如下:

网络地址为3字节(减去3位),节点地址为1字节。因此,我们可以拥有2²¹个C类网络,每个网络最多包含256个节点。

安格斯大学理学院的计算机地址 Lagaffe 为 193.49.144.1,我们可以看出最高字节为 193,即二进制表示为 11000001。这意味着该网络属于 C 类。

保留地址

  • 某些IP地址是网络地址,而非网络中节点的地址。这些地址的节点地址被设置为0。例如,地址193.49.144.0是昂热科学学院的IP地址。因此,网络中没有任何节点可以使用地址0。
  • 当 IP 地址中的节点地址仅包含 1 时,即为广播地址:该地址代表网络中的所有节点
  • 在理论上允许 2⁸=256 个节点的 C 类网络中,若剔除两个被禁止的地址,则剩余 254 个有效地址。

11.1.5.2. 转换协议:互联网地址 <--> 物理地址

我们已经看到,当信息从一台机器传输到另一台机器时,在经过IP层时会被封装成数据包。这些数据包具有以下形式:

因此,IP数据包中包含源机和目的机的互联网地址。当该数据包被传递到负责将其发送至物理网络的层时,会向其中添加其他信息,从而形成最终将在网络上发送的物理帧。例如,以太网网络中帧的格式如下:

最终的帧包含源机和目的机的物理地址。这些地址是如何获得的?

发送主机在知道其希望通信的机器的IP地址后,会使用一种名为ARP地址解析协议)的特殊协议来获取后者的物理地址。

  • 它会发送一种称为 ARP 数据包的特殊数据包,其中包含我们要查找的机器的 IP 地址。同时,它也会在数据包中包含自己的 IP 地址及其物理地址。
  • 该数据包会被发送至所有网络节点。
  • 这些节点会识别该数据包的特殊性质。识别出数据包中包含其 IP 地址的节点会通过向数据包的发送方发送其物理地址来响应。它是如何做到的呢?因为它在数据包中找到了发送方的 IP 地址和物理地址。
  • 发送方收到了他正在寻找的物理地址。他将其存储在内存中,以便日后需要向同一接收方发送其他数据包时使用。

一台机器的IP地址通常记录在其某个文件中,它可以通过查阅该文件来获取自己的IP地址。通过编辑该文件可以更改这个地址。另一方面,物理地址存储在网卡的内存中,无法更改。

当管理员希望重新规划网络结构时,可能需要更改所有节点的 IP 地址,因此必须编辑各个节点的配置文件。如果机器数量众多,这将非常繁琐且容易出错。一种方法是:不直接为机器分配 IP 地址,而是将一个特殊代码写入机器用于查找其 IP 地址的文件中。 当机器发现自己没有IP地址时,会通过一种名为RARP(反向地址解析协议)的协议进行请求随后,它会在网络上发送一个名为RARP数据包的特殊数据包(类似于上述的ARP数据包),并在其中包含其物理地址。该数据包会被发送至所有节点,而这些节点能够识别RARP数据包。 其中一个节点,称为 RARP 服务器,拥有一个包含所有节点物理地址与 IP 地址对应关系的文件。它随后会回复 RARP 数据包的发送方,将其 IP 地址发回。管理员若想重新配置网络,只需编辑 RARP 服务器的映射文件即可。该服务器通常应拥有一个固定 IP 地址,管理员无需亲自使用 RARP 协议即可查明该地址。

11.1.6. 互联网的IP网络层

IP协议(互联网协议)定义了数据包应采用的形式,以及在发送或接收时应如何处理。这种特定类型的数据包被称为IP数据报。我们已经介绍了:

关键在于,除了待传输的数据外,IP数据报还包含源机和目的机的互联网地址。这样,接收机就能知道是谁向其发送了消息。

与网络帧不同,后者的长度由其传输所经网络的物理特性决定,而IP数据报的长度由软件固定,因此在不同的物理网络上长度保持一致。如前所述,当IP数据报从网络层向下传递至物理层时,会被封装在物理帧中。我们曾以以太网的物理帧为例说明过:

物理帧在节点间传输,最终到达其目的地,而该目的地可能并不位于与发送主机相同的物理网络上。因此,在连接两种不同类型网络的节点上,IP数据包可能会被依次封装到不同的物理帧中。此外,IP数据包也可能因体积过大而无法封装到单个物理帧中。 此时,发生此问题的节点的IP软件会根据精确的规则将IP数据包拆分为多个片段,每个片段随后通过物理网络发送。这些片段直到到达最终目的地才会被重新组装。

11.1.6.1. 路由

路由是指将IP数据包引导至其目的地的方法。主要有两种方式:直接路由和间接路由。

直接路由

直接路由是指在同一网络内,将IP数据包从发送方直接路由至接收方:

  • 发送IP数据报的机器拥有接收方的IP地址。
  • 它通过ARP协议获取收件人的物理地址,或者如果该地址已获取,则直接从其表中获取。
  • 它将数据包通过网络发送到该物理地址。

间接路由

间接路由是指将 IP 数据包路由到发送方所属网络以外的网络上的目的地。在这种情况下,源机和目标机的 IP 地址中的网络地址部分是不同的。 源机器会识别这一点。然后,它将数据包发送到一个称为路由器router)的特殊节点,该节点将本地网络连接到其他网络,并在其表中找到该节点的 IP 地址,该地址最初是从文件或永久内存中获得的,或者通过网络上流通的信息获得的。

路由器连接于两个网络之间,并在这两个网络中各拥有一个IP地址。

在上述示例中:

. 网络 #1 的地址为 193.49.144.0,网络 #2 的地址为 193.49.145.0。

. 在网络 1 中,路由器的地址为 193.49.144.6;在网络 2 中,其地址为 193.49.145.3。

路由器的作用是将收到的IP数据包(该数据包包含在网络1典型的物理帧中)放入一个可在网络2上传输的物理帧中。如果数据包收件人的IP地址位于网络2中,路由器将直接向其发送数据包;否则,它会将数据包发送给另一台路由器,该路由器连接网络2与网络3,以此类推。

11.1.6.2. 错误和控制消息

同样位于网络层、与IP协议处于同一层级的还有ICMP互联网控制消息协议)。它用于发送有关网络内部运行状况的消息:节点下线、路由器拥塞等……ICMP消息被封装在IP数据包中并通过网络发送。各节点的IP层会根据收到的ICMP消息采取相应的行动。 通过这种方式,应用程序本身永远不会察觉到这些网络特有的问题。

节点会利用 ICMP 信息来更新其路由表。

11.1.7. 传输层:UDP 和 TCP 协议

11.1.7.1. UDP 协议:用户数据报协议

UDP 协议支持两点间不可靠的数据交换,即无法保证数据包能正确路由至目的地。应用程序可自行处理此问题,例如在发送消息后等待收到确认,再发送下一条消息。

目前,在网络层面上,我们讨论的是 IP 主机地址。在一台机器上,不同的进程可以同时共存,并且它们之间可以相互通信。因此,在发送消息时,不仅需要指定目标主机的 IP 地址,还必须指定目标进程的“名称”。这个名称实际上是一个数字,称为端口号。 某些数字被保留用于标准应用程序:例如,端口 69 用于 TFTP(简单文件传输协议)

由 UDP 协议管理的数据包也被称为数据报。它们采用以下形式:

这些数据报被封装在 IP 数据包中,随后又被封装在物理帧中。

11.1.7.2. TCP协议:传输控制协议

对于安全通信而言,UDP协议是不够的:应用程序开发人员必须开发自己的协议来检测数据包是否被正确路由。TCP(传输控制协议)避免了这些问题。其特点如下:

  • 希望发送信息的进程首先会与接收该信息的进程建立连接。该连接是在发送端机器的一个端口与接收端机器的一个端口之间建立的。由此在两个端口之间创建了一条虚拟路径,该路径将专用于建立连接的这两个进程。
  • 源进程发送的所有数据包都遵循这条虚拟路径,并按发送顺序到达。而在UDP协议中,由于数据包可能遵循不同的路径,这种顺序是无法保证的。
  • 发送的信息是连续的。发送进程按照自己的节奏发送信息。这些信息并不一定立即发送:TCP协议会等待直到积累足够的信息后再发送。这些信息被存储在一个称为TCP分段的结构中。一旦完成,该分段会被传输到IP层,并在那里封装成IP数据包。
  • TCP 协议发送的每个数据段都会被编号。接收端的 TCP 协议会检查数据段是否按顺序接收。对于每个正确接收的数据段,它都会向发送方发送一个确认。
  • 当发送方收到确认后,会通知发送进程。这意味着发送进程知道该数据段已安全到达,而这在 UDP 协议中是无法实现的。
  • 如果经过一定时间后,发送数据段的 TCP 协议未收到确认,它将重传该数据段,从而保证信息路由服务的质量。
  • 在两个通信进程之间建立的虚拟电路是全双工的:这意味着信息可以双向流动。这样,目标进程可以在源进程继续发送信息的同时发送确认。这使得TCP源协议(例如)能够发送多个数据段而无需等待确认。如果它在经过一定时间后发现尚未收到某个数据段n的确认,它将从该点开始恢复数据段的广播。

11.1.8. 应用层

在 UDP 和 TCP 协议之上,还有各种标准协议:

TELNET

该协议允许网络中机器 A 上的用户连接到机器 B(通常称为主机)。TELNET 在机器 A 上模拟一个通用终端。用户操作时,仿佛其终端已连接到机器 B。Telnet 基于 TCP 协议。

FTP:(文件传输协议)

该协议支持在两台远程计算机之间交换文件,并可进行文件操作(如创建目录)。它基于TCP协议。

TFTP:(简易文件传输协议)

该协议是FTP的一种变体。它基于UDP协议,功能比FTP简单。

DNS:(域名系统)

当用户希望与远程计算机交换文件(例如通过FTP)时,需要知道该计算机的互联网地址。例如,若要在昂热大学的Lagaffe计算机上进行FTP操作,需按以下方式运行FTP:FTP 193.49.144.1

这需要一个将计算机与IP地址相互映射的目录。在这个目录中,计算机通常会使用符号名称来标识,例如:

来自昂热大学的 DPX2/320 机器

来自昂热ISERPA的Sun机器

显然,用名称而非IP地址来指代一台机器会更方便。此外还有名称唯一性的问题:互联的机器多达数百万台。 我们可以设想由一个中央机构来分配名称。这无疑会相当繁琐。事实上,名称的管理权已被分散到**各个领域**。每个域名由一个非常小的组织管理,该组织可以自由选择自己的机器名称。例如,法国的机器属于由巴黎Inria管理的en域名。 为了 保持简单,我们将管理权限进一步下放:**在en域名**下创建子域名。昂热大学**属于univ-Angers域名**。管理该域名的部门可以自由为昂热大学网络中的机器命名。目前,该域名尚未进一步细分。但在拥有大量联网机器的大型大学中,可能会进行细分。

昂热大学的DPX2/320主机被*命*名为Lagaffe,而一台486DX50个人电脑则被*命名为liny*。如何从外部引用这些主机?只需指定它们所属的域名层级即可。例如,Lagaffe主机的完整名称为:

    **Lagaffe.univ-Angers.fr**

在域内可以使用相对名称。因此,在 **en** 域内且位于 **univ-Angers** 域之外时,可以通过以下方式引用 Lagaffe 机器:

    **Lagaffe.univ-Angers**

最后,在 *univ-Angers* 内部,只需使用

    **Lagaffe**

因此,应用程序可以通过名称来引用一台机器。但归根结底,你仍然需要获取该机器的互联网地址。这该如何实现?假设你想从机器 A 与机器 B 进行通信。
  • 如果机器 B 与机器 A 属于同一个域,我们很可能在机器 A 上的某个文件中找到它的 IP 地址。
  • 否则,机器 A 将查找包含若干域名服务器及其 IP 地址的列表。域名服务器负责将机器名称映射到其 IP 地址。机器 A 会向列表中的第一个域名服务器发送一个特殊请求,称为 DNS 请求,其中包含要查找的机器名称。如果被查询的服务器在其记录中拥有该名称,它将向机器 A 发送相应的 IP 地址。 若未找到,该服务器也会在其文件中查找一组名称服务器的列表,并将该列表发送给计算机A以便其进行查询。随后计算机A将依此进行查询。通过这种方式,将依次查询多个名称服务器,并非无序地进行,而是以尽可能减少查询次数的方式进行。如果最终找到了该计算机,响应将返回给计算机A。

XDR:(外部数据表示法)

该协议由 Sun Microsystems 公司创建,规定了一种标准的、与机器无关的数据表示形式。

RPC:(远程过程调用)

该协议同样由Sun定义,是一种独立于传输层的远程应用程序间通信协议。该协议具有重要意义:它使程序员无需了解传输层的细节,并提高了应用程序的可移植性。该协议基于XDR协议

NFS:网络文件系统

该协议同样由 Sun 定义,它允许一台机器“看到”另一台机器的文件系统。它基于前文提到的 RPC 协议。

11.1.9. 结论

在本篇简介中,我们概述了互联网协议的几个要点。若想更深入地了解这一领域,请阅读道格拉斯·科默(Douglas Comer)所著的优秀著作:

书名 《TCP/IP:架构、协议与应用》。

作者 道格拉斯·科默

出版社 InterEditions

11.2. 用于 IP 地址管理的 .NET 类

互联网网络中的每台机器都由一个 IP(互联网协议)地址唯一标识,该地址有两种形式:

  • IPv4:采用 32 位编码,表示形式为 "I1.I2.I3.I4",其中 In 是 1 到 254 之间的数字。这些目前是最常见的 IP 地址。
  • IPv6:采用 128 位编码,表示形式为 "[I1.I2.I3.I4.I5.I6.I7.I8]",其中 In 由 4 位十六进制数字组成。本文档中,我们将不使用 IPv6 地址。

一台机器也可以通过一个同样唯一的名称来定义。这个名称并非强制要求,因为应用程序最终总是会使用机器的 IP 地址。例如,通过浏览器请求 URL http://www.ibm.com 比请求 URL http://129.42.17.99 更简单,尽管这两种方法都是可行的。

如果一台机器同时物理连接到多个网络,它可能拥有多个 IP 地址。此时,它在每个网络上都拥有一个 IP 地址。

在 .NET 中,IP 地址可以通过两种方式表示:

  • 字符串形式 "I1.I2.I3.I4" 或 "[I1.I2.I3.I4.I5.I6.I7.I8]"
  • 或以 IPAddress 对象的形式表示

IPAddress 类

IPAddress 的 M 个方法、P 个属性和 C 个常量中,包括以下内容:

AddressFamily AddressFamily
P
地址族 IP。AddressFamily 类型是一个枚举类型。其中最常见的两个值是:
AddressFamily.InterNetwork:用于 IPv4 地址
AddressFamily.InterNetworkV6:用于 IPv6 地址
IPAddress 任何
C
IP 地址 "0.0.0.0"。当服务与该地址相关联时,表示该服务接受运行其所在机器上所有 IP 地址的客户端。
IPAddress LoopBack
C
地址 IP "127.0.0.1"。被称为“回环地址”。当服务与该地址相关联时,意味着它只接受与其位于同一台机器上的客户端。
IPAdress None
C
IP 地址“255.255.255.255”。当服务与该地址相关联时,表示它不接受任何客户。
bool TryParse(string ipString, out IPAddress address)
M
尝试将 IP 地址 ipString(格式为“I1.I2.I3.I4”)转换为 IPAddress 地址。如果操作成功,则返回 true
bool IsLoopBack
M
如果 IP 地址为 "127.0.0.1",则返回 true
string ToString()
M
将 IP 地址呈现为“I1.I2.I3.I4”或“[I1.I2.I3.I4.I5.I6.I7.I8]”

IP地址与计算机名称之间的映射关系由一种名为DNS(域名系统)的分布式互联网服务提供。DNS的静态方法实现了IP地址与计算机名称之间的映射:

GetHostEntry (string hostNameOrAddress)
该方法根据字符串形式的 IP 地址或计算机名称返回 IPHostEntry 对象。若无法找到该计算机,则抛出异常。
GetHostEntry (IPAddress ip)
根据 IPAddress 类型的 IP 地址返回 IPHostEntry 对象。若无法找到该机器,则抛出异常。
string GetHostName()
返回正在运行执行此指令的程序的机器名称
IPAddress[] GetHostAddresses(string hostNameOrAddress)
根据其名称或其中一个 IP 地址,返回该计算机的 IP 地址。

IPHostEntry 实例封装了 IP 地址、别名和计算机名称。IPHostEntry 类型的定义如下:

IPAddress[] AddressList
P
主机 IP 地址表
String[] Aliases
P
该机器的 DNS 别名。这些是与该机器各种 IP 地址相对应的名称。
字符串 HostName
P
机器的主主机名

请看以下程序,它会显示当前运行机器的名称,然后以交互方式提供 IP 与机器名称之间的对应关系:


using System;
using System.Net;
 
namespace Chap9 {
    class Program {
        static void Main(string[] args) {
             // displays the name of the local machine
             // then interactively provides information on network machines
             // identified by name or address IP
 
             // local machine
            Console.WriteLine("Machine Locale= {0}" ,Dns.GetHostName());
 
             // interactive Q&A
            string machine;
            IPHostEntry ipHostEntry;
            while (true) {
                 // enter the name or IP address of the machine you are looking for
                Console.Write("Machine recherchée (rien pour arrêter) : ");
                machine = Console.ReadLine().Trim().ToLower();
                 // finished?
                if (machine == "") return;
                 // management exception
                try {
                     // machine search
                    ipHostEntry = Dns.GetHostEntry(machine);
                     // machine name
                    Console.WriteLine("Machine : " + ipHostEntry.HostName);
                     // the machine's IP addresses
                    Console.Write("Adresses IP : {0}" , ipHostEntry.AddressList[0]);
                    for (int i = 1; i < ipHostEntry.AddressList.Length; i++) {
                        Console.Write(", {0}" , ipHostEntry.AddressList[i]);
                    }
                    Console.WriteLine();
                     // machine aliases
                    if (ipHostEntry.Aliases.Length != 0) {
                        Console.Write("Alias : {0}" , ipHostEntry.Aliases[0]);
                        for (int i = 1; i < ipHostEntry.Aliases.Length; i++) {
                            Console.Write(", {0}" , ipHostEntry.Aliases[i]);
                        }
                        Console.WriteLine();
                    }
                } catch {
                     // the machine doesn't exist
                    Console.WriteLine("Impossible de trouver la machine [{0}]",machine);
                }
            }
        }
    }
}

执行结果如下:

Machine Locale= LISA-AUTO2005A
Machine recherchée (rien pour arrêter) : localhost
Machine : LISA-AUTO2005A
Adresses IP : 127.0.0.1
Machine recherchée (rien pour arrêter) : 127.0.0.1
Machine : LISA-AUTO2005A
Adresses IP : 127.0.0.1
Machine recherchée (rien pour arrêter) : istia.univ-angers.fr
Machine : istia.univ-angers.fr
Adresses IP : 193.49.146.171
Machine recherchée (rien pour arrêter) : 193.49.146.171
Machine : istia.istia.univ-angers.fr
Adresses IP : 193.49.146.171
Machine recherchée (rien pour arrêter) : xx
Impossible de trouver la machine [xx]

11.3. 编程基础 互联网

11.3.1. 概述

考虑两台远程机器 A 和 B 之间的通信:

当机器 A 上的应用程序 AppA 想要与互联网上的机器 B 上的应用程序 AppB 进行通信时,它需要了解以下几点:

  • 机器 B 的 IP 地址或主机名
  • 应用程序 AppB 所使用的端口号。机器 B 可能支持大量在互联网上运行的应用程序。当它从网络接收信息时,需要知道该信息是发给哪个应用程序的。机器 B 上的应用程序通过“窗口”(也称为通信端口)访问网络。这些信息包含在机器 B 接收的数据包中,以便将其传递给正确的应用程序。
  • 计算机B所支持的通信协议。在本研究中,我们将仅使用TCP/IP协议。
  • 应用程序AppB所接受的对话协议。实际上,机器A和B将相互“对话”。它们传递的内容被封装在TCP/IP协议中。然而,在传输链路的末端,应用程序AppB将接收由AppA发送的信息,并必须能够对其进行解析。这类似于两个人A和B通过电话进行沟通的情况:他们的对话由电话传输。 电话A将语音编码为信号形式,通过 电话线路传输,到达电话B后被解码。随后,B听到了语音。这就是对话协议概念发挥作用的地方:如果A说法语而B不懂该语言,A和B就无法进行有效的对话。

因此,两个通信应用程序必须就将采用的对话类型达成一致。例如,与FTP服务进行的对话与POP服务不同:这两项服务接受的指令不同,它们采用的是不同的对话协议。

11.3.2. TCP协议的特性

本文仅探讨使用TCP传输协议的网络通信。在此回顾一下其特性:

  • 希望发送信息的进程首先会与接收该信息的进程建立连接。该连接是在发送机器上的一个端口与接收机器上的一个端口之间建立的。由此在两个端口之间创建了一条虚拟路径,该路径将专用于建立连接的这两个进程。
  • 源进程发送的所有数据包都遵循这条虚拟路径,并按发送时的顺序到达
  • 发送的信息是连续的。发送进程按照自己的节奏发送信息。这些信息并不一定立即发送:TCP协议会等待直到积累足够的信息后再发送。它们被存储在一个称为TCP分段的结构中。一旦完成,该分段会被传输到IP层,并在那里封装成一个IP数据包。
  • TCP协议发送的每个分段都有序号。接收端的TCP协议会检查是否按顺序接收到了这些分段。对于每个正确接收到的分段,它都会向发送方发送一个确认。
  • 当发送方收到确认后,会将此信息通知发送进程。这意味着发送进程知道该分段已安全到达。
  • 如果经过一定时间后,发送该分段的 TCP 协议未收到确认,它将重传该分段,从而保证信息路由服务的质量。
  • 两个通信进程之间建立的虚拟电路是全双工的:这意味着信息可以双向流动。 这样,目标进程可以在源进程继续发送信息的同时发送确认。这使得 TCP 源协议可以发送多个数据段,而无需等待确认。如果经过一段时间后,它发现尚未收到某个数据段(编号 n)的确认,它将从该点恢复数据段的广播。

11.3.3. 客户端-服务器关系

通常,互联网上的通信是非对称的:机器 A 发起连接以向机器 B 请求服务:它指定希望与机器 B 的 SB1 服务建立连接。 机器 B 接受或拒绝。若接受,机器 A 即可向 SB1 服务发送请求。这些请求必须符合 SB1 服务所理解的对话协议。由此,在机器 A(我们称之为客户端)与机器 B(我们称之为服务器)之间建立起请求-响应对话。双方中的一方将关闭连接。

11.3.4. 客户端架构

请求服务器应用程序服务的网络程序的架构如下:

ouvrir la connexion avec le service SB1 de la machine B
si réussite alors
    tant que ce n'est pas fini
        préparer une demande
        l'émettre vers la machine B
        attendre et récupérer la réponse
        la traiter
    fin tant que
finsi
fermer la connexion

11.3.5. 服务器架构

提供服务的程序架构如下:

ouvrir le service sur la machine locale
tant que le service est ouvert
    se mettre à l'écoute des demandes de connexion sur un port dit port d'écoute
    lorsqu'il y a une demande, la faire traiter par une autre tâche sur un autre port dit port de service
fin tant que

服务器程序处理客户端的初始连接请求与后续的服务请求方式不同。 该程序本身并不提供服务。如果它自己提供服务,那么在服务期间就无法继续监听连接请求,客户也就无法得到服务。因此,它采取了不同的处理方式:一旦监听端口接收到连接请求并予以接受,服务器就会创建一个任务,负责提供客户请求的服务。该服务在服务器机器上的另一个端口(称为服务端口)上提供。这意味着您可以同时为多个客户提供服务。

一个服务任务将具有以下结构:

tant que le service n'a pas été rendu totalement
        attendre une demande sur le port de service
        lorsqu'il y en a une, élaborer la réponse
        transmettre la réponse via le port de service
fin tant que
libérer le port de service

11.4. 探索互联网的 通信协议

11.4.1. 简介

一旦客户端连接到服务器,双方之间便建立起对话。这种对话的性质构成了所谓的服务器通信协议。最常见的互联网协议包括以下几种:

  • HTTP:超文本传输协议——用于与 Web 服务器(HTTP 服务器)进行通信的协议
  • SMTP:简单邮件传输协议——用于与电子邮件服务器(SMTP服务器)通信的协议
  • POP:邮局协议——用于与电子邮件存储服务器(POP服务器)进行交互的协议。其目的是检索收到的电子邮件,而非发送邮件。
  • FTP:文件传输协议——用于与文件存储服务器(FTP服务器)通信的协议。

所有这些协议都有一个显著特征,即它们都是文本行协议:客户端和服务器之间交换的是文本行。如果我们有一个客户端能够:

  • 与 TCP 服务器建立连接
  • 将服务器发送的文本行显示在控制台上
  • 将用户输入的文本行发送至服务器

那么,只要你了解该协议的规则,就能使用文本行协议与 TCP 服务器进行通信。

Unix 或 Windows 系统上的 telnet 程序就是这样的客户端。在 Windows 系统上,还有一款名为 putty 的工具,我们将使用它。putty 可从 [http://www.putty.org/] 下载。它是一个可直接运行的可执行文件(.exe)。我们将按以下方式进行配置:

  • [1]:你要连接的 TCP 服务器的 IP 地址或其名称
  • [2]:服务器监听的 TCP 端口
  • [3]:选择 Raw 模式,表示建立原始 TCP 连接
  • [4]: 采用 Never 模式,以防止服务器关闭连接时 PuTTY 客户端窗口随之关闭。
  • [6,7]: 控制台的列数/行数
  • [5]: 内存中存储的最大行数。HTTP 服务器可能会发送大量行,您需要能够“滚动”查看这些内容。
  • [8,9]:若要保留先前设置,请为配置命名 [8] 并保存 [9]。
  • [11,12]:要调用已保存的配置,请选择它 [11] 并加载它 [12]。

配置好此工具后,让我们来看看一些 TCP 协议。

11.4.2. HTTP 协议(超文本传输协议)

让我们将 [1] 客户端 TCP 连接到位于 istia.univ-angers.fr [2] 机器上的 Web 服务器,端口 80 [3]:

PuTTY 中,我们建立 的 HTTP 连接如下:

GET / HTTP/1.1
Host: istia.univ-angers.fr:80
Connection: close

HTTP/1.1 200 OK
Date: Sat, 03 May 2008 07:53:47 GMT
Server: Apache/1.3.34 (Debian) PHP/4.4.4-8+etch4 mod_jk/1.2.18 mod_perl/1.29
X-Powered-By: PHP/4.4.4-8+etch4
Set-Cookie: fe_typo_user=0d2e64b317; path=/
Connection: close
Transfer-Encoding: chunked
Content-Type: text/html;charset=iso-8859-1

693f
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"                                                                        "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
         <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr_FR" lang="fr_FR">
....
         </html>
0
  • 第1-4行是客户通过键盘输入的请求
  • 第5-19行是服务器的响应
  • 第1行:语法 GET UrlDocument HTTP/1.1 - 我们请求 URL /,即网站根目录 [istia.univ-angers.fr]。
  • 第2行:语法 Host: 主机:端口
  • 第3行:语法 Connection: [连接模式]。[close] 模式指示服务器在发送响应后关闭连接。[Keep-Alive] 模式指示服务器保持连接打开。
  • 第 4 行:空行。第 1-3 行称为 HTTP 头部。可能还有此处未显示的其他头部。HTTP 头部的结尾由空行表示。
  • 第 5-13 行:服务器响应中的 HTTP 头——同样以空行结尾。
  • 第14-19行:服务器发送的文档,此处为HTML文档
  • 第 5 行:HTTP/1.1 消息代码语法——代码 200 表示已找到请求的文档。
  • 第 6 行:服务器日期和时间
  • 第 7 行:Web 服务器的软件标识——此处为运行于 Linux/Debian 系统的 Apache 服务器
  • 第 8 行:该文档由 PHP 动态生成
  • 第 9 行:用户识别 Cookie——若用户希望下次连接时被识别,必须在 HTTP 头中返回此 Cookie。
  • 第 10 行:表示在提供所请求的文档后,服务器将关闭连接
  • 第 11 行:文档将分块传输,而非作为一个整体传输。
  • 第 12 行:文档类型:此处为 HTML 文档
  • 第13行:空行,表示服务器HTTP头结束
  • 第14行:十六进制数,表示文档第一块中的字符数。当该数值为0(第19行)时,客户端将知道已接收完整文档。
  • 第15-18行:已接收的文档部分。

连接已关闭,客户端 Putty 处于非活动状态。让我们重新连接 [1] 并清除屏幕上的先前显示内容 [2,3]:

此次对话如下:

GET /inconnu HTTP/1.1
Host: istia.univ-angers.fr:80
Connection: Close

HTTP/1.1 404 Not Found
Date: Sat, 03 May 2008 08:16:02 GMT
Server: Apache/1.3.34 (Debian) PHP/4.4.4-8+etch4 mod_jk/1.2.18 mod_perl/1.29
Connection: close
Transfer-Encoding: chunked
Content-Type: text/html; charset=iso-8859-1

11a
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
                                                  <HTML><HEAD>
                                                              <TITLE>404 Not Found</TITLE>
                                                                                          </HEAD><BODY>
                                                                                                       <H1>Not Found</H1>
 The requested URL /inconnu was not found on this server.<P>
                                                            <HR>
                                                                <ADDRESS>Apache/1.3.34 Server at www.istia.univ-angers.fr Port 80</ADDRESS>
                   </BODY></HTML>

0
  • 第 1 行:请求的文档不存在
  • 第 5 行:服务器返回 HTTP 404 状态码,表示未找到所请求的文档。

如果您使用 Firefox 浏览器请求此文档:

Image

如果我们查看源代码 [显示/源代码]:

1
2
3
4
5
6
7
8
9
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<HTML><HEAD>
<TITLE>404 Not Found</TITLE>
</HEAD><BODY>
<H1>Not Found</H1>
The requested URL /inconnu was not found on this server.<P>
<HR>
<ADDRESS>Apache/1.3.34 Server at www.istia.univ-angers.fr Port 80</ADDRESS>
</BODY></HTML>

我们获取了客户通过 Putty 接收的第 13 至 22 行内容。这样做的优势在于,它同时向我们展示了响应的 HTTP 头部信息。使用 Firefox 同样可以获取这些信息。

11.4.3. 协议 SMTP(简单邮件传输协议)

SMTP 服务器通常运行在 25 端口 [2]。我们连接到服务器 [1]。对于 Ici 服务器,通常需要一个

与该机器属于同一 IP 域的 IP 地址,因为大多数 SMTP 服务器都配置为仅接受来自与自身属于同一域的机器的请求。通常,个人计算机上的防火墙或防病毒软件会被配置为不接受来自外部机器对 25 号端口的连接。因此,可能需要重新配置 [3] 该防火墙或防病毒软件。

Putty 客户端窗口中的 SMTP 对话框如下所示:

220 neuf-infra-smtp-out-sp604001av.neufgp.fr neuf telecom Service relais mail ready
HELO istia.univ-angers.fr
250 neuf-infra-smtp-out-sp604002av.neufgp.fr hello [84.100.189.193], Banniere OK , pret pour envoyer un mail
mail from: @expéditeur
250 2.1.0 <@expéditeur> sender ok
rcpt to: @destinataire
250 2.1.5 <@destinataire> destinataire ok
data
354 enter mail, end with "." on a line by itself
ligne1
ligne2
.
250 2.0.0 LwiU1Z00V4AoCxw0200000 message ok
quit
221 2.0.0 neuf-infra-smtp-out-sp604002av.neufgp.fr neuf telecom closing connection

下文中 (D) 表示客户端请求,(R) 表示服务器响应。

  • 第1行:(R) 服务器SMTP问候
  • 第2行:(D) 发送HELO命令以进行问候
  • 第3行:(R) 服务器响应
  • 第 4 行:(D) 发件人地址,例如 mail from: someone@gmail.com
  • 第 5 行:(R) 服务器响应
  • 第 6 行:(D) 收件人地址,例如 rcpt to: someoneelse@gmail.com
  • 第 7 行:(R) 服务器响应
  • 第 8 行:(D) 标记消息的开始
  • 第 9 行:(R) 服务器响应
  • 第 10-12 行:(D) 待发送的消息,以仅包含一个句点的行结束。
  • 第 13 行:(R) 服务器响应
  • 第 14 行:(D) 客户端发出操作结束信号
  • 第15行:(R) 服务器响应,随后关闭连接

11.4.4. POP协议(邮局协议)

POP 服务器通常运行在 110 端口 [2]。我们连接到服务器 [1]。客户端窗口 putty 中的 POP 对话框如下:

+OK Hello there.
user xx
+OK Password required.
pass yy
+OK logged in.
list
+OK POP3 clients that break here, they violate STD53.
1 10105
2 55875
...
64 1717
.
retr 64
+OK 1717 octets follow.
Return-Path: <xx@neuf.fr>
X-Original-To: xx@univ-angers.fr
Delivered-To: xx@univ-angers.fr
....
Date: Sat,  3 May 2008 10:59:25 +0200 (CEST)
From: xx@neuf.fr
To: undisclosed-recipients:;

ligne1
ligne2
.
quit
+OK Bye-bye.
  • 第1行:(R) 服务器欢迎信息 POP
  • 第 2 行:(R) 服务器欢迎信息 POP,即用户用于读取邮件的登录名
  • 第3行:(R) 服务器响应
  • 第4行:(D) 用户密码
  • 第5行:(R) 服务器响应
  • 第6行:(D) 用户请求查看其邮件列表
  • 第7-12行:(R) 用户邮箱中的邮件列表,格式为 [邮件编号 邮件大小(字节)]
  • 第13行:(D) 请求第64号邮件
  • 第14-25行:(R) 消息编号64,其中第15-22行为消息头,第23-24行为消息正文。
  • 第 26 行:(D) 客户端表示操作完成
  • 第 27 行:(R) 服务器响应,随后关闭连接。

11.4.5. FTP协议(文件传输协议)

FTP协议比上述协议更为复杂。若要查看客户端与服务器之间交换的文本内容,可使用FileZilla [http://www.filezilla.fr/]等工具。

FileZilla 是一款提供 Windows 界面的 FTP 客户端,用于文件传输。用户在该界面上的操作会被转换为 FTP 命令,并记录在 [1] 中。这是了解 FTP 协议命令的绝佳途径。

11.5. .NET 互联网编程类

11.5.1. 选择合适的类

.NET 框架提供了多种用于处理 : 的类:

  • Socket 类是与网络交互最紧密的类。它支持对网络连接进行精细化管理。Socket 一词原指电源插座,后来被引申为软件网络套接字。在两台机器 A 和 B 之间的 TCP/IP 通信中,它们通过两个套接字相互通信。应用程序可以直接操作套接字,上文中的应用程序 A 便是如此。一个套接字可以是客户端,也可以是服务器
  • 若您希望在比 Socket 类更底层的级别进行操作,可以使用
  • TcpClient 来创建 Tcp 客户端
  • TcpListener 来创建 Tcp 服务器

这两个类为使用它们的应用程序提供了更简化的网络通信视图,代为处理套接字管理的技术细节。

  • .NET 为特定协议提供了专用类:
  • SmtpClient 类用于管理 SMTP 协议,以便与 SMTP 服务器通信并发送电子邮件
  • WebClient 类用于管理 HTTP 或 FTP 协议,以与 Web 服务器进行通信。

Socket 类本身足以处理所有 TCP/IP 通信,但我们将重点介绍如何使用这些更高层次的类,以简化 TCP/IP 应用程序的编写。

11.5.2. TcpClient 类

TcpClient 是最适合用于创建 TCP 服务客户端的类。其 C 构造函数、M 方法和 P 属性包括以下内容:

TcpClient(string hostname, int port)
C
用于与指定主机(hostname)上运行在指定端口(port)的服务建立 TCP 连接。例如,new TcpClient("istia.univ-angers.fr", 80) 可连接到 istia.univ-angers.fr 主机的 80 端口
套接字客户端
P
客户端用于与服务器通信的套接字。
NetworkStream GetStream()
M
获取通往服务器的读写流。正是这个流使得客户端与服务器之间的数据交换成为可能。
void Close()
M
关闭连接。Socket 和 NetworkStream 流也将被关闭
bool Connected()
P
如果连接已建立,则返回 true

NetworkStream 类表示客户端与服务器之间的网络流。它继承自 Stream 类。许多客户端-服务器应用程序交换以换行符“\r\n”结尾的文本行。因此,使用 StreamReaderStreamWriter 在网络流中读写这些行是一个好主意。 因此,如果机器 M1 使用 TcpClient 对象 customer1 与机器 M2 建立了连接,并且它们交换文本行,则可以如下创建其读写流:

StreamReader in1=new StreamReader(client1.GetStream());
StreamWriter out1=new StreamWriter(client1.GetStream());
out1.AutoFlush=true;

说明

out1.AutoFlush=true;

这意味着客户1不会经过中间缓冲区,而是直接发送到网络。这一点非常重要。通常情况下,当客户1向其伙伴发送一行文本时,它会期待收到响应。如果该行文本实际上被缓冲在机器M1上且从未发送给机器M2,那么响应将永远不会到达。

要向机器 M2 发送一行文本,请编写:

client1.WriteLine("un texte");

要读取 M2 的响应,请输入:

string réponse=client1.ReadLine();

现在我们已经具备了编写互联网客户端基本架构的要素,该客户端采用以下与服务器的基本通信协议:

  • 客户端发送一条包含在单行中的请求
  • 服务器发送的响应仅包含一行内容

using System;
using System.IO;
using System.Net.Sockets;
 
namespace ... {
    class ... {
        static void Main(string[] args) {
            ...
            try {
                 // connect to the service
                using (TcpClient tcpClient = new TcpClient(serveur, port)) {
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamReader reader = new StreamReader(networkStream)) {
                            using (StreamWriter writer = new StreamWriter(networkStream)) {
                                 // unbuffered output stream
                                writer.AutoFlush = true;
                                 // request-response loop
                                while (true) {
                                     // demand comes from the keyboard
                                    Console.Write("Demande (bye pour arrêter) : ");
                                    demande = Console.ReadLine();
                                     // finished?
                                    if (demande.Trim().ToLower() == "bye")
                                        break;
                                     // send the request to the server
                                    writer.WriteLine(demande);
                                     // we read the server response
                                    réponse = reader.ReadLine();
                                     // the answer is processed
                                    ...
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                 // error
                ...
            }
        }
    }
}
  • 第 11 行:创建客户登录——该 using 子句确保在使用时释放相关资源。
  • 第 12 行:在 using 子句中打开网络流
  • 第 13 行:在 using 子句中创建并操作读取流
  • 第 14 行:在 using 子句中创建和操作写入流
  • 第 16 行:不缓冲输出流
  • 第 18-31 行:客户端请求/服务器响应循环
  • 第 26 行:客户端向服务器发送请求
  • 第 28 行:客户端等待服务器的响应。这是一项阻塞操作,类似于从键盘读取。等待状态在接收到以 "\n" 结尾的字符串或流结束时终止。后者发生在服务器关闭与客户端建立的连接时。

11.5.3. TcpListener 类

TcpListener 类是创建 TCP 服务的最合适类。其 C 构造函数、M 方法和 P 属性包括以下内容:

TcpListener(int port)
C
创建一个 TCP 服务,该服务将在作为参数(port)传递的端口(称为监听端口)上等待(监听)客户端请求。如果机器连接到多个 IP 网络,该服务将在每个网络上进行监听。
TcpListener(IPAddress ip, int port)
C
同上,但仅在指定的 IP 地址上进行监听。
void Start()
M
监听客户请求
TcpClient AcceptTcpClient()
M接受客户端请求
接受客户端的请求。随后,它会与客户端建立一个新的连接,称为服务连接。服务器端使用的端口是随机的,由系统选择。该端口称为服务端口。AcceptTcpClient 会返回一个与服务器端服务连接关联的 TcpClient 对象。
void Stop()
M
停止监听客户端请求
套接字服务器
P
服务器的监听套接字

使用以下协议与客户端交换数据的 TCP 服务器的基本结构:

  • 客户端发送一行内容组成的请求
  • 服务器发送包含在一行中的响应

可能如下所示:


using System;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using System.Net;
 
namespace ... {
    public class ... {
            ...
             // we create the listening service
            TcpListener ecoute = null;
            try {
                 // create the service - it will listen on all the machine's network interfaces
                ecoute = new TcpListener(IPAddress.Any, port);
                 // launch it
                ecoute.Start();
                 // service loop
                TcpClient tcpClient = null;
                 // infinite loop - will be stopped by Ctrl-C
                while (true) {
                     // waiting for a customer
                    tcpClient = ecoute.AcceptTcpClient();
                     // the service is provided by another task
                    ThreadPool.QueueUserWorkItem(Service, tcpClient);
                     // next customer
                }
            } catch (Exception ex) {
                // on signale l'erreur
                ...
            } finally {
                 // end of service
                ecoute.Stop();
            }
        }
 
        // -------------------------------------------------------
         // provides service to a customer
        public static void Service(Object infos) {
             // the customer is picked up and served
            Client client = infos as Client;
             // operation link TcpClient
            try {
                using (TcpClient tcpClient = client.CanalTcp) {
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamReader reader = new StreamReader(networkStream)) {
                            using (StreamWriter writer = new StreamWriter(networkStream)) {
                                 // unbuffered output stream
                                writer.AutoFlush = true;
                                 // loop read request/write response
                                bool fini=false;
                                while (! fini) != null) {
                                     // waiting for customer request - blocking operation
                                    demande=reader.ReadLine();
                                     // response preparation
                                    réponse=...;
                                     // reply to customer
                                    writer.WriteLine(réponse);
                                     // next request
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                 // error
                ...
            } finally {
                 // end customer
                ...
            }
        }
    }
}
  • 第 14 行:为指定的端口和 IP 地址创建了监听服务。 请注意,一台机器至少拥有两个 IP 地址:其自身的“127.0.0.1”地址(即回环地址),以及在所连接网络中的“I1.I2.I3.I4”地址。若连接到多个 IP 网络,它还可能拥有其他 IP 地址。IPAddress.Any 表示一台机器的所有 IP 地址。
  • 第 16 行:监听服务启动。此前该服务已创建,但尚未开始监听。监听即等待客户请求。
  • 第 20-26 行:针对每个新客户,重复执行等待客户请求/服务客户的循环
  • 第 22 行:接受一个客户端的请求。AcceptTcpClient 方法会创建一个名为 TcpClient 的服务实例:
    • 客户端通过其自身的 TcpClient 实例(位于客户端侧,我们称之为 TcpClientDemande)发出了请求
    • 服务器通过 AcceptTcpClient 接受该请求。该方法在服务器端创建了一个 TcpClient 实例,我们将其命名为 TcpClientService。此时,双方(TcpClientDemande <--> TcpClientService)均持有 TcpClient 实例,一条 Tcp 连接已建立
    • 后续的客户端/服务器通信将通过此连接进行。监听服务不再参与其中。
  • 第 24 行:为了使服务器能够同时处理多个客户端,该服务由线程提供,每个客户端对应一个线程。
  • 第 32 行:关闭监听服务
  • 第 38 行:由客户端服务线程执行的方法。它接收已连接至待服务客户的 TcpClient 实例。
  • 第 38-71 行:代码与上文研究的基本 Tcp 客户端类似。

11.6. TCP 客户端/服务器示例

11.6.1. 一个回显服务器

我们建议编写一个回显服务器,该服务器可通过以下命令在 DOS 窗口中启动:

ServeurEcho 端口

该服务器将在作为参数传递的端口上运行。它仅将请求原样发回给客户端。程序代码如下:


using System;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using System.Net;
 
// call: serveurEcho port
// echo server
// returns the line sent to the customer
 
namespace Chap9 {
    public class ServeurEcho {
        public const string syntaxe = "Syntaxe : [serveurEcho] port";
 
         // main program
        public static void Main(string[] args) {
 
             // is there an argument?
            if (args.Length != 1) {
                Console.WriteLine(syntaxe);
                return;
            }
             // this argument must be integer >0
            int port = 0;
            if (!int.TryParse(args[0], out port) || port<=0) {
                Console.WriteLine("{0} : {1}Port incorrect", syntaxe, Environment.NewLine);
                return;
            }
             // we create the listening service
            TcpListener ecoute = null;
             int numClient =     0; // next customer no
            try {
                 // create the service - it will listen on all the machine's network interfaces
                ecoute = new TcpListener(IPAddress.Any, port);
                 // launch it
                ecoute.Start();
                 // follow-up
                Console.WriteLine("Serveur d'écho lancé sur le port {0}", ecoute.LocalEndpoint);
                 // service threads
                ThreadPool.SetMinThreads(10, 10);
                ThreadPool.SetMaxThreads(10, 10);
                 // service loop
                TcpClient tcpClient = null;
                 // infinite loop - will be stopped by Ctrl-C
                while (true) {
                     // waiting for a customer
                    tcpClient = ecoute.AcceptTcpClient();
                     // the service is provided by another task
                    ThreadPool.QueueUserWorkItem(Service, new Client() { CanalTcp = tcpClient, NumClient = numClient });
                     // next customer
                    numClient++;
                }
            } catch (Exception ex) {
                // on signale l'erreur
                Console.WriteLine("L'erreur suivante s'est produite sur le serveur : {0}", ex.Message);
            } finally {
                 // end of service
                ecoute.Stop();
            }
        }
 
        // -------------------------------------------------------
         // provides service to an echo server client
        public static void Service(Object infos) {
             // the customer is picked up and served
            Client client = infos as Client;
             // renders service to the customer
            Console.WriteLine("Début de service au client {0}", client.NumClient);
             // operation link TcpClient
            try {
                using (TcpClient tcpClient = client.CanalTcp) {
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamReader reader = new StreamReader(networkStream)) {
                            using (StreamWriter writer = new StreamWriter(networkStream)) {
                                 // unbuffered output stream
                                writer.AutoFlush = true;
                                 // loop read request/write response
                                string demande = null;
                                while ((demande = reader.ReadLine()) != null) {
                                     // console monitoring
                                    Console.WriteLine("<--- Client {0} : {1}", client.NumClient, demande);
                                     // echo from demand to customer
                                    writer.WriteLine("[{0}]", demande);
                                     // console monitoring
                                    Console.WriteLine("---> Client {0} : {1}", client.NumClient, demande);
                                     // service stops when customer sends "bye
                                    if (demande.Trim().ToLower() == "bye")
                                        break;
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                 // error
                Console.WriteLine("L'erreur suivante s'est produite lors du service au client {0} : {1}", client.NumClient, e.Message);
            } finally {
                 // end customer
                Console.WriteLine("Fin du service au client {0}", client.NumClient);
            }
        }
    }
 
     // customer info
    internal class Client {
         public TcpClient CanalTcp { get; se        t; } // customer liaison
         public int NumClient { get; se            t; } // customer no
    }
}

回显服务器的结构与上述基本 TCP 服务器架构一致。我们仅对“客户服务”部分进行说明:

  • 第 79 行:读取客户端的请求
  • 第83行:将请求用方括号包围后返回给客户端
  • 第 79 行:当客户端关闭连接时,服务停止

在 DOS 窗口中,我们使用 C# 项目的可执行文件:

...\Chap9\02\bin\Release>dir
 03/05/2008  11:46             7 168 ServeurEcho.exe
...>ServeurEcho 100
Serveur d'écho lancé sur le port 0.0.0.0:100

随后,我们启动两个PuTTY客户端,并将其连接到该机器的100端口(本地主机)

 

回显服务器的控制台显示变为:

1
2
3
Serveur d'écho lancé sur le port 0.0.0.0:100
Début de service au client 0
Début de service au client 1

客户端 1 随后客户端 0 发送以下文本:

  • [1]: 客户编号 1
  • [2]: 客户编号 0
  • [3]: 回显服务器控制台
  • 在 [4] 中:客户端 1 通过命令 bye 断开连接。
  • 在 [5] 中:服务器检测到此情况

按下 Ctrl-C 可停止服务器。随后客户端 0 检测到此情况 [6]。

11.6.2. 一个用于回显服务器的客户端

现在我们为前面的服务器编写一个客户端。它的调用方式如下:

ClientEcho 服务器名称 端口

它将连接到名为 nomServeur 的机器上的 port 端口,然后向服务器发送多行文本,服务器会将这些文本原样回显。


using System;
using System.IO;
using System.Net.Sockets;
 
namespace Chap9 {
     // connects to an echo server
     // any line typed on the keyboard is received as an echo
    class ClientEcho {
        static void Main(string[] args) {
             // syntax
            const string syntaxe = "pg machine port";
 
             // number of arguments
            if (args.Length != 2) {
                Console.WriteLine(syntaxe);
                return;
            }
 
             // note the server name
            string serveur = args[0];
 
             // port must be integer >0
            int port = 0;
            if (!int.TryParse(args[1], out port) || port <= 0) {
                Console.WriteLine("{0}{1}port incorrect", syntaxe, Environment.NewLine);
                return;
            }
 
            // on peut travailler
             string demande = nu        ll; // customer request
             string réponse =         nu ll; // server response
            try {
                 // connect to the service
                using (TcpClient tcpClient = new TcpClient(serveur, port)) {
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamReader reader = new StreamReader(networkStream)) {
                            using (StreamWriter writer = new StreamWriter(networkStream)) {
                                 // unbuffered output stream
                                writer.AutoFlush = true;
                                 // request-response loop
                                while (true) {
                                     // demand comes from the keyboard
                                    Console.Write("Demande (bye pour arrêter) : ");
                                    demande = Console.ReadLine();
                                     // finished?
                                    if (demande.Trim().ToLower() == "bye")
                                        break;
                                     // send the request to the server
                                    writer.WriteLine(demande);
                                     // we read the server response
                                    réponse = reader.ReadLine();
                                     // the answer is processed
                                    Console.WriteLine("Réponse : {0}", réponse);
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                 // error
                Console.WriteLine("L'erreur suivante s'est produite : {0}", e.Message);
            }
        }
    }
}

该客户端的结构符合为 Tcp 提出的基本通用架构。以下是采用以下配置获得的结果:

  • 在同一台机器的 DOS 窗口中,服务器在 100 端口上启动
  • 在同一台机器上,通过两个不同的DOS窗口分别启动两个客户端

客户端 A(编号 0)的窗口显示以下信息:

1
2
3
4
5
6
...\Chap9\03\bin\Release>ClientEcho localhost 100
Demande (bye pour arrêter) : ligne1A
Réponse : [ligne1A]
Demande (bye pour arrêter) : ligne2A
Réponse : [ligne2A]
Demande (bye pour arrêter) :

在客户B(编号1)处:

1
2
3
4
5
6
...\Chap9\03\bin\Release>ClientEcho localhost 100
Demande (bye pour arrêter) : ligne1B
Réponse : [ligne1B]
Demande (bye pour arrêter) : ligne2B
Réponse : [ligne2B]
Demande (bye pour arrêter) :

在服务器上:

...\Chap9\02\bin\Release>ServeurEcho 100
Serveur d'écho lancé sur le port 0.0.0.0:100
Début de service au client 0
<--- Client 0 : ligne1A
---> Client 0 : ligne1A
<--- Client 0 : ligne2A
---> Client 0 : ligne2A
Début de service au client 1
<--- Client 1 : ligne1B
---> Client 1 : ligne1B
<--- Client 1 : ligne2B
---> Client 1 : ligne2B

客户 A 编号 0 断开连接:

1
2
3
4
Demande (bye pour arrêter) : ligne1A
Réponse : [ligne1A]
...
Demande (bye pour arrêter) : bye

服务器控制台:

1
2
3
Serveur d'écho lancé sur le port 0.0.0.0:100
...
Fin du service au client 0

11.6.3. 一个通用的 TCP 客户端

我们将编写一个通用 TCP 客户端,其启动方式如下:ClientTcpGenerique 服务器端口。该客户端的工作方式与 PuTTY 客户端类似,但将采用控制台界面,且不支持选项配置。

在之前的应用程序中,对话协议是已知的:客户端发送一行,服务器回复一行。每个服务都有其特定的协议,还可能遇到以下情况:

  • 客户端必须发送多行文本才能获得响应
  • 服务器的响应可能包含多行文本

因此,向服务器发送一行文本并接收一行文本的循环模式并不总是适用。为了处理比回显协议更复杂的协议,通用 TCP 客户端将包含两个线程:

  • 主线程读取键盘输入的文本行并将其发送至服务器。
  • 一个辅助线程将并行工作,读取服务器发送的文本行。一旦接收到一行,它就会将其显示在控制台上。该线程不会停止,直到服务器关闭连接。因此,它会持续运行。

代码如下:


using System;
using System.IO;
using System.Net.Sockets;
using System.Threading;
 
namespace Chap9 {
     // receives the characteristics of a service as a parameter in the form: server port
     // connects to the service
     // sends each line typed on the keyboard to the server
     // creates a thread to continuously read text lines sent by the server
    class ClientTcpGenerique {
        static void Main(string[] args) {
             // syntax
            const string syntaxe = "pg serveur port";
 
             // number of arguments
            if (args.Length != 2) {
                Console.WriteLine(syntaxe);
                return;
            }
 
             // note the server name
            string serveur = args[0];
 
             // port must be integer >0
            int port = 0;
            if (!int.TryParse(args[1], out port) || port <= 0) {
                Console.WriteLine("{0}{1}port incorrect", syntaxe, Environment.NewLine);
                return;
            }
             // connect to the service
            TcpClient tcpClient = null;
            try {
                tcpClient = new TcpClient(serveur, port);
            } catch (Exception ex) {
                 // error
                Console.WriteLine("Impossible de se connecter au service ({0},{1}) : erreur {2}", serveur, port, ex.Message);
                 // end
                return;
            }
 
             // launch a separate thread to read the text lines sent by the server
            ThreadPool.QueueUserWorkItem(Receive, tcpClient);
 
             // keyboard commands are read in the main thread
            Console.WriteLine("Tapez vos commandes (bye pour arrêter) : ");
             string demande = nu        ll; // customer request
            try {
                 // operate the customer connection
                using (tcpClient) {
                     // create a write stream to the server
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamWriter writer = new StreamWriter(networkStream)) {
                             // unbuffered output stream
                            writer.AutoFlush = true;
                             // request-response loop
                            while (true) {
                                demande = Console.ReadLine();
                                 // finished?
                                if (demande.Trim().ToLower() == "bye")
                                    break;
                                 // send the request to the server
                                writer.WriteLine(demande);
                            }
                        }
                    }
                }
            } catch (Exception e) {
                 // error
                Console.WriteLine("L'erreur suivante s'est produite dans le thread principal : {0}", e.Message);
            }
        }
 
         // client read thread <-- server
        public static void Receive(object infos) {
             // local data
             string réponse =     nu ll; // server response
             // input flow creation
            try {
                using (TcpClient tcpClient = infos as TcpClient) {
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamReader reader = new StreamReader(networkStream)) {
                             // loop continuous reading of text lines in the input stream
                            while ((réponse = reader.ReadLine()) != null) {
                                 // console display
                                Console.WriteLine("<-- {0}", réponse);
                            }
                        }
                    }
                }
            } catch (Exception ex) {
                 // error
                Console.WriteLine("Flux de lecture : l'erreur suivante s'est produite : {0}", ex.Message);
            } finally {
                 // signals the end of the read thread
                Console.WriteLine("Fin du thread de lecture des réponses du serveur. Si besoin est, arrêtez le thread de lecture console avec la commande bye.");
            }
        }
    }
}
  • 第 34 行:客户端连接到服务器
  • 第 43 行:启动一个线程从服务器读取文本行。该线程必须执行第 73 行的 Receive 方法。我们传入已连接到服务器的 TcpClient 实例。
  • 第 57-64 行:键盘命令输入/向服务器发送命令的循环。键盘命令输入由主线程处理。
  • 第 75-98 行:文本行读取线程执行的 Receive 方法。该方法接收已连接到服务器的 TcpClient 实例。
  • 第 84-87 行:用于读取服务器发送的文本行的持续循环。该循环仅在服务器关闭与客户端的连接时停止。

以下是基于第 11.4 节中客户端 putty 示例的几个示例。客户端在 DOS 控制台中运行。

协议 HTTP

...\Chap9\04\bin\Release>ClientTcpGenerique istia.univ-angers.fr 80
Tapez vos commandes (bye pour arrêter) :
GET /inconnu HTTP/1.1
Host: istia.univ-angers.fr:80
Connection: Close

<-- HTTP/1.1 404 Not Found
<-- Date: Sat, 03 May 2008 12:35:11 GMT
<-- Server: Apache/1.3.34 (Debian) PHP/4.4.4-8+etch4 mod_jk/1.2.18 mod_perl/1.29

<-- Connection: close
<-- Transfer-Encoding: chunked
<-- Content-Type: text/html; charset=iso-8859-1
<--
<-- 11a
<-- <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<-- <HTML><HEAD>
<-- <TITLE>404 Not Found</TITLE>
<-- </HEAD><BODY>
<-- <H1>Not Found</H1>
<-- The requested URL /inconnu was not found on this server.<P>
<-- <HR>
<-- <ADDRESS>Apache/1.3.34 Server at www.istia.univ-angers.fr Port 80</ADDRESS>
<-- </BODY></HTML>
<--
<-- 0
<--
[Fin du thread de lecture des réponses du serveur]
bye

...\Chap9\04\bin\Release>

建议读者重新阅读第11.4.2节中的说明。我们仅针对本应用的特定内容进行说明:

  • 第28行:发送第27行内容后,HTTP服务器关闭了连接,从而终止了读取线程。读取键盘命令的主线程仍然处于活动状态。第29行通过键盘输入的命令将其终止。

SMTP 协议

...\Chap9\04\bin\Release>ClientTcpGenerique smtp.neuf.fr 25
Tapez vos commandes (bye pour arrêter) :
<-- 220 neuf-infra-smtp-out-sp604002av.neufgp.fr neuf telecom Service relais mail ready
HELO istia.univ-angers.fr
<-- 250 neuf-infra-smtp-out-sp604002av.neufgp.fr hello [84.100.189.193], Banniere OK , pret pour envoyer un mail
mail from: xx@neuf.fr
<-- 250 2.1.0 <xx@neuf.fr> sender ok
rcpt to: yy@univ-angers.fr
<-- 250 2.1.5 <yy@univ-angers.fr> destinataire ok
data
<-- 354 enter mail, end with "." on a line by itself
ligne1
ligne2
.
<-- 250 2.0.0 M0jL1Z0044AoCxw0200000 message ok
quit
<-- 221 2.0.0 neuf-infra-smtp-out-sp604002av.neufgp.fr neuf telecom closing connection
[Fin du thread de lecture des réponses du serveur]
bye

...\Chap9\04\bin\Release>

建议读者重新阅读第11.4.3节中的说明,并测试其他使用客户端putty的示例。

11.6.4. 一个 服务器 Tcp 通用

我们现在关注一个服务器

  • ,它将客户发送的订单显示在屏幕上
  • 并将用户输入的文本行发送给客户。用户充当服务器。

该程序通过以下命令在 DOS 窗口中启动:ServeurTcpGenerique portEcoute,其中 portEcoute 是客户端应连接的端口。对客户端的服务将由两个线程提供:

  • 主线程:
    • 将依次处理客户,而非并行处理。
    • 该线程将读取用户输入的文本行并将其发送给客户端。用户将发送“bye”命令以关闭与客户端的连接。由于控制台无法同时服务两个客户端,因此我们的服务器每次仅处理一个客户端。
  • 一个辅助线程,专门用于读取客户端发送的文本行

除非用户在键盘上按下 Ctrl-C,否则服务器不会停止运行。

让我们来看几个示例。服务器在100端口上启动,我们使用第11.6.3节中的通用客户端与其通信。客户端窗口如下所示:

1
2
3
4
5
6
7
...\Chap9\04\bin\Release>ClientTcpGenerique localhost 100
Tapez vos commandes (bye pour arrêter) :
commande 1 du client 1
<-- réponse 1 au client 1
commande 2 du client 1
<-- réponse 2 au client 1
bye

以 <-- 开头的行是服务器发送给客户端的,其余行是客户端发送给服务器的。服务器窗口如下:

...\Chap9\05\bin\Release>ServeurTcpGenerique 100
Serveur générique lancé sur le port 0.0.0.0:100
Client 127.0.0.1:4165
Tapez vos commandes (bye pour arrêter) :
<-- commande 1 du client 1
réponse 1 au client 1
<-- commande 2 du client 1
réponse 2 au client 1
[Fin du thread de lecture des demandes du client]
bye

以 <-- 开头的行是客户端发送给服务器的,其余行是服务器发送给客户端的。第 9 行表示客户端的请求读取线程已停止。服务器的主线程仍处于 状态,等待键盘命令发送给客户端。要执行此操作,请输入第 10 行中的命令 bye 以转到下一个客户端。服务器仍处于活动状态,而客户端 1 已完成。 我们为同一服务器启动第二个客户端:

1
2
3
4
5
...\Chap9\04\bin\Release>ClientTcpGenerique localhost 100
Tapez vos commandes (bye pour arrêter) :
commande 3 du client 2
<-- réponse 3 au client 2
bye

此时服务器窗口显示如下:

1
2
3
4
5
6
Tapez vos commandes (bye pour arrêter) :
Client 127.0.0.1:4166
<-- commande 3 du client 2
réponse 3 au client 2
[Fin du thread de lecture des demandes du client]
bye

在上述第 6 行之后,服务器将等待新的客户端。按 Ctrl-C 即可停止程序。

现在让我们通过在 88 端口启动我们的通用服务器来模拟一个 Web 服务器:

1
2
3
...\Chap9\05\bin\Release>ServeurTcpGenerique 88

Serveur générique lancé sur le port 0.0.0.0:88

我们打开一个浏览器,访问网址 http://localhost:88/exemple.html。浏览器将连接到本地主机(localhost)88 端口,然后请求页面 /exemple.html

 

现在我们来看看服务器窗口:

Serveur générique lancé sur le port 0.0.0.0:88
Client 127.0.0.1:4167
Tapez vos commandes (bye pour arrêter) :
<-- GET /exemple.html HTTP/1.1
<-- Accept: image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, application/x-shockwave-flash, application/vnd.ms-excel, application/msword, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-appl
ication, application/x-silverlight, */*
<-- Accept-Language: fr,en-US;q=0.7,fr-FR;q=0.3
<-- UA-CPU: x86
<-- Accept-Encoding: gzip, deflate
<-- User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 1.1.
4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.590; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
<-- Host: localhost:88
<-- Connection: Keep-Alive
<--

我们发现了浏览器发送的 HTTP 头部。这使我们能够发现除已遇到之外的其他 HTTP 头部。让我们为客户端编写一个响应。此时,操作键盘的用户就是真正的服务器,他可以手动编写响应。回顾前一个示例中 Web 服务器生成的响应:

HTTP/1.1 200 OK
Date: Sat, 03 May 2008 07:53:47 GMT
Server: Apache/1.3.34 (Debian) PHP/4.4.4-8+etch4 mod_jk/1.2.18 mod_perl/1.29
X-Powered-By: PHP/4.4.4-8+etch4
Set-Cookie: fe_typo_user=0d2e64b317; path=/
Connection: close
Transfer-Encoding: chunked
Content-Type: text/html;charset=iso-8859-1

693f
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"                                                                        "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
         <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr_FR" lang="fr_FR">
....
         </html>
0

让我们尝试给出一个类似的答案,并尽量精简:

HTTP/1.1 200 OK
Server: serveur tcp generique
Connection: close
Content-Type: text/html

<html>
<head><title>Serveur generique</title></head>
<body><h2>Reponse du serveur generique</h2></body>
</html>
bye
Flux de lecture des lignes de texte du client : l'erreur suivante s'est produite : Unable to read data from the transport connection: Une opération de blocage a été interrompue par un appel à WSACancelBlockingCall.
[Fin du thread de lecture des demandes du client]

在我们的响应中,我们仅在第 1-4 行提供了 HTTP 头部信息。 我们并未提供待发送文档的大小(Content-Length),而是仅声明发送完成后将关闭连接(Connection: close)。这对浏览器而言已足够。当浏览器检测到连接已关闭时,便会知晓服务器的响应已完成,并显示收到的 HTML 页面。这即为第 6-9 行所示的页面。 随后,键盘用户通过输入第10行的命令“bye”关闭了与客户端的连接。接收到该键盘命令后,主线程会关闭与客户端的连接。这导致了第11行的异常。由于与客户端的连接被突然关闭,正在读取客户端文本行的线程被中断,并抛出了异常。第12行之后,服务器将等待新的客户端。

此时客户端浏览器显示如下内容:

如果此时我们使用“显示/源代码”功能查看浏览器接收到的内容,会得到[2],即与我们从通用服务器发送的内容完全一致。

通用 TCP 服务器代码如下:


using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading;
 
namespace Chap9 {
    public class ServeurTcpGenerique {
        public const string syntaxe = "Syntaxe : ServeurGénérique Port";
 
         // main program
        public static void Main(string[] args) {
 
             // is there an argument?
            if (args.Length != 1) {
                Console.WriteLine(syntaxe);
                Environment.Exit(1);
            }
             // this argument must be integer >0
            int port = 0;
            if (!int.TryParse(args[0], out port) || port <= 0) {
                Console.WriteLine("{0} : {1}Port incorrect", syntaxe, Environment.NewLine);
                Environment.Exit(2);
            }
             // we create the listening service
            TcpListener ecoute = null;
            try {
                 // create the service
                ecoute = new TcpListener(IPAddress.Any, port);
                 // launch it
                ecoute.Start();
                 // follow-up
                Console.WriteLine("Serveur générique lancé sur le port {0}", ecoute.LocalEndpoint);
                while (true) {
                     // waiting for a customer
                    Console.WriteLine("Attente du client suivant...");
                    TcpClient tcpClient = ecoute.AcceptTcpClient();
                    Console.WriteLine("Client {0}", tcpClient.Client.RemoteEndPoint);
                     // launch a separate thread to read the lines of text sent by the client
                    ThreadPool.QueueUserWorkItem(Receive, tcpClient);
                     // keyboard commands are read in the main thread
                    Console.WriteLine("Tapez vos commandes (bye pour arrêter) : ");
                     string répon        se = null; // server response
                     // operate the customer connection
                    using (tcpClient) {
                         // create a write flow to the client
                        using (NetworkStream networkStream = tcpClient.GetStream()) {
                            using (StreamWriter writer = new StreamWriter(networkStream)) {
                                 // unbuffered output stream
                                writer.AutoFlush = true;
                                 // keyboard response loop
                                while (true) {
                                    réponse = Console.ReadLine();
                                     // finished?
                                    if (réponse.Trim().ToLower() == "bye")
                                        break;
                                     // we send the request to the customer
                                    writer.WriteLine(réponse);
                                }
                            }
                        }
                    }
                }
            } catch (Exception ex) {
                // on signale l'erreur
                Console.WriteLine("Main : l'erreur suivante s'est produite : {0}", ex.Message);
            } finally {
                 // end of listening
                ecoute.Stop();
            }
        }
 
         // read thread server <-- client
        public static void Receive(object infos) {
             // local data
             string demande = nu    ll; // customer request
             string idClient    =nu ll; // customer identity
 
             // operation customer connection
            try {
                using (TcpClient tcpClient = infos as TcpClient) {
                     // customer identity
                    idClient = tcpClient.Client.RemoteEndPoint.ToString();
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamReader reader = new StreamReader(networkStream)) {
                             // loop continuous reading of text lines in the input stream
                            while ((demande = reader.ReadLine()) != null) {
                                 // console display
                                Console.WriteLine("<-- {0}", demande);
                            }
                        }
                    }
                }
            } catch (Exception ex) {
                 // error
                Console.WriteLine("Flux de lecture des lignes de texte du client {1} : l'erreur suivante s'est produite : {0}", ex.Message,idClient);
            } finally {
                 // signals the end of the read thread
                Console.WriteLine("Fin du thread de lecture des lignes de texte du client {0}. Si besoin est, arrêtez le thread de lecture console du serveur pour ce client, avec la commande bye.", idClient);
            }
        }
    }
}
  • 第 29 行:监听服务已创建但尚未启动。它监听该机器的所有网络接口。
  • 第 31 行:监听服务已启动
  • 第 34 行:无限的客户端等待循环。用户通过 Ctrl-C 终止了服务器。
  • 第 37 行:等待客户端——阻塞操作。当客户端到达时,由 AcceptTcpClient 创建的 TcpClient 代表与客户端建立的连接中的服务器端。
  • 第 40 行:客户端请求由一个单独的线程读取。
  • 第 45 行:在 using 子句中使用 client 连接,以确保无论发生什么情况,该连接都会被关闭。
  • 第 47 行:在 using 子句中使用网络流
  • 第 48 行:在 `using` 子句中从写入流创建网络流
  • 第 50 行:写流将设置为无缓冲
  • 第 52-59 行:用于接收发给客户的订单的键盘输入循环
  • 第 69 行:监听服务结束。由于服务器被 Ctrl-C 终止,此指令在此处永远不会被执行。
  • 第 78 行:Receive 方法,该方法会持续在控制台上显示客户端发送的文本行。这与通用 TCP 客户端的情况相同。

11.6.5. 客户Web

在前面的示例中,我们看到了一些由客户端发送的 HTTP 头部:

<-- GET /exemple.html HTTP/1.1
<-- Accept: image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, application/x-shockwave-flash, application/vnd.ms-excel, application/msword, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-appl
ication, application/x-silverlight, */*
<-- Accept-Language: fr,en-US;q=0.7,fr-FR;q=0.3
<-- UA-CPU: x86
<-- Accept-Encoding: gzip, deflate
<-- User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 1.1.
4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.590; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
<-- Host: localhost:88
<-- Connection: Keep-Alive
<--

我们将编写一个 Web 客户端,向其传递一个 URL 作为参数,该客户端将把服务器发送的文本显示在屏幕上。我们假设服务器支持 HTTP 1.1 协议。在上述标头中,我们将仅使用以下内容:

1
2
3
4
<-- GET /exemple.html HTTP/1.1
<-- Host: localhost:88
<-- Connection: close
<--
  • 第一个标头指明了所需的文档
  • 第二个是被查询的服务器
  • 第三个表示希望服务器在向我们响应后关闭连接。

如果我们将第 1 行中的 GET 替换为 HEAD,服务器将只向我们发送 HTTP 头部,而不会发送第 1 行中指定的文档。

我们的客户端 Web 将采用以下命名规则:ClientWeb URL cmd,其中 URL 表示目标 URL,cmd 则是 GET 或 HEAD 这两个关键字之一,用于指示是否仅需请求头部信息(HEAD)或同时请求页面内容(GET)。让我们来看一个示例:

...\Chap9\06\bin\Release>ClientWeb http://istia.univ-angers.fr:80 HEAD
HTTP/1.1 200 OK
Date: Sat, 03 May 2008 14:05:24 GMT
Server: Apache/1.3.34 (Debian) PHP/4.4.4-8+etch4 mod_jk/1.2.18 mod_perl/1.29
X-Powered-By: PHP/4.4.4-8+etch4
Set-Cookie: fe_typo_user=e668408ac1; path=/
Connection: close
Content-Type: text/html;charset=iso-8859-1

...\Chap9\06\bin\Release>
  • 第 1 行,我们仅请求 HTTP (HEAD) 头部
  • 第2-9行:服务器响应

如果我们在 Web 客户端调用中使用 GET 代替 HEAD,我们将获得与 HEAD 相同的结果,外加所请求的文档正文。

客户端代码如下:


using System;
using System.IO;
using System.Net.Sockets;
 
namespace Chap9 {
    class ClientWeb {
        static void Main(string[] args) {
             // syntax
            const string syntaxe = "pg URI GET/HEAD";
 
             // number of arguments
            if (args.Length != 2) {
                Console.WriteLine(syntaxe);
                return;
            }
 
             // note the URI required
            string stringURI = args[0];
            string commande = args[1].ToUpper();
 
             // URI validity check
            if(! stringURI.StartsWith("http://")){
                Console.WriteLine("Indiquez une Url de la forme http://machine[:port]/document");
                return;
            }
            Uri uri = null;
            try {
                uri = new Uri(stringURI);
            } catch (Exception ex) {
                 // URI incorrect
                Console.WriteLine("L'erreur suivante s'est produite : {0}", ex.Message);
                return;
            }
             // order verification
            if (commande != "GET" && commande != "HEAD") {
                 // incorrect order
                Console.WriteLine("Le second paramètre doit être GET ou HEAD");
                return;
            }
 
            try {
                 // connect to the service
                using (TcpClient tcpClient = new TcpClient(uri.Host, uri.Port)) {
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamReader reader = new StreamReader(networkStream)) {
                            using (StreamWriter writer = new StreamWriter(networkStream)) {
                                 // unbuffered output stream
                                writer.AutoFlush = true;
                                 // request URL - send HTTP headers
                                writer.WriteLine(commande + " " + uri.PathAndQuery + " HTTP/1.1");
                                writer.WriteLine("Host: " + uri.Host + ":" + uri.Port);
                                writer.WriteLine("Connection: close");
                                writer.WriteLine();
                                 // we read the answer
                                string réponse = null;
                                while ((réponse = reader.ReadLine()) != null) {
                                     // the response is displayed on the console
                                    Console.WriteLine(réponse);
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                // on affiche l'exception
                Console.WriteLine("L'erreur suivante s'est produite : {0}", e.Message);
            }
        }
    }
}

本程序的唯一新功能是使用了 Uri。程序接收格式为 http://server:port/cheminPageHTML?param1=val1;param2=val2;.... 的 URL(统一资源定位符)或 URI(统一资源标识符)。Uri 类允许我们将 URL 链分解为各个独立元素。

  • 第 26-33 行:根据作为参数接收的字符串 stringURI 构建一个 Uri 对象。如果作为参数接收的 URI 字符串不是有效的 URI(缺少协议、服务器等),则会抛出异常。这使我们能够检查接收到的参数是否有效。 一旦 Uri 构建完成,我们即可访问该 Uri 的各个元素。因此,如果前文代码中的 Uri 是根据字符串 http://server:port/document?param1=val1&param2=val2;... 构建的,则我们得到:
    • uri.Host=server,
    • uri.Host=server,
    • uri.Host=server,
    • uri.Query=param1=val1&param2=val2;...,
    • uri.pathAndQuery= cheminPageHTML?param1=val1&param2=val2;...,
    • uri.Scheme=http.

11.6.6. 用于管理重定向的 Web 客户端

前面的 Web 客户端不会处理其请求的 URL 的任何重定向。以下是一个示例:

...\Chap9\06\bin\Release>ClientWeb http://www.ibm.com GET
HTTP/1.1 302 Found
Date: Sat, 03 May 2008 14:50:52 GMT
Server: IBM_HTTP_Server
Location: http://www.ibm.com/us/
Content-Length: 206
Kp-eeAlive: timeout=10, max=73
Connection: Keep-Alive
Content-Type: text/html

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>302 Found</title>
</head><body>
<h1>Found</h1>
<p>The document has moved <a href="http://www.ibm.com/us/">here</a>.</p>
</body></html>
  • 第 2 行:代码 302 Found 表示重定向。浏览器应重定向到的地址位于文档正文第 16 行。

第二个示例:

...\Chap9\06\bin\Release>ClientWeb http://www.bull.com GET
HTTP/1.1 301 Moved Permanently
Date: Sat, 03 May 2008 14:52:31 GMT
Server: Apache/1.3.33 (Unix) WS_filter/2.1.15 PHP/4.3.4
X-Powered-By: PHP/4.3.4
Location: http://www.bull.com/index.php
Connection: close
Transfer-Encoding: chunked
Content-Type: text/html

0
  • 第 2 行:状态码 301(永久重定向)表示发生了重定向。浏览器应重定向到的地址在第 6 行的 HTTP 头部 Rental 中指定。

第三个示例:

1
2
3
4
5
6
7
...\Chap9\06\bin\Release>ClientWeb http://www.gouv.fr GET
HTTP/1.1 302 Moved Temporarily
Server: AkamaiGHost
Content-Length: 0
Location: http://www.premier-ministre.gouv.fr/fr/
Date: Sat, 03 May 2008 14:56:53 GMT
Connection: close
  • 第 2 行:代码 302 Moved Temporarily 表示重定向。浏览器必须重定向到的地址在第 5 行,即 HTTP 头 Rental 中。

第四个示例使用本地 IIS 服务器:

...\istia\Chap9\06\bin\Release>ClientWeb.exe http://localhost HEAD
HTTP/1.1 302 Object moved
Server: Microsoft-IIS/5.1
Date: Sun, 04 May 2008 10:16:56 GMT
Connection: close
Location: localstart.asp
Content-Length: 121
Content-Type: text/html
Set-Cookie: ASPSESSIONIDQQASDQAB=FDJLADLCOLDHGKGNIPMLHIIA; path=/
Cache-control: private
  • 第 2 行:代码 302 Object moved 表示重定向。浏览器必须重定向到的地址在第 5 行的 HTTP 标头 Rental 中指定。请注意,与之前的示例不同,此处的重定向地址是相对路径。完整的地址实际上是 http://localhost/localstart.asp

我们建议在HTTP头的第一行包含关键词“moved”(不区分大小写)且重定向地址位于HTTP头Rental中时,进行重定向处理。

若以最后三个示例为例,结果如下:

URL:http://www.bull.com

...\Chap9\06B\bin\Release>ClientWebAvecRedirection http://www.bull.com HEAD
HTTP/1.1 301 Moved Permanently
Date: Sun, 04 May 2008 10:22:48 GMT
Server: Apache/1.3.33 (Unix) WS_filter/2.1.15 PHP/4.3.4
X-Powered-By: PHP/4.3.4
Location: http://www.bull.com/index.php
Connection: close
Content-Type: text/html


<--Redirection vers l'URL http://www.bull.com/index.php-->

HTTP/1.1 200 OK
Date: Sun, 04 May 2008 10:22:49 GMT
Server: Apache/1.3.33 (Unix) WS_filter/2.1.15 PHP/4.3.4
X-Powered-By: PHP/4.3.4
Connection: close
Content-Type: text/html
  • 第 11 行:重定向到第 6 行中的地址

网址:http://www.gouv.fr

...\Chap9\06B\bin\Release>ClientWebAvecRedirect
ion http://www.gouv.fr HEAD
HTTP/1.1 302 Moved Temporarily
Server: AkamaiGHost
Content-Length: 0
Location: http://www.premier-ministre.gouv.fr/fr/
Date: Sun, 04 May 2008 10:30:38 GMT
Connection: close


<--Redirection vers l'URL http://www.premier-ministre.gouv.fr/fr/-->

HTTP/1.1 200 OK
Server: Apache
X-Powered-By: PHP/4.4.1
Last-Modified: Sun, 04 May 2008 10:29:48 GMT
Content-Type: text/html
Expires: Sun, 04 May 2008 10:40:38 GMT
Date: Sun, 04 May 2008 10:30:38 GMT
Connection: close
  • 第 11 行:重定向到第 6 行中的地址

网址:http://localhost

...\Chap9\06B\bin\Release>ClientWebAvecRedirection.exe http://localhost HEAD
HTTP/1.1 302 Object moved
Server: Microsoft-IIS/5.1
Date: Sun, 04 May 2008 10:37:11 GMT
Connection: close
Location: localstart.asp
Content-Length: 121
Content-Type: text/html
Set-Cookie: ASPSESSIONIDQQASDQAB=GDJLADLCJCMPCHFFEJEFPKMK; path=/
Cache-control: private


<--Redirection vers l'URL http://localhost/localstart.asp-->

HTTP/1.1 401 Access Denied
Server: Microsoft-IIS/5.1
Date: Sun, 04 May 2008 10:37:11 GMT
WWW-Authenticate: Negotiate
WWW-Authenticate: NTLM
WWW-Authenticate: Basic realm="localhost"
Connection: close
Content-Length: 4766
Content-Type: text/html
  • 第 13 行:重定向到第 6 行中的地址
  • 第 15 行:访问页面 http://localhost/localstart.asp 被拒绝。

处理重定向的程序如下:


using System;
using System.IO;
using System.Net.Sockets;
using System.Text.RegularExpressions;
 
namespace Chap9 {
    class ClientWebAvecRedirection {
        static void Main(string[] args) {
             // syntax
            const string syntaxe = "pg URI GET/HEAD";
 
             // number of arguments
            if (args.Length != 2) {
                Console.WriteLine(syntaxe);
                return;
            }
 
             // note the URI required
            string stringURI = args[0];
            string commande = args[1].ToUpper();
 
             // URI validity check
            if (!stringURI.StartsWith("http://")) {
                Console.WriteLine("Indiquez une Url de la forme http://machine[:port]/document");
                return;
            }
            Uri uri = null;
            try {
                uri = new Uri(stringURI);
            } catch (Exception ex) {
                 // URI incorrect
                Console.WriteLine("L'erreur suivante s'est produite : {0}", ex.Message);
                return;
            }
             // order verification
            if (commande != "GET" && commande != "HEAD") {
                 // incorrect order
                Console.WriteLine("Le second paramètre doit être GET ou HEAD");
                return;
            }
 
             const int nbRedirsMa        x = 1; // no more than one redirection accepted
             int nbRedirs =                             0; // number of redirects in progress
 
             // regular expression to find a URL redirect
            Regex location = new Regex(@"^Location: (.+?)$");
            try {
                 // you may have several URL to request if there are redirections
                while (nbRedirs <= nbRedirsMax) {
                     // redirection management
                    bool redir = false;
                    bool locationFound = false;
                    string locationString = null;
                     // connect to the service
                    using (TcpClient tcpClient = new TcpClient(uri.Host, uri.Port)) {
                        using (StreamReader reader = new StreamReader(tcpClient.GetStream())) {
                            using (StreamWriter writer = new StreamWriter(tcpClient.GetStream())) {
                                 // unbuffered output stream
                                writer.AutoFlush = true;
                                 // request URL - send HTTP headers
                                writer.WriteLine(commande + " " + uri.PathAndQuery + " HTTP/1.1");
                                writer.WriteLine("Host: " + uri.Host + ":" + uri.Port);
                                writer.WriteLine("Connection: close");
                                writer.WriteLine();
                                 // read the first line of the answer
                                string premièreLigne = reader.ReadLine();
                                 // screen echo
                                Console.WriteLine(premièreLigne);
 
                                 // redirection?
                                if (Regex.IsMatch(premièreLigne.ToLower(), @"\s+moved\s*")) {
                                     // there is a redirection
                                    redir = true;
                                    nbRedirs++;
                                }
 
                                 // next HTTP headers until you find the empty line signalling the end of the headers
                                string réponse = null;
                                while ((réponse = reader.ReadLine()) != "") {
                                     // the answer is displayed
                                    Console.WriteLine(réponse);
                                     // if there is a redirection, we search for the Location header
                                    if (redir && !locationFound) {
                                         // compare the current line with the relational expression location
                                        Match résultat = location.Match(réponse);
                                        if (résultat.Success) {
                                             // if found, note the URL of redirection
                                            locationString = résultat.Groups[1].Value;
                                             // we note that we found
                                            locationFound = true;
                                        }
                                    }
                                }
 
                                 // the HTTP headers have been used up - write the empty line
                                Console.WriteLine(réponse);
                                 // then move on to the body of the document
                                while ((réponse = reader.ReadLine()) != null) {
                                    Console.WriteLine(réponse);
                                }
                            }
                        }
                    }
                    // a-t-on fini ?
                    if (!locationFound || nbRedirs > nbRedirsMax)
                        break;
                     // there is a redirection to be made - we build the new Uri
                    try {
                        if (locationString.StartsWith("http")) {
                             // full http address
                            uri = new Uri(locationString);
                        } else {
                             // http address relative to current uri
                            uri = new Uri(uri, locationString);
                        }
                         // log console
                        Console.WriteLine("\n<--Redirection vers l'URL {0}-->\n", uri);
                    } catch (Exception ex) {
                         // pb with Uri
                        Console.WriteLine("\n<--L'adresse de redirection {0} n'a pas été comprise : {1} -->\n", locationString, ex.Message);
                    }
                }
            } catch (Exception e) {
                // on affiche l'exception
                Console.WriteLine("L'erreur suivante s'est produite : {0}", e.Message);
            }
        }
    }
}

与上一版本相比,更改如下:

  • 第 46 行:用于从 HTTP 标头 Location: 字段中提取重定向地址的正则表达式。
  • 第 49 行:原先针对单个 URI 执行的代码,现在可以依次对多个 URI 执行。
  • 第 66 行:读取服务器发送的 HTTP 头部的第一行。如果请求的文档已被移动,该行将包含“moved”关键字。
  • 第 71-75 行:检查第一行是否包含“moved”关键字。如果包含,则将其记录下来。
  • 第 79-93 行:读取其余 HTTP 头部,直到遇到表示结束的空行。如果第一行宣布了重定向,则关注 HTTP 头部 Location: 地址,并将重定向地址存储在 locationString 中。
  • 第 98-100 行:将 HTTP 服务器响应的其余部分显示在控制台上。
  • 第 105-106 行:请求的 Uri 已完全解析并显示。如果不存在需要执行的重定向,或者允许的重定向次数已超出,则程序退出。
  • 第 108-122 行:如果存在重定向,我们将计算要请求的新 Uri。这涉及一些操作,具体取决于找到的重定向地址是绝对地址(第 111 行)还是相对地址(第 114 行)。

11.7. 专用于特定互联网协议的 .NET 类

在之前的 Web 客户端示例中,HTTP 协议是通过 TCP 客户端来管理的。因此,我们不得不自己处理具体的通信协议。同样地,我们本可以构建一个 SMTP 或 POP 客户端。而 .NET 框架为 HTTP 和 SMTP 协议提供了专门的类。这些类了解客户端与服务器之间的通信协议,从而省去了开发人员自行管理这些协议的麻烦。下面我们将介绍这些类。

11.7.1. WebClient 类

WebClient 类能够与 Web 服务器进行通信。让我们以第 11.6.5 节中的 Web 客户端示例为例,在此使用 WebClient 类进行处理。


using System;
using System.IO;
using System.Net;
namespace Chap9 {
    public class Program {
        public static void Main(string[] args) {
             // syntax: [prog] Uri
            const string syntaxe = "pg URI";
 
             // number of arguments
            if (args.Length != 1) {
                Console.WriteLine(syntaxe);
                return;
            }
 
             // note the URI required
            string stringURI = args[0];
 
             // URI validity check
            if (!stringURI.StartsWith("http://")) {
                Console.WriteLine("Indiquez une Url de la forme http://machine[:port]/document");
                return;
            }
            Uri uri = null;
            try {
                uri = new Uri(stringURI);
            } catch (Exception ex) {
                 // URI incorrect
                Console.WriteLine("L'erreur suivante s'est produite : {0}", ex.Message);
                return;
            }
 
            try {
                 // web client creation
                using (WebClient client = new WebClient()) {
                     // added HTTP header 
                    client.Headers.Add("user-agent", "st");
                    using (Stream stream = client.OpenRead(uri)) {
                        using (StreamReader reader = new StreamReader(stream)) {
                             // display web server response
                            Console.WriteLine(reader.ReadToEnd());
                             // display headers server response
                            Console.WriteLine("---------------------");
                            foreach (string clé in client.ResponseHeaders.Keys) {
                                Console.WriteLine("{0}: {1}", clé, client.ResponseHeaders[clé]);
                            }
                            Console.WriteLine("---------------------");
                        }
                    }
                }
            } catch (WebException e1) {
                Console.WriteLine("L'exception suivante s'est produite : {0}", e1);
            } catch (Exception e2) {
                Console.WriteLine("L'exception suivante s'est produite : {0}", e2);
            }
        }
    }
}
  • 第 35 行:创建了客户端 Web 服务,但尚未配置
  • 第 37 行:向 HTTP 请求中添加了一个 HTTP 头部。我们将发现,默认情况下还会发送其他头部。
  • 第 38 行:Web 客户端请求用户提供的 Uri 并读取发送的文档。[WebClient].OpenRead(Uri) 通过 Uri 建立连接并读取响应。这就是该类发挥作用的地方。它负责与 Web 服务器进行交互。其结果是 OpenRead 方法的返回类型为 Stream,代表所请求的文档。服务器发送的 HTTP 头以及响应中位于文档之前的头部信息并不包含在其中。
  • 第 39 行:创建一个 StreamReader 对象,第 41 行调用其 ReadToEnd 方法以读取完整的响应内容。
  • 第 44-46 行:服务器响应中包含 HTTP 头部。[WebClient].ResponseHeaders 表示一个值型集合,其键为 HTTP 头部的名称,值为与这些头部关联的字符串。
  • 第 51 行:客户端/服务器交互过程中抛出的异常属于 WebException 类型。

让我们来看几个示例。

6.4.6 节中构建的通用 TCP 服务器:

...\Chap9\05\bin\Release>ServeurTcpGenerique.exe 88
Serveur générique lancé sur le port 0.0.0.0:88

前面的 Web 客户端启动方式如下:

...\Chap9\09\bin\Release>09 http://localhost:88

请求的 URI 是通用服务器的地址。随后,通用服务器会显示 Web 客户端发送给它的 HTTP 头部:

1
2
3
4
5
6
7
Client 127.0.0.1:1415
Tapez vos commandes (bye pour arrêter) :
<-- GET / HTTP/1.1
<-- User-Agent: st
<-- Host: localhost:88
<-- Connection: Keep-Alive
<--

这显示:

  • 客户端网页默认发送 3 个 HTTP 头部(第 3、5、6 行)
  • 第 4 行:我们自己生成的头部(代码第 37 行)
  • 该客户端 Web 默认使用 GET 方法(第 3 行)。其他方法包括 POST 和 HEAD。

现在让我们请求一个不存在的资源:

1
2
3
4
5
...\Chap9\09\bin\Release>09 http://istia.univ-angers.fr/inconnu
L'exception suivante s'est produite : System.Net.WebException: The remote server returned an error: (404) Not Found.
   at System.Net.WebClient.OpenRead(Uri address)
   at System.Net.WebClient.OpenRead(String address)
   at Chap9.WebClient1.Main(String[] args) in C:\data\2007-2008\c# 2008\poly\istia\Chap9\09\Program.cs:line 16
  • 第 2 行:发生类型为 WebException 的异常,因为服务器返回了 404 未找到状态码,表示请求的资源不存在。

最后,让我们请求一个已存在的资源:

...\istia\Chap9\09\bin\Release>09 http://istia.univ-angers.fr >istia.univ-angers.txt

该命令生成的文件 istia.univ-angers.txt 内容如下:

<!DOCTYPE html
     PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
     "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr_FR" lang="fr_FR">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
...
</html>
---------------------
Keep-Alive: timeout=15, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: text/html;charset=iso-8859-1
Date: Sun, 04 May 2008 14:30:53 GMT
Set-Cookie: fe_typo_user=22eaaf283a; path=/
Server: Apache/1.3.34 (Debian) PHP/4.4.4-8+etch4 mod_jk/1.2.18 mod_perl/1.29
X-Powered-By: PHP/4.4.4-8+etch4
---------------------
  • 第 1 行:请求的 HTML 文档。
  • 第 3-10 行:HTTP 响应头,其顺序不一定与发送时的顺序一致。

WebClient 类提供了用于接收文档(DownLoad 方法)或发送文档(UpLoad 方法)的方法:

DownLoadData
将资源(例如图像)下载为字节数组
DownLoadFile
用于下载资源并将其保存为本地文件
DownLoadString
用于下载资源并将其作为字符串获取(例如 HTML 文件)
OpenWrite
OpenRead 的对应方法,但用于将数据发送至服务器
UpLoadData
DownLoadData 相对应,但用于向服务器发送数据
UpLoadFile
DownLoadFile 相对应,但用于向服务器上传
UpLoadString
DownLoadString 功能相同,但上传至服务器
UpLoadValues
用于将 POST 命令的数据发送至服务器,并以字节数组的形式获取结果。 POST 命令请求一个文档,同时向服务器传输其确定实际发送文档所需的信息。这些信息以文档形式发送至服务器,因此该方法命名为 UpLoad。它们以 param1=value1&param2=value2&... 的形式,位于空的 HTTP 头行之后发送:
POST /document HTTP/1.1
...
[空行]
param1=value1&param2=value2&...
也可以使用 GET 方法请求同一文档:
GET /document?param1=值1&param2=值2&...
...
[空行]
这两种方法的区别在于:当使用 POST 方法时,显示所请求 URI 的浏览器将显示 /document;而当使用 GET 方法时,则显示 /document?param1=value1&param2=value2&...

11.7.2. WebRequest / WebResponse 类

有时 WebClient 类不够灵活,无法满足您的需求。让我们以第 11.6.6 节中研究的包含重定向的 Web 客户端为例。我们需要发送 HTTP 头:

HEAD /document HTTP/1.1

我们已经看到,WebClient默认发送的HTTP头部如下:

1
2
3
<-- GET / HTTP/1.1
<-- Host: machine:port
<-- Connection: Keep-Alive

我们还看到,可以通过 [WebClient].Headers 向之前的 HTTP 头部添加新的头部。只有第 1 行不属于 Headers,因为它没有“键:值”的格式。 我无法弄清楚如何将 WebClient 类中第 1 行中的 GET 改为 HEAD(也许是我查错了?)。当 WebClient 类的功能已无法满足需求时,我们可以转向 WebRequest / WebResponse

  • WebRequest:代表整个 Web 客户端请求。
  • WebResponse:表示整个服务器响应。

我们提到,WebClient 支持 http:https:ftp:file: 协议。这些不同协议的请求和响应格式各不相同。因此,我们需要操作这些元素的具体类型,而非其通用类型 WebRequestWebResponse。因此,我们将使用:

  • HttpWebRequestHttpWebResponse 来处理 HTTP 请求
  • FtpWebRequestFtpWebResponse 用于 FTP 请求

现在,我们将通过第 11.6.6 节中研究的包含重定向的 Web 客户端示例,来探讨 HttpWebRequestHttpWebResponse。代码如下:


using System;
using System.IO;
using System.Net.Sockets;
using System.Net;
 
namespace Chap9 {
    class WebRequestResponse {
        static void Main(string[] args) {
             // syntax
            const string syntaxe = "pg URI GET/HEAD";
 
             // number of arguments
            if (args.Length != 2) {
                Console.WriteLine(syntaxe);
                return;
            }
 
             // note the URI required
            string stringURI = args[0];
            string commande = args[1].ToUpper();
 
             // URI validity check
            Uri uri = null;
            try {
                uri = new Uri(stringURI);
            } catch (Exception ex) {
                 // URI incorrect
                Console.WriteLine("L'erreur suivante s'est produite : {0}", ex.Message);
                return;
            }
             // order verification
            if (commande != "GET" && commande != "HEAD") {
                 // incorrect order
                Console.WriteLine("Le second paramètre doit être GET ou HEAD");
                return;
            }
 
            try {
                 // configure the query
                HttpWebRequest httpWebRequest = WebRequest.Create(uri) as HttpWebRequest;
                httpWebRequest.Method = commande;
                httpWebRequest.Proxy = null;
                 // it is executed
                HttpWebResponse httpWebResponse = httpWebRequest.GetResponse() as HttpWebResponse;
                 // result
                Console.WriteLine("---------------------");
                Console.WriteLine("Le serveur {0} a répondu : {1} {2}", httpWebResponse.ResponseUri,(int)httpWebResponse.StatusCode, httpWebResponse.StatusDescription);
                 // headers HTTP
                Console.WriteLine("---------------------");
                foreach (string clé in httpWebResponse.Headers.Keys) {
                    Console.WriteLine("{0}: {1}", clé, httpWebResponse.Headers[clé]);
                }
                Console.WriteLine("---------------------");
                 // document
                using (Stream stream = httpWebResponse.GetResponseStream()) {
                    using (StreamReader reader = new StreamReader(stream)) {
                         // the response is displayed on the console
                        Console.WriteLine(reader.ReadToEnd());
                    }
                }
            } catch (WebException e1) {
                 // the answer is retrieved
                HttpWebResponse httpWebResponse = e1.Response as HttpWebResponse;
                Console.WriteLine("Le serveur {0} a répondu : {1} {2}", httpWebResponse.ResponseUri, (int)httpWebResponse.StatusCode, httpWebResponse.StatusDescription);
            } catch (Exception e2) {
                // on affiche l'exception
                Console.WriteLine("L'erreur suivante s'est produite : {0}", e2.Message);
            }
        }
    }
}
  • 第 40 行:通过静态方法 WebRequest.Create(Uri uri) 创建了一个 WebRequest 类型的对象,其中 uri 是要下载的文档的 URI。由于我们知道 Uri 的协议是 HTTP,因此将结果类型更改为 HttpWebRequest,以便访问 HTTP 协议的特定元素。
  • 第 41 行:我们为 HTTP 头的第一行设置 GET / POST / HEAD 方法。此处将使用 GET 或 HEAD。
  • 第 42 行:在企业私有网络中,出于安全考虑,公司机器通常与互联网隔离。为此,私有网络使用互联网路由器不会转发的 IP 地址。私有网络通过称为代理的特殊机器连接到互联网,这些代理同时连接到公司的私有网络和互联网。这是具有多个 IP 地址的机器的一个示例。 私有网络中的机器无法直接与互联网上的服务器(例如 Web 服务器)建立连接,必须请求代理服务器代为处理。一台代理服务器可以托管不同协议的代理服务。我们称代为私有网络中机器发送 HTTP 请求的服务为 HTTP 代理。如果存在这样的 HTTP 代理服务器,必须在 [WebRequest].proxy 字段中指定。例如,写入:
[WebRequest].proxy=new WebProxy("pproxy.istia.uang:3128");

若 HTTP 代理位于 pproxy.istia.uang 服务器的 3128 端口上。若设备可直接访问互联网且无需通过代理,则在 [WebRequest].proxy 字段中填写 null

  • 第 44 行:GetResponse() 方法请求由其 Uri 标识的文档,并返回一个 WebRequestResponse 对象,该对象在此处被转换为 HttpWebResponse 对象。该对象代表服务器对文档请求的响应。
  • 第 47 行:
    • [HttpWebResponse].ResponseUri:是发送文档的服务器 Uri。在重定向的情况下,这可能与最初查询的服务器 Uri 不同。请注意,代码不处理重定向。它由 GetResponse 自动处理。这再次体现了 Tcp 协议中高阶类相对于基础类的优势。
    • [HttpWebResponse].StatusCode[HttpWebResponse].StatusDescription 代表响应的第一行,例如:HTTP/1.1 200 OK。其中 StatusCode 为 200,StatusDescription 为 OK。
  • 第 50 行:[HttpWebResponse].Headers 是响应中 HTTP 头信息的集合。
  • 第 55 行:[HttpWebResponse].GetResponseStream:用于获取响应中包含的文档的流。
  • 第 61 行:一个 WebException 类型的异常
  • 第 63 行:[WebException].Response 是引发该异常的响应。

以下是一个示例:

...\Chap9\09B\bin\Release>09B http://www.gouv.fr HEAD
---------------------
Le serveur http://www.premier-ministre.gouv.fr/fr/ a répondu : 200 OK
---------------------
Connection: keep-alive
Content-Type: text/html; charset=iso-8859-1
Date: Mon, 05 May 2008 13:02:29 GMT
Expires: Mon, 05 May 2008 13:07:20 GMT
Last-Modified: Mon, 05 May 2008 12:56:59 GMT
Server: Apache
X-Powered-By: PHP/4.4.1
---------------------
  • 第1行和第3行:响应的服务器与被查询的服务器不同。因此发生了重定向。
  • 第 5-11 行:服务器发送的 HTTP 头部

11.7.3. 应用:Web 翻译服务器的代理客户端

接下来我们将展示如何利用上述类来调用 Web 资源。

11.7.3.1. 该应用程序

网络上有许多翻译网站。本文将使用 http://trans.voila.fr/traduction_voila.php 这一网站:

将待翻译的文本输入到[1]中,在[2]中选择翻译方向。在[3]中提交翻译请求,并在[4]中获取翻译结果。

我们将编写一个 Windows 应用程序,作为上述应用程序的客户端。它所做的将仅限于 [trans.voila.fr] 网站应用程序的功能。其界面如下所示:

11.7.3.2. 应用程序架构

该应用程序将采用以下两层架构:

11.7.3.3. Visual Studio 项目

Visual Studio 项目将如下所示:

  • 在 [1] 中,该解决方案包含两个项目,
  • [2]:一个用于 [DAO] 层及其所使用的实体,
  • [3]:另一个用于 Windows 界面

11.7.3.4. [dao] 项目

[dao] 项目包含以下组件:

  • IServiceTraduction.cs:面向 [ui] 层的接口
  • ServiceTraduction:该接口的实现
  • WebTraductionsException:应用程序特有的异常

IServiceTraduction 接口如下:


using System.Collections.Generic;
 
namespace dao {
    public interface IServiceTraduction {
         // languages used
        IDictionary<string, string> LanguesTraduites { get; }
         // translation
        string Traduire(string texte, string deQuoiVersQuoi);
    }
}
  • 第 6 行:LanguesTraduites 属性返回翻译服务器支持的语言字典。该字典的条目形式为 ["fe", "French-English"],其中值表示翻译方向(此处为法语到英语),而 "fe" 键是翻译服务器 trans.voila.fr 使用的代码。
  • 第 8 行:Translate 方法是翻译方法:
    • text 是要翻译的文本
    • deQuoiVersQuoi 是翻译语言字典中的一个键
    • 该方法负责翻译文本

ServiceTraductionIServiceTraduction 接口的实现类。我们将在下一节中对其进行详细说明。

WebTraductionsException 是以下异常类:


using System;
 
namespace entites {
    public class WebTraductionsException : Exception {
 
         // error code
        public int Code { get; set; }
 
         // manufacturers
        public WebTraductionsException() {
        }
        public WebTraductionsException(string message)
            : base(message) {
        }
        public WebTraductionsException(string message, Exception e)
            : base(message, e) {
        }
    }
}
  • 第 7 行:一个错误代码

11.7.3.5. 客户网站 [ServiceTraduction]

让我们回到应用程序的架构:

我们需要编写的 [ServiceTraduction] 类是 Web 翻译服务 [trans.voila.fr] 的客户端。要编写它,我们需要了解

  • 翻译服务器对其客户端的期望
  • 以及它会向用户返回什么

让我们来看看翻译过程中涉及的客户端/服务器交互。以应用程序介绍中给出的示例为例:

待翻译的文本输入在[1]处,翻译方向在[2]处选择。在[3]处发起翻译请求,并在[4]处获取翻译结果。

为了获取翻译结果[4],浏览器发送了以下GET请求(显示在地址栏中):

http://trans.voila.fr/traduction_voila.php?isText=1&translationDirection=fe&stext=ce+chien+est+malade

这很容易理解:

  • http://trans.voila.fr/traduction_voila.php 是翻译服务的网址
  • isText=1 似乎表示处理的是文本
  • translationDirection 指翻译的方向,此处为法语-英语
  • stext 是要翻译的文本,采用我们称为 URL 编码的形式。某些字符不能出现在 URL 中。例如,空格在这里就被编码为 +。.NET 框架提供了静态方法 System.Web.HttpUtility.UrlEncode 来完成这种编码工作。

综上所述,为了向翻译服务器发起查询,我们的 [ServiceTraduction] 类可以使用字符串

"http://trans.voila.fr/traduction_voila.php?isText=1&translationDirection={0}&stext={1}"

,其中 {0} 和 {1} 将分别被替换为翻译方向和待翻译的文本。

如何知道服务器支持哪些翻译方向?在上方的截图中,支持的翻译语言位于下拉列表中。如果我们在浏览器中查看页面源代码(查看 / 源代码),会发现下拉列表对应的 HTML 代码如下:

<select name="translationDirection" class="champs">
    <option selected value='fe'>Fran&ccedil;ais vers Anglais
    <option  value='ef'>Anglais vers Fran&ccedil;ais
    <option  value='fg'>Fran&ccedil;ais vers Allemand
    <option  value='gf'>Allemand vers Fran&ccedil;ais
    <option  value='fs'>Fran&ccedil;ais vers Espagnol
    <option  value='sf'>Espagnol vers Fran&ccedil;ais
    <option  value='fr'>Fran&ccedil;ais vers Russe
    <option  value='rf'>Russe vers Fran&ccedil;ais
    <option  value='es'>Anglais vers Espagnol
    <option  value='se'>Espagnol vers Anglais
    <option  value='eg'>Anglais vers Allemand
    <option  value='ge'>Allemand vers Anglais
    <option  value='ep'>Anglais vers Portugais
    <option  value='pe'>Portugais vers Anglais
    <option  value='ie'>Italien vers Anglais
    <option  value='gs'>Allemand vers Espagnol
    <option  value='sg'>Espagnol vers Allemand
</select>

这段 HTML 代码并不十分规范,因为每个 <option> 标签通常都应由 </option> 标签闭合。话虽如此,value 属性仍为我们提供了要发送至服务器的翻译代码列表。在 LanguesTraduites 字典的 IServiceTraduction 接口中,键将对应上文的 value 属性,而值则对应下拉列表中显示的文本。

现在让我们查看(查看 / 源代码)翻译服务器返回的翻译结果在 HTML 页面中的位置:

...                                                                
<strong>Texte traduit : </strong><div class="txtTrad">this dog is sick</div> 
...

翻译内容位于返回的 HTML 页面正中间。我该如何找到它?您可以使用正则表达式,匹配 <div class="txtTrad">...</div> 这一序列,因为 <div class="txtTrad"> 仅出现在 HTML 页面的此处。用于提取翻译文本的 C# 正则表达式为:

@"<div class=""txtTrad"">(.*?)</div>"

现在我们已经有了编写实现类 ServiceTraduction 接口 IServiceTraduction 所需的元素:


using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text.RegularExpressions;
using System.Web;
using entites;
 
namespace dao {
    public class ServiceTraduction : IServiceTraduction {
         // automatic service configuration properties
        public IDictionary<string, string> LanguesTraduites { get; set; }
        public string UrlServeurTraduction { get; set; }
        public string ProxyHttp { get; set; }
        public String RegexTraduction { get; set; }
 
         // translation
        public string Traduire(string texte, string deQuoiVersQuoi) {
             // is the requested translation possible?
            if (!LanguesTraduites.ContainsKey(deQuoiVersQuoi)) {
                throw new WebTraductionsException(String.Format("Le sens de traduction [{0}] n'est pas reconnu")) { Code = 10 };
            }
             // text to translate
            string texteATraduire = HttpUtility.UrlEncode(texte);
             // uri to request
            string uri = string.Format(UrlServeurTraduction, deQuoiVersQuoi, texteATraduire);
             // regular expression to find the translation in the answer
            Regex patternTraduction = new Regex(RegexTraduction);
             // exception
            WebTraductionsException exception = null;
             // translation
            string traduction = null;
            try {
                 // configure the query
                HttpWebRequest httpWebRequest = WebRequest.Create(uri) as HttpWebRequest;
                httpWebRequest.Method = "GET";
                httpWebRequest.Proxy = ProxyHttp == null ? null : new WebProxy(ProxyHttp); ;
                 // it is executed
                HttpWebResponse httpWebResponse = httpWebRequest.GetResponse() as HttpWebResponse;
                 // document
                using (Stream stream = httpWebResponse.GetResponseStream()) {
                    using (StreamReader reader = new StreamReader(stream)) {
                        bool traductionTrouvée = false;
                        string ligne = null;
                        while (!traductionTrouvée && (ligne = reader.ReadLine()) != null) {
                             // search for translation in current line
                            MatchCollection résultats = patternTraduction.Matches(ligne);
                             // translation found?
                            if (résultats.Count != 0) {
                                traduction = résultats[0].Groups[1].Value.Trim();
                                traductionTrouvée = true;
                            }
                        }
                         // translation found?
                        if (!traductionTrouvée) {
                            exception = new WebTraductionsException("Le serveur n'a pas renvoyé de réponse") { Code = 12 };
                        }
                    }
                }
            } catch (Exception e) {
                exception = new WebTraductionsException("Erreur rencontrée lors de la traduction", e) { Code = 11 };
            }
             // exception?
            if (exception != null) {
                throw exception;
            } else {
                return traduction;
            }
        }
    }
}
  • 第 12 行:属性 LanguesTraduites 接口 IServiceTraduction - 外部初始化
  • 第 13 行:属性 UrlServeurTraduction 是向翻译服务器发送请求的 URL:http://trans.voila.fr/traduction_voila.php?isText=1&translationDirection={0}&stext={1},其中 {0} 标记必须替换为翻译方向,{1} 标记必须替换为待翻译的文本 - 外部初始化
  • 第 14 行:ProxyHttp 属性是待使用的 HTTP 代理,例如:pproxy.istia.uang:3128 - 外部初始化
  • 第 15 行:属性 RegexTraduction 是用于从翻译服务器返回的 HTML 流中提取翻译内容的正则表达式,例如 @"<div class=""txtTrad"">(.*?)</div>" - 外部初始化
  • 在我们的应用程序中,这四个属性将由 Spring 进行初始化。
  • 第 20-22 行:检查所请求的翻译方向是否存在于已翻译语言的词典中。如果不存在,则抛出异常。
  • 第 24 行:将待翻译的文本编码为 URL 的一部分
  • 第 26 行:构建翻译服务的 URI。如果 UrlServeurTraduction 是字符串 http://trans.voila.fr/traduction_voila.php?isText=1&translationDirection={0}&stext={1},则 {0} 标记将被翻译方向替换,{1} 标记将被待翻译的文本替换。
  • 第 28 行:构建翻译服务器返回的 HTML 响应中的翻译检索模型。
  • 第 33、60 行:翻译服务器的查询操作在 try/catch 模式下进行
  • 第 35 行:用于查询翻译服务器的 HttpWebRequest 对象是根据请求文档的 Uri 构建的。
  • 第 36 行:查询方法为 GET。此语句可省略,因为 GET 可能是 HttpWebRequest 的默认方法。
  • 第 37 行:我们将 HttpWebRequest 对象设置为代理对象。
  • 第 39 行:向翻译服务器发出请求,并获取其响应 HttpWebResponse
  • 第 41-42 行:使用 StreamReader 读取服务器 HTML 响应的每一行。
  • 第 45-53 行:在响应的每一行中查找翻译内容。一旦找到,便停止读取 HTML 响应并关闭所有已打开的流。
  • 第 55-57 行:如果未在 HTML 响应中找到翻译,则准备一个 WebTraductionsException 类型的异常来报告此情况。
  • 第 60-62 行:如果客户端/服务器交互过程中发生了异常,将其封装为 WebTraductionsException 类型的异常以报告该情况。
  • 第 64-68 行:如果已记录异常,则抛出该异常;否则返回找到的翻译。

本示例假设 Http 代理无需身份验证。若非如此,我们需编写类似以下代码:


httpWebRequest.Proxy = ProxyHttp == null ? null : new WebProxy(ProxyHttp); ;
httpWebRequest.Proxy.Credentials=new NetworkCredential("login","password");

我们使用 WebRequest / WebResponse 而不是 WebClient,是因为我们无需处理翻译服务器返回的完整 HTML 响应。一旦在响应中找到翻译内容,响应中的其余行就不再需要。而 WebClient 类不允许这样做。

以下是 ServiceTraduction 的测试程序:


using System;
using System.Collections.Generic;
using dao;
using entites;
 
namespace ui {
    class Program {
        static void Main(string[] args) {
            try {
                 // creation translation service
                ServiceTraduction serviceTraduction = new ServiceTraduction();
                 // regular expression to find the translation
                serviceTraduction.RegexTraduction = @"<div class=""txtTrad"">(.*?)</div>";
                 // url translation server
                serviceTraduction.UrlServeurTraduction = "http://trans.voila.fr/traduction_voila.php?isText=1&translationDirection={0}&stext={1}";
                 // dictionary of translated languages
                Dictionary<string, string> languesTraduites = new Dictionary<string, string>();
                languesTraduites["fe"]= "Français-Anglais";
                languesTraduites["fs"]= "Français-Espagnol";
                languesTraduites["ef"]= "Anglais-Français";
                serviceTraduction.LanguesTraduites = languesTraduites;
                 // proxy
                 //serviceTraduction.ProxyHttp = "pproxy.istia.uang:3128";
                 // translation
                string texte = "ce chien est perdu";
                string deQuoiVersQuoi = "fe";
                Console.WriteLine("Traduction [{0}] de [{1}] : [{2}]", languesTraduites[deQuoiVersQuoi], texte, serviceTraduction.Traduire(texte, deQuoiVersQuoi));
                texte = "l'été sera chaud";
                deQuoiVersQuoi = "fs";
                Console.WriteLine("Traduction [{0}] de [{1}] : [{2}]", languesTraduites[deQuoiVersQuoi], texte, serviceTraduction.Traduire(texte, deQuoiVersQuoi));
                texte = "my tailor is rich";
                deQuoiVersQuoi = "ef";
                Console.WriteLine("Traduction [{0}] de [{1}] : [{2}]", languesTraduites[deQuoiVersQuoi], texte, serviceTraduction.Traduire(texte, deQuoiVersQuoi));
                texte = "xx";
                deQuoiVersQuoi = "ef";
                Console.WriteLine("Traduction [{0}] de [{1}] : [{2}]", languesTraduites[deQuoiVersQuoi], texte, serviceTraduction.Traduire(texte, deQuoiVersQuoi));
            } catch (WebTraductionsException e) {
                 // error
                Console.WriteLine("L'erreur suivante de code {1} s'est produite : {0}", e.Message, e.Code);
            }
        }
    }
}

结果如下:

1
2
3
4
Traduction [Français-Anglais] de [ce chien est perdu] : [this dog is lost]
Traduction [Français-Espagnol] de [l'été sera chaud] : [el verano será caliente]
Traduction [Anglais-Français] de [my tailor is rich] : [mon tailleur est riche]
Traduction [Anglais-Français] de [xx] : [xx]

解决方案项目 [dao] 已编译为 DLL 文件 HttpTraductions.dll

 

11.7.3.6. 应用程序的图形界面

让我们回到应用程序的架构:

现在我们来编写 [ui] 层。这是正在构建的解决方案中 [ui] 项目的主题:

[lib]文件夹[3]包含[4]项目引用的部分DLL:

  • Spring 所需的库:Spring.CoreCommon.Logging、antlr.runtime
  • [dao] 层:HttpTraductions

[App.config] 文件包含 Spring 的配置:


<?xml version="1.0" encoding="utf-8" ?>
<configuration>
 
    <configSections>
        <sectionGroup name="spring">
            <section name="context" type="Spring.Context.Support.ContextHandler, Spring.Core" />
            <section name="objects" type="Spring.Context.Support.DefaultSectionHandler, Spring.Core" />
        </sectionGroup>
    </configSections>
 
    <spring>
        <context>
            <resource uri="config://spring/objects" />
        </context>
        <objects xmlns="http://www.springframework.net">
            <description>Traductions sur le web</description>
             <!-- translation service -->
            <object name="ServiceTraduction" type="dao.ServiceTraduction, HttpTraductions">
                <property name="UrlServeurTraduction" value="http://trans.voila.fr/traduction_voila.php?isText=1&amp;translationDirection={0}&amp;stext={1}"/>
                <!--
                <property name="ProxyHttp" value="pproxy.istia.uang:3128"/>
                -->
                <property name="RegexTraduction" value="&lt;div class=&quot;txtTrad&quot;&gt;(.*?)&lt;/div&gt;"/>
                <property name="LanguesTraduites">
                    <dictionary key-type="string" value-type="string">
                        <entry key="fe" value="Français-Anglais"/>
                        <entry key="ef" value="Anglais-Français"/>
...
                        <entry key="ei" value="Anglais-Italien"/>
                        <entry key="ie" value="Italien-Anglais"/>
                    </dictionary>
                </property>
            </object>
        </objects>
    </spring>
</configuration>
  • 第 15 行:由 Spring 实例化的对象。只有一个,即第 18 行中的那个,它使用位于 DLL HttpTraductions 中的 ServiceTraduction 类来实例化翻译服务。
  • 第 19 行:ServiceTraduction 类的 UrlServeurTraduction 属性 Url 中的 & 字符存在问题。该字符在 XML 文件中具有特殊含义,因此必须进行转义。文件其余部分中出现的其他字符也需如此处理,必须用 [&code;] 序列替换:& 替换为 [&amp;],< 替换为 [&lt;],> 替换为 [&gt;]," 替换为 [&quot;]。
  • 第 21 行:ServiceTraduction 类中的 ProxyHttp 属性未初始化的属性将保持为 null。未设置此属性意味着不存在 Http 代理。
  • 第 23 行:ServiceTraduction 类的 RegexTraduction 属性在正则表达式中,我们必须将 [< > "] 字符替换为它们的转义形式。
  • 第 24-33 行:ServiceTraduction 类中的 LanguesTraduites 所有权

当应用程序启动时,将执行 [Program.cs] 程序。其代码如下:


using System;
using System.Text;
using System.Windows.Forms;
using dao;
using Spring.Context;
using Spring.Context.Support;
 
namespace ui {
    static class Program {
         /// <summary>
        /// The main entry point for the application.
         /// </summary>
        [STAThread]
        static void Main() {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
 
             // --------------- Developer code
             // instantiation translation service
            IApplicationContext ctx = null;
            Exception ex = null;
            ServiceTraduction serviceTraduction = null;
            try {
                 // spring context
                ctx = ContextRegistry.GetContext();
                 // request a reference for the translation service
                serviceTraduction = ctx.GetObject("ServiceTraduction") as ServiceTraduction;
            } catch (Exception e1) {
                 // memory exception
                ex = e1;
            }
             // form to display
            Form form = null;
             // was there an exception?
            if (ex != null) {
                 // yes - create the error message to be displayed
                StringBuilder msgErreur = new StringBuilder(String.Format("Chaîne des exceptions : {0}{1}", "".PadLeft(40, '-'), Environment.NewLine));
                Exception e = ex;
                while (e != null) {
                    msgErreur.Append(String.Format("{0}: {1}{2}", e.GetType().FullName, e.Message, Environment.NewLine));
                    msgErreur.Append(String.Format("{0}{1}", "".PadLeft(40, '-'), Environment.NewLine));
                    e = e.InnerException;
                }
                 // creation of an error window to which the error message to be displayed is passed
                Form2 form2 = new Form2();
                form2.MsgErreur = msgErreur.ToString();
                 // this will be the window to display
                form = form2;
            } else {
                 // all went well
                 // creation of a graphical interface [Form1] to which we pass the reference on the translation service
                Form1 form1 = new Form1();
                form1.ServiceTraduction = serviceTraduction;
                 // this will be the window to display
                form = form1;
            }
             // window display
            Application.Run(form);
        }
    }
}

该代码已在 Impôts 第 6 版第 7.6.2 节中使用过。

  • 翻译服务由Spring在第27行创建。若创建成功,将显示表单[Form1](第52-55行);否则将显示错误表单[Form2](第36-48行)。

表单 [Form2] 是 Impôts 6.0 版本中使用的表单,相关说明见第 7.6.4 节。

表单 [Form1] 如下所示:

编号
编号类型
名称
角色
1
文本框
待翻译文本框
待翻译文本的输入框
MultiLine=true
2
下拉列表
comboBoxLangues
翻译方向列表
3
按钮
buttonTraduire
用于请求将文本 [1] 翻译为 [2] 方向
4
文本框
textBoxTraduction
文本 [1] 的翻译

表单代码 [Form1] 如下:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using dao;
 
namespace ui {
    public partial class Form1 : Form {
         // translation service
        public ServiceTraduction ServiceTraduction { get; set; }
         // language dictionary
        Dictionary<string, string> languesInversées = new Dictionary<string, string>();
 
         // manufacturer
        public Form1() {
            InitializeComponent();
        }
 
         // initial form loading
        private void Form1_Load(object sender, EventArgs e) {
             // building an inverted language dictionary
            foreach (string code in ServiceTraduction.LanguesTraduites.Keys) {
                 // languages
                string langues = ServiceTraduction.LanguesTraduites[code];
                // add (languages, code) to the inverted dictionary
                languesInversées[langues] = code;
            }
            // filling combo in alphabetical language order
            string[] languesCombo = languesInversées.Keys.ToArray();
            Array.Sort<string>(languesCombo);
            foreach (string langue in languesCombo) {
                comboBoxLangues.Items.Add(langue);
            }
             // 1st language selection
            if (comboBoxLangues.Items.Count != 0) {
                comboBoxLangues.SelectedIndex = 0;
            }
        }
 
        private void buttonTraduire_Click(object sender, EventArgs e) {
             // something to translate?
            string texte = textBoxTexteATraduire.Text.Trim();
            if (texte == "") return;
             // translation
            try {
                textBoxTraduction.Text = ServiceTraduction.Traduire(texte, languesInversées[comboBoxLangues.SelectedItem.ToString()]);
            } catch (Exception ex) {
                textBoxTraduction.Text = ex.Message;
            }
        }
    }
}
  • 第 10 行:对翻译服务的引用。该公共属性已在 [Program.cs] 的第 53 行初始化。当 Form1_Load(第 20 行)或 buttonTraduire_Click(第 40 行)执行时,该字段已初始化。
  • 第 12 行:包含 ["French-English", "fe"] 类型条目的已翻译语言字典,即翻译服务返回的 LanguesTraduites 字典的逆向映射。
  • 第 20 行:当窗体加载时,将执行 Form1_Load 方法。
  • 第 22-27 行:使用字典 serviceTraduction.LanguesTraduites ["fe", "Français-Anglais"] 来构建字典 languagesInversées ["French-English", "fe"]。
  • 第 29 行:languesCombo 是词典键 languagesInversées 的数组,即一个包含 ["French-English"] 元素的数组
  • 第 30 行:对该表进行排序,以便在下拉列表中按字母顺序显示翻译方向
  • 第 31-33 行:语言下拉列表的填充操作。
  • 第 40 行:用户点击 [Translate] 按钮时执行的方法
  • 第 46 行:直接调用 serviceTraduction.Traduire 方法请求翻译。第一个参数是待翻译的文本,第二个参数是翻译方向代码。该代码取自语言下拉框中选定项对应的 languagesInversées 数组。
  • 第 48 行:若发生异常,则显示异常信息而非翻译结果。

11.7.3.7. 结论

本应用程序展示了 .NET 框架的 Web 客户端如何让我们充分利用 Web 资源。每次实现的技术原理都大同小异:

  • 确定要查询的 URI。该 URI 大多数情况下是预设的。
  • 向其发送请求
  • 利用正则表达式在服务器响应中查找所需内容

这种方法具有随机性。随着时间的推移,所查询的 URI 或用于查找预期结果的正则表达式可能会发生变化。因此,将这两项信息都放入配置文件中是个好主意。但这可能还不够。我们将在下一章看到,Web 上还有更稳定的资源:Web 服务。

11.7.4. 使用 SmtpClient 类的 SMTP(简单邮件传输协议)客户端

SMTP 客户端是 SMTP 邮件服务器的客户端。.NET 中的 SmtpClient 类完全封装了此类客户端的需求。开发人员无需了解 SMTP 协议的细节。我们对此已很熟悉,相关内容已在第 11.4.3 节中介绍过。

我们将 SmtpClient 作为一款用于发送带附件电子邮件的基本 Windows 应用程序的一部分进行介绍。该应用程序将连接到 SMTP 服务器的 25 号端口。请注意,在大多数 Windows 个人电脑上,防火墙或其他防病毒软件会阻止对 25 号端口的连接,因此需要禁用此类保护才能测试该应用程序:

该 SMTP 客户端将采用单层架构:

Visual Studio 项目结构如下:

  

该应用程序的图形界面 [SendMailForm.cs] 如下所示:

编号
类型
名称
角色
1
文本框
textBoxServeur
要连接的 SMTP 服务器名称
2
数字上下
numericUpDownPort
要连接的端口
3
文本框
textBoxExpediteur
消息发送者的地址
4
文本框
textBoxTo
收件人地址,格式为:地址1,地址2, ...
5
文本框
textBoxCc
抄送收件人的地址(CC=抄送),格式为:address1,address2, ...
6
文本框
textBoxBcc
密件副本收件人地址(BCC=Blind Carbon Copy),格式为:地址1,地址2, ... 这三个输入字段中的所有地址都将收到包含相同附件的同一封邮件。邮件的收件人将知道第4和第5字段中的地址,但不知道第6字段中的地址。因此,密件副本( )是一种在不让邮件其他收件人知情的情况下抄送某人的方式。
7
按钮
按钮Ajouter
向邮件添加附件
8
列表框
附件列表
附件列表
9
文本框
textBoxSujet
邮件主题
10
文本框
textBoxMessage
消息正文。
MultiLine=true
11
Button
buttonEnvoyer
以发送消息及任何附件
12
文本框
textBoxResult
显示已发送消息的摘要,若遇到问题则显示错误信息
13
按钮
buttonEffacer
用于删除 [12]
 
打开文件对话框
openFileDialog1
非可视化控件,用于在本地文件系统中选择附件

在上一个示例中,[12]中显示的摘要如下:

Envoi réussi...
Sujet : votre demande
Destinataires : y2000@hotmail.com
Cc : 
Bcc : 
Pièces jointes :
C:\data\travail\2007-2008\recrutements 0809\ing3\documents\ing3.zip
Texte : Bonjour,

Vous trouverez ci-joint le dossier de candidature à l'ISTIA.

Cordialement,

ST

表单代码 [SendMailForm.cs] 如下:


using System;
using System.Windows.Forms;
using System.Net.Mail;
using System.Text.RegularExpressions;
using System.Text;
 
namespace Chap9 {
    public partial class SendMailForm : Form {
        public SendMailForm() {
            InitializeComponent();
        }
 
         // add an attachment
        private void buttonAjouter_Click(object sender, EventArgs e) {
            // set the openfileDialog1 dialog box
            openFileDialog1.InitialDirectory = Application.ExecutablePath;
            openFileDialog1.Filter = "Tous les fichiers (*.*)|*.*";
            openFileDialog1.FilterIndex = 0;
            openFileDialog1.FileName = "";
            // display the dialog box and retrieve the result
            if (openFileDialog1.ShowDialog() == DialogResult.OK) {
                // retrieve the file name
                listBoxPiecesJointes.Items.Add(openFileDialog1.FileName);
            }
        }
 
        private void textBoxServeur_TextChanged(object sender, EventArgs e) {
            setStatutEnvoyer();
        }
 
        private void setStatutEnvoyer() {
            buttonEnvoyer.Enabled = textBoxServeur.Text.Trim() != "" && textBoxTo.Text.Trim() != "" && textBoxSujet.Text.Trim() != "";
        }
 
         // remove an attachment
        private void buttonRetirer_Click(object sender, EventArgs e) {
             // selected attachment?
            if (listBoxPiecesJointes.SelectedIndex != -1) {
                 // remove it
                listBoxPiecesJointes.Items.RemoveAt(listBoxPiecesJointes.SelectedIndex);
                // update the Remove button
                buttonRetirer.Enabled = listBoxPiecesJointes.Items.Count != 0;
            }
        }
 
        private void listBoxPiecesJointes_SelectedIndexChanged(object sender, EventArgs e) {
             // selected attachment?
            if (listBoxPiecesJointes.SelectedIndex != -1) {
                // update the Remove button
                buttonRetirer.Enabled = true;
            }
        }
 
        // sending the message with attachments
        private void buttonEnvoyer_Click(object sender, EventArgs e) {
....
        }
 
        private void textBoxTo_TextChanged(object sender, EventArgs e) {
            setStatutEnvoyer();
        }
 
        private void textBoxSujet_TextChanged(object sender, EventArgs e) {
            setStatutEnvoyer();
        }
 
        private void buttonEffacer_Click(object sender, EventArgs e) {
            textBoxResultat.Text = "";
        }
    }
}

我们不会对这段代码进行评论,因为它没有呈现任何新功能。要理解第 14 行中的 buttonAjouter_Click 方法,建议读者重读第 7.5.1 节。

第55行的buttonEnvoyer_Click方法用于发送邮件,具体如下:


private void buttonEnvoyer_Click(object sender, EventArgs e) {
            try {
                 // hourglass
                Cursor = Cursors.WaitCursor;
                // the customer Smtp
                SmtpClient smtpClient = new SmtpClient(textBoxServeur.Text.Trim(), (int)numericUpDownPort.Value);
                // the message
                MailMessage message = new MailMessage();
                 // sender
                message.Sender = new MailAddress(textBoxExpéditeur.Text.Trim());
                message.From = message.Sender;
                 // recipients
                Regex marqueur = new Regex("\\s*,\\s*");
                string[] destinataires = marqueur.Split(textBoxTo.Text.Trim());
                foreach (string destinataire in destinataires) {
                    if (destinataire.Trim() != "") {
                        message.To.Add(new MailAddress(destinataire));
                    }
                }
                 // CC
                string[] copies = marqueur.Split(textBoxCc.Text.Trim());
                foreach (string copie in copies) {
                    if (copie.Trim() != "") {
                        message.CC.Add(new MailAddress(copie));
                    }
                }
                 // BCC
                string[] blindCopies = marqueur.Split(textBoxBcc.Text.Trim());
                foreach (string blindCopie in blindCopies) {
                    if (blindCopie.Trim() != "") {
                        message.Bcc.Add(new MailAddress(blindCopie));
                    }
                }
                 // subject
                message.Subject = textBoxSujet.Text.Trim();
                 // message text
                message.Body = textBoxMessage.Text;
                 // attachments
                foreach (string attachement in listBoxPiecesJointes.Items) {
                    message.Attachments.Add(new Attachment(attachement));
                }
                // sending the message
                smtpClient.Send(message);
                // Ok - a summary is displayed
                StringBuilder msg = new StringBuilder(String.Format("Envoi réussi...{0}", Environment.NewLine));
                msg.Append(String.Format("Sujet : {0}{1}", textBoxSujet.Text.Trim(), Environment.NewLine));
                textBoxSujet.Clear();
                msg.Append(String.Format("Destinataires : {0}{1}", textBoxTo.Text.Trim(), Environment.NewLine));
                textBoxTo.Clear();
                msg.Append(String.Format("Cc : {0}{1}", textBoxCc.Text.Trim(), Environment.NewLine));
                textBoxCc.Clear();
                msg.Append(String.Format("Bcc : {0}{1}", textBoxBcc.Text.Trim(), Environment.NewLine));
                textBoxBcc.Clear();
                msg.Append(String.Format("Pièces jointes :{0}", Environment.NewLine));
                foreach (string attachement in listBoxPiecesJointes.Items) {
                    msg.Append(String.Format("{0}{1}", attachement, Environment.NewLine));
                }
                msg.Append(String.Format("Texte : {0}{1}", textBoxMessage.Text, Environment.NewLine));
                listBoxPiecesJointes.Items.Clear();
                textBoxResultat.Text = msg.ToString();
            } catch (Exception ex) {
                // error is displayed
                textBoxResultat.Text = String.Format("L'erreur suivante s'est produite {0}", ex);
            }
             // normal slider
            Cursor = Cursors.Arrow;
        }
  • 第 6 行:创建客户端 Smtp。它需要两个参数:SMTP 服务器名称和服务器运行的端口
  • 第 8 行:创建 MailMessage 对象。它封装了待发送的完整邮件。
  • 第 10 行:填写发件人的电子邮件地址。该电子邮件地址是 MailAddress 类型的实例,由字符串 "xx@yy.zz" 构造而成。该字符串必须符合电子邮件地址的预期格式,否则将抛出异常。在此情况下,该异常将以不友好的形式显示在 textBoxResultat(第 63 行)中。
  • 第 13-19 行:将收件人的电子邮件地址放入邮件的“收件人”列表中。这些地址是从 textBoxTo 中获取的。第 13 行中的正则表达式用于提取以逗号分隔的各个地址。
  • 第 21-26 行:重复相同过程,将 textBoxCc 中的地址复制到 CC 字段中。
  • 第 28-33 行:重复相同过程,将 textBoxBcc 中的地址用于初始化 Bcc 字段。
  • 第 35 行:将字段 Subject 初始化为字段 textBoxSujet 中的主题。
  • 第 37 行:使用 textBoxMessage 字段中的消息文本初始化 Body 字段。
  • 第 39-41 行:将附件附加到邮件中。每个附件作为 Attachment 对象添加到邮件的 Attachments 字段中。Attachment 对象是从本地文件系统中待附加部分的完整路径实例化而来的。
  • 第 43 行:使用 Send 客户 SMTP 发送消息。
  • 第 45-60 行:将发货摘要写入 textBoxResultat 字段,并重置表单。
  • 第 63 行:显示错误信息

11.8. 通用异步 TCP 客户端

11.8.1. 演示

在本章的所有示例中,客户端/服务器通信均采用阻塞模式,也称为同步模式:

  • 当客户端连接到服务器时,它会等待服务器对此请求的响应,然后才继续执行。
  • 当客户端读取服务器发送的一行文本时,它会被阻塞,直到服务器发送完该行文本。
  • 在服务器端,为客户端提供服务的服务线程也以同样的方式运行。

在图形用户界面中,通常需要避免在长时间操作期间阻塞用户。常被提及的例子是下载大文件。在下载文件期间,用户必须能够自由地继续与图形界面进行交互。

我们在此建议对第11.6.3节中的通用TCP客户端进行重写,并做出以下修改:

  • 接口将采用图形化形式
  • 与服务器的通信工具将采用 Socket
  • 通信模式将采用异步方式:
    • 客户端将主动向服务器发起连接,但不会因等待连接建立而阻塞
    • 客户端将向服务器发起数据传输,但不会因等待传输完成而阻塞
    • 客户端将发起从服务器接收数据,但不会因等待数据接收而阻塞。

让我们回顾一下在客户端/服务器通信中,Socket 对象位于 TCP 的哪个层级:

Socket 类是操作最接近网络的类。它支持对网络连接进行精细管理。术语“socket”源自电源插座。该术语已被扩展用于指代软件网络套接字。在两台机器 A 和 B 之间的 TCP/IP 通信中,它们是相互通信的两个套接字。应用程序可以直接与套接字进行交互。上述应用程序 A 即属于这种情况。一个套接字可以是客户端,也可以是服务器

11.8.2. 异步 TCP 客户端图形界面

Visual Studio 应用程序如下所示:

  

[ClientTcpAsynchrone.cs] 是图形界面。内容如下:

编号
类型
名称
角色
1
文本框
textBoxNomServeur
要连接的 TCP 服务器名称
2
服务器端口上下限
numericUpDownPortServeur
要连接的端口
3
单选按钮
radioButtonLF
单选按钮RCLF
用于指定客户端使用的换行符:LF "\n" 或 RCLF "\r\n"
4
Button
buttonConnexion
用于连接到服务器 [1] 的端口 [2]。当客户端未连接到服务器时,按钮显示为 [连接];连接后显示为 [断开]。
5
文本框
textBoxMsgToServeur
连接建立后发送给服务器的消息。当用户按下 [Enter] 键时,将发送该消息,并使用 [3] 中选定的换行符
6
ListBox
listBoxEvts
显示主要客户端/服务器连接事件的列表:连接、断开、流关闭、通信错误等
7
列表框
listBoxDialogue
显示客户端/服务器对话消息的列表
8
按钮
buttonRazEvts
用于清除列表 [6]
4
按钮
buttonRazDialogue
用于清空列表 [7]

该界面的工作原理如下:

  • 用户通过 [1, 2, 3, 4] 将他的 TCP 图形客户端连接到 TCP 服务。
  • 一个异步线程会持续接收 TCP 服务器发送的所有数据,并将其显示在列表 [7] 中。该线程与其他接口活动相互独立。
  • 借助[5],用户可以按自己的节奏向服务器发送消息。每条消息均由一个异步线程发送。与永不停歇的接收线程不同,发送线程在消息发送完成后即刻终止。下一条消息将使用新的异步线程。
  • 当任一方关闭连接时,客户端/服务器通信即告结束。用户可通过按钮 [4] 主动发起断开操作,该按钮在连接建立后将显示为 [断开连接]。

以下是运行时的截图:

  • [1]:连接至POP服务
  • 在 [2]:显示连接过程中发生的事件
  • [3]:连接结束时由POP服务器发送的消息
  • 在 [4] 中:[连接] 按钮已变为 [断开] 按钮
  • 在 [1] 中,我们向 POP 服务器发送了 quit 命令。服务器回复 +OK goodbye 并关闭了连接
  • 在 [2] 中,检测到了服务器端的断开操作。随后客户端也关闭了其端的连接
  • 在 [3] 中,[断开连接] 按钮已恢复为 [连接] 按钮

11.8.3. 异步服务器连接

按下 [Connect] 按钮将执行以下方法:


        private void buttonConnexion_Click(object sender, EventArgs e) {
            // connection or disconnection?
            if (buttonConnexion.Text == "Déconnecter")
                déconnexion();
            else
                connexion();
}
  • 第3行:该按钮可标注为 [连接] 或 [断开]。

连接方法如下:


using System.Net.Sockets;
...
 
namespace Chap9 {
    public partial class ClientTcp : Form {
        const int tailleBuffer = 1024;
        private Socket client = null;
        private byte[] data = new byte[tailleBuffer];
        private string réponse = null;
        private string finLigne = "\r\n";
 
         // delegates
        public delegate void writeLog(string log);
 
        public ClientTcp() {
            InitializeComponent();
        }
....................................
    private void connexion() {
             // data checks
            string nomServeur = textBoxNomServeur.Text.Trim();
            if (nomServeur == "") {
                logEvent("indiquez le nom du serveur");
                return;
            }
             // follow-up
            logEvent(String.Format("connexion en cours au serveur {0}", nomServeur));
            try {
                 // socket creation
                client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                 // asynchronous connection
                client.BeginConnect(Dns.GetHostEntry(nomServeur).AddressList[0],(int)numericUpDownPortServeur.Value, connecté, client);
 
            } catch (Exception ex) {
                logEvent(String.Format("erreur de connexion : {0}", ex.Message));
                return;
            }
        }
 
        // the connection has been made
        private void connecté(IAsyncResult résultat) {
            // retrieve the client socket
            Socket client = résultat.AsyncState as Socket;
    ...
        }
 
 
         // process monitoring
        private void logEvent(string msg) {
....
        }
    }
}
  • 第 1 行:ClassroomSocket 属于 System.Net.Sockets 命名空间。

若干表单方法之间必须共享一定量的数据。具体如下:

  • 第 7 行:customer 是与服务器通信的套接字
  • 第 6 行和第 8 行:客户端将通过字节组接收消息。
  • 第 9 行:answer 是服务器发送的响应。
  • 第 10 行:finLigne 是客户端使用的行结束标记。Tcp 默认初始化为 RCLF,但用户可通过单选按钮进行修改 [3]。

第19行的connection过程用于连接到TCP服务器:

  • 第 21-25 行:检查服务器名称是否为空。如果不是,则在 listBoxEvts 方法的 logEvent 方法第 49 行中记录该事件。
  • 第 27 行:提示连接即将建立
  • 第 30 行:创建 Tcp-Ip 通信所需的 Socket 对象。该类支持三个参数:
    • AddressFamily addressFamily:IP客户端和服务器地址的家族,此处为IPv4地址(AddressFamily.InterNetwork
    • SocketType socketType:套接字类型。SocketType.Stream 类型适用于 TCP/IP 连接
    • ProtocolType protocolType:使用的互联网协议类型,此处为 TCP 协议
  • 第 32 行:连接以异步方式建立。连接已启动,但程序执行将继续进行,不会等待连接结束。[Socket].BeginConnect 方法有四个参数:
    • IPAddress ipAddress:要连接的服务所在主机的IP地址
    • Int32 port:服务所使用的端口
    • AsyncCallBack asyncCallBackAsyncCallBack 是一种委托类型:
public void AsyncCallBack(IAsyncResult ar);

作为 BeginConnect 方法第三个参数传递的 asyncCallBack 方法必须是一个接受 IAsyncCallBack 并返回无结果的方法。这是在连接建立后将被调用的方法。我们将第 41 行中连接的方法作为第三个参数传递。

  • (待续)
    • 对象状态:传递给 asyncCallBack 的对象。该方法接收(参见上文的委托)一个类型为 IAsyncResult 的参数 ar。对象状态可通过 ar.AsyncState(第 43 行)获取。我们将 ici 作为第四个参数传递,即客户端套接字。
  • 第 38 行:方法结束。用户可以再次与 GUI 交互。连接在后台进行,与 GUI 事件处理并行。同样并行的是,无论连接成功与否,第 41 行的 connected 方法都将在连接结束时被调用。

connected 方法的代码如下:


// the connection has been made
        private void connecté(IAsyncResult résultat) {
            // retrieve the client socket
            Socket client = résultat.AsyncState as Socket;
            try {
                 // end asynchronous operation
                client.EndConnect(résultat);
                 // follow-up
                logEvent(String.Format("connecté au service {0}", client.RemoteEndPoint));
                 // form
                buttonConnexion.Text = "Déconnecter";
                // asynchronous reading of data from the server
                réponse = "";
                client.BeginReceive(data, 0, tailleBuffer, SocketFlags.None, lecture, client);
            } catch (SocketException e) {
                logEvent(String.Format("erreur de connexion : {0}", e.Message));
                return;
            }
}
 
         // data reception
        private void lecture(IAsyncResult résultat) {
            // retrieve the client socket
            Socket client = résultat.AsyncState as Socket;
...
        }
 
  • 第 4 行:从方法接收的 result 参数中获取客户端套接字。请注意,该对象正是作为 BeginConnect 方法的第 4 个参数传递的那个。
  • 第 7 行:通过 EndConnect 方法终止连接尝试,该方法接收的参数 result 用于此操作。
  • 第 9 行:将事件记录到事件列表中
  • 第 11 行:[Connect] 按钮变为 [Disconnect] 按钮,以便用户请求断开连接。
  • 第 13 行:初始化服务器响应。该响应将通过反复调用异步方法 BeginReceive 进行更新。
  • 第 14 行:首次调用异步方法 BeginReceive。调用时传入以下参数:
    • byte[] buffer:用于存放待接收数据的缓冲区——此处缓冲区名为 data
    • int offset:从缓冲区哪个位置开始存放待接收的数据——此处偏移量为 0,即数据从缓冲区的第 1 个字节开始存放。
    • int size:缓冲区大小(以字节为单位)——此处 size 即为 tailleBuffer
    • SocketFlags socketFlags:套接字配置——此处未进行配置
    • AsyncCallBack asyncCallBack:接收完成时调用的方法。这种情况可能发生在缓冲区已接收数据或连接已关闭时。在此处,回调方法即第22行的读取操作。
    • Object state:要传递给回调方法 asyncCallBack 的对象。在此处,再次传递客户端套接字。

请注意,除用户通过 [Connect] 按钮发出的初始连接请求外,整个过程无需用户进行任何操作。连接建立后,后台会执行另一个方法:即我们正在探讨的读取操作。


// data reception
        private void lecture(IAsyncResult résultat) {
            // retrieve the client socket
            Socket client = résultat.AsyncState as Socket;
            int nbOctetsReçus = 0;
            bool erreur = false;
            try {
                // number of bytes received
                nbOctetsReçus = client.EndReceive(résultat);
                if (nbOctetsReçus == 0) {
                     // server no longer responds
                    logEvent("le serveur a fermé la connexion");
                }
            } catch (Exception e) {
                 // we had a reception problem
                logEvent(String.Format("erreur de réception : {0}", e.Message));
                erreur = true;
            }
             // finished?
            if (nbOctetsReçus == 0 || erreur) {
                // the customer is disconnected as required
                déconnexion();
                // the end of the answer is displayed
                afficherRéponseServeur(réponse, true);
                 // end reading
                return;
            }
            // retrieve the data received
            string données = Encoding.UTF8.GetString(data, 0, nbOctetsReçus);
            // we add them to the data already received
            réponse += données;
            // the answer is displayed
            afficherRéponseServeur(réponse, false);
            // we read on
            client.BeginReceive(data, 0, tailleBuffer, SocketFlags.None, lecture, client);
        }
  • 第 2 行:当接收到数据或服务器关闭连接时,读取方法会在后台被触发。
  • 第 9 行:通过 EndReceive 终止异步读取请求。同样,调用此方法时必须传入回调函数接收到的参数。EndReceive 返回读取缓冲区中接收到的字节数。
  • 第 10 行:如果字节数为零,则表示服务器已关闭连接。
  • 第 12 行:将该事件记录到事件列表中
  • 第 14 行:处理异常
  • 第 16-17 行:将事件记录到事件列表中并记录错误
  • 第 20 行:检查是否应关闭连接
  • 第 22 行:通过断开连接(稍后将详细说明)关闭客户端连接。
  • 第 24 行:服务器响应,即全局变量 answer 通过私有方法 displayServerResponse 显示在 listBoxDialogue 中。
  • 第 26 行:异步方法读取结束
  • 第 29 行:将接收到的字节转换为 UTF-8 格式的字符串。
  • 第 31 行:将它们添加到正在构建的 answer 中
  • 第 33 行:将 answer 显示在 listBoxDialogue 列表中。
  • 第 35 行:返回继续等待服务器数据

最终,该异步读取方法不会停止。它会持续从服务器读取数据并显示在 listBoxDialogue 中。只有当服务器或用户本人关闭连接时,它才会停止。

11.8.4. 服务器断开连接

按下 [断开连接] 按钮将执行以下方法:


        private void buttonConnexion_Click(object sender, EventArgs e) {
            // connection or disconnection?
            if (buttonConnexion.Text == "Déconnecter")
                déconnexion();
            else
                connexion();
}
  • 第 3 行:按钮可以标记为 [连接] 或 [断开]。

disconnect 方法确保客户断开连接:


private void déconnexion() {
             // socket closure
            if (client != null && client.Connected) {
                try {
                     // follow-up
                    logEvent(String.Format("déconnexion du service {0}", client.RemoteEndPoint));
                     // disconnect
                    client.Shutdown(SocketShutdown.Both);
                    client.Close();
                     // form
                    buttonConnexion.Text = "Connecter";
                } catch (Exception ex) {
                     // follow-up
                    logEvent(String.Format("erreur de lors de la déconnexion : {0}", ex.Message));
                }
            }
        }
  • 第 3 行:如果客户存在且已连接
  • 第 6 行:断开连接事件在 listBoxEvts 中发布。属性 client.RemoteEndPoint 提供连接另一端(即服务器端)的 (IP 地址, 端口) 对。
  • 第 8 行:通过 ShutDown 方法关闭套接字数据流。套接字的数据流是双向的:套接字既发送数据也接收数据。ShutDown 方法可以调用 ShutDown.Receive 来关闭接收流,调用 ShutDown.Send 来关闭发送流,或者调用 ShutDown.Both 来同时关闭两个流。
  • 第 9 行:释放套接字资源
  • 第 11 行:[Disconnect] 按钮变为 [Connect] 按钮
  • 第 12-15 行:异常处理

11.8.5. 向服务器进行异步数据传输

当用户在 textBoxMsgToServeur 中验证消息时,将执行以下方法:


        private void textBoxMsgToServeur_KeyPress(object sender, KeyPressEventArgs e) {
             // enter] key ?
            if (e.KeyChar == 13 && client.Connected) {
                envoyerMessage();
            }
}
  • 第 3-5 行:如果用户按下了 [Enter] 键且客户端套接字已连接,则使用 envoyerMessage 方法发送 textBoxMsgToServeur 中的消息。

envoyerMessage 方法的实现如下:


        private void envoyerMessage() {
             // send a message asynchronously
            // the message
            byte[] message = Encoding.UTF8.GetBytes(textBoxMsgToServeur.Text.Trim() + finLigne);
            // it is sent
            client.BeginSend(message, 0, message.Length, SocketFlags.None, écriture, client);
             // dialogue
            logDialogue("--> " + textBoxMsgToServeur.Text.Trim());
             // raz message
            textBoxMsgToServeur.Clear();
}
  • 第 4 行:将客户端的换行符添加到消息中,并将其放入字节数组 message 中。
  • 第 6 行:使用 BeginSend 启动异步传输。BeginSend 的参数与 BeginReceive 的参数完全相同。在异步消息传输操作结束时,将调用 write 方法。
  • 第 8 行:将发送的消息添加到列表 listBoxDialogue 中,以监控客户端/服务器对话
  • 第 10 行:从图形界面中删除已发送的消息

回调函数的编写方式如下:


        private void écriture(IAsyncResult résultat) {
             // result of message transmission
            Socket client = résultat.AsyncState as Socket;
            try {
                client.EndSend(résultat);
            } catch (Exception e) {
                 // we had an emission problem
                logEvent(String.Format("erreur d'émission : {0}", e.Message));
            }
}
  • 第 4 行:回调方法的调用接收一个类型为 IAsyncResult 的 result 参数。
  • 第 3 行:在 result 参数中获取客户端套接字。该套接字是 BeginSend 的第 5 个参数。
  • 第 5 行:异步发送操作结束。

您无需等待消息发送完成即可将其交还给用户。这意味着,即使第一条消息尚未发送,用户也可以发送第二条消息。

11.8.6. 事件显示与客户端/服务器对话

事件通过 logEvents 进行显示:


         // process monitoring
        private void logEvent(string msg) {
            listBoxEvts.Invoke(new writeLog(logEventCallBack), msg);
        }
 
        private void logEventCallBack(string msg) {
             // message display
            msg = msg.Replace(finLigne, " ");
            listBoxEvts.Items.Insert(0, String.Format("{0:hh:mm:ss} : {1}", DateTime.Now, msg));
}
  • 第 2 行:logEvents 方法将要添加到列表中的消息作为参数 listBoxEvts 接收。
  • 第 3 行:不能直接使用 listBoxEvents 组件。实际上,logEvents 方法由两类线程调用:
    • 拥有 GUI 的主线程,例如当它发出连接尝试正在进行时的信号时
    • 用于异步操作的辅助线程。此类线程不拥有控件,其对 C 控件的访问必须通过 C.Invoke 进行控制。该操作会通知 C 控件,有线程希望对其执行操作。Invoke 有两个参数:
      • 一个委托。该回调函数将由拥有 GUI 的主线程执行,而非由调用 C.Invoke 的线程执行。
      • 一个要传递给回调函数的对象。

此处传递给 Invoke 的第一个参数是以下委托的实例:


        public delegate void writeLog(string log);

该写入日志委托(writeLog)有一个字符串类型的参数,且不返回结果。该参数即为将写入 listBoxEvts 的消息。

第 3 行,传递给 Invoke 的第一个参数是第 6 行中的 logEventCallBack。它与委托 writeLog 的签名相对应。传递给 Invoke 的第二个参数是作为参数传递给 logEventCallBack 的消息。

Invoke 操作是同步操作。子线程的执行将被阻塞,直到拥有该控件的主线程执行回调方法为止。

  • 第 6 行:由 GUI 线程执行的回调方法接收将在控件 listBoxEvts 中显示的消息。
  • 第 9 行:该事件被记录在列表的第 1 位,因此列表顶部显示的是最新事件。

客户端/服务器对话框消息通过 logDialogue 显示:


         // dialogue follow-up
        private void logDialogue(string msg) {
            listBoxDialogue.Invoke(new writeLog(logDialogueCallBack), msg);
        }
        private void logDialogueCallBack(string msg) {
             // message display
            msg = msg.Replace(finLigne, " ");
            listBoxDialogue.Items.Add(String.Format("{0:hh:mm:ss} : {1}", DateTime.Now, msg));
}

其原理与 logEvent 中的一样。

客户端接收到的消息将通过 displayServerResponse 进行显示:


        private void afficherRéponseServeur(String msg, bool dernièreLigne) {
...
}

第一个参数是要显示的消息。该消息可能包含多行内容。实际上,客户端以 tailleBuffer(1024)字节为单位从服务器读取数据。在这 1024 字节内,可能包含多行内容,这些行通过其“\n”换行符进行标识。最后一行可能不完整,其换行符可能位于随后的 1024 字节中。 该方法会查找消息中以“\n”结尾的行,然后请求 logDialogue 显示它们。该方法的第二个参数用于指定是否显示找到的最后一行,还是将其保留在缓冲区中,由下一条消息来补全。该代码相当复杂,且在此处无关紧要,因此不再赘述。

11.8.7. 结论

该示例也可通过同步操作实现。在此,图形界面的异步特性对用户帮助不大。然而,如果用户登录后发现服务器“不再响应”,他仍可选择断开连接——这得益于图形界面在异步操作执行期间仍能响应事件。这个相当复杂的示例使我们得以引入一些新概念:

  • 套接字的使用
  • 异步方法的使用。我们所见的内容属于一项标准规范。还存在其他异步方法,它们基于相同的模型运行。
  • 由辅助线程更新 GUI 控件。

对于服务器而言,异步 TCP/IP 通信所带来的优势远比前例所示更为显著。 我们知道,服务器通过子线程为其客户端提供服务。如果其线程池中有 N 个线程,这意味着它只能同时服务 N 个客户端。如果所有 N 个线程都在执行阻塞(同步)操作,那么直到其中一个阻塞操作完成并释放一个线程之前,将没有可用线程来处理新客户端。如果在线程上执行的是异步操作而非同步操作,则线程永远不会被阻塞,并能迅速被回收以服务新客户端。

11.9. 示例应用程序,第 8 版:税费计算服务器

11.9.1. 新版本的架构

我们再次回到此前已以多种形式介绍过的税费计算应用程序。让我们回顾一下其最新版本——第9.8节中的第7版。

数据存储在数据库中,而[ui]层则是图形用户界面:

 

我们将重现这一架构,并将其部署在两台机器上:

  • 一台 [服务器] 机器将托管第 7 版的 [业务] 和 [DAO] 层。将构建一个 TCP/IP [服务器] [1] 层,以便互联网客户端能够查询税务计算服务。
  • 一台 [客户端] 机器将托管第 7 版的 [UI] 层。将构建一个 TCP/IP [客户端] [2] 层,以便 [UI] 层能够查询税费计算服务。

此处的架构发生了重大变化。第7版是一个单用户Windows应用程序。第8版则转变为互联网客户端/服务器应用程序。服务器将能够同时为多个客户端提供服务。

首先,我们将编写应用程序的 [服务器] 部分。

11.9.2. 税费计算服务器

11.9.2.1. Visual Studio 项目

Visual Studio 项目结构如下:

  • 在 [1] 中,该项目包含以下组件:
  • [ServeurImpot.cs]:以控制台应用程序形式实现的 TCP/IP 税务计算服务器。
  • [dbimpots.sdf]:第 9.8.5 节所述的 SQL Server Compact 数据库(版本 7)。
  • [App.config]:应用程序配置文件。
  • 在 [2] 中,[lib] 文件夹包含该项目所需的 DLL:
    • [ImpotsV7-dao]:第 7 版的 [dao] 层
    • [ImpotsV7-metier]:第 7 版的 [metier] 层
    • [antlr.runtime, CommonLogging, Spring.Core] 用于 Spring
  • 在 [3] 中,该项目引用了

11.9.2.2. 应用程序配置

文件 [App.config] 由 Spring 管理。其内容如下:


<?xml version="1.0" encoding="utf-8" ?>
<configuration>
 
    <configSections>
        <sectionGroup name="spring">
            <section name="context" type="Spring.Context.Support.ContextHandler, Spring.Core" />
            <section name="objects" type="Spring.Context.Support.DefaultSectionHandler, Spring.Core" />
        </sectionGroup>
    </configSections>
 
    <spring>
        <context>
            <resource uri="config://spring/objects" />
        </context>
        <objects xmlns="http://www.springframework.net">
            <object name="dao" type="Dao.DataBaseImpot, ImpotsV7-dao">
                <constructor-arg index="0" value="System.Data.SqlServerCe.3.5"/>
                <constructor-arg index="1" value="Data Source=|DataDirectory|\dbimpots.sdf;" />
                <constructor-arg index="2" value="select data1, data2, data3 from data"/>
            </object>
            <object name="metier" type="Metier.ImpotMetier, ImpotsV7-metier">
                <constructor-arg index="0" ref="dao"/>
            </object>
        </objects>
    </spring>
</configuration>
  • 第 16-20 行:与 SQL Server Compact 数据库关联的 [dao] 层配置
  • 第 21-23 行:[业务] 层的配置。

这是版本 7 的 [ui] 层所使用的配置文件。该文件已在第 9.8.4 节中介绍。

11.9.2.3. 服务器运行

服务器启动时,服务器应用程序会实例化 [metier] 和 [dao] 层,然后显示一个管理控制台界面:

  

管理控制台支持以下命令:

start port
用于在指定端口上启动服务
停止
以停止该服务。随后可在同一端口或另一个端口上重新启动该服务。
echo start
在控制台上输入 echo 启用客户端/服务器对话
echo stop
用于停用回显
status
用于显示服务的活动/非活动状态
quit
退出应用程序

让我们启动服务器:

1
2
3
Serveur de calcul d'impôt >start 27
Serveur de calcul d'impôt lancé sur le port 27
Serveur de calcul d'impôt >

现在,让我们运行本节 11.8 前面所研究的异步图形化 TCP 客户端。

Image

客户已登录。他可以向税费计算服务器发送以下命令:

aide
以获取授权命令列表
impot marié nbEnfants salaireAnnuel
用于计算育有 nbEnfants 名子女且年薪为 salaireAnnuel 欧元的纳税人应纳税额。marié 参数取值为 o 表示已婚,否则为 n
aurevoir
用于关闭与服务器的连接

以下是一个对话示例:

在服务器端,控制台显示如下内容:

1
2
3
4
Serveur de calcul d'impôt >start 27
Serveur de calcul d'impôt >Serveur de calcul d'impôt lancé sur le port 27
Début du service au client 0
Fin du service au client 0

让我们开启回显功能,并从图形客户端启动一个新的对话框:

 

随后管理控制台将显示以下内容:

1
2
3
4
5
6
7
echo start
Serveur de calcul d'impôt >Début du service au client 1
<--- Client 1 : aide
---> Client 1 : Commandes acceptées
1-aide
2-impot marié(O/N) nbEnfants salaireAnnuel
3-aurevoir
  • 第1行:启用了客户端/服务器对话回显
  • 第2行:有顾客光临
  • 第3行:他发送了[help]命令
  • 第4-7行:服务器分4行进行响应。

停止服务:

1
2
3
stop
L'erreur suivante s'est produite sur le serveur : Une opération de blocage a été interrompue par un appel à WSACancelBlockingCall
Serveur de calcul d'impôt >
  • 第 1 行:请求停止服务(而非应用程序本身)
  • 第 2 行:由于服务器因等待客户端响应而阻塞,但因监听服务关闭导致该阻塞被突然中断,从而引发异常。
  • 第 3 行:现在可以通过 start port 重启服务,或通过 quit 停止服务。

在监听服务停止之前,另一个连接上正在为客户端提供服务。当监听套接字关闭时,该连接并未关闭。客户端可以继续发出命令:在监听服务关闭之前与其关联的服务线程将继续响应:

Image

11.9.3. 用于税费计算的 Tcp 服务器代码

1
  

服务器代码 [ServeurImpot.cs] 如下:


...
namespace Chap9 {
    public class ServeurImpot {
 
        // data shared between threads and methods
        private static IImpotMetier metier = null;
        private static int port;
        private static TcpListener service;
        private static bool actif = false;
        private static bool echo = false;
 
         // main program
        public static void Main(string[] args) {
            // instantiations layers [metier] and [dao]
            IApplicationContext ctx = null;
            metier = null;
            try {
                 // context Spring
                ctx = ContextRegistry.GetContext();
                // a reference is requested on the [metier] layer
                metier = (IImpotMetier)ctx.GetObject("metier");
 
                 // thread pool configuration
                ThreadPool.SetMinThreads(10, 10);
                ThreadPool.SetMaxThreads(10, 10);
 
                // reads server administration commands typed on the keyboard in an endless loop
                string commande = null;
                string[] champs = null;
                while (true) {
                     // invite
                    Console.Write("Serveur de calcul d'impôt >");
                    // read command
                    commande = Console.ReadLine().Trim().ToLower();
                    champs = Regex.Split(commande, @"\s+");
                     // order execution
                    switch (champs[0]) {
                        case "start":
                             // active?
                            if (actif) {
                                //error
                                Console.WriteLine("Le serveur est déjà actif");
                            } else {
                                 // port check
                                if (champs.Length != 2 || !int.TryParse(champs[1], out port) || port <= 0) {
                                    Console.WriteLine("Syntaxe : start port. Port incorrect");
                                } else {
                                    // we launch the listening service
                                    ThreadPool.QueueUserWorkItem(doEcoute, null);
                                }
                            }
                            break;
                        case "echo":
                             // echo start / stop
                            if (champs.Length != 2 || (champs[1] != "start" && champs[1] != "stop")) {
                                Console.WriteLine("Syntaxe : echo start / stop");
                            } else {
                                echo = champs[1] == "start";
                            }
                            break;
                        case "stop":
                             // end of service
                            if (actif) {
                                service.Stop();
                                actif = false;
                            }
                            break;
                        case "status":
                             // server status
                            if (actif) {
                                Console.WriteLine("Le service est lancé sur le port {0}", port);
                            } else {
                                Console.WriteLine("Le service n'est pas lancé}");
                            }
                            break;
                        case "quit":
                            // quit the application
                            Console.WriteLine("Fin du service");
                            Environment.Exit(0);
                            break;
                        default:
                             // incorrect order
                            Console.WriteLine("Commande incorrecte. Utilisez (start,stop,echo, status, quit)");
                            break;
                    }
                }
            } catch (Exception e1) {
                 // exception display
                Console.WriteLine("L'erreur suivante s'est produite à l'initialisation de l'application : {0}", e1.Message);
                return;
            }
        }
 
 
        private static void doEcoute(Object data) {
...
        }
 
....
    }
}
  • 第 18-21 行:通过 [App.config] 配置的 Spring 实例化 [metier] 和 [dao] 层。随后初始化第 6 行中的全局变量 job
  • 第 24-25 行:配置应用程序的线程池,最小和最大线程数均为 10。
  • 第 30-86 行:循环处理服务管理命令(start、stop、quit、echo、status)。
  • 第 32 行:针对每个新命令显示服务器提示符
  • 第 34 行:读取管理员命令
  • 第 35 行:将命令拆分为各个字段以便分析
  • 第 38-52 行:根据命令启动监听服务的端口
    • 第 40 行:如果服务已处于活动状态,则无需执行任何操作
    • 第 45 行:检查端口是否存在且正确。若正确,则将第 7 行的全局变量 port 设置为该值。
    • 第 49 行:监听服务将由一个子线程管理,以便主线程可以继续执行控制台命令。如果 doEcoute 连接成功,则初始化第 8 行的全局变量 service 和第 9 行的全局变量 assets
  • 第 53-60 行:echo start / stop 命令用于启用/禁用控制台上的客户端/服务器对话回显
    • 第 58 行:第 7 行中的全局变量 `echo` 被设置
  • 第 61-67 行:用于停止监听服务的 stop 命令。
    • 第 64 行:停止监听服务
  • 第 68-75 行:status 命令,用于显示服务的活动/非活动状态
  • 第 76-80 行:quit 命令,用于停止所有操作。

负责监听客户请求的线程接下来执行 doEcoute


        private static void doEcoute(Object data) {
            // thread for listening to customer requests
            try {
                // create the service
                service = new TcpListener(IPAddress.Any, port);
                 // launch it
                service.Start();
                // the server is active
                actif = true;
                 // follow-up
                Console.WriteLine("Serveur de calcul d'impôt lancé sur le port {0}", port);
                 // customer service loop
                TcpClient tcpClient = null;
                 // customer no
                int numClient = 0;
                 // endless loop
                while (true) {
                    // waiting for a customer
                    tcpClient = service.AcceptTcpClient();
                    // the service is provided by another task
                    ThreadPool.QueueUserWorkItem(doService, new Client() { CanalTcp = tcpClient, NumClient = numClient });
                     // next customer
                    numClient++;
                }
            } catch (Exception ex) {
                // we report the error
                Console.WriteLine("L'erreur suivante s'est produite sur le serveur : {0}", ex.Message);
            }
        }
 
         // customer info
        internal class Client {
             public TcpClient CanalTcp { get; set        ; } // customer liaison
             public int NumClient { get; set            ; } // customer no
}

这段代码与第11.6.1节中研究的echo服务器代码类似。我们仅对不同之处进行说明:

  • 第 7 行:启动监听服务
  • 第9行:指出服务现已处于活动状态

第21行,客户由运行 doService 的服务线程处理:


private static void doService(Object infos) {
            // the customer is picked up and served
            Client client = infos as Client;
            // renders service to the customer
            Console.WriteLine("Début du service au client {0}", client.NumClient);
             // operation link TcpClient
            try {
                using (TcpClient tcpClient = client.CanalTcp) {
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamReader reader = new StreamReader(networkStream)) {
                            using (StreamWriter writer = new StreamWriter(networkStream)) {
                                 // unbuffered output stream
                                writer.AutoFlush = true;
                                // send a welcome message to the customer
                                writer.WriteLine("Bienvenue sur le serveur de calcul de l'impôt");
                                // loop read request/write response
                                string demande = null;
                                bool serviceFini = false;
                                while (!serviceFini && (demande = reader.ReadLine()) != null) {
                                     // console monitoring
                                    if (echo) {
                                        Console.WriteLine("<--- Client {0} : {1}", client.NumClient, demande);
                                    }
                                     // demand analysis
                                    demande = demande.Trim().ToLower();
                                    // empty request?
                                    if (demande.Length == 0) {
                                        // erroneous request
                                        writeClient(writer,client.NumClient,"Commande non reconnue. Utilisez la commande aide.");
                                        return;
                                    }
 
                                    // demand is broken down into fields
                                    string[] champs = Regex.Split(demande, @"\s+");
                                     // analysis
                                    switch (champs[0].ToLower()) {
                                        case "aide":
                                            writeClient(writer, client.NumClient, "Commandes acceptées\n1-aide\n2-impot marié(O/N) nbEnfants salaireAnnuel\n3-aurevoir");
                                            break;
                                        case "impot":
                                            // tax calculation
                                            writeClient(writer, client.NumClient, calculImpot(writer, client.NumClient, champs));
                                            break;
                                        case "aurevoir":
                                            serviceFini = true;
                                            writeClient(writer, client.NumClient, "Au revoir...");
                                            break;
                                        default:
                                            writeClient(writer, client.NumClient, "Commande non reconnue. Utilisez la commande aide.");
                                            break;
                                    }
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                // error
                Console.WriteLine("L'erreur suivante s'est produite lors du service au client {0} : {1}", client.NumClient, e.Message);
            } finally {
                Console.WriteLine("Fin du service au client {0}", client.NumClient);
            }
        }
 
        private static void writeClient(StreamWriter writer, int numClient, string message) {
             // echo console ?
            if (echo) {
                Console.WriteLine("---> Client {0} : {1}", numClient, message);
            }
             // send msg to customer
            writer.WriteLine(message);
}

同样,这段代码与第11.6.1节中研究的回显服务器代码相似。我们仅对不同之处进行说明:

  • 第 15 行:客户端连接后,服务器发送欢迎信息。
  • 第19-52行:用于读取客户命令的循环。当客户发送“goodbye”时,循环停止。
  • 第27行:处理空订单的情况
  • 第34行:将请求拆分为字段以便分析
  • 第 37 行:订单帮助:客户请求授权订单列表
  • 第 40 行:订单税费:客户端请求计算税费。我们返回由 calculImpot 函数生成的消息,该函数将在稍后详细说明。
  • 第 44 行:订单结束:客户表示操作已完成。
  • 第 45 行:我们准备退出客户请求的读取循环(第 19-52 行)
  • 第 46 行:向客户发送告别信息
  • 第 48 行:订单错误。向客户发送错误消息。

订单处理由以下计算功能确保:


private static string calculImpot(StreamWriter writer, int numClient, string[] champs) {
             // request calculation married(Y/N) nbEnfants salaireAnnuel
             // 4 fields are required
            if (champs.Length != 4) {
                return "Commande calcul incorrecte. Utilisez la commande aide.";
            }
             // fields [1]
            string marié = champs[1];
            if (marié != "o" && marié != "n") {
                return "Commande calcul incorrecte. Utilisez la commande aide.";
            }
             // fields [2]
            int nbEnfants;
            if (!int.TryParse(champs[2], out nbEnfants)) {
                return "Commande calcul incorrecte. Utilisez la commande aide.";
            }
             // fields [3]
            int salaireAnnuel;
            if (!int.TryParse(champs[3], out salaireAnnuel)) {
                return "Commande calcul incorrecte. Utilisez la commande aide.";
            }
             // that's it - tax calculation
            int impot = 0;
            try {
                impot = metier.CalculerImpot(marié == "o", nbEnfants, salaireAnnuel);
                return impot.ToString();
            } catch (Exception ex) {
                return ex.Message;
            }
        }
  • 第 1 行:该方法将命令字段数组作为第 3 个参数 tax 接收。如果格式正确,其形式应为 married tax nbEnfants salaireAnnuel。该方法的返回结果即为发送给客户端的响应。
  • 第 4 行:检查命令是否包含 4 个字段
  • 第 8 行:检查 married 字段是否有效
  • 第14行:检查nbEnfants是否有效
  • 第 19 行:检查 salaireAnnuel 是否有效
  • 第 25 行:使用 [metier] 层的 CalculerImpot 计算税额。请注意,该层封装在 DLL 中。
  • 第 26 行:如果 [metier] 层返回了结果,则将结果返回给客户端。
  • 第 28 行:如果 [metier] 层抛出了异常,则将异常消息返回给客户端。

11.9.4. Tcp 税费计算服务器图形客户端

11.9.4.1. 项目 Visual Studio

图形客户端的 Visual Studio 项目结构如下:

  • 在 [1] 中,有两个解决方案项目,分别对应两个应用层
  • 在 [2] 中,TCP 客户端,它作为 [ui] 层的 [metier] 层。我们将在这里同时使用这两个术语。
  • [3] 中的 [ui] 层(第 7 版),其中有一个细节我们稍后将讨论

11.9.4.2. 尿布 [业务]

接口 IImpotMetier 未发生变化。它仍与第 7 版中的完全一致:


namespace Metier {
    public interface IImpotMetier {
        int CalculerImpot(bool marié, int nbEnfants, int salaire);
    }
}

该接口由以下 [ImpotMetierTcp] 类实现:


using System.Net.Sockets;
using System.IO;
namespace Metier {
    public class ImpotMetierTcp : IImpotMetier {
 
         // information [server]
        private string Serveur { get; set; }
        private int Port { get; set; }
 
         // tAX CALCULATION
        public int CalculerImpot(bool marié, int nbEnfants, int salaire) {
                 // connect to the service
                using (TcpClient tcpClient = new TcpClient(Serveur, Port)) {
                    using (NetworkStream networkStream = tcpClient.GetStream()) {
                        using (StreamReader reader = new StreamReader(networkStream)) {
                            using (StreamWriter writer = new StreamWriter(networkStream)) {
                                 // unbuffered output stream
                                writer.AutoFlush = true;
                                 // skip the welcome message
                                reader.ReadLine();
                                 // request
                                writer.WriteLine(string.Format("impot {0} {1} {2}",marié ? "o" : "n",nbEnfants, salaire));
                                 // answer
                                return int.Parse(reader.ReadLine());
                            }
                        }
                    }
                }
            }
        }
    }
  • 第7行:税费计算服务器的名称或IP地址(TCP)
  • 第 8 行:该服务器的监听端口
  • 这两个属性将在 [ImpotMetierTcp] 类实例化时由 Spring 进行初始化。
  • 第 11 行:税费计算方法。执行时,Server Port 属性已初始化。代码遵循经典的 TCP 客户端实现方式
  • 第 13 行:已建立与服务器的连接
  • 第 14-16 行:我们从该连接中获取(第 14 行)相关的网络流,并从中派生出一个读取流(第 15 行)和一个写入流(第 16 行)。
  • 第 18 行:写流必须设置为无缓冲
  • 第 20 行:请注意,建立连接时,服务器会向客户端发送第一行消息,即“欢迎”信息“欢迎使用税费计算服务器”。该消息被读取后直接忽略。
  • 第 22 行:发送类似于“impot o 2 60000”的命令,以计算一名已婚、有两个孩子且年薪为 60,000 欧元的纳税人的应纳税额。
  • 第 24 行:服务器以“4282”的形式返回税额,若命令格式错误(此处不会发生)或计算税额时出现问题,则返回错误信息。此处未处理后一种情况,但若进行处理,代码无疑会更“简洁”。 实际上,若读取的行是错误信息,由于整数转换失败,系统将抛出异常。GUI 捕获到的异常将是转换错误,而原始异常的性质则完全不同。欢迎读者改进此代码。
  • 第25-28行:使用“using”子句释放所有已使用的资源。

[metier] 层编译在 DLL ImpotsV8-metier.dll 中:

Image

11.9.4.3. [ui] 层

[ui] [1,3] 层即第 7 版第 9.8.4 节中研究的那个层,但有三个细节除外:

  • 由于 [metier] 层的实现已发生变更,其在 [App.config] 中的配置也随之不同
  • 已修改 [Form1.cs] 中的 GUI 以显示可能出现的异常
  • [metier] 层位于 DLL 文件 [ImpotsV8-metier.dll] 中。

[App.config] 文件内容如下:


<?xml version="1.0" encoding="utf-8" ?>
<configuration>
 
    <configSections>
        <sectionGroup name="spring">
            <section name="context" type="Spring.Context.Support.ContextHandler, Spring.Core" />
            <section name="objects" type="Spring.Context.Support.DefaultSectionHandler, Spring.Core" />
        </sectionGroup>
    </configSections>
 
    <spring>
        <context>
            <resource uri="config://spring/objects" />
        </context>
        <objects xmlns="http://www.springframework.net">
            <object name="metier" type="Metier.ImpotMetierTcp, ImpotsV8-metier">
                <property name="Serveur" value="localhost"/>
                <property name="Port" value="27"/>
            </object>
        </objects>
    </spring>
</configuration>
  • 第 16 行:使用 DLL ImpotsV8-metier.dll 中的 Metier.ImpotMetierTcp 类实例化 [metier] 层
  • 第 17-18 行:初始化 Metier.ImpotMetierTcp 类的 Server Port 属性。服务器将运行在本地主机上,并使用 27 号端口。

向用户展示的图形界面如下:

  • 在[1]中,我们添加了一个TextBox用于显示可能出现的异常。该字段在之前的版本中并不存在。

除这一细节外,表单代码与第6.4.3节所述内容相同。建议读者参阅该节。在[2]中,您可以看到一个通过以下方式启动服务器获得的执行示例:

1
2
3
4
5
6
7
8
9
Serveur de calcul d'impôt >start 27
Serveur de calcul d'impôt lancé sur le port 27
Serveur de calcul d'impôt >echo start
Serveur de calcul d'impôt >
...
Début du service au client 9
<--- Client 9 : impot o 2 60000
---> Client 9 : 4282
Fin du service au client 9

客户截图 [2] 对应上文客户9的记录。

11.9.5. 结论

我们再次成功复用了现有代码,有些无需修改(服务器层 [业务]、[DAO]),有些仅需微调(客户端层 [UI])。这得益于我们系统地使用接口,并通过 Spring 进行实例化。 如果我们在第 7 版中将业务代码直接放入 GUI 事件处理程序中,该业务代码将无法被复用。这就是单层架构的主要缺点。

最后需要注意的是,[ui]层完全不知道税额是由远程服务器计算得出的。