精华内容
下载资源
问答
  • netty案例,netty4.1中级拓展篇一《NettySpringBoot整合》源码 https://mp.weixin.qq.com/s?__biz=MzIxMDAwMDAxMw==&mid=2650724796&idx=1&sn=ce5dc3c913d464b0e2e4e429a17bb01e&scene=19#wechat_redirect
  • netty SpringBoot myBatis

    Netty, SpringBoot, MyBatis搭建游戏服务器 - 1


    各框架简介

    Netty是步的、事件驱动的网络应用程序框架和工具,,很适合做游戏服务器。

    使用SpringBoot可以抛弃传统Spirng框架的繁琐配置和依赖版本管理,更方便地打包与部署。这里我们主要是使用SpringBoot的依赖注入功能。

    MyBatis 是一款优秀的持久层框架,它支持定制化 SQL、存储过程以及高级映射。因为我们的数据库可能因为业务需求不断地变化,所以我们可以用MyBaits的逆向工程实时根据数据库的表生成实体类、Mapper接口,Mapper XML。或者利用注解注解写SQL语句查询。


    目的

    我们要使用Netty构建一个游戏服务端,她可以接受客户端的信息,并根据业务逻辑做出响应。
    架构图

    Spring负责依赖注入,管理对象(游戏业务对象另外由缓存模块管理),使得项目代码直之间解耦合。

    MyBatis负责依据业务需求持久化各类数据。


    依赖配置

    项目使用Maven来管理依赖,依赖结构初步想法是

    • mmorpg
      • gameserver
        • mysql
        • 缓存模块
        • 游戏配置管理模块
      • gameclient

    下面是当前版本项目的pom文件

    总项目定义文件

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>com.wan37</groupId>
        <artifactId>mmorpg</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <packaging>pom</packaging>
    
        <name>mmorpg</name>
        <description>Demo project for Spring Boot</description>
    
        <modules>
            <module>gameserver</module>
            <module>mysql</module>
        </modules>
    
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.0.5.RELEASE</version>
            <relativePath/> <!-- lookup parent from repository -->
        </parent>
    
        <properties>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
            <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
            <java.version>1.8</java.version>
        </properties>
    
        <dependencies>
            <!--<dependency>-->
                <!--<groupId>org.springframework.boot</groupId>-->
                <!--<artifactId>spring-boot-starter-data-redis</artifactId>-->
            <!--</dependency>-->
    
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <optional>true</optional>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-configuration-processor</artifactId>
                <optional>true</optional>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
    
        <repositories>
            <repository>
                <id>spring-snapshots</id>
                <name>Spring Snapshots</name>
                <url>https://repo.spring.io/snapshot</url>
                <snapshots>
                    <enabled>true</enabled>
                </snapshots>
            </repository>
            <repository>
                <id>spring-milestones</id>
                <name>Spring Milestones</name>
                <url>https://repo.spring.io/milestone</url>
                <snapshots>
                    <enabled>false</enabled>
                </snapshots>
            </repository>
        </repositories>
    
        <pluginRepositories>
            <pluginRepository>
                <id>spring-snapshots</id>
                <name>Spring Snapshots</name>
                <url>https://repo.spring.io/snapshot</url>
                <snapshots>
                    <enabled>true</enabled>
                </snapshots>
            </pluginRepository>
            <pluginRepository>
                <id>spring-milestones</id>
                <name>Spring Milestones</name>
                <url>https://repo.spring.io/milestone</url>
                <snapshots>
                    <enabled>false</enabled>
                </snapshots>
            </pluginRepository>
        </pluginRepositories>
    </project>
    
    

    持久化模块定义文件

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <parent>
            <artifactId>mmorpg</artifactId>
            <groupId>com.wan37</groupId>
            <version>0.0.1-SNAPSHOT</version>
        </parent>
        <modelVersion>4.0.0</modelVersion>
    
        <artifactId>mysql</artifactId>
        <packaging>jar</packaging>
    
    
        <dependencies>
    
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>1.3.2</version>
            </dependency>
    
    
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <scope>runtime</scope>
            </dependency>
    
            <!-- https://mvnrepository.com/artifact/com.alibaba/druid -->
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>druid</artifactId>
                <version>1.0.26</version>
            </dependency>
    
    
            <!-- 分页插件 -->
            <dependency>
                <groupId>com.github.pagehelper</groupId>
                <artifactId>pagehelper</artifactId>
                <version>4.1.6</version>
            </dependency>
    
            <!--Json Support-->
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>fastjson</artifactId>
                <version>1.1.43</version>
            </dependency>
    
            <!--mybatis逆向工程-->
            <!--<dependency>-->
                <!--<groupId>org.mybatis.generator</groupId>-->
                <!--<artifactId>mybatis-generator-core</artifactId>-->
                <!--<version>1.3.2</version>-->
            <!--</dependency>-->
    
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
    
                <!--mybatis插件-->
                <plugin>
                    <groupId>org.mybatis.generator</groupId>
                    <artifactId>mybatis-generator-maven-plugin</artifactId>
                    <version>1.3.2</version>
                    <configuration>
                        <configurationFile>src\main\resources\mybatis\generatorConfig.xml</configurationFile>
                        <!--允许移动生成的文件-->
                        <verbose>true</verbose>
                        <!--允许覆盖生成的文件-->
                        <overwrite>true</overwrite>
                    </configuration>
                    <executions>
                        <execution>
                            <id>GenerateMyBatis Artifacts</id>
                            <goals>
                                <goal>generate</goal>
                            </goals>
                        </execution>
                    </executions>
                    <dependencies>
                        <dependency>
                            <groupId>org.mybatis.generator</groupId>
                            <artifactId>mybatis-generator-core</artifactId>
                            <version>1.3.2</version>
                        </dependency>
                    </dependencies>
                </plugin>
    
            </plugins>
        </build>
    
    </project>
    

    服务端模块

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
    
        <parent>
            <artifactId>mmorpg</artifactId>
            <groupId>com.wan37</groupId>
            <version>0.0.1-SNAPSHOT</version>
        </parent>
    
    
    
        <artifactId>gameserver</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <packaging>jar</packaging>
    
        <name>gameserver</name>
        <description>A game server project for Spring Boot</description>
    
    
        <properties>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
            <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
            <java.version>1.8</java.version>
        </properties>
    
        <dependencies>
            <!-- Netty -->
            <dependency>
                <groupId>io.netty</groupId>
                <artifactId>netty-all</artifactId>
                <version>4.1.29.Final</version>
            </dependency>
    
            <!-- 引用基于MySQL和-->
            <dependency>
                <groupId>com.wan37</groupId>
                <artifactId>mysql</artifactId>
                <version>0.0.1-SNAPSHOT</version>
            </dependency>
    
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
    
    </project>
    
    

    Spring和myBatis的配置文件

    gameserver模块中的application.yml

    spring:
      datasource:
        url: jdbc:mysql://localhost:3306/game?useUnicode=true&characterEncoding=utf-8&useSSL=true
        username: root
        password: 123456
        driver-class-name: com.mysql.jdbc.Driver
        type: com.alibaba.druid.pool.DruidDataSource
    
    mybatis:
      mapper-locations: classpath*:mapper/*.xml
      check-config-location: true
      type-aliases-package: com.wan37.mysql.pojo.entity
      
    debug: true
    

    MyBitis配置类(分页助手不知是否能用到)

    package com.wan37.gameServer.config;
    
    import com.github.pagehelper.PageHelper;
    import org.mybatis.spring.annotation.MapperScan;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    import java.util.Properties;
    
    
    /**
     * @author 钱伟健 gonefutre
     * @date 2017/9/12 22:27.
     * @E-mail gonefuture@qq.com
     */
    
    /*
     * 注册MyBatis分页插件PageHelper
     */
    
    @Configuration
    @MapperScan(basePackages = {"com.wan37"})
    public class MybatisConfig {
    
        @Bean
        public PageHelper pageHelper() {
            System.out.println("MyBatisConfiguration.pageHelper()");
            PageHelper pageHelper = new PageHelper();
            Properties p = new Properties();
            p.setProperty("offsetAsPageNum", "true");
            p.setProperty("rowBoundsWithCount", "true");
            p.setProperty("reasonable", "true");
            pageHelper.setProperties(p);
            return pageHelper;
        }
    }
    

    MyBatis逆向工程文件

    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE generatorConfiguration PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
            "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd" >
    <generatorConfiguration >
        <!-- 引入配置文件 -->
        <!-- 	<properties resource="db.properties"/> -->
        <!-- 指定数据连接驱动jar地址 -->
        <classPathEntry location="C:\project\mmorpg\mysql\mysql-connector-java-5.1.47.jar"/>
        <!-- 一个数据库一个context -->
        <context id="msql" targetRuntime="MyBatis3" >
    
            <!-- generate entity时,生成hashcode和equals方法 -->
            <!--<plugin type="org.mybatis.generator.plugins.EqualsHashCodePlugin" />-->
            <!-- generate entity时,生成serialVersionUID -->
            <plugin type="org.mybatis.generator.plugins.SerializablePlugin" />
            <!-- 这个插件只会增加字符串字段映射到一个JDBC字符的方法 -->
            <!--<plugin type="org.mybatis.generator.plugins.CaseInsensitiveLikePlugin" />-->
            <!-- genenat entity时,生成toString -->
            <plugin type="org.mybatis.generator.plugins.ToStringPlugin" />
    
    
            <!-- 注释 -->
            <commentGenerator>
                <property name="suppressAllComments" value="true"/><!-- 是否取消注释 -->
                <property name="supperssDate" value="false"/><!-- 是否生成注释代码时间戳 -->
            </commentGenerator>
            <!-- jdbc连接 -->
            <jdbcConnection driverClass="com.mysql.jdbc.Driver"
                            connectionURL="jdbc:mysql://localhost:3306/game"
                            userId="root"
                            password="123456" />
            <!-- 类型转换 -->
            <javaTypeResolver>
                <!-- 是否使用bigDecimals,false可自动转化以下类型(Long,Integer,Short,ets..) -->
                <property name="forceBigDecimals" value="false"/>
            </javaTypeResolver>
            <!-- 生成实体类地址 -->
            <javaModelGenerator targetPackage="com.wan37.mysql.pojo.entity" targetProject="src/main/java">
                <!-- 是否在当前路径下新加一层schema,eg:
                fase路径:com.sky.ssm.po ; true路径:com.sky.ssm.po.[shemaName]
                 -->
                <property name="enableSubPackages" value="true"/>
                <!-- 是否针对string类型的字段在set的时候进行trim调用 -->
                <property name="trimStrings" value="true"/>
            </javaModelGenerator>
    
            <!-- 生成mapxml文件 -->
            <sqlMapGenerator targetPackage="mapper" targetProject="src/main/resources">
                <!-- 是否在当前路径下新加一层schema,eg:
                fase路径:com.sky.ssm.mapper ; true路径:com.sky.ssm.mapper.[shemaName]
                 -->
                <property name="enableSubPackages" value="true"/>
            </sqlMapGenerator>
            <!-- 生成mapxml对应client,也就是接口dao -->
            <javaClientGenerator targetPackage="com.wan37.mysql.pojo.mapper" targetProject="src/main/java"
                                 type="XMLMAPPER" >
                <!-- 是否在当前路径下新加一层schema,eg:
                fase路径:com.sky.ssm.mapper ; true路径:com.sky.ssm.mapper.[shemaName]
                 -->
                <property name="enableSubPackages" value="true"/>
            </javaClientGenerator>
            <!-- 配置表信息 -->
            <!-- schema即为数据库名; tableName为对应的数据库表 ;domainObjectName是要生成的实体类 ;enable*ByExample是否生成 example类 -->
            <!-- <table schema="store" tableName="user"
                domainObjectName="User" enableCountByExample="true"
                enableDeleteByExample="true" enableSelectByExample="true"
                enableUpdateByExample="ture">
                忽略列,不生成bean字段
                 <ignoreColumn column="FRED"/>
                     指定列的java数据类型
               <columnOverride column="PRICE" javaType="double" />
              </table> -->
            <!-- 指定数据库表,要生成哪些表,就写哪些表,要和数据库中对应,不能写错! -->
            <table tableName="game_role"/>
            <table tableName="player"/>
            <table tableName="player_roles"/>
    
        </context>
    </generatorConfiguration>
    

    整合Netty

    我使用的办法是把Netty服务的Handler,启动类都都变成由Spring管理的类。通过在类定义上加@Component注解,SpringBoot启动时会自动扫描注解并将Bean注入到容器中。使用 @PostConstruct修饰启动方法来启动Netty服务。

    import com.wan37.gameServer.server.handler.ServerHandler;
    import io.netty.bootstrap.ServerBootstrap;
    import io.netty.channel.*;
    import io.netty.channel.nio.NioEventLoopGroup;
    import io.netty.channel.socket.SocketChannel;
    import io.netty.channel.socket.nio.NioServerSocketChannel;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    
    import javax.annotation.PostConstruct;
    import javax.annotation.Resource;
    
    
    /**
     * @author gonefuture  gonefuture@qq.com
     * time 2018/9/10 11:36
     * @version 1.00
     * Description: 游戏服务端
     */
    @Slf4j
    @Component
    @ChannelHandler.Sharable
    public class GameServer {
    
        @Resource
        private ServerHandler serverHandler;
    
        //绑定端口
        private void bind(int port) throws Exception {
            // 逻辑线程组
            EventLoopGroup bossGroup = new NioEventLoopGroup();
            // 工作线程
            EventLoopGroup workGroup = new NioEventLoopGroup();
    
            ServerBootstrap bootstrap = new ServerBootstrap(); // 启动器
            bootstrap.group(bossGroup, workGroup)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 1024) // 最大客户端连接数为1024
                    //是否启用心跳保活机制
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
            .childHandler(new ChannelInitializer<SocketChannel>() {
    
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    // 这里添加业务处理handler
                    ch.pipeline().addLast( serverHandler);
                }
            });
    
            try {
                ChannelFuture future = bootstrap.bind(port).sync();
                if (future.isSuccess()) {
                    System.out.println("Server starts success at port:"+port);
                }
                future.channel().closeFuture().sync();
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                bossGroup.shutdownGracefully();
                workGroup.shutdownGracefully();
            }
        }
    
        public static void main(String[] args) throws Exception {
            int port = 8000;
            new GameServer().bind(port);
        }
    
    
        @PostConstruct
        public void start() {
            int port = 8000;
            try {
              bind(port);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    
    }
    
    

    期间遇到的各种问题

    整合的时候遇到过很多问题

    1. 整合Netty时,由于使用new关键字创建Handler实例,导致在Handle中装配的服务实例为空,不能使用。
    2. MyBatis配置类写在了mysql模块而没有写在gameserver模块,导致mapper文件扫描不到。
    3. 逆向工程时产生了同名的mapper xml文件未及时删除,导致出现mapper result map 重复的错误。

    结语

    当前只整合了持久化模块,接下来还要编写请求分发的部分和整合缓存模块。

    展开全文
  • Netty+SpringBoot+FastDFS+Html5实现聊天App,项目介绍。Netty+SpringBoot+FastDFS+Html5实现聊天App,项目github链接。本章完整代码链接。本节主要讲解聊天App PigChat中关于好友申请的发送与接受。 包含以下内容:...

    Netty+SpringBoot+FastDFS+Html5实现聊天App,项目介绍

    Netty+SpringBoot+FastDFS+Html5实现聊天App,项目github链接

    本章完整代码链接


    本节主要讲解聊天App PigChat中关于好友申请的发送与接受。

    包含以下内容:

    (1)搜索好友接口

    (2)发送添加好友申请的接口

    (3)接受添加好友申请的接口

    搜索好友接口

    定义枚举类型 SearchFriendsStatusEnum,表示添加好友的前置状态

        SUCCESS(0, "OK"),
        USER_NOT_EXIST(1, "无此用户..."),    
        NOT_YOURSELF(2, "不能添加你自己..."),            
        ALREADY_FRIENDS(3, "该用户已经是你的好友...");



    在service中定义搜索朋友的前置条件判断的方法preconditionSearchFriends。

    传入的是用户的Id以及搜索的用户的名称。

    【1】首先根据搜索的用户的名称查找是否存在这个用户。
    【2】如果搜索的用户不存在,则返回[无此用户]。
    【3】如果搜索的用户是你自己,则返回[不能添加自己]。
    【4】如果搜索的用户已经是你的好友,则返回[该用户已经是你的好友]。
    【5】否则返回成功。

        @Transactional(propagation = Propagation.SUPPORTS)
        @Override
        public Integer preconditionSearchFriends(String myUserId, String friendUsername) {
    
            //1. 查找要添加的朋友是否存在
            Users user = queryUserInfoByUsername(friendUsername);
            
            // 2. 搜索的用户如果不存在,返回[无此用户]
            if (user == null) {
                return SearchFriendsStatusEnum.USER_NOT_EXIST.status;
            }
            
            // 3. 搜索账号是你自己,返回[不能添加自己]
            if (user.getId().equals(myUserId)) {
                return SearchFriendsStatusEnum.NOT_YOURSELF.status;
            }
            
            // 4. 搜索的朋友已经是你的好友,返回[该用户已经是你的好友]
            Example mfe = new Example(MyFriends.class);
            Criteria mfc = mfe.createCriteria();
            mfc.andEqualTo("myUserId", myUserId);
            mfc.andEqualTo("myFriendUserId", user.getId());
            MyFriends myFriendsRel = myFriendsMapper.selectOneByExample(mfe);
            if (myFriendsRel != null) {
                return SearchFriendsStatusEnum.ALREADY_FRIENDS.status;
            }
            
            //返回成功
            return SearchFriendsStatusEnum.SUCCESS.status;
        }



    在controller中创建搜索好友接口 searchUser。
    传入的是用户的Id,以及要搜索的用户的名字。

    【0】首先判断传入的参数是否为空。
    【1】通过userService的preconditionSearchFriends方法得到前置条件。
    【2】如果搜索前置条件为成功,则向前端返回搜索用户的信息。
    【3】否则搜索失败。

        /**
         * @Description: 搜索好友接口, 根据账号做匹配查询而不是模糊查询
         */
        @PostMapping("/search")
        public IMoocJSONResult searchUser(String myUserId, String friendUsername)
                throws Exception {
            
            // 0. 判断 myUserId friendUsername 不能为空
            if (StringUtils.isBlank(myUserId) 
                    || StringUtils.isBlank(friendUsername)) {
                return IMoocJSONResult.errorMsg("");
            }
            
            // 前置条件 - 1. 搜索的用户如果不存在,返回[无此用户]
            // 前置条件 - 2. 搜索账号是你自己,返回[不能添加自己]
            // 前置条件 - 3. 搜索的朋友已经是你的好友,返回[该用户已经是你的好友]
            //1. 得到前置条件状态
            Integer status = userService.preconditionSearchFriends(myUserId, friendUsername);
            //2. 搜索成功,返回搜索用户的信息
            if (status == SearchFriendsStatusEnum.SUCCESS.status) {
                Users user = userService.queryUserInfoByUsername(friendUsername);
                UsersVO userVO = new UsersVO();
                BeanUtils.copyProperties(user, userVO);
                return IMoocJSONResult.ok(userVO);
            } else {
            //3. 搜索失败
                String errorMsg = SearchFriendsStatusEnum.getMsgByKey(status);
                return IMoocJSONResult.errorMsg(errorMsg);
            }
        }

    发送添加好友申请的接口

    在service中定义添加好友请求记录,保存到数据库的sendFriendRequest方法。
    传入的是添加好友记录的发送方——用户的Id,以及记录的接收方——想要添加的朋友的名称。

    【1】首先根据用户名把朋友信息查询出来。
    【2】然后查询发送好友请求记录表。
    【3】如果不是你的好友,并且好友记录没有添加,则新增好友请求记录。这样可以保证打你发送了两次请求之后,数据库中仍然只记录一次请求的数据。

        @Transactional(propagation = Propagation.REQUIRED)
        @Override
        public void sendFriendRequest(String myUserId, String friendUsername) {
            
            // 1. 根据用户名把朋友信息查询出来
            Users friend = queryUserInfoByUsername(friendUsername);
            
            // 2. 查询发送好友请求记录表
            Example fre = new Example(FriendsRequest.class);
            Criteria frc = fre.createCriteria();
            frc.andEqualTo("sendUserId", myUserId);
            frc.andEqualTo("acceptUserId", friend.getId());
            FriendsRequest friendRequest = friendsRequestMapper.selectOneByExample(fre);
            if (friendRequest == null) {
                // 3. 如果不是你的好友,并且好友记录没有添加,则新增好友请求记录
                String requestId = sid.nextShort();
                
                FriendsRequest request = new FriendsRequest();
                request.setId(requestId);
                request.setSendUserId(myUserId);
                request.setAcceptUserId(friend.getId());
                request.setRequestDateTime(new Date());
                friendsRequestMapper.insert(request);
            }
        }

    在controller中创建发送添加好友请求的接口。
    传入的是添加好友记录的发送方——用户的Id,以及记录的接收方——想要添加的朋友的名称。

    【0】首先判断传入参数不为空。
    【1】然后判断前置条件,若为成功则通过userService的sendFriendRequest方法发送好友请求,否则返回失败。

        /**
         * @Description: 发送添加好友的请求
         */
        @PostMapping("/addFriendRequest")
        public IMoocJSONResult addFriendRequest(String myUserId, String friendUsername)
                throws Exception {
            
            // 0. 判断 myUserId friendUsername 不能为空
            if (StringUtils.isBlank(myUserId) 
                    || StringUtils.isBlank(friendUsername)) {
                return IMoocJSONResult.errorMsg("");
            }
            
            // 前置条件 - 1. 搜索的用户如果不存在,返回[无此用户]
            // 前置条件 - 2. 搜索账号是你自己,返回[不能添加自己]
            // 前置条件 - 3. 搜索的朋友已经是你的好友,返回[该用户已经是你的好友]
            // 1. 判断前置条件
            Integer status = userService.preconditionSearchFriends(myUserId, friendUsername);
            if (status == SearchFriendsStatusEnum.SUCCESS.status) {
                userService.sendFriendRequest(myUserId, friendUsername);
            } else {
                String errorMsg = SearchFriendsStatusEnum.getMsgByKey(status);
                return IMoocJSONResult.errorMsg(errorMsg);
            }
            
            return IMoocJSONResult.ok();
        }

    最终实现效果

    在这里插入图片描述

    接受添加好友申请的接口

    在service中定义查询好友请求列表的queryFriendRequestList方法。

        @Transactional(propagation = Propagation.SUPPORTS)
        @Override
        public List<FriendRequestVO> queryFriendRequestList(String acceptUserId) {
            return usersMapperCustom.queryFriendRequestList(acceptUserId);
        }



    在controller中定义接受添加好友请求的接口queryFriendRequests。

        /**
         * @Description: 发送添加好友的请求
         */
        @PostMapping("/queryFriendRequests")
        public IMoocJSONResult queryFriendRequests(String userId) {
            
            // 0. 判断不能为空
            if (StringUtils.isBlank(userId)) {
                return IMoocJSONResult.errorMsg("");
            }
            
            // 1. 查询用户接受到的朋友申请
            return IMoocJSONResult.ok(userService.queryFriendRequestList(userId));
        }
        

    最终实现效果

    在这里插入图片描述

    展开全文
  • 背景:项目是使用netty进行通信的,并没有使用springboot功能,启动的时候是主方法里面有生成线程,通过new创建channel监听端口。 public class RunServer { public static void main(String[] args) { Runnable ...

    问题描述

    背景:项目是使用netty进行通信的,并没有使用springboot功能,启动的时候是主方法里面有生成线程,通过new创建channel监听端口。

    public class RunServer {
        public static void main(String[] args) {
            Runnable task1 = () -> {
                TcpServer server = new TcpServer();
                try {
                    server.start(new InetSocketAddress(ServerConfig.TCP_PORT));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    System.out.println("启动TCP服务器失败");
                }
            };
    
            ExecutorService fixedThreadPool = new ThreadPoolExecutor(2, 2,
                    0L, TimeUnit.MILLISECONDS, new LinkedBlockingDeque<Runnable>(2));
            fixedThreadPool.execute(task1);
        }
    

    现在开始将项目重构成为一个springboot项目,同时集成邮件和redis功能。

    这里是部分的代码

    public class ServerHandler  extends SimpleChannelInboundHandler {
        private final StringBuffer buffer = new StringBuffer(1000);
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
        @Autowired
        private EmailSend  emailSend;
        @Autowired
        private DataForIOTConstant dataForIOTConstant;
    
    

    结果并没有实现功能。

    问题分析

    这里我们要说一下传统使用netty和springboot集成netty的差别。在传统使用netty的时候我们会这样初始化bootstrap

     serverBootstrap
                    .group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .handler(new LoggingHandler(LogLevel.INFO))
                    .childHandler(new ChannelServerHandler());
    

    注意这里的childHandler(new ChannelServerHandler()),是通过new得到了对象,而我们springboot是自动注入使用的是@Autowired

    我们回忆一下springboot的启动,他说通过扫描bean,通过autoconfiguration将配置的properties注入到bean实例中,同时将这个bean托管给IOC容器。

    当我们需要某一个bean的时候通过@Autowired注入。

    目前的代码:

    • 没有托管给springboot进行扫描
    • 没有实例化成为bean交给IOC进行管理

    问题解决

    在类的上面加上@Component,同时所有的对象通过autowired注入,不能使用new。

    小结:

    在使用springboot的时候,只有扫描到的bean才会被初始化,只有被初始化的bean才能被加入IOC容器,只有被加入IOC的bean才能使用autowired来获得.


    在部署的时候又出问题了

    问题描述:

    首先项目在ide(我是用的是idea)跑项目没有问题,但是部署成jar的时候确保空指针。

    找到空指针的对象:

    /*
    这里的想法是通过ConfigurationProperties将properties的属性注入进来,在所有的bean初始化之后将属性赋值给静态变量,从而优雅的调用。
    */
    @Component
    @Data
    @ConfigurationProperties(prefix = "iot.constant")
    public class DataForIOTConstant  implements InitializingBean {
        private String url;
        private String dataAddress;
        private String apiKey;
        private Integer tcpPost;
        
         private String URL_CONSTANT;
        private String DATA_ADDRESS_CONSTANT;
        private String API_KEY_CONSTANT;
        private Integer TCP_POST_CONSTANT;
        
        @Override
        public void afterPropertiesSet() throws Exception {
            URL_CONSTANT = url;
            DATA_ADDRESS_CONSTANT = dataAddress;
            API_KEY_CONSTANT = apiKey;
            TCP_POST_CONSTANT = tcpPost;
    
            System.out.println(URL_CONSTANT);
            System.out.println(DATA_ADDRESS_CONSTANT);
            System.out.println(API_KEY_CONSTANT);
            System.out.println(TCP_POST_CONSTANT);
        }
    

    问题分析:

    首先我们说说这个方法,在网络编程中,这个方法是可以用的,通过ConfigurationProperties,可以将properties的属性放入属性中,由于static不能通过注入的方式获得值,所以只能在某一个节点进行赋值。这个要求我们属性已经过来了。所以我们用了InitializingBean,在bean初始化之后再进行赋值。

    对于一般的网络编程,这样倒是无所谓,但是本项目并不是一个javaweb项目。而是一个启动以后监听某一个端口,等待数据过来进行解析,这个使用的是netty。

    这样就意味着项目在启动的要注意启动顺序,使用InitializingBean虽然可以优雅的调用,不过对于这种在启动的时候就要开始监听端口,就需要属性值的方法,这种方式并不合适。

    // DataForIOTConstant.TCP_POST_CONSTANT 优雅的调用配置对象
    channelFuture = serverBootstrap.bind(DataForIOTConstant.TCP_POST_CONSTANT).sync();
    Log.get().info("绑定了端口{},开始进行监听",dataForIOTConstant.getTcpPost());
    

    问题解决

    放弃使用InitializingBean,直接通过get对属性值调用即可。

    小结:

    这里是idea和jar的启动不同导致了开发时候没有问题,而部署的时候出现空指针。

    即idea是先启动初始化所有的bean实例,在开始调用方法。(这样并不会出现属性为空的情况)。

    jar包启动,bean的初始化时放在了最后的。

    后记:小编在写总结的时候模拟bug却没有再现。而且本来空指针的error也没出现过。。。如果有遇到一样问题的朋友们我这个可以作为一个选项,如果有大佬知道其中的原因,可以指导一下。

    展开全文
  • 1、门岗系统(gatekeeper),选型springBoot-2.1.16.RELEASE ,netty-4.1.12.Final,websocket等主要核心技术, 部署在门口老大爷的window台式机上。 2、停车终控系统(parking),基于C语言设计研发, 部署在...

    业务场景:

    基于商业停车场的主程序控制系统(C语言开发)socket TCP 通信协议,建设B/S模式的门岗收费系统。

    系统简介:

    1、门岗系统(gatekeeper),选型 springBoot-2.1.16.RELEASE ,netty-4.1.12.Final,websocket等主要核心技术,

        部署在门口老大爷的window台式机上。

    2、停车终控系统(parking),基于C语言设计研发,

        部署在树莓派上。

    gatekeeper为什么部署在门口老大爷的window台式机上?系统的高可用不考虑了?,parking为什么部署在树莓派上?

    一句话:控制成本!!!没有那么多6个9或5个9的高可用要求,这就是二三线城市,低成本投入,活着很重要。

     

    核心技术实现:

    1、 SocketClientImpl.java

    @Slf4j
    @Order(value = 1)//这里表示启动顺序
    @Component("com.pasq.gatekeeper.service.impl.SocketClientImpl")
    public class SocketClientImpl implements ISocketClient , CommandLineRunner {
        public static ChannelFuture channelFuture;
        public static Bootstrap bootstrap;
        // 注册线程池
        public static EventLoopGroup eventLoopGroup = new NioEventLoopGroup();
    
        public static String ip;
        public static int port;
    
        @Value("${gate.socket.ip:192.168.200.218}")
        public  void setIp(String ip) {
            SocketClientImpl.ip = ip;
        }
        @Value("${gate.socket.port:8313}")
        public  void setPort(int port) {
            SocketClientImpl.port = port;
        }
        public static String getIp() {
            return ip;
        }
    
        public static int getPort() {
            return port;
        }
    
        public void init() {
    
        }
    
        public void start(){
            log.info("========MyClient==start===========ip:{},port:{}",ip,port);
            try{
    
                if(eventLoopGroup == null){
                    eventLoopGroup = new NioEventLoopGroup();
                }
    
                // bootstrap 可重用
                bootstrap = new Bootstrap();
                bootstrap.group(eventLoopGroup)
                        .channel(NioSocketChannel.class)
                        .handler(new MyClientInitializer());
    
                // 服务器异步创建绑定
                channelFuture = bootstrap.connect(ip,port).sync();
    
                channelFuture.addListener(new ConnectionListener());
    
                //TODO 断线重连 自动静默登录
                if(Contants.AUTO_LOGIN){
                    Contants.AUTO_LOGIN = false;
                    auotLogin();
                }
    
                // 关闭服务器通道
                channelFuture.channel().closeFuture().sync();
            } catch (Exception e) {
                try{
                    //使用过程中断线重连
                    eventLoopGroup.schedule(new Runnable() {
                        private SocketClientImpl nettyClient = new SocketClientImpl();
                        @Override
                        public void run() {
                            nettyClient.init();
                            nettyClient.start();
                        }
                    }, 15, TimeUnit.SECONDS);
                }catch (Exception e1){
                    e1.printStackTrace();
                }
                //eventLoopGroup.shutdownGracefully();
                e.printStackTrace();
            }
    
        }
    
        @PreDestroy
        public void stop() {
            log.info("========eventLoopGroup.stop==end===========");
            if (channelFuture != null) {
                log.info("Netty Server close");
                // 释放线程池资源
                eventLoopGroup.shutdownGracefully();
            }
        }
    
        @Async//注意这里,组件启动时会执行run,这个注解是让线程异步执行,这样不影响主线程
        @Override
        public void run(String... args) throws Exception {
            this.init();
            this.start();
        }
    
        @Override
        public void sendMsg(String msg) {
            channelFuture.channel().writeAndFlush(msg);
        }
        /**
         * 静默自动登录
         */
        public void auotLogin(){
            log.info("=========================auotLogin==========================");
            Iterator<String> keys = WebSocketBiz.webSocketMap.keySet().iterator();
            while (keys.hasNext()){
                String toUserId = "";
                try{
                    toUserId = keys.next();
                    String reqStr = Contants.AUTO_LOGIN_PARAM.get(toUserId);
                    LoginUserDTO reqDTO = JSON.toJavaObject(JSON.parseObject(reqStr),LoginUserDTO.class);
    
                    LoginVO vo = new LoginVO();
                    //登录
                    CBaseDTO dto = new CBaseDTO(Contants.CODE_10,Contants.CODE_10);
                    dto.setData(reqDTO);
                    String sendMsg = JSON.toJSONString(dto, SerializerFeature.WriteMapNullValue);
                    sendMsg(sendMsg);
    
                    //登录失败信息
                    String msg = getCurrentSocketMsg(Contants.CODE_19);
                    if(StringUtils.isEmpty(msg)){
                        //成功登录
                        msg = getCurrentSocketMsg(Contants.CODE_20);
                    }
                    if(!StringUtils.isEmpty(msg)){
                        String dataStr = JSON.parseObject(msg).getString("data");
                        vo = JSON.parseObject(dataStr, LoginVO.class);
                    }
                    log.info("====autoLogin=toUserId:{},reqDTO:{},vo:{}",toUserId, JSON.toJSONString(reqDTO),JSON.toJSONString(vo));
                }catch (Exception e){
                    log.error("====autoLogin==error==toUserId:{}",toUserId,e.getMessage(),e);
                }
            }
    
        }
    
        /**
         * 通过 code 获取 对象的socket 返回信息
         * @param code
         * @return
         */
        public  String getCurrentSocketMsg(int code){
            String msg = null;
            int i =0 ;
            //尝试取 3次
            while (i < 3 && StringUtils.isEmpty(msg)){
                i ++;
                msg = ISocketClient.CALL_MSG.get(code);
                if(StringUtils.isEmpty(msg)){
                    try {
                        Random random = new Random();
                        int k = random.nextInt(10);
                        Thread.sleep(100 * k);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
    
            //仅支持一个在线 访问
            //ISocketClient.CALL_MSG.clear();
            Iterator<Integer> iter = ISocketClient.CALL_MSG.keySet().iterator();
            while(iter.hasNext()){
                Integer key = iter.next();
                if(key==code){
                    iter.remove();
                    //ISocketClient.CALL_MSG.remove(key);
                }
            }
    
            return msg;
        }
    
    }

     

    2、ConnectionListener.java

    /**
     * 监控 netty连接是否断线重连
     */
    @Slf4j
    public class ConnectionListener implements ChannelFutureListener {
        private SocketClientImpl nettyClient = new SocketClientImpl();
    
        @Override
        public void operationComplete(ChannelFuture channelFuture) throws Exception {
            log.info("=============ConnectionListener==operationComplete====================");
            if (!channelFuture.isSuccess()) {
                log.info("ConnectionListener: 服务端链接不上,开始重连操作=======");
                final EventLoop loop = channelFuture.channel().eventLoop();
                loop.schedule(new Runnable() {
                    @Override
                    public void run() {
                        nettyClient.init();
                        nettyClient.start();
                    }
                }, 15, TimeUnit.SECONDS);
            } else {
                log.info("=====================ConnectionListener服务端链接成功========");
            }
    
    
        }
    
    
    }

     

    3、MyClientInitializer.java

    /**
     * 定义 netty client 初始化配置
     */
    @Slf4j
    @Component("com.pasq.gatekeeper.socketc.MyClientInitializer")
    public class MyClientInitializer extends ChannelInitializer<SocketChannel> {
        @Autowired
        private MyClientHandler clientHandler;
    
        private static final int MAX_FRAME_LENGTH = 1024 * 1024;  //最大长度
    
        @Override
        protected void initChannel(SocketChannel ch) throws Exception {
            ChannelPipeline pipeline = ch.pipeline();
            pipeline.addLast("ping", new IdleStateHandler(25, 15, 10, TimeUnit.SECONDS));
    //控制先追加待发送消息的长度 且是4位 低位在后。
            pipeline.addFirst(new LengthFieldBasedFrameDecoder(ByteOrder.LITTLE_ENDIAN,MAX_FRAME_LENGTH,0,4,0,4,false));
    
            //以$为分隔符
            ByteBuf buf = Unpooled.copiedBuffer("$".getBytes());
            //pipeline.addLast(new DelimiterBasedFrameDecoder(8192, buf));
            //增加消息头size
            pipeline.addLast(new LengthFieldPrepender(ByteOrder.LITTLE_ENDIAN,4,0,false));
    
            pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8));
            pipeline.addLast(new StringEncoder(CharsetUtil.UTF_8));
            if(clientHandler == null){
                pipeline.addLast(new MyClientHandler());
            }else{
                pipeline.addLast(clientHandler);
            }
    
        }
    
    }

     

    4、MyClientHandler.java

     

    /**
     * netty client handler 消息消费的具体的客户端 实现
     */
    @Slf4j
    @Component("com.pasq.gatekeeper.socketc.MyClientHandler")
    public class MyClientHandler extends SimpleChannelInboundHandler<String> {
    
        @Override
        protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
            boolean flag = true;
            //服务端的远程地址
            log.info("==accept msg:{}==channelRead0==ip:{},client accept==",msg,ctx.channel().remoteAddress());
            if(msg == null || "".equals(msg.trim())){
                log.error("==========MyClientHandler result error===");
            }else{
               try{
                   JSONObject obj = JSON.parseObject(msg);
                   int code = obj.getInteger("code");
                   //0 代表心跳
                   if(code != 0){
                       flag = false;
                       //接受消息
                       ISocketClient.CALL_MSG.put(code,msg);
                       //TODO 推送WS socket
                       WebSocketBiz.sendMsgToWS(code,msg);
                   }else{
                       //推送心跳,防止ws 关闭
                       WebSocketBiz.sendMsgToWS(code,msg);
                   }
               }catch (Exception e){
                   log.error("===error===============================error=msg:{}=====:{}",msg,e.getMessage(),e);
               }
            }
    
    
            if(flag){
                JumpDTO jump = new JumpDTO();
                jump.setIp("192.168.1.218");
                CBaseDTO dto = new CBaseDTO();
                dto.setData(jump);
                String sendMsg = JSON.toJSONString(dto);
                //发送心跳
                ctx.writeAndFlush(sendMsg);
            }
    
        }
    
        /**
         * 当服务器端与客户端进行建立连接的时候会触发,如果没有触发读写操作,则客户端和客户端之间不会进行数据通信,
         * 也就是channelRead0不会执行,
         * 当通道连接的时候,触发channelActive方法向服务端发送数据触发服务器端的handler的channelRead0回调,然后
         * 服务端向客户端发送数据触发客户端的channelRead0,依次触发。
         */
        @Override
        public void channelActive(ChannelHandlerContext ctx) throws Exception {
            String hostAddress = "";
            try{
                InetAddress address = InetAddress.getLocalHost();//获取的是本地的IP地址 //PC-20140317PXKX/192.168.0.121
                hostAddress = address.getHostAddress(); //192.168.0.121
            }catch (Exception e){
                hostAddress = "192.168.1.200";
            }
            JumpDTO jump = new JumpDTO();
            jump.setIp(hostAddress);
    
            CBaseDTO dto = new CBaseDTO();
            dto.setData(jump);
            String sendMsg = JSON.toJSONString(dto);
    
            log.info("===channelActive===client start connect server:" + ctx.channel().localAddress() + "channelActive");
            ctx.writeAndFlush(sendMsg);
    
        }
    
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
            log.error("===exceptionCaught===error:{}",cause.getMessage(),cause);
            cause.printStackTrace();
            ctx.close();
        }
    
        //服务端突然挂了
        @Override
        public void channelInactive(ChannelHandlerContext ctx) throws Exception {
            log.info("============channelInactive===掉线了==============");
            //掉线重连时 自动静默登录
            Contants.AUTO_LOGIN = true;
            //使用过程中断线重连
            final EventLoop eventLoop = ctx.channel().eventLoop();
            eventLoop.schedule(new Runnable() {
                private SocketClientImpl nettyClient = new SocketClientImpl();
                @Override
                public void run() {
                    nettyClient.init();
                    nettyClient.start();
                }
            }, 15, TimeUnit.SECONDS);
            super.channelInactive(ctx);
        }
    
            /**
             * 一段时间未进行读写操作 回调
             */
        /*@Override
        public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
            log.info("========userEventTriggered===================");
            super.userEventTriggered(ctx, evt);
    
            if (evt instanceof IdleStateEvent) {
    
                IdleStateEvent event = (IdleStateEvent) evt;
    
                if (event.state().equals(IdleState.READER_IDLE)) {
                    //未进行读操作
                    log.error("READER_IDLE");
                    // 超时关闭channel
                    ctx.close();
    
                } else if (event.state().equals(IdleState.WRITER_IDLE)) {
    
                } else if (event.state().equals(IdleState.ALL_IDLE)) {
                    //未进行读写
                    log.error("=============ALL_IDLE==============userEventTriggered==");
                    // 发送心跳消息
                    JumpDTO jump = new JumpDTO();
                    jump.setIp("192.168.1.218");
                    CBaseDTO dto = new CBaseDTO();
                    dto.setData(jump);
                    String sendMsg = JSON.toJSONString(dto);
                    //发送心跳
                    ctx.writeAndFlush(sendMsg);
                }
            }
        }*/
    
    }

     

    至此:netty client 与 C service 的 socket 通信核心基本实现。支持断线重连,心跳消息发送。

    展开全文
  • 但是我想做一下技术的积累,自己实现一个IM服务器,主要实现的功能有:消息的发送和云端存储,离线消息,已读回执,集群部署,用户授权,强制下线,关键字过滤等。最后加入音视频聊天。 从今天开始,我将记录下实现...
  • 工程背景:springboot + netty(如果单纯的springboot同样适用) 工程打包,包括:工程IP的更换,数据库IP地址更换。 工程上传到指定服务器,数据库导入服务器数据库管理系统。 通过命令运行服务器中上传的工程包。 ...
  • 作者 | LemonSquash来源 | cnblogs.com/npeng/p/14267007.html1.手工方式1.1.准备Springboot jar项目将项目打包成jar1....
  • 源码精品专栏 原创 | Java 2020 超神之路,很肝~ 中文详细注释的开源项目 RPC 框架 Dubbo 源码解析 网络应用框架 Netty 源码解析 消息中间件 RocketMQ 源码解析 数据库中间件 Sharding-JDBC 和 MyCAT 源码解析 ...
  • 今天将本机尬聊一下项目(基于netty-socketio)的服务端程序调试好以后,通过jar包部署在服务器的时候,出现了报错,提示tomcat已经占用了端口。 之前在部署iReview项目时的确是通过war包 + tomcat部署的,难道只要...
  • 问题:控制台上有报错 Deploying 'xxx-server-project Dockerfile: Dockerfile'... Failed to deploy 'xxx-server-project Dockerfile .... 原因:Idea没有以管理员权限启动。 ...若通过Toolbox启动,则也需要以...
  • 问题描述:在Tomcat中热部署Springboot应用时,报如下错误: java.net.BindException: Address already in use at sun.nio.ch.Net.bind0(Native Method) at sun.nio.ch.Net.bind(Net.java:444) at sun.nio....
  • Netty-socketio Overview This project is an open-source Java implementation of Socket.IO server. Based on Netty server framework. Checkout Demo project Licensed under the Apache License 2.0. Features ...
  • Netty 是一个高性能的 NIO 网络框架,本文基于 SpringBoot 以常见的心跳机制来认识 Netty。 最终能达到的效果: 客户端每隔 N 秒检测是否需要发送心跳。 服务端也每隔 N 秒检测是否需要发送心跳。 服务端可以主动...
  • Netty部署集群后都会遇到跨服务端怎么通信,也就是有集群服务X和Y,用户A链接服务X,用户B链接服务Y,那么他们都不在一个服务内怎么通信?本章节将介绍一种实现方式案例,以满足跨服务之间的用...
  • 最近在搭建SpringBoot项目时遇到打war包部署外部Tomcat,war包能解压,能运行,不报任何错误,但是不能访问tomcat也不能访问项目!本人也是第一次用Netty框架,排查问题和解决过程走了很多弯路,特此将这次过程记录...
  • 看这篇博文之前,你需要对netty的使用有基本的了解,比如服务端建立,... 废话不多说,结合自己的实践,在此给大家做一下使用springboot+netty开发聊天室的详细介绍。我力求话语简单直白,不给大家增加疑惑。 ...
  • SpringBoot 整合 Netty + Websocket

    千次阅读 2019-09-07 17:35:55
    最近项目中需要用到长连接服务,特地整合Netty+Websocket。我们系统需要给用户主动推送订单消息,还有强制用户下线的功能也需要长连接来推送消息 一、准备工作 Netty的介绍就看这里:...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 5,104
精华内容 2,041
关键字:

netty部署springboot

spring 订阅