架构¶
服务器的组织结构:internal/ 下的六边形分层、各个限界上下文、连接它们的事件,以及确保代码遵守这些规则的静态检查。
代码位于 internal/domain、internal/app、internal/adapters/{driving,driven},在 cmd/evey/wire*.go 里组装起来;
分层方式由 ADR-0001 决定,仓储的拆分由 ADR-0009 决定,
事件总线由 ADR-0010 决定。构建和运行请读 DEV.md;某项改动需要修改哪些文件,请读代码地图。
后端用 Go 编写;网页、移动和电视客户端是 frontend/ 下的一个 TypeScript/React 统一代码仓库(monorepo)(移动和电视端用 React Native / Expo)。
分层¶
flowchart LR
driving[驱动适配器<br/>internal/adapters/driving<br/>grpcapi、http、pluginhost]
app[应用服务<br/>internal/app/context]
domain[领域<br/>internal/domain/context<br/>模型、值对象、端口]
driven[被驱动适配器<br/>internal/adapters/driven<br/>postgres、ffmpeg、mkvgo……]
driving --> app --> domain
driven -. 实现端口 .-> domain
| 目录 | 内容 | 规则 |
|---|---|---|
internal/domain/<context>/ |
模型、值对象和端口(接口) | 不导入任何框架,不导入其他上下文 |
internal/app/<context>/ |
编排各个端口的应用服务 | 输入输出都经过端口;dlna(SSDP / UPnP AVTransport)和 loadtest(有自己的 HTTP 客户端)是两个有意为之的例外,它们直接访问网络 |
internal/adapters/driven/ |
实现端口的出站适配器:postgres、meilisearch、ffmpeg、mkvgo、filesystem、prober、opensubtitles、email、webpush、webhooksender、pluginrunner、metadata、introproviders、search |
另外三个实现的是应用层的接口,而不是领域端口:itunes 和 musicbrainz(app/music.AlbumArtFetcher)、ociregistry(app/updatecheck) |
internal/adapters/driving/ |
入站适配器:grpcapi(+ grpc-gateway HTTP)、http(静态文件、身份验证中间件、SSE、HLS、插件 iframe 资源)、pluginhost(Unix 套接字上的 gRPC 服务器,按对端凭据验证身份,是沙箱里的插件回调用的宿主 API) |
|
internal/pkg/ |
共享的技术内核:auth、logger、eventbus、i18n、metrics、tracing、ratelimit、procfork、ssrf、cache…… | |
internal/config/ |
服务器配置 | |
gen/ |
生成的 proto 代码 |
依赖规则:adapters -> app -> domain。领域层从不导入应用层或适配器。组装代码是手写的(没有用 google/wire),在 cmd/evey/wire.go
以及同目录的 wire_infra.go、wire_services.go、wire_adapters.go、wire_events.go、wire_helpers.go 里。
测试绕过适配器,注入实现同样领域端口的假对象:21 个领域包里有 20 个带有 fakes/ 子包。
限界上下文¶
internal/domain/ 和 internal/app/ 下共有三十二个上下文包:19 个在两层都有,2 个只在领域层(ratings、shared),11 个只在应用层。
它们部分通过共享总线上的领域事件协作;应用层也会直接读取相邻上下文的领域包(见关系)。
| 上下文 | 职责 |
|---|---|
user |
账户、身份验证、会话、偏好 |
library |
媒体库、文件夹、扫描任务、媒体索引、人物、合集、个人资料的收藏和点赞(点赞没有应用服务:评分 RPC 直接存取 UserRatingRepository)、trickplay、片头/片尾检测 |
media(仅应用层) |
分享链接和串流令牌 |
mediarequest |
用户提交的媒体请求 |
metadata |
TMDB / OMDb 元数据补全、图片 |
playback |
串流、进度、远程控制、正在播放 |
transcoding |
ffmpeg 任务、HLS 分段 |
subtitle |
内嵌字幕 + 外部字幕 + opensubtitles |
mkv |
MKV 容器操作(通过 mkvgo) |
music |
音频专用的播放 + 标签 |
photo |
照片库 |
favorite(仅应用层) |
按个人资料的收藏;模型和 FavoriteRepository 在 domain/library 里 |
playlist |
用户播放列表、智能播放列表 |
collection(仅应用层) |
管理员整理的合集;它的端口在 domain/library 里 |
ratings(仅领域层) |
把各国的分级(CSA、FSK、MPAA、BBFC、ACB)映射到同一条最低年龄轴上 |
feed |
动态 / 发现 |
activity |
用户活动时间线 |
chromecast |
投屏会话的生命周期 |
syncplay |
多设备同步会话 |
plugin |
插件注册表、沙箱令牌、生命周期 |
notification |
应用内通知 + 网页推送通知 |
webhook |
出站 Webhook |
backup |
数据库 + 配置的备份和恢复 |
config(仅应用层) |
持久化的服务器设置,REST 和 gRPC 两个入口都会写入 |
dlna(仅应用层) |
SSDP 发现和 UPnP AVTransport 控制 |
loadtest(仅应用层) |
模拟观众访问服务器自己的 HTTP 栈:HLS,或者在客户端判定为直接播放时访问原始文件 |
maintenance(仅应用层) |
由管理员触发的媒体维护:重建跳转索引、修复日志、撤销、音频完整性 |
presence(仅应用层) |
内存里、不持久化的“此刻谁在线”登记表 |
qoe(仅应用层) |
接收并汇总 CMCD(CTA-5004)播放遥测数据 |
demo(仅应用层) |
公开演示实例(EVEY_DEMO=1):通过产品自己的服务预置账户、媒体库和自由许可的媒体,并能自我重置 |
updatecheck(仅应用层) |
把正在运行的版本和镜像仓库发布的版本对比,告诉管理员;它从不更新任何东西 |
shared(仅领域层) |
横切的端口(EventBus、TransactionalUnit、ServerConfigRepository、ShareLinkRepository)、类型化的 id、游标、串流令牌 |
关系¶
| 发出方 | 事件 | 由谁消费 |
|---|---|---|
library |
LibraryCreated / LibraryDeleted、LibraryScanStarted / LibraryScanCompleted、MediaItemAdded、MediaItemRemoved、TrickplayGenerated |
media.item.added:notification、metadata(自动补全)、library 自己(trickplay 工作器 + 片头检测)、feed(使流派缓存失效) |
playback |
PlaybackStarted / PlaybackStopped |
目前没有专门的订阅者;只有 webhook 的 "*" 通配订阅会收到 |
user |
UserCreated、user.password.reset_requested |
前者没有订阅者;后者由 email 被驱动适配器消费 |
pluginrunner |
plugin.error / plugin.started |
app/plugin 用于它的安全仪表板,notification 用于通知管理员 |
shared |
ServerConfigChanged |
那些无需重启就能应用管理面板中已保存设置的服务(SMTP、时区、CPU 预算……) |
- 搜索索引不是由事件驱动的:
library.ScanService直接调用library.SearchIndex端口。 - 没有把领域事件传给插件的机制:插件通过限定权限范围的 JWT 访问 API(ADR-0003)。 总线本身见 ADR-0010。
shared.TransactionalUnit包裹涉及多个聚合的写入(ADR-0002)。
internal/domain 下的任何包都不会导入其他上下文的领域包,这条规则由 internal/contractguard/domain_imports_test.go 强制执行
(见架构检查)。应用层不受这条规则约束:目前有 15 个应用包导入了相邻上下文的领域包。
典型的数据流:“用户播放一个媒体”¶
- 移动或网页客户端调用
POST /api/v1/playback/start(grpc-gateway 路由)。 internal/adapters/driving/http运行它的身份验证中间件(auth_middleware.go,httpAuthMiddleware),取出会话。internal/adapters/driving/grpcapi(playback_handler.go)校验 proto,调用app/playback.CommandService.StartStream(命令和查询分离,见 ADR-0009)。app/playback.CommandService调用领域端口:user.UserRepository、playback.SessionQueryRepo(幂等 + 设备数量上限)、playback.SessionCommandRepo,然后shared.EventBus.Publish(PlaybackStarted)。- 被驱动适配器执行:
postgres在一个shared.TransactionalUnit.Do范围内保存会话;进程内的总线(internal/pkg/eventbus)异步分发事件。 PlaybackStarted目前没有专门的订阅者:只有webhook的"*"通配订阅会收到它。- 响应以 JSON 形式经 grpc-gateway 返回;进度更新通过 SSE / gRPC 流传输。
播放器请求清单文件和分段之后发生的事情,是播放链路的主题。
架构检查¶
上面这些规则不只是写在文档里。internal/contractguard/ 里有分布在 30 个文件中的 58 个静态测试,代码一旦偏离这些规则,构建就会失败。
六边形规则本身是 domain_imports_test.go 里的 TestDomainContextsDoNotImportEachOther(ADR-0001)。它遍历 internal/domain,
严格执行这一点:一个上下文可以导入 internal/domain/shared 和它自己的包树,internal/domain 里的其他内容一律不能导入,
也永远不能导入 internal/app 或 internal/adapters。它的范围止于 internal/domain:对应用层不做任何约束。
其余的检查保护跨越语言或进程边界的约定,其中包括:
| 检查 | 它拒绝什么 |
|---|---|
TestNoClientReadOfAFieldTheServerNeverWrites |
客户端读取、但服务器代码从不写入的 proto 响应字段 |
TestRegisteredHandlersGetEveryPortTheyDeclare、TestBootInstallsItsSilentWirings |
否则会在没有任何提示的情况下失败的组装 |
TestPostgresRepositoriesBoundTheirQueries |
没有超时的 Postgres 仓储查询 |
TestProcfork_EveryForkGoesThroughCommand |
绕过 internal/pkg/procfork 创建的子进程 |
TestEverySortFieldTheAPIAcceptsHasASQLColumn、...CanBuildACursorValue |
在 SQL 列映射或游标编码器里缺失的排序字段 |
TestEveryWebSurfaceReachingAnAdminRPCIsClassified,以及它在移动和电视端的原生对应测试 |
调用仅限管理员的 RPC、却没有决定谁能看到的界面 |
TestEveryAllowedSlotIsMountedOnWebAndMobile、TestPluginBridgeParityWebMobile |
只在一个客户端上存在的插件界面插槽或桥接消息 |
TestImageSubtitleCodecsAgreeAcrossServerAndClients |
播放器上另起一份位图字幕编码列表 |
TestHandWrittenClientTypesMatchTheEndpointTheyCall |
与其端点不一致的手写移动/电视响应类型 |
TestOnlyThePlanDecidesPlayback、TestNoCallerDerivesDownstreamOfThePlaybackPlan |
在计划解析器之外做出的播放决定(ADR-0030) |
TestRestartExitCodeAgreesWithTheEntrypoint |
Go 的退出码和 docker/entrypoint.sh 对“重启”的含义理解不一致 |
TestServerEmittedI18nKeysExistInCatalogue、TestI18nPlaceholdersAgreeAcrossLocales |
服务器发出的键在翻译目录里不存在,或者不同语言之间的占位符不一致 |
C4 图¶
四张图按照 C4 模型,从远到近逐级放大来描述 Evey。节点名称就是 internal/ 下真实的 Go 包名。
evey-server、evey-postgres 和 evey-caddy 的容器名来自 docker-compose.yml;evey-web、evey-meilisearch、evey-mobile 和 evey-mailcatcher
是在 docker-compose.override.yml 里声明的,只存在于开发环境的拓扑中。
第 1 层:系统上下文¶
谁和 Evey 通信,以及 Evey 会访问哪些第三方。
flowchart LR
user[用户<br/>网页 / 移动 / 电视客户端]
admin[管理员<br/>网页 /admin 界面]
oidc[OIDC 身份提供方<br/>可选的单点登录]
tmdb[TMDB API<br/>电影/剧集元数据]
omdb[OMDb API<br/>后备元数据 + 评分]
imdb[IMDbAPI / TVmaze<br/>后备剧集元数据]
art[MusicBrainz / iTunes Search<br/>专辑封面]
introdb[IntroDB / TIDB<br/>片头 + 片尾时间点]
opensubs[OpenSubtitles<br/>字幕搜索]
webpush[网页推送网关<br/>FCM / APNs / VAPID]
registry[OCI 镜像仓库<br/>已发布的镜像标签]
subgraph evey[Evey 媒体服务器]
core((自托管媒体平台<br/>媒体库、播放、转码、<br/>音乐、照片、插件))
end
user -->|HTTPS<br/>HLS / SSE / gRPC-Web| evey
admin -->|HTTPS<br/>/admin| evey
evey -.->|OIDC 授权码流程| oidc
evey -.->|HTTPS 获取元数据| tmdb
evey -.->|HTTPS 获取元数据| omdb
evey -.->|HTTPS 获取元数据| imdb
evey -.->|HTTPS 获取封面| art
evey -.->|HTTPS 查询片头| introdb
evey -.->|HTTPS 获取字幕| opensubs
evey -.->|HTTPS 推送投递| webpush
evey -.->|HTTPS 检查更新| registry
第 2 层:容器¶
由 docker compose --env-file .env.dev up 启动的可部署单元。实线箭头是运行时的流量;虚线箭头是对外访问第三方。
flowchart TB
user([用户的浏览器 / 手机])
admin([管理员的浏览器])
subgraph compose[Docker Compose 网络]
caddy[evey-caddy<br/>Caddy 2.9<br/>:80 / :443<br/>TLS、反向代理]
web[evey-web<br/>开发用 Vite / 生产用 nginx<br/>开发端口 :5173<br/>React SPA + RN web]
server[evey-server<br/>Go 二进制<br/>:9091 HTTP+gRPC-gateway<br/>内部 :9090 gRPC]
pg[(evey-postgres<br/>PostgreSQL 16<br/>:5432)]
meili[(evey-meilisearch<br/>Meilisearch 1.12<br/>:7700)]
mobile[evey-mobile<br/>仅开发<br/>:8081 Expo / Metro 打包器]
mail[evey-mailcatcher<br/>仅开发<br/>:1025 SMTP / :1080 界面]
end
tmdb[[TMDB / OMDb / OpenSubtitles]]
fs[(/media 卷<br/>只读绑定挂载)]
cache[(evey-cache 卷<br/>转码 + trickplay)]
data[(evey-data 卷<br/>密钥、日志、备份、<br/>插件、头像)]
user -->|HTTPS| caddy
admin -->|HTTPS /admin| caddy
caddy -->|HTTP /| web
caddy -->|HTTP /v1、/api、SSE、HLS| server
web -->|经 /v1 的 gRPC-Web| server
user -.->|Metro 开发包| mobile
server -->|SQL/TCP 5432| pg
server -->|HTTP 7700<br/>建索引 + 搜索| meili
server -.->|SMTP 1025 开发| mail
server -.->|HTTPS| tmdb
server -->|读文件系统| fs
server -->|读写文件系统| cache
server -->|读写文件系统| data
生产环境和这个开发拓扑不同
安装用的 compose 只声明一个服务:evey。没有单独的 evey-web 容器(也没有仅供开发的 mailcatcher 和 Metro 打包器):
二进制程序从 /srv 提供构建好的网页界面,所以反向代理把所有东西,界面和 API,都转发到它的 :9091。
Postgres 和 Meilisearch 在同一个容器里运行,由 docker/entrypoint.sh 管理:PostgreSQL 17 的数据放在 /data/postgres 下,
Meilisearch 二进制(Dockerfile 的 meili-download 阶段,固定为 1.12.8)的索引放在 /data/meili 下。
仓库自带的 docker-compose.yml 保留了单独的 postgres 和 caddy 服务,在高级部署里有说明。
另见安装和反向代理配置。
第 3 层:组件(放大 evey-server)¶
Go 二进制内部的六边形分层。驱动适配器通过接口调用应用服务;应用服务只依赖领域端口;被驱动适配器实现这些端口。
flowchart LR
subgraph driving[驱动适配器 - 入站]
grpc[grpcapi<br/>internal/adapters/driving/grpcapi<br/>按上下文划分的处理器]
gw[grpc-gateway<br/>JSON/HTTP <-> gRPC<br/>由 proto 生成]
http[http<br/>internal/adapters/driving/http<br/>静态文件、SSE、HLS、<br/>auth_middleware.go]
plug[pluginhost<br/>internal/adapters/driving/pluginhost<br/>Unix 套接字上的 gRPC<br/>对端凭据验证]
end
subgraph app[应用层 - internal/app]
direction TB
a_lib[library]
a_med[media]
a_meta[metadata]
a_play[playback]
a_trans[transcoding]
a_sub[subtitle]
a_mkv[mkv]
a_music[music]
a_photo[photo]
a_user[user]
a_upd[updatecheck]
a_misc[favorite, playlist, collection,<br/>feed, activity,<br/>chromecast, syncplay,<br/>plugin, notification, webhook,<br/>backup, dlna, config, mediarequest,<br/>maintenance, presence, qoe, loadtest,<br/>demo]
end
subgraph domain[领域核心 - internal/domain]
d_models[聚合 + 值对象<br/>按限界上下文划分]
d_ports[出站端口<br/>仓储 - 命令/查询分离、<br/>EventBus、TransactionalUnit、<br/>ServerConfigRepository]
end
subgraph driven[被驱动适配器 - 出站]
pgad[postgres<br/>仓储 + 迁移<br/>shared.TransactionalUnit]
meiad[meilisearch<br/>搜索索引]
fsad[filesystem<br/>扫描器、解析器、监视器]
ffad[ffmpeg<br/>转码器、trickplay]
mkvad[mkvgo<br/>MKV 操作]
metaad[metadata<br/>TMDB、OMDb、IMDbAPI、<br/>TVmaze 客户端]
introad[introproviders<br/>IntroDB + TIDB 链]
itunad[itunes<br/>专辑封面]
mbad[musicbrainz<br/>专辑封面]
ociad[ociregistry<br/>已发布的标签]
probead[prober<br/>mkvgo 快速路径、<br/>ffprobe 后备]
searchad[search<br/>meili 之上的容错外观层]
osad[opensubtitles]
emad[email]
wpad[webpush]
whad[webhooksender]
prad[pluginrunner]
end
grpc --> app
gw --> grpc
http --> app
plug --> app
app --> d_ports
app --> d_models
d_ports -.实现.- pgad
d_ports -.实现.- meiad
d_ports -.实现.- fsad
d_ports -.实现.- ffad
d_ports -.实现.- mkvad
d_ports -.实现.- metaad
d_ports -.实现.- introad
d_ports -.实现.- probead
a_music -.应用层接口.- itunad
a_music -.应用层接口.- mbad
a_upd -.应用层接口.- ociad
d_ports -.实现.- searchad
d_ports -.实现.- osad
d_ports -.实现.- emad
d_ports -.实现.- wpad
d_ports -.实现.- whad
d_ports -.实现.- prad
pgad --> pg2[(PostgreSQL)]
meiad --> me2[(Meilisearch)]
fsad --> fs2[(/media 绑定挂载)]
ffad --> cc2[(evey-cache + ffmpeg 二进制)]
metaad -.-> ext2[[TMDB / OMDb / IMDbAPI / TVmaze]]
introad -.-> ext3[[IntroDB / TIDB]]
itunad -.-> ext4[[iTunes Search / MusicBrainz]]
mbad -.-> ext4
ociad -.-> ext5[[OCI 镜像仓库]]
关于 goroutine:
- gRPC 服务器(
grpc.Server.Serve)和 HTTP 服务器(网关 + 静态文件)各自在自己的 goroutine 里运行,由cmd/evey的主组装流程启动。 app/library的扫描任务在一个工作池里运行(每个正在扫描的媒体库一个 goroutine,见library.ScanService)。- ffmpeg 任务由
ffmpeg被驱动适配器(ffmpeg.Transcoder.Start)通过internal/pkg/procfork创建子进程,每个任务有一个负责监督的 goroutine 读取进程的 stderr。 shared.EventBus异步分发给各个订阅者:每个订阅者在自己的 goroutine 里运行,所以慢的消费者永远不会阻塞发布者。总线自己不开启事务;事务由处理器负责。pluginrunner为每个插件创建一个子进程;iframe 插件运行在用户的浏览器里,凭借app/plugin签发的签名令牌与服务器通信。
第 3 层(附加):放大扫描流程¶
把磁盘上的文件变成可搜索、已补全信息的媒体的热路径。实线箭头是直接调用;虚线箭头是总线上的领域事件。
搜索索引是 ScanService 直接调用端口完成的,不是事件。
flowchart LR
user([管理员触发<br/>POST /api/v1/libraries/:id/scan])
subgraph driving2[驱动]
h[grpcapi<br/>library_handler.go]
end
subgraph appLayer[internal/app/library]
svc[ScanService<br/>工作池,<br/>每个媒体库 1 个 goroutine]
end
subgraph drivenScan[被驱动]
scanner[filesystem.Scanner<br/>遍历 + 解析]
watcher[filesystem.FSWatcher<br/>fsnotify,增量]
end
subgraph domainScan[domain/library]
repo[(MediaItemCommandRepo + MediaItemQueryRepo 端口)]
bus[(EventBus 端口)]
end
subgraph persist[被驱动的持久化]
pgrepo[postgres.MediaItemRepository<br/>在 TransactionalUnit 内]
searchidx[meilisearch.SearchAdapter<br/>实现 library.SearchIndex]
end
subgraph consumers[事件消费者 - 各自在自己的 goroutine 里]
meta[app/metadata<br/>TMDB/OMDb 补全]
trick[app/library<br/>trickplay 工作器]
intro[app/library<br/>片头检测]
feed[app/feed<br/>流派缓存失效]
notif[app/notification<br/>新媒体提醒]
wh[app/webhook<br/>全收订阅,出站 POST]
end
user --> h --> svc
svc --> scanner
watcher --> svc
scanner --> svc
svc --> repo
repo --> pgrepo
svc --> searchidx
svc --> bus
bus -. MediaItemAdded .-> meta
bus -. MediaItemAdded .-> trick
bus -. MediaItemAdded .-> intro
bus -. MediaItemAdded .-> feed
bus -. MediaItemAdded .-> notif
bus -. MediaItemAdded .-> wh
trick -. TrickplayGenerated .-> intro
meta --> repo
trick --> ffstore[(evey-cache<br/>trickplay 拼图)]
请结合上面的“典型的数据流”一节来读:播放路径是与之对称的读取路径;扫描流程则是写入路径,它预先填好读取路径所依赖的媒体索引。