本文是仿微信聊天程序专栏的第十一篇文章,主要记录了【米虫IM-服务端】的实现。
界面设计
仿微信聊天程序的服务端正常来说可能不需要界面,但是为了配置和调试方便,还是开发了一下简单的界面,主要由两部分组成:
- 服务端域名(或IP)端口配置
- 收发数据包日志打印
Spring集成
仿微信聊天程序服务端需要对数据进行管理,后续也可以提供WEB的管理端,为了开发方便,选择集成Spring(SpringBoot)框架进行业务功能开发,主要是技术选型如下:
- 数据库:H2(可切换成MySQL)
- orm:spring-data-jpa
JavaFX和SpringBoot项目集成,只需要JavaFX的启动类加上@SpringbootApplication注解,然后在启动的时候调用SpringApplication.run()即可。
import javafx.application.Application;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class App extends Application {
}
这里稍作调整,不在Application的start部分调用SpringApplication.run,而是采用“启动”/“停止”按钮来控制SpringBoot项目的启停。
@SpringBootApplication
public class App extends Application {
VBox root() {
// ... 省略其他控件的创建代码 ....
startButton.setOnAction(e -> {
stateText.setText("提示:服务启动中,请耐心等待.....");
startButton.setDisable(true);
new Thread(() -> {
try {
System.setProperty(TCPConst.NETTY_HOST_KEY, hostTf.getText());
System.setProperty(TCPConst.NETTY_PORT_KEY, portTf.getText());
context = SpringApplication.run(App.class, args);
Platform.runLater(() -> {
stateText.setText("");
stopButton.setDisable(false);
});
} catch (Throwable t) {
String error = "服务启动失败,原因:" + t.getMessage();
Platform.runLater(() -> stateText.setText(error));
}
}).start();
});
stopButton.setOnAction(e -> {
stateText.setText("提示:服务停止中,请耐心等待.....");
stopButton.setDisable(true);
new Thread(() -> {
try {
if (Objects.nonNull(context)) {
SpringApplication.exit(context);
}
Platform.runLater(() -> {
stateText.setText("");
startButton.setDisable(false);
});
} catch (Throwable t) {
String error = "服务停止失败,原因:" + t.getMessage();
Platform.runLater(() -> stateText.setText(error));
}
}).start();
});
}
}
Netty集成
因为在JavaFX应用上已经集成了Spring,所以Netty的集成就相对比较简单,只需要将Netty的服务端启停,挂在Spring Bean的生命周期上即可。
/**
* @author michong
*/
@Component
public class ImServer {
@PostConstruct
public void start() {
// 在这里启动Netty服务
}
@PreDestroy
public void stop() {
// 在这里关闭Netty服务
}
}
完整的ImServer代码如下:
@Component
public class ImServer {
private final Logger logger = LoggerFactory.getLogger(ImServer.class);
private EventLoopGroup bossGroup;
private EventLoopGroup workGroup;
@Autowired
private MessageDispatcher messageDispatcher;
@PostConstruct
public void start() {
bossGroup = new NioEventLoopGroup(2);
workGroup = new NioEventLoopGroup(Math.max(Runtime.getRuntime().availableProcessors() * 16, 256));
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workGroup).channel(NioServerSocketChannel.class);
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) {
ChannelPipeline pipe = socketChannel.pipeline();
pipe.addLast(new IdleStateHandler(TCPConst.IDLE_TIME_OUT_MILLISECONDS * 4, 0, 0, TimeUnit.MILLISECONDS));
pipe.addLast(new PingChannelHandler(messageDispatcher));
pipe.addLast(new PacketDecoder());
pipe.addLast(new PacketEncoder());
pipe.addLast(new PacketChannelHandler(messageDispatcher));
}
});
String host = System.getProperty(TCPConst.NETTY_HOST_KEY);
int port = Integer.parseInt(System.getProperty(TCPConst.NETTY_PORT_KEY));
bootstrap.bind(host, port).addListener((ChannelFutureListener) cf -> {
if (cf.isSuccess()) {
logger.info("ImServer started. host={}, port={}", host, port);
} else {
stop();
logger.info("ImServer start error.", cf.cause());
}
});
}
@PreDestroy
public void stop() {
try {
if (bossGroup != null) {
bossGroup.shutdownGracefully().sync();
}
if (workGroup != null) {
workGroup.shutdownGracefully().sync();
}
} catch (Throwable e) {
logger.error(e.getMessage(), e);
}
}
}
Netty-TCP
在《集成Netty》部分的ImServer代码中可以看到两个Handler,即:
- PacketDecoder:负责数据解码
- PacketEncoder:负责数据编码
这两个Handler负责对通讯的TCP数据包进行编解码,其中数据包的定义如下:
/**
* @author michong
*/
public class Packet {
/**
* 消息类型
*/
private byte type;
/**
* 内容长度(不含size和type长度)
*/
private int size;
/**
* 消息内容
*/
private byte[] content;
}
关于Netty-TCP编解码的部分可以查看之前发布的专栏《Netty-TCP》,专栏发布在微信小程序“Coding鱼塘”中,有兴趣的可以看看,包含数据包定义、客户端实现、服务端实现。
除了PacketDecoder和PacketEncoder之外,PingChannelHandler负责对心跳进行检查,而PacketChannelHandler负责对业务数据包进行处理。
实际上,PacketChannelHandler对业务数据包的处理是由MessageDispatcher负责的,PacketChannelHandler只是简单地将收到的数据包转交给MessageDispatcher而已。
public class PacketChannelHandler extends SimpleChannelInboundHandler<Packet> {
private final MessageDispatcher messageDispatcher;
public PacketChannelHandler(MessageDispatcher messageDispatcher) {
this.messageDispatcher = messageDispatcher;
}
@Override
protected void channelRead0(ChannelHandlerContext context, Packet packet) {
messageDispatcher.onChannelMessage(context.channel(), packet);
}
}
而MessageDispatcher会对收到的数据包进行解析,根据不同的业务包,处理不同的业务,然后将处理结果返回给客户端:
@Component
public class MessageDispatcher {
@Autowired
private UserService userService;
@Autowired
private ContactsService contactsService;
@Autowired
private MessageService messageService;
// 业务数据包处理
public void onChannelMessage(Channel channel, Packet packet) {
if (packet.getType() == PKT.PING || packet.getType() != PKT.TEXT) {
return;
}
String content = new String(packet.getContent(), StandardCharsets.UTF_8);
int index = IMIndexDef.getIndex(content);
if (index != -1) {
content = IMIndexDef.getContent(content);
}
Long userId = ChannelContext.getUserId(channel.id().asLongText());
String ret = "";
boolean valid = true;
if (index != IMIndexDef.REGISTER_RP && index != IMIndexDef.LOGIN_RP) {
if (Objects.isNull(userId)) {
valid = false;
ret = onException(new IllegalStateException("会话已过期,需要重新登录"));
}
}
if (valid) {
switch (index) {
// 注册
case IMIndexDef.REGISTER_RP:
ret = onRegisterRP(content);
break;
// 登录
case IMIndexDef.LOGIN_RP:
ret = onLoginRP(channel, content);
break;
// 查询用户
case IMIndexDef.QUERY_USER_RP:
ret = onQueryUserRP(content);
break;
// 添加好友
case IMIndexDef.ADD_CONTACTS_RP:
ret = onAddContactsRP(userId, content);
break;
// 删除好友
case IMIndexDef.DEL_CONTACTS_RP:
break;
// 转发信息
case IMIndexDef.MESSAGE_RP:
ret = onMessageRP(userId, content);
break;
}
}
if (!StringUtils.hasText(ret)) {
ret = "";
}
Packet pkt = new Packet(PKT.TEXT, (index + ret).getBytes(StandardCharsets.UTF_8));
channel.writeAndFlush(pkt);
}
// 客户端掉线处理
public void onChannelOffline(String channelIdLongAsText) {
ChannelContext.removeChannel(channelIdLongAsText);
}
}
业务功能
业务功能开发跟传统的WEB开发没有太大区别,整个服务端的整体流程如下:
ImClient(仿微信聊天程序客户端) ----发送消息----> ImServer ----解码数据----> MessageDispatcher
^ | ^ |
| | | V
------------------返回结果------------------ -----------返回结果---------业务处理
即:ImServer收到数据时,将数据解码成Packet数据包,然后将数据包交给MessageDispatcher处理,而MessageDispatcher根据不同的数据包调用不同的业务处理。
以用户(User)业务功能为例,整个模块包含三个Java类:
User:用户实体(JPA-Entity)对应数据库tb_user表
UserRepository:JPA持久层
UserService:用户业务层
- User
/**
* @author michong
*/
@Entity
@Getter
@Setter
@Table(name = "tb_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String nickname;
private String username;
private String password;
}
- UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
User findByUsernameAndPassword(String username, String password);
User findByUsername(String username);
}
- UserServer
@Service
public class UserService {
@Autowired
@Getter
private UserRepository userRepository;
public void register(RegisterREQ registerREQ) {
// 测试程序、暂时不校验、不加密
User user = new User();
user.setNickname(registerREQ.nickname);
user.setUsername(registerREQ.username);
user.setPassword(registerREQ.password);
userRepository.save(user);
}
public User login(LoginREQ loginREQ) {
// 测试程序、暂时不校验、不加密
return userRepository.findByUsernameAndPassword(loginREQ.username, loginREQ.password);
}
public User queryByUsername(String username) {
return userRepository.findByUsername(username);
}
}
业务测试
服务端开发完成后,在客户端实现Netty的接入,就可以实现业务功能测试了,下面是测试过程的一些收发数据包打印结果: