从 Mini NocoBase Demo 看无代码平台的微内核与插件化设计
最近整理了一个 mini_nocobase_learning_demo,它不是 NocoBase 的源码复刻,而是用 Python 标准库写出来的一个学习版微内核。项目规模很小,但把无代码平台里最关键的几件事串起来了:动态表、Resource Action、权限、UI Schema、工作流、审批、图表、文件和导入导出。
这类 demo 的价值不在于“又写了一套 CRUD”,而在于它能帮助我们看清一个无代码平台到底在抽象什么。
普通业务系统通常是把能力直接写死在代码里:
代码里写用户表
代码里写订单表
代码里写订单接口
代码里写订单页面
代码里写审批逻辑
代码里写权限判断
无代码平台的思路不一样。它更像一套可注册、可解释、可组合的运行时:
核心只提供注册点
插件负责注册能力
元数据描述表结构
UI Schema 描述页面
Resource Action 描述接口动作
ACL 描述权限
Workflow Definition 描述流程
也就是说,平台核心不应该知道“订单”是什么,也不应该知道“报销审批”是什么。它只需要知道:有人注册了一张表、注册了一批动作、注册了一套权限、注册了一些 UI 区块和流程节点。运行时根据这些注册表把能力组合起来。
微内核:Core 不写业务
在这个 demo 里,App 是整个系统的入口,但它本身很薄:
class App:
def __init__(self, *, db_path=":memory:", storage_dir=None):
self.acl = ACL()
self.ui = UIRegistry()
self.collections = CollectionManager(self)
self.resources = ResourceManager(self)
self.workflow = WorkflowEngine(self)
self.events = EventBus(self)
self.plugins = PluginManager(self)
这里没有 CRM、订单、审批、图表这些业务概念,只有几个运行时组件:
ACL 权限注册表
UIRegistry UI 能力注册表
CollectionManager 元数据和动态表管理
ResourceManager Resource Action 分发
WorkflowEngine 工作流执行器
EventBus 事件总线
PluginManager 插件生命周期管理
这就是微内核架构的味道:核心提供机制,业务通过插件进入系统。
如果你以后要设计一个可扩展平台,最先要问的不是“我要不要加一个订单模块”,而是“订单模块需要挂到哪些注册点上”。这个问题一变,代码组织方式就会变。
元数据驱动:表结构不是写死的
无代码平台一定绕不开动态数据建模。用户在页面上新增一个数据表,后端必须能够把它变成真实可查询、可写入的数据结构。
demo 里用 sys_collections 和 sys_fields 保存元数据:
sys_collections 保存 collection 名称、标题、来源插件
sys_fields 保存字段名、字段类型、控件类型、必填、默认值、关联配置
同时,每个 collection 会映射成真实业务表:
customers -> biz_customers
orders -> biz_orders
files -> biz_files
当插件注册订单表时,大概是这样的:
app.collections.define_collection(
"orders",
"订单",
[
{"name": "order_no", "type": "string", "title": "订单号"},
{"name": "customer", "type": "belongsTo", "target": "customers"},
{"name": "amount", "type": "decimal", "title": "金额"},
],
plugin="crm",
)
它背后做了几件事:
1. 写入 sys_collections
2. 创建 biz_orders 真实表
3. 写入 sys_fields 字段元数据
4. 给真实表 ALTER TABLE ADD COLUMN
后续新增字段也是同一个思路:先写元数据,再同步物理表。
这里还有一个很容易被忽略的安全点。动态 SQL 不能只靠参数绑定,因为表名、字段名不能作为普通参数传入。值可以参数绑定,identifier 必须做白名单校验:
IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def safe_ident(name: str) -> str:
if not IDENT_RE.match(name):
raise ValueError(f"unsafe SQL identifier: {name!r}")
return name
无代码平台经常要把用户配置变成 SQL,这个边界必须非常明确:
SQL 值:参数绑定
表名和字段名:白名单校验后再拼接
否则动态建模会直接变成动态注入风险。
Resource Action:接口也可以注册出来
NocoBase 风格的接口不是传统 REST 路径,而是更偏命令式:
/api/orders:list
/api/orders:get/1
/api/orders:create
/api/orders:update/1
/api/orders:destroy/1
/api/orders:salesByStatus
/api/approval:submit
这个形式的好处是统一。CRUD、图表聚合、审批提交、文件上传、导入导出,本质上都可以抽象成:
resource + action
插件可以注册一个 action:
app.resources.register_action("orders", "salesByStatus", handler, "echarts")
也可以给某个 collection 一次性注册通用 CRUD:
app.resources.register_collection_crud("orders", "crm")
请求进来后,ResourceManager 做统一分发:
1. 解析 /api/orders:create
2. 得到 resource=orders, action=create
3. 检查 ACL
4. 注入 data scope
5. 找到 handler
6. 执行 handler
7. 包装响应
这个设计的关键不只是“路由好注册”,而是它把平台里所有可调用能力都放进了同一个协议里。前端按钮、区块动作、工作流节点、导入导出入口,都可以指向某个 resource action。
ACL:权限必须进入后端查询
很多系统会把权限理解成“前端按钮显不显示”,但这在平台型系统里远远不够。只要接口还在,用户绕过前端直接请求就可能越权。
demo 里的 ACL 支持 role/resource/action 规则,也支持简单的数据范围:
app.acl.allow("admin", "*", "*")
app.acl.allow("editor", "orders", "list", data_scope={"owner_id": "$user_id"})
app.acl.allow("viewer", "orders", "get")
当 editor 查询订单时,ResourceManager 会先做权限检查,再把数据范围注入请求:
editor list orders
↓
ACL 检查通过
↓
注入 data_scope: owner_id = 当前用户
↓
CollectionManager.list 合并 filter
↓
生成带 WHERE 的 SQL
这条链路非常重要。权限不应该停留在 UI 层,而应该进入后端查询层。尤其是无代码平台,用户可以动态建表、动态配置页面、动态开放接口,如果 ACL 不在统一分发层执行,后面每个插件都会重复踩坑。
UI 也能插件化
很多人说到插件系统,只会想到后端接口。但无代码平台里,前端能力同样需要注册。
demo 里的 UIRegistry 维护几类配置:
blockTypes 区块类型,例如 table、form、chart
fieldInterfaces 字段控件,例如 input、select、recordPicker
actionTypes 动作类型,例如 submitApproval、export
pages 页面 schema
插件可以这样注册一个页面:
app.ui.register_page(
"crm",
{
"title": "CRM",
"blocks": [
{"type": "table", "collection": "customers"},
{"type": "table", "collection": "orders"},
],
},
"crm",
)
真实前端会读取类似 manifest 的结构,然后知道系统里有哪些页面、哪些区块、每个区块绑定哪个 collection、哪些字段应该使用哪个控件。
所以 UI 不是写死的页面文件,而是插件注册出来的 schema。插件不只是在后端多一个 API,它也可以给前端多一种字段控件、多一种区块、多一个动作按钮。
事件和工作流:业务变化后的第二条链路
平台型系统里,创建一条业务记录通常不是终点。订单创建后要通知管理员,报销提交后要生成审批任务,文件上传后可能要做扫描,客户更新后可能要写审计。
如果这些逻辑都塞进 CRUD 里,核心会越来越重。
demo 的处理方式是事件总线加工作流:
CollectionManager.create("orders")
↓
INSERT INTO biz_orders
↓
emit orders.created
↓
EventBus 通知监听器
↓
WorkflowEngine 匹配 trigger
↓
按 workflow steps 执行节点
Workflow 本身也不写死节点类型,而是让插件注册:
app.workflow.register_node_type("set_field", set_field, "workflow")
app.workflow.register_node_type("notify", notify, "notification")
订单创建后的流程可以用 JSON 描述:
app.workflow.create_workflow(
name="订单创建后通知管理员",
trigger="orders.created",
steps=[
{"type": "set_field", "config": {"field": "status", "value": "submitted"}},
{"type": "notify", "config": {"user_id": 1, "message": "新订单 {order_no}"}},
],
plugin="crm",
)
这里体现了一个很典型的解释器模式:工作流定义是数据,运行时读取这份数据,再通过 node registry 找到真正的执行函数。
插件生命周期:能力挂载需要顺序
插件不是简单 import 一下就完事。它需要依赖、加载顺序和生命周期。
demo 里的插件有这些钩子:
after_add
before_load
load
install
after_enable
after_disable
remove
通常可以这样理解:
before_load 先注册 collection、字段、基础模型
load 再注册 API、ACL、UI、workflow 节点、事件监听
install 最后初始化默认数据或默认流程
比如一个 CRM 插件可能会:
before_load:
注册 customers 和 orders 表
load:
注册 customers/orders CRUD
注册订单图表 action
注册 CRM 页面
注册 editor/viewer/admin 权限
install:
创建订单创建后的默认 workflow
PluginManager.enable("crm") 会先递归启用依赖插件,再依次执行生命周期。这样 UI 核心、用户系统、工作流、通知这些基础能力先准备好,CRM 插件才能把自己的能力挂上去。
一条订单请求如何串起所有机制
看一条订单创建请求,平台能力就串起来了:
/api/orders:create
↓
ResourceManager.parse_path
↓
ACL.can(editor, orders, create)
↓
CollectionManager.create("orders")
↓
读取 orders 字段元数据
↓
校验字段、处理默认值、动态 INSERT
↓
写入 biz_orders
↓
emit orders.created
↓
WorkflowEngine 执行订单创建流程
↓
set_field 把状态改成 submitted
↓
notify 创建管理员通知
↓
返回订单记录
这条链路里,订单本身只是一个插件定义出来的 collection。接口是注册出来的,权限是注册出来的,事件是通用机制发出的,工作流节点也是插件注册的。
这就是从“写业务系统”到“写平台”的区别。
审批为什么不应该写死在业务表里
审批是另一个很好的例子。
如果你只做一个报销系统,很容易把审批字段写在报销单表里:
expense_requests.status
expense_requests.approver_id
expense_requests.approved_at
但平台化以后,审批应该是一套独立运行时。它通过 collection + record_id 指向任意业务记录:
approval_tasks
collection = expense_requests
record_id = 123
approver_id = 3
status = pending
提交审批时:
/api/approval:submit
↓
读取目标业务记录
↓
创建 approval_tasks
↓
保存业务快照
↓
更新原业务记录状态为 approving
↓
通知审批人
审批通过时:
/api/approval_tasks:approve
↓
检查当前用户是否是审批人
↓
更新 approval_tasks.status = approved
↓
写 approval_actions 历史
↓
更新原业务记录状态为 approved
这样审批插件就不属于某一张表,而是可以服务订单、报销、合同、请假等任意 collection。
这个 demo 还不是生产系统
学习版 demo 的目标是把机制讲清楚,不是直接当生产框架用。如果要把这套思路真正落地,还要补很多工程能力。
第一是数据库迁移。ALTER TABLE ADD COLUMN 只能覆盖最简单的动态字段新增,生产里还要处理字段重命名、类型变更、索引、唯一约束、大表迁移、回滚、多实例迁移锁和插件版本 migration。
第二是事务边界。订单创建后触发工作流,工作流又更新订单、创建通知。这里要明确:工作流失败是否回滚订单?通知失败是否影响主流程?审批提交失败时业务状态如何恢复?学习版可以简单处理,生产系统必须设计清楚。
第三是插件卸载。禁用一个插件容易,卸载一个插件很难。要不要删除业务表?要不要保留数据?其他插件依赖它怎么办?历史审批任务引用了它的 collection 怎么办?这些都不是一个 remove() 钩子能简单解决的。
第四是安全和性能。动态 filter、动态 sort、关联 appends、数据范围权限、工作流执行、图表聚合都可能成为慢查询来源。文件上传、Workflow HTTP 节点、插件扩展点也都需要额外的安全边界。
所以这个 demo 更适合作为架构骨架学习:先理解注册表和运行时怎么协作,再考虑生产级实现。
可以带走的几个结论
这套 mini demo 最值得记住的不是 Python 代码,而是几个架构判断。
微内核只提供机制,不写具体业务。插件通过生命周期把 collection、resource action、ACL、UI schema、workflow node、event listener 挂进系统。
Collection 和 Field 是元数据,但业务数据应该进入真实物理表。只有这样,查询、索引和迁移才有进一步优化空间。
Resource + Action 是统一调用协议。它能把 CRUD、图表、审批、文件、导入导出都放进同一种分发模型里。
ACL 必须在后端统一执行,并且要进入数据查询层。前端隐藏按钮只是体验优化,不是安全边界。
Workflow 是事件驱动的解释器。流程定义是数据,节点能力来自插件注册,运行时根据事件触发并逐步解释执行。
审批、图表、文件、导入导出都不应该变成 core 的一部分。它们应该证明 core 的扩展点设计是否足够稳。
一句话总结:
无代码平台的核心不是帮你少写几个 CRUD,而是把“数据、接口、页面、权限、流程”都变成可注册、可解释、可组合的运行时能力。
如果要用 Go、Java 或其他语言重写类似后端,也不要一上来就写业务接口。先把这些 registry 和生命周期设计好:
PluginManager
CollectionRegistry
ResourceRegistry
ACLRegistry
UIRegistry
EventBus
WorkflowEngine
StorageRegistry
NotificationRegistry
这些骨架稳定以后,CRM、ECharts、Workflow、Approval、File、ImportExport 才能自然成为插件,而不是慢慢把核心拖成一个巨大的业务泥球。
Enjoy Reading This Article?
Here are some more articles you might like to read next:
- 一次接口文档站工程化实践:VitePress、Swagger 与 Cloudflare 部署踩坑记录
- 从插件系统到微内核平台:一篇从入门到进阶的 NocoBase 架构笔记
- 给 FastAPI 后台模板补了一轮生产化能力
- 现代大语言模型的架构细节:从 RMSNorm 到 Loss 计算
- 从零写一个 AI 编程助手:MiniCode 的设计笔记
- 强化学习算法笔记:用一套框架串起 MC、TD、DQN、PPO、SAC
- 一个分布式锁没能拦住的重复下发问题
- 搞清楚 BatchNorm、LayerNorm、RMSNorm 到底在干嘛
- 强化学习基础笔记
- FastAPI-Template 实践笔记:以依赖注入管理请求生命周期