本节书摘来自异步社区《Android智能穿戴设备开发指南》一书中的第6章,第6.2节使用TCP协议传输数据,作者 王长青,更多章节内容可以访问云栖社区“异步社区”公众号查看
6.2 使用TCP协议传输数据
Android智能穿戴设备开发指南
TCP/IP通信协议是一种可靠的网络协议,能够在通信的两端各建立一个Socket,从而在通信的两端之间形成网络虚拟链路。一旦建立了虚拟的网络链路,两端的程序就可以通过虚拟链路进行通信。Java语言对TCP网络通信提供了良好的封装,通过Socket对象代表两端的通信端口,并通过Socket产生的IO流进行网络通信。本章将详细讲解Java应用中TCP编程的基本知识。
6.2.1 使用ServletSocket
在Java程序中,使用类ServerSocket接受其他通信实体的连接请求。对象ServerSocket的功能是监听来自客户端的Socket连接,如果没有连接则会一直处于等待状态。在类ServerSocket中包含了如下监听客户端连接请求的方法。
① Socket accept()。如果接收到一个客户端Socket的连接请求,该方法将返回一个与客户端Socket对应的Socket,否则该方法将一直处于等待状态,线程也被阻塞。
② ServerSocket(int port)。用指定的端口PORT创建一个ServerSocket,该端口应该有一个有效的端口整数值:0~65 535。
③ ServerSocket(int port,int backlog)。增加一个用来改变连接队列长度的参数backlog。
④ ServerSocket(int port,int backlog,InetAddress localAddr)。在机器存在多个 IP地址的情况下,允许通过localAddr这个参数来指定将ServerSocket绑定到指定的IP地址。
当使用ServerSocket后,需要使用ServerSocket中的方法close()关闭该ServerSocket。在通常情况下,因为服务器不会只接受一个客户端请求,而是会不断地接受来自客户端的所有请求,所以可以通过循环来不断地调用ServerSocket中的方法accept(),见下面的代码。
//创建一个ServerSocket,用于监听客户端Socket的连接请求
ServerSocket ss = new ServerSocket(30000);
//采用循环不断接收来自客户端的请求
while (true)
{
//每当接收客户端Socket的请求,服务器端也对应产生一个Socket
Socket s = ss.accept();
//下面就可以使用Socket进行通信了
...
}
在上述代码中,创建的ServerSocket没有指定IP地址,该ServerSocket会绑定到本机默认的IP地址。在代码中使用30000作为该ServerSocket的端口号,通常推荐使用10000以上的端口,主要是为了避免与其他应用程序的通用端口冲突。
6.2.2 使用Socket
在客户端可以使用Socket的构造器实现和指定服务器的连接,在Socket中可以使用如下两个构造器。
① Socket(InetAddress/String remoteAddress, int port)。创建连接到指定远程主机、远程端口的Socket,该构造器没有指定本地地址、本地端口,默认使用本地主机的默认IP地址,默认使用系统动态指定的IP地址。
② Socket(InetAddress/String remoteAddress, int port, InetAddress localAddr, int localPort)。
创建连接到指定远程主机、远程端口的Socket,并指定本地IP地址和本地端口号,适用于本地主机有多个IP地址的情形。
在使用上述构造器指定远程主机时,既可使用InetAddress来指定,也可以使用String对象指定,在Java中通常使用String对象指定远程IP,如192.168.2.23。当本地主机只有一个IP地址时,建议使用第一个方法,因为这样更简单。例如下面的代码。
//创建连接到本机、30000端口的Socket
Socket s = new Socket("127.0.0.1" , 30000);
当程序执行上述代码后会连接到指定服务器,让服务器端的ServerSocket的方法accept()向下执行,于是服务器端和客户端就产生一对互相连接的Socket。上述代码连接到“远程主机”的IP地址是127.0.0.1,此IP地址总是代表本级的IP地址。因为笔者示例程序的服务器端、客户端都是在本机运行,所以Socket连接到远程主机的IP地址使用127.0.0.1。
当客户端、服务器端产生对应的Socket之后,程序无需再区分服务器端和客户端,而是通过各自的Socket进行通信。在Socket中提供如下两个方法获取输入流和输出流。
① InputStream getInputStream()。返回该Socket对象对应的输入流,让程序通过该输入流从Socket中取出数据。
② OutputStream getOutputStream()。返回该Socket对象对应的输出流,让程序通过该输出流向Socket中输出数据。
例如,下面是一段TCP协议的服务器端程序。
源码路径:daima\6\tcpudp\src\Server.java。
import java.net.*;
import java.io.*;
public class Server
{
public static void main(String[] args)
throws IOException
{
//创建一个ServerSocket,用于监听客户端Socket的连接请求
ServerSocket ss = new ServerSocket(30000);
//采用循环不断接收来自客户端的请求
while (true)
{
//每当接收到客户端Socket的请求,服务器端也对应产生一个Socket
Socket s = ss.accept();
//将Socket对应的输出流包装成PrintStream
PrintStream ps = new PrintStream(s.getOutputStream());
//进行普通I/O操作
ps.println("圣诞快乐!");
//关闭输出流,关闭Socket
ps.close();
s.close();
}
}
}
通过上述代码建立了ServerSocket监听,并且使用Socket获取了输出流,所以执行后不会显示任何信息。
而下面是一段TCP协议的客户端程序。
源码路径:daima\6\tcpudp\src\Client.java。
import java.net.*;
import java.io.*;
public class Client
{
public static void main(String[] args)
throws IOException
{
Socket socket = new Socket("127.0.0.1" , 30000);
//将Socket对应的输入流包装成BufferedReader
BufferedReader br = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
//进行普通I/O操作
String line = br.readLine();
System.out.println("来自服务器的数据:" + line);
//关闭输入流、socket
br.close();
socket.close();
}
}
上述代码使用Socket建立了与指定IP、指定端口的连接,并使用Socket获取输入流读取数据。执行后的效果如图6-1所示。
由此可见,一旦使用ServerSocket和Socket建立网络连接之后,程序通过网络通信与普通I/O并没有太大的区别。如果先运行上面程序中的Server类,将看到服务器一直处于等待状态,因为服务器使用了死循环来接收来自客户端的请求;再运行Client类,将可看到程序输出“来自服务器的数据:圣诞快乐!”这表明客户端和服务器端通信成功。上述代码为了突出通过ServerSocket和Socket建立连接,并通过底层I/O流进行通信的主题,程序没有进行异常处理,也没有使用finally块来关闭资源。
6.2.3 TCP中的多线程
在6.2.2节的实例中,Server和Client只是进行了简单的通信操作,当服务器接收到客户端连接之后,向客户端输出一个字符串,而客户端也只是读取服务器的字符串后就退出了。在实际应用中,客户端可能需要和服务器保持长时间的通信,即服务器需要不断地读取客户端的数据,并向客户端写入数据,客户端也需要不断地读取服务器的数据,并向服务器写入数据。
当使用readLine()方法读取数据时,如果在该方法成功返回之前线程被阻塞,则程序无法继续执行。所以此服务器很有必要为每个Socket单独启动一条线程,每条线程负责与一个客户端进行通信。另外,因为客户端读取服务器数据的线程同样会被阻塞,所以系统应该单独启动一条线程,该线程专门负责读取服务器的数据。
假设要开发一个聊天室程序,在服务器端应该包含多条线程,其中每个Socket对应一条线程,该线程负责读取Socket对应输入流的数据(从客户端发送过来的数据),并将读到的数据向每个Socket输出流发送一遍(将一个客户端发送的数据“广播”给其他客户端),因此,需要在服务器端使用List来保存所有的Socket。在具体实现时,为服务器提供了如下两个类。
① 创建ServerSocket监听的主类。
② 处理每个Socket通信的线程类。
接下来介绍具体实现流程,首先看一段代码。
源码路径:daima\6\tcpudp\src\liao\server\IServer.java。
package liao.server;
import java.net.*;
import java.io.*;
import java.util.*;
public class IServer
{
//定义保存所有Socket的ArrayList
public static ArrayList<Socket> socketList = new ArrayList<Socket>();
public static void main(String[] args)
throws IOException
{
ServerSocket ss = new ServerSocket(30000);
while(true)
{
//此行代码会阻塞,将一直等待别人的连接
Socket s = ss.accept();
socketList.add(s);
//每当客户端连接后启动一条ServerThread线程为该客户端服务
new Thread(new Serverxian(s)).start();
}
}
}
在上述代码中,服务器端只负责接收客户端Socket的连接请求,每当客户端Socket连接到该ServerSocket之后,程序将对应Socket加入 socketList集合中保存,并为该Socket启动一条线程,该线程负责处理该Socket所有的通信任务。
然后看服务器端线程类文件的主要代码。
源码路径:daima\6\tcpudp\src\liao\server\Serverxian.java。
//负责处理每个线程通信的线程类
public class Serverxian implements Runnable
{
//定义当前线程所处理的Socket
Socket s = null;
//该线程所处理的Socket所对应的输入流
BufferedReader br = null;
public Serverxian(Socket s)
throws IOException
{
this.s = s;
//初始化该Socket对应的输入流
br = new BufferedReader(new InputStreamReader(s.getInputStream()));
}
public void run()
{
try
{
String content = null;
//采用循环不断从Socket中读取客户端发送过来的数据
while ((content = readFromClient()) != null)
{
//遍历socketList中的每个Socket,将读到的内容向每个Socket发送一次
for (Socket s : IServer.socketList)
{
PrintStream ps = new PrintStream(s.getOutputStream());
ps.println(content);
}
}
}
catch (IOException e)
{
//e.printStackTrace();
}
}
//定义读取客户端数据的方法
private String readFromClient()
{
try
{
return br.readLine();
}
//如果捕捉到异常,表明该Socket对应的客户端已经关闭
catch (IOException e)
{
//删除该Socket。
IServer.socketList.remove(s);
}
return null;
}
}
在上述代码中,服务器端线程类会不断读取客户端数据,在获取时使用方法readFromClient()来读取客户端数据。如果读取数据过程中捕获到 IOException异常,则说明此Socket对应的客户端Socket出现了问题,程序就会将此Socket从socketList中删除。当服务器线程读到客户端数据之后会遍历整个socketList集合,并将该数据向socketList集合中的每个Socket发送一次,该服务器线程将把从Socket中读到的数据向socketList中的每个Socket转发一次。
接下来开始客户端的编码工作,在本应用的每个客户端应该包含如下2条线程。
① 第一条:功能是读取用户的键盘输入,并将用户输入的数据写入Socket对应的输出流。
② 第二条:功能是读取Socket对应输入流中的数据(从服务器发送过来的数据),并将这些数据打印输出。其中负责读取用户键盘输入的线程由Myclient负责,也就是由程序的主线程负责。
客户端主程序文件的主要代码如下。
源码路径:daima\6\tcpudp\src\liao\server\Iclient.java。
public class IClient
{
public static void main(String[] args)
throws IOException
{
Socket s = s = new Socket("127.0.0.1" , 30000);
//客户端启动ClientThread线程不断读取来自服务器的数据
new Thread(new ClientThread(s)).start();
//获取该Socket对应的输出流
PrintStream ps = new PrintStream(s.getOutputStream());
String line = null;
//不断读取键盘输入
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
while ((line = br.readLine()) != null)
{
//将用户的键盘输入内容写入Socket对应的输出流
ps.println(line);
}
}
}
在上述代码中,当线程读到用户键盘输入的内容后,会将用户键盘输入的内容写入该Socket对应的输出流。当主线程使用Socket连接到服务器之后,会启动ClientThread来处理该线程的Socket通信。
最后编写客户端的线程处理文件,此线程负责读取Socket输入流中的内容,并将这些内容在控制台打印出来。具体代码(见源程序daima6tcpudpsrcliaoserverClientxian.java)如下。
public class Clientxian implements Runnable
{
//该线程负责处理的Socket
private Socket s;
//该线程所处理的Socket对应的输入流
BufferedReader br = null;
public Clientxian(Socket s)
throws IOException
{
this.s = s;
br = new BufferedReader(
new InputStreamReader(s.getInputStream()));
}
public void run()
{
try
{
String content = null;
//不断读取Socket输入流中的内容,并将这些内容打印输出
while ((content = br.readLine()) != null)
{
System.out.println(content);
}
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
上述代码能够不断获取Socket输入流中的内容,当获取Socket输入流中的内容后,直接将这些内容打印在控制台。先运行上面程序中的类IServer,该类运行后作为本应用的服务器,不会看到任何输出。接着可以运行多个IClient——相当于启动多个聊天室客户端登录该服务器,此时可以看到,在任何一个客户端通过键盘输入一些内容后,按回车键,所有客户端(包括自己)都会在控制台收到它刚刚输入的内容,这就简单地实现了一个聊天室的功能。
6.2.4 实现非阻塞Socket通信
在Java应用程序中,可以使用NIO API来开发高性能网络服务器。当程序执行输入、输出操作后,在这些操作返回之前会一直阻塞该线程,服务器必须为每个客户端都提供一条独立的线程进行处理。这说明前面的程序是基于阻塞式API的,当服务器需要同时处理大量客户端时,这种做法会降低性能。
在Java应用程序中可以用NIO API让服务器使用一个或有限几个线程来同时处理连接到服务器上的所有客户端。在Java的NIO中,为非阻塞式的Socket通信提供了下面的特殊类。
① Selector。它是SelectableChannel对象的多路复用器,所有希望采用非阻塞方式进行通信的Channel都应该注册到 Selector对象。可通过调用此类的静态open()方法来创建Selector实例,该方法将使用系统默认的Selector来返回新的Selector。Selector可以同时监控多个SelectableChannel的I/O状况,是非阻塞I/O的核心。一个Selector实例有如下3个SelectionKey的集合。
a.所有SelectionKey集合。它代表了注册在该Selector上的Channel,这个集合可以通过keys()方法返回。
b.被选择的SelectionKey集合。它代表了所有可通过select()方法监测到、需要进行I/O处理的Channel,这个集合可以通过selectedKeys()返回。
c.被取消的SelectionKey集合。它代表了所有被取消注册关系的Channel,在下一次执行select()方法时,这些Channel对应的SelectionKey会被彻底删除,程序通常无须直接访问该集合。
除此之外,Selector还提供了如下和select()相关的方法。
a.int select()。监控所有注册的Channel,当它们中间有需要处理的I/O操作时,该方法返回,并将对应的SelectionKey加入被选择的SelectionKey集合中,该方法返回这些Channel的数量。
b.int select(long timeout)。可以设置超时时长的select()操作。
c.int selectNow()。执行一个立即返回的select()操作,相对于无参数的select()方法而言,该方法不会阻塞线程。
Selector wakeup()。使一个还未返回的select()方法立刻返回。
② SelectableChannel。它代表可以支持非阻塞I/O操作的Channel对象,可以将其注册到Selector上,这种注册的关系由SelectionKey实例表示。在Selector对象中,可以使用select()方法设置允许应用程序同时监控多个I/O Channel。Java程序可调用SelectableChannel中的register()方法将其注册到指定Selector上,当该Selector上某些SelectableChannel上有需要处理的I/O操作时,程序可以调用Selector实例的select()方法获取它们的数量,并通过selectedKeys()方法返回它们对应的SelectKey集合。这个集合的作用巨大,因为通过该集合就可以获取所有需要处理I/O操作的SelectableChannel集。
对象SelectableChannel支持阻塞和非阻塞两种模式,其中所有Channel默认都是阻塞模式,我们必须使用非阻塞式模式才可以利用非阻塞I/O操作。
在SelectableChannel中提供了如下两个方法来设置和返回该Channel的模式状态。
a.SelectableChannel configureBlocking(boolean block)。设置是否采用阻塞模式。
b.boolean isBlocking()。返回该Channel是否是阻塞模式。
不同的SelectableChannel所支持的操作不一样,如ServerSocketChannel代表一个ServerSocket,它就只支持OP_ACCEPT操作。在SelectableChannel中提供了如下方法来返回它支持的所有操作。
int validOps():返回一个bit mask,表示这个Channel上支持的I/O操作。
除此之外,SelectableChannel还提供了如下方法获取它的注册状态。
① boolean isRegistered()。返回该Channel是否已注册在一个或多个Selector上。
② SelectionKey keyFor(Selector sel)。返回该Channel和sel Selector之间的注册关系,如果不存在注册关系,则返回null。
③ SelectionKey。该对象代表SelectableChannel和Selector之间的注册关系。
④ ServerSocketChannel。支持非阻塞操作,对应于java.net.ServerSocket这个类,提供了TCP协议I/O接口,只支持OP_ACCEPT操作。该类也提供了accept()方法,功能相当于ServerSocket提供的accept()方法。
⑤ SocketChannel。支持非阻塞操作,对应于java.net.Socket这个类,提供了TCP协议I/O接口,支持OP_CONNECT、OP_READ和OP_WRITE操作。这个类还实现了ByteChannel接口、ScatteringByteChannel接口和GatheringByteChannel接口,所以可以直接通过SocketChannel来读写ByteBuffer对象。
服务器上所有Channel都需要向Selector注册,包括ServerSocketChannel和SocketChannel。该Selector则负责监视这些Socket的I/O状态,当其中任意一个或多个Channel具有可用的I/O操作时,该Selector的select()方法将会返回大于0的整数,该整数值就表示该Selector上有多少个Channel具有可用的I/O操作,并提供了selectedKeys()方法来返回这些Channel对应的SelectionKey集合。正是通过Selector才使得服务器端只需要不断地调用Selector实例的select()方法,这样就可以知道当前所有Channel是否有需要处理的I/O操作。当Selector上注册的所有Channel都没有需要处理的I/O操作时,将会阻塞select()方法,此时调用该方法的线程被阻塞。
我们继续以聊天室为例,讲解非阻塞Socket通信在Java应用项目中的实现过程。我们的目标是,在服务器端使用循环不断获取Selector的select()方法返回值,当该返回值大于0时就处理该Selector上被选择SelectionKey所对应的Channel。在具体实现时,服务器端使用ServerSocketChannel来监听客户端的连接请求,程序先调用它的socket()方法获得关联ServerSocket对象,再用该ServerSocket对象绑定到来指定监听IP和端口。最后在服务器端调用Selector的select()方法来监听所有Channel上的I/O操作。
接下来开始具体编码,其中服务器端的主要代码如下。
源码路径:daima\6\tcpudp\src\feizu\feizuServer.java。
public class feizuServer
{
//用于检测所有Channel状态的Selector
private Selector selector = null;
//定义实现编码、解码的字符集对象
private Charset charset = Charset.forName("UTF-8");
public void init()throws IOException
{
selector = Selector.open();
//通过open方法来打开一个未绑定的ServerSocketChannel实例
ServerSocketChannel server = ServerSocketChannel.open();
InetSocketAddress isa = new InetSocketAddress(
"127.0.0.1", 30000);
//将该ServerSocketChannel绑定到指定IP地址
server.socket().bind(isa);
//设置ServerSocket以非阻塞方式工作
server.configureBlocking(false);
//将server注册到指定Selector对象
server.register(selector, SelectionKey.OP_ACCEPT);
while (selector.select() > 0)
{
//依次处理selector上每个已选择的SelectionKey
for (SelectionKey sk : selector.selectedKeys())
{
//从selector上的已选择Key集中删除正在处理的SelectionKey
selector.selectedKeys().remove(sk);
//如果sk对应的通道包含客户端的连接请求
if (sk.isAcceptable())
{
//调用accept方法接收连接,产生服务器端对应的SocketChannel
SocketChannel sc = server.accept();
//设置采用非阻塞模式
sc.configureBlocking(false);
//将该SocketChannel也注册到selector
sc.register(selector, SelectionKey.OP_READ);
//将sk对应的Channel设置成准备接收其他请求
sk.interestOps(SelectionKey.OP_ACCEPT);
}
//如果sk对应的通道有数据需要读取
if (sk.isReadable())
{
//获取该SelectionKey对应的Channel,该Channel中有可读的数据
SocketChannel sc = (SocketChannel)sk.channel();
//定义准备执行读取数据的ByteBuffer
ByteBuffer buff = ByteBuffer.allocate(1024);
String content = "";
//开始读取数据
try
{
while(sc.read(buff) > 0)
{
buff.flip();
content += charset.decode(buff);
}
//打印从该sk对应的Channel里读取到的数据
System.out.println("=====" + content);
//将sk对应的Channel设置成准备下一次读取
sk.interestOps(SelectionKey.OP_READ);
}
//如果捕捉到该sk对应的Channel出现了异常,即表明该Channel
//对应的Client出现了问题,所以从Selector中取消sk的注册
catch (IOException ex)
{
//从Selector中删除指定的SelectionKey
sk.cancel();
if (sk.channel() != null)
{
sk.channel().close();
}
}
//如果content的长度大于0,则聊天信息不为空
if (content.length() > 0)
{
//遍历该selector里注册的所有SelectKey
for (SelectionKey key : selector.keys())
{
//获取该key对应的Channel
Channel targetChannel = key.channel();
//如果该channel是SocketChannel对象
if (targetChannel instanceof SocketChannel)
{
//将读到的内容写入该Channel
SocketChannel dest = (SocketChannel)targetChannel;
dest.write(charset.encode(content));
}
}
}
}
}
}
}
public static void main(String[] args)
throws IOException
{
new feizuServer().init();
}
}
通过上述代码,在启动时马上建立一个可监听连接请求的ServerSocketChannel,并将该Channel注册到指定的Selector,接着程序直接采用循环不断监控Selector对象的select()方法返回值,当该返回值大于0时处理该Selector上所有被选择的 SelectionKey。在处理指定SelectionKey之后立即从该Selector中的被选择的SelectionKey集合中删除该SelectionKey。服务器端的Selector仅需要监听连接和读数据这两种操作,在处理连接操作时只需将接受连接后产生的SocketChannel注册到指定Selector对象即可。当处理读数据操作后,系统先从该Socket中读取数据,再将数据写入Selector上注册的所有Channel。
接下来开始编写客户端的代码,本应用的客户端程序需要如下两个线程。
① 负责读取用户的键盘输入,并将输入的内容写入SocketChannel。
② 不断查询Selector对象的select()方法的返回值。
客户端的主要代码如下。
源码路径:daima\6\tcpudp\src\feizu\feizuClient.java。
public class feizuClient{
//定义检测SocketChannel的Selector对象
private Selector selector = null;
//定义处理编码和解码的字符集
private Charset charset = Charset.forName("UTF-8");
//客户端SocketChannel
private SocketChannel sc = null;
public void init()throws IOException
{
selector = Selector.open();
InetSocketAddress isa = new InetSocketAddress("127.0.0.1", 30000);
//调用open静态方法创建连接到指定主机的SocketChannel
sc = SocketChannel.open(isa);
//设置该sc以非阻塞方式工作
sc.configureBlocking(false);
//将SocketChannel对象注册到指定Selector
sc.register(selector, SelectionKey.OP_READ);
//启动读取服务器端数据的线程
new ClientThread().start();
//创建键盘输入流
Scanner scan = new Scanner(System.in);
while (scan.hasNextLine())
{
//读取键盘输入
String line = scan.nextLine();
//将键盘输入的内容输出到SocketChannel
sc.write(charset.encode(line));
}
}
//定义读取服务器数据的线程
private class ClientThread extends Thread
{
public void run()
{
try
{
while (selector.select() > 0)
{
//遍历每个有可用I/O操作Channel对应的SelectionKey
for (SelectionKey sk : selector.selectedKeys())
{
//删除正在处理的SelectionKey
selector.selectedKeys().remove(sk);
//如果该SelectionKey对应的Channel中有可读的数据
if (sk.isReadable())
{
//使用NIO读取Channel中的数据
SocketChannel sc = (SocketChannel)sk.channel();
ByteBuffer buff = ByteBuffer.allocate(1024);
String content = "";
while(sc.read(buff) > 0)
{
sc.read(buff);
buff.flip();
content += charset.decode(buff);
}
//打印输出读取的内容
System.out.println("聊天信息:" + content);
//为下一次读取做准备
sk.interestOps(SelectionKey.OP_READ);
}
}
}
}
catch (IOException ex)
{
ex.printStackTrace();
}
}
}
public static void main(String[] args)
throws IOException
{
new feizuClient().init();
}
上述客户端代码只有一条SocketChannel,当此SocketChannel注册到指定的Selector后,程序会启动另一条线程来监测该Selector。
在使用NIO来实现服务器时,甚至无须使用ArrayList来保存服务器中所有SocketChannel,因为所有的SocketChannel都需要注册到指定的Selector对象。除此之外,当客户端关闭时会导致服务器对应的Channel也抛出异常,而且本程序只有一条线程,如果该异常得不到处理将会导致整个服务器退出,所以程序捕捉了这种异常,并在处理异常时从Selector删除异常Channel的注册。