本文是仿微信聊天程序专栏的第十一篇文章,主要记录了【米虫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的接入,就可以实现业务功能测试了,下面是测试过程的一些收发数据包打印结果:
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)