在JVM跑JavaScript脚本 | 简单 FaaS 架构设计与实现

发布于:2025-08-15 ⋅ 阅读:(20) ⋅ 点赞:(0)
 _______  _______  _______    __   __  _______  _______  _______ 
|   _   ||       ||       |  |  |_|  ||       ||       ||   _   |
|  |_|  ||    _  ||    _  |  |       ||    ___||_     _||  |_|  |
|       ||   |_| ||   |_| |  |       ||   |___   |   |  |       |
|       ||    ___||    ___|  |       ||    ___|  |   |  |       |
|   _   ||   |    |   |      | ||_|| ||   |___   |   |  |   _   |
|__| |__||___|    |___|      |_|   |_||_______|  |___|  |__| |__|

基于 SpringBoot3 + VUE3 + Naive UI + Electron 应用快速开发、发布平台,旨在帮助使用者(包含但不限于开发人员、业务人员)快速响应业务需求。
前端仓库:👉GitCode(中国大陆)👈、👉GitHub👈
后端仓库:👉GitCode(中国大陆)👈、👉GitHub👈


🔦 关于 app-meta

这里给自己开源的项目露个脸😄。

运行时截图

数据总览


我的应用


快应用


🛎️ FaaS/函数即服务

FaaS 是云计算领域的一种无服务器架构(Serverless)服务模式,它允许开发者专注于编写和部署单个函数,而无需管理底层服务器、容器或运行时环境。云服务商负责处理服务器资源的分配、扩展、维护等工作,开发者只需按函数的实际执行次数和资源消耗付费。

而在 app-meta 中,FaaS 是更加小巧(功能单一)的实现😂。以下是该项功能的设计。

🚗 运行机制

通常由客户端请求触发,经平台统一鉴权后,创建上下文对象,按预设的模式执行,最终返回计算结果。
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

参考资料:

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)

网站公告

今日签到

点亮在社区的每一天
去签到