将 asaserverv2 包中的镜像启动方式(mirror-based startup)迁移至 asaserver 包,替代 v1 的 setupInstanceConfig + confReset 方式,然后删除 asaserverv2 包。
迁移后效果:
- 所有服务器实例可并行启动(消除全局锁)
- 消除共享目录修改(不再在
server-files/上创建 junction) - 简化日志管理(去掉全局映射文件)
- 统一代码库,消除 v1/v2 两套并存的维护负担
| 模块 | 源文件:行号 | 目标位置 | 说明 |
|---|---|---|---|
| 镜像管理 | mirror.go (900行) |
asaserver/mirror.go |
全部函数:SyncInstanceMirror(:74), createInstanceMirror(:145), syncMirrorEntries(:531), CleanupInstanceMirror(:411), CleanupMirrorCache(:523), InstanceMirrorDir(:68), buildExceptionTargets(:209), IsElevated(:46), EntryType* 常量(:28-31) 等 |
| 启动核心 | server.go:startServerInternal(:37) |
asaserver/server.go 替换 v1 版本 |
镜像同步 + exe 路径 + 日志路径 |
| 日志路径 | server.go:GetGameLogFilePath(:497) |
asaserver/server.go 替换 v1 版本 |
简化为直接路径 instances/<name>/Logs/ShooterGame.log |
| 存档安全 | common.go:SaveWorldSafely(:148) |
asaserver/common.go 更新 |
存档路径从 server-files 改为 instances//Save/ |
| 地图名标准化 | common.go:savePathReplacement(:142) |
asaserver/common.go 迁入 |
SaveWorldSafely 中的地图名映射(如 BobsMissions_WP → BobsMissions) |
| 强制停止 | force_stop.go:ForceStopServer(:11) |
asaserver/server.go 替换 |
添加镜像清理,去掉 WaitForNoInitializing |
| 配置同步 | server.go:SyncGameConfigToInstance(:524) |
asaserver/server.go 迁入 |
v1 已有类似实现,需对比合并 |
| 配置同步 | server.go:syncConfigFile(:549) |
asaserver/server.go 迁入 |
单文件配置同步 |
| 辅助函数 | common.go:arkApiCleanConsoleOutput(:68) |
asaserver/common.go |
v1 已有,保留不变 |
| 辅助函数 | server.go:quotifyIfNeeded(:514) |
asaserver/server.go |
v1 已有(:1153),保留不变 |
| 模块 | 函数/变量 | 文件:行号 | 原因 |
|---|---|---|---|
| v1 config junction | setupInstanceConfig() |
server.go:386 |
被 SyncInstanceMirror 替代 |
| v1 config reset | confReset 变量及其所有引用 |
server.go:551 |
v2 不需要 junction 释放 |
| v1 目录复制 | CopyDir() |
server.go:357 |
不再需要 |
| v1 日志映射 | InitializeLogMapping() |
server.go:76 |
v2 直接使用实例目录 |
| v1 日志映射 | GetGameLogFileName() |
server.go:177 |
v2 直接返回固定文件名 |
| v1 日志映射 | PersistLogMapping() |
server.go:154 |
v2 不需要持久化映射 |
| v1 日志映射 | RemoveInstanceLogMapping() |
server.go:166 |
v2 无需清理映射 |
| v1 日志映射 | removeNotRunningServerLogMapper() |
server.go:456 |
v2 无需清理 |
| v1 日志映射 | instanceLogMapping map + logMappingMutex |
server.go:32,26 |
v2 不使用内存映射 |
| v1 日志映射 | SetInstanceLogFile() |
server.go:254 |
依赖内存映射 |
| v1 日志映射 | GetInstanceLogFile() |
server.go:261 |
依赖内存映射,webapi/api.go:1006 使用 |
| v1 日志映射 | LogMappingFile 变量 |
config.go |
v2 不使用映射文件 |
| v1 日志映射 | LogMapping struct + LoadLogMappingFromFile / SaveLogMappingToFile |
config.go:305,330 |
v2 不使用 |
| v1 全局锁 | isAnyInstanceInitializingLocked() |
state_manager.go:426 |
v2 不需要全局初始化互斥 |
| v1 全局锁 | getInitializingInstanceLocked() |
state_manager.go:444 |
v2 不需要 |
| v1 全局锁 | IsAnyInstanceInitializing() |
state_manager.go:640 |
v2 不需要 |
| v1 全局锁 | WaitForNoInitializing() |
state_manager.go:650 |
v2 不需要等待 junction 释放 |
| v1 isOperationAllowed | 全局互斥规则(规则1) | state_manager.go:587-589 |
删除全局锁检查,仅保留 per-instance 状态检查 |
| 模块 | 说明 |
|---|---|
state_manager.go |
状态机逻辑不变(CAS、状态常量、状态历史、卡死恢复) |
config.go 中的 InstanceConfig |
结构体和读写逻辑不变 |
config.go 中的 LoadInstanceConfig / SaveInstanceConfig / UpdateInstanceConfig |
不变 |
config.go 中的 CheckForDuplicatePorts |
不变 |
config.go 中的 Config 文件操作 |
GetGameIniContent, SaveGameIniContent 等不变 |
common.go 中的 TailLogFileWithLines / TailLogFileWithLinesContext |
不变 |
common.go 中的 WaitGamePidExit |
不变 |
common.go 中的 WaitArkApiRunServer |
不变 |
common.go 中的 GetPIDByPort |
不变 |
common.go 中的 killGameServer |
不变 |
common.go 中的 MonitorAndExtractModInfo |
不变 |
server.go 中的 StartServer 公共入口 |
CAS 逻辑不变 |
server.go 中的 StopServer 核心逻辑 |
基本不变 |
server.go 中的 RestartServer |
基本不变 |
注意:
asaserverv2包目前未被任何外部包导入。以下更新是因为 v1 的函数签名/行为变化而需要同步修改的调用方。
| 调用方 | 文件:行号 | 当前代码 | 迁移后 |
|---|---|---|---|
| webapi 日志流 | webapi/api.go:1006 |
asaserver.GetInstanceLogFile(instanceName) |
asaserver.GetGameLogFilePath(instanceName) |
| webapi 停止 | webapi/api.go:902 |
GetInstanceLogFile(instanceName) |
GetGameLogFilePath(instanceName) |
| backup 备份 | backup/backup.go:36 |
filepath.Join(asaserver.ServerFilesDir, "ShooterGame/Saved", config.SaveDir) |
filepath.Join(asaserver.InstancesDir, instanceName, "Save") |
| backup 恢复 | backup/backup.go:253 |
filepath.Join(asaserver.ServerFilesDir, "ShooterGame/Saved", saveDir) |
filepath.Join(asaserver.InstancesDir, instanceName, "Save") |
操作:
- 复制
asaserverv2/mirror.go到asaserver/mirror.go - 修改 package 声明为
package asaserver - 更新内部引用:
asaserver.BaseDir→BaseDir(同包引用)asaserver.InstancesDir→InstancesDirasaserver.ServerFilesDir→ServerFilesDirasaserver.LoadInstanceConfig→LoadInstanceConfigasaserver.InstanceConfig→InstanceConfig
- 添加缺失的 import
影响文件:新增 asaserver/mirror.go
操作:在 asaserver/server.go 中重写 startServerInternal:
func startServerInternal(instanceName string, options ...StartServerOptionsFunc) error {
// ===== 保留部分 =====
// 1. Options 初始化 + context 创建
// 2. Deferred 错误处理(写 StatusStartFailed + 清理镜像)
// 3. CheckForDuplicatePorts()
// 4. LoadInstanceConfig(instanceName)
// 5. 构建命令行参数
// 6. 启动进程(PTY / exec.Command)
// 7. SaveInstancePID()
// 8. waitServerStartup() goroutine
// ===== 替换部分 =====
// [删除] setupInstanceConfig(instanceName, &confReset)
// [新增] mirrorDir, err := SyncInstanceMirror(instanceName, config)
// ↓
// exe 路径改为: mirrorDir/ShooterGame/Binaries/Win64/ArkAscendedServer.exe
// 工作目录改为: mirrorDir/ShooterGame/Binaries/Win64
// [删除] confReset() 调用(在 successfullyCallback 中)
// [删除] removeNotRunningServerLogMapper() 调用
// [删除] PersistLogMapping() 调用
// ===== 简化部分 =====
// 日志路径: 直接使用 instances/<name>/Logs/ShooterGame.log
// (不再经过 GetGameLogFileName → instanceLogMapping → PersistLogMapping 链路)
}关键变化:
| 项目 | v1 代码 | v2 代码 |
|---|---|---|
| Config 准备 | setupInstanceConfig(name, &confReset) |
SyncInstanceMirror(name, config) |
| exe 路径 | filepath.Join(ServerFilesDir, "ShooterGame/Binaries/Win64", exeName) |
filepath.Join(mirrorDir, "ShooterGame/Binaries/Win64", exeName) |
| 工作目录 | filepath.Join(ServerFilesDir, "ShooterGame/Binaries/Win64") |
filepath.Join(mirrorDir, "ShooterGame/Binaries/Win64") |
| 日志初始化 | GetGameLogFilePath() + mapping 系统 |
直接返回 InstancesDir/<name>/Logs/ShooterGame.log |
| confReset | confReset() 调用释放 junction |
不需要 |
| 启动后持久化 | PersistLogMapping() |
不需要 |
影响文件:asaserver/server.go
操作:修改 startServerInternal 中 waitServerStartup 的两个回调:
// callback (启动完成/失败)
callback := func(startup bool, err string) {
if startup {
WriteInstanceState(instanceName, StatusStarted, "")
if opts.WaitServerCompleted {
startupSuccess <- true // 通知 WaitServerCompleted 路径
}
if opts.OnRestartStartupComplete != nil {
opts.OnRestartStartupComplete(instanceName)
}
} else {
WriteInstanceState(instanceName, StatusStartFailed, err)
initFailed <- fmt.Errorf("%s", err)
}
}
// successfullyCallback (游戏初始化完成)
successfullyCallback := func() {
WriteInstanceState(instanceName, StatusStartStartInitializationSuccessful, "")
// [删除] confReset() ← v2 不需要
WriteInstanceState(instanceName, StatusStarting, "")
initSuccessful <- true
if opts.GameInitializationSuccessful != nil {
opts.GameInitializationSuccessful()
}
}影响文件:asaserver/server.go 中 startServerInternal 的回调定义
操作:替换 v1 的 log mapping 系统为 v2 的直接路径:
删除(asaserver/server.go 和 asaserver/config.go):
InitializeLogMapping()函数及其 fsnotify watcherGetGameLogFileName()函数PersistLogMapping()函数RemoveInstanceLogMapping()函数removeNotRunningServerLogMapper()函数instanceLogMappingmap 变量logMappingMutex变量LogMappingFile变量LogMappingstruct 及其LoadLogMappingFromFile()/SaveLogMappingToFile()
替换为(asaserver/common.go):
func GetGameLogFilePath(instanceName string) (string, error) {
logDir := filepath.Join(InstancesDir, instanceName, "Logs")
if err := os.MkdirAll(logDir, 0755); err != nil {
return "", err
}
logPath := filepath.Join(logDir, "ShooterGame.log")
if _, err := os.Stat(logPath); os.IsNotExist(err) {
os.WriteFile(logPath, nil, 0644)
}
return logPath, nil
}影响文件:asaserver/server.go, asaserver/config.go, asaserver/common.go
操作:在 stopServerInternal 中:
- 删除
removeInstanceLogMapping()调用 - 更新
SaveWorldSafely中的存档路径(Step 6)
影响文件:asaserver/server.go
操作:修改存档路径构建逻辑:
// v1:
savePath := filepath.Join(ServerFilesDir, "ShooterGame/Saved", config.SaveDir, dirMapName, dirMapName+".ark")
// v2:
savePath := filepath.Join(InstancesDir, instanceName, "Save", dirMapName, dirMapName+".ark")影响文件:asaserver/common.go
操作:
// v1:
func ForceStopServer(instanceName string) error {
WaitForNoInitializing(2 * time.Minute) // [删除] 不需要等 junction
// ... kill process ...
// ... write StatusStopped ...
}
// v2:
func ForceStopServer(instanceName string) error {
// [新增] 清理镜像目录
CleanupInstanceMirror(instanceName)
// ... kill process ...
// ... write StatusStopped ...
// 无需 WaitForNoInitializing,因为每个实例的 junction 是独立的
}影响文件:asaserver/server.go
操作:
// v1:
func RestartServer(instanceName string) error {
stopServerInternal(instanceName)
time.Sleep(10 * time.Second) // [删除] 不需要等 junction 释放
StartServer(instanceName, WithWaitServerCompleted(), ...)
}
// v2:
func RestartServer(instanceName string) error {
stopServerInternal(instanceName)
// [删除] time.Sleep(10 * time.Second)
StartServer(instanceName, WithWaitServerCompleted(), ...)
}影响文件:asaserver/server.go
操作:修改 state_manager.go:
- 删除
isOperationAllowed中的全局互斥规则(行 587-589):
// [删除] 规则 1:全局互斥 - 检查是否有实例在 start_initialization
if initInstance := sm.getInitializingInstanceLocked(); initInstance != "" {
return false, fmt.Sprintf("instance '%s' is in start_initialization state, all operations are blocked", initInstance)
}-
删除以下函数:
isAnyInstanceInitializingLocked()(行 426)getInitializingInstanceLocked()(行 444)IsAnyInstanceInitializing()(行 640)WaitForNoInitializing()(行 650)
-
删除调用点:
ForceStopServer中的WaitForNoInitializing(2 * time.Minute)(server.go:963)
影响文件:asaserver/state_manager.go, asaserver/server.go
操作:更新因 v1 函数变更而受影响的调用方:
webapi/api.go — 日志流(行 1006):
// 当前:
logPath, exists = asaserver.GetInstanceLogFile(instanceName)
// 迁移后(GetInstanceLogFile 被删除):
logPath, err := asaserver.GetGameLogFilePath(instanceName)
// 注意:不再需要轮询等待,v2 的 GetGameLogFilePath 直接返回固定路径webapi/api.go — stopServerInternal 日志路径(行 902):
// 当前:
gameLogPath, _ = GetInstanceLogFile(instanceName)
// 迁移后:
gameLogPath, _ = GetGameLogFilePath(instanceName)backup/backup.go — 备份存档路径(行 36):
// 当前:
savePath := filepath.Join(asaserver.ServerFilesDir, "ShooterGame/Saved", config.SaveDir)
// 迁移后:
savePath := filepath.Join(asaserver.InstancesDir, instanceName, "Save")backup/backup.go — 恢复存档路径(行 253):
// 当前:
target = filepath.Join(filepath.Join(asaserver.ServerFilesDir, "ShooterGame/Saved", saveDir), relPath)
// 迁移后:
target = filepath.Join(filepath.Join(asaserver.InstancesDir, instanceName, "Save"), relPath)操作:删除整个 asaserverv2/ 目录:
asaserverv2/server.go ← 已迁移
asaserverv2/common.go ← 已迁移
asaserverv2/mirror.go ← 已迁移
asaserverv2/force_stop.go ← 已迁移
asaserverv2/server_test.go ← 迁移到 asaserver/
注意:StartAllInstances 和 StopAllInstances 已被手动移除,不需要迁移。
操作:删除以下不再使用的代码:
setupInstanceConfig()函数CopyDir()函数(用于 config 复制的那个版本)confReset变量及所有引用- v1 日志映射相关全部代码(见 Step 4)
| Bug | 描述 | 修复方案 |
|---|---|---|
startErr 数据竞争 |
asaserverv2/server.go:60,272 — goroutine 写 startErr,deferred 读取 |
去掉 startErr 变量,deferred 从 initFailed channel 读取错误值 |
waitServerStartup double-close |
asaserverv2/common.go:47,56 — 两个 goroutine 可能同时 close(startup) |
从 v1 移植 sync.Once + safeCloseStartup 模式 |
| PTY 泄漏 | asaserverv2/server.go:224 — AsaApiLoader 路径缺少 defer pp.Close() |
添加 defer pp.Close() |
| 风险 | 等级 | 缓解措施 |
|---|---|---|
| 首次创建镜像耗时 | 中 | 增量同步复用已有镜像;大目录跳过递归 |
| 磁盘空间(N 个 exe 副本) | 中 | 每实例 ~200-500MB,其他均为 junction/symlink(0 空间) |
| exe 复制后版本不一致 | 低 | 增量同步的 MD5 校验会检测并更新 |
| 多实例同时启动 I/O 压力 | 中 | 非 exe 文件是 symlink,I/O 压力有限 |
| 存档路径变更影响备份功能 | 高 | 需同步更新 backup/backup.go 中的路径(行 36, 253) |
| webapi 日志流中断 | 高 | GetInstanceLogFile 被删除后必须同步更新 webapi/api.go:1006 |
| 日志路径变更影响前端日志流 | 中 | SSE 日志流当前使用 GetInstanceLogFile,需改为 GetGameLogFilePath |
| Config API 不受影响 | 低 | Config 文件位置未变(instances/<name>/Config/) |
| IsServerRunning 依赖端口扫描 | 中 | v2 没有此函数,需确认 stopServerInternal 如何判断运行状态 |
为 mirror.go 核心函数编写测试:
TestCreateInstanceMirror— 验证镜像创建正确TestCleanupInstanceMirror— 验证清理不删除目标文件TestSyncMirrorEntries— 验证增量同步TestBuildExceptionTargets— 验证 exception 映射构建
- 创建两个实例,验证可同时启动(无全局锁)
- 验证镜像创建后 junction 指向正确目标
- 验证增量同步:修改 server-files 后启动,检查镜像更新
- 验证 启动 → 停止 → 启动 循环
- 验证 ForceStop 正确清理镜像
- 验证启动失败时镜像被清理
- 验证 Restart 不再需要 sleep 10 秒
- Config API:读写 Game.ini / GameUserSettings.ini
- 备份/恢复功能
- Save 监控(parseserver)
- WebSocket / SSE 事件推送
- 日志流(
GET /api/logs/:name) - Windows 服务模式
迁移前:
asaserver/
├── server.go # v1 启动(含 setupInstanceConfig, CopyDir, log mapping)
├── common.go # 工具函数
├── config.go # 配置(含 LogMapping)
├── state_manager.go # 状态机
├── installer.go # 安装器
asaserverv2/
├── server.go # v2 启动(含 mirror)
├── common.go # v2 工具函数
├── mirror.go # 镜像管理
├── force_stop.go # 强制停止
迁移后:
asaserver/
├── server.go # v2 启动方式(含 mirror 调用)
├── common.go # 工具函数(简化后的日志路径)
├── config.go # 配置(去掉 LogMapping)
├── state_manager.go # 状态机(去掉全局锁)
├── mirror.go # 镜像管理(从 v2 迁入)
├── installer.go # 安装器
(asaserverv2/ 已删除)
backup/backup.go 中的 BackupInstanceWorld 和 RestoreInstanceWorld 需要更新存档路径:
// v1:
savePath := filepath.Join(ServerFilesDir, "ShooterGame/Saved", config.SaveDir)
// v2:
savePath := filepath.Join(InstancesDir, instanceName, "Save")同样,RestoreInstanceWorld 中恢复文件的目标路径也需要同步更新。