一次 Tengo 动态配置应用初体验
❝
可以遗憾,但不要后悔。
我们留在这里,从来不是身不由己。
--------- 而是选择在这里经历生活
❞
业务背景
在业务不断扩展的背景下,"数据" 和 "计算" 的解耦需求逐渐凸显。计算规则的灵活性变化,因此将其直接嵌入主应用程序变得不太适宜,因此我们需要一款强大的规则引擎或动态配置平台。
平台的设计考虑到两个主要用户群体:技术人员和非技术人员。
对于非技术用户,我们提供了简单易用的界面和交互方式,以降低他们的学习成本和使用门槛。这可能包括可视化工具、拖放式界面和预定义模板等,旨在简化配置和规则定义过程。
同时,对于技术用户,我们提供了一种灵活且可扩展的方式来自定义和配置规则,以满足他们的需求。这些用户可能更喜欢编写脚本以实现复杂的业务逻辑,以获得更高的灵活性和自定义能力。
无论用户属于哪一类,底层都可以使用一套适用于频繁变更和动态配置的脚本引擎。我们选择了 Tengo
来完成这项任务,它适用于构建自定义脚本引擎、扩展应用程序功能和实现插件系统等多种场景。Tengo
为我们提供了稳定性和灵活性,助力我们有效应对不断变化的业务需求。
Tengo 介绍
Tengo 语言具有类似于 Go 语言的语法和特性,并且被设计用于嵌入到其他应用程序中,以提供脚本化和扩展能力。
Tengo 是什么
Tengo
是一门年轻而强大的动态无类型脚本语言。Tengo
的底层依赖于Golang
,兼具简洁易懂的语法和卓越的性能表现。Tengo
具备支持外部变量和函数的特性,能够方便地导入外部资源,同时支持局部变量的定义和控制语句的使用。Tengo
非常适用于作为脚本引擎,在虚拟机的实现方式下,它可以应用于多个应用领域。
Playground 体验
可以在 Playground
中体验 Tengo
:tengolang.com/
Tengo 应用场景
动态配置和规则引擎
Tengo 是一款强大的动态脚本语言,不仅语法简单易用,而且基于 Golang 实现,性能出色。它可以同时作为动态配置管理和规则引擎的理想搭档,为业务规则的定义、执行和管理提供了灵活性和强大的工具。
使用 Tengo
进行动态配置管理和规则引擎的优势如下:
- 动态性 :
Tengo
允许你在运行时动态修改配置和规则,无需重新启动应用程序。这适用于需要频繁调整和测试的场景,使得实时配置和规则调整成为可能。 - 灵活性 :
Tengo
提供了一种脚本化的方式来修改配置和定义规则。通过编写Tengo
脚本,你可以利用条件逻辑、循环和自定义函数来处理配置和规则,使得配置和规则的定义过程更加灵活和可控。 - 安全性 :
Tengo
提供了受控的脚本执行环境,可以限制脚本的功能和访问权限,确保配置和规则的安全性。相比直接操作操作系统环境变量,使用Tengo
提供更多的安全保障。 - 可扩展性 :
Tengo
是一个可扩展的脚本语言,可以与应用程序逻辑集成。你可以在Tengo
脚本中定义自定义函数和数据结构,更好地处理和管理配置和规则,根据应用需求进行定制和扩展。
总而言之,使用 Tengo
实现动态配置管理和规则引擎提供了更多的灵活性、控制和扩展性。无论是配置的实时调整,还是复杂规则的定义和执行,Tengo
都为这些需求提供了高效和灵活的解决方案。同时,Tengo
的性能和易用性也使得它在实际项目中应用广泛,适用于多种复杂和灵活的配置和规则管理需求。
工作流引擎
我们的工作流引擎设计中融合了前端的可视化配置和 Tengo
脚本引擎,以满足不同用户的需求。这个设计带来的好处如下:
- 简化配置 :对于非技术用户,我们提供直观的拖拽式配置界面,降低了门槛,使他们能够轻松定义和配置工作任务流。用户无需深入了解
Tengo
语言的细节,便能快速创建和管理任务流。 - 灵活性 :通过
Tengo
脚本引擎,我们为技术用户提供了强大的自定义能力。他们可以在脚本中使用条件逻辑、循环、自定义函数等功能,以更灵活的方式处理任务逻辑,满足复杂的业务需求。 - 实时动态:我们的架构支持在运行时动态修改配置和任务流,无需重新启动应用程序。这使得用户可以实时地对配置进行调整,适用于需要频繁调整和测试配置的场景。
- 安全保障 :通过限制
Tengo
脚本的功能和访问权限,我们确保了配置修改和任务执行的安全性。相比直接操作操作系统环境变量,这种方式提供了更多的安全保障。
这个设计方案将前端界面与 Tengo
脚本引擎紧密结合,既满足了非技术用户的需求,又为技术用户提供了自定义和扩展的能力。希望在保持用户友好界面的同时,赋予强大的脚本处理能力,使得任务流配置更加灵活、高效,满足各种复杂的业务场景需求。
Tengo 快速开始
The Tengo Language: Tengo is a small, dynamic, fast, secure script language for Go.
可作为独立语言执行
全局安装
bash
go install github.com/d5/tengo/v2
交互模式
直接输入 tengo
进入终端交互命令行
bash
➜ tengo
>> a := "foo"
foo
>> b := -19.84
-19.84
>> c := 5
5
>> d := true
true
运行脚本
- 编写
demo.tengo
脚本
go
// module import
fmt := import("fmt")
// variable definition and primitive types
a := "foo" // string
b := -19.84 // floating point
c := 5 // integer
d := true // boolean
fmt.println("a: ", a)
fmt.println("b: ", b)
fmt.println("c: ", c)
fmt.println("d: ", d)
- 使用
Tengo
解释器直接解释执行
bash
➜ tengo demo.tengo
a: foo
b: -19.84
c: 5
d: true
- 也支持先编译二进制文件再执行
bash
➜ tengo -o demo demo.tengo
demo
➜ ls -l demo
-rwxr-xr-x 1 mystic staff 1556 8 13 21:01 demo
➜ tengo demo
a: foo
b: -19.84
c: 5
d: true
代码示例
目录结构:
bash
.
├── base.tengo
└── demo.tengo
base.tengo
脚本:
tengo
// base.tengo
fn := func() {
return {
a: true,
b: [1, 2, 3],
c: {d: "ddd", e: "eee"}
}
}
// 将命名空间导出供其他脚本使用
export {
data: fn(),
}
demo.tengo
脚本:
tengo
// demo.tengo
fmt := import("fmt")
base := import("./base")
fmt.println(base.data.a) // true
fmt.println(base.data.b[0]) // 1
fmt.println(base.data.c.d) // ddd
执行脚本:
bash
➜ tengo demo.tengo
true
1
ddd
使用 Go 的编译或运行时
下面的示例演示了如何在 Go 代码中使用 Tengo 解释器来执行 Tengo 脚本
库安装
bash
go get github.com/d5/tengo/v2
代码示例
使用 Tengo
计算数组元素之和
go
package main
import (
"context"
"fmt"
"github.com/d5/tengo/v2"
)
func main() {
// 创建一个新的 Tengo Script 实例
script := tengo.NewScript([]byte(
// Tengo 脚本字符串
`each := func(seq, fn) {
for x in seq { fn(x) }
}
sum := 0
each([a, b, c, d], func(x) {
sum += x
})`))
// 设置变量值
_ = script.Add("a", 1)
_ = script.Add("b", 2)
_ = script.Add("c", 3)
_ = script.Add("d", 4)
// 运行脚本
compiled, err := script.RunContext(context.Background())
if err != nil {
panic(err)
}
// 检索值
sum := compiled.Get("sum")
fmt.Println(sum) // 打印总和(sum)的值
}
使用 Tengo
进行表达式求值
go
package main
import (
"context"
"fmt"
"github.com/d5/tengo/v2"
)
func main() {
// 创建一个空的上下文
ctx := context.TODO()
// 定义 Tengo 表达式并设置变量 input 为 true
expression := `input ? "success" : "fail"`
// 设置上下文变量 input 的值为 true
vars := map[string]any{"input": true}
// 通过 Tengo.Eval 进行表达式求值
res, err := tengo.Eval(ctx, expression, vars)
if err != nil {
panic(err)
}
// 打印结果,应为 "success"
fmt.Println(res)
}
动态配置案例
需求分析
目标需求如下:
我们通过一个总控脚本(base.tengo
)来控制其他两个脚本(dev.tengo
和prod.tengo
)的选项参数。希望实现的效果是根据 currentEnv
变量的值,动态加载不同的脚本。
可以按照以下步骤进行实现:
- 在总控脚本(
base.tengo
)中,暴露currentEnv
选项参数。 - 在主应用程序(
main.go
)中读取总控脚本,根据currentEnv
的值来加载对应的环境配置脚本(dev.tengo
或prod.tengo
)。 - 另外,通过开启
goroutine
来周期性地更新配置,实现动态切换。
这样的设计允许你根据总控脚本的设定,动态地选择使用哪个数据库配置脚本,并根据 isEnabled
的值来决定是否连接到数据库,而不需要使应用程序做重启或退出。这种方案能够提高应用的灵活性和实时性,满足动态配置需求。
目录结构
bash
.
├── config
│ ├── base.tengo
│ ├── dev.tengo
│ └── prod.tengo
└── main.go
代码实现
config/base.tengo
文件
tengo
// 当前环境:主应用程序通过该值的变化,选择具体的tengo脚本,并动态加载参数
currentEnv := "dev" // 或 "prod"
config/dev.tengo
文件
tengo
// dev configuration
fmt := import("fmt")
sshCmd := func(isEnabled) {
sys := {
user: "root",
password: "12345",
ip: "192.168.0.100",
port: 22
}
if isEnabled {
cmd := fmt.sprintf("sshpass -p '%s' ssh -p %d %s@%s", sys.password, sys.port, sys.user, sys.ip)
return cmd
}
return ""
}(true)
mysql := func(isEnabled) {
if isEnabled {
return {
host: "dev.mysql.com",
port: 3306,
username: "dev_user",
password: "dev_pass",
dbname: "dev_db"
}
}
return {}
}(true)
redis := func(isEnabled) {
if isEnabled {
return {
host: "dev.redis.com",
port: 6379,
password: "12345",
dbNumber: 0
}
}
return {}
}(true)
// test function
test := func() {
fmt.println(sshCmd)
fmt.printf("mysql: %v\n", mysql)
fmt.printf("redis: %v\n", redis)
}
// test()
config/prod.tengo
文件
tengo
// prod configuration
fmt := import("fmt")
sshCmd := func(isEnabled) {
sys := {
user: "root",
password: "admin@123",
ip: "10.50.0.2",
port: 10022
}
if isEnabled {
cmd := fmt.sprintf("sshpass -p '%s' ssh -p %d %s@%s", sys.password, sys.port, sys.user, sys.ip)
return cmd
}
return ""
}(true)
mysql := func(isEnabled) {
if isEnabled {
return {
host: "prod.mysql.com",
port: 13306,
username: "prod_user",
password: "prod_pass",
dbname: "prod_db"
}
}
return {}
}(true)
redis := func(isEnabled) {
if isEnabled {
return {
host: "prod.redis.com",
port: 16379,
password: "secret-password",
dbNumber: 1
}
}
return {}
}(true)
// test function
test := func() {
fmt.println(sshCmd)
fmt.printf("mysql: %v\n", mysql)
fmt.printf("redis: %v\n", redis)
}
// test()
main.go
主文件
go
package main
import (
"fmt"
"io/ioutil"
"log"
"strings"
"time"
"github.com/d5/tengo/v2"
"github.com/d5/tengo/v2/stdlib"
)
func loadConfigFile(filename string) ([]byte, error) {
configData, err := ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
return configData, nil
}
func runConfigScript(script *tengo.Script) (*tengo.Compiled, error) {
compiled, err := script.Run()
if err != nil {
return nil, err
}
return compiled, nil
}
func execConnection(env string, compiled *tengo.Compiled) {
// 连接ssh
func() {
sshCmd := compiled.Get("sshCmd").Value().(string)
if sshCmd == "" {
sshCmd = "unable to ssh connect!"
}
log.Printf("| Linux SSH (%s) is connecting | %s\n", env, sshCmd)
}()
// 连接mysql
func() {
mysql := compiled.Get("mysql").Value().(map[string]any)
host := mysql["host"]
port := mysql["port"]
user := mysql["username"]
password := mysql["password"]
dbname := mysql["dbname"]
var dsn string
if host == nil || port == nil || user == nil || password == nil || dbname == nil {
dsn = "unable to mysql connect!"
} else {
dsn = fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, password, host, port, dbname)
}
log.Printf("| MySQL DSN (%s) is connecting | %s\n", env, dsn)
}()
// 连接redis
func() {
redis := compiled.Get("redis").Value().(map[string]any)
host := redis["host"]
port := redis["port"]
password := redis["password"]
dbNumber := redis["dbNumber"]
var dsn string
if host == nil || port == nil || password == nil || dbNumber == nil {
dsn = "unable to redis connect!"
} else {
dsn = fmt.Sprintf("redis://%s@%s:%d/%d", password, host, port, dbNumber)
}
log.Printf("| REDIS URL (%s) is connecting | %s\n", env, dsn)
}()
}
func runConfigUpdater() {
var i int = 1
for {
fmt.Println("轮训监听次数:", i)
// 加载 base.tengo 文件来获取当前环境配置
baseConfigData, err := loadConfigFile("config/base.tengo")
if err != nil {
log.Fatalf("Error reading base config file:", err)
}
// 创建 Tengo 解释器并加载 base.tengo 脚本
baseScript := tengo.NewScript(baseConfigData)
baseScript.SetImports(stdlib.GetModuleMap(stdlib.AllModuleNames()...))
baseCompiled, err := runConfigScript(baseScript)
if err != nil {
log.Fatalf("Error running base config script:", err)
}
currentEnv := baseCompiled.Get("currentEnv").Value().(string)
// 加载当前环境的配置文件
var envConfigData []byte
switch strings.ToLower(currentEnv) {
case "prod":
envConfigData, err = loadConfigFile("config/prod.tengo")
case "dev":
fallthrough
default:
envConfigData, err = loadConfigFile("config/dev.tengo")
}
if err != nil {
log.Fatalf("Error reading %s config file:", currentEnv, err)
}
// 创建 Tengo 解释器并加载当前环境的配置脚本
envScript := tengo.NewScript(envConfigData)
envScript.SetImports(stdlib.GetModuleMap(stdlib.AllModuleNames()...))
envCompiled, err := runConfigScript(envScript)
if err != nil {
log.Fatalf("Error running config script:", err)
} else {
// 执行连接
execConnection(currentEnv, envCompiled)
}
// 每 3 秒重新加载一次 base.tengo 和当前环境的配置文件
time.Sleep(3 * time.Second)
i++
}
}
func main() {
// 启动周期性的 goroutine 来读取和更新连接参数
runConfigUpdater()
// 暂停主 goroutine,以便周期性的 goroutine 继续执行
select {}
}
效果演示
💡 当然,这仅仅是 Tengo
作为脚本引擎的一个非常基础的初步尝试。在实际的生产环境中,我们并不建议基于灵活性而采用这样的设计来切换生产和测试库!