一、前言
在基于C/S架构的应用程序中,对于服务端我们需要实现的功能通常有:
- 保持长连接并随时接收客户端发送的请求
- 能够主动向客户端发送响应信息
- 能够实现客户端到客户端信息的传输
对于客户端:
- 保持和服务器的长连接
- 能够主动向服务器发送消息
- 能随时接收服务器的响应
下面我们来讨论如何实现上面的功能
二、服务端
(1)服务器启动前的引导
在启动服务器前,我们需要根据业务需求进行一系列具体的配置。
服务器引导类ServerBootstrap主要方法如下:
方法 | 作用 |
---|---|
group | 绑定EventLoopGroup,一般来说,客户端只需绑定一个EventLoopGroup,服务端一般需要绑定两个, 一个负责接收客户端连接,另外一个负责管理客户端连接 |
channel | 指定管理连接的Channel类,一般会指定非阻塞Channel类,服务器为NioServerSocketChannel,客户端为NioSocketChannel,也可以指定阻塞Channel类(一般不用) |
option | 进行一系列设置,比如是否设置长连接,指定是TCP连接还是UDP连接,关于具体的设置请参考:https://blog.csdn.net/asdfayw/article/details/62433902 |
childHander | 指定一个ChannelInitializer<T>,泛型参数为Channel的类型,一般为SocketChannel |
下面为服务器启动类参考源码:
public final class NettyServer implements Runnable {
private final int port;
private ServerSocketChannel serverSocketChannel;
public NettyServer(int port) {
this.port = port;
}
@Override
public void run() {
//设置两个EventLoopGroup,connGroup负责接收客户端连接,workGroup负责向已连接的客户端传送和接收数据
EventLoopGroup connGroup = new NioEventLoopGroup();
EventLoopGroup workGroup = new NioEventLoopGroup();
//创建一个服务端引导类
ServerBootstrap boot = new ServerBootstrap();
//绑定EventLoopGroup,使用非阻塞IO,并设置ChannelInitializer
boot.group(connGroup, workGroup).channel(NioServerSocketChannel.class).option(ChannelOption.SO_BACKLOG, 128)
.option(ChannelOption.SO_KEEPALIVE, true).childOption(ChannelOption.TCP_NODELAY, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new ObjectDecoder(102400 * 1024,
ClassResolvers.cacheDisabled(this.getClass().getClassLoader())));
ch.pipeline().addLast(new ObjectEncoder());
ch.pipeline().addLast(new InboundHandler());
}
});
try {
//绑定指定端口,并阻塞到ChannelFuture返回
ChannelFuture future = boot.bind(port).sync();
if (future.isSuccess()) {
serverSocketChannel = (ServerSocketChannel) future.channel();
System.out.println("服务器已启动,端口号:" + port);
} else {
System.out.println("服务器启动失败");
}
//一直阻塞,直到服务器关闭
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
connGroup.shutdownGracefully();
workGroup.shutdownGracefully();
}
}
}
(2)如何实现长连接和主动向客户端发送消息
长连接
长连接需要在服务器引导阶段设置,调用ServerBootstrap实例的option方法并设置ChannelOption.SO_KEEPALIVE为true即可。
如何主动向客户端发送消息
当有一个新的客户端连入服务器后,进站处理器的channelActive方法会被调用,这时我们可以调用ChannelHandlerContext对象的channel方法取出对应的SocketChannel。
对于每一个客户端连接,我们可以创建一个Session对象,并保存在一个Map里。这个Session对象需要保存客户端连接对应的SocketChannel,并且需要为它们生成一个UUID,类似于Web里的Session ID,并将这个ID发送给客户端,客户端发送的请求内容都需要包含这个Session ID以便服务器能快速找到对应的SocketChannel,如果这个时候我们希望发送数据给客户端,我们可以调用SocketChannel对象的writeAndFlush方法即可。
这里我们采用一个服务端管理类,它负责启动服务器初始化进程和管理客户端连接。
服务端管理类
public class Server {
private static boolean isInit = false;
private static NettyServer server;
//Key和Value均采用同一对象,主要是为了方便通过会话ID创建一个临时Session对象来找出Value
private static final ConcurrentMap<Session, Session> clientMap = new ConcurrentHashMap<>();
//服务端初始化,由主类调用,运行期间只允许初始化一次
public static void init(int port) {
if(isInit)
throw new UnsupportedOperationException("服务器不允许第二次初始化");
server = new NettyServer(port);
new Thread(server).start();
}
//获得管理用户会话的Map
public static ConcurrentMap<Session, Session> getClientSet() {
return clientMap;
}
/**
* 向指定客户端发送消息
* @param sessionId 客户端会话对象的id(SessionId)
* @param message 向客户端发送的响应
*/
public static void sendMessage(String sessionId, Response message) {
System.out.println(sessionId);
Session session = clientMap.get(Session.hash(sessionId));
session.getSocket().writeAndFlush(message);
}
private Server() { }
}
进站处理器部分
@Sharable
public class InboundHandler extends ChannelInboundHandlerAdapter {
// 服务器与客户端创建连接时调用,将Session对象加入Map中,并及时将SessionID发送给客户端
@Override
public void channelActive(ChannelHandlerContext ctx) {
System.out.println("已收到客户端连接");
Session session = new Session((SocketChannel) ctx.channel());
Server.getClientSet().put(session, session);
ctx.writeAndFlush(new Response(ResponseProtocol.SESSION_ID, session.getId()));
}
// 客户端与服务器断开时调用,从Map中移除Session对象
@Override
public void channelInactive(ChannelHandlerContext ctx) {
System.out.println("客户端会话关闭");
Server.getClientSet().remove(new Session((SocketChannel) ctx.channel()));
}
// 服务端接收客户端发送的数据结束后调用,这里用来清空缓冲区
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
//当用户强制退出或有其它异常抛出时会调用这个方法,这里会直接关闭客户端的连接
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable t) {
if (!(t instanceof IOException)) {
t.printStackTrace();
} else {
System.out.println("客户端断开连接");
}
ctx.close();
}
// 用于读取客户端发送的请求类,这里根据实际业务需求决定
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("已收到客户端对象请求");
Request request = (Request) msg;
//....
//需要显式释放资源
ReferenceCountUtil.release(msg);
}
}
Session对象的设计
为了便于查找,我们建议仅通过会话ID计算hash值并使用一个静态方法来返回一个临时Session对象,这个临时对象仅设置用户Request中包含的会话ID。
public class Session {
private final String id;
private User user;
private final SocketChannel socket;
public Session(SocketChannel socket) {
this.id = UUID.randomUUID().toString();
this.socket = socket;
}
//只可由静态方法hash调用,用于从Map中查找
private Session(String id) {
this.id = id;
this.socket = null;
}
//返回一个用于查找Session的临时对象
public static Session hash(String id) {
return new Session(id);
}
public SocketChannel getSocket() {
return this.socket;
}
public String getId() {
return id;
}
//hash值仅与会话ID有关
@Override
public int hashCode() {
return id.hashCode();
}
//通过会话ID判断两个类是否相等
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Session))
return false;
Session session = (Session) obj;
if (!session.getId().equals(this.id))
return false;
return true;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
}
当我们需要从Map中取出需要的Session对象时,我们可以这样(以上述代码为标准):
Session session = clientMap.get(Session.hash(sessionId));
然后取出Session对象中包含的SocketChannel即可。
(3)通信协议的设计
通信协议的解决方案之一就是将信息包含在一个可序列化的对象中。
请求类(Request)
请求类包含三个部分,请求协议头,客户端会话ID,请求体(即具体的信息)
当服务端接收到一个客户端的请求时,我们先取出Request对象的请求头部分,并通过请求头具体信息调用对应的方法来处理请求体。
如果需要向客户端发送一条响应,我们就可以按照上述方法通过会话ID找出对应的SocketChannel即可。
下面是请求类的参考代码:
public class Request implements java.io.Serializable {
private static final long serialVersionUID = 6336306709336891264L;
private String head; //请求头,这里用了String类型,也可以用其他类型
private String fromId; //客户端会话ID
private Object[] content; //请求体
public Request(String head, String fromId, Object... content) {
this.head = head;
this.fromId = fromId;
this.content = content;
}
public String getHead() {
return head;
}
public String getFromId() {
return fromId;
}
public Object[] getContent() {
return content;
}
}
响应类(Response)
对于响应类我们只需包含两个部分即可,请求体和请求头。
发送响应对象时通过调用对应客户端的SocketChannel的writeAndFlush(response)即可
下面是响应类参考代码:
public class Response implements java.io.Serializable {
private static final long serialVersionUID = -7804537742238419970L;
private String head;
private Object[] content;
public Response(String head, Object... content) {
this.head = head;
this.content = content;
}
public String getHead() {
return head;
}
public Object[] getContent() {
return content;
}
}
如果有更复杂的业务需求,也可以定义一个抽象Request、Response类,这个抽象类仅保留有请求头和客户端会话ID,然后再通过定义多个具体的类继承它们。每当服务器接收到一个请求类时,通过请求头将它们强制转换成子类并调用处理方法即可。
三、客户端
客户端连接部分相对于服务端来说要显得简单,客户端只需要保留有一个和服务器连接的SocketChannel,而且只需要一个EventLoopGroup。
(1)客户端引导
客户端引导类和服务端不同,客户端引导类为Bootstrap,且在连接时需要提供服务器主机名和端口号。
public final class NettyClient implements Runnable {
private final String host;
private final int port;
//保存和服务器连接对应的SocketChannel
SocketChannel socketChannel;
public NettyClient(String host, int port) {
this.host = host;
this.port = port;
}
@Override
public void run() {
//创建一个EventLoopGroup用来接收服务端发送过来的数据
EventLoopGroup group = new NioEventLoopGroup();
Bootstrap boot = new Bootstrap();
//绑定EventLoopGroup,设置为非阻塞模式,绑定主机名和端口,设置ChannelInitializer
boot.group(group).channel(NioSocketChannel.class).remoteAddress(new InetSocketAddress(host, port))
.option(ChannelOption.SO_KEEPALIVE, true).option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new ObjectDecoder(102400 * 1024,
ClassResolvers.cacheDisabled(this.getClass().getClassLoader())));
ch.pipeline().addLast(new InboundHandler());
ch.pipeline().addLast(new ObjectEncoder());
}
});
try {
//调用connect方法连接(UDP协议调用bind即可),并阻塞到有返回结果时为止
ChannelFuture future = boot.connect().sync();
if(future.isSuccess()) {
//连接成功后返回SocketChannel
socketChannel = (SocketChannel) future.channel();
System.out.println("成功连入服务器");
} else {
JOptionPane.showMessageDialog(null, "服务器或客户端异常,请尝试重新启动客户端",
"错误", JOptionPane.WARNING_MESSAGE);
}
//一直阻塞到连接关闭为止
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
try {
group.shutdownGracefully().sync();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
(2)如何向服务端发送消息
客户端连接到服务器后,引导类会自动设置好SocketChannel。这时,我们需要创建一个客户端连接管理类,用来保存服务端发送过来的会话ID,并负责向服务端发送数据。
客户端连接管理类参考源码
public final class Client {
private static boolean isInit = false;
private static NettyClient client;
private static String sessionId;
//由主类调用此方法,此方法会引导客户端连接
public static void init(String host, int port) {
if(isInit)
throw new UnsupportedOperationException("客户端不允许第二次初始化");
client = new NettyClient(host, port);
new Thread(client).start();
}
public static NettyClient getClient() {
return client;
}
/**
* 向服务端发送请求
* @param request 发送的请求
*/
public static void sendMessage(Request request) {
//调用socketChannel方法的writeAndFlush发送消息
client.socketChannel.writeAndFlush(request);
}
public static String getSessionId() {
return sessionId;
}
static void setSessionId(String sessionId) {
Client.sessionId = sessionId;
}
private Client() { }
}
进站处理器参考源码
不同于服务端,我们只需要重写两个方法:channelRead和exceptionCaught方法即可。因为在客户端连接时已经返回一个具体的SocketChannel对象,无需再通过channelActive来得到一个和服务端连接的SocketChannel对象。(对于客户端可以继承SimpleChannelInboundHandlerAdapter<T>,这个方法会自动将消息进行强制转换(转换为T),只需要重写channelRead0方法即可,并且无需显式释放资源)
@Sharable
public class InboundHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object obj) {
System.out.println("已收到客户端对象请求");
Request request = (Request) msg;
//....
//这里同样需要显式释放资源
ReferenceCountUtil.release(msg);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable t) {
t.printStackTrace();
ctx.close();
}
}