3.TCP Socket 编程
在 Delphi 中,我们经常使用 Indy 组件进行网络编程。本节我们介绍基于 Tcp 的 Socket 编程,主要使用的组件是 TIdTCPServer 和 TIdTCPClient 组件。
3.1 TIdTCPServer 组件
TIdTCPServer 组件位于 Indy Servers 页中,该组件封装了一个完整的多线程 TCP 服务器。TIdTCPServer 使用一个或者多个线程来接收客户端的连接,并联合 TidThreadMgr 分配专门的线程来处理每一个客户端和服务器的连接。在线程中维持一个激活 ThreadClass 实例的列表。
TIdTCPServer 提供各种选项来进行服务端接收进程的配置,包括:
- DefaultPort
- ListenQueue
- OnListenExeption
- ReuseSocket
- MaxConnections
- MaxConnectionReply
TIdTCPServer 也提供各种属性和方法来控制协议特殊选项,包括:
- Greeting
- ReplyExecptionCode
- ReplyUnknownCommand
TIdTCPServer 组件实现了两套机制来链接线程提供的服务:
1.利用响应事件句柄的方法来处理客户链接,这些事件包括:
- OnConnect
- OnExecute
- OnDisconnect
- OnException
2.使用 TIdCommandHandler 对象来辨认合法的服务命令,提供一些属性和方法来处理参数,执行动作,表述正确或错误的响应。这些属性和方法包括:
- CommandHandlers
- CommandHandlersEnabled
- OnNoCommandHandler
- OnAfterCommandHandler
- OnBeforeCommandHandler
3.1.1 TIdTCPServer 组件的主要属性
- Active
用于设置 TIdTCPServer 组件的状态
- Bindings
服务器分配的 Socket 句柄的容器。为 TCP 服务指定默认端口号,并被 TIdListenerThread 对象用来获取对 Socket 句柄的访问和由 TCP/IP 协议栈提供的底层方法。
- DefaultPort
设置监听新的客户端链接请求的端口号
- Greeting
当客户端连接请求被监听线程接受时,Greeting 属性中包含被发送到客户端的欢迎信息
- ListenQueue
指定监听线程允许的、未处理的最大连接请求数目
- Threads
在监听线程中创建的线程列表
- LocalName
标识用户计算机系统的主机名
- Intercept
指定 Socket 数据处理器
- ThreadMgr
指定服务器使用的线程管理器
3.1.2 TIdTCPServer 组件的方法
- BeginWork
格式:
Procedure BeginWork(AWorkMode: TWorkModel; const Size: Integer);
该过程用于触发 OnBeginWork 事件,同时维护读写堵塞操作的数量,以及初始化读写操作的大小信息。
- DoWork
格式:
Procedure DoWork(AWorkMode: TWorkModel; const ACount: Integer)
该过程用于触发 OnWork 事件,在调用 DoWork 过程之前必须调用 BeginWork 过程,否则该过程不会产生任何效果。
- EndWork
格式:
Procedure EndWork(AWorkMode: TWorkModel);
该过程用于触发 OnEndWork 事件,该过程可以嵌套调用,但是 OnEndWork 事件只在第一次调用时触发。
3.1.3 TIdTCPServer 组件的事件
- OnConnect - 在客户端连接到 TCP 服务器时触发
- OnDisconnect - 在客户端断开与 TCP 服务器连接时触发
- OnExecute - 当客户端执行 TIdPeerThread.Run 方法时触发
- OnStatus - 当前连接的状态发生改变时触发
3.2 TIdTCPClient 组件
TIdTCPClient 组件位于 Indy Clients 组件页,TIdTCPClient 组件封装一个完整的 TCP 客户端程序,其中包括 Socket 支持。TIdTCPClient 组件是许多其他 Indy 客户端组件的基类,如 TIdDayTime、TIdEcho、TIdFinger、TIdFTP、TIdGopher、TIdHTTP、TIdPOP3、TIdQUOTO 等组件都是由它派生出来的。 使用 TIdTCPClient 组件最基本的要设置它的 Host 和 Port 属性,然后调用它的 Connect 方法即可连接服务器。
3.2.1 TIdTCPClient 组件的主要属性
- BoundIP
用来指定客户端连接的本地 IP 地址
- BoundPort
用来指定客户端连接的本地端口,在 Connect 方法中指定绑定的端口号
- Host
标识远程计算机地址,可以是 IP 地址,也可以是计算机名。
- Port
标识远程计算机的端口。
- ReadTimeout
表示读取数据时的连接超时毫秒数。
3.2.2 TIdTCPClient 组件的主要方法
- Connect
格式:
procedure Connect(Const ATimeout:Integer=IdTimeoutDefault);
向服务器请求建立一个连接。
- ConnectAndGetCall
格式:
function ConnectAndGetAll:String;
连接到远程主机并从服务器中读取所有数据。
- Connected
格式:
function Connected(): Boolean;
检查与服务器的连接是否已经建立。
- Disconnect
格式:
procedure Disconnect();
断开与服务器的连接
- ReadBuffer
格式:
procedure ReadBuffer(var ABuffer; const AByteCount: Longint);
从 Indy 的接收缓冲区中读取数据。
- SendBuffer
格式:
procedure SendBuffer(const AHost: string, const APort: TIdPort, const ABuffer: TIdBytes)
向服务器发送数据,数据被写往 Indy 缓冲区或者直接发送给服务器。
3.2.3 TIdTCPClient 组件的事件
- OnStatus - 在当前连接状态改变时触发
3.3 Indy TCP Socket 编程示例
本节采用 Indy 10 提供的组件 TIdTCPServer 和 TIdTCPClient 来演示 TCP Socket 编程,对于 TIdTCPServer 组件,其本身就是多线程的,所以,只需要实现相应的事件就可以了,而 TIdTCPClient 组件获取从服务器上下行的数据时,需要通过线程来实现。
对于 TCP Socket 编程,一般情况下是服务端和客户端分别编写的,所以,我们本节的示例也采用服务端和客户端分别编写应用程序的方法来说明,很多书上的例子将服务端和客户端写到同一个应用程序中,这样不是很适用的。
示例:客户端定时实时检测所在机器的屏幕分辨率上行到服务端,服务端接收到数据后,根据其屏幕分辨率随机生成一个坐标并下发给客户端,客户端将应用程序的窗体位置放置到相应的坐标上。
根据示例的需求,我们首先需要确定服务端和客户端相互交换数据的结构,由于都是采用同一种语言来编写程序,所以我们可以使用 Pascal 的 Record 类型来传递数据,设计如下:
TCommBlock = Record
// 客户端上传: W-屏幕宽度, H-屏幕高度, E-结束;
// 服务端下发: X-水平坐标, Y-垂直坐标, E-结束;
Part: String[1];
Desc: String[16]; // 描述
Value: Integer; // 数据值
end;
其中:Part - 表示传输的数据代表的含义;Desc - 为文字描述;Value - 为数据。
3.3.1 服务端
服务端界面设计如下图所示:
由于界面比较简单,各个组件的属性基本不需要设置,只需要把对应组件的 Name 属性命名为有意义的名称即可,所以在这里我们就不再介绍组件的属性设置了。
首先,是启动按钮的功能,实现打开对应的端口等待客户端连接,主要是设置 IdTCPServer 组件的端口,很简单,代码如下:
procedure TForm1.StartButtonClick(Sender: TObject);
begin
// 启动
IdTCPServer.DefaultPort:=PortSpinEdit.Value;
IdTCPServer.StartListening;
IdTCPServer.Active:=True;
SetupUI;
end;
设置 IdTCPServer 的 DefaultPort 属性为用户在编辑框中输入的值,然后调用 StartListening 启动监听,并设置其 Active 属性为 True。
当客户端连接时,IdTCPServer 组件会触发如下事件:
- OnConnect
- OnExecute
- OnDisconnect
- OnException
接下来我们来实现 OnConnect 和 OnDisconnect 事件,这两个事件是当客户端连接和断开连接时触发的,我们这里简单地在日志框中记录一下客户端的 IP 地址。
procedure TForm1.IdTCPServerConnect(AContext: TIdContext);
var
Info: String;
begin
// 连接
Info := AContext.Connection.Socket.Binding.PeerIP + ' ' + inttostr(AContext.Connection.Socket.Binding.PeerPort) + ' has created connection.';
AppendLog(Info);
end;
procedure TForm1.IdTCPServerDisconnect(AContext: TIdContext);
var
Info: String;
begin
// 断开连接
Info := AContext.Connection.Socket.Binding.PeerIP + ' ' + inttostr(AContext.Connection.Socket.Binding.PeerPort) + ' has closed connection.';
AppendLog(Info);
end;
上面的代码中,AppendLog 是一个自定义的过程,用于在日志框中追加文本。
最主要需要实现的功能就是 OnExecute 事件的编码,按照示例的需求,我们实现如下:
procedure TForm1.IdTCPServerExecute(AContext: TIdContext);
var
CommBlock: TCommBlock;
bytes: TIdBytes;
W, H, X, Y: Integer;
Host: String;
begin
// 执行
if AContext.Connection.Connected then
begin
try
Host:=AContext.Connection.Socket.Binding.PeerIP;
AContext.Connection.IOHandler.ReadBytes(bytes, SizeOf(CommBlock), False);
BytesToRaw(bytes, CommBlock, SizeOf(CommBlock));
AppendLog(Host + ' - ' + CommBlock.Desc + ': ' + inttostr(CommBlock.Value));
if CommBlock.Part = 'W' then W:=CommBlock.Value;
if CommBlock.Part = 'H' then H:=CommBlock.Value;
if CommBlock.Part = 'E' then
begin
Randomize;
X:=Random(W);
Y:=Random(H);
// 发送水平坐标
CommBlock.Part:='X';
CommBlock.Desc:='水平坐标';
CommBlock.Value:=X;
AppendLog(Host + ' - ' + CommBlock.Desc + ': ' + inttostr(CommBlock.Value));
AContext.Connection.IOHandler.Write(RawToBytes(CommBlock, SizeOf(CommBlock)));
// 发送垂直坐标
CommBlock.Part:='Y';
CommBlock.Desc:='垂直坐标';
CommBlock.Value:=Y;
AppendLog(Host + ' - ' + CommBlock.Desc + ': ' + inttostr(CommBlock.Value));
AContext.Connection.IOHandler.Write(RawToBytes(CommBlock, SizeOf(CommBlock)));
// 发送结束标志
CommBlock.Part:='E';
CommBlock.Desc:='结束';
CommBlock.Value:=0;
AppendLog(Host + ' - ' + CommBlock.Desc + ': ' + inttostr(CommBlock.Value));
AContext.Connection.IOHandler.Write(RawToBytes(CommBlock, SizeOf(CommBlock)));
end;
except
ON E: Exception do
AppendLog('ERROR: ' + Host + ' - ' + E.Message);
end;
end;
end;
在 Indy 10 中,读取和写入数据与 Indy 9 中有一些不同的地方,我们这里主要说明 Indy 10 中的方法,在 OnExecute 事件中,传递的参数是 TIdContext 的上下文类,所有操作都使用该类的实例来完成。
由于我们采用 Record 来传输数据,所以必须读取二进制字节。
原型格式:
procedure ReadBytes(
var VBuffer: TIdBytes;
AByteCount: Integer;
AAppend: boolean = true
);
其中,参数 AAppend 表示读取完后的行为,如设置为 True,则表示后面传输过来的数据可以追加到缓冲区,所以,一般需要将其设置为 False。
读取后的字节数组需要转换为 Record 类型,可以使用 BytesToRaw 过程来实现。
原型格式:
procedure BytesToRaw(
const AValue: TIdBytes;
var VBuffer;
const ASize: Integer
);
该过程位于 IdGlobal 单元中,请务必 uses。
而发送数据则使用 Write 方法,在 Write 之前同样要使用 RawToBytes 将 Record 数据转换为字节数组。
原型格式:
function RawToBytes(
const AValue;
const ASize: Integer
): TIdBytes;
Write 方法原型格式:
procedure Write(
AValue: Cardinal;
AConvert: Boolean = True
); overload;
该过程具有不同类型重载。
3.3.2 客户端
客户端界面设计如下图所示:
对于客户端来说,主要是 IdTCPClient 组件的使用,该组件没有提供类似 IdTCPServer 的 OnExecute 事件,所以需要在线程中读取服务端下发的数据。
首先,通过单击“连接”按钮来建立与服务端的连接,代码如下:
procedure TForm1.StartButtonClick(Sender: TObject);
begin
// 连接
if HostEdit.Text = '' then
begin
Application.MessageBox('请设置地址!', '提示');
Exit;
end;
IdTCPClient.Host:=HostEdit.Text;
IdTCPClient.Port:=PortSpinEdit.Value;
IdTCPClient.Connect;
if IdTCPClient.Connected then
begin
Application.MessageBox('连接成功!', '提示');
AppendLog('连接成功!');
// 启动读取线程
ClientHandleThread:=TClientHandleThread.Create(True);
ClientHandleThread.FreeOnTerminate:=True;
ClientHandleThread.Start;
end
else
begin
Application.MessageBox('连接失败!', '提示');
AppendLog('连接失败!');
end;
end;
连接服务端主要通过设置 IdTCPClient 组件的 Host 和 Port 属性,然后调用 Connect 过程来建立连接,成功建立连接后启动数据读取线程,该线程我们在后面逐步介绍,先实现上行数据,数据上行采用定时器来每隔一段时间上行实时数据,就相当于物联网设备一样,这里我们采用 TTimer 组件来实现,代码如下:
procedure TForm1.TimerTimer(Sender: TObject);
var
CommBlock: TCommBlock;
w, h: Integer;
begin
// 定时器
w:=Screen.Width;
h:=Screen.Height;
AppendLog('分辨率: ' + inttostr(w) + ' * ' + inttostr(h));
if IdTCPClient.Connected then
try
// 发送宽度
CommBlock.Part:='W';
CommBlock.Desc:='宽度';
CommBlock.Value:=w;
IdTCPClient.IOHandler.Write(RawToBytes(CommBlock, SizeOf(CommBlock)));
// 发送高度
CommBlock.Part:='H';
CommBlock.Desc:='高度';
CommBlock.Value:=h;
IdTCPClient.IOHandler.Write(RawToBytes(CommBlock, SizeOf(CommBlock)));
// 发送结束标志
CommBlock.Part:='E';
CommBlock.Desc:='结束';
CommBlock.Value:=0;
IdTCPClient.IOHandler.Write(RawToBytes(CommBlock, SizeOf(CommBlock)));
except
ON E: Exception do
begin
AppendLog('ERROR: '+E.Message);
end;
end;
end;
写入数据同样采用 Write 方法。这里不再赘述。下面我们来看看读取下行数据的线程。首先需要定义线程类:
TClientHandleThread = class(TTHread)
private
Logs: String;
procedure HandleLog;
procedure HandlePos;
protected
procedure Execute; Override;
end;
该类继承自 TTHread ,属性 Logs 用于当接收到下行数据时在日志框中输出的信息,为什么不直接写入到日志框呢,原因就是在子线程中不能直接操作主线程界面,所以需要先记录下来;方法 HandleLog 是将日志写入到日志框,方法 HandlePos 是将窗体的位置按照下行的数据放置。此外,必须重载 Execute 方法来读取数据。
HandleLog 方法实现:
procedure TClientHandleThread.HandleLog;
begin
Form1.AppendLog(Logs);
Logs:='';
end;
HandlePos 方法实现:
procedure TClientHandleThread.HandlePos;
begin
Form1.Left:=X;
Form1.Top:=Y;
end;
这里的 X 和 Y 就是由服务器下行的数据。
Execute 方法实现:
procedure TClientHandleThread.Execute;
var
CommBlock: TCommBlock;
bytes: TIdBytes;
begin
while not Self.Terminated do
begin
if not Form1.IdTCPClient.Connected then
Self.Terminate
else
try
Form1.IdTCPClient.IOHandler.ReadBytes(bytes, SizeOf(CommBlock), False);
BytesToRaw(bytes, CommBlock, SizeOf(CommBlock));
Logs := Logs + CommBlock.Desc + ': ' + inttostr(CommBlock.Value);
Synchronize(@HandleLog);
if CommBlock.Part = 'X' then X:=CommBlock.Value;
if CommBlock.Part = 'Y' then Y:=CommBlock.Value;
if CommBlock.Part = 'E' then Synchronize(@HandlePos);
except
ON E: Exception do
begin
Logs := Logs + 'ERROR: ' + E.Message;
Synchronize(@HandleLog);
end;
end;
end;
end;
线程执行 Start 方法后开始执行该方法,在该方法中采用 while 循环不停地从 IdTCPClient 中读取数据,当读取到数据后,通过 BytesToRaw 将数据转换为 Record 类型,然后赋值 X 、Y ,当为 E 时调用 HandlePos,调用 HandleLog 和 HandlePos 时,使用 Synchronize 将线程同步到主线程去执行,这样就可以更新主线程中的 VCL 组件的数据。
3.3.3 开发过程中常见问题
Indy 组件已经非常成熟了,尤其是服务端的组件,让服务端开发变得非常简单,如果我们在开发中需要处理的业务逻辑非常复杂,也可以在其 OnExecute 事件中启动一个新的线程来处理,当然,随着物联网、大数据技术的成熟,我们做服务端数据接口时一般不会去处理复杂的业务逻辑,大部分时候都是事先将需要处理的数据处理好,当需要下行的时候直接打包数据包来下行,对于上行数据也是同样的,数据接口服务端一般不会去解析处理所有数据,而是先把数据存放到缓冲区,由专门的进程来负责对缓冲区的数据进行处理。
虽然 Indy 组件发展成熟,但在使用 Indy 组件开发的过程中仍然会遇到很多难以解决的问题。目前,大部分开发人员在开发中会遇到的一个问题就是:当客户端连接到服务端时,我们单击服务端的关闭按钮,会出现无法正常关闭窗体的情况,网上有很多解决方案,但都不是最好的解决方案,实际上并不是因为有客户端连接而造成的,主要原因是因为我们正常情况下会去设置 Active 属性为 False,这一步确实会出现无法正常关闭窗体,所以我们在关闭窗体时不要设置 Active 属性为 False 就可以了,直接关闭窗体或者在关闭窗体时调用 StopListening 方法将监听停止即可,根本没有必要对每一个连接进行关闭。如:
procedure TForm1.FormClose(Sender: TObject; var CloseAction: TCloseAction);
begin
// 关闭窗体
IdTCPServer.StopListening;
end;
在当今物联网发展迅猛的时代,我们大部分的服务端开发会考虑使用 Linux 服务器,此时我们就可以考虑使用 Lazarus 或者 CodeTyphon 来进行开发。
本文暂时没有评论,来添加一个吧(●'◡'●)