_______ _______ _______ __ __ _______ _______ _______
| _ || || | | |_| || || || _ |
| |_| || _ || _ | | || ___||_ _|| |_| |
| || |_| || |_| | | || |___ | | | |
| || ___|| ___| | || ___| | | | |
| _ || | | | | ||_|| || |___ | | | _ |
|__| |__||___| |___| |_| |_||_______| |___| |__| |__|
基于 SpringBoot3 + VUE3 + Naive UI + Electron 应用快速开发、发布平台,旨在帮助使用者(包含但不限于开发人员、业务人员)快速响应业务需求。
前端仓库:👉GitCode(中国大陆)👈、👉GitHub👈
后端仓库:👉GitCode(中国大陆)👈、👉GitHub👈
🔦 关于 app-meta
这里给自己开源的项目露个脸😄。
运行时截图
🛎️ FaaS/函数即服务
FaaS
是云计算领域的一种无服务器架构(Serverless)服务模式,它允许开发者专注于编写和部署单个函数,而无需管理底层服务器、容器或运行时环境。云服务商负责处理服务器资源的分配、扩展、维护等工作,开发者只需按函数的实际执行次数和资源消耗付费。
而在 app-meta 中,FaaS 是更加小巧(功能单一)的实现😂。以下是该项功能的设计。
🚗 运行机制
通常由客户端请求触发,经平台统一鉴权后,创建上下文对象,按预设的模式执行,最终返回计算结果。
📦 上下文变量
FaaS 模块将客户端请求参数(Map<String, Object> 类型)
、当前登录用户
、应用编号
保存到上下文,允许运行时使用。
全局变量名 | 格式 | 说明 |
---|---|---|
params | Map<String, Object> | 入参,如果配置了强制参数模式 则只传递定义的参数 |
user | Map<String, Object> | 用户信息,包含属性 id、name(名称)、depart(包含 id 跟 name 的部门信息)、roles(平台角色清单)、appRoles(应用角色清单)、appAuths(已授权应用 url 地址) |
appId | String | 应用ID |
// user 对象示例
{
"id": "admin",
"name": "管理员",
"channel":"", //客户端类型
"depart": { "id": "001", "name":"" },
"roles": ["ADMIN"],
"appRoles": [],
"appAuths": []
}
使用示例
假设传递参数:template=faas, limit=5
# 对于 SQL 模式,直接使用 {{ 变量名 }}
SELECT id, name FROM page WHERE template='{{ params.template }}' LIMIT {{ params.limit }}
# 最终 SQL 为:SELECT id, name FROM page WHERE template='faas' LIMIT 5
// 对于 JavaScript 模式
console.debug(`参数`, params)
console.debug(`用户`, user)
let result = { time: Date.now(), user: user?.id }
// 返回变量值(兼容各类语法错误=.=)
result
📄 JavaScript 模式
JavaScript 模式下,支持调用平台相关的功能(通过全局对象meta
):
方法名 | 参数 | 说明 |
---|---|---|
sql | (text:String) | 在关联的数据源内执行 SQL |
getBlock | (uuid:String) | 获取应用下的数据块 |
setBlock | (uuid:String, text:String) | 更新应用下的数据块 |
insertData | (row:Object) | 插入单个数据行 |
insertData | (rows:Array, model:DataCreateModel) | 新增多个数据行 |
updateData | (dataId:Number, obj:Object, merge:Boolean) | 更新指定ID的数据行,merge 为true则为合并 |
queryData | (model:DataReadModel) | 查询数据行 |
removeData | (model:DataDeleteModel) | 删除指定数据行 |
getSession | (uuid:String) | 获取会话级别的临时值 |
getSession | (uuid:String, defaultVal:*) | 获取会话级别的临时值,若不存在则返回默认值 |
setSession | (uuid:String, val:Object) | 赋值到会话(下一次函数调用时可读取) |
faas | (funcId:Number, params:Object) | 调用另一个 FaaS 函数,参数 params 必填,若无参数请填写 {} |
// 使用方式
meta.sql("SELECT count(*) AS count FROM page") //返回 { count: 1 }
meta.setSession("count", 10)
let count = meta.getSession("count") //此时 count 的值为 10
调用 FaaS 函数
平台支持在脚本内调用其他 FaaS (详见 meta.faas 方法),此时,FaaS 函数公用一个用户上下文
通过 meta.faas
返回的数据结果,如是 Java 的 Map、List 对象,在脚本内可以直接使用
let data = meta.faas(1, {}) //示例:{id:1, name:"演示数据"}
// 可直接访问数据属性:data.id、data.name
// 不支持转换为 JSON 字符串: JSON.stringify(data) ,得到的是 {}
// 如需转化为真正的 JS Object 对象,可通过如下方式
let d = {}
for (var key in data) {
d[key] = data[key]
}
🔨 如何测试?
函数未发布(
保存函数信息
即视为发布)前,请进行模拟测试,验证函数的可用性
平台提供了一套测试环境,使用编辑中的函数代码运行,期间不会执行任何数据操作(如 SQL 执行、IO、会话存储等),而是输出日志,开发人员通过反显结果 DEBUG 函数😄
👨💻 核心代码
代码在后端仓库
meta-server/src/main/java/org/appmeta/module/faas
下
实体对象/Domain
class FuncParmeter {
companion object {
const val STRING = "string"
const val NUMBER = "number"
const val BOOLEAN= "boolean"
}
var id = ""
var name = ""
var value: String? = null
var required = false
var regex = ""
var type = STRING
constructor()
constructor(id: String, name: String, required: Boolean = false, regex: String = "") {
this.id = id
this.name = name
this.required = required
this.regex = regex
}
}
class Func {
companion object {
const val SQL = "sql"
const val JS = "js"
const val RESULT_ARRAY = "Array"
const val RESULT_OBJECT = "Object"
}
var mode = SQL
var summary = ""
var params:List<FuncParmeter> = listOf() //入参配置
var paramsLimit = false
var sourceId:Long? = null //数据源ID
var cmd = "" //代码或者脚本
var resultType = RESULT_OBJECT //结果格式(针对 mode=sql)
}
class FuncContext(
val appId:String, //应用ID
val params:MutableMap<String, Any>, //入参
val user:UserContext, //用户信息上下文
val devMode:Boolean = false, //是否为开发者模式
) {
val logs = mutableListOf<String>()
var result:Any? = null
/**
* 增加日志行
*/
fun appendLog(msg:String):Unit {
logs.add(msg)
}
fun appendException(e:Throwable) {
logs.add("-------------------------- EXCEPTION --------------------------")
logs.add(ExceptionUtils.getMessage(e))
}
}
/**
* 登录用户上下文对象
*/
class UserContext : NameBean {
var channel = ""
var ip = ""
var depart:Department? = null
var roles = listOf<String>()
var appRoles = listOf<String>()
var appAuths = listOf<String>()
private var inited = false
constructor()
constructor(id:String, name:String) {
this.id = id
this.name = name
}
constructor(account: Account):this(account.id, account.name)
constructor(user:AuthUser): this(user.id, user.name) {
this.roles = user.roles
this.ip = user.ip?:EMPTY
}
companion object {
fun empty() = UserContext()
}
fun showName() = "$name($id)"
fun toMap() = mapOf(
F.ID to id,
F.NAME to name,
F.DEPART to if(depart == null) mapOf() else mapOf(
F.ID to depart!!.id,
F.NAME to depart!!.name
),
"roles" to roles,
"appRoles" to appRoles,
"appAuths" to appAuths
)
fun toAuthUser() = AuthUser(id, name, ip).also { it.roles = roles }
fun checkInited() = inited
fun setInited() {
inited = true
}
}
运行时接口
interface MetaRuntime {
val logger: Logger
get() = LoggerFactory.getLogger(javaClass)
fun _log(msg:String, isDebug:Boolean=true){
if(isDebug)
if(logger.isDebugEnabled) logger.debug("[META] $msg")
else
logger.info("[META] $msg")
}
fun sql(text: String):Any
fun getBlock(uuid: String):String?
fun setBlock(uuid: String, text: String)
/**
* 新增一条数据行,不指定 pid(如需指定,请使用 insertData(List, pid)
*/
fun insertData(row: Map<String, Any>) = insertData(listOf(row), DataCreateModel())
fun insertData(rows: List<Map<String, Any>>, model: DataCreateModel)
fun updateData(dataId:Long, obj:Map<String, Any>, merge:Boolean)
fun queryData(model: DataReadModel):Any?
fun removeData(model: DataDeleteModel)
fun getSession(uuid: String) = getSession(uuid, null)
fun getSession(uuid: String, defaultVal:Any?): Any?
fun setSession(uuid: String, obj:Any?)
/**
* 调用外部的 FaaS 函数
*/
fun faas(id:Int, params:MutableMap<String, Any>):Any?
}
测试模式实现
/**
* 测试模式下的 JS 环境
*/
class MetaRuntimeDevImpl(val context: FuncContext) : MetaRuntime {
companion object {
// 默认的返回值
const val DEFAULT_RESULT = "_DEFAULT_RETURN_"
}
private fun logToContext(msg: String) = "[DEV-JS] $msg".also {
context.appendLog(it)
_log(it)
}
override fun sql(text: String): Any = logToContext("执行SQL > $text")
override fun getBlock(uuid: String): String? {
logToContext("<BLOCK> 获取数据块 #$uuid (AID=${context.appId})")
return uuid
}
override fun setBlock(uuid: String, text: String) {
logToContext("<BLOCK> 更新数据块 #$uuid (AID=${context.appId})为:$text")
}
override fun insertData(rows: List<Map<String, Any>>, model: DataCreateModel) {
println("新增数据:${JSON.toJSONString(model)}")
logToContext("<DATA> 新增数据行(PID=${model.pid}) $rows")
}
override fun updateData(dataId: Long, obj: Map<String, Any>, merge: Boolean) {
logToContext("<DATA> 更新数据行 ID=$dataId(MERGE=$merge) > $obj")
}
override fun queryData(model: DataReadModel): Any? {
logToContext("<DATA> 查询数据行 id=${model.id} match=${model.match}")
return emptyList<Any>()
}
override fun removeData(model: DataDeleteModel) {
logToContext("<DATA> 删除数据行 id=${model.id} match=${model.match}")
}
override fun getSession(uuid: String, defaultVal:Any?): Any? {
logToContext("<SESSION> 获取会话值 #$uuid (默认值=${defaultVal})")
return defaultVal
}
override fun setSession(uuid: String, obj: Any?) {
logToContext("<SESSION> 更新会话值 #$uuid 为:${JSON.toJSONString(obj)}")
}
override fun faas(id: Int, params: MutableMap<String, Any>): Any? {
logToContext("<FAAS> 调用函数#$id 参数 $params")
return params.getOrDefault(DEFAULT_RESULT, 0)
}
}
生产模式实现
class MetaRuntimeImpl(
val context: FuncContext,
val sourceId:Long?,
private val dbService: DatabaseService,
private val dataService: DataService,
private val sessionStore: MutableMap<String, Any?>,
private val faasRunner: FaasRunner
):MetaRuntime {
override fun sql(text:String):Any {
if(sourceId == null) throw Exception("未关联数据源,无法执行SQL")
if(sourceId == 0L) return SqlRunner.db().selectList(text)
val model = DbmModel()
model.sourceId = sourceId
model.sql = text
return dbService.runSQL(model)
}
override fun setBlock(uuid:String, text: String) {
_log("设置(AID=${context.appId}) uuid=$uuid 的 Block...")
dataService.setBlockTo(DataBlock(context.appId, uuid, text))
}
override fun getBlock(uuid: String): String? {
_log("获取(AID=${context.appId}) uuid=${uuid} 的 Block...")
return dataService.getBlockBy(DataBlock(context.appId, uuid))?.text
}
override fun insertData(rows: List<Map<String, Any>>, model: DataCreateModel) {
if(StringUtils.hasText(model.batch) && !StringUtils.hasText(model.pid))
throw Exception("按批次导入数据请指明 pid")
model.channel = Channels.FAAS
model.aid = context.appId
model.uid = context.user.id
_log("新增数据行 ${rows.size} 条 UID=${model.uid} BATCH=${model.batch}(ORIGIN=${model.origin})")
if(logger.isDebugEnabled){
rows.forEachIndexed { index, d -> _log("数据${index+1} > $d") }
}
dataService.create(model)
}
override fun updateData(dataId: Long, obj: Map<String, Any>, merge: Boolean) {
dataService.update(DataUpdateModel().also {
it.aid = context.appId
it.id = dataId
it.merge = merge
it.obj = obj
})
_log("更新数据行 id=$dataId merge=$merge > $obj")
}
override fun queryData(model: DataReadModel): Any? {
model.aid = context.appId
_log("查询数据行 id=${model.id} pid=${model.pid} match=${model.match}")
return dataService.read(model)
}
override fun removeData(model: DataDeleteModel) {
model.aid = context.appId
dataService.delete(model)
_log("删除数据行 id=${model.id} pid/pids=${model.pid}/${model.pids} match=${model.match}")
}
override fun getSession(uuid: String, defaultVal:Any?): Any? {
_log("获取会话值 ID=$uuid (默认值=${defaultVal})")
return sessionStore.getOrDefault(uuid, defaultVal)
}
override fun setSession(uuid: String, obj:Any?) {
_log("设置会话值 $uuid = $obj")
sessionStore[uuid] = obj
}
override fun faas(id: Int, params: MutableMap<String, Any>): Any? {
_log("调用 FaaS#$id,参数 $params")
return faasRunner.execute(id, params, context.user)
}
}
Service类
/**
* 为了更好地递归调用,将 Excecutor 合并到同一个实现类中
*/
@Service
class FaasRunnerImpl (
private val dataS: DataService,
private val logAsync: LogAsync,
private val appAsync: AppAsync,
private val authHelper: AuthHelper,
private val pageS:PageService,
private val dataSourceS: DatabaseSourceService,
private val dbService: DatabaseService,
private val helper: FaasHelper): FaasRunner {
private val logger = LoggerFactory.getLogger(javaClass)
/**
* 在指定数据源执行 SQL 语句,暂不支持动态参数
*/
private fun sqlExecute(func: Func, context: FuncContext):Any? {
Assert.isTrue(func.sourceId != null, "函数未配置数据源")
val sql = Handlebars().compileInline(func.cmd).apply(context)
if(context.devMode){
context.appendLog("[DEV-SQL] 执行语句 $sql")
return DateUtil.getDateTime()
}
logger.info("执行sql:${sql}")
return if(func.sourceId == 0L){
//使用主数据源,只能执行查询
val items = SqlRunner.db().selectList(sql)
if(func.resultType == Func.RESULT_ARRAY)
items.map { it.values }
else
items
}
else {
// 指定了 DataBaseSource 时,调用相应的模块
val source = dataSourceS.withCache(func.sourceId!!)?: throw Exception("数据源#${func.sourceId} 未定义")
val dbmResult = dbService.runSQL(DbmModel().also {
it.sourceId = source.id
it.batch = false
it.action = DbmModel.SQL
it.sql = sql
})
dbmResult as List<*>
}
}
private val env = ScriptEnv()
private fun jsExecute(func: Func, context: FuncContext):Any? {
func.sourceId?.also {
if(it>0L)
dataSourceS.withCache(it)?: throw Exception("数据源#${func.sourceId} 未定义")
}
if(!env.sessionData.contains(context.appId))
env.sessionData[context.appId] = mutableMapOf()
val out = object : OutputStream(){
val bytes = mutableListOf<Byte>()
private var cur = 0
override fun write(b: Int) {
bytes.add(b.toByte())
if(b==10){
val line = String(bytes.subList(cur, bytes.size-1).toByteArray(), Charset.defaultCharset())
logger.info("[JS引擎] $line")
context.appendLog(line)
cur = bytes.size
}
}
}
val ctx = Context.newBuilder(Func.JS)
.engine(env.engine)
//设置为 HostAccess.ALL 后,可以在 js 中调用 java 方法(通过 Bindings 传递),但是不支持使用 Java.type 功能
.allowHostAccess(env.hostAccess)
//设置 JS 与 JAVA 的交互性(如 Java.type、Packages )
//.allowAllAccess(true)
//不允许IO(如引入外部文件)
.allowIO(IOAccess.NONE)
.out(out)
.build()
val ctxBindings = ctx.getBindings(Func.JS)
ctxBindings.putMember("params", context.params)
ctxBindings.putMember("user", context.user.toMap())
ctxBindings.putMember("appId", context.appId)
ctxBindings.putMember(
"meta",
if(context.devMode)
MetaRuntimeDevImpl(context)
else
MetaRuntimeImpl(
context,
func.sourceId,
dbService,
dataS,
env.sessionData[context.appId]!!,
this
)
)
return ctx.eval(Func.JS, func.cmd).let {
if (it.isNull) return null
if (it.isException) return it.throwException()
var body = it.toString()
if (logger.isDebugEnabled) logger.debug("JS代码执行结果:$body")
env.regexList.find(body)?.also { m ->
if (logger.isDebugEnabled) logger.debug("结果为数组,即将替换开头的 ([0-9]+)")
body = body.replaceFirst(m.groupValues.last(), "")
}
//转换 JSON 格式
JSON.parse(body, JSONReader.Feature.AllowUnQuotedFieldNames)
}
}
private fun _error(msg:String, bean:Authable):Void = throw Exception("$msg,请联系管理者<${bean.uid}>")
override fun execute(pageId:Serializable, params: MutableMap<String, Any>, userContext: UserContext, logTransfer: ((TerminalLog) -> Unit)?):Any? {
val page = pageS.getOne(QueryWrapper<Page>().eq(F.ID, pageId).eq(F.TEMPLATE, Page.FAAS))?: throw Exception("FaaS函数 #$pageId 不存在")
if(!page.active) _error("功能未开放", page)
val canCall = if(page.serviceAuth == ALL){
logger.info("访问完全公开的 FaaS 函数#${pageId}")
true
}
else {
authHelper.checkService(page, userContext.toAuthUser())
}
if(!canCall) _error("您未授权访问该功能", page)
if(logger.isDebugEnabled){
logger.debug("FaaS 函数开始执行,用户=${userContext.showName()}")
logger.debug("原始入参:${params}")
}
val log = TerminalLog(page.aid, "$pageId", Const.EMPTY)
val timing = Timing()
if(!userContext.checkInited()) helper.fillUpUserContext(userContext, page.aid)
return FuncContext(page.aid, params, userContext).let { context->
context.appendLog("[参数] ${JSON.toJSONString(params)}")
execute( JSON.parseObject(pageS.buildContent(page, false), Func::class.java), context ).also { funResult->
if(logger.isDebugEnabled) logger.debug("FaaS 函数执行完成: {}", funResult)
log.uid = userContext.id
if(logTransfer != null) logTransfer(log)
log.used = timing.toMillSecond()
log.summary = context.logs.joinToString(Const.NEW_LINE)
if(context.params.isNotEmpty())
log.url = JSON.toJSONString(context.params)
logAsync.save(log)
// 执行次数增加
appAsync.afterLaunch(
PageModel(page.aid, page.id, userContext.channel),
userContext.id,
userContext.ip
)
}
}
}
fun execute(func: Func, context: FuncContext):Any? {
helper.checkParams(func, context.params)
if(logger.isDebugEnabled) logger.debug("修正后参数:${context.params}")
return when(func.mode){
Func.SQL -> sqlExecute(func, context)
Func.JS -> jsExecute(func, context)
else -> throw Exception("无效的类型<${func.mode}>")
}
}
}
📷 运行效果
📚 附录
🐞 问题汇总
1、 集成 SpringBoot 打包后报错
# 在类包下找不到指定资源
Caused by: java.lang.NullPointerException: null
at java.base/java.util.Objects.requireNonNull(Objects.java:233)
at java.base/sun.nio.fs.WindowsFileSystem.getPath(WindowsFileSystem.java:215)
at java.base/java.nio.file.Path.of(Path.java:148)
at org.graalvm.polyglot.Engine$ClassPathIsolation.collectClassPathJars(Engine.java:1988)
at org.graalvm.polyglot.Engine$ClassPathIsolation.createIsolatedTruffleModule(Engine.java:1783)
at org.graalvm.polyglot.Engine$ClassPathIsolation.createIsolatedTruffle(Engine.java:1723)
at org.graalvm.polyglot.Engine$1.searchServiceLoader(Engine.java:1682)
at org.graalvm.polyglot.Engine$1.run(Engine.java:1668)
at org.graalvm.polyglot.Engine$1.run(Engine.java:1663)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:319)
at org.graalvm.polyglot.Engine.initEngineImpl(Engine.java:1663)
at org.graalvm.polyglot.Engine$ImplHolder.<clinit>(Engine.java:186)
... 47 common frames omitted
修复方式:在启动脚本增加特定参数项目
java -D"polyglotimpl.DisableClassPathIsolation"=true -jar .\meta-server-1.0.jar
参考资料:
- [23.1.0] Error creating a GraalJS Engine in a Spring Boot app
- Cannot run polyglot code with Spring Boot executable JAR and Java 21
2、打包后体积约增加90MB
主要来自:icu4j-23.1.1.jar(39MB)、js-language-23.1.1.jar(27MB)、truffle-api-23.1.1.jar(16MB)、regex-23.1.1.jar(3MB)、polyglot-23.1.1.jar(1MB)
参考
- clever-graaljs:基于 graaljs 的高性能js脚本引擎,适合各种需要及时修改代码且立即生效的场景,如:ETL工具、动态定时任务、接口平台、工作流执行逻辑。 fast-api 就是基于clever-graaljs开发的接口平台,可以直接写js脚本开发Http接口,简单快速!
- delight-graaljs-sandbox:A sandbox for executing JavaScript with Graal in Java
- Javet:It is an awesome way of embedding Node.js and V8 in Java.
- Java表达式引擎选型调研分析(https://juejin.cn/post/7300562752422756361)