Gitea源码深度剖析-第1章 Gitea 总体架构与代码全景
本章是全书的开篇,目标只有一个:在进入任何细节之前,先把 Gitea 这个工程"摊开在桌面上"——它由哪些目录组成、代码之间如何依赖、一次请求从进入进程到返回 HTML 中间经过了哪些真实函数、有哪些横切机制贯穿始终。本章的所有断言都以 gitea-28.1.0 源码树(module 名为 gitea.dev,go.mod 中 go 1.27)为准,引用代码均给出文件路径与函数名,摘录忠实于原文(省略无关行以 … 标注)。
读完本章,你应该能够回答这些问题:
- 为什么 Gitea 可以编译成单个二进制,而 GitLab 不行?
routers/、services/、models/、modules/四层到底谁可以 import 谁?gitea web、gitea serv、gitea hook这些子命令是共用一个main()还是各自独立?- 浏览器访问
http://host/owner/repo时,从net/http到模板渲染之间究竟穿过了多少层中间件? - 配置、日志、数据库、队列、缓存、通知这六件"横切的事"分别的入口函数在哪里?
1.1 Gitea 的定位与技术选型
1.1.1 定位:自托管 Git 服务的"最小完整实现"
Gitea 在 cmd/main.go 中给自己的描述是:
app.Usage = "A painless self-hosted Git service"
README.zh-cn.md 进一步说明了它的目标:"提供最简单、最快速、最无痛的方式来设置自托管的 Git 服务",并且"由于 Gitea 是用 Go 语言编写的,它可以在 Go 支持的所有平台和架构上运行"。项目于 2016 年 11 月从 Gogs 分叉,随后在功能广度上(Actions、包注册、Projects、Webhook、审批/评审等)大幅扩展。
从代码规模上看(对源码树做一次统计,单位为文件数):
| 目录 | 文件数 | 说明 |
|---|---|---|
modules/ | 1298 | 横切基础库,约 80 个子包 |
services/ | 681 | 业务逻辑层,约 40 个子包 |
tests/ | 934 | 集成/e2e/模糊测试与测试夹具 |
templates/ | 599 | Go html/template 模板(585 个 .tmpl) |
models/ | 467 | ORM 模型与数据访问层 |
modelmigration/ | 392 | 按版本分组的数据库迁移 |
options/ | 332 | 嵌入的静态选项数据(license/gitignore/locale 等) |
routers/ | 513 | HTTP 层(Web/API/private/install 四套路由) |
cmd/ | 58 | CLI 子命令实现 |
web_src/ | 412 | 前端 TS/Vue/CSS 源码 |
public/ | 492 | 构建产物与嵌入式静态资源 |
这个分布本身就说明了架构重心:modules + services + models 三者合计接近 2600 个文件,是整个系统的躯干;routers 只有 513 个文件,是薄薄的一层外壳。
1.1.2 技术选型:每一项选择都有明确的代价与收益
Gitea 的技术栈在 go.mod 与 package.json 中一览无余:
| 领域 | 选型 | 关键依据(源码位置) |
|---|---|---|
| 语言/构建 | Go 1.27,单二进制 gitea | go.mod;Makefile:515 的 go build -ldflags '-s -w $(LDFLAGS)' |
| CLI 框架 | github.com/urfave/cli/v3 | main.go 与 cmd/*.go 全部使用 *cli.Command |
| HTTP 路由 | github.com/go-chi/chi/v5,由 modules/web 封装 | modules/web/router.go 的 type Router struct { chiRouter *chi.Mux … } |
| ORM | xorm.io/xorm v1.4.3 + xorm.io/builder | models/db/engine_init.go |
| Git 操作 | 系统 git 命令(默认)或 go-git(可选 tag) | modules/git/*_gogit.go 与 *_nogogit.go 双实现 |
| 模板 | Go 标准库 html/template | modules/templates/page.go 的 pageRenderer.HTML |
| 搜索 | Bleve(blevesearch/bleve/v2) | go.mod;modules/indexer/code、issues |
| 前端 | Vite + TypeScript + Vue 3(渐进式组件)+ Tailwind/Fomantic | package.json、vite.config.ts、web_src/js/components/*.vue |
| 会话 | gitea.com/go-chi/session | routers/common/middleware.go 的 MustInitSessioner |
| 队列 | 自研 worker pool,后端可选内存 LevelDB / Redis / channel | modules/queue/ |
几个值得展开的选型判断:
(1)为什么是"单二进制 + Go 模板"而不是前后端分离。
templates/ 下 585 个 .tmpl 文件由 modules/templates/page.go 的 PageRenderer(一个 sync.OnceValue)一次性编译:
var PageRenderer = sync.OnceValue(func() *pageRenderer {
rendererType := util.Iif(setting.IsProd, "static", "auto-reloading")
…
pr := &pageRenderer{tmplRenderer: tr}
if err := tr.recompileTemplates(pr.funcMapDummy()); err != nil {
processStartupTemplateError(err)
}
if !setting.IsProd {
go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
if err := tr.recompileTemplates(pr.funcMapDummy()); err != nil { … }
})
}
return pr
})
注意两个细节:其一,生产模式用 static 渲染器(模板随二进制一起嵌入),开发模式用 auto-reloading 并起一个 goroutine 监听文件变更,改模板无需重启进程——这直接决定了"单二进制部署"与"开发体验"可以兼得。其二,funcMapDummy 在编译期就用 nil 上下文跑一遍模板函数,模板语法错误在启动时暴露而不是在第一个用户访问时暴露。
模板里也能拿到请求上下文:pageRenderer.funcMap 把当前 context.Context 注入为模板函数 ctx:
func (r *pageRenderer) funcMap(ctx context.Context) template.FuncMap {
pageFuncMap := newFuncMapWebPage()
pageFuncMap["ctx"] = func() any { return ctx }
return pageFuncMap
}
所以模板中可以写 {{ctx.Locale.Tr "repo.archive.title"}}(见 templates/repo/home.tmpl),翻译、主题、CSP nonce 都能随渲染取得。
(2)为什么是 chi 而不是 gin/echo。
modules/web/router.go 在 chi 之上做了两件关键的事:
// Router defines a route based on chi's router
type Router struct {
chiRouter *chi.Mux
afterRouting []any
curGroupPrefix string
curMiddlewares []any
}
BeforeRouting:中间件在路径匹配之前执行(可以改写 method/path,例如chi_middleware.GetHead);AfterRouting:中间件在路径匹配之后执行,此时chi.RouteContext里已经有了路由 pattern 和路径参数(例如BlockExpensive要读RoutePattern()判断是否"昂贵"路由)。
普通 web 框架通常只有一档中间件时机。Gitea 需要在匹配前规范化路径(子路径、// 折叠、%2f),又需要在匹配后按 pattern 做 QoS 降级,因此自己包了一层,而不是被框架的抽象卡死。1.5 节会把这条链路完整走一遍。
(3)为什么默认用系统 git 而非 go-git。
modules/git/ 里同一概念往往有两份实现:repo_base_gogit.go / repo_base_nogogit.go、blob_gogit.go / blob_nogogit.go、commit_info_gogit.go / commit_info_nogogit.go。构建时通过 build tag 切换。默认路径(nogogit)直接 spawn 系统 git 进程(modules/git/gitcmd 子包负责命令组装),好处是与服务端 git 行为完全一致、性能与内存占用可控、支持全部传输协议;go-git 纯 Go 实现则用于无法执行外部命令的场景。routers/init.go 启动时打印的就是这条信息:
mustInit(git.InitFull)
log.Info("Git version: %s (home: %s)", git.DefaultFeatures().VersionInfo(), gitcmd.HomeDir())
if !git.DefaultFeatures().SupportHashSha256 {
log.Warn("sha256 hash support is disabled - requires Git >= 2.42." + …)
}
(4)为什么 ORM 选 xorm。
Gitea(承袭 Gogs)选择 xorm 并在其上做了一层泛型封装,models/db/context.go 提供 Get[T]、Find[T]、Count[T]、Delete[T]、WithTx 等函数,调用方写 db.Count[git_model.Branch](ctx, branchOpts) 而不是手搓 xorm.Engine 链式调用。这样做的直接收益是:事务通过 context.Context 隐式传递(1.6.3 节详述),业务函数签名里永远只有 ctx context.Context,不需要把 *xorm.Session 层层传下去。
1.1.3 与 GitLab / Gogs / GitHub 的架构取舍差异
| 维度 | Gitea | GitLab (CE/EE) | Gogs | GitHub |
|---|---|---|---|---|
| 语言与进程模型 | Go,单进程单二进制(web + 内嵌后台任务) | Ruby on Rails + Go/Scala 多组件(Sidekiq、Gitaly、Workhorse、Pages…) | Go,单二进制 | 闭源,内部微服务集群 |
| Git 存取 | 直接在本机执行 git(或 go-git),仓库目录本地可见 | 独立的 Gitaly gRPC 服务,仓库可能不在应用机器上 | 直接执行 git | 独立 Git 域服务 |
| 页面渲染 | 服务端 html/template + 渐进式 Vue 组件 | Rails 视图 + Vue 单页组件 | 服务端 html/template | 客户端 React SPA + API |
| ORM/持久化 | xorm,一套 SQL 模型支持 SQLite/MySQL/PostgreSQL/MSSQL | ActiveRecord,主推 PostgreSQL | xorm | 自研存储 |
| 异步任务 | 进程内队列(modules/queue)+ cron | Redis + Sidekiq 独立进程池 | 进程内 goroutine | 内部任务系统 |
| 迁移 | modelmigration/ 按版本分组,启动时自动执行 | Rails migration | 启动时执行 | 不公开 |
从这张表可以读出三种不同的架构哲学:
- GitLab 走的是"拆":把最重、最危险的 Git 操作隔离进 Gitaly,把 CPU 密集任务隔离进独立 worker,代价是部署复杂度高(官方 Omnibus 包要编排十几个服务)与运维心智负担重。
- Gitea 走的是"合":一个二进制包含 Web、SSH、队列、cron、内嵌 sqlite,
docker run gitea/gitea一条命令即可运行;代价是所有功能共享同一个进程的 CPU/内存,需要自己在进程内做资源治理——这正是我们在源码里看到modules/graceful(优雅停机)、modules/process(进程内任务登记)、routers/common/qos.go(按登录态降级)、modules/queue(可配置后端队列)这些机制的原因。 - Gogs 是 Gitea 的上游起点,功能面停在较早阶段,没有 Actions、包注册、Projects 等模块,架构与 Gitea 早期一致;Gitea 后续把
cmd/拆成独立子命令体系、引入泛型db封装与reqctx请求上下文,都属于对原始形态的系统性重构。 - GitHub 作为闭源 SaaS,其架构参考价值在于接口形态(这也是 Gitea REST API 大量对齐 GitHub v3 API、URL 结构兼容
/tree/、/blob/的原因,见routers/web/repo/view_home.go中RedirectRepoTreeToSrc的注释:"This is done intentionally so that Gitea's repo URL structure matches other forges (GitHub/GitLab) provide")。
一句话概括取舍:Gitea 用"进程内一切"换取部署简单,因此必须在代码层面补齐本该由进程边界提供的隔离与治理;Gitea 的 modules/ 里那一堆看似零散的小包,本质上就是这些补偿机制。
1.2 顶层目录结构逐一剖析
下面按"放什么 / 为什么这么分"逐一过一遍顶层目录。先给一张总表,后文对重点目录展开:
| 目录 | 放什么 | 分层角色 |
|---|---|---|
main.go | 进程唯一入口,组装 CLI app | 入口 |
cmd/ | CLI 子命令(web/serv/hook/dump/doctor/…) | 入口适配层 |
routers/ | HTTP 路由注册与 handler | 表现层 |
services/ | 业务逻辑 | 业务层 |
models/ | ORM 模型、SQL 访问、事务 | 持久层 |
modules/ | 与业务无关的横切基础库 | 基础库(被所有层引用) |
modelmigration/ | 按版本分组的数据库迁移脚本 | 持久层附属 |
options/ | 嵌入二进制的静态数据(locale/gitignore/license…) | 数据资源 |
templates/ | Go html/template 模板 | 表现资源 |
web_src/ | 前端 TS/Vue/CSS 源码 | 前端工程 |
public/ | 静态资源构建产物与嵌入入口 | 静态资源 |
build/ | 代码生成器(bindata/emoji/openapi…) | 构建工具 |
contrib/ | 部署与集成辅助(systemd、grafana mixin、VSCode 配置…) | 周边 |
tests/ | 集成/e2e/模糊测试夹具与测试仓库 | 测试 |
tools/ | 前端与仓库级脚本工具 | 工具 |
docs/ | 开发者文档(backend/frontend/testing 指南等) | 文档 |
custom/ | 用户自定义配置与资源(运行时生成,非源码) | 运行时数据 |
docker/、snap/、.github/ | 容器、snap、CI | 交付 |
1.2.1 cmd/ —— CLI 子命令的实现
cmd/ 共 58 个文件,每个子命令一个(或一组)文件:web.go、serv.go、hook.go、dump.go、doctor.go、admin_*.go、migrate.go、manager.go、embedded.go、keys.go、config.go、cert.go、generate.go、docs.go、actions.go 等。
它的定位非常明确:cmd/ 只做"参数解析 → 调用 routers/services 完成一件事 → 退出",不承载业务逻辑。例如 cmd/dump.go 的命令定义:
func newDumpCommand() *cli.Command {
return &cli.Command{
Name: "dump",
Usage: "Dump Gitea files and database",
Description: `Dump compresses all related files and database into zip file. …`,
Action: runDump,
Flags: []cli.Flag{ … },
}
}
其中 dump 的 Action 直接调用 db、storage、setting 完成备份。serv 与 hook 两个子命令被标记为 Hidden: true,因为它们由 SSH authorized_keys 和 git hook 间接调用,不应出现在 gitea help 里:
func newServCommand() *cli.Command {
return &cli.Command{
Name: "serv",
Usage: "(internal) Should only be called by SSH shell",
Description: "Serv provides access auth for repositories",
Hidden: true, // Internal commands shouldn't be visible in help
Before: PrepareConsoleLoggerLevel(log.FATAL),
Action: runServ,
…
}
}
cmd/main_test.go、cmd/hook_test.go、cmd/admin_user_*_test.go 等说明子命令层也被单测覆盖——cmd/ 是"薄"的,但不是"不可测"的。
1.2.2 routers/ —— 四套路由 + 一个装配入口
routers/
├── init.go # 全局初始化 InitWebInstalled + NormalRoutes 装配
├── common/ # 四套路由共享:协议中间件、鉴权、DB 初始化、QoS、错误页
├── web/ # 面向浏览器的 HTML 路由(web.go 路由表 + 按域拆分的 handler 包)
│ ├── auth/ repo/ org/ user/ admin/ explore/ misc/ feed/ actions/ …
├── api/v1/ # REST API(api.go 路由表 + repo/user/org/… handler 包)
├── api/actions/ # Actions runner 专用 API
├── api/packages/ # 包注册协议 API(npm/pypi/maven/oci/nuget/conan…)
├── private/ # 仅进程内/子进程调用的内部 API(/api/internal)
└── install/ # 安装向导路由
三条硬规则:
- 路由表集中注册,handler 按域拆文件。
routers/web/web.go有 8 万字节,里面是完整的 URL → 中间件 → handler 映射;具体业务处理则散在routers/web/repo/*.go等文件。这样"全局可见的路由结构"永远只有一个查询入口。 routers/可以同时引用services/与models/,它是唯一"两头都碰"的层——handler 需要组装模板数据时确实会直接查模型(如db.Count[repo_model.Release](…)出现在services/context/repo.go,详见 1.3 节的边界讨论)。routers/private/是内部通道:gitea serv、gitea hook这些子进程通过 HTTP 调用主进程的/api/internal/*(1.4.4 节详述),这是 Gitea 实现"SSH 登录校验、git hook 决策"与"主进程共享数据库连接"的关键设计。
1.2.3 services/ —— 业务逻辑层
services/ 约 681 个文件、40 余个子包,按业务域划分:pull/(PR 创建/合并/冲突检测)、issue/、repository/、auth/(鉴权方法插件)、notify/(通知多播总线)、webhook/、mailer/、cron/(定时任务)、indexer/(索引调度)、actions/(CI)、packages/、mirror/、lfs/、gitdiff/、markup/、context/(请求上下文类型,见 1.5 节)、forms/(表单结构与校验规则)。
servers/context/ 的存在是 Gitea 的一个特色:context.Context(web 请求上下文)、context.Repository、context.APIContext、context.PrivateContext 这些类型属于"HTTP 层的公共语言",被放在 services 下而不是 routers 下,以便 models 之外的所有层都能引用。它的文件结构也印证了这一点:base.go(通用响应能力)、context.go(web context 组装)、repo.go(仓库鉴权与 ref 解析中间件)、context_response.go(HTML/404/500 渲染)。
1.2.4 models/ —— 持久层
models/
├── db/ # 引擎初始化、泛型查询封装、事务、迁移钩子
├── repo/ user/ issues/ org/ pull/ git/ perm/ auth/ actions/
├── packages/ system/ webhook/ unit/ activities/ project/ secret/ …
└── fixtures/ # 测试夹具(与 tests/ 配合)
models/ 只依赖 modules/ 与 xorm,从不 import services/ 或 routers/(已用 grep 验证:"gitea.dev/(routers|services)/" 在 models/**/*.go 下零匹配)。这一条是整个架构里最需要守住的底线:一旦模型层反向依赖业务层,事务边界和测试隔离都会崩塌。
模型定义使用 xorm tag 描述表结构,例如 models/repo/repo.go:
type Repository struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"UNIQUE(s) index"`
OwnerName string
Owner *user_model.User `xorm:"-"`
LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
Name string `xorm:"INDEX NOT NULL"`
Description string `xorm:"TEXT"`
…
NumWatches int
NumStars int
…
IsPrivate bool `xorm:"INDEX"`
IsEmpty bool `xorm:"INDEX"`
IsArchived bool `xorm:"INDEX"`
IsMirror bool `xorm:"INDEX"`
…
Units []*RepoUnit `xorm:"-"`
PrimaryLanguage *LanguageStat `xorm:"-"`
注意 xorm:"-" 标注的字段:Owner、Units、PrimaryLanguage 不落库,需要显式 LoadAttributes/LoadUnits 填充。这是"显式加载"风格——与 Rails 的 belongs_to 自动加载相反,好处是每条 SQL 都可预测(对一个以性能为卖点的自托管产品,隐式 N+1 是不可接受的),代价是调用方必须记住加载关联,忘记时字段为 nil。
1.2.5 modules/ —— 横切基础库
约 80 个子包,按名字即可看出用途:setting/(配置)、log/(日志)、git/(git 封装)、queue/(队列)、cache/(缓存)、session/、web/(路由封装)、storage/(对象存储抽象)、markup/(Markdown 等渲染)、indexer/(bleve 索引实现)、notification相关的 webhook/、graceful/(优雅停机)、process/(进程内任务登记)、translation/(i18n)、public/、assetfs/(分层资源文件系统)、structs/(API 共享结构体)、optional/、reqctx/(请求上下文)、cachegroup/、globallock/、gtprof/(内建 tracing)。
modules/ 的准入标准可以概括为:不出现 Repository、Issue、PullRequest 这类业务名词(少数例外见 1.3 节),因此它可以被 models、services、routers、cmd 任何一层引用。
1.2.6 options/、templates/、public/、build/
options/:813 万字节的纯数据目录——locale/(29 个语言文件,其中locale_en-US.json是唯一手工维护的,其余由 Crowdin 同步)、gitignore/、license/、label/、readme/、fileicon/。它们通过build/generate-bindata.go之类的生成器嵌入二进制,运行时经modules/options访问。templates/:585 个.tmpl,按repo/、user/、org/、admin/、mail/、status/等分目录;templates/base/head.tmpl等被{{template}}复用。与options/一样,可通过 tag 切换为嵌入或磁盘读取(modules/public.AssetFS()见 1.5.6 节)。public/:只放三类东西——assets/img/(界面图片资源)、assets/emoji.json(emoji 数据)、.well-known/security.txt(RFC 8615 安全联系文件),共 492 个文件、约 600 KB。它是"随二进制一起分发的静态产物目录",本身不含任何 Go 源码;真正的前端构建产物(js/css)由 Vite 输出后与它一起经build/generate-bindata.go嵌入。注意public/下没有生成器——生成器全部集中在build/。build/:10 个文件,全部是构建期工具,不参与运行时:generate-bindata.go(把 templates/public/options 打进二进制)、generate-emoji.go、generate-gitignores.go、generate-go-licenses.go、generate-openapi.go(从 swagger 注释生成规范)、openapi3gen/(convert.go、enumscan.go等 OpenAPI 3 转换器)、update-locales.sh(从 Crowdin 同步 locale)。它们由Makefile的generate-backend: $(TAGS_PREREQ) generate-go目标在go build之前调用(Makefile:503),对应make backend = generate-backend $(EXECUTABLE)(Makefile:496)。
1.2.7 web_src/、contrib/、tests/、tools/
web_src/:js/(features/按页面划分的初始化函数、components/*.vue、modules/、markup/、webcomponents/)、css/(features/、themes/、repo.css等)。入口web_src/js/index.ts是一长串initXxx()调用(initRepoCodeView、initRepoDiffView、initNotificationCount…),每个函数对应一类页面元素——这是典型的"渐进增强"前端:服务端渲染全部 HTML,JS 只做增强,禁用 JS 时页面依然可读。contrib/:development/vscode/(编辑器任务)、fhs-compliant-script/、grafana-monitoring-mixin/(Prometheus/Grafana 监控套件)、options/label/(标签预设)、service/(systemd 等)、sample-page/、upgrade.sh。tests/:integration/(264 个_test.go,含_mock_data)、e2e/、fuzz/、testdata/、gitea-repositories-meta/(集成测试用的仓库夹具,如user2、org4等)、mssql.ini.tmpl等数据库配置模板。测试策略在第 15 章展开。tools/:generate-svg.ts、generate-images.ts、generate-codemirror-languages.ts、lint-*、test-e2e.sh等,由 Makefile 调用;gocovmerge.go合并覆盖率。
1.2.8 modelmigration/
392 个文件,按版本分目录:v1_6/ … v1_27/、v28/、v29/、base/、fixtures/、migrationtest/。入口 modelmigration/migrations.go:
const minDBVersion = 70 // Gitea 1.5.3
type migration struct {
idNumber int64 // DB version is "the last migration's idNumber" + 1
description string
migrate func(context.Context, base.EngineMigration) error
}
把迁移从 models/ 中拆出来的原因在 routers/common/db.go 可见一斑:migrateWithSetting 需要在 AutoMigration=false 时校验"当前库版本 == 期望版本",不一致直接 log.Fatal 并提示用户手动执行 gitea migrate。迁移代码独立成包后,doctor、migrate、web 三种启动方式可以共享同一份迁移定义,而 versioned_migration.Migrate(services/versioned_migration/migration.go)作为唯一调度入口。
1.2.9 docs/
仓库内 docs/ 只有 9 个开发者文档:build-setup.md、build-source.md、development.md、guidelines-backend.md、guidelines-frontend.md、guidelines-refactoring.md、testing.md、community-governance.md、release-management.md。按仓库 AGENTS.md 的要求,动手改代码前应先读这些文档——它们是本系列书籍的官方对照材料。
1.3 分层与依赖方向:谁可以 import 谁
1.3.1 设计上的四层规则
口头流传的 Gitea 分层规则是"routers → services → models,modules 是横切基础库,谁都不许反向依赖"。把这句话拆成可检验的命题:
models/不得 importservices/、routers/(持久层不知道业务和表现);services/不得 importrouters/(业务层不知道 HTTP);modules/不得 importservices/、routers/(基础库不含业务);routers/可以 importservices/与models/(handler 组装数据时直接查模型是允许的)。
这些不是文档里的客套话,仓库 docs/guidelines-backend.md 对 import 层次有明确约束,而真正的裁判是 Go 编译器——只要有人写反向 import,要么编译期成环报错,要么(在不成环的边角情况下)静静地破坏分层。所以下面不引用文档,直接用 grep 对整个源码树实测。
1.3.2 实测:grep import 关系
对 models/、services/、modules/、routers/ 四个目录分别搜索对其他层的 import(模式 gitea.dev/routers/、gitea.dev/services/、gitea.dev/models/),结果如下(计数为 import 行所在文件数/总行数):
| 源目录 → 目标目录 | routers/ | services/ | models/ |
|---|---|---|---|
models/ | 0 | 0 | —(自身) |
services/ | 3 个文件 | —(自身) | 大量(正常方向) |
modules/ | 0(仅 2 处字符串/注释,非 import) | 2 个文件 | 104 处 / 52 文件 |
routers/ | —(自身) | 大量 | 大量(合计 1918 处 / 433 文件) |
逐条看这些数字背后的实体:
(1)models/ → routers/、models/ → services/ 零匹配。 这是最干净的一条:models/**/*.go 里搜 gitea.dev/routers/ 与 gitea.dev/services/ 均无结果。持久层完全不知道上两层的存在,事务边界与单测隔离的前提成立。
(2)services/ → routers/ 只有 3 个文件,且只借了一个类型。 全部集中在 services/repository/files/:
gitea-28.1.0\services\repository\files\content.go:19: "gitea.dev/routers/api/v1/utils"
gitea-28.1.0\services\repository\files\file.go:19: "gitea.dev/routers/api/v1/utils"
gitea-28.1.0\services\repository\files\update.go:26: "gitea.dev/routers/api/v1/utils"
用到的只有 utils.RefCommit 一个类型(content.go:43 的 refCommit *utils.RefCommit 参数)。这是分层的一处真实"污点":RefCommit(已解析的 ref + commit 组合)本是通用值对象,却被放在了 routers/api/v1/utils 包里,导致文件内容服务反向引用 API 层。修复方向显然是把它挪进 modules/git 或 services/repository,但现状就是现状——它是全书观察"分层腐化从一个共享类型开始"的最好样本。
(3)modules/ → services/ 只有 2 个文件,都在 modules/templates 包内:
gitea-28.1.0\modules\templates\helper.go:23: "gitea.dev/services/gitdiff"
gitea-28.1.0\modules\templates\util_render.go:33: "gitea.dev/services/webtheme"
模板 helper 需要 services/gitdiff 的 diff 渲染类型和 services/webtheme 的主题信息。反向核实过:services/gitdiff 与 services/webtheme 都不 import modules/templates,因此这 2 条边不成环。另外 modules/web/routing/funcinfo.go:133 出现的 "gitea.dev/routers/common/logger_context.go" 只是注释里的示例字符串,funcinfo_test.go:20 是测试夹具,均非 import。
(4)modules/ → models/ 有 104 处,这与"modules 是纯基础库"的直觉相悖,必须直说。 分布集中在几类包:
| 包 | 处数 | 依赖 models 的原因 |
|---|---|---|
modules/repository/ | 21 | 创建仓库、环境变量注入等"仓库操作原语",紧贴模型 |
modules/indexer/{code,issues,stats}/ | 37 | 索引器天然要读 models/* 的行数据 |
modules/templates/ | 18 | 模板渲染需要 models/git、models/issues 等类型 |
modules/actions/、modules/badge/、modules/system/、modules/session/、modules/ssh/、modules/private/、modules/metrics/ | 各 1–12 | 与具体业务表(actions 任务、系统设置、session 落库)耦合的辅助包 |
诚实的结论是:分层规则在 models、routers 两侧执行得近乎完美,在 modules 侧则存在一个历史形成的"半下沉"包群——modules/repository、modules/indexer、modules/templates 名义上是基础库,实际是"没有业务编排的薄业务代码"。它们不造成 import 环(models 不回头引用它们),但意味着"modules 可被任何层引用"这句话要修正为"modules 的多数包可被任何层引用,repository/indexer/templates 三个子树例外"。第 11 章讲索引、第 13 章讲模板时会再次碰到这条边界。
(5)routers/ 对下层的依赖是压倒性的 1918 处 / 433 文件——表现层同时引用 services/ 与 models/,符合预期(routers/init.go 一个文件就 import 了 30 余个 services 包)。
1.3.3 每层的准入与禁忌
把规则反写成"某层能做什么"的清单,方便后续章节对号入座:
| 层 | 允许 import | 禁止 import | 典型职责 | 违规时的后果 |
|---|---|---|---|---|
models/ | modules/*、xorm、标准库 | services/、routers/ | 表结构、SQL、事务、迁移钩子 | 事务边界失控、模型无法独立单测 |
services/ | models/、modules/*、其他 services | routers/(实测 3 文件例外) | 业务编排、权限计算、通知、队列消费者 | 业务逻辑被 HTTP 绑死,CLI/后台任务无法复用 |
modules/ | 标准库、第三方库、models(受限) | routers/(0 违规)、services/(2 文件例外) | 与业务名词无关的通用能力 | 基础库变成大泥球,任何层改动引发连锁重编译 |
routers/ | services/、models/、modules/* | —(顶层) | 路由注册、鉴权中间件、参数绑定、渲染 | 无(它就是最上层) |
cmd/ | routers/、services/、modules/* | — | CLI 解析与进程生命周期 | 无 |
cmd/ 值得单独一提:它直接 import gitea.dev/routers(cmd/web.go:28)和 gitea.dev/routers/install,是唯一"驱动 routers 层"的调用方——1.4 节会看到,serveInstalled 里的 routers.InitWebInstalled(...) + routers.NormalRoutes() 就是整棵树的组装点。
1.3.4 一个具体例子:db.Find 如何贯穿三层
分层不是禁止跨层调用,而是规定调用方向。以仓库首页侧栏查询 topic 为例,调用链是:
routers/web/repo/view_home.go prepareHomeSidebarRepoTopics
→ models/repo/topic.go db.Find[repo_model.Topic](ctx, &FindTopicOptions{...}) [泛型封装]
→ models/db/context.go GetEngine(ctx) 取 engine(或事务 session)
→ xorm 执行 SQL
prepareHomeSidebarRepoTopics(view_home.go:51)直接调 db.Find,说明 handler 允许读模型;而 models/repo/topic.go 只知道自己要拼一条 SQL,不知道最终服务于哪个页面。方向单一、每层可替换——这就是这套约束换来的收益:第 15 章的集成测试可以用 models/unittest 直接测 services,完全绕开 routers;反过来 API 改版也动不了 models。
1.3.5 依赖违规实例:grep 出来的真实例外及其成因
1.3.2 的表格给了汇总数字,这里列出每一处例外的文件与原因——规则被打破的地方,往往比规则本身更能说明架构的真实张力。逐条核实后共四类:
(1)services/repository/files → routers/api/v1/utils(3 个文件,向上的反向依赖)。
services/repository/files/content.go :: "gitea.dev/routers/api/v1/utils"
services/repository/files/file.go :: "gitea.dev/routers/api/v1/utils"
services/repository/files/update.go :: "gitea.dev/routers/api/v1/utils"
用途非常具体:三处都只用一个类型 utils.RefCommit(routers/api/v1/utils/git.go:17 定义,NewRefCommit 构造)和它的方法。services/repository/files 是"文件内容读写"的业务逻辑(REST API 的 GET/PUT /contents 由 routers/api/v1/repo/file.go 转调它),它需要"某个 ref 解析出的 commit"这个概念,而这个概念恰好被定义在了 API 层的 utils 包里。这不是循环 import(routers/api/v1/utils 只 import services/context,不反向依赖 services/repository/files),编译完全合法,但它意味着:想在 gitea hook 或 cron 里复用文件写入逻辑,就得把一个 routers/ 包链进子进程。成因可以推断为:RefCommit 最初为 API handler 而写,业务逻辑随后从 handler 里抽进 services 时,类型没有跟着搬家。合理的修复方向是把 RefCommit 下沉到 modules/git 或 services/repository,让两边都向下依赖——第 9 章写 REST API 时会再撞见这个类型。
(2)modules/templates → services/gitdiff、services/webtheme(2 处,"半下沉"包群的另一面)。
modules/templates/helper.go :: "gitea.dev/services/gitdiff" (模板函数 "CommentMustAsDiff": gitdiff.CommentMustAsDiff)
modules/templates/util_render.go :: "gitea.dev/services/webtheme"
模板函数表需要渲染 diff、解析主题,于是 modules/templates 反向 import 了 services。核实过 services/gitdiff/services/webtheme 均不 import modules/templates,不构成环,但它解释了为什么 1.3.2 里 modules → services 只有 2 个文件却全部集中在这里:modules/templates 名义上是"基础库",实际是为渲染服务的业务胶水,它和 modules/repository、modules/indexer 一起构成了"modules 里的服务化子包群"。理解这个群体后,"modules 是横切层"的教科书表述就该修正为:modules 大多数是纯基础库,少数几个包(templates/repository/indexer/migration)承载了本该在 services 的逻辑——它们对 models 的 104 处 import 集中于此(按子包统计:modules/repository 27 处、modules/indexer 各实现 8+4+12 处、其余为 modules/migration 对迁移辅助的引用)。
(3)models → services 与 models → routers:0 处。
这条最硬的规则没有例外——持久层对上层的反向依赖是编译期就能发现的架构腐化,Gitea 在这里保持了完全干净。grep 结果可以作为断言写进任何架构评审:models/ 下没有任何一行 import gitea.dev/services/ 或 gitea.dev/routers/。
(4)routers → services|models(1918 处 / 433 文件)看着吓人,但方向正确。
表现层 import 业务层与持久层是设计允许的(1.3.1 规则的正向调用)。真正值得记的反而是它的分布特征:routers/web/repo/ 的 handler 大量直接调 models(如 1.3.4 的 db.Find),说明 Gitea 没有强制"handler 只准调 services"的教条——读多写少的查询允许穿透到 models 以省一层包装,写路径与复杂编排才必须进 services。这个取舍在第 4/5 章读 models/repo 的查询函数时会反复印证。
另外还有一条被 lint 强制执行的反向规则(.golangci.yml:58 的 depguard migrations 规则):
migrations:
files:
- '**/modelmigration/**/*.go'
deny:
- pkg: gitea.dev/models
desc: "migrations must not depend on the models package. HINT: MIGRATION-STRUCT-FROZEN"
- pkg: gitea.dev/modules/structs
desc: "migrations must not depend on modules/structs. HINT: MIGRATION-STRUCT-FROZEN"
迁移代码禁止 import 当前的 models 定义(MIGRATION-STRUCT-FROZEN):迁移必须描述"历史时刻"的表结构,如果它引用了今天的 models.User,将来模型加字段时旧迁移会悄悄改变语义、破坏已部署实例的可重现性。同文件还有一组全局 depguard 禁令(encoding/json → 用 modules/json、gopkg.in/ini.v1 → 用 setting 配置系统、io/ioutil、exec.CommandContext → 用 graceful 包装…),每条 desc 就是一行架构决策记录——.golangci.yml 本身就是一份可执行的架构文档,比任何 wiki 页面都权威,因为它不遵守就进不了 CI。
1.4 程序入口与进程模型
1.4.1 main.go:59 行的进程起点
整个仓库只有一个 main 包,入口文件 main.go 短得惊人:
var (
Version = "development" // program version for this build
Tags = "" // the Golang build tags
)
func init() {
setting.AppVer = Version
setting.AppBuiltWith = formatBuiltWith()
setting.AppStartTime = time.Now().UTC()
}
func main() {
gitproxy.MaybeTunnel()
cli.OsExiter = func(code int) {
log.GetManager().Close()
os.Exit(code)
}
app := cmd.NewMainApp(cmd.AppVersion{Version: Version, Extra: formatBuiltWith()})
_ = cmd.RunMainApp(app, os.Args...) // all errors should have been handled by the RunMainApp
// flush the queued logs before exiting, it's a MUST, otherwise there will be log loss
log.GetManager().Close()
}
四个值得记住的细节:
Version与Tags是var而非常量,因为它们由链接期注入:Makefile:109的LDFLAGS := ... -X "main.Version=$(GITEA_VERSION)" -X "main.Tags=$(TAGS)"。gitea -v打印的版本号就来自这里,未注入时回退为"development"。gitproxy.MaybeTunnel()在解析任何参数之前执行——支持通过环境变量让所有出站 git 流量走代理隧道,必须早于 git 相关初始化。cli.OsExiter被覆盖为"先刷日志再退出",与结尾的log.GetManager().Close()呼应:Gitea 的日志是异步写入的(modules/log的 LoggerManager 带缓冲),任何退出路径都要先 flush,否则丢最后几行——这正是排障时"错误日志缺尾巴"的根因防护。- 文件顶部还有一批 blank import(
_ "gitea.dev/modules/markup/markdown"等 5 个):markdown/csv/jupyter/console/orgmode 这些文档格式的渲染器通过init()自注册,main.go是唯一的注册清单。要支持新的 markup 格式,改的就是这 5 行旁边的清单。
1.4.2 cmd.NewMainApp:一个二进制、一套子命令表
cmd/main.go:87 的 NewMainApp 组装 CLI 应用。这里要澄清一个流传甚广的错误说法:Gitea 用的不是 cobra,而是 github.com/urfave/cli/v3(go.mod 中 v3.13.0)——cmd/*.go 里所有 *cli.Command 都来自这个库,文件头部 import 写得明白。
子命令被明确分成两类:
// these sub-commands need to use a config file
subCmdWithConfig := []*cli.Command{
webCmd,
newServCommand(),
newHookCommand(),
NewKeysCommand(),
newDumpCommand(),
newAdminCommand(),
newMigrateCommand(),
newDoctorCommand(),
newManagerCommand(),
newEmbeddedCommand(),
newMigrateStorageCommand(),
newDumpRepositoryCommand(),
newRestoreRepositoryCommand(),
newActionsCommand(),
}
// these sub-commands do not need the config file, and they do not depend on any path or environment variable.
subCmdStandalone := []*cli.Command{
cmdConfig(),
cmdCert(),
newGenerateCommand(),
newDocsCommand(),
}
分类的执行机制在 PrepareSubcommandWithGlobalFlags(cmd/main.go:51):它给每个 subCmdWithConfig 命令包一层 Before 钩子,在原钩子之后调用 prepareWorkPathAndCustomConf(cmd) → setting.InitWorkPathAndCommonConfig(os.Getenv, args)(modules/setting/path.go:96)。也就是说,"需要读配置的子命令"在真正执行前都会统一解析 --work-path/--config/--custom-path 三个全局 flag 加环境变量;subCmdStandalone 则完全不碰配置,gitea cert、gitea docs 在任何目录下都能裸跑。
子命令一览及各自"做一件事"的边界:
| 子命令 | 定义位置 | 作用 | 关键点 |
|---|---|---|---|
web(默认) | cmd/web.go newWebCommand | 启动 HTTP/SSH 服务 | app.DefaultCommand = webCmd.Name,双击 exe 或直接 gitea 即启动 web |
serv | cmd/serv.go | SSH 侧 git 接入鉴权 | Hidden: true,由 authorized_keys 间接调用 |
hook | cmd/hook.go | pre-receive/update/post-receive/proc-receive | Hidden: true,由仓库 git hook 脚本调用 |
keys | cmd/keys.go | 打印 authorized_keys 模板 | 供 SSHD 配置 Include 使用 |
dump | cmd/dump.go newDumpCommand | 打包数据库+文件为 zip | 调 db、storage、setting |
admin | cmd/admin_*.go | 用户/OAuth2/-feed/两步验证等管理操作 | 子子命令组 |
migrate | cmd/migrate.go | 手动执行数据库迁移 | 对应 1.2.8 的版本校验失败提示 |
doctor | cmd/doctor.go | list/default/run 体检项 | 检查磁盘、hooks、repo 状态 |
manager | cmd/manager.go | shutdown/restart/reload-templates/flush-queues/processes | 经 /api/internal/manager/* 驱动运行中的主进程 |
embedded | cmd/embedded.go | list/view/extract 二进制内嵌资源 | 运维排查模板/静态资源用 |
migrate-storage | cmd/migrate_storage.go | 存储后端间迁移(本地↔S3…) | 调 modules/storage |
dump-repository / restore-repository | cmd/dump_repository.go 等 | 单仓库备份恢复 | |
actions | cmd/actions.go | 生成 runner token 等 Actions 管理 | |
config / cert / generate / docs | cmd/config.go 等 | 独立工具:打印配置、生成证书/模板/文档 | 不读配置 |
RunMainApp(cmd/main.go:176)负责运行与收尾:
func RunMainApp(app *cli.Command, args ...string) error {
ctx, cancel := installSignals()
defer cancel()
setCLIOnUsageError(app)
// the completion subcommands are built during app.Run, after the Walk above, so cover them via this hook
app.ConfigureShellCompletionCommand = setCLIOnUsageError
err := app.Run(ctx, args)
…
if _, ok := errors.AsType[usageErr](err); ok {
cli.OsExiter(1) // cliOnUsageError already reported it
return err
}
…
}
installSignals() 把 SIGINT/SIGTERM 接到 context 上——优雅停机(modules/graceful)从这里拿到根 context;errors.AsType[usageErr] 是 Go 1.26+ 的类型断言泛型写法,用于区分"参数用法错误"(cli 框架已打印)与"运行错误"(要补印 Command error:)。
1.4.3 Makefile 如何把它打成一个二进制
make help(Makefile:183)列出的目标里,构建链条是:
build: frontend backend ## build everything (Makefile:490)
frontend: $(FRONTEND_DEST) ## build frontend files (Makefile:493)
backend: generate-backend $(EXECUTABLE) ## build backend files (Makefile:496)
generate-backend: $(TAGS_PREREQ) generate-go (Makefile:503)
frontend:pnpm/Vite 把web_src/编译到public/assets/.vite/manifest.json(Makefile:117的FRONTEND_DEST);generate-backend→generate-go:跑build/generate-*.go,产物是四个 bindata 文件(Makefile:121):modules/migration/bindata.*、modules/public/bindata.*、modules/options/bindata.*、modules/templates/bindata.*——模板、静态资源、选项数据、迁移数据全部被go:embed风格打进二进制,这就是"单二进制"的技术兑现点;$(EXECUTABLE):go build -ldflags '-s -w $(LDFLAGS)'(Makefile:515附近),-s -w剥掉符号表与调试信息压体积,LDFLAGS注入版本号。
所以 make build 产出的 gitea 一个文件就包含:Web 服务 + SSH 接入 + 队列 + cron + 全部模板与静态资源。数据库驱动(sqlite/mysql/pgsql/mssql)则由 build tags 决定编进哪些。
1.4.4 进程模型:主进程 + 受控子进程,靠 /api/internal 通信
Gitea 运行时不是纯粹的单进程,而是"一个主进程 + 若干短命子进程",子进程通过内部 HTTP 接口回连主进程:
graph LR
A["git 客户端 / SSH 登录"] -->|"authorized_keys: gitea serv key-123"| B["gitea serv 子进程"]
C["git push 触发 hook"] -->|"pre-receive: gitea hook pre-receive ..."| D["gitea hook 子进程"]
B -->|"HTTP POST /api/internal/serv/..."| E["主进程 gitea web<br/>routers/private.Routes()"]
D -->|"HTTP POST /api/internal/hook/pre-receive/..."| E
E --> F["models: 数据库连接池"]
B -->|"exec git-upload-pack"| G["系统 git 进程"]
D -->|"读写仓库 refs"| G
三条链各自的关键代码:
(1)SSH 接入链。 gitea serv 的入口 runServ(cmd/serv.go:137):解析 key-123 形式的参数拿到 keyID,读环境变量 SSH_ORIGINAL_COMMAND,然后不自己碰数据库业务,而是调 private.ServCommand(ctx, keyID, owner, repo, mode, verb, lfsVerb)(cmd/serv.go:253)——modules/private 是一个 HTTP client,打到主进程的 /api/internal/serv/command/...。鉴权通过后,它 spawn 真正的 git 进程(cmd/serv.go:296-335):找到 git-upload-pack 可执行文件(Windows 上回退为 git.exe upload-pack),把 stdout/stdin/stderr 直接接给调用者,并注入 GITEA_REPO_ID、GITEA_PUSHER_ID 等环境变量供 hook 读取。
(2)git hook 链。 仓库的 hook 脚本由 repo_service.SyncRepositoryHooks 写入,内容是 gitea hook pre-receive ...。cmd/hook.go 的 runHookPreReceive 解析 stdin 上的 ref 更新行,再经 private.HookPreReceive(modules/private/hook.go:95)发给主进程 /api/internal/hook/pre-receive/{owner}/{repo},由 routers/private/hook_pre_receive.go 完成权限检查、issue 关联、通知入队,返回"允许/拒绝"及附加输出。
(3)管理链。 gitea manager flush-queues 等命令同样经 modules/private 调 /api/internal/manager/*(路由见 routers/private/internal.go 的 Routes()),操作的是运行中主进程的内存对象——所以必须回连而不是本地执行。
为什么选"子进程回连 HTTP"而不是直接共享数据库?三个理由:其一,hook/serv 子进程生命极短,各自建数据库连接池成本高且回收不及时;其二,队列、缓存、模板渲染器都活在主进程内存里,hook 触发的"推送后刷新索引/发通知"必须投递到主进程的队列(1.6.5 节),走内部 API 是唯一直达路径;其三,routers/private.Routes() 挂在 /api/internal 而非公网路由组,且 NormalRoutes() 里的这组路由带 PrivateContexter + authInternal 校验(1.5.4 节),默认只监听本机,形成一个受控的进程间通道。
(4)例外:LFS 的纯 SSH 传输。 runServ 在 verb == git.CmdVerbLfsTransfer 时不再 spawn git,而是直接进入 lfstransfer.Main(ctx, ...)(cmd/serv.go:267,实现位于 modules/lfstransfer/main.go)在当前子进程内完成 LFS 数据搬运——这是唯一"子进程内直接干业务"的路径,因为 LFS 传输的数据流无法经过主进程转发。
此外 main.go 所在的二进制还被 services/gitproxy 用于出站 git 代理、被 SSH authorized_keys 的 command= 前缀复用——同一个二进制在不同调用场景下扮演不同角色,这是"单二进制"除部署简单外的第二层含义。
1.4.5 /api/internal 私有路由:受控子进程通道的完整协议
1.4.4 概括了"子进程回连主进程"的模型,这里把协议本身拆开看。服务端是 routers/private/internal.go 的 Routes()(1281 行的公网路由之外的第三组 Mount,routers/init.go:185):
// Routes registers all internal APIs routes to web application.
// These APIs will be invoked by internal commands for example `gitea serv` and etc.
func Routes() *web.Router {
r := web.NewRouter()
r.AfterRouting(context.PrivateContexter())
r.AfterRouting(authInternal)
// Log the real ip address of the request from SSH is really helpful for diagnosing sometimes.
// Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers.
r.AfterRouting(setRealIP)
r.AfterRouting(common.AuditOrigin(audit_model.OriginSystem))
r.Get("/dummy", misc.DummyOK)
r.Post("/ssh/authorized_keys", AuthorizedPublicKeyForSSH)
r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive)
r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookPostReceive)
r.Post("/hook/proc-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookProcReceive)
r.Get("/serv/none/{keyid}", ServNoCommand)
r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand)
r.Post("/manager/shutdown", Shutdown)
r.Post("/manager/restart", Restart)
r.Post("/manager/reload-templates", ReloadTemplates)
r.Post("/manager/flush-queues", bind(private.FlushOptions{}), FlushQueues)
…
r.Post("/mail/send", SendEmail)
r.Post("/restore_repo", RestoreRepo)
r.Post("/actions/generate_actions_runner_token", GenerateActionsRunnerToken)
…
}
逐项拆解这条通道的四个设计点:
-
认证是单 token 常量时间比较。
authInternal(internal.go:25)读请求头X-Gitea-Internal-Auth: Bearer <token>,与setting.InternalToken做subtle.ConstantTimeCompare——常量时间比较防止通过响应耗时逐字节猜 token;token 缺失时直接 403 并在日志里 Warn。源码里留着一条 TODO:"use something like JWT or HMAC to avoid passing the token in the clear"——token 目前以明文过线,安全性依赖/api/internal只监听本机/内网这一部署前提(1.4.4 已述)。失败时log.Debug("Forbidden attempt to access internal url: Authorization header: %s", tokens),Debug 级别避免把 token 塞进常规日志。 -
setRealIP只在这个信任域内启用。 内部客户端(modules/private.NewInternalRequest)会带X-Real-IP头,setRealIP把它写回req.RemoteAddr,让从 SSH 子进程进来的请求在日志里显示真实来源 IP。注释明确说明了与 chi 官方中间件的取舍:"It replaces chi's deprecated middleware.RealIP, which is unsafe on public-facing endpoints."——同一逻辑在公网路由上是 IP 伪造漏洞,在 token 门禁之后则是诊断利器,位置决定语义,这是"中间件挂在哪个链上"影响安全性的直接例子。 -
bind[T]泛型表单绑定。bind(private.HookOptions{})返回func(ctx *context.PrivateContext),用middleware.BindFormAny把子进程 POST 的表单解码进结构体,校验失败回PrivateInternalErrorf——私有路由复用了 web 层的 binding/校验设施,但错误输出走PrivateContext而非渲染 HTML 模板。 -
路由本身就是一条业务流水线。 对比公网侧:
/hook/pre-receive/{owner}/{repo}挂的是RepoAssignment+bind+ handler——和浏览器路由用的是同一个services/context.RepoAssignment(1.5.6 详述),即子进程触发的 hook 请求同样要走"查 owner → 查 repo → 打开 git 仓库"的装配流程;/hook/post-receive与/hook/proc-receive还额外挂context.OverrideContext(),允许 hook 处理覆盖 request context 中的用户身份(推送者由 token 关联的 key 解析而来,而非 session)。
客户端侧的对应物是 modules/private 包:ServCommand、HookPreReceive、HookPostReceive、FlushQueues 等函数各自封装"构造 URL → 注入 X-Gitea-Internal-Auth 头 → 发 POST → 解析错误体"。gitea serv、gitea hook、gitea manager 三个子命令共享同一套 client,协议的两端(modules/private 客户端 / routers/private 服务端)分别落在两个不互相 import 的包里,中间只靠 HTTP + token 约定——这使得私有 API 的调用方不需要链接任何路由代码,子进程的内存占用保持在最低。
值得注意的还有 /repo 组里 LFS 路由的两行 FIXME:
r.Group("/repo", func() {
// FIXME: it is not right to use context.Contexter here because all routes here should use PrivateContext
// Fortunately, the LFS handlers are able to handle requests without a complete web context
common.AddOwnerRepoGitLFSRoutes(r, func(ctx *context.PrivateContext) {
webContext := &context.Context{Base: ctx.Base} // see above, it shouldn't manually construct the web context
ctx.SetContextValue(context.WebContextKey, webContext) // FIXME: this is not ideal but no other way at the moment
})
})
LFS 协议处理器被写成了期望完整 context.Context 的形态,私有路由只好手工拼一个"半上下文"塞进去。注释诚实地承认这是权宜之计,读源码时遇到 FIXME 应当理解为"此处有已知的抽象泄漏",它们往往就是后续重构的切入口(也提醒写第 6/14 章时要回到这里核对 LFS 链路)。
1.5 一次 HTTP 请求的完整生命周期(以仓库首页为例)
本节跟踪 GET /owner/repo 从 net/http 进入进程到 HTML 写出的全过程。选这个 URL 是因为它穿过的中间件最全:既有一般页面共有的会话/鉴权,又有仓库专属的 RepoAssignment、RepoRefByType。全链路涉及的文件:
| 阶段 | 文件 | 关键函数 |
|---|---|---|
| ① 监听 | cmd/web.go | runWeb → serveInstalled → listen |
| ② 顶层路由 | routers/init.go | NormalRoutes |
| ③ 路径规范化 | modules/web/router.go | ServeHTTP → normalizeRequestPath |
| ④ 协议中间件 | routers/common/middleware.go | ProtocolMiddlewares |
| ⑤ Web 中间件链 | routers/web/web.go | Routes / newWebAuthMiddleware / verifyAuthWithOptionsWeb |
| ⑥ handler 参数注入 | modules/web/handler.go | toHandlerProvider → prepareHandleArgsIn |
| ⑦ 路由级中间件 | services/context/repo.go | RepoAssignment / RepoRefByType |
| ⑧ 业务 handler | routers/web/repo/view_home.go | Home |
| ⑨ 模板渲染 | services/context/context_response.go → modules/templates/page.go | ctx.HTML → pageRenderer.HTML |
1.5.1 阶段①:从 runWeb 到 listener
cmd/web.go:261 的 runWeb 做四件事:graceful.InitManager(managerCtx) 建优雅停机管理器 → _ = templates.PageRenderer() 预编译模板(失败立即退出,见 1.1.2)→ 按 setting.InstallLock 分流安装页/正式页 → 正式路径走 serveInstalled:
func serveInstalled(c *cli.Command) error {
setting.MustInstalled()
…
routers.InitWebInstalled(graceful.GetManager().HammerContext())
…
// Set up Chi routes
webRoutes := routers.NormalRoutes()
auditCtx := cliAuditContext(context.Background())
audit.Record(auditCtx, audit_model.SystemStartup, nil, "version", setting.AppVer)
err := listen(webRoutes, true)
…
}
listen(m http.Handler, ...)(cmd/web.go:332)按 setting.Protocol 分支:HTTP/HTTPS(可选 ACME 自动证书)/FCGI/HTTPUnix/FCGIUnix,分别交给 runHTTP/runHTTPS/runFCGI。默认 HTTP 分支构造 http.Server{Handler: webRoutes} 开始 Accept 循环——NormalRoutes() 返回的 *web.Router 就是整个进程的根 http.Handler,此后每个连接的 goroutine 都从它的 ServeHTTP 进入。
InitWebInstalled(routers/init.go:115)是启动期的总装配,顺序有讲究(这里只列骨架,1.6 节逐项展开):
func InitWebInstalled(ctx context.Context) {
mustInit(git.InitFull)
…
setting.LoadSettings()
mustInit(storage.Init)
…
mustInitCtx(ctx, common.InitDBEngine) // 数据库就位,之后的 Init 才能查库
…
mustInitCtx(ctx, models.Init)
…
mustInit(indexer_service.Init)
mirror_service.InitSyncMirrors()
mustInit(webhook.Init)
…
cron.Init(ctx) // 最后才启动定时任务
}
mustInit/mustInitCtx(routers/init.go:60)用反射取出函数名后 log.Fatal——任何一个子系统初始化失败都直接终止启动,而不是带着残缺状态继续对外服务,这是"fail-fast 装配"的典型写法。
1.5.2 阶段②③:NormalRoutes 的四组 Mount 与路径规范化
// NormalRoutes represents non install routes
func NormalRoutes() *web.Router {
r := web.NewRouter()
r.BeforeRouting(common.ProtocolMiddlewares()...)
r.AfterRouting(common.MaintenanceModeHandler())
r.Mount("/", web_routers.Routes())
r.Mount("/api/v1", apiv1.Routes())
r.Mount("/api/internal", private.Routes())
…
if setting.Packages.Enabled {
r.Mount("/api/packages", packages_router.CommonRoutes())
r.Mount("/v2", packages_router.ContainerRoutes()) // OCI 必须挂根路径
}
if setting.Actions.Enabled {
r.Mount("/api/actions", actions_router.Routes("/api/actions"))
…
}
r.NotFound(func(w http.ResponseWriter, req *http.Request) { … http.NotFound(w, req) })
return r
}
四组路由的分工:/ 是浏览器 UI,/api/v1 是对齐 GitHub 的 REST API,/api/internal 是 1.4.4 节的子进程通道,/api/packages + /v2 是包注册协议(OCI 规范要求 /v2 必须在站点根,代码注释里专门提示了反代配置)。/v2 的特例在路径规范化里也有对应处理(见下)。
请求进入根 Router.ServeHTTP:
// ServeHTTP implements http.Handler
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// TODO: need to move it to the top-level common middleware, otherwise each "Mount" will cause it to be executed multiple times, which is inefficient.
r.normalizeRequestPath(w, req, r.chiRouter)
}
normalizeRequestPath(modules/web/router.go:250)在匹配前做四件事,每件都对应一类真实故障:
- 去尾斜杠、折叠
//——/owner/repo/与/owner/repo视为同一路由,避免重复内容对 SEO 与链接分享不友好;//折叠走的是逐字符慢路径,但只在检测到//时才执行; - 子路径剥离——
setting.UseSubURLPath为真时把setting.AppSubURL前缀裁掉(strings.CutPrefix(normalizedPath, setting.AppSubURL+"/")),让 Gitea 不依赖反代 rewrite 就能跑在子路径下;此时非/v2/前缀且非 Vite 开发请求的其它路径会被主动 404(注释原话:"do not respond to other requests, to simulate a real sub-path environment")——宁可直接拒绝,也不给用户"半可用"的错觉; /v2白名单——子路径模式下 OCI 端点不参与裁剪,符合容器 registry 规范;- 回填
RawPath/Path——规范化后的路径经url.PathUnescape写回 request,保证 chi 拿到的是最终匹配串;解码失败则 400。
完成规范化才进入 chiRouter,chi 按 method + pattern 匹配到 GET /{username}/{reponame}。
1.5.3 阶段④:ProtocolMiddlewares(匹配前全局中间件)
r.BeforeRouting(...) 把中间件挂到 chi 的 Use 上,在路径匹配之前执行。routers/common/middleware.go:27 明确注释了 "the order is important":
func ProtocolMiddlewares() (handlers []any) {
// the order is important
handlers = append(handlers, ChiRoutePathHandler()) // make sure chi has correct paths
handlers = append(handlers, RequestContextHandler()) // prepare the context and panic recovery
handlers = append(handlers, SecurityHeadersHandler())
if setting.ReverseProxyLimit > 0 && len(setting.ReverseProxyTrustedProxies) > 0 {
handlers = append(handlers, ForwardedHeadersHandler(setting.ReverseProxyLimit, setting.ReverseProxyTrustedProxies))
}
handlers = append(handlers, routing.NewRequestInfoHandler())
if setting.IsAccessLogEnabled() {
handlers = append(handlers, context.AccessLogger())
}
if !setting.IsProd {
handlers = append(handlers, public.ViteDevMiddleware)
}
return handlers
}
逐个说明为什么排这个位置:
ChiRoutePathHandler:把chiCtx.RoutePath设为EscapedPath()(或RawPath),注释说明是为了让%2f被当作路径分隔符正确处理——否则/a%2Fb会被当成一个 segment,目录路由匹配错乱。它必须第一个执行,因为后面所有 handler 都依赖路由匹配结果。RequestContextHandler:一个函数干了五件事,是全链路的"地基":
func RequestContextHandler() func(h http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(respOrig http.ResponseWriter, req *http.Request) {
// this response writer might not be the same as the one in context.Base.Resp
// because there might be a "gzip writer" in the middle, so the "written size" here is the compressed size
respWriter := context.WrapResponseWriter(respOrig)
profDesc := fmt.Sprintf("HTTP: %s %s", req.Method, req.RequestURI)
ctx, finished := reqctx.NewRequestContext(req.Context(), profDesc)
defer finished()
ctx, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanHTTP)
req = req.WithContext(ctx)
defer func() {
chiCtx := chi.RouteContext(req.Context())
span.SetAttributeString(gtprof.TraceAttrHTTPRoute, chiCtx.RoutePattern())
span.End()
}()
defer func() {
if recovered := recover(); recovered != nil {
renderPanicErrorPage(respWriter, req, recovered) // it should never panic, and it handles the stack trace internally
}
}()
ctx = cache.WithCacheContext(req.Context())
req = httplib.RequestWithContext(req, reqctx.FromContext(ctx))
next.ServeHTTP(respWriter, req)
})
}
}
拆开看:① WrapResponseWriter 包一层 ResponseWriter 以记录"已写字节数/状态码"(services/context/response.go 的 Response 结构在首次 Write/WriteHeader 时执行 beforeFuncs,这正是 flash cookie 能在响应结束前写回的机制);② reqctx.NewRequestContext 建请求级数据仓库(modules/reqctx/datastore.go,并登记进 process manager 便于 gitea manager processes 观察);③ gtprof 开 tracing span,结束时记录最终匹配到的 RoutePattern();④ recover() + renderPanicErrorPage——任何 handler panic 都在这里兜底成 500 页面而不是拖垮进程(routers/common/errpage.go,非 prod 或管理员访问时才展示堆栈细节);⑤ cache.WithCacheContext 把缓存上下文塞进 request context,让后续 handler 通过 ctx 拿到本请求共享的缓存视图。
SecurityHeadersHandler:按配置写X-Content-Type-Options、X-Frame-Options。ForwardedHeadersHandler:仅在配置了可信反代时启用,按ReverseProxyLimit逐级信任X-Forwarded-For——不配就绝不信任,防止伪造 IP 绕过限流。routing.NewRequestInfoHandler(modules/web/routing/requestinfo.go:18):记录请求方法、URI 供性能追踪使用;同包的RecordFuncInfo/GetFuncInfo(funcinfo.go:38)为每个 handler 标注源码文件名,供routers/web/admin/perftrace页面展示"哪个函数最慢"。context.AccessLogger(仅setting.IsAccessLogEnabled())、public.ViteDevMiddleware(仅非 prod,把/assets请求代理到 Vite dev server)都是条件挂载——中间件链本身是按配置动态拼装的,这条设计贯穿全书。
1.5.4 阶段⑤:web.Routes() 的 AfterRouting 链
routers/web/web.go:254 的 Routes() 构建面向浏览器的路由。要点:
routes := web.NewRouter()
// GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route
routes.BeforeRouting(chi_middleware.GetHead)
…
var mid []any
if setting.EnableGzip {
wrapper, err := gzhttp.NewWrapper(gzhttp.RandomJitter(32, 0, false), gzhttp.MinSize(GzipMinSize), gzhttp.CompressionLevel(6))
…
mid = append(mid, wrapper)
}
…
mid = append(mid, common.MustInitSessioner(), context.Contexter(), common.AuditOrigin(audit_model.OriginUI))
// Get user from session if logged in.
webAuth := newWebAuthMiddleware()
mid = append(mid, webAuth.MiddlewareHandler)
…
mid = append(mid, goGet)
mid = append(mid, common.PageGlobalData)
mid = append(mid, common.BlockExpensive(), common.QoS(), web.RouterMockPoint(RouterMockPointBeforeWebRoutes))
webRoutes := web.NewRouter()
webRoutes.AfterRouting(mid...)
registerWebRoutes(webRoutes, webAuth)
routes.Mount("", webRoutes)
这些被 AfterRouting 收集的中间件在路径匹配之后执行(由 wrapMiddlewareAndHandler 拼进每个路由的 middleware 数组,modules/web/router.go:149),此时 chi.RouteContext 里已有路由 pattern 与路径参数——BlockExpensive 正是靠 chi.RouteContext(reqCtx).RoutePattern() 判断当前是否是"昂贵路由"(routers/common/blockexpensive.go)。链上各成员:
- gzip:
gzhttp包装,MinSize起压缩,RandomJitter(32,...)防 BREACH 攻击(注释引用了 compress 库 README)。 MustInitSessioner(routers/common/middleware.go:124):gitea.com/go-chi/session的Sessioner,按setting.SessionConfig初始化 provider(内存/文件/Redis/MySQL…)。源码注释暴露了一个已知设计缺陷:chi-session 会对Set里存的类型做gob.Register,服务重启后首次Get可能因类型未注册而解码失败——所以各包必须在启动期注册好 session 中要存的 struct。context.Contexter(services/context/context.go):把裸http.Request升级成 Gitea 的*context.Context——NewBaseContext→NewWebContext→ 公共模板数据(CommonTemplateContextData)→ 当前 URL/Link →PageData→ 读 flash cookie → 注册ctx.Resp.Before(...)在响应前把 flash 写回 cookie → 解析 multipart(上限ParseMultipartFormMaxMemory = 32 << 20,即 32 MiB)→ httpcache → 系统配置与语言列表。这一步之后 handler 拿到的才是"能直接ctx.Data[...] = ...+ctx.HTML(...)"的完整上下文。AuditOrigin(audit_model.OriginUI):给请求打"来源=UI"标记,写入审计事件(第 12 章展开)。webAuth.MiddlewareHandler(newWebAuthMiddleware,web.go:102):真正的鉴权分发器。它构造auth_service.NewGroup(),按 OAuth2 → DeployToken → Basic → ReverseProxy → Session → SSPI 的顺序尝试各auth.Method(services/auth/group.go的Group.Verify会记录命中的AuthedMethod),成功则经common.AuthShared(routers/common/auth.go)把ctx.Doer、ctx.IsSigned、ctx.IsBasicAuth、Data["IsSigned"]、IsAdmin等一并写好。注意路由上写在ctx.Doer之前的webAuth.AllowBasic(web.go:118)是一个PreMiddlewareProvider:它只在 request context 里打一个keyAllowBasic标记,告诉MiddlewareHandler这条路由允许 Basic 认证——因为仓库 git-over-HTTP 要用 Basic,而普通页面默认不启用,避免凭据被随意用于非预期端点。goGet:响应?go-get=1,让go get owner/repo能解析 meta import。common.PageGlobalData(routers/common/pagetmpl.go):用sync.OnceValue惰性取"通知未读数"与 stopwatch,同一请求内所有页面模板共享一次查询。BlockExpensive+QoS:BlockExpensive(routers/common/blockexpensive.go)维护一份 expensive 路径清单并计算请求优先级;QoS(routers/common/qos.go:49)在系统过载时按requestPriority(登录态/管理态优先)返回 503。这是单进程架构下"进程内资源治理"的具体实现,呼应 1.1.3 的取舍分析。
同在 Routes() 里的还有独立注册的静态端点:/assets/* 走 public.FileHandlerFunc()、/avatars/* 走存储适配器、/robots.txt、/api/healthz 等,它们在 mid 组装前注册,刻意不经过 session/鉴权链——静态资源与健康检查不需要那套开销。
registerWebRoutes(web.go:341)随后定义各路由的访问策略闭包:
reqSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true})
reqSignOut := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignOutRequired: true})
optSignInHome := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: false})
optSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict})
…
verifyAuthWithOptionsWeb(routers/web/web.go:178)是所有这些变体的唯一实现,忠实摘录其闭包主体(外层构造略):
// verifyAuthWithOptionsWeb checks authentication according to options
func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.Context) {
crossOriginProtection := http.NewCrossOriginProtection()
return func(ctx *context.Context) {
// Check prohibit login users.
if ctx.IsSigned {
check := common.CheckSignedInUser(ctx.Doer, ctx.Session)
if check.NeedActivateAccount {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, "user/auth/activate")
return
} else if check.LoginIsProhibited {
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
…
} else if check.NeedChangePassword {
if ctx.Req.URL.Path != "/user/settings/change_password" {
if strings.HasPrefix(ctx.Req.UserAgent(), "git") {
ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password"))
return
}
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
return
}
} else if ctx.Req.URL.Path == "/user/settings/change_password" {
// make sure that the form cannot be accessed by users who don't need this
ctx.Redirect(setting.AppSubURL + "/")
return
}
}
// When a signed-in user visits a page that requires sign-out (e.g.: "/user/login"), redirect to home (or alternate location)
if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" {
ctx.RedirectToCurrentSite(ctx.FormString("redirect_to"))
return
}
if !options.SignOutRequired && !options.DisableCrossOriginProtection {
if err := crossOriginProtection.Check(ctx.Req); err != nil {
http.Error(ctx.Resp, err.Error(), http.StatusForbidden)
return
}
}
if options.SignInRequired && !ctx.IsSigned {
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
return
}
// Redirect to log in page if auto-signin info is provided and has not signed in.
if !options.SignOutRequired && !ctx.IsSigned && ctx.GetSiteCookie(setting.CookieRememberName) != "" {
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
return
}
if options.AdminRequired {
if !ctx.Doer.IsAdmin {
ctx.HTTPError(http.StatusForbidden)
return
}
ctx.Data["PageIsAdmin"] = true
}
}
}
这段 60 行里有五个容易被忽略的行为细节:
- 它拦的不只是"未登录",还有"登录状态不完整":待激活账号一律渲染激活页;被封禁账号渲染 prohibit_login 页;需改密用户被强制导流——而且对
git开头的 User-Agent(git-over-HTTP 操作)返回 401 而不是网页跳转,否则 git 客户端收到 HTML 会直接报错。这个 UA 分支正是"同一鉴权函数同时服务浏览器与 git 客户端"的证据。 - 改密表单页自身也受它管辖:不需要改密的用户访问
/user/settings/change_password会被踢回首页(源码注释 "make sure that the form cannot be accessed by users who don't need this")——权限检查函数同时承担了页面可见性控制。 crossOriginProtection是 Go 标准库net/http的NewCrossOriginProtection(不是自制实现):非 GET 请求若没有Sec-Fetch-Site: same-origin/正确 Origin 头就 403,防跨站表单提交(CSRF 的一道补充防线)。它被排除在SignOutRequired场景外——登录页本身要接受"外站跳转过来"的请求。- "记住我" cookie 的补跳:没登录但浏览器里躺着 RememberMe cookie 时,直接送去登录页走自动登录流程,而不是先渲染目标页再跳一次。
AdminRequired分支把ctx.Data["PageIsAdmin"]的赋值也放在这里——鉴权通过的副作用(往模板数据写标记)与检查写在同一处,模板据此显示管理页眉。
crossOriginProtection 每个变体各构造一次(闭包外 http.NewCrossOriginProtection()),但它是无状态的,代价可忽略。同一段检查函数通过 VerifyOptions 参数化出 reqSignIn/optSignIn 等多个变体,是"策略与路由解耦"的小范例。
我们的目标路由在 1281 行:
m.Get("/{username}/{reponame}", optSignIn, webAuth.AllowBasic, context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch), repo.SetEditorconfigIfExists, repo.Home)
m.Get 经 Router.Methods → wrapMiddlewareAndHandler(r.afterRouting, r.curMiddlewares, h):最终中间件顺序 = 全局 AfterRouting 链(gzip→session→Contexter→…→QoS)+ 路由级列表(optSignIn → AllowBasic → RepoAssignment → RepoRefByType → SetEditorconfigIfExists)+ 最终 handler(repo.Home),其中 PreMiddlewareProvider 类型的(AllowBasic、optSignIn 内部的某些部分)会被 wrapMiddlewareAppendPre 排到普通中间件之前——这解释了为什么"打标记"的中间件总能先于"读标记"的执行。
1.5.5 阶段⑥:handler 的反射参数注入
Gitea 的 handler 直接写成 func Home(ctx *context.Context) 而不是 http.HandlerFunc,靠 modules/web/handler.go 的 toHandlerProvider 在注册期把任意签名适配成 http.Handler:
prepareHandleArgsIn根据函数参数类型,从 request/response 里取值注入:http.ResponseWriter、*http.Request、注册过的ResponseStatusProvider(RegisterResponseStatusProvider[T]用于声明"本 handler 会写 404"之类状态供中间件判断);hasResponseBeenWritten决定中间件链在 handler 写出响应后是否还调用next——短路语义就在这里实现;- 注册期 pre-check:
provider(nil).ServeHTTP(nil, nil)用 nil 跑一遍,签名不合法在启动时 panic 而不是第一个用户请求时才炸; executeMiddlewaresHandler用slices.Backward反向组合中间件,得到标准的 onion 模型。
对我们的请求,注入产物就是 ctx *context.Context(由 Contexter 中间件预先放进 request context),随后调用 repo.Home(ctx)。
1.5.6 阶段⑦:RepoAssignment 与 RepoRefByType——仓库上下文的十步装配
context.RepoAssignment(services/context/repo.go:804)是所有 /{username}/{reponame} 路由的公共前置,忠实摘录其函数体:
// RepoAssignment returns a middleware to handle repository assignment
func RepoAssignment(ctx *Context) {
repoAssignmentPreCheck(ctx)
prepareData := repoAssignmentPrepareData(ctx)
funcs := []func(ctx *Context, data *repoAssignmentPrepareDataStruct){
repoAssignmentPrepareOwner,
repoAssignmentAutoRedirectWiki,
repoAssignmentPrepareRepo,
repoAssignmentLegacy,
repoAssignmentPrepareTemplateData,
repoAssignmentAutoRedirectNotReady,
repoAssignmentPrepareGitRepo,
repoAssignmentPrepareRepoTransfer,
repoAssignmentPrepareBranches,
repoAssignmentPreparePullRequests,
}
for _, f := range funcs {
f(ctx, prepareData)
if ctx.Written() {
return
}
}
}
结构本身值得学习:十步共享一个 repoAssignmentPrepareDataStruct(ownerName、repoName、repo 三字段,repo.go:495),把上一步的产物传给下一步,避免每步重复查库;每步执行后检查 ctx.Written(),一旦某步已经写出响应(重定向/404/403)就立即停止——没有 error 返回值,"出错"的表达方式就是"已经写了响应",与 1.5.7 的 Home 是同一套协议。逐步展开前四步(也是最能体现边界处理的四步):
第 0 步 repoAssignmentPreCheck(repo.go:501)——防重入护栏:
func repoAssignmentPreCheck(ctx *Context) {
if ctx.Data["Repository"] != nil {
setting.PanicInDevOrTesting("RepoAssignment should not be executed twice")
}
if ctx.Repo.GitRepo != nil {
setting.PanicInDevOrTesting("RepoAssignment: GitRepo should be nil")
_ = ctx.Repo.GitRepo.Close()
ctx.Repo.GitRepo = nil
}
}
RepoAssignment 被误挂两次(如某些子路由组忘了继承父组中间件)会泄漏已打开的 git 仓库句柄,这个检查在 dev/test 模式直接 panic 把问题暴露在开发期——PanicInDevOrTesting 只在非生产生效,生产环境则尽力关闭旧句柄继续跑,同一件事在不同环境有不同严格度。
第 0.5 步 repoAssignmentPrepareData(repo.go:512)——URL 后缀清洗:
userName := ctx.PathParam("username")
repoName := ctx.PathParam("reponame")
repoName = strings.TrimSuffix(repoName, ".git")
if setting.Other.EnableFeed {
ctx.Data["EnableFeed"] = true
repoName = strings.TrimSuffix(repoName, ".rss")
repoName = strings.TrimSuffix(repoName, ".atom")
}
三个后缀各对应一种真实客户端行为:git clone 默认请求 owner/repo.git(即便浏览器不这么访问),RSS/Atom 订阅链接带 .rss/.atom。注释还留了 HINT:"here it doesn't handle '.wiki' extension, it is handled in repoAssignmentAutoRedirectWiki, need to be refactored in the future"——同类逻辑拆在两处并注明待重构,这是读遗留代码时要留心的信号。
第 1 步 repoAssignmentPrepareOwner(repo.go:525)——四层回退链:
// Check if the user is the same as the repository owner
if ctx.IsSigned && strings.EqualFold(ctx.Doer.LowerName, userName) {
ctx.Repo.Owner = ctx.Doer
} else {
ctx.Repo.Owner, err = user_model.GetUserByName(ctx, userName)
if err != nil {
if user_model.IsErrUserNotExist(err) {
// go-get does not support redirects
// https://github.com/golang/go/issues/19760
if ctx.FormString("go-get") == "1" {
EarlyResponseForGoGetMeta(ctx)
return
}
if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil {
RedirectToUser(ctx.Base, ctx.Doer, userName, redirectUserID)
} else if user_model.IsErrUserRedirectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("LookupUserRedirect", err)
}
} else {
ctx.ServerError("GetUserByName", err)
}
return
}
}
这段浓缩了四个设计决定:① 已登录且访问自己主页时直接复用 session 里的 ctx.Doer,省一次查库(用 EqualFold 比较 LowerName,大小写不敏感);② 用户不存在时先查 ?go-get=1——go get 命令遇到重定向会解析失败,所以必须就地返回 meta 而非 302(注释附了 Go 官方 issue 链接,说明这是被上游工具行为逼出来的特判);③ 再查用户改名重定向表(LookupUserRedirect,改名后旧 URL 仍可达);④ 三种错误出口严格区分:IsErrUserRedirectNotExist → 404,其它库错误 → 500。"查不到"与"查询失败"在错误语义上是两回事,这个区分贯穿全书所有 handler。
第 3 步 repoAssignmentPrepareRepo(repo.go:582)——与 owner 完全对称的回退: 仓库不存在 → 查仓库改名/转让重定向表(repo_model.LookupRedirect)→ 命中则 RedirectToRepo 301,查不到再看 go-get=1,最后 ctx.NotFound(nil)。owner 与 repo 两步合起来保证:任何历史 URL(改名前的用户、转让前的仓库)都能被自动导向新地址。
其后的六步一句话概括,第 5/7/8 章会展开:repoAssignmentLegacy 处理 go-get meta 与旧 URL(并把权限写入 cache.Put 缓存,见下);repoAssignmentPrepareTemplateData 填 ctx.Data["RepoName"] 等模板变量;repoAssignmentAutoRedirectNotReady 把克隆中/迁移中的仓库导向进度页;repoAssignmentPrepareGitRepo 调 git.OpenRepositoryCtx 打开 git 对象(这是本请求第一次真正碰磁盘上的 git 数据,失败即 ServerError);repoAssignmentPrepareRepoTransfer 展示转让中提示;repoAssignmentPrepareBranches/PreparePullRequests 查分支列表与关联 PR——注意这两步用的是 models/git、models/issues 的查询,全部发生在同一个 ctx 上,若外层开了事务则共享同一连接(1.6.3)。
repoAssignmentLegacy 里有个性能细节:它调用 GetDoerRepoPermission 拿权限后写入缓存(cache.Put),后续同请求内的权限判断直接命中缓存,避免一页内重复计算 ACL——第 5 章权限体系会展开这张权限表的构造。
随后 context.RepoRefByType(git.RefTypeBranch) 解析 URL 中的 ref(此 URL 无 ref 段,故解析默认分支):查 git_model 分支表拿 CommitID → 打开 commit → 若发现同名分支被重命名则 301 → 走 legacy 路径时也做对应 redirect。到这里 ctx.Repo.Commit、ctx.Repo.RefFullName、ctx.Repo.TreePath 全部就位。
repo.SetEditorconfigIfExists(routers/web/repo/middlewares.go:20)做了一次"尽力而为"的取数:仓库非空时读 .editorconfig 塞进 ctx.Data["Editorconfig"];文件不存在或网络错误直接忽略,注释明说理由:"it used to check !git.IsErrNotExist(err) and create a system notice, but it is quite annoying and useless because network errors also happen frequently"——一次首页渲染不值得为读不到编辑器配置而报警。
1.5.7 阶段⑧:repo.Home——业务 handler 的短路风格
routers/web/repo/view_home.go:410:
// Home render repository home page
func Home(ctx *context.Context) {
if handleRepoHomeFeed(ctx) {
return
}
if redirectSrcToRaw(ctx) {
return
}
// a scoped or public-only API token authenticating this web request must still satisfy
// the repository read scope before private repo content is served
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
if ctx.Written() {
return
}
// Check whether the repo is viewable: not in migration, and the code unit should be enabled
// Ideally the "feed" logic should be after this, but old code did so, so keep it as-is.
checkHomeCodeViewable(ctx)
if ctx.Written() {
return
}
title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name
…
prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL())
if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() {
// empty or broken repositories need to be handled differently
handleRepoEmptyOrBroken(ctx)
return
}
…
prepareFuncs := []func(*context.Context){
prepareClonePanel,
prepareHomeSidebarRepoTopics,
prepareToRenderDirOrFile(entry),
prepareRecentlyPushedNewBranches,
}
if isTreePathRoot {
prepareFuncs = append(prepareFuncs,
checkOutdatedBranch,
prepareUpstreamDivergingInfo,
prepareHomeSidebarLicenses,
prepareHomeSidebarCitationFile(entry),
prepareHomeSidebarLanguageStats,
prepareHomeSidebarLatestRelease,
)
}
for _, prepare := range prepareFuncs {
prepare(ctx)
if ctx.Written() {
return
}
}
if isViewHomeOnlyContent(ctx) {
ctx.HTML(http.StatusOK, tplRepoViewContent)
} else if ctx.Repo.TreePath != "" {
ctx.HTML(http.StatusOK, tplRepoView)
} else {
ctx.HTML(http.StatusOK, tplRepoHome)
}
}
这个函数把 Gitea handler 的三种惯用模式集中展示了:
- "先短路后干活":feed 视图、
/src重定向、scoped token 校验、可读性检查依次执行,每步之后ctx.Written()(base.go中Resp.WrittenStatus() != 0)为真就返回。Written()是全书出现频率最高的控制流原语——中间件和 handler 之间没有返回值协议,只有"谁写下了响应"这一个信号。 - prepare 数组 + 逐个执行:把"侧栏要显示什么"拆成独立小函数,根目录时追加许可证、CITATION.cff、语言统计、最新 release 等仅根路径才需要的项。
prepareHomeSidebarCitationFile(entry)还是柯里化——先绑定 git entry 再返回func(ctx)。 - 模板三选一:
tplRepoViewContent(仅内容片段,供前端局部刷新)、tplRepoView(带路径的文件页)、tplRepoHome(仓库首页)——同一 handler 按 URL 形态选模板,常量定义在view.go:52-55。
值得记下的边界处理:checkHomeCodeViewable(view.go:178)面对三种"非常态仓库"各有出口——迁移中(IsBeingCreated)渲染 tplMigrating 展示进度/失败原因;登录用户访问时顺手 SetRepoReadBy 消掉未读通知;code 单元不可读时按 unit 排序重定向到第一个可读单元(wiki/issues 等),一个都读不了则 ctx.NotFound。此外 Home 的注释诚实地记录了一处历史包袱:"Ideally the 'feed' logic should be after this, but old code did so, so keep it as-is."——顺序不合理但改动风险大于收益,就留注释说明,这是成熟代码库的取舍。
1.5.8 阶段⑨:ctx.HTML 到字节写出
services/context/context_response.go:79 的 ctx.HTML 是渲染的唯一入口:
// HTML calls Context.HTML and renders the template to HTTP response
func (ctx *Context) HTML(status int, name templates.TplName) {
log.Debug("Template: %s", name)
tmplStartTime := time.Now()
if !setting.IsProd {
ctx.Data["TemplateName"] = name
}
ctx.Data["TemplateLoadTimes"] = func() string {
return strconv.FormatInt(time.Since(tmplStartTime).Nanoseconds()/1e6, 10) + "ms"
}
err := ctx.Render.HTML(ctx.Resp, status, name, ctx.Data, ctx.TemplateContext)
if err == nil || httplib.IsClientOrNetworkError(ctx, err) {
return
}
// if rendering fails, show error page
if name != tplStatus500 {
err = fmt.Errorf("failed to render template: %s, error: %s", name, templates.HandleTemplateRenderingError(err))
ctx.ServerError("Render failed", err) // show the 500 error page
} else {
ctx.PlainText(http.StatusInternalServerError, "Unable to render status/500 page, the template system is broken, or Gitea can't find your template files.")
return
}
}
三处设计意图:TemplateLoadTimes 是一个惰性闭包塞进 ctx.Data,只有模板真的调用它才计时,页面底部的"rendered in Xms"就来自这里;开发模式额外注入 TemplateName 便于调试;错误处理区分三种情况——客户端断连(IsClientOrNetworkError,浏览器中途取消)直接静默返回,普通模板失败渲染 status/500,连 500 模板本身都失败时退化为纯文本(注释原话:"the template system is broken"),保证任何情况下客户端都能拿到明确状态码而不是挂死连接。
ctx.Render 即 modules/templates 的 PageRenderer(1.1.2 已见 sync.OnceValue),其 pageRenderer.HTML(page.go):
w.Header().Set("Content-Type", "text/html; charset=utf-8")→w.WriteHeader(status);tmplLookup取模板(static 模式查嵌入资源,auto-reloading 模式查最新编译的模板集);tmpl.Execute(w, data)直接把字节推进ctx.Resp——也就是 1.5.3 里WrapResponseWriter包过的响应对象,beforeFuncs(flash cookie 写回等)在第一次Write时触发;- 失败则调
ctx.ServerError:context_response.go中errors.Is(logErr, util.ErrNotExist)判为 404(模板文件不存在),否则 500。
模板执行时,funcMap["ctx"] 让模板里的 {{ctx.Locale.Tr "repo.archive.title"}}(见 templates/repo/home.tmpl)拿到当前请求的 *context.Context,从而取 locale、主题、CSP nonce;{{template "base/head" .}} 复用公共头部。渲染出的 HTML 经(若启用的)gzip 中间件压缩后写出。
1.5.9 收尾与可观测性
响应写完后,RequestContextHandler 的 deferred 闭包依次执行:span.End()(tracing 收尾,带最终 RoutePattern)、finished()(reqctx 清理请求数据并从 process manager 注销)、panic 恢复若未触发则无操作。gtprof span 数据可由 setting.EnablePprof/admin 的 perftrace 页面查看,routing.GetFuncInfo 提供的"handler → 源文件"映射让慢请求能定位到具体 .go 文件。
把 1.5 全程压成一行调用链(省略中间展开):
net/http Conn → Router.ServeHTTP → normalizeRequestPath → chi 路由匹配
→ [BeforeRouting] ChiRoutePathHandler → RequestContextHandler → SecurityHeaders → …
→ [AfterRouting] gzip → Sessioner → Contexter → AuditOrigin → webAuth → goGet → PageGlobalData → BlockExpensive → QoS
→ [route] optSignIn → AllowBasic → RepoAssignment(10 步) → RepoRefByType → SetEditorconfigIfExists
→ toHandlerProvider 反射注入 → repo.Home(短路序列) → ctx.HTML → pageRenderer.HTML
→ Execute 写出 → deferred: span.End / reqctx 清理
每一层的职责边界:① 路径层只管"请求要匹配哪条规则";② 协议层只管"这是不是一个合法、可追踪、不 panic 的请求";③ Web 层只管"你是谁、系统扛不扛得住";④ 路由级中间件只管"这个仓库上下文就位了吗";⑤ handler 只管"业务数据与选模板";⑥ 渲染层只管"把 ctx.Data 塞进模板"。越往下语义越具体,任何一层想干下一层的事都会在代码里留下痕迹——第 3 章会把这条链按中间件逐个放大。
1.6 核心横切机制总览(后续章节的路标)
配置、日志、数据库、队列、缓存、通知这六件事贯穿所有层,任何一次请求、任何一个子命令都会碰到它们。本节只做"入口定位":每项给出入口函数、关键文件和它在启动序列中的位置,细节留给对应章节。
1.6.1 配置体系:modules/setting(第 2 章)
modules/setting/ 有 93 个文件,是一个按配置节(section)拆分的大包:server.go、security.go、database.go、queue.go、mailer.go、repository.go、actions.go、session.go、indexer.go、log.go、cron.go、lfs.go、packages.go……基本一一对应 app.ini 的节。
加载分两段,且顺序被显式固定(modules/setting/setting.go:119 起,源码注释原话 "WARNING: don't change the sequence except you know what you are doing."):
// a lot of logic depends on InstallLock value, so it must be loaded before any other settings
InstallLock = HasInstallLock(cfg)
// WARNING: don't change the sequence except you know what you are doing.
loadRunModeFrom(cfg)
loadLogGlobalFrom(cfg)
loadAuditFrom(cfg)
loadServerFrom(cfg)
loadSSHFrom(cfg)
mustCurrentRunUserMatch(cfg) // it depends on the SSH config, only non-builtin SSH server requires this check
loadOAuth2From(cfg)
loadSecurityFrom(cfg)
…
loadGitFrom(cfg)
loadMirrorFrom(cfg)
loadMarkupFrom(cfg)
loadRedisFrom(cfg)
loadGlobalLockFrom(cfg)
loadOtherFrom(cfg)
return nil
这段是 loadCommonSettingsFrom,由 setting.InitWorkPathAndCommonConfig(modules/setting/path.go:96,即 1.4.2 里每个 subCmdWithConfig 的 Before 钩子)触发,在任何子命令执行前完成:InstallLock 最先(大量逻辑依赖它)、loadRunModeFrom 决定 setting.IsProd(只有显式 RUN_MODE=dev 才是 dev,"non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo"——拼错值时向安全侧倒)、mustCurrentRunUserMatch 校验运行用户与 RUN_USER 一致、mustNotRunAsRoot 默认拒绝 root 运行(要绕过得在配置或环境变量里明确写 I_AM_BEING_UNSAFE_RUNNING_AS_ROOT,注释还致谢了 VLC 的措辞灵感)。
第二段是 LoadSettings(setting.go:221),由 InitWebInstalled 在 git.InitFull 之后调用,加载只有正式服务才需要的节:
// LoadSettings initializes the settings for normal start up
func LoadSettings() {
initAllLoggers()
loadDBSetting(CfgProvider)
loadServiceFrom(CfgProvider)
loadOAuth2ClientFrom(CfgProvider)
loadCacheFrom(CfgProvider)
loadWebsocketFrom(CfgProvider)
loadSessionFrom(CfgProvider)
loadCorsFrom(CfgProvider)
loadMailsFrom(CfgProvider)
loadProxyFrom(CfgProvider)
loadWebhookFrom(CfgProvider)
loadMigrationsFrom(CfgProvider)
loadIndexerFrom(CfgProvider)
loadTaskFrom(CfgProvider)
LoadQueueSettings()
loadProjectFrom(CfgProvider)
loadMimeTypeMapFrom(CfgProvider)
loadFederationFrom(CfgProvider)
}
注意 initAllLoggers 排第一——日志必须在任何可能报错的加载之前就绪。另有 LoadSettingsForInstall(只加载 DB/Service/Mail 三节),供未安装状态的安装页使用。
两个后续章节反复引用的 API:全局结构化配置走 setting.Config()(modules/setting/config.go:88,返回 *ConfigStruct,带类型安全的 getter,cfgSecKeyGetter.GetValue(sec, key) 是底层实现);配置值可声明为动态解析(如 setting.Config().Repository.OpenWithEditorApps.Value(ctx),1.5.7 的 prepareClonePanel 用过),即读取时才求值、可随运行时状态变化。第 2 章会把 config_provider.go(配置文件解析)、config_env.go(环境变量覆盖)、config_option_instance.go(动态选项)三件套讲透。
1.6.2 日志:modules/log(第 2 章末)
入口是 LoggerManager(modules/log/manager.go):NewManager()(:138)创建、GetManager()(:134)全局取、Close()(:75)flush 全部 logger——main.go 的两条退出路径都调它(1.4.1)。日志不是 log.Printf 直写文件,而是"多个 logger 并行消费"的扇出结构:console、file、conn(网络)、SMTP 等由 initAllLoggers(LoadSettings 第一步)按 [log] 配置注册,每条日志广播给所有 logger。PrepareConsoleLoggerLevel(log.FATAL)(cmd/serv.go 的 Before)这类函数用于在配置加载完成前先给控制台 logger 定一个临时级别——serv/hook 子进程只关心 fatal,避免正常日志污染 git 客户端的 stderr。第 2 章会展开 logger 生命周期与级别控制。
1.6.3 数据库:models/db(第 4 章)
初始化链:InitWebInstalled → common.InitDBEngine(routers/common/db.go)→ db.InitEngineWithMigration(models/db/engine_init.go)。InitDBEngine 包着 DBConnectRetries 次重试(容器编排里数据库常比应用后就绪),然后:
// InitEngineWithMigration 调用顺序(models/db/engine_init.go):
// Ping → collation 检查 → MSSQL snapshot → migrateFunc(AutoMigration 或 versioned_migration)
// → SyncAllTables → registeredInitFuncs
migrateWithSetting(routers/common/db.go)区分两种模式:AutoMigration=true 时让 xorm 自动同步表结构;否则比对 modelmigration.GetCurrentDBVersion 与 ExpectedDBVersion,不一致就 log.Fatal 提示执行 gitea migrate(1.2.8 的伏笔)。引擎构造 newXORMEngine 设置 GonicMapper(SSL/UID 这类缩写命名的 lint 白名单在 init() 里注册)、NewXORMLogger(把 xorm SQL 日志接进 modules/log)、连接池参数、MySQL ReadCommitted 隔离级别、慢查询 EngineHook。
真正的日常入口是 models/db/context.go 的泛型层:
| 函数 | 用途 | 关键机制 |
|---|---|---|
GetEngine(ctx) | 从 context 取 engine | dev/test 下跑 contextSafetyCheck 防裸用 |
WithTx(ctx, fn) / WithTx2 | 事务包装 | 复用父事务(若 ctx 已在事务中)否则新建 |
Get[T]/GetByID[T]/Exist[T]/Find[T]/Count[T]/Delete[T] | 泛型 CRUD | 事务经 contextKey{"engine"} 隐式传递 |
Insert/Exec | 插入/裸 SQL |
事务传递的设计是这层的精华:TxContext 把 *xorm.Session 包成 halfCommitter 放进 context,WithTx 检测到已有事务就直接复用——所以业务函数永远只需要 ctx context.Context 一个参数,事务嵌套自动降级为 savepoint 语义(或直接共享 session),不需要把 session 层层传参。第 4 章会用一整章讲透这套机制与 ListOptions(models/db/list.go,SetDefaultValues 用 setting.API.DefaultPagingNum/MaxResponseItems、DefaultMaxInSize = 50)。
1.6.4 队列与缓存:modules/queue + modules/cache(第 10 章)
缓存入口 cache.Init()(modules/cache/cache.go:23,InitWebInstalled 中 mustInit(cache.Init)):
func Init() error {
if defaultCache == nil {
c, err := NewStringCache(setting.CacheService.Cache)
…
for range 10 {
if err = c.Ping(); err == nil {
break
}
time.Sleep(time.Second)
}
…
defaultCache = c
}
return nil
}
最多 Ping 10 次、每次间隔 1 秒——同样是"依赖的外部服务可能没就绪"的容忍设计(与 DBConnectRetries 同理)。适配器由 blank import 注册(_ "gitea.com/go-chi/cache/memcache"),按 setting.CacheService.Cache(memory/redis/...)选择。SlowCacheThreshold = 30 * time.Millisecond 常量用于 doctor 检测缓存性能(注释引用了 issue #33190 的讨论)。
队列是一套自研 worker pool(modules/queue/),核心事实三条:
- 创建入口是泛型函数
CreateUniqueQueue[T](modules/queue/manager.go:98),典型调用如 webhook 投递队列:
// services/webhook/deliver.go:281
hookQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "webhook_sender", handler)
"Unique" 指按 key 去重(同一仓库的多条 webhook 消息合并),避免推送风暴时队列被相同目标塞爆。
- 持久后端可插拔:channel(纯内存)、LevelDB(磁盘文件)、Redis(远程),由
setting/queue.go的LoadQueueSettings(:100,LoadSettings中调用)读取各队列配置。测试文件base_channel_test.go、base_levelqueue_test.go、base_redis_test.go一一对应三种后端。 - 管理面:
queue.GetManager()(manager.go:52)维护map[int64]ManagedWorkerPoolQueue,FlushAll(ctx, timeout)(manager.go:81)一键冲刷——这正是gitea manager flush-queues子命令(1.4.2)背后调用的方法,也对应 admin 面板的队列页。
cron 也是同类基础设施,入口 cron.Init(original context.Context)(services/cron/cron.go:33),在 InitWebInstalled 的最后一行启动——所有依赖它任务要查的子系统都就位之后才开表。第 10 章把队列、缓存、cron 三者合并讲解。
1.6.5 通知:services/notify(第 12 章)
这是一条进程内多播总线,services/notify/notify.go 全部核心不到 30 行:
var notifiers []Notifier
// RegisterNotifier providers method to receive notify messages
func RegisterNotifier(notifier Notifier) {
go notifier.Run()
notifiers = append(notifiers, notifier)
}
注意 go notifier.Run():Run() 是 Notifier 接口的第一个方法(services/notify/notifier.go:20),语义由各实现决定——多数 notifier 内嵌 services/notify 包提供的 NullNotifier,其 Run() 是空函数(services/notify/null.go:25),goroutine 立即退出;需要后台循环的实现则覆写它,例如站内通知 notificationService.Run(services/uinotification/notify.go:68):
func (ns *notificationService) Run() {
go graceful.GetManager().RunWithCancel(ns.issueQueue) // TODO: using "go" here doesn't seem right, just leave it as old code
}
而 webhook notifier 的 Run 走的是嵌入的空实现,真正的工作方式是在各个事件方法里把 hook task 推入队列(services/webhook/notifier.go 中大量 // Add to hook queue ... 注释即为此)。也就是说:RegisterNotifier 保证注册不阻塞启动,各消费方自行决定同步处理还是入队异步处理。每个事件函数(NewIssue、CreateIssueComment、IssueChangeStatus…)就是一段 for 循环向所有已注册 notifier 多播:
// NewIssue notifies new issue to notifiers
func NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
for _, notifier := range notifiers {
notifier.NewIssue(ctx, issue, mentions)
}
}
全仓库 11 处注册(grep RegisterNotifier( 实测),每处一个消费方:
| 注册处 | 消费方职责 |
|---|---|
services/webhook/notifier.go:32 | 向外部 URL POST webhook |
services/feed/notifier.go:31 | 动态 feed / 时间线 |
services/uinotification/notify.go:36 | 站内通知铃铛 |
services/indexer/indexer.go:15 | 触发 issue/代码索引更新 |
services/mailer/mailer.go:34 | 发邮件 |
services/actions/init.go:69 | 触发 CI workflow |
services/mirror/notifier.go:16 | 镜像同步排队 |
services/automerge/automerge.go:35 | 满足条件时自动合并 PR |
services/audit/notifier.go:19 | 审计事件记录 |
services/websocket/notifier.go:16 | 推送实时更新到浏览器 |
services/repository/transfer_test.go:31 | (测试中注入) |
两个设计要点:注册即启动 goroutine(go notifier.Run()),notifier 自己管理内部缓冲(如 webhook 的队列投递、mailer 的发件池);事件函数不做任何错误聚合——发布方 fire-and-forget,单个 notifier 的慢或失败不影响其他 notifier,也不阻塞业务主流程(可靠性由各 notifier 内部的队列保证)。这个模式解释了为什么"关掉邮件不影响 webhook",也是第 12 章的主线。
1.6.6 启动时序总表:函数 → 文件 → 作用 → 失败行为
把 1.5.1 的 InitWebInstalled 与它前后的调用按真实执行顺序展开成一张完整清单(逐行核对自 routers/init.go:60-174 与 cmd/web.go),作为第 2/4/10 章的目录:
| 序 | 调用(按执行序) | 定义位置 | 作用 | 失败行为 |
|---|---|---|---|---|
| — | InitWorkPathAndCommonConfig | modules/setting/path.go:96(Before 钩子) | 解析 --work-path/--config/--custom-path,跑 loadCommonSettingsFrom(InstallLock、RunMode、SSH、Security…) | log.Fatal(setting.go 内各 Must*) |
| — | _ = templates.PageRenderer() | cmd/web.go:283(runWeb) | 编译全部模板(static/auto-reloading) | dev 模式只 Warn 继续跑,prod 模式 processStartupTemplateError 退出 |
| — | setting.MustInstalled() | cmd/web.go:169 | 确认 INSTALL_LOCK=true | log.Fatal(未安装则先前已转入安装页流程) |
| 1 | git.InitFull | routers/init.go:116 | 探测 git 版本与传输能力 | mustInit → log.Fatal("%s failed: %v", fi.Name(), err)(反射取函数名) |
| 2 | translation.InitLocales | init.go:123 | 加载 29 种语言包 | 无返回值;缺包在使用处回退 en-US |
| 3 | setting.LoadSettings | modules/setting/setting.go:221 | initAllLoggers + DB/缓存/会话/队列/索引等 18 个配置节 | 首行 initAllLoggers 内失败即 Fatal |
| 4 | storage.Init | init.go:126 | 附件/头像/归档的对象存储抽象 | mustInit → Fatal |
| 5 | mailer.NewContext、cache.Init、feed_service.Init、uinotification.Init、archiver.Init | init.go:128-132 | 邮件系统、默认缓存(Ping 重试 10 次,cache.go:29)、动态 feed、站内通知队列、归档构建器 | 除 mailer 外均 mustInit/mustInitCtx → Fatal |
| 6 | external.RegisterRenderers、markup.Init(...) | init.go:134-135 | 注册外部文档渲染器与 markup 格式 | 无返回值;渲染期才暴露的错误留给单次请求 |
| 7 | common.InitDBEngine | init.go:137 | → db.InitEngineWithMigration:建 xorm engine、版本校验/执行迁移、SyncAllTables | mustInitCtx → Fatal;迁移到一半失败是唯一会留下"半新半旧表结构"的环节(第 4 章展开) |
| 8 | system.Init、oauth2.Init、oauth2_provider.Init、release_service.Init | init.go:139-142 | 系统表、OAuth2 客户端与提供方、release 初始化 | mustInit(Ctx) → Fatal |
| 9 | models.Init、authmodel.Init、repo_service.Init、packages_spec.InitManager | init.go:144-147 | 模型级初始化(权限表、单位注册、仓库单位) | mustInit(Ctx) → Fatal |
| 10 | gitproxy_service.Run | init.go:150 | 出站 git 代理 goroutine(注释:must start before mirror/migration services spawn git) | mustInitCtx → Fatal |
| 11 | indexer_service.Init | init.go:151 | bleve/ES 索引启动 | mustInit → Fatal |
| 12 | mirror.InitSyncMirrors、webhook.Init、pull.Init、automerge.Init、task.Init、websocket.Init、mailer_incoming.Init | init.go:153-159 | 各业务队列消费者上线(镜像同步、webhook 投递、PR 合并、自动合并、任务、WebSocket、收信) | 除镜像外 mustInit(Ctx) → Fatal;InitSyncMirrors 无返回值,单个镜像失败只记日志 |
| 13 | syncAppConfForGit | init.go:78 | AppState 比对二进制路径/配置路径,变了就 SyncRepositoryHooks + RewriteAllPublicKeys | mustInitCtx → Fatal |
| 14 | ssh.Init、auth.Init、svg.Init | init.go:163-166 | 内建 SSH 服务、鉴权方法组、SVG 图标缓存 | ssh/svg 走 mustInit → Fatal;auth.Init 无返回值 |
| 15 | actions_service.Init、repo_service.InitLicenseClassifier | init.go:168-170 | Actions 运行时、许可证分类器 | mustInit(Ctx) → Fatal |
| 16 | cron.Init(ctx) | init.go:173(最后一行) | 全部定时任务开跑 | 无返回值;任务自身错误记日志不拉垮进程 |
| 17 | routers.NormalRoutes() | cmd/web.go:230 | 注册四组 Mount + 全部路由(1.5.2) | 注册期 pre-check 失败 panic(modules/web/handler.go 的 nil-调用校验) |
| 18 | audit.Record(..., SystemStartup) | cmd/web.go:234 | 写启动审计事件 | 忽略错误(审计失败不应阻止服务) |
| 19 | listen(webRoutes, true) | cmd/web.go:236 | 按 Protocol 分支开监听 | log.Error 后返回,主流程收尾 |
表中隐藏着一条可以反向检验的排序定理——"先配置、后基础设施、再业务队列、最后 cron 与路由":
- 序 3 的日志先于序 7 的数据库:DB 连不上时必须已经有像样的错误日志可写;
- 序 10 的
gitproxy.Run刻意排在序 12 的 mirror/webhook 之前(注释写明 "must start before mirror/migration services spawn git")——因为队列消费者一旦上线就可能立刻 spawn git 进程,代理没起就是直连; - 序 16 的
cron.Init垫底在所有 Init 之后:定时任务的第一轮 tick 可能发生在启动后数秒内,它要查的库、要进的队列必须全部就绪; - 路由(序 17-19)放在 cron 之后:宁可晚开监听,也不接受"端口已开但内部半初始化"的窗口。
失败行为也分三档:mustInit/mustInitCtx(routers/init.go:60-76)用 reflect.ValueOf(fn).Pointer() + runtime.FuncForPC 取出完整函数名再 log.Fatal("%s failed: %v", ...)——启动日志里能直接看到是哪个 Init 挂了,无需 stack trace;无返回值的注册类调用(auth.Init、cron.Init、InitSyncMirrors)内部自行记日志;PanicInDevOrTesting 类护栏只在开发/测试收紧。三种失败策略对应三类错误的可恢复性:基础设施错误不可恢复(退出),业务注册错误可降级(记录后继续),逻辑错误应在开发期暴露(panic)。
1.7 架构图:三张图看懂全局
1.7.1 分层结构图
graph TB
subgraph entry["入口层"]
MAIN["main.go<br/>(唯一 main 包)"]
CMD["cmd/<br/>urfave/cli/v3 子命令<br/>web/serv/hook/dump/doctor/…"]
end
subgraph presentation["表现层 routers/"]
WEB["routers/web/<br/>HTML 路由 (513 文件)"]
API["routers/api/v1 + api/packages + api/actions"]
PRIV["routers/private/<br/>/api/internal 子进程通道"]
INST["routers/install/"]
COMMON["routers/common/<br/>协议中间件/鉴权/DB初始化/QoS"]
end
subgraph business["业务层 services/ (681 文件)"]
CTX["services/context<br/>请求上下文类型"]
BIZ["pull/ issue/ repository/ auth/<br/>notify/ webhook/ cron/ actions/ …"]
end
subgraph persistence["持久层 models/ (467 文件) + modelmigration/ (392 文件)"]
DB["models/db<br/>xorm 泛型封装/事务"]
MODELS["models/repo user issues git perm …"]
MIG["modelmigration/<br/>按版本分组迁移"]
end
subgraph crosscut["横切基础库 modules/ (1298 文件)"]
MOD1["setting/ log/ git/ queue/ cache/ web/"]
MOD2["storage/ markup/ indexer/ reqctx/ graceful/ …"]
end
subgraph resources["资源与前端"]
TPL["templates/ (585 .tmpl)"]
OPT["options/ locale gitignore license…"]
PUB["public/ 静态产物"]
WSRC["web_src/ Vite+TS+Vue3"]
BLD["build/ 生成器 → bindata"]
end
MAIN --> CMD
CMD --> WEB
CMD --> INST
WEB --> BIZ
API --> BIZ
PRIV --> BIZ
WEB --> MODELS
API --> MODELS
BIZ --> MODELS
MODELS --> DB
MIG --> DB
BIZ --> CTX
WEB --> CTX
CMD --> COMMON
COMMON --> CTX
WEB --> TPL
BLD -.生成.-> TPL
BLD -.生成.-> OPT
BLD -.生成.-> PUB
WSRC -.构建.-> PUB
MODELS -.依赖.-> MOD1
BIZ -.依赖.-> MOD1
WEB -.依赖.-> MOD2
(图中实线为常规依赖方向,虚线为构建期生成或受限依赖;modules → services 的 2 个例外与 modules → models 的 104 处实测见 1.3.2。)
1.7.2 一次请求的链路图
sequenceDiagram
participant B as 浏览器
participant S as net/http Server
participant R as web.Router (chi)
participant P as BeforeRouting 中间件
participant W as AfterRouting 中间件链
participant M as 路由级中间件
participant H as repo.Home
participant DB as models/db
participant T as pageRenderer
B->>S: GET /owner/repo
S->>R: ServeHTTP → normalizeRequestPath
R->>P: ChiRoutePathHandler / RequestContext / SecurityHeaders
P->>R: 匹配 GET /{username}/{reponame}
R->>W: gzip → session → Contexter → webAuth → QoS
W->>M: optSignIn → AllowBasic → RepoAssignment → RepoRefByType
M->>DB: prepareOwner/prepareRepo/分支查询 (10 步)
DB-->>M: Repository / Permission / Commit
M->>H: 反射注入 *context.Context
H->>DB: prepareHomeSidebarRepoTopics 等 (db.Find)
H->>T: ctx.HTML(200, tplRepoHome)
T->>B: 模板 Execute → HTML (+gzip)
Note over R,W: panic → renderPanicErrorPage<br/>span.End / reqctx 清理 (deferred)
1.7.3 模块依赖图(含实测例外)
graph LR
CMD["cmd/"] --> RT["routers/"]
RT --> SV["services/"]
RT --> MD["models/"]
SV --> MD
MD --> MO["modules/"]
SV --> MO
RT --> MO
RT -. 3 文件例外 .-> RT2["routers/api/v1/utils<br/>(RefCommit)"]
SV -.-> RT2
MO -. 2 文件例外 .-> SV2["services/gitdiff<br/>services/webtheme"]
SV2 -. 不反向 .-> MO
MO -. 104 处/52 文件 .-> MD
style RT2 fill:#ffe0e0
style SV2 fill:#ffe0e0
三条例外的完整清单与影响已在 1.3.2 给出。看图时请记住一句话:箭头永远朝"下"(更稳定的方向),例外都以"借用一个类型"的形式出现,且都没有形成环——这正是这套分层到今天仍然可用的原因。
1.8 全书导航:15 章路线图
本章给的是地图,后面 14 章逐个区域深挖。全书章节与本章的对应关系:
| 章 | 主题 | 从本章哪个问题出发 | 核心源码坐标 |
|---|---|---|---|
| 1 | 总体架构与代码全景(本章) | 目录、分层、请求生命周期、横切机制 | routers/init.go cmd/main.go |
| 2 | 启动流程与配置体系 | 1.6.1/1.6.2/1.6.6 的启动时序 | modules/setting/、cmd/web.go、routers/init.go |
| 3 | Web 路由与中间件 | 1.5 整章的逐层放大 | modules/web/、routers/web/web.go、routers/common/middleware.go |
| 4 | 数据库层与 ORM | 1.6.3 的泛型封装与事务传递 | models/db/、modelmigration/、xorm |
| 5 | 权限体系 | RepoAssignment 里的 Permission 从哪来 | models/perm/、services/auth/、routers/common/auth.go |
| 6 | Git 底层封装 | 1.1.2 的 git/go-git 双实现、1.4.4 的 serv 链 | modules/git/、modules/git/gitcmd/ |
| 7 | Issue 体系 | models/issues 与 notify 事件流 | models/issues/、services/issue/、routers/web/repo/issue_*.go |
| 8 | Pull Request | preparePullRequests 与 pull_service.Init | models/issues/pull.go、services/pull/、routers/web/repo/pull*.go |
| 9 | REST API | /api/v1 Mount 与 toHandlerProvider | routers/api/v1/api.go、services/、swagger |
| 10 | 异步基础设施(队列/缓存/Cron) | 1.6.4 的 CreateUniqueQueue/cache.Init/cron.Init | modules/queue/、modules/cache/、services/cron/ |
| 11 | 搜索与索引 | indexer_service.Init 与 modules/indexer 的下沉包 | modules/indexer/、services/indexer/ |
| 12 | 通知与 Webhook | 1.6.5 的 11 处 RegisterNotifier | services/notify/、services/webhook/、services/uinotification/ |
| 13 | 前端工程 | 1.1.2 的模板与 web_src 渐进增强 | web_src/、templates/、modules/templates/、vite.config.ts |
| 14 | Actions 与包注册 | NormalRoutes 的 /api/actions、/api/packages、/v2 | routers/api/actions/、routers/api/packages/、services/actions/ |
| 15 | 测试与部署运维 | 1.3.3 的"每层可独立测试"、make help 目标 | tests/、models/unittest/、Makefile、docs/testing.md |
阅读建议(也是作者验证过的顺序):
- 按依赖序读(2 → 4 → 3 → 5 → 6 → …):先配置与数据库,再回到路由层——此时
InitWebInstalled的每一行都能看懂; - 按请求序读(3 → 5 → 7/8 → 12):跟随一个浏览器请求,从中间件走到业务域,适合边读边在
routers/web/web.go里对照路由表; - 按子系统读(10 → 11 → 14 → 12):异步与集成类主题相对独立,可与 Web 层解耦阅读。
每章结尾都会保留本章这种"入口函数 + 文件坐标"的路标风格,方便随时跳回。本章未展开但已埋下线索的关键点——handler 反射分发的完整机制(1.5.5)、modules 下沉包的边界(1.3.2)、队列三种后端的取舍(1.6.4)、11 个 notifier 的内部实现(1.6.5)——都将在对应章节兑现。