Gitea源码深度剖析-第1章 Gitea 总体架构与代码全景

2 阅读39分钟

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/599Go html/template 模板(585 个 .tmpl)
models/467ORM 模型与数据访问层
modelmigration/392按版本分组的数据库迁移
options/332嵌入的静态选项数据(license/gitignore/locale 等)
routers/513HTTP 层(Web/API/private/install 四套路由)
cmd/58CLI 子命令实现
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,单二进制 giteago.mod;Makefile:515 的 go build -ldflags '-s -w $(LDFLAGS)'
CLI 框架github.com/urfave/cli/v3main.go 与 cmd/*.go 全部使用 *cli.Command
HTTP 路由github.com/go-chi/chi/v5,由 modules/web 封装modules/web/router.go 的 type Router struct { chiRouter *chi.Mux … }
ORMxorm.io/xorm v1.4.3 + xorm.io/buildermodels/db/engine_init.go
Git 操作系统 git 命令(默认)或 go-git(可选 tag)modules/git/*_gogit.go 与 *_nogogit.go 双实现
模板Go 标准库 html/templatemodules/templates/page.go 的 pageRenderer.HTML
搜索Bleve(blevesearch/bleve/v2)go.mod;modules/indexer/code、issues
前端Vite + TypeScript + Vue 3(渐进式组件)+ Tailwind/Fomanticpackage.json、vite.config.ts、web_src/js/components/*.vue
会话gitea.com/go-chi/sessionrouters/common/middleware.go 的 MustInitSessioner
队列自研 worker pool,后端可选内存 LevelDB / Redis / channelmodules/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 的架构取舍差异

维度GiteaGitLab (CE/EE)GogsGitHub
语言与进程模型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/MSSQLActiveRecord,主推 PostgreSQLxorm自研存储
异步任务进程内队列(modules/queue)+ cronRedis + 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/        # 安装向导路由

三条硬规则:

  1. 路由表集中注册,handler 按域拆文件。routers/web/web.go 有 8 万字节,里面是完整的 URL → 中间件 → handler 映射;具体业务处理则散在 routers/web/repo/*.go 等文件。这样"全局可见的路由结构"永远只有一个查询入口。
  2. routers/ 可以同时引用 services/ 与 models/,它是唯一"两头都碰"的层——handler 需要组装模板数据时确实会直接查模型(如 db.Count[repo_model.Release](…) 出现在 services/context/repo.go,详见 1.3 节的边界讨论)。
  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 是横切基础库,谁都不许反向依赖"。把这句话拆成可检验的命题:

  1. models/ 不得 import services/、routers/(持久层不知道业务和表现);
  2. services/ 不得 import routers/(业务层不知道 HTTP);
  3. modules/ 不得 import services/、routers/(基础库不含业务);
  4. routers/ 可以 import services/ 与 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/00—(自身)
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/*、其他 servicesrouters/(实测 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()
}

四个值得记住的细节:

  1. Version 与 Tags 是 var 而非常量,因为它们由链接期注入:Makefile:109 的 LDFLAGS := ... -X "main.Version=$(GITEA_VERSION)" -X "main.Tags=$(TAGS)"。gitea -v 打印的版本号就来自这里,未注入时回退为 "development"。
  2. gitproxy.MaybeTunnel() 在解析任何参数之前执行——支持通过环境变量让所有出站 git 流量走代理隧道,必须早于 git 相关初始化。
  3. cli.OsExiter 被覆盖为"先刷日志再退出",与结尾的 log.GetManager().Close() 呼应:Gitea 的日志是异步写入的(modules/log 的 LoggerManager 带缓冲),任何退出路径都要先 flush,否则丢最后几行——这正是排障时"错误日志缺尾巴"的根因防护。
  4. 文件顶部还有一批 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
servcmd/serv.goSSH 侧 git 接入鉴权Hidden: true,由 authorized_keys 间接调用
hookcmd/hook.gopre-receive/update/post-receive/proc-receiveHidden: true,由仓库 git hook 脚本调用
keyscmd/keys.go打印 authorized_keys 模板供 SSHD 配置 Include 使用
dumpcmd/dump.go newDumpCommand打包数据库+文件为 zip调 db、storage、setting
admincmd/admin_*.go用户/OAuth2/-feed/两步验证等管理操作子子命令组
migratecmd/migrate.go手动执行数据库迁移对应 1.2.8 的版本校验失败提示
doctorcmd/doctor.golist/default/run 体检项检查磁盘、hooks、repo 状态
managercmd/manager.goshutdown/restart/reload-templates/flush-queues/processes经 /api/internal/manager/* 驱动运行中的主进程
embeddedcmd/embedded.golist/view/extract 二进制内嵌资源运维排查模板/静态资源用
migrate-storagecmd/migrate_storage.go存储后端间迁移(本地↔S3…)调 modules/storage
dump-repository / restore-repositorycmd/dump_repository.go 等单仓库备份恢复
actionscmd/actions.go生成 runner token 等 Actions 管理
config / cert / generate / docscmd/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 -->|&#34;HTTP POST /api/internal/serv/...&#34;| E[&#34;主进程 gitea web<br/>routers/private.Routes()&#34;]
    D -->|&#34;HTTP POST /api/internal/hook/pre-receive/...&#34;| E
    E --> F[&#34;models: 数据库连接池&#34;]
    B -->|&#34;exec git-upload-pack&#34;| G[&#34;系统 git 进程&#34;]
    D -->|&#34;读写仓库 refs&#34;| 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)
	…
}

逐项拆解这条通道的四个设计点:

  1. 认证是单 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 塞进常规日志。

  2. 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 门禁之后则是诊断利器,位置决定语义,这是"中间件挂在哪个链上"影响安全性的直接例子。

  3. bind[T] 泛型表单绑定。 bind(private.HookOptions{}) 返回 func(ctx *context.PrivateContext),用 middleware.BindFormAny 把子进程 POST 的表单解码进结构体,校验失败回 PrivateInternalErrorf——私有路由复用了 web 层的 binding/校验设施,但错误输出走 PrivateContext 而非渲染 HTML 模板。

  4. 路由本身就是一条业务流水线。 对比公网侧:/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.gorunWeb → serveInstalled → listen
② 顶层路由routers/init.goNormalRoutes
③ 路径规范化modules/web/router.goServeHTTP → normalizeRequestPath
④ 协议中间件routers/common/middleware.goProtocolMiddlewares
⑤ Web 中间件链routers/web/web.goRoutes / newWebAuthMiddleware / verifyAuthWithOptionsWeb
⑥ handler 参数注入modules/web/handler.gotoHandlerProvider → prepareHandleArgsIn
⑦ 路由级中间件services/context/repo.goRepoAssignment / RepoRefByType
⑧ 业务 handlerrouters/web/repo/view_home.goHome
⑨ 模板渲染services/context/context_response.go → modules/templates/page.goctx.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)在匹配前做四件事,每件都对应一类真实故障:

  1. 去尾斜杠、折叠 //——/owner/repo/ 与 /owner/repo 视为同一路由,避免重复内容对 SEO 与链接分享不友好;// 折叠走的是逐字符慢路径,但只在检测到 // 时才执行;
  2. 子路径剥离——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")——宁可直接拒绝,也不给用户"半可用"的错觉;
  3. /v2 白名单——子路径模式下 OCI 端点不参与裁剪,符合容器 registry 规范;
  4. 回填 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)。链上各成员:

  1. gzip:gzhttp 包装,MinSize 起压缩,RandomJitter(32,...) 防 BREACH 攻击(注释引用了 compress 库 README)。
  2. 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。
  3. 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(...)"的完整上下文。
  4. AuditOrigin(audit_model.OriginUI):给请求打"来源=UI"标记,写入审计事件(第 12 章展开)。
  5. 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,而普通页面默认不启用,避免凭据被随意用于非预期端点。
  6. goGet:响应 ?go-get=1,让 go get owner/repo 能解析 meta import。
  7. common.PageGlobalData(routers/common/pagetmpl.go):用 sync.OnceValue 惰性取"通知未读数"与 stopwatch,同一请求内所有页面模板共享一次查询。
  8. 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 的三种惯用模式集中展示了:

  1. "先短路后干活":feed 视图、/src 重定向、scoped token 校验、可读性检查依次执行,每步之后 ctx.Written()(base.go 中 Resp.WrittenStatus() != 0)为真就返回。Written() 是全书出现频率最高的控制流原语——中间件和 handler 之间没有返回值协议,只有"谁写下了响应"这一个信号。
  2. prepare 数组 + 逐个执行:把"侧栏要显示什么"拆成独立小函数,根目录时追加许可证、CITATION.cff、语言统计、最新 release 等仅根路径才需要的项。prepareHomeSidebarCitationFile(entry) 还是柯里化——先绑定 git entry 再返回 func(ctx)。
  3. 模板三选一: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):

  1. w.Header().Set("Content-Type", "text/html; charset=utf-8") → w.WriteHeader(status);
  2. tmplLookup 取模板(static 模式查嵌入资源,auto-reloading 模式查最新编译的模板集);
  3. tmpl.Execute(w, data) 直接把字节推进 ctx.Resp——也就是 1.5.3 里 WrapResponseWriter 包过的响应对象,beforeFuncs(flash cookie 写回等)在第一次 Write 时触发;
  4. 失败则调 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 取 enginedev/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/),核心事实三条:

  1. 创建入口是泛型函数 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 消息合并),避免推送风暴时队列被相同目标塞爆。

  1. 持久后端可插拔:channel(纯内存)、LevelDB(磁盘文件)、Redis(远程),由 setting/queue.go 的 LoadQueueSettings(:100,LoadSettings 中调用)读取各队列配置。测试文件 base_channel_test.go、base_levelqueue_test.go、base_redis_test.go 一一对应三种后端。
  2. 管理面: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 章的目录:

序调用(按执行序)定义位置作用失败行为
—InitWorkPathAndCommonConfigmodules/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=truelog.Fatal(未安装则先前已转入安装页流程)
1git.InitFullrouters/init.go:116探测 git 版本与传输能力mustInit → log.Fatal("%s failed: %v", fi.Name(), err)(反射取函数名)
2translation.InitLocalesinit.go:123加载 29 种语言包无返回值;缺包在使用处回退 en-US
3setting.LoadSettingsmodules/setting/setting.go:221initAllLoggers + DB/缓存/会话/队列/索引等 18 个配置节首行 initAllLoggers 内失败即 Fatal
4storage.Initinit.go:126附件/头像/归档的对象存储抽象mustInit → Fatal
5mailer.NewContext、cache.Init、feed_service.Init、uinotification.Init、archiver.Initinit.go:128-132邮件系统、默认缓存(Ping 重试 10 次,cache.go:29)、动态 feed、站内通知队列、归档构建器除 mailer 外均 mustInit/mustInitCtx → Fatal
6external.RegisterRenderers、markup.Init(...)init.go:134-135注册外部文档渲染器与 markup 格式无返回值;渲染期才暴露的错误留给单次请求
7common.InitDBEngineinit.go:137→ db.InitEngineWithMigration:建 xorm engine、版本校验/执行迁移、SyncAllTablesmustInitCtx → Fatal;迁移到一半失败是唯一会留下"半新半旧表结构"的环节(第 4 章展开)
8system.Init、oauth2.Init、oauth2_provider.Init、release_service.Initinit.go:139-142系统表、OAuth2 客户端与提供方、release 初始化mustInit(Ctx) → Fatal
9models.Init、authmodel.Init、repo_service.Init、packages_spec.InitManagerinit.go:144-147模型级初始化(权限表、单位注册、仓库单位)mustInit(Ctx) → Fatal
10gitproxy_service.Runinit.go:150出站 git 代理 goroutine(注释:must start before mirror/migration services spawn git)mustInitCtx → Fatal
11indexer_service.Initinit.go:151bleve/ES 索引启动mustInit → Fatal
12mirror.InitSyncMirrors、webhook.Init、pull.Init、automerge.Init、task.Init、websocket.Init、mailer_incoming.Initinit.go:153-159各业务队列消费者上线(镜像同步、webhook 投递、PR 合并、自动合并、任务、WebSocket、收信)除镜像外 mustInit(Ctx) → Fatal;InitSyncMirrors 无返回值,单个镜像失败只记日志
13syncAppConfForGitinit.go:78AppState 比对二进制路径/配置路径,变了就 SyncRepositoryHooks + RewriteAllPublicKeysmustInitCtx → Fatal
14ssh.Init、auth.Init、svg.Initinit.go:163-166内建 SSH 服务、鉴权方法组、SVG 图标缓存ssh/svg 走 mustInit → Fatal;auth.Init 无返回值
15actions_service.Init、repo_service.InitLicenseClassifierinit.go:168-170Actions 运行时、许可证分类器mustInit(Ctx) → Fatal
16cron.Init(ctx)init.go:173(最后一行)全部定时任务开跑无返回值;任务自身错误记日志不拉垮进程
17routers.NormalRoutes()cmd/web.go:230注册四组 Mount + 全部路由(1.5.2)注册期 pre-check 失败 panic(modules/web/handler.go 的 nil-调用校验)
18audit.Record(..., SystemStartup)cmd/web.go:234写启动审计事件忽略错误(审计失败不应阻止服务)
19listen(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[&#34;入口层&#34;]
        MAIN[&#34;main.go<br/>(唯一 main 包)&#34;]
        CMD[&#34;cmd/<br/>urfave/cli/v3 子命令<br/>web/serv/hook/dump/doctor/…&#34;]
    end

    subgraph presentation[&#34;表现层 routers/&#34;]
        WEB[&#34;routers/web/<br/>HTML 路由 (513 文件)&#34;]
        API[&#34;routers/api/v1 + api/packages + api/actions&#34;]
        PRIV[&#34;routers/private/<br/>/api/internal 子进程通道&#34;]
        INST[&#34;routers/install/&#34;]
        COMMON[&#34;routers/common/<br/>协议中间件/鉴权/DB初始化/QoS&#34;]
    end

    subgraph business[&#34;业务层 services/ (681 文件)&#34;]
        CTX[&#34;services/context<br/>请求上下文类型&#34;]
        BIZ[&#34;pull/ issue/ repository/ auth/<br/>notify/ webhook/ cron/ actions/ …&#34;]
    end

    subgraph persistence[&#34;持久层 models/ (467 文件) + modelmigration/ (392 文件)&#34;]
        DB[&#34;models/db<br/>xorm 泛型封装/事务&#34;]
        MODELS[&#34;models/repo user issues git perm …&#34;]
        MIG[&#34;modelmigration/<br/>按版本分组迁移&#34;]
    end

    subgraph crosscut[&#34;横切基础库 modules/ (1298 文件)&#34;]
        MOD1[&#34;setting/ log/ git/ queue/ cache/ web/&#34;]
        MOD2[&#34;storage/ markup/ indexer/ reqctx/ graceful/ …&#34;]
    end

    subgraph resources[&#34;资源与前端&#34;]
        TPL[&#34;templates/ (585 .tmpl)&#34;]
        OPT[&#34;options/ locale gitignore license…&#34;]
        PUB[&#34;public/ 静态产物&#34;]
        WSRC[&#34;web_src/ Vite+TS+Vue3&#34;]
        BLD[&#34;build/ 生成器 → bindata&#34;]
    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[&#34;cmd/&#34;] --> RT[&#34;routers/&#34;]
    RT --> SV[&#34;services/&#34;]
    RT --> MD[&#34;models/&#34;]
    SV --> MD
    MD --> MO[&#34;modules/&#34;]
    SV --> MO
    RT --> MO

    RT -. 3 文件例外 .-> RT2[&#34;routers/api/v1/utils<br/>(RefCommit)&#34;]
    SV -.-> RT2
    MO -. 2 文件例外 .-> SV2[&#34;services/gitdiff<br/>services/webtheme&#34;]
    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
3Web 路由与中间件1.5 整章的逐层放大modules/web/、routers/web/web.go、routers/common/middleware.go
4数据库层与 ORM1.6.3 的泛型封装与事务传递models/db/、modelmigration/、xorm
5权限体系RepoAssignment 里的 Permission 从哪来models/perm/、services/auth/、routers/common/auth.go
6Git 底层封装1.1.2 的 git/go-git 双实现、1.4.4 的 serv 链modules/git/、modules/git/gitcmd/
7Issue 体系models/issues 与 notify 事件流models/issues/、services/issue/、routers/web/repo/issue_*.go
8Pull RequestpreparePullRequests 与 pull_service.Initmodels/issues/pull.go、services/pull/、routers/web/repo/pull*.go
9REST API/api/v1 Mount 与 toHandlerProviderrouters/api/v1/api.go、services/、swagger
10异步基础设施(队列/缓存/Cron)1.6.4 的 CreateUniqueQueue/cache.Init/cron.Initmodules/queue/、modules/cache/、services/cron/
11搜索与索引indexer_service.Init 与 modules/indexer 的下沉包modules/indexer/、services/indexer/
12通知与 Webhook1.6.5 的 11 处 RegisterNotifierservices/notify/、services/webhook/、services/uinotification/
13前端工程1.1.2 的模板与 web_src 渐进增强web_src/、templates/、modules/templates/、vite.config.ts
14Actions 与包注册NormalRoutes 的 /api/actions、/api/packages、/v2routers/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)——都将在对应章节兑现。