目录
引言
由于现在java web太卷了,所以各位同行可以考虑换一个赛道,做游戏还是很开心的。
本篇教程给新人用于学习游戏服务器的基本知识,给新人们一些学习方向,有什么错误的地方欢迎各位同行进行讨论。
技术选型
开发语言Java
目前主流的游戏服务器开发语言有C+lua(skynet)、C++、Python、Go、Java。 在广州有些公司习惯使用Erlang。
缓存数据库Redis
基本上是唯一的选择,部分小公司制作滚服的游戏由于每个服务器人数不多所以不上Redis。
持久化数据库MongoDB
也有部分使用MySQL,最近面试的公司比较多都从MySQL转到MongoDB。我进入公司后也着手将公司内的DB服改造成了使用MongoDB的存储服务。
架构设计
整体服务器架构计划使用比较主流的 登录服 + 游戏服 的分布式架构。
登录服用来接收客户端连接,并将其上传的数据发送到对应的游戏服。
可以有多个登录服+多个游戏服用于负载均衡。
正文
本着先完成再完美的原则,从最简单的echo服务器开始。
Echo服务器就是,客户端发什么数据,服务端就原样返回回去。
创建基础架构
IDEA创建项目
我这边用Gradle进行依赖管理,使用的版本为 gradle8.1.1, openjdk17+.
我开发的时候习惯使用最新版本的,所以openjdk我已经升级到20了,不过基本不会用到17以上的特性,所以没有20的用17也足够。
修改build.gradle导入几个基础开发包。
同样的我用的包也都是导入最新的稳定包。
subprojects { // 使用多模块开发,主gradle配置加上subprojects // ... dependencies { //spring implementation 'org.springframework:spring-context:6.1.4' //netty implementation 'io.netty:netty-all:4.1.107.Final' //日志 implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.36' implementation group: 'ch.qos.logback', name: 'logback-core', version: '1.4.12' implementation group: 'ch.qos.logback', name: 'logback-access', version: '1.2.11' implementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.4.14' implementation group: 'net.logstash.logback', name: 'logstash-logback-encoder', version: '7.4' //Akka implementation group: 'com.typesafe.akka', name: 'akka-actor-typed_3', version: '2.8.5' //lombok compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' } }
配置多模块
将创建出来的src目录删除,然后按ctrl+alt+shift+s打开项目配置。
在Modules目录下为根项目添加多个module,分别为
client: 测试用的客户端程序
common: 通用模块,通用的代码放在这个模块下面
gameServer: 游戏服模块
loginServer: 登录服模块
前置开发
先在common模块配置一个服务启动器基类BaseMain
@Slf4j public abstract class BaseMain { public boolean shutdownFlag = false; protected void init() { initServer(); initListenConsoleInput(); } /** * 初始化控制台输入监听 */ private void initListenConsoleInput() { //region 处理控制台输入,每秒检查一遍 shutdownFlag,为true就跳出循环,执行关闭操作 BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); while (true) { if (this.shutdownFlag) { log.info("收到kill-15信号,跳出while循环,准备停服"); break; } //线程休眠一秒 try { Thread.sleep(1000L); } catch (InterruptedException e) { e.printStackTrace(); } //处理控制台指令 try { if (br.ready()) { String str = br.readLine().trim(); log.info("后台指令: {}", str); if ("stop".equals(str)) { this.shutdownFlag = true; } else { handleBackGroundCmd(str);//子类实现 } } } catch (Exception e) { e.printStackTrace(); log.error("执行命令失败:遇到致命错误"); } } //endregion //region 关闭服务器前执行的逻辑,加上try-catch防止异常导致无法关服 try { onShutdown(); } catch (Exception e) { log.error("执行关闭服务器逻辑出现异常了!!!!", e); } //endregion } /** * 虚方法:处理控制台传过来的指令 * @param cmd 指令 */ protected abstract void handleBackGroundCmd(String cmd); /** * 服务器关闭时的操作 */ protected void onShutdown(){} /** * 各个服务初始化要做的事情 */ protected abstract void initServer(); }
这个抽象类规定了服务器生命周期需要实现的方法
并且实现了initListenConsoleInput()使得程序可以接收控制台中输入的指令。
创建一个SpringUtils,用于快速获取Spring中的bean
@Component @Lazy(false) public class SpringUtils implements ApplicationContextAware { private static ApplicationContext context; @Override public void setApplicationContext(ApplicationContext applicationContext) { context = applicationContext; } /** * 通过字节码获取 * @param beanClass Class * @return bean */ public static <T> T getBean(Class<T> beanClass) { return context.getBean(beanClass); } public static <T> T getBean(String beanName) { return (T) context.getBean(beanName); } }
SpringUtils实现了ApplicationContextAware接口,在程序启动时会自动调用setApplicationContext加载applicationContext。
后面要获取某个bean就使用SpringUtils.getBean就可以。
日志系统配置logback.xml 这部分先不讲。
登录服开发
现在回到loginServer模块中进行开发。
先将common模块导入到loginServer的依赖中。
修改loginServer模块下的build.gradle
dependencies { implementation project(path: ':common') }
创建Bean配置类
@Configuration @ComponentScan(basePackages = {"org.login", "org.common"}) // 扫描包需要包括login服和common模块的包名 public class LoginBeanConfig { }
创建主类
@Component @Slf4j public class LoginMain extends BaseMain{ public static void main(String[] args) { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(LoginBeanConfig.class); context.start(); LoginMain loginMain = SpringUtils.getBean(LoginMain.class); loginMain.init(); System.exit(0); } @Override protected void initServer() { log.info("LoginServer start!"); } @Override protected void handleBackGroundCmd(String cmd) {} @Override protected void onShutdown() { log.warn("LoginServer is ready to shutdown."); } }
运行一下,正常输出LoginServer start!
运行Netty服务
要与客户端进行TCP连接,需要建立socket通道,然后通过socket通道进行数据交互。
传统BIO一个线程一个连接,有新的连接进来时就要创建一个线程,并持续读取数据流,当这个连接发送任何请求时,会对性能造成严重浪费。
NIO一个线程通过多路复用器可以监听多个连接,通过轮询判断连接是否有数据请求。
Netty对java原生NIO进行了封装,简化了代码,便于我们的使用。
Netty的包我们之前已经导入过了。
首先我们在common模块创建一个Netty自定义消息处理类。
package org.common.netty; import io.netty.channel.SimpleChannelInboundHandler; /** * netty消息处理器基类 */ public abstract class BaseNettyHandler extends SimpleChannelInboundHandler<byte[]> { }
再创建一个NettyServer用来启动netty服务
package org.common.netty; import ... /** * netty服务器 */ @Slf4j public class NettyServer { private final BaseNettyHandler handler; public NettyServer(BaseNettyHandler handler) { this.handler = handler; } public void start(int port) { final EventLoopGroup boss = new NioEventLoopGroup(1); final EventLoopGroup worker = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(boss, worker); bootstrap.channel(NioServerSocketChannel.class); bootstrap.option(ChannelOption.SO_REUSEADDR, true);//允许重用端口 bootstrap.option(ChannelOption.SO_BACKLOG, 512);//允许多少个新请求进入等待 bootstrap.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);//是否使用内存池 bootstrap.childOption(ChannelOption.SO_KEEPALIVE, true); bootstrap.childOption(ChannelOption.TCP_NODELAY, false); bootstrap.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);//是否使用内存池 bootstrap.childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); // ---------- 解码器 ------------- // 1. 读取数据的长度 pipeline.addLast(new LengthFieldBasedFrameDecoder(10 * 1024 * 1024, 0, 4, 0, 4)); // 2. 将ByteBuf转成byte[] pipeline.addLast(new ByteToMessageDecoder() { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { if (in.isReadable()) { byte[] bytes = new byte[in.readableBytes()]; in.readBytes(bytes); out.add(bytes); } } }); // ---------- 编码器 -------------- // 2. 添加数据的长度到数据头 pipeline.addLast(new LengthFieldPrepender(4)); // 1. 将打包好的数据由byte[]转成ByteBuf pipeline.addLast(new MessageToByteEncoder<byte[]>() { @Override protected void encode(ChannelHandlerContext ctx, byte[] msg, ByteBuf out) throws Exception { out.writeBytes(msg); } }); // ---------- 自定义消息处理器 ----------- pipeline.addLast(handler); } }); bootstrap.bind(port).sync(); } catch (InterruptedException e) { throw new RuntimeException(e); } Runtime.getRuntime().addShutdownHook(new Thread(() -> { boss.shutdownGracefully(); worker.shutdownGracefully(); })); log.info("Start NettyServer ok!"); } }
要注意编码器和解码器的入栈顺序。
当接收到消息时,数据会从头向后流入解码器;当发送消息时,会从尾向前流入编码器。
回到loginServer模块,
我们先添加一个配置类用于配置绑定端口login.conf
player.port=8081
创建配置类LoginConfig
/** * 登录服配置文件 */ @Getter @Component @PropertySource("classpath:login.conf") public class LoginConfig { @Value("${player.port}") private int port; }
loginServer的自定义消息处理器LoginNettyHandler
@Slf4j @ChannelHandler.Sharable public class LoginNettyHandler extends BaseNettyHandler { /** * 收到协议数据 */ @Override protected void channelRead0(ChannelHandlerContext ctx, byte[] msg) throws Exception { log.info(new String(msg)); ctx.channel().writeAndFlush(msg); } /** * 建立连接 */ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { InetSocketAddress address = (InetSocketAddress) ctx.channel().remoteAddress(); String ip = address.getAddress().getHostAddress(); if (ctx.channel().isActive()) { log.info("创建连接—成功:ip = {}", ip); } } /** * 连接断开 */ @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { log.info("连接断开"); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { if (cause instanceof ClosedChannelException) { return; } cause.printStackTrace(); ctx.close(); } }
这个自定义消息处理类将在接收到消息时,将byte[]数据还原成String。
在bean配置类中添加NettyServer的bean
@Bean NettyServer socketServer() { LoginNettyHandler handler = new LoginNettyHandler(); return new NettyServer(handler); }
修改LoginMain的initServer方法
protected void initServer() { LoginConfig config = SpringUtils.getBean(LoginConfig.class); // netty启动 NettyServer nettyServer = SpringUtils.getBean(NettyServer.class); nettyServer.start(config.getPort()); log.info("LoginServer start!"); }
我们当我们启动LoginMain时,创建了一个Netty服务器,同时绑定了端口8081。然后程序不断循环监听控制台输入直到输入stop时停机。
我们要注意一下initChannel这块代码,添加了netty自带的长度编码器和解码器,他会在消息头部插入一个消息体的长度,方便程序知道一次协议发送的数据长度。然后添加了ByteBuf转byte[]解码器和byte[]转ByteBuf的编码器,因为我们后面的自定义消息处理使用byte[],所以直接在这里进行转换。最后我们添加了一个自定义的消息处理器LoginNettyHandler用来将收到的信息打印。
至此服务端Netty接入完毕,我们下面编写一个客户端进行测试。
编写客户端进行测试
到client模块进行开发。
创建Netty客户端NettyClient
@Slf4j @Component public class NettyClient { private Channel channel; public void start(String host, int port) { final EventLoopGroup group = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.group(group); bootstrap.channel(NioSocketChannel.class); bootstrap.handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); // ---------- 解码器 ------------- // 1. 读取数据的长度 pipeline.addLast(new LengthFieldBasedFrameDecoder(10 * 1024 * 1024, 0, 4, 0, 4)); // 2. 将ByteBuf转成byte[] pipeline.addLast(new ByteToMessageDecoder() { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { if (in.isReadable()) { byte[] bytes = new byte[in.readableBytes()]; in.readBytes(bytes); out.add(bytes); } } }); // ---------- 编码器 -------------- // 2. 添加数据的长度到数据头 pipeline.addLast(new LengthFieldPrepender(4)); // 1. 将打包好的数据由byte[]转成ByteBuf pipeline.addLast(new MessageToByteEncoder<byte[]>() { @Override protected void encode(ChannelHandlerContext ctx, byte[] msg, ByteBuf out) throws Exception { out.writeBytes(msg); } }); // ---------- 自定义消息处理器 ----------- pipeline.addLast(new SimpleChannelInboundHandler<byte[]>() { @Override protected void channelRead0(ChannelHandlerContext ctx, byte[] msg) throws Exception { log.info(new String(msg)); ctx.channel().writeAndFlush(msg); } }); } }); ChannelFuture future = bootstrap.connect(new InetSocketAddress(host, port)).sync(); channel = future.channel(); } catch (InterruptedException e) { throw new RuntimeException(e); } Runtime.getRuntime().addShutdownHook(new Thread(group::shutdownGracefully)); log.info("Start NettyClient ok!"); } public void send(byte[] data) { channel.writeAndFlush(data); } }
与服务端的区别在于:
- 只有一个group用于数据处理。
- 使用的是Bootstrap而非ServerBootstrap。
- 最后使用的是bootstrap.connect创建连接到指定地址而非bootstrap.bind。
创建ClientMain类,继承BaseMain。
@Component @Slf4j public class ClientMain extends BaseMain { public static void main(String[] args) { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ClientBeanConfig.class); context.start(); ClientMain clientMain = SpringUtils.getBean(ClientMain.class); clientMain.init(); System.exit(0); } @Override protected void handleBackGroundCmd(String cmd) { if (cmd.equals("test")) { NettyClient nettyClient = SpringUtils.getBean(NettyClient.class); nettyClient.send("test".getBytes()); } } @Override protected void initServer() { ClientConfig config = SpringUtils.getBean(ClientConfig.class); //netty启动 NettyClient nettyClient = SpringUtils.getBean(NettyClient.class); nettyClient.start(config.getHost(), config.getPort()); } }
测试一下,我们先运行服务器,再运行客户端。
在客户端控制台下输入test,就会向服务端发送数据“test”。
服务端收到消息后会原路返回给客户端。
可以成功进行信息交互
总结
本节一共做了这么几件事:
- 项目的初步创建,通过build.gradle进行多模块依赖包的管理。
- Netty服务器的启动,并且不断监听控制台输入,客户端上行数据的读取。
- 编写测试用客户端,与服务器进行数据交互。
下一节将进行注册登录的开发,内容将会比较多,感兴趣的点点关注或者留言评论。