hongs-crud
v0.2.2
Published
Hong's CRUD library
Downloads
571
Readme
hongs-crud
基于 Mongoose Schema 的 CRUD 封装,提供 search / create / update / delete 四个标准方法,以及 statis / upsert / schema 三个扩展方法,通过 schema 可返回 JSON Schema 规范的结构,以便前端和 AI 识别处理。
另含可选的检索组件 Chaser(从 hongs-crud/es 引入),把 search / statis 的查询执行搬到 ElasticSearch,采用与 MongoDB 一致的查询语法,提供更强的搜索及筛选能力,并在写入后自动同步索引,见第 4 节。
另含可选的键值组件 Roster(接口在 hongs-crud/kv,实现在 hongs-crud/kv/mongo 与 hongs-crud/kv/redis),基于 MongoDB 或 Redis 存取带有效期的键值记录,适合验证码、上传令牌等短命数据,见第 5 节。
源码:github.com/ihongs/HongsCRUD
npm install hongs-crud依赖(peer):mongoose
^7 || ^8;@elastic/elasticsearch^8(可选,仅使用 ES 检索组件时需要);redis^4(可选,仅使用 KV 的 RedisRoster 时需要)
1. Schema 配置
hongs-crud 围绕标准 Mongoose Schema 展开,能力通过两种扩展叠加获得:
- 字段内部自定义选项:
title/description/writable/readable/countable/reference/enumTags等。 - 扩展参数自定义选项:
title/description/collection/softDelete/limitDef/limitMax等。
下面是一个完整、简单的例子,包含所有扩展点:
import { Schema } from 'mongoose';
const userSchema = new Schema(
/* ====================== 字段定义 ====================== */
{
username: {
type: String,
unique: true,
required: true, // → 上级 object 的 required 数组
minlength: 3, // → minLength
maxlength: 32, // → maxLength
match: /^[a-zA-Z0-9_]+$/, // → pattern(正则转字符串)
title: '用户名', // → title
description: '登录账号,字母数字下划线', // → description
},
password: {
type: String,
select: false, // → writeOnly,可写不可读
readable: false, // → writeOnly,同 select(程序层面)
required: true,
},
passsalt: {
type: String,
writable: false, // → readOnly,外部不可写(程序层面)
},
age: {
type: Number,
min: 0, // → minimum
max: 200, // → maximum
},
status: {
type: String,
default: 'active', // → default
immutable: true, // → x-immutable,创建后不可修改
countable: true, // → x-countable,可被 statis() 统计
enum: ['active', 'frozen', 'closed'], // mongoose 原生枚举验证,不透出
enumTags: { // → x-enum-tags,码值 → 标签
active: '启用', frozen: '冻结', closed: '关闭',
},
},
orgId: {
type: Schema.Types.ObjectId, // → type: string, format: object-id
reference: { // 远程取数,search/statis 传 refs 时取回关联数据
method : 'org.search', // json-rpc 方法名(Func 或 CrudName.MethodName)
params : { cols: { _id: 1, name: 1 } }, // 附加参数
refName : 'org', // refs 聚集名,默认为字段名
listKey : 'list', // 返回结果中的列表键,默认 list
idField : '_id', // 关联项取值字段,默认 _id
idParam : 'id', // 查询参数名,默认 id
description: '所属组织', // 关联说明
},
}
},
/* ====================== Schema 第二参数的 hongs-crud 扩展 ====================== */
{
collection: 'users', // 必填:集合名,同时用作 mongoose.model() 名称
timestamps: true, // mongoose 原生:自动维护 createdAt / updatedAt
softDelete: true, // 伪删除,等价于 { isDeleted: 'isDeleted', deletedAt: 'deletedAt', deleted: true, default: false }
limitDef : 20, // search() 默认 limit,未传时的默认值,默认 1;0 表示不限
limitMax : 500, // search() limit 上限,超过抛 CrudErrno.PARAMS_INVALID,默认 1000;0 表示不限
title : '用户', // 模型标题,透出到 schema() 根节点 title
description : '系统用户表', // 模型说明,透出到 schema() 根节点 description
},
);mongoose 扩展:
| 扩展点 | 归属 | 作用 |
|---|---|---|
| title | Schema 扩展、字段内 | 模型标题、字段标题,透出为 JSON Schema 的 title |
| description | Schema 扩展、字段内 | 模型说明、字段说明,透出为 JSON Schema 的 description |
| writable | 字段内 | 写 writable: false 表示外部不可写,透出为 readOnly |
| readable | 字段内 | 写 readable: false 表示预留的禁读声明,透出为 writeOnly;与 writable 同属程序层面预留的读写符号,select 则为 mongoose 原生投影控制 |
| countable | 字段内 | 写 countable: true 表示该字段可被 statis() 统计,透出为 x-countable |
| reference | 字段内 | 声明该字段的关联数据来源,透出为 x-reference;通过 method 远程调用,供 refs 取数,见 2.4 / 3.5 |
| enumTags | 字段内 | 枚举值 → 标签映射,透出为 x-enum-tags,与 enum 配套使用 |
| collection | Schema 扩展 | 必填,集合名 |
| softDelete | Schema 扩展 | 伪删除配置;true 或 { isDeleted, deletedAt, deleted, default },启用后自动补字段,且 search / update / delete / statis 自动注入条件 |
| limitDef | Schema 扩展 | search() 默认 limit,默认 1,0 不限 |
| limitMax | Schema 扩展 | search() limit 上限,默认 1000,0 不限,超过抛异常 CrudErrno.PARAMS_INVALID |
mongoose 选项到 JSON Schema 的映射:
| Mongoose | JSON Schema |
|---|---|
| type: String / Number / Boolean | type: 'string' / 'number' / 'boolean' |
| type: Schema.Types.Decimal128 | type: 'number' |
| type: Date | type: 'string', format: 'date-time' |
| type: Schema.Types.ObjectId | type: 'string', format: 'object-id' |
| type: Map / SubDocument | type: 'object'(properties / additionalProperties) |
| type: [X] | type: 'array'(items) |
| default | default(函数型默认值不透出) |
| required: true | 追加到上级 object 的 required 数组 |
| min / max | minimum / maximum |
| minlength / maxlength | minLength/maxLength、minItems/maxItems、minProperties/maxProperties(按 type) |
| match | pattern |
| select: false | writeOnly |
| readable: false | writeOnly |
| writable: false | readOnly |
| select or readable: false + writable: false | 整个字段跳过,不透出 |
| createdAt / updatedAt | readOnly( timestamps 自动维护) |
| immutable: true | x-immutable |
| countable: true | x-countable |
| reference: { ... } | x-reference |
| enumTags: { ... } | x-enum-tags |
然后,new Cradle(userSchema) 即可获得 create / update / delete / search / statis / upsert / schema 能力。
2. 方法请求参数与返回结果
方法的入参与返回都是纯 POJO,可直接 JSON 化;所有参数和结果都支持附加任意扩展字段。下面用最简单的举例说明每个方法的请求参数与返回数据。
2.1 create
// 请求
{ data: { username: 'alice', status: 'active' } }
// 返回
{ id: '66b...a01' }2.2 update
// 请求
{ id: '66b...a01', data: { status: 'frozen' } }
// 返回(实际内容发生变化的文档数;同值更新计 0)
{ affected: 1, validIds: ['66b...a01'] }force: true时,不存在的 id 静默跳过;缺省则抛异常。find可选,附加查询条件(做租户/归属隔离)。
2.3 delete
// 请求
{ id: '66b...a01' }
// 返回(硬删:删除条数;软删:被打标条数,重复打标计 0)
{ affected: 1, validIds: ['66b...a01'] }force: true时,不存在的 id 静默跳过;缺省则抛异常。find可选,附加查询条件(做租户/归属隔离)。
2.4 search
// 请求
{
id : ['66b...a01'], // 可单个或数组,用于获取详情
wd : 'alice', // 搜索关键词
find : { status: 'active' }, // 查询条件
cols : { username: 1, status: 1 }, // 投影
sort : { createdAt: -1 }, // 排序
start: 0, // 跳过
limit: 20, // 上限;缺省用 schema.limitDef,超过 limitMax 抛异常
mode: '', // 列表模式,见下
refs: true, // 关联数据:true 全部,或 {字段名: 1} 白名单,见下
}
// 返回
{
list : [{ _id: '66b...a01', username: 'alice', status: 'active' }, ...],
total: 32,
refs : { // 传了 refs 且有外键时才有,见下
'orgId': [{ _id: '66b...o01', name: '组织A' }, ...],
},
}mode 模式:
| 值 | 返回 |
|---|---|
| 未传 | { list, total } |
| 'list-more' | { list, more } |
| 'only-list' | { list } |
| 'only-total' | { total } |
refs 关联数据:对结果 list 里声明了 reference 的字段,按聚集名 reference.refName(默认字段名)收集外键值(数组自动展开去重)——多个字段同指一个 refName(如工作单位所在地和毕业院校所在地都指向地区表)时,共享首个字段的 ref 配置(method、params 等),收集的外键值合并去重后一起查;各聚集名分别调 reference.method(经 callFunc 调度,需相应权限),返回 {refName: [关联数据]}(按外键查到的行数组,不建外键映射,映射由前端按需建立)。refs 传 true 全取,传对象时按字段名或 refName 命中:全为 1 是白名单,含 0 是黑名单,undefined / null 等同 false 不取(默认不取关联)。
2.5 statis(扩展)
对字段内声明了 countable: true 的字段做分组统计,常用于搜索页筛选器。
// 请求
{
find: { status: 'active' }, // 基础过滤
sels: { status: ['active'] }, // 联动已选;空数组视为没选
tops: 10, // 每字段取前 N,默认 10;也可按字段 { status: 5 }
refs: true, // 关联数据:true 全部,或 {字段名: 1} 白名单,规则同 search
}
// 返回
{
total: 35, // 应用 sels 已选条件后的总文档数
hits : { // 每字段 [{value, count}],按 count 降序
status: [{ value: 'active', count: 28 }, { value: 'frozen', count: 5 }, { value: 'closed', count: 2 }],
roles : [{ value: 'user', count: 32 }, { value: 'admin', count: 3 }],
orgId : [{ value: '66b...o01', count: 12 }],
},
refs : { // 传了 refs 且有外键时才有,规则同 search
'orgId': [{ _id: '66b...o01', name: '组织A' }, ...],
},
}sels 联动规则:
- 任一非空数组转为
$in并入总过滤条件,total反映该条件下的总数。 - 已选字段不应用自己的
sels条件(避免无法继续筛选该字段其他选项)。 - 其他字段应用所有
sels条件,结果相互联动。
hits 桶结构:value 统一 String() 化(ObjectId / Date / 数字都可直接作键比对),字段缺失的文档记 value: '';同 count 的桶顺序不保证稳定,按 value 对齐而非下标。
2.6 upsert(扩展)
批量 upsert:逐行根据 uks 检查是否存在,存在则更新,不存在则添加。单行失败不中断,记入 errors。
// 请求
{
uks: ['_id'], // 默认 ['_id'];有 _id 更新、没 _id 添加
items: [
{ name: 'alice', age: 20 }, // 没 _id → 添加
{ _id: '66b...a01', name: 'alice', age: 21 }, // 有 _id 且存在 → 更新
{ _id: '66b...xxx', name: 'ghost', age: 99 }, // 有 _id 不存在 → 报错
],
}
// 返回
{
created: 1,
updated: 1,
errors: [
{ index: 2, message: 'Item with _id(66b...xxx) not found' },
],
}uks默认['_id']:有_id就更新、没_id就添加;有_id但找不到记入errors。uks为其他字段(如['username'])时:按uks查到则更新,查不到则添加(upsert 语义,不报错)。- 校验失败的行:
errors项含message+errors(字段级明细);其他错误只记message。
2.7 schema(扩展)
把 Mongoose Schema 转译为标准 JSON Schema(draft 2020-12),供前端渲染表单及 AI 编排。返回体本身就是 JSON Schema 根节点:$schema / type: 'object' / title / description / required / properties 都在顶层,properties 里才是具体字段。
// 请求
{ cols: { username: 1, status: 1 } } // 可选,投影,只输出指定字段
// 返回
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"title": "用户",
"description": "系统用户表",
"required": ["username", "password"],
"properties": {
"_id": {
"type": "string",
"format": "object-id"
},
"username": {
"type": "string",
"title": "用户名",
"description": "登录账号,字母数字下划线",
"minLength": 3,
"maxLength": 32,
"pattern": "^[a-zA-Z0-9_]+$"
},
"password": {
"type": "string",
"writeOnly": true
},
"age": {
"type": "number",
"minimum": 0,
"maximum": 200
},
"status": {
"type": "string",
"default": "active",
"x-immutable": true,
"x-countable": true,
"x-enum-tags": { "active": "启用", "frozen": "冻结", "closed": "关闭" }
},
"orgId": {
"type": "string",
"format": "object-id",
"x-reference": {
"method" : "org.search",
"params" : { "cols": { "_id": 1, "name": 1 } },
"refName" : "org",
"listKey" : "list",
"idField" : "_id",
"idParam" : "id",
"description": "所属组织"
}
},
"createdAt": { "type": "string", "format": "date-time", "readOnly": true },
"updatedAt": { "type": "string", "format": "date-time", "readOnly": true }
}
}节点说明:
- 标准关键字:
type/title/description/default/format/pattern/minLength/maxLength/minimum/maximum/minItems/maxItems/minProperties/maxProperties/items/properties/additionalProperties/required/readOnly/writeOnly,语义与 JSON Schema 一致。 - 扩展关键字:
x-immutable(创建后不可改)、x-countable(可被statis()统计)、x-reference(关联数据来源)、x-enum-tags(枚举值 → 标签映射)。 required只在 object 节点上:根节点及子文档节点用required: string[],字段节点自身不带required。- 数组与子文档递归展开:
[String]→items: { type: 'string' },[SubDocument]→items: { type: 'object', properties: {...} },Map→additionalProperties: { ... }。
3. 注册器:crud / func / hook / role
四者都是扁平的全局注册表;callFunc(name, params, ctx) 会按「Func 名 → CrudName.MethodName」的顺序解析并执行,并对执行进行钩子包裹。
3.1 注册 Crud(模型)
import { Cradle, regCrud, getCrud, hasCrud, getCrudNames } from 'hongs-crud';
const userSchema = new Schema({ /* ... */ }, { collection: 'users' });
const userCrud = new Cradle(userSchema);
// 注册:动作字符串 "user.search" / "user.create" ... 就指向该实例的对应方法
regCrud('user', userCrud);
hasCrud('user'); // → true
getCrud('user'); // → userCrud 实例(类型:Crud 接口)
getCrudNames(); // → ['user', ...]Cradle 默认的 callable(可被外部调度的方法白名单)为:
callable = ['create', 'update', 'delete', 'search', 'statis', 'upsert', 'schema'];子类可覆写 callable 来收紧或扩展,不在其中的方法即便权限符合也不会被调度。
3.2 注册 Func(全局函数)
import { regFunc, getFunc, hasFunc, getFuncNames } from 'hongs-crud';
regFunc('health.ping', () => ({ ok: true, ts: Date.now() }));
regFunc('system.versions', () => ({ node: process.version }));
regFunc('org.search', async ({ id, cols }) => {
// 常见 reference 目标:可接收 params { id: [外键值] }(refs 取数时传入),
// 返回 { list: [{_id, name}, ...] } 供下拉选项与关联数据消费
return { list: [{ _id: 'o1', name: '组织A' }, { _id: 'o2', name: '组织B' }] };
});注意:上面 schema 例子中
orgId.reference.method = 'org.search'就是指向这里注册的 Func。
3.3 注册 Hook(钩子函数)
import { regHook, hookPermits } from 'hongs-crud';
// !!! callFunc 不内置权限检查:不注册 hookPermits 则所有方法都不做权限检查
// !!! 务必第一个注册(最外层),缺省 name 即作用于全部调用
regHook(undefined, hookPermits);
// 先注册的在外层,包裹方法执行
// 第一参为 name:缺省(undefined/null/空串)时通配全部,字符串精确匹配,亦可用正则匹配
regHook('note.search', async (name, pms, ctx, next) => {
pms.uid = ctx.uid; // 输入干预:强制当前用户
const res = await next(pms, ctx);
res.list.pop(); // 输出干预:去掉最后一行
return res;
});说明:
- 执行顺序为:钩子链(按注册顺序由外到内)→ 方法执行(Func 查找 / CrudName.MethodName 调度)
name缺省(undefined/null/空串)时匹配全部方法,为字符串时精确匹配方法名,为正则时匹配方法名next为Func签名,须显式传参:next(params, ctx)原样放行;改写传入的对象(引用传递)即完成输入干预- 钩子抛错即短路,调用方收到该异常;返回值即最终结果,可对
next(params, ctx)的结果(多为 Promise)调整后返回
3.4 注册 Role(角色 → 动作集合)
import { regRole, hasRole, getRole, getRoleNames, isPermitted } from 'hongs-crud';
// 一个角色对应可执行「动作字符串」集合(Func 名 或 CrudName.MethodName)
regRole('admin', ['user.search', 'user.create', 'user.update', 'user.delete',
'user.statis', 'user.schema',
'health.ping', 'system.versions']);
regRole('user', ['user.search', 'health.ping']);
regRole('guest', ['health.ping']);
// 单个判断:任一角色包含动作即放行
isPermitted('user.delete', ['user']); // → false
isPermitted('user.delete', ['admin']); // → true
isPermitted('health.ping', ['guest']); // → trueacts 参数可传 string[] 或 Set<string>。
预定角色:
open: 开放接口anon:匿名用户,roles 缺失时的角色ref:内部关联,关联查询的临时角色
3.5 统一调度入口 callFunc
import { callFunc, CrudError, CrudErrno } from 'hongs-crud';
// 以 RPC 调度函数举例
async function dispatch(req: RpcRequest, ctx: Context): Promise<RpcResponse> {
const id = req.id ?? null;
if (typeof req.method !== 'string') {
return { jsonrpc: '2.0', error: {code: -32600, message: 'Invalid Request: method required!'}, id };
}
try {
const result = await callFunc(req.method, req.params || {}, ctx);
return { jsonrpc: '2.0', result, id };
} catch (e: any) {
if (e instanceof CrudError) {
return { jsonrpc: '2.0', error: {code: e.code || -32603, message: e.message, data: e.data}, id };
}
return { jsonrpc: '2.0', error: {code: -32603, message: e?.message || 'Internal error!'}, id };
}
}错误 code 枚举:
enum CrudErrno {
METHOD_MISSING = -32601, // 方法缺失 / 不在 callable
PARAMS_INVALID = -32602, // 参数非法
INTERNEL_ERROR = -32603, // 内部错误
LOGIN_REQUIRED = -32001, // 需要登录
RIGHT_DEPRIVED = -32003, // 权限不足(isPermitted 拒绝)
ALTER_REJECTED = -32009, // 目标 id 不存在 / 非当前可变更的数据
}4. ES 检索组件(Chaser)
Chaser 继承自 Cradle,在保留全部 CRUD 能力的基础上,把 search / statis 的查询执行搬到 Elasticsearch(全文检索、多条件过滤、聚合统计),并在写入后自动同步索引。适合数据量大、需要全文检索或复杂筛选联动的场景;mongo 始终是权威数据源,ES 只承担查询。
4.1 引入与初始化
从 subpath hongs-crud/es 引入,主入口 hongs-crud 不含 es:
import { Client } from '@elastic/elasticsearch';
import { Chaser, setEsClient } from 'hongs-crud/es'; // 注意:从 subpath 引入
const es = new Client({ node: 'http://127.0.0.1:9200' });
// 方式一:构造时注入(第二参 model 沿用 Cradle,第三参为 es 客户端)
const userCrud = new Chaser(userSchema, undefined, es);
// 方式二:注册全局默认客户端,构造时可省略 es
setEsClient(es);
const userCrud = new Chaser(userSchema);用不到检索的项目无需安装 @elastic/elasticsearch,且安装 / 类型检查 / 打包三环节均不受影响:
| 环节 | 未装 ES 客户端且不用 es | 说明 |
|---|---|---|
| 安装 | 包管理器不告警 | ES 客户端在 peerDependencies 中标记为 optional |
| 类型检查 | 不报错,无需 skipLibCheck | subpath 隔离,不 import 'hongs-crud/es' 就不会加载其类型声明 |
| 打包 | 不报 module not found、无告警 | es 内对 ES 客户端只用 import type,产物零引用 |
类与全局客户端(同步选项与统计见 4.5):
export class Chaser extends Cradle {
constructor(schema: Schema, model?: Model<any>, es?: Client);
getClient(): Client; // 未注入则取全局默认,缺失抛 CrudErrno.INTERNEL_ERROR
getIndex (): string; // esIndex || collection
/* ---------- mapping 与字段清单 ---------- */
getMapping(): Record<string, any>; // 入索引字段 -> ES mapping,含合并字段
getSyncable(): Set<string>; // 入索引字段名集合(含子文档点号路径)
getTextable(): Set<string>; // 并入全文的 textable 字段名集合
getCountable(): Set<string>; // 入索引 + countable 字段名集合
getNestedPaths(): Set<string>; // 声明了 nested 的字段路径集合
/** 拼装全文内容,写入 esFullText 字段;默认按 getTextable() 取值拼接,子类可覆盖 */
protected getFullText(doc: any): string;
/* ---------- 直查 mongo:透传 Cradle 原实现 ---------- */
rawSearch(params: SearchParams, ctx: Context): Promise<SearchResult>;
rawStatis(params: StatisParams, ctx: Context): Promise<StatisResult>;
/* ---------- 覆盖:读走 ES ---------- */
search(params: SearchParams, ctx: Context): Promise<SearchResult>;
statis(params: StatisParams, ctx: Context): Promise<StatisResult>;
/* ---------- 覆盖:写后同步(esAutoSync,见 4.5) ---------- */
add (data: Record<string, any>): [ any, string ];
set (id : string, data: Record<string, any>): [ any, number ];
putAll(ids: string[], data : Record<string, any>): number;
delAll(ids: string[], data?: Record<string, any>): number;
/* ---------- 索引与同步(见 4.5 / 4.6) ---------- */
makeIndex(): Promise<void>;
initIndex(): Promise<void>;
dropIndex(): Promise<void>;
pushMapping(): Promise<string[]>;
syncDocs(docs: any[], opts?: SyncOpts): Promise<SyncStat>;
syncDels(ids: string[], opts?: SyncOpts): Promise<SyncStat>;
syncFind(find?: Record<string, any>, opts?: SyncFindOpts): Promise<SyncStat>;
syncCull(opts : SyncCullOpts): Promise<SyncStat>;
}
/** 注册 / 读取全局默认 ES 客户端 */
export function setEsClient(client: Client): void;
export function getEsClient(): Client | undefined;构造方法只在 Cradle 的 model 之后加一个 es,其余可调项(索引名、合并字段名、分词器、是否自动同步等)一律放在 Schema 扩展选项里,见下节。
4.2 Schema 扩展选项
const userSchema = new Schema({
username: { type: String, textable: true }, // 入全文,wd 可搜
intro : { type: String, textable: true, analyzer: 'ik_smart' }, // 可选,字段级分词器,覆盖 esAnalyzer
remark : { type: String }, // 进 ES 可单独精确查,默认不并入全文
notes : { type: String, syncable: false }, // 不进 ES,find / wd / sort 均不可用
passwd : { type: String, select: false, syncable: false }, // select 只管显示,不入索引须显式关闭
works : { type: [workSchema], nested: true }, // 数组子文档,声明 nested 才保留元素关联
}, {
collection : 'users',
timestamps : true,
esIndex : 'crud_users', // 可选,索引名,默认取 collection
esFullText : 'fullText', // 可选,合并搜索字段名,默认 fullText
esSyncTime : 'syncTime', // 可选,同步戳字段名,默认 syncTime
esAnalyzer : 'ik_max_word', // 可选,分词字段的默认分词器,默认不设(用 ES 的 standard)
esAutoSync : true, // 可选,写入后是否自动同步 ES,默认 true
esSyncError: console.error, // 可选,同步失败回调 (err, info) => void
});Schema 扩展选项:
| 选项 | 默认 | 说明 |
|---|---|---|
| esIndex | collection | ES 索引名 |
| esFullText | 'fullText' | 合并搜索字段名,wd 的查询目标 |
| esSyncTime | 'syncTime' | 同步戳字段名,每次写入 ES 时置为当前时间,见 4.5 |
| esAnalyzer | 无(ES 的 standard) | 索引内所有分词字段(textable 的 .text 子字段与合并字段)的默认分词器,可被字段级 analyzer 覆盖 |
| esAutoSync | true | 写入(add / set / putAll / delAll)后是否自动同步 ES;false 则完全交给定时 syncFind |
| esSyncError | console.error | 同步失败回调 (err, info) => void |
字段扩展项:
| 扩展项 | 默认 | 说明 |
|---|---|---|
| syncable | true | 是否纳入 ES 索引;false 则该字段(容器字段则整棵子树)不进 ES,find / wd / sort 引用时抛 CrudErrno.PARAMS_INVALID,但不影响返回(文档一律回 mongo 取) |
| textable | false | String 是否附 .text 子字段(分词、并入全文,wd 可搜);false 仍进索引、仍可单独 find / sort(主类型 keyword),供备注、日志这类无需全文的长文本使用 |
| nested | false | 数组子文档标 nested: true 才映射为 ES nested(保留元素关联),默认按扁平模式,见 4.4 |
| analyzer | 无 | 字段级分词器,覆盖 Schema 级 esAnalyzer;只对 textable: true 的 String 有效,标在其他字段上视为配置矛盾,构造时抛 CrudErrno.INTERNEL_ERROR |
| termsize | 256 | textable 字段 keyword 主字段的截断阈值(ignore_above):超过的长串不进 keyword,等值匹配本就不可靠;0 不要 keyword 视角(主字段纯 text,只搜不精确匹配,省索引,等值 / 排序 / 聚合静默不命中不报错);-1 不限长(超长串也能精确匹配);仅对 textable 生效,改它需 initIndex() 重建 |
规则:
- 默认全同步:Schema 中所有可映射字段一律纳入索引,仅
syncable: false与不可映射类型(如Map)不进。 select: false的字段默认照常同步:select只管「能否显示」,不管「能否查询」;同步进 ES 后可查、可排序,但不会出现在返回中(cols显式指定时可取出)。确实不该入索引的(如密码)显式加syncable: false。countable: true仍单独决定可否被statis()统计,但字段须先在索引内;与syncable: false并用视为配置矛盾,构造时抛CrudErrno.INTERNEL_ERROR。- 启用
softDelete时,isDeleted/deletedAt不入索引(ES 只留有效文档,见 4.4 / 4.5);timestamps的createdAt/updatedAt自动入索引,可直接排序与范围过滤。 esFullText/esSyncTime是组件内部字段,分别承担全文检索与同步水位,不可在find/sort/statis中引用(引用时同不可映射字段一样抛PARAMS_INVALID);与业务字段撞名时改 Schema 选项避开即可。
分词器:
- 两级配置、就近覆盖:Schema 选项
esAnalyzer定索引默认,字段扩展项analyzer就近覆盖,都不设则不写analyzer,由 ES 用standard(中文按字切分,可满足基本包含式检索)。 - 组件不负责安装分词插件:配了 ES 未安装的分词器,建索引时由 ES 直接报错。
wd的分词只由合并字段(取esAnalyzer)决定:getFullText()拼进去的是原始文本,源字段各自的analyzer不影响wd。- 改分词器要重建索引:已建索引的字段换
analyzer会被 ES 拒绝,只能initIndex()+syncFind(),见 4.6。
4.3 类型映射与全文检索
mongoose 到 ES mapping 的类型推导:
| Mongoose | ES mapping |
|---|---|
| String | keyword(主字段,ignore_above: 256,等值 / 排序 / 聚合走它);标 textable: true 的附 .text 子字段(分词、并入全文),阈值由字段级 termsize 调整,0 无 keyword 视角、-1 不限 |
| String + enum | keyword(枚举值不做分词) |
| Number / Schema.Types.Decimal128 | double |
| Boolean | boolean |
| Date | date |
| Schema.Types.ObjectId | keyword |
| [X] | 按元素类型推导(ES 数组与标量同 mapping) |
| 子文档(非数组) | object,递归推导 |
| [SubSchema] | 默认 object 扁平;标 nested: true 则 nested |
| Map | 不支持,一律跳过(键不可枚举,无法预生成 mapping),无需标 syncable: false |
- mapping 根级与所有
object/nested容器一律dynamic: 'strict':未在 mapping 中声明的字段写入 ES 直接报错。syncDocs按 mapping 裁剪字段,正常路径不会触发,它是防止 mapping 与代码脱节的安全网。 - 索引惰性建立:首次查询或同步前经
makeIndex()检查索引是否存在(结果内存缓存),不存在则按getMapping()创建;已存在则不改动、不校验,避免误改线上索引。
合并字段(全文字段)与 getFullText(doc):
/** 拼装全文内容,写入 esFullText 字段;子类可覆盖以追加标签等派生文本 */
protected getFullText(doc: any): string;wd只查合并字段(默认fullText)这一处,不做multi_match;mapping 中显式定义它为{ type: 'text' },且_source排除该字段(倒排照建、能搜,但不占存储)。- 默认实现按文本字段清单(
getTextable(),即所有标textable: true的 String)逐个取值,扁平化数组、去空、去重后join(' '),效果与copy_to等价,但不用 ES 的copy_to。 - 子类覆盖以追加码值标签、关联名称等派生文本(
copy_to只能拷字段原始值,status: 1拷进去就是"1",搜不到「已发布」,这条路必须自行拼装):
class UserChaser extends Chaser {
protected getFullText(doc: any): string {
return [
super.getFullText(doc), // 默认的文本字段拼接
USER_STATUS[doc.status] || '', // 码值 -> 标签,如 1 -> '已发布'
doc.orgName || '', // 关联名称等派生文本
].filter(Boolean).join(' ');
}
}- 改
getFullText的实现不涉及 mapping,只需syncFind()重写一遍数据即生效,无需重建索引;改某字段的textable会增删.text子字段(mapping 变更),须走initIndex()重建。
4.4 查询行为
find 保持 mongo 风格,内部翻译为 ES DSL,全部进 filter 上下文(不参与打分);支持 $eq / $ne / $gt / $gte / $lt / $lte / $in / $nin / $regex / $exists / $search 与 $and / $or / $not 与字段等值(含 null),不认识的写法抛 CrudErrno.PARAMS_INVALID。其余约定:
- 只有
id没有wd/find的请求直查 mongo(内部走rawSearch):纯取详情无过滤无打分,ES 帮不上忙还多一趟回表;软删除过滤等语义与 mongo 版完全一致。 - 返回文档一律来自 mongo:ES 查询只取
_id与_score,命中 id 回 mongo 取完整文档并按 ES 顺序重排,返回结构与Cradle.search完全一致;cols交 mongo 处理,沿用Cradle的投影与select: false规则。条件、排序、分页、计数一概仍由 ES 完成。 refs参数与 mongo 版一致:关联数据由字段级reference经callFunc补充(见 3.5),Cradle/Chaser行为相同。- ES 命中但 mongo 已无(索引滞后 / 已硬删)的 id 直接跳过,不补位。
wd非空时追加match到合并字段参与打分,并把_score并入结果;wd为空则整个查询不计分。$search:字段级分词匹配(mongo 社区版无此能力),符号对齐 mongo 的$text: { $search }。仅textable: true的 String 可用,ES 侧翻译为match(打.text子字段;termsize: 0的纯 text 字段打主名),operator: 'and'须全部分词命中;分词用字段自己的analyzer,可与$and/$or/$not组合。如find: { body: { $search: 'hello world' } }要求body分词后hello与world同时命中;非 textable 字段或空串抛PARAMS_INVALID。- 排序、分页(
start/limit)、四种mode模式与 mongo 版一致;深翻页(search_after)不在范围内,start + limit超出 ESmax_result_window(默认 10000)直接报错。 - 扁平模式的限制:数组子文档未标
nested: true时按object扁平索引,元素间的关联会丢失,跨字段的联合条件不保证落在同一元素。如find: { 'works.tag': 'a', 'works.qty': 9 }在扁平模式下会误命中「tag=a 与 qty=9 分属两个元素」的文档;要求同一元素同时满足时给该字段标nested: true。 nested字段:查询条件自动按 path 归组合并进同一个 nested query(保证同元素语义),排序自动带nested: { path }与mode(升序min、降序max),statis统计自动内嵌reverse_nested回到父文档计数。- 不加软删除条件:ES 里只有有效文档;「已伪删但 ES 尚未同步」的滞后命中由回 mongo 查询的软删除条件自然过滤(等同跳过)。
自定义排序:getSort 为 protected,负责把外部 sort 翻译为 ES sort 数组(数组序即多级排序优先级),重写它可接入脚本排序。如约定虚拟字段 vFieldx 表示按 field1 * 0.8 + field2 * 0.5 加权排序:
class MyChaser extends Chaser {
protected getSort(sort: Record<string, 1 | -1>): Record<string, any>[] {
const out: Record<string, any>[] = [];
for (const [field, dir] of Object.entries(sort)) { // 按传入顺序落位,虚拟字段在中间就落中间
if (field === 'vFieldx') {
out.push({ _script: {
type : 'number',
script: {
// doc values 缺值须兜底,否则 painless 直接抛错
source: `(doc['field1'].size() > 0 ? doc['field1'].value : 0) * 0.8`
+ ` + (doc['field2'].size() > 0 ? doc['field2'].value : 0) * 0.5`,
},
order : dir === -1 ? 'desc' : 'asc',
missing: '_last',
} });
} else {
out.push(...super.getSort({ [field]: dir })); // 逐字段复用原翻译与校验
}
}
if (out.length) out.push({ _doc: 'asc' }); // 并列兜底,保证分页翻页稳定
return out;
}
}- 调用侧无感:
search({ sort: { vFieldx: -1 } }),可与真实字段混用,优先级即传入顺序。 - 脚本引用的字段必须已入索引(
syncable非 false 且有 doc values);虚拟字段名被剥离,不参与_leaves校验。 _script逐文档计算、无法利用索引序,数据量大时建议物化:重写esDoc()算好加权值存成真实字段(mapping 同步声明),getSort走普通排序。传了sort即不再按_score排,但_score仍并入返回文档。
4.5 同步
写入自动同步(esAutoSync 默认 true):
add/set/putAll/delAll在 mongo 写入成功后同步 ES;setAll/create/update/upsert内部落到这些方法,自动获得同步。- 删除无视
softDelete:ES 侧一律物理删除,索引中只保留有效文档。 - ES 同步失败不回滚、不影响写方法的返回值(mongo 是权威数据源),仅按 Schema 选项
esSyncError告警。 esAutoSync: false时写入不触 ES,改由定时syncFind分批补齐;建议配合softDelete使用——未启用softDelete时硬删的记录定时任务无从发现,只能靠低频的全量syncFind()兜底。
手动与定时同步:
// 直接同步文档(数组),唯一的 ES 写入出口;伪删文档自动转为删除动作
await userCrud.syncDocs([ doc1, doc2 ], { refresh: 'wait_for' });
// 按 id 批量删除
await userCrud.syncDels([ '66b...a01', '66b...a02' ]);
// 只同步某机构下的数据
await userCrud.syncFind({ orgId: '66b...a01' });
// 每天同步「最近 25 小时」的变更,相邻两天有 1 小时重叠,消除时间误差与临界遗漏
await userCrud.syncFind({ updatedAt: { $gte: new Date(Date.now() - 25 * 3600e3) } });
// 全量刷新:扫 mongo 全量覆盖 ES,收尾按同步戳水位清掉孤立记录,无空窗
await userCrud.syncFind();
// 只清理不刷新(明确知道水位时;since 必传,且须早于最近一次全量同步的开始时间)
await userCrud.syncCull({ since: lastSyncStartAt });
// 索引管理:makeIndex 确保存在(幂等,已存在不动)、initIndex 删后重建(改类型 / 分词用,有空窗)、
// dropIndex 删索引、pushMapping 增量推送 mapping
await userCrud.makeIndex();
await userCrud.initIndex();
await userCrud.dropIndex();
await userCrud.pushMapping();同步选项与返回:
interface SyncOpts { // 同步公共选项
refresh?: boolean | 'wait_for'; // 默认 false;写后即读传 'wait_for'
}
interface SyncFindOpts extends SyncOpts {
batch?: number; // 每批文档数,默认 1000
}
interface SyncCullOpts extends SyncOpts {
since : Date; // 水位,删除同步戳早于此时间的文档,必传
}
interface SyncStat {
total : number; // 扫描到的文档数
indexed: number; // 成功写入数
deleted: number; // 删除数
failed : number; // 失败数
errors : any[]; // 失败明细(截断保留前 N 条)
}同步规则:
- 查询同步用 mongo 游标逐个取、攒批(每批
batch条)一次 bulk 提交,文档量大时内存可控;重复同步幂等(index按_id整体覆盖,delete对不存在的 id 不报错),重叠区间安全。 - 全量
syncFind()先记水位 T,扫完全量后补一次syncCull({ since: T }),把「ES 里有、mongo 里已无」的孤立记录清掉,一趟完成补齐与清理且无空窗(不需要先清空索引);失败数不为 0 时跳过收尾清理,以免误删同步失败的文档。 - 单独调
syncCull必须传since,且该时刻要早于最近一次覆盖全量的同步的开始时间,否则会删掉正常数据;不提供默认值就是为了避免误用。 - 增量同步依赖
timestamps的updatedAt做水位,条件由调用方自行拼;Schema 未启用timestamps时只能全量。 - 全量
syncFind()成本远高于增量(扫 mongo 全量并重写整个索引),百万级可每天跑、千万级建议低峰期每天一次、亿级建议每周或按段切分;不要与增量同步混在一个定时任务里。 - 不引入队列与重试机制:失败明细记入
SyncStat.errors,由下一次定时syncFind自然补偿。
4.6 Schema 变更后的索引维护
ES 的硬约束决定了各类变更不能一概而论:
| 变更 | 处理方式 |
|---|---|
| 新增字段 | pushMapping() + syncFind() 回填,无空窗 |
| 删除字段(或改标 syncable: false) | 只需 syncFind() 覆盖旧值;mapping 定义删不掉但留着无害 |
| 改 getFullText 实现 | 只需 syncFind() 重写一遍数据(改 textable / termsize 属于 mapping 变更,走下一行) |
| 改已有字段类型 / 分词 / textable / termsize | 只能 initIndex() + syncFind() 重建(有空窗) |
标准操作序列(全程无空窗、可重复执行、中断重跑即可):
// 1. 推增量 mapping:与索引现有 mapping 做 diff,只补新增字段,不动既有定义
await userCrud.pushMapping();
// 2. 全量刷新:既回填新字段,又覆盖掉已删字段的旧值,同时清掉孤立记录
await userCrud.syncFind();注意:已存在的索引不会被
Chaser自动改动,改过 Schema 后必须显式走上述流程,否则新字段查不到且没有任何报错。pushMapping()必须由本方法推送而非手写 mapping:keyword子字段、nested 结构、dynamic: 'strict'等推导规则都在getMapping()里,手写极易与索引内的既有定义不一致。
5. KV 存储组件(Roster)
Roster 是带有效期的键值存取接口:set 落一条记录,get 取值,过期视同不存在。适合验证码、上传令牌、防重放 nonce 等短命数据。接口与注册器从 subpath hongs-crud/kv 引入,实现在 hongs-crud/kv/mongo(mongoose)与 hongs-crud/kv/redis(node-redis v4),按需选用,主入口 hongs-crud 均不含:
import kv from 'hongs-crud/kv';
await kv.set('token:abc', { uid: 1 }, 300); // 300 秒后失效
await kv.get('token:abc'); // → { uid: 1 }
await kv.getAll('token:abc'); // → { key, value, expiresAt, createdAt, updatedAt }| 方法 | 说明 |
|---|---|
| set(key, value, expires) | 写入并覆盖同 key 记录;expires 为 Date 表示到期时间,为 number 表示多少秒后失效 |
| get(key) | 取值,过期或不存在返回 null |
| getAll(key) | 取完整记录 { key, value, expiresAt, createdAt, updatedAt } |
| getAndDel(key) | 取值并删除,过期或不存在返回 null |
| getAllAndDel(key) | 取完整记录并删除 |
| del(key) | 删除记录,不存在时无效果 |
| cleanup(before?) | 清理 expiresAt 早于 before(默认 7 天前)的记录,返回删除数,Redis 自动删除故总是返回 0 |
KV_ROSTER 值为模块路径(取其默认导出为实现类):模块以 ./、../ 开头时相对 process.cwd() 解析,其余按包名或绝对路径加载。实现类以无参构造自行读环境变量完成配置:
| 环境变量 | 说明 | 默认值 |
|---|---|---|
| KV_ROSTER | 自动注册的模块(默认导出为实现类) | 未设置且未注册时 getRoster() 抛 INTERNEL_ERROR |
| KV_ROSTER_COLLECTION | MongoRoster 存储集合 | rosters |
| KV_ROSTER_REDIS_URL | RedisRoster 自建客户端连接地址 | redis://127.0.0.1:6379 |
| KV_ROSTER_REDIS_PRE | RedisRoster 键前缀 | 空串(不隔离) |
亦可在代码里显式注册(优先于 KV_ROSTER):regRoster(new YourRoster()),之后 getRoster() 获取全局单例。未设置且未注册时首次调用抛 INTERNEL_ERROR。
5.1 MongoRoster(hongs-crud/kv/mongo)
基于 mongoose(主库已有 peer 依赖,无需额外安装)。new MongoRoster(collection?),collection 未传时读 KV_ROSTER_COLLECTION,默认集合 rosters。
过期记录不即时删除:
get/getAll查询时按expiresAt过滤即可,无需写后清理;记录量大了再定期(如每日)调cleanup()。同 key 重复set为覆盖语义,value与expiresAt一并更新,createdAt保留首次。
5.2 RedisRoster(hongs-crud/kv/redis)
基于 node-redis v4(可选 peer 依赖,仅使用本实现时安装)。new RedisRoster(client?, prefix?):client 未传时读 KV_ROSTER_REDIS_URL 自建客户端(默认 redis://127.0.0.1:6379),prefix 未传时读 KV_ROSTER_REDIS_PRE(默认不隔离),用于同 db 内多实例键隔离。自建或传入未连接的客户端时首次操作自动发起连接(惰性连接),传入已连接的客户端则直接使用:
import { createClient } from 'redis';
import { regRoster } from 'hongs-crud/kv';
import { RedisRoster } from 'hongs-crud/kv/redis';
const client = createClient({ url: 'redis://127.0.0.1:6379' });
await client.connect();
regRoster(new RedisRoster(client, 'roster:'));也可完全交给环境变量(
KV_ROSTER+KV_ROSTER_REDIS_URL+KV_ROSTER_REDIS_PRE),getRoster()自动注册,连客户端都无需自建。每键一个 hash(value/expiresAt/createdAt/updatedAt)并带 TTL,到期由 Redis 自身删除,无惰性留档,cleanup()恒返回 0。
License
MIT
