









本文记录从 Go 后端生成 OpenAPI,到 React 前端生成 TypeScript API 的完整操作。
最终调用形式:
api.post.list();
api.post.get();
api.article.list();
api.articleType.list();
整个流程只有 6 步:
/openapi.yaml。operationId 处理成全局唯一的“动作-资源”,例如 list-post。mode: "tags",按 Tag 生成不同文件。list-post 转换为局部函数名 list。gen-index.mjs,组装成 api.post.list()。对应关系:
后端路由 /post
↓
OpenAPI Tag: post
OpenAPI operationId: list-post
↓
Orval 生成 generated/post.ts
↓
operationName 将 list-post 转成 list
↓
gen-index.mjs 将 post.ts 组装到 api.post
↓
api.post.list()
后端项目:
/Users/admin/files.localized/mcode/blog-server
文件:internal/bootstrap/app.go
后端使用 humago.New 将 Huma 注册到 Go 的 http.ServeMux:
package bootstrap
import (
"blog-server/internal/infrastructure"
"blog-server/internal/modules/article"
"blog-server/internal/modules/post"
sharedApi "blog-server/internal/shared/api"
"log"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type App struct {
Router *http.ServeMux
Config *infrastructure.Config
Database *gorm.DB
Server *http.Server
Redis *redis.Client
}
func (app *App) Run() {
addr := ":" + app.Config.HTTPPort
server := &http.Server{
Addr: addr,
Handler: sharedApi.Cors(app.Router),
}
log.Printf("HTTP 服务已启动: http://localhost%s", addr)
log.Printf("OpenAPI 文档: http://localhost%s/docs", addr)
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func NewApp() *App {
cfg := infrastructure.LoadConfig()
db, err := infrastructure.NewPostgres(cfg.Database)
if err != nil {
panic(err)
}
router := http.NewServeMux()
humaConfig := huma.DefaultConfig("My API", "1.0.0")
humaConfig.CreateHooks = nil
api := humago.New(router, humaConfig)
redisClient, _ := infrastructure.NewRedis(cfg.Redis)
article.RegisterModule(db, api)
post.RegisterModule(db, api)
return &App{
Router: router,
Config: cfg,
Database: db,
Redis: redisClient,
}
}
Huma 默认提供:
http://localhost:8080/docs
http://localhost:8080/openapi.yaml
http://localhost:8080/openapi.json
前端 Orval 使用的是:
http://localhost:8080/openapi.yaml
文件:internal/modules/post/api/router.go
完整推荐写法:
package api
import (
"github.com/danielgtaylor/huma/v2"
)
func RegisterRoutes(handler *PostHandler, api huma.API) {
postGroup := huma.NewGroup(api, "/post")
postGroup.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-post"
op.Tags = []string{"post"}
op.Description = "说说"
})
huma.Get(postGroup, "", handler.List, func(op *huma.Operation) {
op.OperationID = "list"
op.Summary = "列表"
})
huma.Get(postGroup, "/{id}", handler.Get, func(op *huma.Operation) {
op.OperationID = "get"
op.Summary = "获取单条"
})
huma.Post(postGroup, "", handler.Create, func(op *huma.Operation) {
op.OperationID = "create"
op.Summary = "新增"
})
huma.Put(postGroup, "/{id}", handler.Update, func(op *huma.Operation) {
op.OperationID = "update"
op.Summary = "更新"
})
huma.Delete(postGroup, "/{id}", handler.Delete, func(op *huma.Operation) {
op.OperationID = "remove"
op.Summary = "删除"
})
}
生成的 OpenAPI 关键信息:
paths:
/post:
get:
tags:
- post
operationId: list-post
post:
tags:
- post
operationId: create-post
/post/{id}:
get:
tags:
- post
operationId: get-post
put:
tags:
- post
operationId: update-post
delete:
tags:
- post
operationId: remove-post
文件:internal/modules/article/api/router.go
完整推荐写法:
package api
import (
"github.com/danielgtaylor/huma/v2"
)
func RegisterRoutes(
articleHandler *ArticleHandler,
articleTypeHandler *ArticleTypeHandler,
api huma.API,
) {
articleGroup := huma.NewGroup(api, "/article")
articleGroup.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-article"
op.Tags = []string{"article"}
})
huma.Get(articleGroup, "", articleHandler.List, func(op *huma.Operation) {
op.OperationID = "list"
op.Summary = "列表"
})
articleTypeGroup := huma.NewGroup(api, "/article-type")
articleTypeGroup.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-article-type"
op.Tags = []string{"article-type"}
})
huma.Get(
articleTypeGroup,
"",
articleTypeHandler.List,
func(op *huma.Operation) {
op.OperationID = "list"
op.Summary = "列表"
},
)
}
生成的 OpenAPI 关键信息:
paths:
/article:
get:
tags:
- article
operationId: list-article
/article-type:
get:
tags:
- article-type
operationId: list-article-type
启动后端:
cd /Users/admin/files.localized/mcode/blog-server
go run .
浏览器打开:
http://localhost:8080/openapi.yaml
确认至少存在:
list-post
get-post
create-post
update-post
remove-post
list-article
list-article-type
前端项目:
/Users/admin/files.localized/mcode/new-blog
安装依赖:
pnpm add axios
pnpm add -D orval
package.json 增加:
{
"scripts": {
"gen:api": "orval"
},
"dependencies": {
"axios": "^1.19.0"
},
"devDependencies": {
"orval": "^8.23.0"
}
}
文件:src/api/custom-axios.ts
当前原文代码:
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
});
export const customAxios = async <T>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig,
): Promise<T> => {
const response = await axiosInstance<T>({
...config,
...options,
headers: {
...config.headers,
...options?.headers,
},
});
return response.data;
};
export type ErrorType<T> = AxiosError<T>;
文件:orval.config.ts
当前原文代码:
import { defineConfig } from 'orval';
export default defineConfig({
myApi: {
input: 'http://localhost:8080/openapi.yaml',
output: {
clean: true,
tsconfig: './tsconfig.app.json',
target: './src/api/generated/api.ts',
schemas: './src/api/generated/models',
client: 'axios-functions',
mode: 'tags',
override: {
mutator: {
path: './src/api/custom-axios.ts',
name: 'customAxios',
},
operationName: (operation, route, verb) => {
const id = operation.operationId || '';
if (id.includes('-')) {
return id.split('-')[0];
}
return id;
},
},
},
hooks: {
afterAllFilesWrite: {
command: 'node src/api/gen-index.mjs',
injectGeneratedDirsAndFiles: false,
},
},
},
});
文件:src/api/gen-index.mjs
当前原文代码:
import { readdir, writeFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
const generatedDir = 'src/api/generated';
const outputFile = 'src/api/index.ts';
const camelCase = (value) =>
value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
const entries = await readdir(generatedDir, { withFileTypes: true });
const tags = entries
.filter(
(entry) => entry.isFile() && extname(entry.name) === '.ts',
)
.map((entry) => basename(entry.name, '.ts'));
tags.sort();
const imports = tags
.map((tag) => {
const variable = camelCase(tag);
return `import * as ${variable} from './generated/${tag}';`;
})
.join('\n');
const properties = tags
.map((tag) => ` ${camelCase(tag)},`)
.join('\n');
const content = `// 此文件由脚本自动生成,请勿手动修改。
${imports}
export const api = {
${properties}
} as const;
export default api;
`;
await writeFile(outputFile, content, 'utf8');
先保证后端正在运行,然后执行:
cd /Users/admin/files.localized/mcode/new-blog
pnpm gen:api
生成目录大致如下:
src/api/
├── custom-axios.ts
├── gen-index.mjs
├── index.ts
└── generated/
├── article.ts
├── article-type.ts
├── post.ts
└── models/
文件:src/api/index.ts
自动生成内容:
// 此文件由脚本自动生成,请勿手动修改。
import * as article from './generated/article';
import * as articleType from './generated/article-type';
import * as post from './generated/post';
export const api = {
article,
articleType,
post,
} as const;
export default api;
import api from '@/api';
import { useMount } from 'ahooks';
function Blog() {
useMount(async () => {
const response = await api.post.list();
console.log(response);
});
return <div>Blog</div>;
}
export default Blog;
前面的内容是实际操作,下面单独解释各部分为什么这样写。
后端代码:
op.Tags = []string{"post"}
配合前端:
mode: 'tags'
Orval 会生成:
src/api/generated/post.ts
因此 Tag 负责的是 api.post 中的 post。
对于:
op.Tags = []string{"article-type"}
Orval 生成 article-type.ts,之后 gen-index.mjs 将文件名转换成 articleType。
下面的写法不符合 OpenAPI 规范:
// /post
op.OperationID = "list"
// /article
op.OperationID = "list"
即使路由属于不同的 huma.NewGroup,operationId 的唯一性范围仍然是整份 OpenAPI 文档。
因此后端 Modifier 会追加资源名:
op.OperationID = op.OperationID + "-post"
接口先设置:
op.OperationID = "list"
最终 OpenAPI 得到:
list-post
当前 Orval 配置取 operationId 中第一个 - 之前的内容:
return id.split('-')[0];
对应转换:
list-post -> list
get-post -> get
create-post -> create
list-article -> list
list-article-type -> list
如果后端使用 post-list,当前规则会得到 post(),不符合目标。因此本方案统一使用“动作-资源”。
OpenAPI 中必须使用全局唯一的:
list-post
list-article
Orval 生成时将它们分别变成:
// generated/post.ts
export const list = ...;
// generated/article.ts
export const list = ...;
两个 list 位于不同的 Tag 文件中,因此可以通过模块隔离:
api.post.list();
api.article.list();
同一个 Tag 内仍然不能出现两个相同的局部动作。例如同一个 post Tag 中不能同时出现两个都会被转换成 list 的 operationId。
Orval 只负责生成接口函数,请求最终统一交给:
customAxios
它负责:
baseURL。response.data。以后需要添加 Token、响应拦截器或统一错误提示,可以集中修改 axiosInstance,不需要修改生成文件。
Orval 的 mode: 'tags' 会生成多个文件,但不会自动生成项目需要的:
api.post.list()
gen-index.mjs 会:
src/api/generated 下所有 .ts 文件。article-type 转换为 articleType。import * as post 导入每个模块。api 对象。该脚本通过 Orval Hook 自动执行:
hooks: {
afterAllFilesWrite: {
command: 'node src/api/gen-index.mjs',
},
},
以后新增接口时按下面顺序操作。
list、get、create、update、remove。/openapi.yaml。示例:
huma.Post(postGroup, "", handler.Create, func(op *huma.Operation) {
op.OperationID = "create"
op.Summary = "新增"
})
最终必须生成:
create-post
pnpm gen:api。src/api/index.ts 是否包含新模块。以下目录由 Orval 管理:
src/api/generated
配置中存在:
clean: true
每次生成前 Orval 都可能清理旧文件,手动修改会丢失。
src/api/index.ts 也由 gen-index.mjs 自动覆盖,不应手动维护。
Orval 的输入是 HTTP 地址:
input: 'http://localhost:8080/openapi.yaml'
如果后端未启动,pnpm gen:api 无法获得接口文档。
本方案固定使用:
动作-资源
不要混用:
list-post
post-get
list1
list2
否则 Orval 的 operationName 无法稳定生成正确函数名。
下面两个 ID 虽然在 OpenAPI 中不同:
list-post
list-post-history
但当前 operationName 都会转换成:
list
如果它们属于同一个 post Tag,就会产生命名冲突。此时应使用不同动作:
list-post
listHistory-post
前端生成:
api.post.list();
api.post.listHistory();
operationId 全局唯一。operationId 转换成模块内函数名。customAxios 统一发送请求。gen-index.mjs 组装 api.<模块>.<方法>()。最终结果:
api.post.list();
api.post.get();
api.post.create();
api.article.list();
api.articleType.list();
前面的方案已经可以正常使用。下面是项目接口增多后可以继续做的优化,不是首次接入的必需步骤。
目前每个模块都要重复编写:
group := huma.NewGroup(api, "/post")
group.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-post"
op.Tags = []string{"post"}
})
可以在共享 API 包中增加公共方法。
例如新建:
internal/shared/api/group.go
完整代码:
package api
import "github.com/danielgtaylor/huma/v2"
func NewGroup(api huma.API, path, resource string) *huma.Group {
group := huma.NewGroup(api, path)
group.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-" + resource
op.Tags = []string{resource}
})
return group
}
业务模块改为:
package api
import (
sharedApi "blog-2026ddd-server/internal/shared/api"
"github.com/danielgtaylor/huma/v2"
)
func RegisterRoutes(handler *PostHandler, api huma.API) {
postGroup := sharedApi.NewGroup(api, "/post", "post")
huma.Get(postGroup, "", handler.List, func(op *huma.Operation) {
op.OperationID = "list"
op.Summary = "列表"
})
}
这样可以保证所有模块统一使用:
动作-资源
同时避免某个模块忘记设置 Tag 或忘记补全 operationId。
共享包本身已经叫 api,业务路由包也可能叫 api,因此导入时建议使用别名:
sharedApi "blog-2026ddd-server/internal/shared/api"
随着接口数量增加,容易出现:
delete
remove
detail
get
add
create
同类操作使用不同名字会让前端 API 不统一。建议约定基础动作:
list
get
create
update
remove
复杂业务动作使用明确的驼峰名称:
publish
archive
listHistory
batchRemove
changeStatus
生成结果示例:
listHistory-post
batchRemove-post
changeStatus-post
前端对应:
api.post.listHistory();
api.post.batchRemove();
api.post.changeStatus();
如果不希望每个接口都重复设置 OperationID 和 Summary,可以增加一个小函数:
package api
import "github.com/danielgtaylor/huma/v2"
func operation(id, summary string) func(*huma.Operation) {
return func(op *huma.Operation) {
op.OperationID = id
op.Summary = summary
}
}
路由注册可以缩短为:
huma.Get(postGroup, "", handler.List, operation("list", "列表"))
huma.Get(postGroup, "/{id}", handler.Get, operation("get", "获取单条"))
huma.Post(postGroup, "", handler.Create, operation("create", "新增"))
huma.Put(postGroup, "/{id}", handler.Update, operation("update", "更新"))
huma.Delete(postGroup, "/{id}", handler.Delete, operation("remove", "删除"))
这个封装只负责重复字段,不应把 Handler、HTTP Method 等业务信息隐藏进去。
当前代码:
operationName: (operation, route, verb) => {
const id = operation.operationId || '';
if (id.includes('-')) {
return id.split('-')[0];
}
return id;
},
可以增加明确的错误提示,避免后端忘记设置 operationId 时静默生成异常函数名:
operationName: (operation, route, verb) => {
const id = operation.operationId;
if (!id) {
throw new Error(`缺少 operationId: ${verb.toUpperCase()} ${route}`);
}
const [action, resource] = id.split('-');
if (!action || !resource) {
throw new Error(
`operationId 必须使用“动作-资源”格式,当前值: ${id}`,
);
}
return action;
},
不过资源名可能包含连字符,例如:
list-article-type
因此这里只检查前两个片段是否存在,不能要求 split('-') 后只能得到两个元素。
当前脚本会导入 generated 根目录下的所有 .ts 文件。为了避免以后出现辅助文件时被误加入 api 对象,可以显式排除非 Tag 文件。
import { readdir, writeFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
const generatedDir = 'src/api/generated';
const outputFile = 'src/api/index.ts';
const excludedFiles = new Set(['api.ts']);
const camelCase = (value) =>
value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
const entries = await readdir(generatedDir, { withFileTypes: true });
const tags = entries
.filter(
(entry) =>
entry.isFile() &&
extname(entry.name) === '.ts' &&
!excludedFiles.has(entry.name),
)
.map((entry) => basename(entry.name, '.ts'))
.sort();
const imports = tags
.map((tag) => {
const variable = camelCase(tag);
return `import * as ${variable} from './generated/${tag}';`;
})
.join('\n');
const properties = tags
.map((tag) => ` ${camelCase(tag)},`)
.join('\n');
const content = `// 此文件由脚本自动生成,请勿手动修改。
${imports}
export const api = {
${properties}
} as const;
export default api;
`;
await writeFile(outputFile, content, 'utf8');
如果 Orval 后续改变输出结构,只需要维护 excludedFiles 或改为读取一份明确的 Tag 列表。
当前 customAxios 已经统一了请求入口。后续可以在这里增加 Token 和统一错误处理。
示例:
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
// 在这里执行统一的登录失效处理。
}
return Promise.reject(error);
},
);
不要直接修改 Orval 生成的请求函数,因为重新生成后会被覆盖。
可以把生成命令扩展为:
{
"scripts": {
"gen:api": "orval && tsc -b"
}
}
这样每次生成后立即执行 TypeScript 检查,可以及时发现:
customAxios 类型不匹配。如果完整构建较慢,也可以单独增加:
{
"scripts": {
"gen:api": "orval",
"gen:api:check": "orval && tsc -b"
}
}
多人协作时,后端 OpenAPI 已更新但前端忘记重新生成是常见问题。
CI 可以执行:
pnpm gen:api
git diff --exit-code -- src/api/generated src/api/index.ts
如果生成后产生差异,说明仓库中的 API 文件不是最新版本,CI 应提示开发者重新生成并提交。
这一检查需要 CI 能访问对应的 OpenAPI 文档。更稳定的方式是由后端在 CI 中生成并保存 OpenAPI 文件,前端读取本地文件:
input: '../blog-2026ddd-server/openapi.yaml'
本地开发仍可继续使用 HTTP 地址,二者可以通过环境变量切换。
后端:
blog-2026ddd-server/
├── internal/
│ ├── shared/api/
│ │ ├── group.go
│ │ └── error.go
│ └── modules/
│ ├── article/api/router.go
│ └── post/api/router.go
└── docs/
└── openapi-orval-api-generation.md
前端:
new-blog/
├── orval.config.ts
└── src/api/
├── custom-axios.ts
├── gen-index.mjs
├── index.ts
└── generated/
├── article.ts
├── article-type.ts
├── post.ts
└── models/
建议按下面顺序实施:
operationId 为“动作-资源”。NewAPIGroup,避免新模块破坏规范。operationName 增加格式校验。gen-index.mjs 增加文件过滤。后端使用全局唯一的 operationId:
list-post
list-article
避免 OpenAPI 校验、接口文档和其他代码生成工具出现命名冲突。
前端经过 Tag 分组和名称转换后,仍然可以使用简洁的调用方式:
api.post.list();
api.article.list();
全局唯一性由后端负责,模块内的易用性由前端负责,两者互不冲突。
请求参数、响应结构和字段类型都来自后端 OpenAPI,不需要在前端重复手写 TypeScript 类型。
当后端修改 DTO 后,前端重新执行:
pnpm gen:api
即可同步最新类型。字段删除、类型变化或必填规则变化会在 TypeScript 检查阶段暴露,减少运行时才发现问题的情况。
前端不再为每个接口重复编写:
axios.get(...);
axios.post(...);
也不需要重复维护请求参数类型、响应类型和接口路径。Orval 根据 OpenAPI 自动生成这些内容,开发者只需要调用生成后的函数。
后端 Tag 会直接映射成前端模块:
post -> api.post
article -> api.article
article-type -> api.articleType
相比把所有接口函数放在一个文件中,按模块组织更容易查找,也能避免不同业务之间的函数名相互污染。
如果没有模块分层,前端可能需要使用:
listPost();
listArticle();
getPost();
本方案通过 Tag 提供模块命名空间,因此可以写成:
api.post.list();
api.article.list();
api.post.get();
调用代码更短,同时仍然能从模块名看出接口所属业务。
所有生成接口统一经过 customAxios。以后增加以下能力时,只需要修改一个文件:
生成代码只描述接口本身,请求基础设施由项目统一维护。
接口路径、HTTP Method、参数、响应和 Schema 都以后端 OpenAPI 为准。
这样可以避免同时维护以下多份容易不一致的定义:
后端修改接口后重新生成前端代码即可完成同步。
生成文件发生变化时,可以直接通过 Git Diff 查看:
配合 TypeScript 检查和 CI,可以让不兼容修改在合并代码前被发现。
新增业务模块时,后端只需要创建 Group、设置 Tag 并遵循 动作-资源 命名约定。前端重新生成后,会自动获得新的 Tag 文件和 API 模块。
例如后端新增:
Tag: comment
operationId: list-comment
前端生成后即可使用:
api.comment.list();
不需要手动修改 API 总入口。
生成的 API 是普通 TypeScript 函数,不依赖某个 React 页面或组件,也不强制使用特定状态管理方案。
同一接口可以用于:
因此接口层和 UI 层保持解耦,后续更换页面结构或数据请求方案时影响较小。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。