ToB企服应用市场:ToB评测及商务社交产业平台

标题: 从零开始搭建游戏服务器 第一节 创建一个简单的服务器架构 [打印本页]

作者: 郭卫东    时间: 2024-7-23 16:19
标题: 从零开始搭建游戏服务器 第一节 创建一个简单的服务器架构
弁言

由于现在java web太卷了,所以各位偕行可以考虑换一个赛道,做游戏还是很开心的。
本篇教程给新人用于学习游戏服务器的根本知识,给新人们一些学习方向,有什么错误的地方欢迎各位偕行举行讨论。
技术选型

开发语言Java

  1. 目前主流的游戏服务器开发语言有C+lua(skynet)、C++、Python、Go、Java。
  2. 在广州有些公司习惯使用Erlang。
复制代码
缓存数据库Redis

  1. 基本上是唯一的选择,部分小公司制作滚服的游戏由于每个服务器人数不多所以不上Redis。
复制代码
持久化数据库MongoDB

  1. 也有部分使用MySQL,最近面试的公司比较多都从MySQL转到MongoDB。我进入公司后也着手将公司内的DB服改造成了使用MongoDB的存储服务。
复制代码
架构设计

团体服务器架构计划使用比较主流的 登录服 + 游戏服 的分布式架构。
登录服用来接收客户端连接,并将其上传的数据发送到对应的游戏服。
可以有多个登录服+多个游戏服用于负载平衡。
正文

本着先完成再完美的原则,从最简单的echo服务器开始。

Echo服务器就是,客户端发什么数据,服务端就原样返回归去。
创建底子架构

IDEA创建项目


我这边用Gradle举行依赖管理,使用的版本为 gradle8.1.1, openjdk17+.
我开发的时候习惯使用最新版本的,所以openjdk我已经升级到20了,不过根本不会用到17以上的特性,所以没有20的用17也足够。
修改build.gradle导入几个底子开发包。
同样的我用的包也都是导入最新的稳定包。
  1. subprojects {        // 使用多模块开发,主gradle配置加上subprojects
  2.         // ...
  3.         dependencies {
  4.         //spring
  5.         implementation 'org.springframework:spring-context:6.1.4'
  6.         //netty
  7.         implementation 'io.netty:netty-all:4.1.107.Final'
  8.         //日志
  9.         implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.36'
  10.         implementation group: 'ch.qos.logback', name: 'logback-core', version: '1.4.12'
  11.         implementation group: 'ch.qos.logback', name: 'logback-access', version: '1.2.11'
  12.         implementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.4.14'
  13.         implementation group: 'net.logstash.logback', name: 'logstash-logback-encoder', version: '7.4'
  14.         //Akka
  15.         implementation group: 'com.typesafe.akka', name: 'akka-actor-typed_3', version: '2.8.5'
  16.                 //lombok
  17.         compileOnly 'org.projectlombok:lombok:1.18.30'
  18.         annotationProcessor 'org.projectlombok:lombok:1.18.30'
  19.     }
  20. }
复制代码
配置多模块

将创建出来的src目次删除,然后按ctrl+alt+shift+s打开项目配置。

在Modules目次下为根项目添加多个module,分别为
client: 测试用的客户端程序
common: 通用模块,通用的代码放在这个模块下面
gameServer: 游戏服模块
loginServer: 登录服模块
前置开发

先在common模块配置一个服务启动器基类BaseMain
  1. @Slf4j
  2. public abstract class BaseMain {
  3.     public boolean shutdownFlag = false;
  4.     protected void init() {
  5.         initServer();
  6.         initListenConsoleInput();
  7.     }
  8.     /**
  9.      * 初始化控制台输入监听
  10.      */
  11.     private void initListenConsoleInput() {
  12.         //region 处理控制台输入,每秒检查一遍 shutdownFlag,为true就跳出循环,执行关闭操作
  13.         BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
  14.         while (true) {
  15.             if (this.shutdownFlag) {
  16.                 log.info("收到kill-15信号,跳出while循环,准备停服");
  17.                 break;
  18.             }
  19.             //线程休眠一秒
  20.             try {
  21.                 Thread.sleep(1000L);
  22.             } catch (InterruptedException e) {
  23.                 e.printStackTrace();
  24.             }
  25.             //处理控制台指令
  26.             try {
  27.                 if (br.ready()) {
  28.                     String str = br.readLine().trim();
  29.                     log.info("后台指令: {}", str);
  30.                     if ("stop".equals(str)) {
  31.                         this.shutdownFlag = true;
  32.                     } else {
  33.                         handleBackGroundCmd(str);//子类实现
  34.                     }
  35.                 }
  36.             } catch (Exception e) {
  37.                 e.printStackTrace();
  38.                 log.error("执行命令失败:遇到致命错误");
  39.             }
  40.         }
  41.         //endregion
  42.         //region 关闭服务器前执行的逻辑,加上try-catch防止异常导致无法关服
  43.         try {
  44.             onShutdown();
  45.         } catch (Exception e) {
  46.             log.error("执行关闭服务器逻辑出现异常了!!!!", e);
  47.         }
  48.         //endregion
  49.     }
  50.     /**
  51.      * 虚方法:处理控制台传过来的指令
  52.      * @param cmd 指令
  53.      */
  54.     protected abstract void handleBackGroundCmd(String cmd);
  55.         /**
  56.         * 服务器关闭时的操作
  57.         */
  58.     protected void onShutdown(){}
  59.     /**
  60.      * 各个服务初始化要做的事情
  61.      */
  62.     protected abstract void initServer();
  63. }
复制代码
这个抽象类规定了服务器生命周期需要实现的方法
并且实现了initListenConsoleInput()使得程序可以接收控制台中输入的指令。
创建一个SpringUtils,用于快速获取Spring中的bean
  1. @Component
  2. @Lazy(false)
  3. public class SpringUtils implements ApplicationContextAware {
  4.     private static ApplicationContext context;
  5.     @Override
  6.     public void setApplicationContext(ApplicationContext applicationContext) {
  7.         context = applicationContext;
  8.     }
  9.     /**
  10.      * 通过字节码获取
  11.      * @param beanClass Class
  12.      * @return bean
  13.      */
  14.     public static <T> T getBean(Class<T> beanClass) {
  15.         return context.getBean(beanClass);
  16.     }
  17.     public static <T> T getBean(String beanName) {
  18.         return (T) context.getBean(beanName);
  19.     }
  20. }
复制代码
SpringUtils实现了ApplicationContextAware接口,在程序启动时会自动调用setApplicationContext加载applicationContext。
后面要获取某个bean就使用SpringUtils.getBean就可以。
日志系统配置logback.xml 这部分先不讲。
登录服开发

现在回到loginServer模块中举行开发。
先将common模块导入到loginServer的依赖中。
修改loginServer模块下的build.gradle
  1. dependencies {
  2.     implementation project(path: ':common')
  3. }
复制代码
创建Bean配置类
  1. @Configuration
  2. @ComponentScan(basePackages = {"org.login", "org.common"}) // 扫描包需要包括login服和common模块的包名
  3. public class LoginBeanConfig {
  4. }
复制代码
创建主类
  1. @Component
  2. @Slf4j
  3. public class LoginMain extends BaseMain{
  4.         public static void main(String[] args) {
  5.         AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(LoginBeanConfig.class);
  6.         context.start();
  7.         LoginMain loginMain = SpringUtils.getBean(LoginMain.class);
  8.         loginMain.init();
  9.         System.exit(0);
  10.     }
  11.     @Override
  12.     protected void initServer() {
  13.                 log.info("LoginServer start!");
  14.         }
  15.     @Override
  16.     protected void handleBackGroundCmd(String cmd) {}
  17.     @Override
  18.     protected void onShutdown() {
  19.         log.warn("LoginServer is ready to shutdown.");
  20.     }
  21. }
复制代码
运行一下,正常输出LoginServer start!
运行Netty服务

要与客户端举行TCP连接,需要建立socket通道,然后通过socket通道举行数据交互。
传统BIO一个线程一个连接,有新的连接进来时就要创建一个线程,并持续读取数据流,当这个连接发送任何哀求时,会对性能造成严重浪费。
NIO一个线程通过多路复用器可以监听多个连接,通过轮询判断连接是否有数据哀求。
Netty对java原生NIO举行了封装,简化了代码,便于我们的使用。
Netty的包我们之前已经导入过了。
起首我们在common模块创建一个Netty自界说消息处置惩罚类。
  1. package org.common.netty;
  2. import io.netty.channel.SimpleChannelInboundHandler;
  3. /**
  4. * netty消息处理器基类
  5. */
  6. public abstract class BaseNettyHandler extends SimpleChannelInboundHandler<byte[]> {
  7. }
复制代码
再创建一个NettyServer用来启动netty服务
  1. package org.common.netty;
  2. import ...
  3. /**
  4. * netty服务器
  5. */
  6. @Slf4j
  7. public class NettyServer {
  8.     private final BaseNettyHandler handler;
  9.     public NettyServer(BaseNettyHandler handler) {
  10.         this.handler = handler;
  11.     }
  12.     public void start(int port) {
  13.         final EventLoopGroup boss = new NioEventLoopGroup(1);
  14.         final EventLoopGroup worker = new NioEventLoopGroup();
  15.         try {
  16.             ServerBootstrap bootstrap = new ServerBootstrap();
  17.             bootstrap.group(boss, worker);
  18.             bootstrap.channel(NioServerSocketChannel.class);
  19.             bootstrap.option(ChannelOption.SO_REUSEADDR, true);//允许重用端口
  20.             bootstrap.option(ChannelOption.SO_BACKLOG, 512);//允许多少个新请求进入等待
  21.             bootstrap.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);//是否使用内存池
  22.             bootstrap.childOption(ChannelOption.SO_KEEPALIVE, true);
  23.             bootstrap.childOption(ChannelOption.TCP_NODELAY, false);
  24.             bootstrap.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);//是否使用内存池
  25.             bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
  26.                 @Override
  27.                 protected void initChannel(SocketChannel ch) throws Exception {
  28.                     ChannelPipeline pipeline = ch.pipeline();
  29.                     // ----------  解码器  -------------
  30.                     // 1. 读取数据的长度
  31.                     pipeline.addLast(new LengthFieldBasedFrameDecoder(10 * 1024 * 1024, 0, 4, 0, 4));
  32.                     // 2. 将ByteBuf转成byte[]
  33.                     pipeline.addLast(new ByteToMessageDecoder() {
  34.                         @Override
  35.                         protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
  36.                             if (in.isReadable()) {
  37.                                 byte[] bytes = new byte[in.readableBytes()];
  38.                                 in.readBytes(bytes);
  39.                                 out.add(bytes);
  40.                             }
  41.                         }
  42.                     });
  43.                     // ----------  编码器  --------------
  44.                     // 2. 添加数据的长度到数据头
  45.                     pipeline.addLast(new LengthFieldPrepender(4));
  46.                     // 1. 将打包好的数据由byte[]转成ByteBuf
  47.                     pipeline.addLast(new MessageToByteEncoder<byte[]>() {
  48.                         @Override
  49.                         protected void encode(ChannelHandlerContext ctx, byte[] msg, ByteBuf out) throws Exception {
  50.                             out.writeBytes(msg);
  51.                         }
  52.                     });
  53.                     // ----------  自定义消息处理器 -----------
  54.                     pipeline.addLast(handler);
  55.                 }
  56.             });
  57.             bootstrap.bind(port).sync();
  58.         } catch (InterruptedException e) {
  59.             throw new RuntimeException(e);
  60.         }
  61.         Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  62.             boss.shutdownGracefully();
  63.             worker.shutdownGracefully();
  64.         }));
  65.         log.info("Start NettyServer ok!");
  66.     }
  67. }
复制代码
要注意编码器和解码器的入栈顺序。
当接收到消息时,数据会重新向后流入解码器;当发送消息时,会从尾向前流入编码器。
回到loginServer模块,
我们先添加一个配置类用于配置绑定端口login.conf
  1. player.port=8081
复制代码
创建配置类LoginConfig
  1. /**
  2. * 登录服配置文件
  3. */
  4. @Getter
  5. @Component
  6. @PropertySource("classpath:login.conf")
  7. public class LoginConfig {
  8.     @Value("${player.port}")
  9.     private int port;
  10. }
复制代码
loginServer的自界说消息处置惩罚器LoginNettyHandler
  1. @Slf4j
  2. @ChannelHandler.Sharable
  3. public class LoginNettyHandler extends BaseNettyHandler {
  4.     /**
  5.      * 收到协议数据
  6.      */
  7.     @Override
  8.     protected void channelRead0(ChannelHandlerContext ctx, byte[] msg) throws Exception {
  9.         log.info(new String(msg));
  10.         ctx.channel().writeAndFlush(msg);
  11.     }
  12.     /**
  13.      * 建立连接
  14.      */
  15.     @Override
  16.     public void channelActive(ChannelHandlerContext ctx) throws Exception {
  17.         InetSocketAddress address = (InetSocketAddress) ctx.channel().remoteAddress();
  18.         String ip = address.getAddress().getHostAddress();
  19.         if (ctx.channel().isActive()) {
  20.             log.info("创建连接—成功:ip = {}", ip);
  21.         }
  22.     }
  23.     /**
  24.      * 连接断开
  25.      */
  26.     @Override
  27.     public void channelInactive(ChannelHandlerContext ctx) throws Exception {
  28.         log.info("连接断开");
  29.     }
  30.     @Override
  31.     public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
  32.         if (cause instanceof ClosedChannelException) {
  33.             return;
  34.         }
  35.         cause.printStackTrace();
  36.         ctx.close();
  37.     }
  38. }
复制代码
这个自界说消息处置惩罚类将在接收到消息时,将byte[]数据还原成String。
在bean配置类中添加NettyServer的bean
  1.         @Bean
  2.     NettyServer socketServer() {
  3.         LoginNettyHandler handler = new LoginNettyHandler();
  4.         return new NettyServer(handler);
  5.     }
复制代码
修改LoginMain的initServer方法
  1.     protected void initServer() {
  2.         LoginConfig config = SpringUtils.getBean(LoginConfig.class);
  3.         // netty启动
  4.         NettyServer nettyServer = SpringUtils.getBean(NettyServer.class);
  5.         nettyServer.start(config.getPort());
  6.         log.info("LoginServer start!");
  7.     }
复制代码
我们当我们启动LoginMain时,创建了一个Netty服务器,同时绑定了端口8081。然后程序不绝循环监听控制台输入直到输入stop时停机。
我们要注意一下initChannel这块代码,添加了netty自带的长度编码器和解码器,他会在消息头部插入一个消息体的长度,方便程序知道一次协议发送的数据长度。然后添加了ByteBuf转byte[]解码器和byte[]转ByteBuf的编码器,因为我们后面的自界说消息处置惩罚使用byte[],所以直接在这里举行转换。最后我们添加了一个自界说的消息处置惩罚器LoginNettyHandler用来将收到的信息打印。
至此服务端Netty接入完毕,我们下面编写一个客户端举行测试。
编写客户端举行测试

到client模块举行开发。
创建Netty客户端NettyClient
  1. @Slf4j
  2. @Component
  3. public class NettyClient {
  4.     private Channel channel;
  5.     public void start(String host, int port) {
  6.         final EventLoopGroup group = new NioEventLoopGroup();
  7.         try {
  8.             Bootstrap bootstrap = new Bootstrap();
  9.             bootstrap.group(group);
  10.             bootstrap.channel(NioSocketChannel.class);
  11.             bootstrap.handler(new ChannelInitializer<SocketChannel>() {
  12.                 @Override
  13.                 protected void initChannel(SocketChannel ch) throws Exception {
  14.                     ChannelPipeline pipeline = ch.pipeline();
  15.                     // ----------  解码器  -------------
  16.                     // 1. 读取数据的长度
  17.                     pipeline.addLast(new LengthFieldBasedFrameDecoder(10 * 1024 * 1024, 0, 4, 0, 4));
  18.                     // 2. 将ByteBuf转成byte[]
  19.                     pipeline.addLast(new ByteToMessageDecoder() {
  20.                         @Override
  21.                         protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
  22.                             if (in.isReadable()) {
  23.                                 byte[] bytes = new byte[in.readableBytes()];
  24.                                 in.readBytes(bytes);
  25.                                 out.add(bytes);
  26.                             }
  27.                         }
  28.                     });
  29.                     // ----------  编码器  --------------
  30.                     // 2. 添加数据的长度到数据头
  31.                     pipeline.addLast(new LengthFieldPrepender(4));
  32.                     // 1. 将打包好的数据由byte[]转成ByteBuf
  33.                     pipeline.addLast(new MessageToByteEncoder<byte[]>() {
  34.                         @Override
  35.                         protected void encode(ChannelHandlerContext ctx, byte[] msg, ByteBuf out) throws Exception {
  36.                             out.writeBytes(msg);
  37.                         }
  38.                     });
  39.                     // ----------  自定义消息处理器 -----------
  40.                     pipeline.addLast(new SimpleChannelInboundHandler<byte[]>() {
  41.                         @Override
  42.                         protected void channelRead0(ChannelHandlerContext ctx, byte[] msg) throws Exception {
  43.                             log.info(new String(msg));
  44.                             ctx.channel().writeAndFlush(msg);
  45.                         }
  46.                     });
  47.                 }
  48.             });
  49.             ChannelFuture future = bootstrap.connect(new InetSocketAddress(host, port)).sync();
  50.             channel = future.channel();
  51.         } catch (InterruptedException e) {
  52.             throw new RuntimeException(e);
  53.         }
  54.         Runtime.getRuntime().addShutdownHook(new Thread(group::shutdownGracefully));
  55.         log.info("Start NettyClient ok!");
  56.     }
  57.     public void send(byte[] data) {
  58.         channel.writeAndFlush(data);
  59.     }
  60. }
复制代码
与服务端的区别在于:
创建ClientMain类,继承BaseMain。
  1. @Component
  2. @Slf4j
  3. public class ClientMain extends BaseMain {
  4.     public static void main(String[] args) {
  5.         AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ClientBeanConfig.class);
  6.         context.start();
  7.         ClientMain clientMain = SpringUtils.getBean(ClientMain.class);
  8.         clientMain.init();
  9.         System.exit(0);
  10.     }
  11.     @Override
  12.     protected void handleBackGroundCmd(String cmd) {
  13.         if (cmd.equals("test")) {
  14.             NettyClient nettyClient = SpringUtils.getBean(NettyClient.class);
  15.             nettyClient.send("test".getBytes());
  16.         }
  17.     }
  18.     @Override
  19.     protected void initServer() {
  20.         ClientConfig config = SpringUtils.getBean(ClientConfig.class);
  21.         //netty启动
  22.         NettyClient nettyClient = SpringUtils.getBean(NettyClient.class);
  23.         nettyClient.start(config.getHost(), config.getPort());
  24.     }
  25. }
复制代码
测试一下,我们先运行服务器,再运行客户端。
在客户端控制台下输入test,就会向服务端发送数据“test”。
服务端收到消息后会原路返回给客户端。
可以乐成举行信息交互
总结

本节一共做了这么几件事:
下一节将举行注册登录的开发,内容将会比较多,感兴趣的点点关注或者留言评论。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4