本文是仿微信聊天程序专栏的第十一篇文章,主要记录了【米虫IM-服务端】的实现。

界面设计

仿微信聊天程序的服务端正常来说可能不需要界面,但是为了配置和调试方便,还是开发了一下简单的界面,主要由两部分组成:

  1. 服务端域名(或IP)端口配置
  2. 收发数据包日志打印

Spring集成

仿微信聊天程序服务端需要对数据进行管理,后续也可以提供WEB的管理端,为了开发方便,选择集成Spring(SpringBoot)框架进行业务功能开发,主要是技术选型如下:

  1. 数据库:H2(可切换成MySQL)
  2. 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,即:

  1. PacketDecoder:负责数据解码
  2. 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的接入,就可以实现业务功能测试了,下面是测试过程的一些收发数据包打印结果:

posted on 2023-08-13 10:05  ql1710  阅读(58)  评论(0编辑  收藏  举报