使用TCP/ IP套接字
TCP/IP 套接字提供了网络上的低级控制。TCP/IP 套接字是两台计算机之间的逻辑连接,有了它,计算机在任何时候可以发送或接收数据。在计算机显式发出关闭指令之前,这个连接一直保持。它提供了高度的灵活性,但也带来了大量的问题,在这一章我们会看到。因此,除非真的需要非常高度的控制,最好不要使用更抽象的网络协议,在这一章的后面我们也会谈到。
为了使用 TCP/IP 套接字,必须用到包含在命名空间 System.Net 中的类,汇总在表 10-1 中。
表10-1. 使用 TCP/IP 套接字需要的类
类 | 描述 |
System.Net.Sockets.TcpListener | 服务器用这个类监听入站请求。 |
System.Net.Sockets.TcpClient | 服务器和客户端都使用这个类,控制如何在网络上发送数据。 |
System.Net.Sockets.NetworkStream | 这个类用于在网络上发送和接收数据。它在网络上发送字节,因此,要发送文本,通常要打包到其他的流类型中。 |
System.IO.StreamReader | 这个类为了读取文本,用于打包 NetworkStream 类。StreamReader 提供两个方法 ReadLine 和 ReadToEnd,以字符串形式返回流数据。在 StreamWriter 创建时,各种不同的文本编码可以使用,由System.Text.Encoding 类的实例提供。 |
System.IO.StreamWriter | 这个类用于打包 NetworkStream 类,为了写文本。StreamWriter 提供了两个方法 Write 和 WriteLine,以字符串形式写数据流。 在 StreamWriter 创建时,各种不同的文本编码可以使用,由System.Text.Encoding 类的实例提供。 |
这一章的第一个例子,我们创建一个聊天程序,包含一个聊天服务器(清单 10-1 )和一个客户端(清单10-2 )。服务器的任务是等待并监听客户端,客户端连接以后,它要求客户提供用户名;它还必须连续监听所有客户端的入站消息,接收了一个入站消息以后,就把这个消息推给所有的客户端。客户端的任务是连接到服务器,并提供一个界面,使用户能够读取接收到的消息,写消息并发送给其他用户。TCP/IP 连接非常适合这种类型的应用程序,因为,连接一直保持,使服务器直接把入站消息推出去,而不必从客户端拉。
清单10-1. 聊天服务器
#light
open System
open System.IO
open System.Net
openSystem.Net.Sockets
open System.Threading
openSystem.Collections.Generic
type ClientTable() = class
let clients = newDictionary<string,StreamWriter>()
/// Add a client and its stream writer
member t.Add(name,sw:StreamWriter) =
lockclients (fun () ->
if clients.ContainsKey(name)then
sw.WriteLine("ERROR - Name in usealready!")
sw.Close()
else
clients.Add(name,sw))
/// Remove a client and close it, if no one else has donethat first
member t.Remove(name) =
lockclients (fun () ->clients.Remove(name) |> ignore)
/// Grab a copy of the current list of clients
member t.Current =
lockclients (fun () ->clients.Values |> Seq.toArray)
/// Check whether a client exists
member t.ClientExists(name):bool =
lockclients (fun () ->clients.ContainsKey(name) )
end
type Server() = class
let clients = newClientTable()
let sendMessage name message =
let combinedMessage =
Printf.sprintf "%s: %s" namemessage
for sw inclients.Current do
try
lock sw (fun () ->
sw.WriteLine(combinedMessage)
sw.Flush())
with
| _-> () // Someclients may fail
let emptyString s = (s = null|| s = "")
let handleClient (connection : TcpClient) =
let stream = connection.GetStream()
let sr = newStreamReader(stream)
let sw = newStreamWriter(stream)
let recrequestAndReadName() =
sw.WriteLine("What is your name? ");
sw.Flush()
let rec readName() =
let name = sr.ReadLine()
if emptyString(name)then
readName()
else
name
let name = readName()
if clients.ClientExists(name)then
sw.WriteLine("ERROR - Name in usealready!")
sw.Flush()
requestAndReadName()
else
name
let name = requestAndReadName()
clients.Add(name,sw)
let rec listen() =
let text = trySome(sr.ReadLine()) with _ -> None
match text with
|Some text ->
if not (emptyString(text))then
sendMessage name text
Thread.Sleep(1)
listen()
|None ->
clients.Remove name
sw.Close()
listen()
let server = newTcpListener(IPAddress.Loopback, 4242)
let rechandleConnections() =
server.Start()
if (server.Pending()) then
let connection = server.AcceptTcpClient()
printf "New Connection"
let t = new Thread(fun () ->handleClient connection)
t.Start()
Thread.Sleep(1);
handleConnections()
member server.Start() = handleConnections()
end
(newServer()).Start()
[
Seq.to_array 已经改成 Seq.toarray
member t.ClientExists(name) =
lockclients (fun () ->clients.ContainsKey(name) |> ignore)
改成:
member t.ClientExists(name):bool =
lockclients (fun () ->clients.ContainsKey(name) )
let rechandleConnections() =
改成:
let rec handleConnections() : unit =
上述两个修改并非必需。
把代码保存为脚本文件,比如:p10-01.fsx
编译:fsc p10-01.fsx
]
我们从头开始看一下清单 10-1 的程序。第一步定义一个类,管理连接到服务器的客户端。成员 Add、Remove、Current 和 ClientExists 共享不可变的字典,由这个绑定定义:
let clients = newDictionary<string,StreamWriter>()
它包含了客户端名字与连接的映射,这对程序中的其他函数是隐藏的。Current 成员把映射中的实体复制到一个数组中,保证在枚举时不会有改变列表的危险,这会引起错误。仍可以用 Add 和 Remove 修改客户端的集合,在下一次调用 Current 时更新可用。因为代码是多线程的,Add 和 Remove 的实现要锁定客户端集合,以保证多线程同时更新集合时,集合的改变不会丢失。
下一个定义的函数是 sendMessage,用 Current 成员获得客户端的映射,用列表解析式进行枚举,把消息发送给集合中的每个客户端。注意一下,在写入之前是如何锁定 StreamWriter 类的:
lock sw (fun () ->
sw.WriteLine(message)
sw.Flush())
这是为阻止多个线程同时写,它会引起文本在客户端的屏幕上出现混乱。再定义 emptyString 函数,这是一个非常有用的小函数,把重复使用的一些动作进行打包。再定义 handleClient 函数,管理客户端的新连接,它分解成几个函数。handleClient 函数它由最后定义的函数 handleConnections 调用。在专门分配的新线程上调用,去管理打开的连接。handleClient 要做的第一件事就是获得的流,表示网络连接和打包在 StreamReader 和 StreamWriter 中:
let stream = connection.GetStream()
let sr = new StreamReader(stream)
let sw = new StreamWriter(stream)
有办法把读写流分开是有用的,因为这个函数本身就是完全分开的。我们已经看过 sendMessage 函数,用于给客户端发送消息,后面还会看到一个新的线程,专门分配给读客户端。
在 handleClient 中定义内部函数 requestAndReadName,是很简单的,只重复询问用户名,直到名字不空或 null 字符串,并且没有在用。有了用户名以后,再用 addClient 函数把它添加到客户端集合中:
let name = requestAndReadName()
addClient name sw
handleConnection 的最后一部分是定义 listen 函数,它负责监听客户端的入站消息。这里,从流中读取一些文本,打包在 try 表达式中,用选项类型的 Some/None 值来表示读取的不管是什么文本:
let text = try Some(sr.ReadLine()) with _-> None
然后,使用模式匹配决定下一步做什么。如何成功读取文本,就用 sendMessage 函数发送消息给所有的客户端;否则,就从客户端集合中删除自己,并退出函数,接着,线程管理的连接也退出。
注意: 虽然 listen 函数是递归的,可能会被多次调用,但是没有堆栈溢出的危险,是因为函数是尾递归,编译器发出专门的尾指定,告诉.NET 运行时调用这个函数,不使用堆栈保存参数和局部变量。在F# 中定义的任何递归函数,递归调用在函数的最后才发生,是尾部递归。
接下来,创建TcpListener 类的实例。这个类是真正实现监听入站连接的,通常用监听服务器的 IP 地址和端口号来初始化。启动监听器时,提供监听 IPAddress 的地址,任意地址,监听器就会监听和这台计算机上网卡所关联的 IP 地址上的所有通信。然而,因为这是一个演示程序,为 TcpListener 类指定监听 IPAddress.Loopback,表示只从本地计算机搜集请求。端口号告诉网络通信只为这个应用程序服务,而不是其他的应用程序。使用 TcpListener 类,使一个监听器一次只监听一个端口是可能的。端口号的选择可以随便一点,但是,应该要大于1023,因为端口号0 到 1023 是留给专门的应用程序。因此,在函数的最后,程序定义了handleConnections,TcpListener 实例在端口 4242 上创建监听器:
let server = newTcpListener(IPAddress.Loopback, 4242)
这个函数是个无限循环,监听新的客户端连接,并创建新的线程来管理。看下面的代码,有了连接以后,用它来获得连接的一个实例,并启动一个新线程去管理它。
let connection = server.AcceptTcpClient()
print_endline "New Connection"
let t = new Thread(fun () -> handleClientconnection)
t.Start()
现在,我们知道了服务器是如何工作的,下面要看客户端,它在很多方面比服务器简单得多。清单 10-2 是客户端的完整代码,下面有相关的讨论。
列表10-2. 聊天客户端
#light
open System
open System.ComponentModel
open System.IO
open System.Net.Sockets
open System.Threading
open System.Windows.Forms
let form =
let temp = new Form()
temp.Text <- "F#Talk Client"
temp.Closing.Add(fune ->
Application.Exit()
Environment.Exit(0))
let output=
newTextBox(Dock = DockStyle.Fill,
ReadOnly = true,
Multiline = true)
temp.Controls.Add(output)
let input =new TextBox(Dock = DockStyle.Bottom, Multiline=true)
temp.Controls.Add(input)
let tc = new TcpClient()
tc.Connect("localhost",4242)
let load()=
letrun() =
letsr = new StreamReader(tc.GetStream())
while(true)do
lettext = sr.ReadLine()
iftext <> null && text <> "" then
temp.Invoke(new MethodInvoker(fun()->
output.AppendText(text+ Environment.NewLine)
output.SelectionStart<- output.Text.Length))
|> ignore
let t =new Thread(newThreadStart(run))
t.Start()
temp.Load.Add(fun_ -> load())
let sw = new StreamWriter(tc.GetStream())
let keyUp _=
if(input.Lines.Length> 1) then
lettext = input.Text
if(text <> null && text <> "") then
begin
try
sw.WriteLine(text)
sw.Flush()
witherr ->
MessageBox.Show(sprintf"Server error\n\n%O" err)
|> ignore
end;
input.Text <- ""
input.KeyUp.Add(fun_ -> keyUp ())
temp
[<STAThread>]
do Application.Run(form)
[
input.KeyUp.Add(fun _ -> keyUp e)
改成:
input.KeyUp.Add(fun _ -> keyUp ())
把代码保存为脚本文件,比如:p10-02.fsx
编译:fsc p10-02.fsx
运行服务器端程序,p10-01
运行两个以上的客户端程序,p10-02
]
图 10-1 客户端-服务器应用程序运行的结果。
图10-1. 聊天客户端-服务器应用程序
现在我们就来看一下清单 10-2 的客户端是如何工作的。客户端的第一部分代码完成窗体各部分的初始化,这不是我们现在感兴趣的,在第八章有更详细的有关 WinForms 应用程序的内容。清单 10-2 的第一部分是有关连接到服务器的 TCP/IP 套接字编程内容。通过创建 TcpClient 类的实例,然后调用 Connect 方法:
let tc = new TcpClient()
tc.Connect("localhost", 4242)
在这个例子中,我们用 localhost,表示本地计算机,端口 4242,监听这台服务器上的端口。在更实际的例子中,可以用服务器的 DNS 名字,或者由用户指定 DNS 名字。如果运行在同一台计算机上,localhost 也是不错的选择。
从服务器读数据的函数是 load 函数,再把它附加到窗体的 Load 事件,保证窗体装载并初始化后能够运行,需要与窗体的控件交互:
temp.Load.Add(fun _ -> load())
为了及时从服务器上读出所有数据,需要创建一个新的线程去读所有的入站请求。要做到,定义函数 run,然后,用于启动一个新线程:
let t = new Thread(new ThreadStart(run))
t.Start()
在 run 函数的定义中,先创建 StreamReader 从连接中读文本,然后无限地循环,这样,保证了线程不停地从连接中读数据。一旦有了数据之后,必须用窗体的 Invoke 方法更新窗体,这是因为不能从创建窗体之外的线程中更新窗体:
temp.Invoke(new MethodInvoker(fun () ->
output.AppendText(text + Environment.NewLine)
output.SelectionStart <- output.Text.Length))
客户端中的另外一个重要功能是把消息写到服务器上。把 keyUp 函数附加到输入文本框的 KeyUp 事件,因此,每次在文本框中按键,代码会触发:
input.KeyUp.Add(fun _ -> keyUp e)
[
以上这一句好像不能通过编译,要改成:
input.KeyUp.Add(fun _ -> keyUp () )
]
keyUp 函数的实现是很简单,如果文本超过一行,表示已按下回车键,通过网络发送任何可用的文本,并清除文本框。
现在,我们已经了解了客户端和服务器,再看看几个有关这个应用程序的一般问题。在清单 10-1 和 10-2 中,在每次网络操作后都调用了 Flush()。否则,通过网络传输的数据要等到流缓存满才进行,这会导致用户必须输入很多消息,才能出现在另外用户的屏幕上。
这种实现有一些问题,特别是在服务器端。为每个入站的客户端分配一个线程,可以保证对每个客户端有很好的响应,但是,随着客户连接数量的增加,这样,就需要为这些线程大量的上下文切换,导致服务器的整体性能下降。另外,每个客户端都要有它自己的线程,因此,客户端的最大数量就受限于进程所能包含的最大线程数量。这些问题是可以解决的,通常也很容易,只要使用一些更加抽象的协议,下面会有讨论。