Web业务服务器
- base on bun@^1.2.7
- types at GledeServerOpts
- routers tree at $workspace/logs/routers.txt
- server size: ungzip(28kb) / gzip(10.5kb)
``bash前提是你必须安装了 bun
bun --version
支持 JSON 配置和 TS 配置, 推荐使用 TS 配置。 推荐!TS 配置案例: app-config.ts
Benchmark
⚡️
HTTP: 6~8x faster than Express@5.1.0⚡️
Websocket/SSE: 3~5x faster than Nodejs@23.6.0MacOS; Intel-i5 2.9GHz; Memory-DDR4(2666Mhz) 32 GB!压测数据
`ts
// app.ts
import { Server } from "glede-server-bun";Server({ conf: "configs/app-config.ts" }, (err, address) => {
if (err) {
console.log(err);
}
else {
console.log(
GledeServer is running at ${address});
}
});
`模版目录结构
- 在您的项目目录下执行
npm install glede-server 后参考本项目的tests目录创建即可
- 注意引包时使用 import {...} from 'glede-server-bun';
- 注意引用类型使用 import type {...} from 'glede-server-bun';`
├── app.ts // 服务器启动入口
├── configs // 服务器配置
│ ├── app-config.ts // 服务器配置文件 支持ts和json格式, 可配置多个用于区分运行时环境
│ ├── app.json
│ └── lua // lua脚本目录
│ ├── index.ts // lua脚本导出口
│ └── statList.lua // 自定义redis lua脚本
├── tsconfig.json // ts编译配置
├── types // ts类型描述
│ ├── server.d.ts // 默认: ///
│ └── redis-lua.ts // 拓展redis指令类型描述
├── controllers // DAO, 数据操作对象
│ └── cat.ts
├── demos // 基础使用方式
├── crons // 定时事务
│ └── test.ts
├── logs // 日志目录
│ ├── apis.json // 配置开启swagger, 在运行时执行生成覆盖接口文档
│ ├── error.log // 服务运行异常日志
│ └── routers.txt // 最新的路由信息, 服务器的路由树
└── routers // 接口目录
├── api // /api开头, 一般用于业务接口
├── common // /开头, 一般用于通用接口
└── openapi // /openapi开头, 一般用于开放接口对接三方
`路由类
`ts
import { GledeRouter, Get, Post } from "glede-server-bun";export class Router extends GledeRouter {
// 注意方法不要使用箭头函数
// 1. 依赖原型处理逻辑; 2. 注入依赖工具方便处理请求
getAllUser(req: GledeBunRequest, data: GledeReqData) {
// doSomething.
if (noPass) {
return {
// 1 客户端参数校验未通过, 业务无需关心
// >= 2 自定义
code: 2,
data: null,
msg: "描述错误原因",
};
}
// 以下情景等价于返回 {code:0, data: null}
// 1. 无return语句
// 2. return null
// 3. return;
// 4. return undefined
return {
code: 0, // 0 处理成功
data: {
// ...
},
};
}
}
`通用方法
- Schema
- Model
- ObjectId
路由注册
$3
非 index 文件或目录会保持大小写被记录到路由中,例如示例中
./api/user/index.ts中user会被注册到 /api/user/$subpath。一下示例中 index 是不会注册到路由中的,若注册/index则需装饰器完成需求:@Get('/index')。routers/open?api|common/index/index.tsrouters/open?api|common/index.ts$3
- 除 '/' 路由外,是否携带 / 需注册不同的
RouterHandler@Get('') 和 @Get('/')监听的是不同的路由,localhost:3020/user和localhost:3020/user/ 是不同的路由`ts
// 目录: routers/api/post
import { Poster } from "../controllers";export default class extends GSD.GledeRouter {
@GSD.NeedAuth("user")
@GSD.Get("/del/:id", { schema: schema.delPost })
async delPost(req: GledeBunRequest, data: GledeReqData) {
const { token, payload } = req.token;
console.log(payload.role, payload.uid, payload.exp);
// 指定身份 root 0 | super 1 | admin 2 可下架用户文章
if (payload.role < ROLE_USER) {
Poster.deleteOne({ postId: data.params.id });
} else {
Poster.deleteOne({ postId: data.params.id }); // 非管理员, 只能删除自己的文章
}
}
}
`最佳实践
- 为了您能便捷使用 GledeServer 的装饰器,
装饰器和GledeRouter被挂在了全局变量GSD上。
- 日志打印、数据库模型、高复用代码块儿等挂载到global上或统一导出在 GledeServer 初始化前或其他合适时机执行一次。具体操作参考tests/app.ts & tests/components/service`ts
// ./routers/api/xx
export class Router extends GSD.GledeRouter {
@GSD.Get("/test")
test(req: GledeBunRequest, data: GledeReqData) {
// do sth
}
}
`装饰器介绍
$3
> 将 Handler 装载至路由
-
@Get(url: string, { schema?: GledeGetSchema, version?: string })
- @Post(url: string, { schema?: GledePostSchema, version?: string })
- @WS(url: string, { schema?: GledePostSchema, version?: string })`ts
// 支持协商压缩,不再需要手动处理数据传输层面的压缩
// 支持心跳机制,不再需要应用层处理心跳,心跳包不再带有数据载体所以开发者工具看不到meesage帧
// @GSD.WS中的第二个参数配置项均为可选参数,可根据需要自行配置
// @GSD.WS配合@NeedAuth装饰器使用,可实现鉴权功能, 小程序端支持使用请求头传递Authorization: 'Bearer ', 浏览器端用url?token=,后续可支持cookie处理,建议使用url传递token
export class WSRouter extends GSD.GledeRouter {
@GSD.NeedAuth("user")
@GSD.WS("/ws", {
schema: schema.wsSchema, // ajv对?search校验/自动转类型, 不用业务再手动处理性能优越 & 安全可靠
upgrade(req) {
/* 协议升级 /
return {
headers: {
/* 自定义HTTP响应头 /
},
data: {
/* 自定义传递给websocket实例的数据 /
},
};
},
// drain() {/* 处理背压 /},
open(ws) {
ws.id = ws.data.query.sessionId;
},
close(ws) {
/* 处理链接关闭 /
},
ping(ws) {
/* 接收客户端ping帧 /
},
pong(ws) {
/* 接收客户端pong帧 /
},
})
handler(ws: ServerWebSocket, message: string | Buffer) {
// 接收消息
const data = client.ClientDirective.decode(message);
// 处理消息
agentdata.option;
}
}
`$3
-
@SSE({ mode: 'direct', upgrade: (req, server) => { headers: Headers; } })> 设置该装饰器后,
GledeReqData 中会注入 controller实例, 通过它发送消息, 并自动关闭连接。若发生异常建议手动调用 controller.close() 关闭链接更加优雅。它仍然可以和其它装饰器一起工作。- mode默认为direct模式对应 controller.write, web对应controller.enqueue
- 通过传入upgrade来允许/阻止请求升级,允许请求升级时还设置自定义响应头
`ts
export class SSERouter extends GSD.GledeRouter {
@CORS() @SSE()
@GSD.NeedAuth("user")
@POST('/chat/sse', { schema: schema.chatSSE })
chatSSE(req: GledeBunRequest, data: GledeReqData) {
const { controller } = data; controller.write(
data: 你的数据\n\n);
// doSomething(controller); // 把键盘⌨️给你,你来写!
await asyncFunction(controlelr); // 请注意必须在方法退出前或contoller关闭前等待编排处理结束, 一旦路由处理执行结束会中断与客户端的连接
controller.close(); // 完事儿后, 断开SSE链接, 它会保证数据传输完成后再断开连接你不用担心。
}
}
`$3
-
@Multer(opts?: MulterOpts, getOpts: MulterGetOpts)> 处理
multipart/form-data 文件上传,基于 @fastify/busboy 实现流式处理,支持大文件上传(100MB-2GB),内存效率极高。特性:
- 大小限制:支持文件大小、字段数量、部分数量等多维度限制
- 文件过滤:通过
fileFilter 函数过滤文件类型
- 流式处理:使用 pipeline() 自动处理背压,避免内存爆炸
- 自动清理:超过限制时自动删除已上传的文件`ts
export class UploadRouter extends GSD.GledeRouter {
// 单文件上传 - 限制10MB
@GSD.Multer(
{ limits: { fileSize: 10 1024 1024 } }, // 10MB限制
{ single: 'file' } // 单个文件字段
)
@GSD.Post('/upload/single')
uploadSingle(req: GledeBunRequest, data: GledeReqData) {
// data.file 包含上传的文件信息
return {
code: 0,
data: {
filename: data.file.filename,
size: data.file.size,
path: data.file.path,
}
};
} // 多文件上传 - 最多5个文件
@GSD.Multer(
{
dest: 'tmp',
limits: { fileSize: 50 1024 1024 }, // 50MB限制
fileFilter: (req, file, callback) => {
// 只允许图片
const allowed = ['image/jpeg', 'image/png', 'image/gif'];
if (allowed.includes(file.mimetype)) {
callback(null, true);
} else {
callback(new Error('只允许上传图片'), false);
}
}
},
{ array: 'files', maxCount: 5 }
)
@GSD.Post('/upload/multiple')
uploadMultiple(req: GledeBunRequest, data: GledeReqData) {
// data.files 是文件数组
return {
code: 0,
data: data.files.map(f => ({
filename: f.filename,
size: f.size,
}))
};
}
}
`配置选项:
`ts
interface MulterOpts {
dest?: string; // 保存目录,默认 'tmp'
limits?: {
fileSize?: number; // 单文件最大大小(字节)
fieldNameSize?: number; // 字段名最大大小(字节),默认100
fieldSize?: number; // 非文件字段最大值(字节),默认1MB
fields?: number; // 最多非文件字段数
files?: number; // 最多文件字段数
parts?: number; // 最多总部分数(字段+文件)
headerSize?: number; // 头部最大大小(字节),默认81920
};
fileFilter?: (req: any, file: any, callback: (err: Error | null, acceptFile: boolean) => void) => void;
preservePath?: boolean; // 是否保留文件路径,默认false
}type MulterGetOpts =
| { single: string } // 单个文件字段
| { array: string; maxCount?: number } // 文件数组字段
| Array<{ name: string; maxCount?: number }>; // 多个文件字段
`$3
> 设置需要跨域的域名、方法、是否允许携带 cookie。🔔提示:使用跨域后并发降低15%~30%; 开发环境、低并发要求的接口可以使用跨域,
-
@Cors(origin: string | string[], method: string, credential?: boolean)$3
> 身份鉴权(noauth | user | admin | super | root), 是否允许 Handler 处理
> Default: noauth
-
@NeedAuth(role: string)$3
> 签名验证, 是否允许 Handler 处理
-
@NeedSign()`ts
/**
* 1. 客户端 摘要过程
*/// 通过登陆等鉴权接口拿到 'MTcwMjE0MTE0Mzg5M183ODk4.BGZh4oyyHMWAWkiVSJptV5yNb7w'
// 切割取第二部分缓存
const signKey = "BGZh4oyyHMWAWkiVSJptV5yNb7w";
// 切割取第一部分, 需要随请求报文发送到服务端
const content = "MTcwMjE0MTE0Mzg5M183ODk4";
// 要发送的报文体
const payload = JSON.stringify({ name: "Kitty" });
// 同服务端约定的本项目的key
const baseKey = "007";
// 请求方法 uppercase
const method = "POST" as "POST" | "GET";
// /开头的url上的query
const query = "/?test=001";
// 一个空格分割method 和 query
const head = method + " " + query;
function stringify(content) {
if (method === "GET") {
return "";
}
if (method === "POST") {
return typeof content === "string" ? content : JSON.stringify(content);
}
return "";
}
function getSign(head, payload) {
return content + "." + sha1(signKey + baseKey + head + stringify(payload));
}
function sendRequest() {
return fetch("http://localhost:3020/?test=001", {
method: "POST",
headers: {
signature: getSign(head, payload),
},
body: stringify(payload),
}).then((res) => res.json());
}
sendRequest().then((res) => {
console.log(res);
});
`数据库操作
$3
`ts
import { GledeStaticUtil } from "glede-server-bun";const sql = GledeStaticUtil.getPgInstance();
const data = await sql
SELECT * FROM "Job" LIMIT 10;// 建议挂载到全局变量上, 方便使用。
global.sql = sql;
// 开启事务
await sql.begin(async (tx) => {
// All queries in this function run in a transaction
await tx
INSERT INTO users (name) VALUES (${"Alice"});
await txUPDATE accounts SET balance = balance - 100 WHERE user_id = 1; // Transaction automatically commits if no errors are thrown
// Rolls back if any error occurs
});
await sql.begin(async (tx) => {
return [
tx
INSERT INTO users (name) VALUES (${"Alice"}),
txUPDATE accounts SET balance = balance - 100 WHERE user_id = 1,
];
});await sql.begin(async (tx) => {
await tx
INSERT INTO users (name) VALUES (${"Alice"}); await tx.savepoint(async (sp) => {
// This part can be rolled back separately
await sp
UPDATE users SET status = 'active';
if (someCondition) {
throw new Error("Rollback to savepoint");
}
}); // Continue with transaction even if savepoint rolled back
await tx
INSERT INTO audit_log (action) VALUES ('user_created');
});
`$3
> 📢 参考 DEMO: ./tests/controllers/cat.ts
> cat 对应了数据库中的集合名称 cats, 起名字要使用单数!否则需要指定集合名字
`ts
import { Model } from "@/index";// 模型数据结构
const CatSchema = {};
// 模型自定义
const CatOpts = {
// 指定集合名, 此时集合链接到了cat, 默认是cats
collection: "cat",
// 添加便捷方法, 注意不要使用箭头函数!
// 可以这样使用:Cat.findByName('^cool').then(res => {});
statics: {
findByName(name: string) {
return this.find({ name: new RegExp(name, "i") });
},
},
};
export default Model("cat", CatSchema, CatOpts);
`$3
`ts
import Cat from "@/tests/controllers/cat";// 1. 在Cat表中插入一条数据, 后面Demo默认包裹在try-catch中
try {
await Cat.create({
// 插入数据格式必须是CatSchema中定义, 否则字段会被忽略
});
} catch (err) {
/ Handle Error /
}
// 2. 在Cat表中查找一条数据, 随便找一只名叫 cool_xx 且小于2岁的🐱
// 非常不推荐正则, 除非搜索过滤等场景。一般在任何语言中的实现都是最慢最耗性能的模式匹配。
// 不过有的语言实现了正则的缓存, 可能在某些场景下会快。尽量不用吧!
Cat.findOne({
name: new RegExp("^cool_", "i"),
age: { $lt: 2 },
});
// 3. 在Cat表中找到一条匹配的数据,删除
Cat.deleteOne({});
// 4. 在Cat表中找到所有可以匹配删除的数据
Cat.deleteMany({});
// 5. 在Cat表中找到数据并更新, upsert默认为false, 设置为true不存在就插入
// 注意原子操作, filter, { $set: { name: '小小明' } }, options
Cat.updateOne({ name: "明" }, { $set: { name: "小明" } }, { upsert: true });
Cat.updateOne({ name: "小明" }, {}, { upsert: true });
// 所有男生, 分数 +1
Cat.updateMany({ sex: "male" }, { $inc: { score: 1 } });
// 6. 多种操作, 一次通信。性能upup!
// [https://mongoosejs.com/docs/api/model.html#model_Model.bulkWrite]
Cat.bulkWrite([
{
insertOne: {
document: {
name: "Eddard Stark",
title: "Warden of the North",
},
},
},
{
updateOne: {
filter: { name: "Eddard Stark" },
update: { title: "Hand of the King" },
},
},
{
deleteOne: {
filter: { name: "Eddard Stark" },
},
},
]).then(({ insertedCount, modifiedCount, deletedCount }) => {
// 1 1 1
console.log(insertedCount, modifiedCount, deletedCount);
});
`默认记录错误日志
- 默认记录日志, 需要创建对应的目录路径
- 根目录创建文件: logs/error.log
请求需通过 Schema 校验
- 手动创建 Schema
- Schema 校验采用 Ajv6
`ts
// 新建或修改路由文件
// mkdir routers/${api | openapi}/${router | routerDir/index.ts}
// api|openapi目录下存放路由可以是ts文件或目录, 文件内和目录内的Schema定义可相互引用
// 示例 /routers/api/user/index.ts
import { getAllUsersSchema, getAllUsersSchemaV2 } from './schema';export Router extends GledeRouter {
// version是接口的版本用于线上并行, 可选:默认 '', 如果出现版本区分可填写 v1, v2, ...
// schema是参数的拦截校验, 必选:1. 客户端字段安全拦截 2. 增加序列化的性能10%~15% 3. 生成接口文档协同开发
// match: /api/v1/:id
@Get('/:id', { version: 'v1', schema: getAllUsersSchema }) @Cors()
getAllUsers(req: GledeBunRequest, data: GledeReqData): GledeResData {
return {
code: 0,
data: {
// ...
}
};
}
@NeedAuth('super') @Cors('https://philuo.com', 'GET,POST')
@Get('/:id', { version: 'v2', schema: getAllUsersSchemaV2 })
@Post('/:id', { schema })
getAllUsersV2(req: GledeBunRequest, data: GledeReqData): GledeResData {
return {
code: 0,
data: {
// ...
}
};
}
}
`集成功能
$3
`ts
// @/utils/log.ts
import { GledeStaticUtil } from "glede-server-bun";
import { join } from "path";export const logger = new GledeStaticUtil.Logger({
// 输出位置, 默认[1]输出到日志文件; [0]输出到控制台, [0, 1]输出到控制台和文件
target: [1],
// 日志输出的目录, 默认存储在运行node的路径下的logs路径下
// !import 注意服务运行中不可以删除 dir目录
dir: join(__dirname, "logs"),
// 日志文件名 默认 glede-server.log 如果开启轮转会自动补充后缀
// !import 注意服务运行中不可以删除 filename文件, 其他轮转生成的文件可以移动或删除
filename: "glede-server.log",
// 日志轮转, 到期生成新的日志文件格式如下 20231210-1411-03-glede-server.log
interval: "30d",
// 日志大小, 超限生成新的日志文件格式如下 20231210-1411-03-glede-server.log
size: "10M",
// 控制单个文件大小, 注意开启压缩再使用 超过限制后旧文件会被压缩
// maxSize: '10M',
// 是否开启压缩, 默认关闭 不允许设置false, 关闭不设置该属性即可
// compress: true
// 最多保留的最近的日志文件和压缩包数量, 默认全部保留不设置即可
// maxFiles: 30
});
logger.error("123"); // level === 0
logger.warn("123"); // level === 1
logger.info("123"); // level === 2
logger.log("123", 2); // 仅输出到控制台, 不干扰日志文件(level可选默认2 INFO级别)
`$3
- 实现分发(sign, unsign)
- 实现校验(verify)
`
if not 过期 -> if not 快过期 -> if match 身份 -> if not 是否篡改 -> if not blklist -> ok
else fail -> else -> else fail -> else fail -> else fail
if ok then blklist and return data with new token
if not ok then fail
`$3
-
mongoose-
ioredis-
sqlite-
postgres-
mysql$3
$3
$3
-
ip blklist判黑条件:超管手动添加 / 时间段频率 / 单日访问次数-
token blklist判黑条件:超管手动添加 / 即将过期且验证通过的Token`- 补充用例,给出友好的报错提示。目前一些异常捕获后没有提示,可能会造成疑惑🤔。
- 接Apifox, 自动更新接口文档避免手动维护。
- 丰富装饰器/通用工具。
- 黑名单持久化
- 触发条件: 程序判定新增IP黑名单
- 通知方式: 邮件/机器人通知警告