从零开始搭建游戏服务器 第一节 创建一个简单的服务器架构

从零开始搭建游戏服务器 第一节 创建一个简单的服务器架构

目录

引言技术选型开发语言Java缓存数据库Redis持久化数据库MongoDB

架构设计正文创建基础架构IDEA创建项目配置多模块前置开发登录服开发运行Netty服务编写客户端进行测试

总结

引言

由于现在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 getBean(Class beanClass) {

return context.getBean(beanClass);

}

public static 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 {

}

再创建一个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() {

@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 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() {

@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() {

@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 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() {

@Override

protected void encode(ChannelHandlerContext ctx, byte[] msg, ByteBuf out) throws Exception {

out.writeBytes(msg);

}

});

// ---------- 自定义消息处理器 -----------

pipeline.addLast(new SimpleChannelInboundHandler() {

@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服务器的启动,并且不断监听控制台输入,客户端上行数据的读取。编写测试用客户端,与服务器进行数据交互。

下一节将进行注册登录的开发,内容将会比较多,感兴趣的点点关注或者留言评论。

相关推荐

2025年 10 款最佳小型SUV:更小巧的车身和更省油的发动机
孕妇秋天吃什么水果好
365体育在哪下载

孕妇秋天吃什么水果好

📅 07-12 👁️ 4221
Beats Powerbeats Pro全新配色即将上市,产品特点一览
365体育在哪下载

Beats Powerbeats Pro全新配色即将上市,产品特点一览

📅 07-15 👁️ 5430

友情链接