schema-dsl 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -113
- package/LICENSE +21 -21
- package/README.md +628 -628
- package/dist/{DslBuilder-DkLaOo9Q.d.ts → DslBuilder-BIgQOAXp.d.ts} +2 -0
- package/dist/{DslBuilder-DQDN0ZxZ.d.cts → DslBuilder-CjHTucNQ.d.cts} +2 -0
- package/dist/{Validator-hFWKGxir.d.ts → Validator-CllRdrY0.d.ts} +1 -1
- package/dist/{Validator-C7GsVQOH.d.cts → Validator-D6okG9tr.d.cts} +1 -1
- package/dist/index.cjs +75 -29
- package/dist/index.d.cts +10 -4
- package/dist/index.d.ts +10 -4
- package/dist/index.js +75 -29
- package/dist/plugins/custom-format.cjs +33 -17
- package/dist/plugins/custom-format.d.cts +1 -1
- package/dist/plugins/custom-format.d.ts +1 -1
- package/dist/plugins/custom-format.js +33 -17
- package/dist/plugins/custom-type-example.cjs +33 -17
- package/dist/plugins/custom-type-example.d.cts +1 -1
- package/dist/plugins/custom-type-example.d.ts +1 -1
- package/dist/plugins/custom-type-example.js +33 -17
- package/dist/plugins/custom-validator.cjs +0 -2
- package/dist/plugins/custom-validator.d.cts +1 -1
- package/dist/plugins/custom-validator.d.ts +1 -1
- package/dist/plugins/custom-validator.js +0 -2
- package/docs/FEATURE-INDEX.md +553 -553
- package/docs/add-custom-locale.md +496 -496
- package/docs/add-keyword.md +24 -24
- package/docs/api-reference.md +1047 -1047
- package/docs/api.md +13 -13
- package/docs/best-practices-project-structure.md +417 -417
- package/docs/best-practices.md +712 -712
- package/docs/cache-manager.md +344 -344
- package/docs/compile.md +45 -45
- package/docs/conditional-api.md +1307 -1307
- package/docs/custom-extensions-guide.md +339 -339
- package/docs/design-philosophy.md +606 -606
- package/docs/doc-index.md +324 -324
- package/docs/dsl-syntax.md +714 -714
- package/docs/dynamic-locale.md +608 -608
- package/docs/enum.md +482 -482
- package/docs/error-handling.md +1975 -1975
- package/docs/export-guide.md +501 -501
- package/docs/export-limitations.md +567 -567
- package/docs/faq.md +596 -596
- package/docs/frontend-i18n-guide.md +307 -307
- package/docs/i18n-user-guide.md +487 -487
- package/docs/i18n.md +476 -476
- package/docs/index.md +48 -48
- package/docs/json-schema-basics.md +40 -40
- package/docs/label-vs-description.md +271 -271
- package/docs/markdown-exporter.md +406 -406
- package/docs/mongodb-exporter.md +302 -302
- package/docs/multi-language.md +26 -26
- package/docs/multi-type-support.md +322 -322
- package/docs/mysql-exporter.md +280 -280
- package/docs/number-operators.md +449 -449
- package/docs/optional-marker-guide.md +326 -326
- package/docs/performance-guide.md +49 -49
- package/docs/plugin-system.md +381 -381
- package/docs/plugin-type-registration.md +34 -34
- package/docs/postgresql-exporter.md +311 -311
- package/docs/public/favicon.svg +4 -4
- package/docs/quick-start.md +435 -435
- package/docs/runtime-locale-support.md +532 -532
- package/docs/schema-helper.md +345 -345
- package/docs/schema-utils-advanced-issues.md +23 -23
- package/docs/schema-utils-best-practices.md +20 -20
- package/docs/schema-utils-chaining.md +150 -150
- package/docs/schema-utils.md +524 -524
- package/docs/security-checklist.md +20 -20
- package/docs/string-extensions.md +488 -488
- package/docs/troubleshooting.md +486 -486
- package/docs/type-converter.md +310 -310
- package/docs/type-reference.md +242 -242
- package/docs/typescript-guide.md +584 -584
- package/docs/union-type-guide.md +157 -157
- package/docs/union-types.md +284 -284
- package/docs/validate-async.md +491 -491
- package/docs/validate-batch.md +49 -49
- package/docs/validate-dsl-object-support.md +578 -578
- package/docs/validate.md +506 -506
- package/docs/validation-guide.md +502 -502
- package/docs/validator.md +39 -39
- package/package.json +131 -131
- package/plugins/custom-format.cjs +8 -8
- package/plugins/custom-type-example.cjs +8 -8
- package/plugins/custom-validator.cjs +8 -8
- package/src/adapters/DslAdapter.ts +111 -111
- package/src/adapters/index.ts +1 -1
- package/src/config/constants.ts +83 -83
- package/src/config/index.ts +2 -2
- package/src/config/patterns.ts +77 -77
- package/src/core/CacheManager.ts +169 -159
- package/src/core/ConditionalBuilder.ts +382 -382
- package/src/core/ConditionalRuntime.ts +27 -27
- package/src/core/ConditionalValidator.ts +254 -254
- package/src/core/DslBuilder.ts +687 -677
- package/src/core/ErrorCodes.ts +38 -38
- package/src/core/ErrorFormatter.ts +271 -271
- package/src/core/JSONSchemaCore.ts +65 -65
- package/src/core/Locale.ts +187 -187
- package/src/core/MessageTemplate.ts +42 -42
- package/src/core/ObjectDslBuilder.ts +64 -64
- package/src/core/PluginManager.ts +326 -326
- package/src/core/StringExtensions.ts +140 -140
- package/src/core/TemplateEngine.ts +44 -44
- package/src/core/Validator.ts +448 -448
- package/src/errors/I18nError.ts +159 -159
- package/src/errors/ValidationError.ts +105 -105
- package/src/exporters/BaseExporter.ts +60 -60
- package/src/exporters/MarkdownExporter.ts +305 -305
- package/src/exporters/MongoDBExporter.ts +126 -126
- package/src/exporters/MySQLExporter.ts +156 -155
- package/src/exporters/PostgreSQLExporter.ts +222 -222
- package/src/exporters/index.ts +18 -18
- package/src/index.ts +651 -633
- package/src/locales/en-US.ts +160 -160
- package/src/locales/es-ES.ts +160 -160
- package/src/locales/fr-FR.ts +160 -160
- package/src/locales/index.ts +103 -103
- package/src/locales/ja-JP.ts +160 -160
- package/src/locales/types.ts +156 -156
- package/src/locales/zh-CN.ts +160 -160
- package/src/parser/ConstraintParser.ts +101 -101
- package/src/parser/DslParser.ts +470 -470
- package/src/parser/SchemaCompiler.ts +66 -66
- package/src/parser/TypeRegistry.ts +250 -250
- package/src/parser/index.ts +6 -6
- package/src/plugins/custom-format.ts +124 -126
- package/src/plugins/custom-type-example.ts +106 -108
- package/src/plugins/custom-validator.ts +138 -140
- package/src/types/conditional.ts +28 -28
- package/src/types/config.ts +59 -59
- package/src/types/dsl.ts +131 -131
- package/src/types/error.ts +60 -60
- package/src/types/index.ts +17 -17
- package/src/types/infer.ts +127 -127
- package/src/types/plugin.ts +58 -58
- package/src/types/safe-regex.d.ts +9 -9
- package/src/types/schema.ts +66 -66
- package/src/types/validate.ts +71 -71
- package/src/utils/SchemaHelper.ts +196 -196
- package/src/utils/SchemaUtils.ts +365 -346
- package/src/utils/TypeConverter.ts +215 -215
- package/src/utils/index.ts +10 -10
- package/src/validators/CustomKeywords.ts +477 -477
package/docs/dynamic-locale.md
CHANGED
|
@@ -1,608 +1,608 @@
|
|
|
1
|
-
# 动态多语言配置指南
|
|
2
|
-
|
|
3
|
-
> **更新时间**: 2025-12-25
|
|
4
|
-
> **场景**: 从请求头动态获取语言配置
|
|
5
|
-
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
## 📑 目录
|
|
9
|
-
|
|
10
|
-
- [基本原理](#基本原理)
|
|
11
|
-
- [方案1: 验证时指定语言(推荐)](#方案1-验证时指定语言推荐)
|
|
12
|
-
- [方案2: 临时切换语言](#方案2-临时切换语言)
|
|
13
|
-
- [方案3: Express/Koa 中间件](#方案3-expresskoa-中间件)
|
|
14
|
-
- [完整示例](#完整示例)
|
|
15
|
-
- [最佳实践](#最佳实践)
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
## 基本原理
|
|
20
|
-
|
|
21
|
-
schema-dsl 的 `Validator` 支持在验证时动态指定语言,无需全局切换。
|
|
22
|
-
|
|
23
|
-
### 核心方法
|
|
24
|
-
|
|
25
|
-
```javascript
|
|
26
|
-
validator.validate(schema, data, {
|
|
27
|
-
locale: 'zh-CN' // 动态指定语言
|
|
28
|
-
});
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
---
|
|
32
|
-
|
|
33
|
-
## 方案1: 验证时指定语言(推荐)✅
|
|
34
|
-
|
|
35
|
-
这是**最推荐**的方案,无需修改全局状态,支持并发请求。
|
|
36
|
-
|
|
37
|
-
### 1.1 应用启动时配置(一次性加载所有语言)
|
|
38
|
-
|
|
39
|
-
使用 `dsl.config` 在应用启动时一次性加载所有自定义语言包。
|
|
40
|
-
|
|
41
|
-
```javascript
|
|
42
|
-
const { dsl, validate } = require('schema-dsl');
|
|
43
|
-
const path = require('path');
|
|
44
|
-
|
|
45
|
-
// ========== 应用启动时配置(只执行一次)==========
|
|
46
|
-
|
|
47
|
-
// 方式一:传入目录路径(推荐)⭐
|
|
48
|
-
// Node >=18:自动扫描目录下的 .js(CommonJS)、.cjs、.json、.jsonc、.json5 文件
|
|
49
|
-
dsl.config({
|
|
50
|
-
i18n: path.join(__dirname, 'locales')
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
// 方式二:直接传入对象
|
|
54
|
-
dsl.config({
|
|
55
|
-
i18n: {
|
|
56
|
-
'fr-FR': {
|
|
57
|
-
'required': '{{#label}} est requis',
|
|
58
|
-
'string.minLength': '{{#label}} doit contenir au moins {{#limit}} caractères'
|
|
59
|
-
},
|
|
60
|
-
'de-DE': {
|
|
61
|
-
'required': '{{#label}} ist erforderlich',
|
|
62
|
-
'string.minLength': '{{#label}} muss mindestens {{#limit}} Zeichen lang sein'
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// 说明:
|
|
68
|
-
// - 只在应用启动时执行一次
|
|
69
|
-
// - 自动与系统内置语言包合并(用户自定义的优先)
|
|
70
|
-
// - 运行时无需重新加载,直接切换
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
### 1.2 运行时直接切换语言(无需重新加载)
|
|
74
|
-
|
|
75
|
-
```javascript
|
|
76
|
-
const { dsl, validate } = require('schema-dsl');
|
|
77
|
-
|
|
78
|
-
// 定义 Schema
|
|
79
|
-
const schema = dsl({
|
|
80
|
-
username: 'string:3-32!',
|
|
81
|
-
email: 'email!'
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// 测试数据
|
|
85
|
-
const data = { username: 'ab', email: 'invalid' };
|
|
86
|
-
|
|
87
|
-
// ========== 运行时直接切换语言 ==========
|
|
88
|
-
|
|
89
|
-
// 使用中文
|
|
90
|
-
const result1 = validate(schema, data, { locale: 'zh-CN' });
|
|
91
|
-
// 错误: "username长度不能少于3个字符"
|
|
92
|
-
|
|
93
|
-
// 使用法语
|
|
94
|
-
const result2 = validate(schema, data, { locale: 'fr-FR' });
|
|
95
|
-
// 错误: "username doit contenir au moins 3 caractères"
|
|
96
|
-
|
|
97
|
-
// 使用德语
|
|
98
|
-
const result3 = validate(schema, data, { locale: 'de-DE' });
|
|
99
|
-
// 错误: "username muss mindestens 3 Zeichen lang sein"
|
|
100
|
-
|
|
101
|
-
// 说明:
|
|
102
|
-
// - 无需重新加载语言包
|
|
103
|
-
// - 每次验证可以使用不同语言
|
|
104
|
-
// - 支持高并发(无全局状态修改)
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
### 1.3 从请求头获取语言(实际应用场景)
|
|
108
|
-
|
|
109
|
-
```javascript
|
|
110
|
-
const express = require('express');
|
|
111
|
-
const { dsl, validate } = require('schema-dsl');
|
|
112
|
-
const path = require('path');
|
|
113
|
-
|
|
114
|
-
const app = express();
|
|
115
|
-
|
|
116
|
-
// ========== 应用启动时配置(只执行一次)==========
|
|
117
|
-
dsl.config({
|
|
118
|
-
i18n: path.join(__dirname, 'locales')
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// 定义 Schema
|
|
122
|
-
const userSchema = dsl({
|
|
123
|
-
username: 'string:3-32!',
|
|
124
|
-
email: 'email!',
|
|
125
|
-
password: 'string:8-32!'
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
// ========== Express 路由 ==========
|
|
129
|
-
app.post('/api/user/register', (req, res) => {
|
|
130
|
-
// 从请求头获取语言偏好
|
|
131
|
-
const locale = parseAcceptLanguage(req.headers['accept-language']);
|
|
132
|
-
|
|
133
|
-
// 验证数据(直接切换语言,无需重新加载)
|
|
134
|
-
const result = validate(userSchema, req.body, { locale });
|
|
135
|
-
|
|
136
|
-
if (!result.valid) {
|
|
137
|
-
return res.status(400).json({
|
|
138
|
-
errors: result.errors // 自动使用用户偏好的语言
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 处理成功...
|
|
143
|
-
res.json({ message: 'User registered successfully' });
|
|
144
|
-
});
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
### 1.3 解析 Accept-Language 头
|
|
148
|
-
|
|
149
|
-
```javascript
|
|
150
|
-
/**
|
|
151
|
-
* 解析 Accept-Language 头
|
|
152
|
-
* @param {string} acceptLanguage - Accept-Language 头的值
|
|
153
|
-
* @returns {string} 语言代码
|
|
154
|
-
*/
|
|
155
|
-
function parseAcceptLanguage(acceptLanguage) {
|
|
156
|
-
if (!acceptLanguage) return 'en-US';
|
|
157
|
-
|
|
158
|
-
// Accept-Language 格式: zh-CN,zh;q=0.9,en;q=0.8
|
|
159
|
-
const languages = acceptLanguage.split(',').map(lang => {
|
|
160
|
-
const [code, qValue] = lang.trim().split(';');
|
|
161
|
-
const q = qValue ? parseFloat(qValue.split('=')[1]) : 1.0;
|
|
162
|
-
return { code: code.trim(), q };
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// 按权重排序
|
|
166
|
-
languages.sort((a, b) => b.q - a.q);
|
|
167
|
-
|
|
168
|
-
// 映射到支持的语言
|
|
169
|
-
const supportedLocales = ['zh-CN', 'en-US', 'ja-JP'];
|
|
170
|
-
for (const lang of languages) {
|
|
171
|
-
const matched = supportedLocales.find(locale =>
|
|
172
|
-
locale.toLowerCase() === lang.code.toLowerCase() ||
|
|
173
|
-
locale.split('-')[0] === lang.code.split('-')[0]
|
|
174
|
-
);
|
|
175
|
-
if (matched) return matched;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return 'en-US'; // 默认语言
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// 使用
|
|
182
|
-
app.post('/api/user/register', (req, res) => {
|
|
183
|
-
const locale = parseAcceptLanguage(req.headers['accept-language']);
|
|
184
|
-
|
|
185
|
-
const result = validator.validate(schema, req.body, { locale });
|
|
186
|
-
|
|
187
|
-
// ...
|
|
188
|
-
});
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
---
|
|
192
|
-
|
|
193
|
-
## 方案2: 临时切换语言
|
|
194
|
-
|
|
195
|
-
适用于少数场景。
|
|
196
|
-
|
|
197
|
-
### 2.1 使用闭包保存原语言
|
|
198
|
-
|
|
199
|
-
```javascript
|
|
200
|
-
function validateWithLocale(validator, schema, data, locale) {
|
|
201
|
-
const originalLocale = Locale.getLocale();
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
Locale.setLocale(locale);
|
|
205
|
-
return validator.validate(schema, data);
|
|
206
|
-
} finally {
|
|
207
|
-
Locale.setLocale(originalLocale); // 恢复原语言
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// 使用
|
|
212
|
-
app.post('/api/user/register', (req, res) => {
|
|
213
|
-
const locale = parseAcceptLanguage(req.headers['accept-language']);
|
|
214
|
-
|
|
215
|
-
const result = validateWithLocale(validator, schema, req.body, locale);
|
|
216
|
-
|
|
217
|
-
// ...
|
|
218
|
-
});
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
---
|
|
222
|
-
|
|
223
|
-
## 方案3: Express/Koa 中间件
|
|
224
|
-
|
|
225
|
-
封装为中间件,自动处理语言切换。
|
|
226
|
-
|
|
227
|
-
### 3.1 Express 中间件 (推荐)
|
|
228
|
-
|
|
229
|
-
通过中间件一次性配置,后续业务代码无需关心语言参数。
|
|
230
|
-
|
|
231
|
-
```javascript
|
|
232
|
-
const { Validator } = require('schema-dsl');
|
|
233
|
-
const validator = new Validator();
|
|
234
|
-
|
|
235
|
-
const schemaIoMiddleware = (req, res, next) => {
|
|
236
|
-
// 1. 自动获取语言
|
|
237
|
-
const lang = req.headers['accept-language']?.split(',')[0]?.trim() || 'en-US';
|
|
238
|
-
// 简单匹配逻辑 (实际可使用 accept-language-parser)
|
|
239
|
-
const locale = lang.includes('zh') ? 'zh-CN' :
|
|
240
|
-
lang.includes('ja') ? 'ja-JP' :
|
|
241
|
-
lang.includes('es') ? 'es-ES' :
|
|
242
|
-
lang.includes('fr') ? 'fr-FR' : 'en-US';
|
|
243
|
-
|
|
244
|
-
// 2. 挂载绑定了语言的验证方法
|
|
245
|
-
req.validate = (schema, data) => {
|
|
246
|
-
return validator.validate(schema, data, { locale });
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
next();
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
app.use(schemaIoMiddleware);
|
|
253
|
-
|
|
254
|
-
// 业务中使用
|
|
255
|
-
app.post('/users', (req, res) => {
|
|
256
|
-
// 直接调用,自动使用中间件解析的语言
|
|
257
|
-
const result = req.validate(userSchema, req.body);
|
|
258
|
-
|
|
259
|
-
if (!result.valid) {
|
|
260
|
-
return res.status(400).json({ errors: result.errors });
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ...
|
|
264
|
-
});
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
完整示例请参考 [dynamic-locale.ts](https://github.com/vextjs/schema-dsl/blob/main/examples/docs/dynamic-locale.ts)。
|
|
268
|
-
|
|
269
|
-
### 3.2 Koa 中间件
|
|
270
|
-
|
|
271
|
-
```javascript
|
|
272
|
-
const { Locale, Validator } = require('schema-dsl');
|
|
273
|
-
|
|
274
|
-
const validator = new Validator();
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Koa 语言中间件
|
|
278
|
-
*/
|
|
279
|
-
function localeMiddleware() {
|
|
280
|
-
return async (ctx, next) => {
|
|
281
|
-
// 解析语言
|
|
282
|
-
const locale = parseAcceptLanguage(ctx.headers['accept-language']);
|
|
283
|
-
|
|
284
|
-
// 保存到上下文
|
|
285
|
-
ctx.locale = locale;
|
|
286
|
-
|
|
287
|
-
// 复用共享 Validator,避免每个请求都重新建立实例和缓存
|
|
288
|
-
ctx.validate = function(schema, data) {
|
|
289
|
-
return validator.validate(schema, data, { locale: ctx.locale });
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
await next();
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// 应用中间件
|
|
297
|
-
app.use(localeMiddleware());
|
|
298
|
-
|
|
299
|
-
// 使用
|
|
300
|
-
router.post('/api/user/register', async (ctx) => {
|
|
301
|
-
// 自动使用请求的语言
|
|
302
|
-
const result = ctx.validate(userSchema, ctx.request.body);
|
|
303
|
-
|
|
304
|
-
if (!result.valid) {
|
|
305
|
-
ctx.status = 400;
|
|
306
|
-
ctx.body = { errors: result.errors };
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ...
|
|
311
|
-
});
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
---
|
|
315
|
-
|
|
316
|
-
## 完整示例
|
|
317
|
-
|
|
318
|
-
### Express 完整示例
|
|
319
|
-
|
|
320
|
-
```javascript
|
|
321
|
-
const express = require('express');
|
|
322
|
-
const { dsl, Validator, Locale } = require('schema-dsl');
|
|
323
|
-
|
|
324
|
-
const app = express();
|
|
325
|
-
app.use(express.json());
|
|
326
|
-
|
|
327
|
-
// ========== 1. 初始化语言包 ==========
|
|
328
|
-
|
|
329
|
-
Locale.addLocale('zh-CN', {
|
|
330
|
-
'required': '{{#label}}不能为空',
|
|
331
|
-
'min': '{{#label}}至少{{#limit}}个字符',
|
|
332
|
-
'max': '{{#label}}最多{{#limit}}个字符',
|
|
333
|
-
'pattern': '{{#label}}格式不正确',
|
|
334
|
-
'format': '请输入有效的{{#label}}'
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
Locale.addLocale('en-US', {
|
|
338
|
-
'required': '{{#label}} is required',
|
|
339
|
-
'min': '{{#label}} must be at least {{#limit}} characters',
|
|
340
|
-
'max': '{{#label}} must be at most {{#limit}} characters',
|
|
341
|
-
'pattern': '{{#label}} format is invalid',
|
|
342
|
-
'format': 'Please enter a valid {{#label}}'
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
// ========== 2. 工具函数 ==========
|
|
346
|
-
|
|
347
|
-
function parseAcceptLanguage(acceptLanguage) {
|
|
348
|
-
if (!acceptLanguage) return 'en-US';
|
|
349
|
-
|
|
350
|
-
const languages = acceptLanguage.split(',').map(lang => {
|
|
351
|
-
const [code, qValue] = lang.trim().split(';');
|
|
352
|
-
const q = qValue ? parseFloat(qValue.split('=')[1]) : 1.0;
|
|
353
|
-
return { code: code.trim(), q };
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
languages.sort((a, b) => b.q - a.q);
|
|
357
|
-
|
|
358
|
-
const supportedLocales = ['zh-CN', 'en-US'];
|
|
359
|
-
for (const lang of languages) {
|
|
360
|
-
const matched = supportedLocales.find(locale =>
|
|
361
|
-
locale.toLowerCase() === lang.code.toLowerCase()
|
|
362
|
-
);
|
|
363
|
-
if (matched) return matched;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
return 'en-US';
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// ========== 3. 中间件 ==========
|
|
370
|
-
|
|
371
|
-
const validator = new Validator();
|
|
372
|
-
|
|
373
|
-
function localeMiddleware(req, res, next) {
|
|
374
|
-
req.locale = parseAcceptLanguage(req.headers['accept-language']);
|
|
375
|
-
|
|
376
|
-
req.validate = function(schema, data) {
|
|
377
|
-
return validator.validate(schema, data, { locale: req.locale });
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
next();
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
app.use(localeMiddleware);
|
|
384
|
-
|
|
385
|
-
// ========== 4. 定义Schema ==========
|
|
386
|
-
|
|
387
|
-
const userSchema = dsl({
|
|
388
|
-
username: 'string:3-32!'.label('用户名'),
|
|
389
|
-
email: 'email!'.label('邮箱地址'),
|
|
390
|
-
password: 'string:8-64!'
|
|
391
|
-
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/)
|
|
392
|
-
.label('密码')
|
|
393
|
-
.messages({
|
|
394
|
-
'pattern': '密码必须包含大小写字母和数字'
|
|
395
|
-
}),
|
|
396
|
-
age: 'number:18-120'.label('年龄')
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// ========== 5. API 路由 ==========
|
|
400
|
-
|
|
401
|
-
app.post('/api/user/register', (req, res) => {
|
|
402
|
-
// 验证数据(自动使用请求语言)
|
|
403
|
-
const result = req.validate(userSchema, req.body);
|
|
404
|
-
|
|
405
|
-
if (!result.valid) {
|
|
406
|
-
return res.status(400).json({
|
|
407
|
-
success: false,
|
|
408
|
-
errors: result.errors,
|
|
409
|
-
locale: req.locale // 返回使用的语言
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// 处理注册逻辑
|
|
414
|
-
res.json({
|
|
415
|
-
success: true,
|
|
416
|
-
message: req.locale === 'zh-CN' ? '注册成功' : 'Registration successful'
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
// ========== 6. 测试 ==========
|
|
421
|
-
|
|
422
|
-
app.listen(3000, () => {
|
|
423
|
-
console.log('Server running on http://localhost:3000');
|
|
424
|
-
console.log('\n测试命令:');
|
|
425
|
-
console.log('# 中文错误消息');
|
|
426
|
-
console.log('curl -X POST http://localhost:3000/api/user/register \\');
|
|
427
|
-
console.log(' -H "Content-Type: application/json" \\');
|
|
428
|
-
console.log(' -H "Accept-Language: zh-CN" \\');
|
|
429
|
-
console.log(' -d \'{"username":"ab"}\'');
|
|
430
|
-
console.log('\n# 英文错误消息');
|
|
431
|
-
console.log('curl -X POST http://localhost:3000/api/user/register \\');
|
|
432
|
-
console.log(' -H "Content-Type: application/json" \\');
|
|
433
|
-
console.log(' -H "Accept-Language: en-US" \\');
|
|
434
|
-
console.log(' -d \'{"username":"ab"}\'');
|
|
435
|
-
});
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
---
|
|
439
|
-
|
|
440
|
-
## 最佳实践
|
|
441
|
-
|
|
442
|
-
### 1. 语言包集中管理
|
|
443
|
-
|
|
444
|
-
```javascript
|
|
445
|
-
// locales/index.js
|
|
446
|
-
module.exports = {
|
|
447
|
-
'zh-CN': require('./zh-CN.json'),
|
|
448
|
-
'en-US': require('./en-US.json'),
|
|
449
|
-
'ja-JP': require('./ja-JP.json')
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
// locales/zh-CN.json
|
|
453
|
-
{
|
|
454
|
-
"required": "{{#label}}不能为空",
|
|
455
|
-
"min": "{{#label}}至少{{#limit}}个字符",
|
|
456
|
-
"max": "{{#label}}最多{{#limit}}个字符",
|
|
457
|
-
"pattern": "{{#label}}格式不正确",
|
|
458
|
-
"format": "请输入有效的{{#label}}"
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// 初始化
|
|
462
|
-
const locales = require('./locales');
|
|
463
|
-
Object.entries(locales).forEach(([locale, messages]) => {
|
|
464
|
-
Locale.addLocale(locale, messages);
|
|
465
|
-
});
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
### 2. 支持的语言列表
|
|
469
|
-
|
|
470
|
-
```javascript
|
|
471
|
-
const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ja-JP'];
|
|
472
|
-
|
|
473
|
-
function getSupportedLocale(requestLocale) {
|
|
474
|
-
return SUPPORTED_LOCALES.includes(requestLocale)
|
|
475
|
-
? requestLocale
|
|
476
|
-
: 'en-US';
|
|
477
|
-
}
|
|
478
|
-
```
|
|
479
|
-
|
|
480
|
-
### 3. 缓存验证器
|
|
481
|
-
|
|
482
|
-
```javascript
|
|
483
|
-
// 为每个语言缓存验证器
|
|
484
|
-
const validators = {
|
|
485
|
-
'zh-CN': new Validator(),
|
|
486
|
-
'en-US': new Validator(),
|
|
487
|
-
'ja-JP': new Validator()
|
|
488
|
-
};
|
|
489
|
-
|
|
490
|
-
function getValidator(locale) {
|
|
491
|
-
return validators[locale] || validators['en-US'];
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// 使用
|
|
495
|
-
const result = getValidator(req.locale).validate(
|
|
496
|
-
schema,
|
|
497
|
-
data,
|
|
498
|
-
{ locale: req.locale }
|
|
499
|
-
);
|
|
500
|
-
```
|
|
501
|
-
|
|
502
|
-
### 4. 错误响应标准化
|
|
503
|
-
|
|
504
|
-
```javascript
|
|
505
|
-
function sendValidationError(res, result, locale) {
|
|
506
|
-
res.status(400).json({
|
|
507
|
-
success: false,
|
|
508
|
-
code: 'VALIDATION_ERROR',
|
|
509
|
-
message: locale === 'zh-CN' ? '验证失败' : 'Validation failed',
|
|
510
|
-
errors: result.errors,
|
|
511
|
-
locale: locale
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// 使用
|
|
516
|
-
if (!result.valid) {
|
|
517
|
-
return sendValidationError(res, result, req.locale);
|
|
518
|
-
}
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
---
|
|
522
|
-
|
|
523
|
-
## 方案对比
|
|
524
|
-
|
|
525
|
-
| 方案 | 优点 | 缺点 | 推荐度 |
|
|
526
|
-
|------|------|------|--------|
|
|
527
|
-
| **方案1: 验证时指定** | ✅ 无竞态问题<br>✅ 支持并发<br>✅ 代码简洁 | - | ⭐⭐⭐⭐⭐ |
|
|
528
|
-
| 方案2: 临时切换 | ✅ 实现简单 | ⚠️ 并发竞态问题 | ⭐⭐⭐ |
|
|
529
|
-
| 方案3: 中间件 | ✅ 自动化<br>✅ 统一管理<br>✅ 可复用共享 Validator 缓存 | - | ⭐⭐⭐⭐⭐ |
|
|
530
|
-
|
|
531
|
-
**推荐**: 方案1 + 方案3(中间件封装)
|
|
532
|
-
|
|
533
|
-
---
|
|
534
|
-
|
|
535
|
-
## 常见问题
|
|
536
|
-
|
|
537
|
-
### Q1: 如何处理不支持的语言?
|
|
538
|
-
|
|
539
|
-
**A**: 回退到默认语言
|
|
540
|
-
|
|
541
|
-
不要直接把原始 `Accept-Language` 头透传给 `locale`;浏览器常见值会带 `q=` 权重,应该先解析再回退。
|
|
542
|
-
|
|
543
|
-
```javascript
|
|
544
|
-
function parseAcceptLanguage(acceptLanguage) {
|
|
545
|
-
// ...解析逻辑
|
|
546
|
-
return supportedLocale || 'en-US'; // 默认英文
|
|
547
|
-
}
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
### Q2: 是否支持动态加载语言包?
|
|
551
|
-
|
|
552
|
-
**A**: 支持
|
|
553
|
-
|
|
554
|
-
```javascript
|
|
555
|
-
async function loadLocale(locale) {
|
|
556
|
-
if (!Locale.getAvailableLocales().includes(locale)) {
|
|
557
|
-
const messages = await import(`./locales/${locale}.json`);
|
|
558
|
-
Locale.addLocale(locale, messages);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// 使用
|
|
563
|
-
app.use(async (req, res, next) => {
|
|
564
|
-
await loadLocale(req.locale);
|
|
565
|
-
next();
|
|
566
|
-
});
|
|
567
|
-
```
|
|
568
|
-
|
|
569
|
-
### Q3: 如何自定义某些字段的错误消息?
|
|
570
|
-
|
|
571
|
-
**A**: 使用 `.messages()` 方法
|
|
572
|
-
|
|
573
|
-
```javascript
|
|
574
|
-
const schema = dsl({
|
|
575
|
-
password: 'string:8-64!'
|
|
576
|
-
.label('密码')
|
|
577
|
-
.messages({
|
|
578
|
-
'required': req.locale === 'zh-CN'
|
|
579
|
-
? '请输入密码'
|
|
580
|
-
: 'Please enter password',
|
|
581
|
-
'min': req.locale === 'zh-CN'
|
|
582
|
-
? '密码太短了,至少8个字符'
|
|
583
|
-
: 'Password is too short, at least 8 characters'
|
|
584
|
-
})
|
|
585
|
-
});
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
---
|
|
589
|
-
|
|
590
|
-
## 相关文档
|
|
591
|
-
|
|
592
|
-
- [String 扩展](./string-extensions.md#多语言支持)
|
|
593
|
-
- [Locale API](./api-reference.md#locale-类)
|
|
594
|
-
- [Validator API](./api-reference.md#validator-类)
|
|
595
|
-
|
|
596
|
-
---
|
|
597
|
-
|
|
598
|
-
## 对应示例文件
|
|
599
|
-
|
|
600
|
-
**示例入口**: [dynamic-locale.ts](https://github.com/vextjs/schema-dsl/blob/main/examples/docs/dynamic-locale.ts)
|
|
601
|
-
**说明**: 覆盖 `Accept-Language` 解析、运行时 locale 选择,以及同一 schema 在不同请求语言下的验证入口。
|
|
602
|
-
|
|
603
|
-
---
|
|
604
|
-
|
|
605
|
-
**最后更新**: 2026-05-08
|
|
606
|
-
**作者**: schema-dsl Team
|
|
607
|
-
|
|
608
|
-
|
|
1
|
+
# 动态多语言配置指南
|
|
2
|
+
|
|
3
|
+
> **更新时间**: 2025-12-25
|
|
4
|
+
> **场景**: 从请求头动态获取语言配置
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 📑 目录
|
|
9
|
+
|
|
10
|
+
- [基本原理](#基本原理)
|
|
11
|
+
- [方案1: 验证时指定语言(推荐)](#方案1-验证时指定语言推荐)
|
|
12
|
+
- [方案2: 临时切换语言](#方案2-临时切换语言)
|
|
13
|
+
- [方案3: Express/Koa 中间件](#方案3-expresskoa-中间件)
|
|
14
|
+
- [完整示例](#完整示例)
|
|
15
|
+
- [最佳实践](#最佳实践)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 基本原理
|
|
20
|
+
|
|
21
|
+
schema-dsl 的 `Validator` 支持在验证时动态指定语言,无需全局切换。
|
|
22
|
+
|
|
23
|
+
### 核心方法
|
|
24
|
+
|
|
25
|
+
```javascript
|
|
26
|
+
validator.validate(schema, data, {
|
|
27
|
+
locale: 'zh-CN' // 动态指定语言
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 方案1: 验证时指定语言(推荐)✅
|
|
34
|
+
|
|
35
|
+
这是**最推荐**的方案,无需修改全局状态,支持并发请求。
|
|
36
|
+
|
|
37
|
+
### 1.1 应用启动时配置(一次性加载所有语言)
|
|
38
|
+
|
|
39
|
+
使用 `dsl.config` 在应用启动时一次性加载所有自定义语言包。
|
|
40
|
+
|
|
41
|
+
```javascript
|
|
42
|
+
const { dsl, validate } = require('schema-dsl');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
|
|
45
|
+
// ========== 应用启动时配置(只执行一次)==========
|
|
46
|
+
|
|
47
|
+
// 方式一:传入目录路径(推荐)⭐
|
|
48
|
+
// Node >=18:自动扫描目录下的 .js(CommonJS)、.cjs、.json、.jsonc、.json5 文件
|
|
49
|
+
dsl.config({
|
|
50
|
+
i18n: path.join(__dirname, 'locales')
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// 方式二:直接传入对象
|
|
54
|
+
dsl.config({
|
|
55
|
+
i18n: {
|
|
56
|
+
'fr-FR': {
|
|
57
|
+
'required': '{{#label}} est requis',
|
|
58
|
+
'string.minLength': '{{#label}} doit contenir au moins {{#limit}} caractères'
|
|
59
|
+
},
|
|
60
|
+
'de-DE': {
|
|
61
|
+
'required': '{{#label}} ist erforderlich',
|
|
62
|
+
'string.minLength': '{{#label}} muss mindestens {{#limit}} Zeichen lang sein'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// 说明:
|
|
68
|
+
// - 只在应用启动时执行一次
|
|
69
|
+
// - 自动与系统内置语言包合并(用户自定义的优先)
|
|
70
|
+
// - 运行时无需重新加载,直接切换
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 1.2 运行时直接切换语言(无需重新加载)
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
const { dsl, validate } = require('schema-dsl');
|
|
77
|
+
|
|
78
|
+
// 定义 Schema
|
|
79
|
+
const schema = dsl({
|
|
80
|
+
username: 'string:3-32!',
|
|
81
|
+
email: 'email!'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// 测试数据
|
|
85
|
+
const data = { username: 'ab', email: 'invalid' };
|
|
86
|
+
|
|
87
|
+
// ========== 运行时直接切换语言 ==========
|
|
88
|
+
|
|
89
|
+
// 使用中文
|
|
90
|
+
const result1 = validate(schema, data, { locale: 'zh-CN' });
|
|
91
|
+
// 错误: "username长度不能少于3个字符"
|
|
92
|
+
|
|
93
|
+
// 使用法语
|
|
94
|
+
const result2 = validate(schema, data, { locale: 'fr-FR' });
|
|
95
|
+
// 错误: "username doit contenir au moins 3 caractères"
|
|
96
|
+
|
|
97
|
+
// 使用德语
|
|
98
|
+
const result3 = validate(schema, data, { locale: 'de-DE' });
|
|
99
|
+
// 错误: "username muss mindestens 3 Zeichen lang sein"
|
|
100
|
+
|
|
101
|
+
// 说明:
|
|
102
|
+
// - 无需重新加载语言包
|
|
103
|
+
// - 每次验证可以使用不同语言
|
|
104
|
+
// - 支持高并发(无全局状态修改)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 1.3 从请求头获取语言(实际应用场景)
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
const express = require('express');
|
|
111
|
+
const { dsl, validate } = require('schema-dsl');
|
|
112
|
+
const path = require('path');
|
|
113
|
+
|
|
114
|
+
const app = express();
|
|
115
|
+
|
|
116
|
+
// ========== 应用启动时配置(只执行一次)==========
|
|
117
|
+
dsl.config({
|
|
118
|
+
i18n: path.join(__dirname, 'locales')
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// 定义 Schema
|
|
122
|
+
const userSchema = dsl({
|
|
123
|
+
username: 'string:3-32!',
|
|
124
|
+
email: 'email!',
|
|
125
|
+
password: 'string:8-32!'
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ========== Express 路由 ==========
|
|
129
|
+
app.post('/api/user/register', (req, res) => {
|
|
130
|
+
// 从请求头获取语言偏好
|
|
131
|
+
const locale = parseAcceptLanguage(req.headers['accept-language']);
|
|
132
|
+
|
|
133
|
+
// 验证数据(直接切换语言,无需重新加载)
|
|
134
|
+
const result = validate(userSchema, req.body, { locale });
|
|
135
|
+
|
|
136
|
+
if (!result.valid) {
|
|
137
|
+
return res.status(400).json({
|
|
138
|
+
errors: result.errors // 自动使用用户偏好的语言
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 处理成功...
|
|
143
|
+
res.json({ message: 'User registered successfully' });
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 1.3 解析 Accept-Language 头
|
|
148
|
+
|
|
149
|
+
```javascript
|
|
150
|
+
/**
|
|
151
|
+
* 解析 Accept-Language 头
|
|
152
|
+
* @param {string} acceptLanguage - Accept-Language 头的值
|
|
153
|
+
* @returns {string} 语言代码
|
|
154
|
+
*/
|
|
155
|
+
function parseAcceptLanguage(acceptLanguage) {
|
|
156
|
+
if (!acceptLanguage) return 'en-US';
|
|
157
|
+
|
|
158
|
+
// Accept-Language 格式: zh-CN,zh;q=0.9,en;q=0.8
|
|
159
|
+
const languages = acceptLanguage.split(',').map(lang => {
|
|
160
|
+
const [code, qValue] = lang.trim().split(';');
|
|
161
|
+
const q = qValue ? parseFloat(qValue.split('=')[1]) : 1.0;
|
|
162
|
+
return { code: code.trim(), q };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// 按权重排序
|
|
166
|
+
languages.sort((a, b) => b.q - a.q);
|
|
167
|
+
|
|
168
|
+
// 映射到支持的语言
|
|
169
|
+
const supportedLocales = ['zh-CN', 'en-US', 'ja-JP'];
|
|
170
|
+
for (const lang of languages) {
|
|
171
|
+
const matched = supportedLocales.find(locale =>
|
|
172
|
+
locale.toLowerCase() === lang.code.toLowerCase() ||
|
|
173
|
+
locale.split('-')[0] === lang.code.split('-')[0]
|
|
174
|
+
);
|
|
175
|
+
if (matched) return matched;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return 'en-US'; // 默认语言
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 使用
|
|
182
|
+
app.post('/api/user/register', (req, res) => {
|
|
183
|
+
const locale = parseAcceptLanguage(req.headers['accept-language']);
|
|
184
|
+
|
|
185
|
+
const result = validator.validate(schema, req.body, { locale });
|
|
186
|
+
|
|
187
|
+
// ...
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 方案2: 临时切换语言
|
|
194
|
+
|
|
195
|
+
适用于少数场景。
|
|
196
|
+
|
|
197
|
+
### 2.1 使用闭包保存原语言
|
|
198
|
+
|
|
199
|
+
```javascript
|
|
200
|
+
function validateWithLocale(validator, schema, data, locale) {
|
|
201
|
+
const originalLocale = Locale.getLocale();
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
Locale.setLocale(locale);
|
|
205
|
+
return validator.validate(schema, data);
|
|
206
|
+
} finally {
|
|
207
|
+
Locale.setLocale(originalLocale); // 恢复原语言
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 使用
|
|
212
|
+
app.post('/api/user/register', (req, res) => {
|
|
213
|
+
const locale = parseAcceptLanguage(req.headers['accept-language']);
|
|
214
|
+
|
|
215
|
+
const result = validateWithLocale(validator, schema, req.body, locale);
|
|
216
|
+
|
|
217
|
+
// ...
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## 方案3: Express/Koa 中间件
|
|
224
|
+
|
|
225
|
+
封装为中间件,自动处理语言切换。
|
|
226
|
+
|
|
227
|
+
### 3.1 Express 中间件 (推荐)
|
|
228
|
+
|
|
229
|
+
通过中间件一次性配置,后续业务代码无需关心语言参数。
|
|
230
|
+
|
|
231
|
+
```javascript
|
|
232
|
+
const { Validator } = require('schema-dsl');
|
|
233
|
+
const validator = new Validator();
|
|
234
|
+
|
|
235
|
+
const schemaIoMiddleware = (req, res, next) => {
|
|
236
|
+
// 1. 自动获取语言
|
|
237
|
+
const lang = req.headers['accept-language']?.split(',')[0]?.trim() || 'en-US';
|
|
238
|
+
// 简单匹配逻辑 (实际可使用 accept-language-parser)
|
|
239
|
+
const locale = lang.includes('zh') ? 'zh-CN' :
|
|
240
|
+
lang.includes('ja') ? 'ja-JP' :
|
|
241
|
+
lang.includes('es') ? 'es-ES' :
|
|
242
|
+
lang.includes('fr') ? 'fr-FR' : 'en-US';
|
|
243
|
+
|
|
244
|
+
// 2. 挂载绑定了语言的验证方法
|
|
245
|
+
req.validate = (schema, data) => {
|
|
246
|
+
return validator.validate(schema, data, { locale });
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
next();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
app.use(schemaIoMiddleware);
|
|
253
|
+
|
|
254
|
+
// 业务中使用
|
|
255
|
+
app.post('/users', (req, res) => {
|
|
256
|
+
// 直接调用,自动使用中间件解析的语言
|
|
257
|
+
const result = req.validate(userSchema, req.body);
|
|
258
|
+
|
|
259
|
+
if (!result.valid) {
|
|
260
|
+
return res.status(400).json({ errors: result.errors });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ...
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
完整示例请参考 [dynamic-locale.ts](https://github.com/vextjs/schema-dsl/blob/main/examples/docs/dynamic-locale.ts)。
|
|
268
|
+
|
|
269
|
+
### 3.2 Koa 中间件
|
|
270
|
+
|
|
271
|
+
```javascript
|
|
272
|
+
const { Locale, Validator } = require('schema-dsl');
|
|
273
|
+
|
|
274
|
+
const validator = new Validator();
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Koa 语言中间件
|
|
278
|
+
*/
|
|
279
|
+
function localeMiddleware() {
|
|
280
|
+
return async (ctx, next) => {
|
|
281
|
+
// 解析语言
|
|
282
|
+
const locale = parseAcceptLanguage(ctx.headers['accept-language']);
|
|
283
|
+
|
|
284
|
+
// 保存到上下文
|
|
285
|
+
ctx.locale = locale;
|
|
286
|
+
|
|
287
|
+
// 复用共享 Validator,避免每个请求都重新建立实例和缓存
|
|
288
|
+
ctx.validate = function(schema, data) {
|
|
289
|
+
return validator.validate(schema, data, { locale: ctx.locale });
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
await next();
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 应用中间件
|
|
297
|
+
app.use(localeMiddleware());
|
|
298
|
+
|
|
299
|
+
// 使用
|
|
300
|
+
router.post('/api/user/register', async (ctx) => {
|
|
301
|
+
// 自动使用请求的语言
|
|
302
|
+
const result = ctx.validate(userSchema, ctx.request.body);
|
|
303
|
+
|
|
304
|
+
if (!result.valid) {
|
|
305
|
+
ctx.status = 400;
|
|
306
|
+
ctx.body = { errors: result.errors };
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ...
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## 完整示例
|
|
317
|
+
|
|
318
|
+
### Express 完整示例
|
|
319
|
+
|
|
320
|
+
```javascript
|
|
321
|
+
const express = require('express');
|
|
322
|
+
const { dsl, Validator, Locale } = require('schema-dsl');
|
|
323
|
+
|
|
324
|
+
const app = express();
|
|
325
|
+
app.use(express.json());
|
|
326
|
+
|
|
327
|
+
// ========== 1. 初始化语言包 ==========
|
|
328
|
+
|
|
329
|
+
Locale.addLocale('zh-CN', {
|
|
330
|
+
'required': '{{#label}}不能为空',
|
|
331
|
+
'min': '{{#label}}至少{{#limit}}个字符',
|
|
332
|
+
'max': '{{#label}}最多{{#limit}}个字符',
|
|
333
|
+
'pattern': '{{#label}}格式不正确',
|
|
334
|
+
'format': '请输入有效的{{#label}}'
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
Locale.addLocale('en-US', {
|
|
338
|
+
'required': '{{#label}} is required',
|
|
339
|
+
'min': '{{#label}} must be at least {{#limit}} characters',
|
|
340
|
+
'max': '{{#label}} must be at most {{#limit}} characters',
|
|
341
|
+
'pattern': '{{#label}} format is invalid',
|
|
342
|
+
'format': 'Please enter a valid {{#label}}'
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ========== 2. 工具函数 ==========
|
|
346
|
+
|
|
347
|
+
function parseAcceptLanguage(acceptLanguage) {
|
|
348
|
+
if (!acceptLanguage) return 'en-US';
|
|
349
|
+
|
|
350
|
+
const languages = acceptLanguage.split(',').map(lang => {
|
|
351
|
+
const [code, qValue] = lang.trim().split(';');
|
|
352
|
+
const q = qValue ? parseFloat(qValue.split('=')[1]) : 1.0;
|
|
353
|
+
return { code: code.trim(), q };
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
languages.sort((a, b) => b.q - a.q);
|
|
357
|
+
|
|
358
|
+
const supportedLocales = ['zh-CN', 'en-US'];
|
|
359
|
+
for (const lang of languages) {
|
|
360
|
+
const matched = supportedLocales.find(locale =>
|
|
361
|
+
locale.toLowerCase() === lang.code.toLowerCase()
|
|
362
|
+
);
|
|
363
|
+
if (matched) return matched;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return 'en-US';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ========== 3. 中间件 ==========
|
|
370
|
+
|
|
371
|
+
const validator = new Validator();
|
|
372
|
+
|
|
373
|
+
function localeMiddleware(req, res, next) {
|
|
374
|
+
req.locale = parseAcceptLanguage(req.headers['accept-language']);
|
|
375
|
+
|
|
376
|
+
req.validate = function(schema, data) {
|
|
377
|
+
return validator.validate(schema, data, { locale: req.locale });
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
next();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
app.use(localeMiddleware);
|
|
384
|
+
|
|
385
|
+
// ========== 4. 定义Schema ==========
|
|
386
|
+
|
|
387
|
+
const userSchema = dsl({
|
|
388
|
+
username: 'string:3-32!'.label('用户名'),
|
|
389
|
+
email: 'email!'.label('邮箱地址'),
|
|
390
|
+
password: 'string:8-64!'
|
|
391
|
+
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/)
|
|
392
|
+
.label('密码')
|
|
393
|
+
.messages({
|
|
394
|
+
'pattern': '密码必须包含大小写字母和数字'
|
|
395
|
+
}),
|
|
396
|
+
age: 'number:18-120'.label('年龄')
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ========== 5. API 路由 ==========
|
|
400
|
+
|
|
401
|
+
app.post('/api/user/register', (req, res) => {
|
|
402
|
+
// 验证数据(自动使用请求语言)
|
|
403
|
+
const result = req.validate(userSchema, req.body);
|
|
404
|
+
|
|
405
|
+
if (!result.valid) {
|
|
406
|
+
return res.status(400).json({
|
|
407
|
+
success: false,
|
|
408
|
+
errors: result.errors,
|
|
409
|
+
locale: req.locale // 返回使用的语言
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 处理注册逻辑
|
|
414
|
+
res.json({
|
|
415
|
+
success: true,
|
|
416
|
+
message: req.locale === 'zh-CN' ? '注册成功' : 'Registration successful'
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ========== 6. 测试 ==========
|
|
421
|
+
|
|
422
|
+
app.listen(3000, () => {
|
|
423
|
+
console.log('Server running on http://localhost:3000');
|
|
424
|
+
console.log('\n测试命令:');
|
|
425
|
+
console.log('# 中文错误消息');
|
|
426
|
+
console.log('curl -X POST http://localhost:3000/api/user/register \\');
|
|
427
|
+
console.log(' -H "Content-Type: application/json" \\');
|
|
428
|
+
console.log(' -H "Accept-Language: zh-CN" \\');
|
|
429
|
+
console.log(' -d \'{"username":"ab"}\'');
|
|
430
|
+
console.log('\n# 英文错误消息');
|
|
431
|
+
console.log('curl -X POST http://localhost:3000/api/user/register \\');
|
|
432
|
+
console.log(' -H "Content-Type: application/json" \\');
|
|
433
|
+
console.log(' -H "Accept-Language: en-US" \\');
|
|
434
|
+
console.log(' -d \'{"username":"ab"}\'');
|
|
435
|
+
});
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## 最佳实践
|
|
441
|
+
|
|
442
|
+
### 1. 语言包集中管理
|
|
443
|
+
|
|
444
|
+
```javascript
|
|
445
|
+
// locales/index.js
|
|
446
|
+
module.exports = {
|
|
447
|
+
'zh-CN': require('./zh-CN.json'),
|
|
448
|
+
'en-US': require('./en-US.json'),
|
|
449
|
+
'ja-JP': require('./ja-JP.json')
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// locales/zh-CN.json
|
|
453
|
+
{
|
|
454
|
+
"required": "{{#label}}不能为空",
|
|
455
|
+
"min": "{{#label}}至少{{#limit}}个字符",
|
|
456
|
+
"max": "{{#label}}最多{{#limit}}个字符",
|
|
457
|
+
"pattern": "{{#label}}格式不正确",
|
|
458
|
+
"format": "请输入有效的{{#label}}"
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 初始化
|
|
462
|
+
const locales = require('./locales');
|
|
463
|
+
Object.entries(locales).forEach(([locale, messages]) => {
|
|
464
|
+
Locale.addLocale(locale, messages);
|
|
465
|
+
});
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### 2. 支持的语言列表
|
|
469
|
+
|
|
470
|
+
```javascript
|
|
471
|
+
const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ja-JP'];
|
|
472
|
+
|
|
473
|
+
function getSupportedLocale(requestLocale) {
|
|
474
|
+
return SUPPORTED_LOCALES.includes(requestLocale)
|
|
475
|
+
? requestLocale
|
|
476
|
+
: 'en-US';
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### 3. 缓存验证器
|
|
481
|
+
|
|
482
|
+
```javascript
|
|
483
|
+
// 为每个语言缓存验证器
|
|
484
|
+
const validators = {
|
|
485
|
+
'zh-CN': new Validator(),
|
|
486
|
+
'en-US': new Validator(),
|
|
487
|
+
'ja-JP': new Validator()
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
function getValidator(locale) {
|
|
491
|
+
return validators[locale] || validators['en-US'];
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 使用
|
|
495
|
+
const result = getValidator(req.locale).validate(
|
|
496
|
+
schema,
|
|
497
|
+
data,
|
|
498
|
+
{ locale: req.locale }
|
|
499
|
+
);
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### 4. 错误响应标准化
|
|
503
|
+
|
|
504
|
+
```javascript
|
|
505
|
+
function sendValidationError(res, result, locale) {
|
|
506
|
+
res.status(400).json({
|
|
507
|
+
success: false,
|
|
508
|
+
code: 'VALIDATION_ERROR',
|
|
509
|
+
message: locale === 'zh-CN' ? '验证失败' : 'Validation failed',
|
|
510
|
+
errors: result.errors,
|
|
511
|
+
locale: locale
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 使用
|
|
516
|
+
if (!result.valid) {
|
|
517
|
+
return sendValidationError(res, result, req.locale);
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
## 方案对比
|
|
524
|
+
|
|
525
|
+
| 方案 | 优点 | 缺点 | 推荐度 |
|
|
526
|
+
|------|------|------|--------|
|
|
527
|
+
| **方案1: 验证时指定** | ✅ 无竞态问题<br>✅ 支持并发<br>✅ 代码简洁 | - | ⭐⭐⭐⭐⭐ |
|
|
528
|
+
| 方案2: 临时切换 | ✅ 实现简单 | ⚠️ 并发竞态问题 | ⭐⭐⭐ |
|
|
529
|
+
| 方案3: 中间件 | ✅ 自动化<br>✅ 统一管理<br>✅ 可复用共享 Validator 缓存 | - | ⭐⭐⭐⭐⭐ |
|
|
530
|
+
|
|
531
|
+
**推荐**: 方案1 + 方案3(中间件封装)
|
|
532
|
+
|
|
533
|
+
---
|
|
534
|
+
|
|
535
|
+
## 常见问题
|
|
536
|
+
|
|
537
|
+
### Q1: 如何处理不支持的语言?
|
|
538
|
+
|
|
539
|
+
**A**: 回退到默认语言
|
|
540
|
+
|
|
541
|
+
不要直接把原始 `Accept-Language` 头透传给 `locale`;浏览器常见值会带 `q=` 权重,应该先解析再回退。
|
|
542
|
+
|
|
543
|
+
```javascript
|
|
544
|
+
function parseAcceptLanguage(acceptLanguage) {
|
|
545
|
+
// ...解析逻辑
|
|
546
|
+
return supportedLocale || 'en-US'; // 默认英文
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Q2: 是否支持动态加载语言包?
|
|
551
|
+
|
|
552
|
+
**A**: 支持
|
|
553
|
+
|
|
554
|
+
```javascript
|
|
555
|
+
async function loadLocale(locale) {
|
|
556
|
+
if (!Locale.getAvailableLocales().includes(locale)) {
|
|
557
|
+
const messages = await import(`./locales/${locale}.json`);
|
|
558
|
+
Locale.addLocale(locale, messages);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// 使用
|
|
563
|
+
app.use(async (req, res, next) => {
|
|
564
|
+
await loadLocale(req.locale);
|
|
565
|
+
next();
|
|
566
|
+
});
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### Q3: 如何自定义某些字段的错误消息?
|
|
570
|
+
|
|
571
|
+
**A**: 使用 `.messages()` 方法
|
|
572
|
+
|
|
573
|
+
```javascript
|
|
574
|
+
const schema = dsl({
|
|
575
|
+
password: 'string:8-64!'
|
|
576
|
+
.label('密码')
|
|
577
|
+
.messages({
|
|
578
|
+
'required': req.locale === 'zh-CN'
|
|
579
|
+
? '请输入密码'
|
|
580
|
+
: 'Please enter password',
|
|
581
|
+
'min': req.locale === 'zh-CN'
|
|
582
|
+
? '密码太短了,至少8个字符'
|
|
583
|
+
: 'Password is too short, at least 8 characters'
|
|
584
|
+
})
|
|
585
|
+
});
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## 相关文档
|
|
591
|
+
|
|
592
|
+
- [String 扩展](./string-extensions.md#多语言支持)
|
|
593
|
+
- [Locale API](./api-reference.md#locale-类)
|
|
594
|
+
- [Validator API](./api-reference.md#validator-类)
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## 对应示例文件
|
|
599
|
+
|
|
600
|
+
**示例入口**: [dynamic-locale.ts](https://github.com/vextjs/schema-dsl/blob/main/examples/docs/dynamic-locale.ts)
|
|
601
|
+
**说明**: 覆盖 `Accept-Language` 解析、运行时 locale 选择,以及同一 schema 在不同请求语言下的验证入口。
|
|
602
|
+
|
|
603
|
+
---
|
|
604
|
+
|
|
605
|
+
**最后更新**: 2026-05-08
|
|
606
|
+
**作者**: schema-dsl Team
|
|
607
|
+
|
|
608
|
+
|