从生成式 ai 出来之后,低代码的热潮又被带动了一波。低代码可以实现少写代码,或者不写代码的方式来搭建一个稳定可用的系统。对于企业而言减少开发成本。
但是对于我自己来说,不怕难,但怕烦。公司内部业务系统较多,如果都要自己实现 也未免太麻烦了一点,人生苦短。应该把时间放在更有意义的事情上。
最近公司内部需要一个业务平台,来记录和统计一些数据,刚好借着这个机会,去了解一下。
我的需求比较明确:
- 只考虑开源项目
- 部署简单
- 完善的鉴权体系
最终我找到了一个用 golang 实现的开源低代码引擎 yao,为什么说这是一个引擎,而不是一个低代码框架或者平台?因为这个并不是像 illacloud 那样的 sass 平台。它的实现方式比较特别,总的来说是利用 json 做的 DSL,从界面到数据处理 都是通过 json 来配置的,除了自定义脚本,脚本是通过 javascript 实现的。
踩坑记录
确定了开源项目之后,花了一天的时间看了 yao 引擎官方的几个 demo 后,我就开始上手了。不过越写到后面,越发现 DSL 学习成本有点高。主要官方的文档没有及时更新,文档和 demo 里面的代码有些地方差距较大。这里浪费了一些时间,最终我是 fork 了一份代码到本地。看着 golang 里面的 DSL 定义来配置 json。
登陆信息获取
yao 引擎的登陆系统存在两个逻辑,一个是平台自己的账号,一个是业务自己的账号。平台的账号可以叫做 admin,业务的账号叫做 user,这是两个不同的路由。这里会有一个问题,系统账号和业务账号登录进去后,页面是一样的,并不能控制权限,只能控制数据(当然这个过程也比较麻烦,后面会写出)。
所以我这里是管理员登陆逻辑和业务登陆逻辑保持一致。把 logins 文件夹下面的 两个文件内容保持一致即可
- logins/user.login.json
- logins/admin.login.json
上面说到的是一些小问题,无伤大雅。最关键的问题是登陆信息获取这个地方有 bug,用户登陆完毕之后,一个完整的业务系统是需要获取到用户信息的。这个引擎虽然实现了 session 相关的操作 process,但是并不怎么好用。设置了之后取出是空的。
let sid = Process("session.Start");
let jwt = Process(
"xiang.helper.JwtMake",
user.id,
{
id: user.id,
role: user.role,
staff_name: user.staff_name,
dept_id: user.dept_id,
},
{ timeout: 30 * 24 * 60 * 60, sid: sid }
);
delete user.password;
//关键代码
Process("session.Set", "user", user, 30 * 24 * 60 * 60);
这里是处理用户登陆的核心代码,在校验完毕账号和密码之后,需要创建 token(jwt)有效期设置为30天,然后给到 session 当中。
这段代码比较简单,在登陆之后把用户数据存储到 session 中应该就可以了。但是我在后面到步骤中,一直无法获取到数据,为空。
let user = Process("session.Get", "user");
// user: null
这里浪费了大量的时间之后,我换了一种实现方式。看见 yao 引擎有一个 store 存储的 process,相当于存储数据到内存中。 那么我把用户登陆的数据和 session id 存储到内存中不就可以了,相当于一个 map。
let sid = Process("session.Start");
new Store("cache").Set(sid, user);
//获取
let user = new Store("cache").Get(Process("session.ID"), "user");
//{id: xxx,name:xxx}
数据权限控制
一个业务系统,根据用户的权限来显示不同的数据是一个很常见的需求,这里介绍下方法。
这是默认表格使用的 process
Process("models.user.Paginate", param, 1, 1000)
如果我们要自定义数据查询方式,这里有三种方式。
使用 DSL 语法增加查询条件
"search": {
"default": [
{
"orders": [{ "column": "event_date", "option": "desc" }],
"user_id[]": []
}
]
}
这种方式一般来说,是首选的方式。但是我并不推荐这样用,首先是但是官网文档晦涩难懂,并没有给出更多的例子 以上只是做了一个默认的排序操作我找文档都找了很久,最终还是在 demo 中看到这种写法 。另外这种方式并不能设置查询条件默认值,可能是通过 querystring 来控制。但是 querystring 我们无法控制,是系统内部的路由处理的。
使用 after:search
// 先配置 DSL "after:search": "scripts.user.AfterSearch"
function AfterSearch(data) {
//获取用户权限
let user = new Store("cache").Get(Process("session.ID"), "user");
if(user.role == 'normal') {
//修改 data
}
return data;
}
使用 hook 函数修改返回的数据,但是这种方式会导致分页信息异常。不推荐
重写查询接口
"search": {
"guard": "bearer-jwt,scripts.user.Guard",
"process": "scripts.wakatime.Search",
"default": [
{
"orders": [{ "column": "event_date", "option": "desc" }],
"user_id[]": []
}
]
}
能看到这一部分的 DSL 多了一些内容,其中最关键的是 process
,这里定义了我们处理查询的 process 相关实现如下:
function Search(param, page, pageSize) {
let user = new Store("cache").Get(Process("session.ID"), "user");
if (param.wheres) {
if (user.role == "管理员") {
var query = new Query();
let users = query.Get({
select: ["id"],
wheres: [
{ ":deleted_at": "删除", "=": null },
{ field: "dept_id", op: "=", value: user.dept_id },
],
from: "e_user",
});
let userid = users.map((e) => e.id);
param.wheres.push({
column: "user_id",
op: "in",
value: userid,
});
} else {
param.wheres.push({ column: "user_id", value: user.id });
}
}
var data = Process(
"models.wakatime.Paginate",
{
...param,
},
page,
pageSize
);
return data;
}
要让这段脚本正常的执行,还需要一个关键的参数,也就是在 DSL 中的这段代码:
"guard": "bearer-jwt,scripts.user.Guard",
由于登录信息无法获取,我这里使用的是会话缓存的方式实现。这里的意思是增加了一个中间件,bearer-jwt
是系统默认的,第二个是scripts.user.Guard
这是自定义的授权,相关实现如下:
function Guard(path, params, query, payload, headers) {
let token = headers.Authorization[0];
let data = Process("xiang.helper.JwtValidate", token.replace("Bearer ", ""));
let sid = Process("session.ID");
new Store("cache").Set(sid, data.data);
}
那么每一次获取列表之前,都会先到我这个中间件来,这里会处理 http 请求中携带的授权信息,根据授权信息取出用户信息存储在会话缓存中。
那么为什么这一步不能写在 scripts.wakatime.Search
中?我也想这么干,但是在查询 process 中无法获取到 token header。所以只能写一个中间件在查询之前先把用户信息获取到,然后在查询脚本中 根据会话 ID 获取用户信息。
源码编译
这个引擎并不能满足我的需求,小问题有点多, 系统并不能正常使用的,起初我加了他们的群,问了下开发人员,并不能很好解决问题,比如
- Select 默认缓存了,只有在开发环境中才不会缓存
- Select 无法搜索
- 只能导出数据库中的记录,不能进行数据处理
- 统计图 饼图显示的数值不是总计,而是最后一条数据的值
- 无法去掉登录验证码
- 弹窗保存事件有时候会触发很多次
前面两个 Select 的问题 0.10.3 pre 已经解决了,后面我更新了下源码,准备自己修改后面几个问题 ,源码是基于 0.10.3 pre 版本。
yao 引擎主要的核心库有以下几个
- yao
- v8go
- xun
- kun
- xgen
- gou
我这次需要修改的主要是 yao 和 xgen 前面是 yao 核心库,xgen 基于 react 实现的前端页面。
自定义导出
看了下官方的导出实现,只能导出 table 数据。我想先从数据库中统计一些数据,然后再导出,并且会增加一些颜色标记等。
导出流程是先触发脚本,生成 excel 文件并放入指定的目录,然后通过 http 请求去下载文件。
新建文件
helper/excel.go
package helper
import (
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
"github.com/yaoapp/gou"
"github.com/yaoapp/yao/config"
"path/filepath"
)
type Payload struct {
Columns []struct {
Name string `json:"name"`
} `json:"columns"`
Data [][]string `json:"data"`
Options struct {
TitleRow int `json:"titleRow"`
ColWidth int `json:"colWidth"`
OtherStyle string `json:"otherStyle""`
HeadStyle string `json:"headStyle""`
BodyStyle string `json:"bodyStyle""`
} `json:"options"`
}
func ProcessExcel(process *gou.Process) interface{} {
process.ValidateArgNums(0)
body := Payload{}
json.Unmarshal([]byte(process.ArgsString(0)), &body)
fmt.Println(body.Options.BodyStyle)
// 创建一个新的工作簿
f := excelize.NewFile()
// 创建一个工作表
sheetName := "Sheet1"
var titleStyle = -1
var bodyStyle = -1
var otherStyle = -1
if len(body.Options.HeadStyle) > 0 {
titleStyle, _ = f.NewStyle(body.Options.HeadStyle)
}
if len(body.Options.BodyStyle) > 0 {
bodyStyle, _ = f.NewStyle(body.Options.BodyStyle)
}
if len(body.Options.OtherStyle) > 0 {
otherStyle, _ = f.NewStyle(body.Options.OtherStyle)
}
var titleRow []string
for _, column := range body.Columns {
titleRow = append(titleRow, column.Name)
}
for i, v := range titleRow {
cellName, _ := excelize.CoordinatesToCellName(i+1, body.Options.TitleRow)
f.SetCellValue(sheetName, cellName, v)
if titleStyle > -1 {
f.SetCellStyle(sheetName, cellName, cellName, titleStyle)
}
}
if body.Options.TitleRow > 1 && otherStyle > -1 {
for i := 0; i < body.Options.TitleRow; i++ {
for col, _ := range titleRow {
cellName, _ := excelize.CoordinatesToCellName(col+1, i)
f.SetCellStyle(sheetName, cellName, cellName, otherStyle)
}
}
}
numRow := body.Options.TitleRow + 1
for i, dataRow := range body.Data {
rowNum := i + numRow // 数据行
for index, v := range dataRow {
cellName, _ := excelize.CoordinatesToCellName(index+1, rowNum)
f.SetCellValue(sheetName, cellName, v)
if bodyStyle > -1 {
f.SetCellStyle(sheetName, cellName, cellName, bodyStyle)
}
}
}
if body.Options.ColWidth > 0 {
for i := 1; i <= len(body.Data); i++ {
colName, _ := excelize.ColumnNumberToName(i)
err := f.SetColWidth("Sheet1", colName, colName, float64(body.Options.ColWidth))
if err != nil {
fmt.Println(err)
}
}
}
uuid, _ := uuid.NewRandom()
filename := fmt.Sprintf("%s.xlsx", uuid.String())
path := filepath.Join(filepath.Join(config.Conf.Root, "data"), filename)
fmt.Println(path)
// 保存工作簿
if err := f.SaveAs(path); err != nil {
fmt.Println(err)
}
return filename
}
注册到处理器
helper/process.go
//Excel 处理器
gou.RegisterProcessHandler("utils.biz.excel", ProcessExcel)
使用
//payload 就是我需要导出的业务数据
Process("utils.biz.excel", JSON.stringify(payload))
效果
增加中文转拼音
业务需要支持拼音搜索数据,js 汉字转音频需要安装一些库,v8go 是不支持的,所以可以新增一个处理器来做这件事情。
新增文件
helper/pinyin.go
package helper
import (
"github.com/mozillazg/go-pinyin"
"github.com/yaoapp/gou"
)
func ProcessPinYin(process *gou.Process) interface{} {
process.ValidateArgNums(1)
hans := process.ArgsString(0)
args := pinyin.NewArgs()
return pinyin.LazyPinyin(hans, args)
}
注册处理器
//自定义增加的处理器
gou.RegisterProcessHandler("utils.biz.pinyin", ProcessPinYin)
使用
function save(payload){
let py = Process("utils.biz.pinyin", payload.project_name);
payload.pinyin_key = py.join("");
let result = Process("models.project.save", payload);
}
解决统计图 NumbearChart 值错误
这里的值应该是累加的合,而不是最后一个数据的值
修改文件: xgen/components/chart/NumberChart/index.tsx
const total = props.data
.map((e) => e[props.valueKey || 'value'])
.reduce((accumulator, currentValue) => accumulator + currentValue, 0)
<BaseNumber {...props} number={total}></BaseNumber>
效果
左边的时间之前是最后一条数据的值。
新窗口打开后 token 消失
在同一个浏览器上,登录成功之后 复制地址重新打开 tab 会发现 token 失效。具体的原因我没有去查,为了快速解决问题,我这里使用了 localStorage 来存储 token,修改了 token 相关逻辑。这个问题算是暂时解决了。
Select 远程搜索太慢
这里官方用了一个抖动函数,但是对于我来说 这里有点太慢了,打完字之后要等个一段时间才出结果。
修改源码: xgen/components/edit/Select/model.ts
//line 30 这里之前是等待了 800 ms
target['onSearch'] = debounce(this.remote.searchOptions, 100, { leading: true })
去掉业务助手和登录验证码
在 0.10.3 pre 版本中,自带了业务助手,当时还没有正式发布,但是业务并不需要这个东西,暂时无法关闭,我只有把这个入口给删除了。
修改文件: xgen/packages/xgen/layouts/index.tsx
这里把 Neo 业务助手注释即可
{/* <Neo {...props_neo}></Neo> */}
登录验证码的移除需要修改两个地方,一处是验证器,一处是表单,但是改动有4个文件 。
修改文件:
xgen/packages/xgen/pages/login/model.ts
// line 48
async afterLogin(res: ResLogin, err: Utils.ResError) {
if (err || !res?.token) {
this.loading.login = false
// this.getCaptcha()
return
}
...
}
//line 93
onFinish(data: FormValues) {
const { mobile, password, code } = data
const is_email = mobile.indexOf('@') !== -1
if (is_email) {
if (!reg_email.test(mobile)) {
return message.warning(this.global.locale_messages.login.form.validate.email)
}
} else {
if (!reg_mobile.test(mobile)) {
return message.warning(this.global.locale_messages.login.form.validate.mobile)
}
}
// captcha: {
// id: this.captcha.id,
// code
// },
this.login({
[is_email ? 'email' : 'mobile']: mobile,
password: password,
sid: local.temp_sid,
...(this.is ? { is: this.is } : {})
})
}
修改文件: xgen/packages/xgen/pages/login/types.ts
// line 17
export interface ReqLogin {
email?: string
mobile?: string
is?: string
sid: string
password: string
// captcha: {
// id: string
// code: string
// }
}
修改文件: xgen/packages/xgen/pages/login/admin/index.tsx
// line 9
const Index = () => {
const [x] = useState(() => container.resolve(Model))
useAsyncEffect(async () => {
await window.$app.Event.emit('app/getAppInfo')
x.user_type = 'admin'
// x.getCaptcha()
}, [])
return <Common type='admin' x={x}></Common>
}
修改文件: xgen/packages/xgen/pages/login/components/Common/components/Form.tsx
//line 91
<Item noStyle shouldUpdate>
{() => (
<Button
className={clsx([
'btn_login',
!(
(getFieldValue('mobile') && getFieldValue('password'))
//&& getFieldValue('code')
) && 'disabled'
])}
type='primary'
htmlType='submit'
shape='round'
loading={loading}
>
{messages.login.title}
</Button>
)}
</Item>
效果
最后
项目目前稳定跑了一段时间了,没有出现过什么问题,但是实现的过程并不是那么的顺利,要实现一个完整的业务系统,我相信上面的需求是最基本的。
还有一些权限的修改,让页面可以支持权限显示和隐藏一些功能,在这之前只能控制的只有数据。以及增加快速录入数据的页面 等等 这其中代码中关联了公司业务,所以这里就不贴出来了。
之前看过 illacloud ,但是这个东西并不那么简单,要实现一个完整业务系统可能比较困难,我最在意的 ui 特别丑,无法调试,比如一个菜单放上去 就那个样式了,无法调整字体大小,边框 菜单背景 等等。
illacloud 这种可能更适合单个页面快速的展示一些数据,页面之间是无状态的,功能比较强大,还支持训练业务数据然后进行提问,类似 chatgpt。
很感谢 yao 开源项目,让我了解低代码引擎的实现原理,以及实现过程中可能出现的问题。开源项目文档很重要,如果这种方式 DSL 学习成本低一点,文档完善一点,基础功能稳定一点,我是最愿意去使用的。
比如这里的一些自带的处理器
这些处理器都是找不到的,因为处理器名称后面修改了,文档并没有同步
gou.AliasProcess("xiang.main.Ping", "utils.app.Ping")
gou.AliasProcess("xiang.main.Inspect", "utils.app.Inspect")
// FMT
gou.AliasProcess("xiang.helper.Print", "utils.fmt.Print")
// ENV
gou.AliasProcess("xiang.helper.EnvSet", "utils.env.Get")
gou.AliasProcess("xiang.helper.EnvGet", "utils.env.Get")
gou.AliasProcess("xiang.helper.EnvMultiSet", "utils.env.SetMany")
gou.AliasProcess("xiang.helper.EnvMultiGet", "utils.env.GetMany")
// Flow
gou.AliasProcess("xiang.helper.For", "utils.flow.For")
gou.AliasProcess("xiang.helper.Each", "utils.flow.Each")
gou.AliasProcess("xiang.helper.Case", "utils.flow.Case")
gou.AliasProcess("xiang.helper.IF", "utils.flow.IF")
gou.AliasProcess("xiang.helper.Throw", "utils.flow.Throw")
gou.AliasProcess("xiang.helper.Return", "utils.flow.Return")
// JWT
gou.AliasProcess("xiang.helper.JwtMake", "utils.jwt.Make")
gou.AliasProcess("xiang.helper.JwtValidate", "utils.jwt.Verify")
yao 的实现思路我觉得比较好,扩展性很强,但是基础服务并没有那么多好用。比如数据导出的自定义处理器无法获取到查询参数,现在导出只能全部导出。另外关于页面多次触发点击的问题,这个我目前没有时间去改了 暂时先这样用吧。
接下来的后续就是,我是给自己找事情干来着,后期计划还是先自己实现,yao 引擎的数据库可以直接用,因为是底层是 xorm,所以还是比较简单扩展。
这次并没有节约我的时间,相反 花的时间更多了,从面向代码编程变成了面向 DSL 编程。收获并不是没有,但是不多。
下一次低代码平台我会选择腾讯微搭。