实现中... (目前大部分功能可用,欢迎测试反馈 bug) BUILDING... (most features are functional, welcome to test and report bugs)
这是一个使用 rust 实现的 arcaea 服务器,用于模拟 Arcaea 的主要功能。 逻辑基本上完全重写 lost 大佬的 Arcaea Server。水平一般,测试也少,总之可以当成玩具项目。 不过从性能上说应该会比 flask 版本更强一些(实测大概 rps 相当,不过写接口稳不少,目前还在进行性能提升中),虽然本项目大概率也用不上高并发什么的。
已实现的功能
- 用户系统(注册,登录
- 歌曲下载(bundle, songs)
- 成绩上传和排名(全球,好友,排名)
- score-v2
- linkplay
- world mode
- course
- 角色系统
- ....
- Rust 1.70+ (Install Rust)
- MariaDB/MySQL database
怎么装就不说了。装好之后需要确认你已经启动了数据库,并创建一个专门用于这个后端的账号密码。之后
cd <this_proj>
# 拷贝完不要忘记修改里面的对应的内容
# 尤其是数据库的连接要记得改,默认账号密码是我自己的测试环境随便设置的
# 相信你一眼就知道这些内容是做什么的
cp .env.example .env
cp Rocket.toml.example Rocket.toml
# 用 cargo 装一个管理数据库的工具
cargo install sqlx-cli
# 完成这步之前必须确认你的数据库已经好了
source .env && sqlx database create && sqlx migrate run
# 做完这一切之后,需要先初始化数据库,然后再开始跑
cargo run --bin init_db
# 初始用户账号密码是:admin / admin
cargo run
# 如果你用了反代域名(例如 https://arc.yinmo.site),建议在 .env 设置:
# DOWNLOAD_LINK_PREFIX=https://arc.yinmo.site/download/
# BUNDLE_DOWNLOAD_LINK_PREFIX=https://arc.yinmo.site/bundle_download/
# LINKPLAY_DISPLAY_HOST=arc.yinmo.site
# LINKPLAY_DISPLAY_PORT=10900
# 如果要开启 Redis 缓存,在 .env 设置 REDIS_URL。
# Redis 不开启时服务仍然可以正常运行,只是所有热点数据会直接查数据库。
# REDIS_URL=redis://127.0.0.1:6379/0
# 如果你要单独跑 linkplay 服务(UDP + TCP)
cargo run --bin linkplayd至于怎么部署上云,
cargo build --release之后去 target/release/<binary>找到对应二进制 scp 到服务器上,数据库,配置文件,乐曲数据,热更新包等等都放到对应位置了,用你喜欢的方式持久化运行这个二进制就行了。
数据库迁移由 sqlx::migrate!("./migrations") 在编译期内嵌进主服务二进制。部署时只需要上传新的 Arcaea_server_rs 二进制并重启服务,启动过程会自动执行尚未应用的迁移;服务器运行目录不需要保留或同步 migrations/ 文件。开发和构建时仍然需要仓库里的 migrations/,因为 sqlx 宏会在编译时读取它们。
Redis 是可选依赖,用于降低热点接口对 MySQL 的压力。当前缓存覆盖:
- 登录 token 鉴权结果
user/me用户信息聚合结果friend/me好友列表score/song总榜、score/song/me个人榜、score/song/friend好友榜- 用户 PTT 派生计算、用户 rating 读取、全服 world rank 计数
user/me聚合子块、曲目/全服排行 ZSET- 购买列表、world map 列表/单图
- S3 presigned URL 和可缓存的歌曲下载列表
相关配置都在 .env.example 的 Redis 段里。默认 TTL 比较短,是为了减少排行榜、好友列表、用户状态这类数据的陈旧窗口。生产环境可以根据实际读写比例调大,例如排行榜和购买列表通常可以比用户状态缓存得更久。
如果使用 macOS 本地测试,可以用:
brew install redis
brew services start redis
redis-cli ping管理台已改为前后端分离架构。前端在 frontend/,采用 React + TypeScript + Tailwind CSS + shadcn/ui 基础组件,并使用 pnpm 管理依赖;后端不再渲染 Askama 模板,只保留 /web/api/* JSON 接口。
# 终端 1:启动 Rust 后端
ROCKET_PORT=8090 cargo run
# 终端 2:启动前端开发服务器
cd frontend
pnpm install
pnpm devVite 已配置 /web/api 代理到 http://127.0.0.1:8090。生产部署时可以先用 pnpm build 生成 frontend/dist,再由反代或静态文件服务托管前端资源。
歌曲和 bundle 资源支持本地文件,也支持 S3 兼容存储。开启 S3 时在 .env 设置:
STORAGE_BACKEND=s3
S3_ENDPOINT=...
S3_REGION=...
S3_BUCKET=...
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_FORCE_PATH_STYLE=true
S3_MANIFEST_KEY=manifest.json服务启动时会读取 manifest,并按 S3_METADATA_SYNC_INTERVAL_SECONDS 周期刷新元数据。下载接口会返回 presigned URL;如果同时开启 Redis,presign 结果会短时间缓存以减少重复签名开销。
仓库里带了一个 Rust 异步压测工具 perf_load,用于模拟不同用户并发执行不同合法操作。它会先准备测试用户,然后按权重随机请求 user/me、排行榜、提交成绩、购买、下载、world、好友列表等接口。
cargo build --release --bin Arcaea_server_rs --bin perf_load
# 启动服务,按机器和数据库情况调整连接池
REDIS_URL=redis://127.0.0.1:6379/0 \
DB_MAX_CONNECTIONS=100 \
ROCKET_PORT=8090 \
./target/release/Arcaea_server_rs
# 另一个终端跑压测
./target/release/perf_load \
--users 200 \
--concurrency 200 \
--duration-secs 20 \
--prepare-concurrency 4 \
--download-url本机一次参考结果:开启 Redis、S3/R2、DB_MAX_CONNECTIONS=100,200 用户、200 并发、20 秒混合操作约 940 QPS,错误为 0。这个数字只代表当时本机、数据库和网络环境,部署到服务器后应重新压测。
linkplayd 通过环境变量读取配置,推荐直接在 .env 里配置。关键项如下:
LINKPLAY_HOST(默认0.0.0.0)LINKPLAY_UDP_PORT(默认10900)LINKPLAY_TCP_PORT(默认10901)LINKPLAY_DISPLAY_HOST(对客户端返回的 Link Play 地址;为空时使用LINKPLAY_HOST)LINKPLAY_DISPLAY_PORT(对客户端返回的 Link Play 端口;默认使用LINKPLAY_UDP_PORT)LINKPLAY_AUTHENTICATIONLINKPLAY_TCP_SECRET_KEY
更多参数见 .env.example 里的 Link Play Daemon Configuration 段。
注意: 这是一个 Arcaea 的服务器实现,仅用于教育与展示目的。请不要用于商业目的,这不是强制要求,只是一个提醒和警告。
Note: This is a reimplementation of the Arcaea game server for educational and performance purposes. DO NOT use for commercial purposes, this is not a mandatory requirement, just a reminder and warning.
基本上功能齐全了,不过欢迎兼容新版本的修复以及任何问题的修复!
现在已经加了一个独立的 linkplayd 进程(src/bin/linkplayd.rs),用于把 Link Play 从主服务拆出来。当前已经实现控制面(TCP)与核心 UDP 二进制 parser(房间状态机、命令队列、倒计时流转),基本测试可用。
关于客户端的事情不要问我,请上网查找,真的很多的相信我。憋不住了可以给我发邮件 arcaea@yinmo19.top,但是我也不一定能解决。
相信你看完这个图已经对这个项目有一些了解了。
相较 python 版本,我们的架构从 python + sqlite + localstorage 迁移到了 rust + mysql + redis + s3 存储。性能上说,mysql 比起 sqlite 读接口其实并不占优,但是写接口显然 mysql 会稳一些。我们加上了 redis 缓存层,因此读接口应该也有不少性能提升。文件存储的架构我们相较于本地存储也做了一些更新——我们新写了一个cli,专门用于同步数据到 s3。同步数据的时候会计算一份 metadata 传输到 s3 上,而我们的服务器则没三分钟定时从s3 存储自动拉下来更新本地缓存,也就是说服务器端完全不需要计算文件 hash 相关的内容,一致性则从上传者保证。因此我们目前特地的避免大量的计算在服务器上做,这可能是我们对原版的一些改进。
另外我们的服务将来可能可以做一些横向拓展——虽然还是那句话,好像完全没有做这个的必要......
下面讲讲我对 rust 写 crud 的理解。采用的 rocket 框架确实是一个非常好写的框架,使用依赖注入的方式可以实现对各种 service 的全局管理,在需要的地方直接注入到对应的路由使用。项目类似于 django(但不同)的三级分层,route、 service、 model 层。即使我采用的不是 orm 架构,我依然把所有的数据库相关的数据结构专门用一个 model 层存起来。这一层专门用于构建结构体来对应数据库结构,以及构建一些返回体,包括实现一些这些模型的互相转换之类的方法。至于路由层和服务层想来不言自明。
rust 的 sqlx 框架相对别的语言都没有的一个最大的优点,是利用 rust 的宏机制实现编译期检查 sql 语句的正确性。他会在编译期连接一个真实的数据库,通过模拟代码中使用的 sql 来判断语句正确性。几乎可以这样说,只要能通过编译,那么写出来的 sql 语句就没有语法错误(但是性能/正确性两说,这些烂了谁也救不了)。再比如下面的代码中,
/// get user's stamina
async fn get_user_stamina(&self, user_id: i32) -> ArcResult<i32> {
let stamina_info = sqlx::query!(
"select max_stamina_ts, stamina from user where user_id = ?",
user_id
)
.fetch_one(&self.pool)
.await?;
let stamina = Stamina {
stamina: stamina_info.stamina.unwrap_or(12),
max_stamina_ts: stamina_info.max_stamina_ts.unwrap_or(0),
};
Ok(stamina.calculate_current_stamina(12, 1800000))
}使用 sqlx::query! 宏可以静态检查这句 sql 语句的返回值,他会自动把返回值的字段组合一个结构体,里面元素 max_stamina_ts, stamina 的类型则通过数据库中的字段类型来确定。如果数据库中的初始定义类型没有指定非 null,那么这个字段则会被自动解析为 Option<T>。这也算强类型的好处,因为在 python 中可能就得
def select(self):
'''获取用户体力信息'''
self.c.execute('''select max_stamina_ts, staminafrom user where user_id = :a''',
{'a': self.user.user_id})
x = self.c.fetchone()
if not x:
raise NoData('The user does not exist.')
self.set_value(x[0], x[1])使用 x[0], x[1] 这样的下表来借代每个返回值,对于长一些的查询语句就不太友好了,并且对于空值的处理有时候也会疏忽,可阅读性在这里反而 rust 会更高一些。
另外一个不错的点是错误类型。使用 thiserror 库可以实现很优秀的错误类型管理。只需要实现统一返回类型,并实现了每种可能出现的 error 到自定义 error 的 From 方法,那么使用起来就非常轻松。只需要在代码里面抛问号,错误就会留给框架自动序列化为特定的 json 丢回去给前端,这些所有内容都是可预见的,并且易于实现的。例如上面的案例中数据库查询最后 .await? 在失败的时候会抛出 sqlx::Error,而
/// Main error type for the Arcaea server
#[derive(Error, Debug)]
pub enum ArcError {
...
/// Database error
#[error("Database error: {message}")]
Database { message: String },
...
}
impl From<sqlx::Error> for ArcError {
fn from(err: sqlx::Error) -> Self {
Self::Database {
message: err.to_string(),
}
}
}既然已经实现了 From 方法,在可能错误的地方直接丢问号就行,错误自动就会序列化成我想要的模样。这也是强类型的一种好处吧。
最后是一个关于鉴权的内容。这是 rocket 提供的 auth 方案,他通过实现请求守卫的方式来进行所有需要对请求头的解析操作以及鉴权操作。这是一个非常有意思的点,因为实际上这样在使用上非常方便。例如我已经实现了对已登录用户的可访问守卫,那么对于任何想要让用户访问的 api,只需要在路由函数的参数中加上这个守卫就自动可以完成这个功能。又比如一些路由需要获取客户端 ip 和一些从 header 解析的信息,专门实现这种请求头之后直接在需要的函数参数中调用即可。这点和 python 的装饰器有点类似,不过这是 rust 的 rocket 框架的宏提供的功能,只能说宏还是太魔法了。
关于代码大概也就讲这些内容吧..... 这是我写过最大的后端项目,也是第一次采取这样的结构进行管理,也算是一种新的尝试。我以前写过 django,虽然并不喜欢,但是在新的项目中还是会不自觉的带上了那样的思维模式。虽然后端项目想来也大同小异,不过我自觉这样的代码写起来也算能看且实用,hah
最后,如果看到咕咕了大概率是我在忙忙,等我忙完了可能想起来就会继续更。这个暑假更了万多行代码,也算不错的进展了。
By YinMo19.