glede-server-bun
v1.0.22
Published
Web业务服务器
Maintainers
Readme
基于配置启动
- base on bun@^1.2.7
- types at GledeServerOpts
- routers tree at
$workspace/logs/routers.txt - server size: ungzip(28kb) / gzip(10.5kb)
初始化项目
# 前提是你必须安装了 bun
bun --version
# 初始化项目(MacOS/Linux)
bunx glede-init
# 初始化项目(Windows)
bun x glede-init
# 或者
npx glede-init
# 如果你想使用最新的脚手架
bunx glede-init@latest
# 或者
npx glede-init@latest
# 进入你的项目目录
cd <your-project>支持 JSON 配置和 TS 配置, 推荐使用 TS 配置。 推荐!TS 配置案例: app-config.ts
Benchmark
⚡️ HTTP: 6~8x faster than [email protected]
⚡️ Websocket/SSE: 3~5x faster than [email protected]
MacOS; Intel-i5 2.9GHz; Memory-DDR4(2666Mhz) 32 GB

// 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 // 默认: /// <reference types="glede-server/types" />
│ └── 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开头, 一般用于开放接口对接三方路由类
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: {
// ...
},
};
}
}通用方法
路由注册
注册不带前缀的路由
非 index 文件或目录会保持大小写被记录到路由中,例如示例中./api/user/index.ts中user会被注册到 /api/user/$subpath。一下示例中 index 是不会注册到路由中的,若注册/index则需装饰器完成需求:@Get('/index')。
routers/open?api|common/index/index.ts
routers/open?api|common/index.ts
严格注册模式
- 除 '/' 路由外,是否携带 / 需注册不同的
RouterHandler
@Get('') 和 @Get('/')监听的是不同的路由,
localhost:3020/user和localhost:3020/user/ 是不同的路由
// 目录: 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
// ./routers/api/xx
export class Router extends GSD.GledeRouter {
@GSD.Get("/test")
test(req: GledeBunRequest, data: GledeReqData) {
// do sth
}
}装饰器介绍
方法装饰器
将 Handler 装载至路由
@Get(url: string, { schema?: GledeGetSchema, version?: string })@Post(url: string, { schema?: GledePostSchema, version?: string })@WS(url: string, { schema?: GledePostSchema, version?: string })
// 支持协商压缩,不再需要手动处理数据传输层面的压缩
// 支持心跳机制,不再需要应用层处理心跳,心跳包不再带有数据载体所以开发者工具看不到meesage帧
// @GSD.WS中的第二个参数配置项均为可选参数,可根据需要自行配置
// @GSD.WS配合@NeedAuth装饰器使用,可实现鉴权功能, 小程序端支持使用请求头传递Authorization: 'Bearer <token>', 浏览器端用url?token=<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);
// 处理消息
agent[data.option](ws, data);
}
}SSE装饰器
@SSE({ mode: 'direct', upgrade: (req, server) => { headers: Headers; } })
设置该装饰器后,
GledeReqData中会注入 controller实例, 通过它发送消息, 并自动关闭连接。若发生异常建议手动调用controller.close()关闭链接更加优雅。它仍然可以和其它装饰器一起工作。
- mode默认为direct模式对应 controller.write, web对应controller.enqueue
- 通过传入upgrade来允许/阻止请求升级,允许请求升级时还设置自定义响应头
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链接, 它会保证数据传输完成后再断开连接你不用担心。
}
}跨域装饰器
设置需要跨域的域名、方法、是否允许携带 cookie。🔔提示:使用跨域后并发降低15%~30%; 开发环境、低并发要求的接口可以使用跨域,
@Cors(origin: string | string[], method: string, credential?: boolean)
鉴权装饰器
身份鉴权(noauth | user | admin | super | root), 是否允许 Handler 处理 Default: noauth
@NeedAuth(role: string)
验签装饰器
签名验证, 是否允许 Handler 处理
@NeedSign()
/**
* 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);
});数据库操作
sql 工具
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 tx`UPDATE 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"})`,
tx`UPDATE 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')`;
});定义数据模型
📢 参考 DEMO: ./tests/controllers/cat.ts cat 对应了数据库中的集合名称 cats, 起名字要使用单数!否则需要指定集合名字
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);操作数据模型
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
// 新建或修改路由文件
// 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: {
// ...
}
};
}
}集成功能
自定义日志输出
// @/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级别)Token 签发与验证
实现分发(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数据库驱动
mongooseioredissqlitepostgresmysql
区域检测
SMTP 邮件发送
黑名单
ip blklist
判黑条件:超管手动添加 / 时间段频率 / 单日访问次数
token blklist
判黑条件:超管手动添加 / 即将过期且验证通过的Token
定时任务
TODO
- 补充用例,给出友好的报错提示。目前一些异常捕获后没有提示,可能会造成疑惑🤔。
- 接Apifox, 自动更新接口文档避免手动维护。
- 丰富装饰器/通用工具。
- 黑名单持久化
- 触发条件: 程序判定新增IP黑名单
- 通知方式: 邮件/机器人通知警告
