首页 > 代码库 > 使用 TCP / IP 套接字(Sockets)

使用 TCP / IP 套接字(Sockets)

使用TCP / IP 套接字(Sockets)

 

TCP/IP 套接字提供了跨网络的低层控制。TCP/IP套接字是两台计算机之间的逻辑连接,通过它,计算机能够在任何时间发送或接收数据;这个连接一直保持,直到这台计算机显式发出关闭指令。它提供了高度的灵活性,但也带来了大量的问题,在这一章中我们会看到,因此,除非真的需要非常高度的控制,否则,最好还是使用更抽象的网络协议,在这一章的后面我们也会谈到。

为了使用TCP/IP 套接字所必须的类包含在命名空间System.Net,表 11-1 进行了汇总。

 

表 11-1 使用 TCP/IP 套接字需要的类

描述

System.Net.Sockets

.TcpListener

服务器用这个类监听入站请求。

System.Net.Sockets

.TcpClient

服务器和客户端都使用这个类,控制如何在网络上发送数据。

System.Net.Sockets

.NetworkStream

这个类用于在网络上发送和接收数据。在网络上是发送字节,因此,要发送文本,通常要打包到另外的流类型中。

System.IO.StreamReader

这个类用于包装 NetworkStream 类,用来读取文本。

StreamReader 提供两个方法 ReadLine 和 ReadToEnd,以字符串形式返回流中的数据。

在 StreamReader [ 这里好像不应该是 StreamWriter ]创建时,可以使用各种不同的文本编码,由System.Text.Encoding 类的实例提供。

System.IO.StreamWriter

这个类用于包装 NetworkStream 类,用于写文本。

StreamWriter 提供两个方法 Write 和 WriteLine,以字符串形式把数据写入流。

在 StreamWriter 创建时,可以使用各种不同的文本编码,由System.Text.Encoding 类的实例提供。

 

这一章的第一个例子,我们将创建一个聊天程序,有聊天服务器(见清单 11-1 )和客户端(见清单 11-2 )。聊天服务器的任务是等待并监听客户端的连接,一旦有客户端连接,必须要求客户提供用户名;还必须连续监听所有客户端的入站消息。一旦有入站消息到达,就把这个消息推给所有的客户端。客户端的任务是连接到服务器,并提供用户界面,来阅读接收到的消息,写消息并发送给其他用户。TCP/IP 连接非常适合这种类型的应用程序,因为,连接总是可用,服务器能够直接把入站消息推给客户端,而不必从客户端拉消息。

 

清单 11-1 聊天服务器

 

open System

open System.IO

open System.Net

open System.Net.Sockets

open System.Text

open System.Threading

open System.Collections.Generic

 

// Enhance the TcpListener class so it canhandle async connections

type System.Net.Sockets.TcpListener with

  memberx.AsyncAcceptTcpClient() =

    Async.FromBeginEnd(x.BeginAcceptTcpClient,x.EndAcceptTcpClient)

 

// Type that defines protocol forinteracting with the ClientTable

type ClientTableCommands =

  |Add of (string * StreamWriter)

  |Remove of string

  |SendMessage of string

  |ClientExists of (string * AsyncReplyChannel<bool>)

 

// A class that will store a list of namesof connected clients along with

// streams that allow the client to bewritten too

type ClientTable() =

  //create the mail box

  letmailbox = MailboxProcessor.Start(fun inbox ->

    //main loop that will read messages and update the

    //client name/stream writer map

    letrec loop (nameMap: Map<string, StreamWriter>) =

     async { let! msg = inbox.Receive()

           match msg with

           | Add (name, sw) ->

              return! loop (Map.add name swnameMap)

           | Remove name ->

              return! loop (Map.remove namenameMap)

           | ClientExists (name, rc) ->

              rc.Reply (nameMap.ContainsKeyname)

              return! loop nameMap

           | SendMessage msg ->

              for (_, sw) in Map.toSeq nameMapdo

                try

                  sw.WriteLine msg

                  sw.Flush()

                with _ -> ()

              return! loop nameMap }

    //start the main loop with an empty map

    loopMap.empty)

  ///add a new client

  memberx.Add(name, sw) = mailbox.Post(Add(name, sw))

  ///remove an existing connection

  memberx.Remove(name) = mailbox.Post(Remove name)

  ///handles the process of sending a message to all clients

  memberx.SendMessage(msg) = mailbox.Post(SendMessage msg)

  ///checks if a client name is taken

  memberx.ClientExists(name) = mailbox.PostAndReply(fun rc -> ClientExists(name,rc))

 

/// perform async read on a network streampassing a continuation

/// function to handle the result

let rec asyncReadTextAndCont (stream:NetworkStream) cont =

  //unfortunatly we need to specific a number of bytes to read

  //this leads to any messages longer than 512 being broken into

  //different messages

  async{ let buffer = Array.create 512 0uy

        let! read = stream.AsyncRead(buffer, 0,512)

       let allText = Encoding.UTF8.GetString(buffer, 0, read)

       return cont stream allText }

 

// class that will handle clientconnections

type Server() =

  //client table to hold all incoming client details

  letclients = new ClientTable()

 

  //handles each client

  lethandleClient (connection: TcpClient) =

    //get the stream used to read and write from the client

    letstream = connection.GetStream()

    //create a stream write to more easily write to the client

    letsw = new StreamWriter(stream)

 

    //handles reading the name then starts the main loop that handles

    //conversations

    letrec requestAndReadName (stream: NetworkStream) (name: string) =

     // read the name

      let name = name.Replace(Environment.NewLine,"")

     // main loop that handles conversations

     let rec mainLoop (stream: NetworkStream) (msg: string) =

       try

         // send received message to all clients

         let msg = Printf.sprintf "%s: %s" name msg

         clients.SendMessage msg

       with _ ->

         // any error reading a message causes client to disconnect

         clients.Remove name

         sw.Close()

       Async.Start (asyncReadTextAndCont stream mainLoop)

     if clients.ClientExists(name) then

       // if name exists print error and relaunch request

       sw.WriteLine("ERROR - Name in use already!")

       sw.Flush()

       Async.Start (asyncReadTextAndCont stream requestAndReadName)

     else

       // name is good lanch the main loop

       clients.Add(name, sw)

       Async.Start (asyncReadTextAndCont stream mainLoop)

    //welcome the new client by printing "What is you name?"

    sw.WriteLine("Whatis your name? ");

    sw.Flush()

    //start the main loop that handles reading from the client

    Async.Start(asyncReadTextAndCont stream requestAndReadName)

 

  //create a tcp listener to handle incoming requests

  letlistener = new TcpListener(IPAddress.Loopback, 4242)

 

  //main loop that handles all new connections

  letrec handleConnections() =

    //start the listerner

    listener.Start()

    iflistener.Pending() then

     // if there are pending connections, handle them

     async { let! connection = listener.AsyncAcceptTcpClient()

            printfn "New Connection"

           // use a thread pool thread to handle the new request

           ThreadPool.QueueUserWorkItem(fun _ ->

              handleClient connection) |>ignore

           // loop

           return! handleConnections() }

    else

     // no pending connections, just loop

     Thread.Sleep(1)

     async { return! handleConnections() }

 

  ///allow tot

  memberserver.Start() = Async.RunSynchronously (handleConnections())

 

// start the server class

(new Server()).Start()

 

我们从头开始看一下清单11-1 的程序。第一步定义一个类ClientTable,来管理连接到服务器的客户端;这是一个很好的示例,解释了如何如用信箱处理程序(MailboxProcessor)安全地在几个线程之间共享数据,这个方法与第十章“消息传递”的非常相似。我们回忆一下,信箱处理程序把非常客户端的消息进行排队,通过调用MailboxProcessor 类的 Receive 方法接收这些消息:

 

let! msg = inbox.Receive()

 

我们总是异步接收消息,这样,可以在等待消息期间不阻塞线程。提交消息给类,使用Post 方法:

 

mailbox.Post(Add(name, sw))

 

我们使用联合类型来定义发关和接收的消息。在这里,我们定义了四种操作,分别是Add、Remove、Current、SendMessage 和 ClientExists:

 

type ClientTableCommands =

  |Add of (string * StreamWriter)

  |Remove of string

  |SendMessage of string

  |ClientExists of (string * AsyncReplyChannel<bool>)

 

这些操作用模式匹配实现,根据接收到的消息,它在专门负责接收消息的异步工作流中,通常,我们使用无限递归的循环连续地读消息。在这个示例中,这个函数是 loop,loop 有一个参数,它是 F# 中不可变的 Map 类,负责在用字符串表示的客户端名字与用StreamWriter 表示的到这个客户端的连接之间的映射:

 

let rec loop (nameMap: Map<string,StreamWriter>) =

...

 

这种很好地实现了操作之间的状态共享。操作先更新这个映射,然后,再传递一个新的实例给下一次循环。Add 和Remove 的操作实现很简单,创建一个更新过的新映射,然后,传递给下一次循环。下面的代码只展示了 Add 操作,因为 Remove 操作非常相似:

 

| Add (name, sw) ->

 return! loop (Map.add name sw nameMap)

 

ClientExists 操作有更有趣一点,因为必须要返回一个结果,对此,使用 AsyncReplyChannel(异步应答通道),它包含在ClientExists 联合的情况中:

 

| ClientExists (name, rc) ->

 rc.Reply (nameMap.ContainsKey name)

 return! loop nameMap

 

把消息传递给MailboxProcessor 类,是通过使用它的 PostAndReply方法,注意,不是前面看到过的 Post 方法,应答通道被加载到联合的情况中:

 

mailbox.PostAndReply(fun rc -> ClientExists(name,rc))

 

可能最有趣的操作是SendMessage,需要枚举出所有的客户端,然后把消息传递给它们。执行这个在MailboxProcessor 类当中,因为这个类实现了一个排队系统,这样,就能保证只有一个消息传递给所有的客户端只有一次;这种方法还保证了一个消息文本不会和其他消息混在一起,以及消息的到达顺序不会改变:

 

| SendMessage msg ->

  for(_, sw) in Map.to_seq nameMap do

   try

     sw.WriteLine msg

     sw.Flush()

   with _ -> ()

 

下面,我们将看到代码中最困难的部分:如何有效地从连接的客户端读消息。要有效地读消息,必须使用异步的方式读,才能保证宝贵的服务器线程不会在等待客户端期间被阻塞,因为客户端发送消息相对并不频繁。F# 通过使用异步工作流,定代码已经很容易了;然而,要想让 F# 的异步工作流运行在最好的状态,还必须有大量的操作能够并发执行。在这里,我们想重复地执行一个异步操作,这是可能的,但有点复杂,因为我们必须使用连续的进行传递(continuation style passing)。我们定义一个函数 asyncReadTextAndCont,它异步地读网络流,并这个结果字符串和原始的网络流传递给它的连续函数(continuation function)。这里的连续函数是 cont:

 

/// perform async read on a network streampassing a continuation

/// function to handle the result

let rec asyncReadTextAndCont (stream:NetworkStream) cont =

 async { let buffer = Array.create 512 0uy

       let! read = stream.AsyncRead(buffer, 0, 512)

       let allText = acc + Encoding.UTF8.GetString(buffer, 0, read)

       return cont stream allText }

 

所以要注意这个函数的重要的一点是,当读取发生,物理线程将从函数返回,并可能返回到线程池;然而,我们不必担心物理线程太多的问题,因为当异步输入输出操作完成时,它会重新启动,结果会传递给 cont 函数。

然后,使用这个函数执行读客户端的所有任务,例如,主递归循环可能像这样:

 

let rec mainLoop (stream: NetworkStream)(msg: string) =

  try

   // send received message to all clients

   let msg = Printf.sprintf "%s: %s" name msg

   clients.SendMessage msg

 with _ ->

   // any error reading a message causes client to disconnect

   clients.Remove name

   sw.Close()

 Async.Start (asyncReadTextAndCont stream mainLoop)

 

把接收到的 msg 字符串作为消息,执行发送操作;然后,使用asyncReadTextAndCont 函数进行递归循环,把 mainLoop 函数作为一个参数传递给它,再使用 Async.Start 函数发送消息,以fire-and-forget (启动后就不管了)模式启动异步工作流,就是说,它不会阻塞,并等待工作流的完成。

接着,创建TcpListener 类的实例。这个类是完成监听入站连接工作的,通常用被监听服务器的 IP 地址和端口号来初始化;当启动监听器时,告诉它监听的IP 地址。通常,它监听和这台计算机上网卡相关的所有 IP 地址的所有通信;然而,这仅是一个演示程序,因此,告诉TcpListener 类监听IPAddress.Loopback,表示只选取本地计算机的请求。使用端口号是判断网络通信只为这个应用程序服务,而不是别的。TcpListener 类一次只允许一个监听器监听一个端口。端口号的选择有点随意性,但要大于 1023,因为端口号 0 到 1023 是保留给专门的应用程序的。因此,我们在最后定义的函数handleConnections 中,使用 TcpListener 实例创建的监听器端口4242:

 

let listener = new TcpListener(IPAddress.Loopback,4242)

[

原文中为 server,就是来自上个版本,未做修改。

另外,本程序与原来的版本相比,作了较大的修改,或者说,是完全重写。

]

 

这个函数是个无限循环,它监听新的客户端连接,并创建新的线程来管理。看下面的代码,一旦有连接,就能检索出这个连接的实例,在新的线程池线程上启动管理它的工作。

 

let! connection =listener.AsyncAcceptTcpClient()

printfn "New Connection"

// use a thread pool thread to handle thenew request

ThreadPool.QueueUserWorkItem(fun _ ->handleClient connection) |> ignore

 

现在,我们知道了服务器是如何工作的,下面要看看客户端了,它在很多方面比服务器简单得多。清单11-2 是客户端的完整代码,注意,要引用Systems.Windows.Forms.dll 才能编译;在清单的后面是相关的代码讨论。

 

清单 11-2 聊天客户端

 

open System

open System.ComponentModel

open System.IO

open System.Net.Sockets

open System.Threading

open System.Windows.Forms

 

let form =

  //create the form

  letform = new Form(Text = "F# Talk Client")

 

  //text box to show the messages received

  letoutput =

    newTextBox(Dock = DockStyle.Fill,

               ReadOnly = true,

               Multiline = true)

  form.Controls.Add(output)

 

  //text box to allow the user to send messages

  letinput = new TextBox(Dock = DockStyle.Bottom, Multiline = true)

  form.Controls.Add(input)

 

  //create a new tcp client to handle the network connections

  lettc = new TcpClient()

  tc.Connect("localhost",4242)

 

  //loop that handles reading from the tcp client

  letload() =

    letrun() =

     let sr = new StreamReader(tc.GetStream())

     while(true) do

       let text = sr.ReadLine()

       if text <> null && text <> "" then

         // we need to invoke back to the "gui thread"

         // to be able to safely interact with the controls

         form.Invoke(new MethodInvoker(fun () ->

           output.AppendText(text + Environment.NewLine)

           output.SelectionStart <- output.Text.Length))

         |> ignore

 

    //create a new thread to run this loop

    lett = new Thread(new ThreadStart(run))

    t.Start()

 

  //start the loop that handles reading from the tcp client

  //when the form has loaded

  form.Load.Add(fun_ -> load())

 

  letsw = new StreamWriter(tc.GetStream())

 

  //handles the key up event - if the user has entered a line

  //of text then send the message to the server

  letkeyUp () =

    if(input.Lines.Length> 1) then

     let text = input.Text

     if (text <> null && text <> "") then

       try

         sw.WriteLine(text)

         sw.Flush()

       with err ->

         MessageBox.Show(sprintf"Server error\n\n%O" err)

         |> ignore

       input.Text <- ""

 

  //wire up the key up event handler

  input.KeyUp.Add(fun_ -> keyUp ())

 

  //when the form closes it‘s necessary to explicitly exit the app

  //as there are other threads running in the back ground

  form.Closing.Add(fun_ ->

    Application.Exit()

    Environment.Exit(0))

 

  //return the form to the top level

  form

 

// show the form and start the apps eventloop

[<STAThread>]

do Application.Run(form)

 

运行前面的代码,产生如图11-2 所示的客户端服务器程序。

 

图 11-2 聊天客户端服务器程序

 

现在我们就来看一下清单11-2 中的客户端是如何工作的。代码的第一部分完成窗体各部分的初始化,这不是我们现在感兴趣的,有关Windows 窗体程序工作原理的详细内容可以回头看第八章。清单11-2 中与TCP/IP 套接字编程相关的第一部分是连接服务器,通过创建TcpClient 类的实例,然后调用它的Connect 方法:

 

let tc = new TcpClient()

tc.Connect("localhost", 4242)

 

在这里,我们指定localhost,即表示本地计算机,端口 4242,与服务器监听的端口相同。在更实际的例子中,可以用服务器的 DNS 名称,或者由让用户指定 DNS 名。因为我们在同一台计算机上运行这个示例程序,localhost 也是不错的选择。

Load 函数负责从服务器读取数据,把它附加到窗体的 Load 事件,是为了保证窗体装载并初始化完成后就能运行,我们需要与窗体的控件进行交互:

 

form.Load.Add(fun _ -> load())

 

[

原文中是temp,有误。

]

 

为了保证及时读取服务器上的所有数据,需要创建一个新的线程去读所有的入站请求;先定义函数run,然后,使用它启动一个新线程:

 

let t = new Thread(new ThreadStart(run))

t.Start()

 

在run 的定义中,先创建StreamReader,从连接中读文本;然后,使用无限循环,这样,保证线程不退出,能够连续从连接中读数据。发现数据之后,要用窗体的Invoke 方法更新窗体,这是因为不能从创建这个窗体之外的线程中更新窗体:

 

form.Invoke(new MethodInvoker(fun () ->

  output.AppendText(text+ Environment.NewLine)

  output.SelectionStart<- output.Text.Length))

 

[

原文中是temp,有误。

]

 

客户端的另外一部分,也是重要功能,是把消息写到服务器,这是在keyUp 函数中完成的,它被附加到输入文本框(input)的KeyUp 事件,这样,在文本框中每按一次键,下面的代码会触发:

 

input.KeyUp.Add(fun _ -> keyUp () )

 

keyUp 函数的实现是非常简单,发现超过一行,表示已按过 Enter 键,就通过网络发送所有可用的文本,并清除文本框。

现在,我们已经知道如何实现客户端和服务器了,再看看有关这个应用程序的一般问题。在清单11-1 和 11-2 中,在每次网络操作后都调用了Flush();否则,要等到流的缓存已满,数据才会通过网络传输,这会导致一个用户必须输入很多消息,才能出现在其他用户的屏幕上。

这种方法也有一些问题,特别是在服务器端。为每个入站的客户端分配一个线程,保证了对每个客户端能有很好的响应;但是,随着客户连接数的增加,对这些线程需要进行上下文切换(context switching,参见第十章“线程、内存、锁定和阻塞”一节)的数量也会增加,这样,服务器的整体性能就会下降。另外,每个客户端都要有它自己的线程,因此,客户端的最大数就受限于进程所能包含的最大线程数。这些问题是可以解决的,但是,通常简单的办法,是使用一些更加抽象的协议,下一节会有讨论。