利用Haskell构建DSL并结合Kotlin Multiplatform实现动态移动端规则引擎架构


移动应用逻辑的硬编码特性与快速变化的市场需求之间存在着天然的矛盾。一次简单的促销规则调整或输入校验逻辑变更,往往需要经历完整的“编码-构建-测试-发布-审核”流程,这个周期以天甚至周为单位计算。一个长期困扰我们的问题是:如何能将核心业务逻辑的决策权从移动开发者手中转移至产品或运营团队,并实现逻辑的近实时部署,同时又不牺牲原生应用的性能和离线能力?

我们曾评估过多种方案。最直接的思路是WebView,但这几乎立刻就被否决了。性能瓶颈、糟糕的平台集成度以及内存开销,都使其无法满足我们对高质量原生体验的要求。另一个方案是将所有逻辑置于服务端,客户端仅作展示。但这又引入了强网络依赖,延迟和离线场景处理变得极其复杂。

我们需要的是一种能在设备端本地执行、由服务器动态分发的逻辑。这最终将我们引向了一个更为大胆的架构:构建一个由Haskell定义的领域特定语言(DSL),通过CI/CD流程将其转译为可移植的JSON指令集,存储于MongoDB中,再由Kotlin Multiplatform编写的跨平台解释器在iOS和Android设备上原生执行。

定义复杂的技术问题

我们的目标是解耦移动应用的逻辑更新周期与版本发布周期。具体来说,需要满足以下几个关键且互斥的约束:

  1. 动态性与实时性: 业务规则(例如,用户注册的密码复杂度要求、购物车结算时的优惠券组合逻辑)必须能在分钟级别进行更新,并下发到所有客户端,无需用户更新App。
  2. 原生性能: 规则的执行不能成为性能瓶颈。它必须接近原生代码的执行效率,尤其是在涉及复杂计算或高频触发的场景下。
  3. 离线支持: 用户在网络不佳或离线状态下,应用的核心逻辑必须依旧可用。这意味着规则必须被缓存并能在本地执行。
  4. 安全性与健壮性: 分发的逻辑必须是安全的,不能引入客户端崩溃或安全漏洞。规则的定义过程需要有严格的校验,防止错误逻辑上线。
  5. 可维护性: 规则的定义语言应该对非程序员(如技术型产品经理)友好,同时整个系统的构建和维护成本必须是可控的。

方案A:基于JavaScript引擎的动态化

在探索初期,一个看似合理的方案是嵌入一个轻量级的JavaScript引擎(如Duktape或QuickJS)到移动应用中。

  • 优势分析:

    • 生态成熟: JavaScript是世界上最流行的语言之一,招聘和培训成本较低。大量的库和工具可以复用。
    • 灵活性高: JS是一门功能完备的动态语言,几乎可以实现任何想要的逻辑。
    • 实现直接: 服务端提供JS脚本,客户端下载后直接通过JS引擎执行。
  • 劣势分析:

    • 性能开销: JS的解释执行与原生代码之间存在性能鸿沟。在移动设备上,CPU和内存的限制会放大这个问题。JIT(即时编译)可以缓解,但会带来额外的内存消耗和预热时间。
    • 平台桥接成本: JS代码与原生代码(Swift/Kotlin)的交互(即Bridge调用)是昂贵且复杂的。频繁的数据类型转换和线程切换会严重拖累性能。
    • 类型安全缺失: JS的动态类型特性是一把双刃剑。在大型、复杂的规则体系中,缺乏编译时类型检查极易引入运行时错误。一个简单的拼写错误或类型不匹配可能导致整个脚本执行失败。
    • 沙箱安全性: 虽然JS引擎提供了沙箱环境,但确保下载的脚本不会执行恶意操作(如无限循环、过度消耗内存)需要额外的、复杂的安全加固工作。

这个方案最终被放弃,因为它牺牲了我们最看重的原生性能和健壮性。它更像是一个打了折扣的WebView,而非真正的原生动态化方案。

方案B:Haskell DSL + KMP原生解释器

这个方案的核心思想是“控制”而非“放任”。我们不给客户端一个图灵完备的语言,而是创建一个专门用于描述业务规则的、受限的DSL。最酷的是,我们选择Haskell来构建这个DSL的工具链,利用其在语言解析和类型系统上的无与伦比的优势。

  • 优势分析:

    • 绝对的类型安全: Haskell的强类型系统可以在编译时(即DSL的验证和转译阶段)捕获几乎所有逻辑错误。我们可以从数学上证明某些类型的错误是不可能发生的。这保证了下发到客户端的逻辑指令集是100%合法的。
    • 表达力与简洁性: 使用Haskell的代数数据类型(ADT)来定义DSL的结构,既清晰又极具表现力。解析器库(如Megaparsec)可以轻松构建出高质量的解析器,将用户友好的文本格式转换为内部的AST(抽象语法树)。
    • 原生执行性能: 逻辑最终由Kotlin Multiplatform编写的原生解释器执行。没有JS桥接,没有虚拟机预热。解释器本身是AOT编译的Kotlin/Native或JVM代码,其执行的是高度优化的原生指令。虽然是解释执行,但操作的是原生数据类型,性能远超JS Bridge方案。
    • 平台无关的逻辑载体: DSL被转译成JSON格式。JSON是一种通用的、轻量级的数据交换格式。这意味着我们的逻辑指令集与平台无关,未来甚至可以扩展到Web端(使用Kotlin/JS实现解释器)。
    • 完美的离线能力: 客户端只需获取一次JSON,就可以在本地无限次、无延迟地执行,完全不受网络影响。
  • 劣势分析:

    • 前期投入巨大: 设计DSL、编写Haskell的解析器和转译器、开发KMP的解释器,这是一项庞大的前期工程。
    • 技术栈陡峭: Haskell的学习曲线非常陡峭,对团队的技能要求极高。
    • 调试复杂性: 一旦在客户端的解释器中发现执行逻辑与预期不符,调试链路会很长:需要追溯到KMP解释器代码、JSON指令集、Haskell转译器,甚至是最初的DSL源文件。

尽管挑战巨大,但这个方案提供的安全性、性能和长期可维护性是无与伦is伦比的。它彻底改变了我们交付业务逻辑的方式。我们决定选择方案B。

核心实现概览

整个系统分为四个核心部分:Haskell DSL及工具链、MongoDB存储、KMP解释器、CI/CD流水线。

1. Haskell DSL 与转译器

我们首先用Haskell的ADT来定义规则的结构。一个规则包含一个条件(Condition)和一系列动作(Action)。条件本身可以是一个复杂的布尔表达式树。

-- file: src/RuleLang/AST.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}

module RuleLang.AST where

import Data.Aeson (ToJSON, FromJSON)
import Data.Text (Text)
import GHC.Generics (Generic)

-- 变量标识符
type VarName = Text

-- 规则的顶层结构
data Rule = Rule
  { ruleId      :: Text
  , description :: Text
  , condition   :: Condition
  , actions     :: [Action]
  } deriving (Show, Eq, Generic)

instance ToJSON Rule
instance FromJSON Rule

-- 条件表达式树
data Condition
  = CTrue
  | CFalse
  | Not Condition
  | And Condition Condition
  | Or  Condition Condition
  | Comparison CompOp Value Value
  deriving (Show, Eq, Generic)

instance ToJSON Condition
instance FromJSON Condition

-- 比较操作符
data CompOp
  = Eq  -- 等于
  | Neq -- 不等于
  | Gt  -- 大于
  | Gte -- 大于等于
  | Lt  -- 小于
  | Lte -- 小于等于
  deriving (Show, Eq, Generic)

instance ToJSON CompOp
instance FromJSON CompOp

-- 动作定义
data Action
  = SetField VarName Value       -- 设置字段值
  | ShowAlert Text Value         -- 显示弹窗
  | ExecuteFunc Text [Value]     -- 执行一个预定义的函数
  deriving (Show, Eq, Generic)

instance ToJSON Action
instance FromJSON Action

-- 值的类型
data Value
  = VInt Integer
  | VDouble Double
  | VString Text
  | VBool Bool
  | VVar VarName  -- 引用一个变量
  deriving (Show, Eq, Generic)

instance ToJSON Value
instance FromJSON Value

这段代码使用Haskell的代数数据类型精确定义了我们DSL的所有可能性。deriving (Generic)instance ToJSON/FromJSON Rule 使得我们可以利用Aeson库轻松地将这个AST与JSON进行双向转换。

接着,我们为这个DSL设计一个简单的文本语法,并使用megaparsec库编写解析器,将文本转换为上述的AST。

一个规则的文本可能长这样:

rule "user_level_discount_rule"
description "Gold level users get 10% discount if cart total is over 100"
when
  (user.level == "GOLD" and cart.total > 100.0)
then
  set cart.discount = 0.10;
  showAlert "Congratulations!" message "You've got a 10% discount!";
end

Haskell工具链的核心任务就是解析这段文本,生成Rule的AST,然后将其序列化为JSON。这个JSON就是我们要下发给客户端的“可执行逻辑”。

{
  "ruleId": "user_level_discount_rule",
  "description": "Gold level users get 10% discount if cart total is over 100",
  "condition": {
    "tag": "And",
    "contents": [
      {
        "tag": "Comparison",
        "contents": [
          "Eq",
          { "tag": "VVar", "contents": "user.level" },
          { "tag": "VString", "contents": "GOLD" }
        ]
      },
      {
        "tag": "Comparison",
        "contents": [
          "Gt",
          { "tag": "VVar", "contents": "cart.total" },
          { "tag": "VDouble", "contents": 100.0 }
        ]
      }
    ]
  },
  "actions": [
    {
      "tag": "SetField",
      "contents": [
        "cart.discount",
        { "tag": "VDouble", "contents": 0.1 }
      ]
    },
    {
      "tag": "ShowAlert",
      "contents": [
        "Congratulations!",
        { "tag": "VString", "contents": "You've got a 10% discount!" }
      ]
    }
  ]
}

Aeson默认生成的JSON格式并不直观,但它结构严谨,非常适合机器解析。

2. MongoDB 作为规则存储

我们选择MongoDB作为规则的存储后端。它的文档模型非常适合存储我们生成的JSON结构。

一个典型的rules集合中的文档可能如下所示:

// MongoDB document in 'rules' collection
{
    "_id": ObjectId("635a8f4c7b8d4f1a2e3b4c5d"),
    "ruleKey": "cart_checkout_rules",
    "version": 3,
    "platform": "mobile", // 'mobile', 'ios', 'android'
    "status": "active", // 'active', 'inactive', 'archived'
    "createdAt": ISODate("2023-10-27T10:00:00Z"),
    "updatedAt": ISODate("2023-10-27T10:25:00Z"),
    "rules": [
        // 这里是Haskell转译器生成的JSON Rule数组
        {
          "ruleId": "user_level_discount_rule",
          // ... rule content ...
        },
        {
          "ruleId": "free_shipping_rule",
          // ... other rule content ...
        }
    ]
}

这种结构的好处是:

  • 版本控制: 通过version字段,我们可以轻松管理规则的多个版本,并支持客户端回滚到旧版本。
  • 分发控制: platformstatus字段让我们能精细化控制规则的分发对象和生命周期。
  • 灵活性: 文档模型允许我们未来轻松地为规则增加新的元数据字段,而无需迁移整个数据库。

3. Kotlin Multiplatform 解释器

这是连接后端与移动端的桥梁,也是整个架构中代码量最大的部分。它位于KMP项目的commonMain模块中,以便同时为iOS和Android提供服务。

首先,定义与JSON结构匹配的data class,使用kotlinx.serialization进行反序列化。

// file: shared/src/commonMain/kotlin/com/yourapp/rules/engine/model/AST.kt
package com.yourapp.rules.engine.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

// 为了与Haskell Aeson的默认输出匹配,我们使用@SerialName("tag")和@SerialName("contents")
// 也可以自定义Haskell端的JSON编码器以生成更友好的格式

@Serializable
sealed class Condition {
    @Serializable @SerialName("CTrue") object CTrue : Condition()
    @Serializable @SerialName("CFalse") object CFalse : Condition()
    @Serializable @SerialName("Not") data class Not(val condition: Condition) : Condition()
    @Serializable @SerialName("And") data class And(val left: Condition, val right: Condition) : Condition()
    // ... 其他条件 ...
    @Serializable @SerialName("Comparison") data class Comparison(val op: CompOp, val left: Value, val right: Value) : Condition()
}

@Serializable
enum class CompOp { Eq, Neq, Gt, Gte, Lt, Lte }

@Serializable
sealed class Value {
    @Serializable @SerialName("VInt") data class VInt(val value: Long) : Value()
    @Serializable @SerialName("VDouble") data class VDouble(val value: Double) : Value()
    @Serializable @SerialName("VString") data class VString(val value: String) : Value()
    @Serializable @SerialName("VBool") data class VBool(val value: Boolean) : Value()
    @Serializable @SerialName("VVar") data class VVar(val name: String) : Value()
}

// ... Action 和 Rule 的数据类 ...

核心的解释器是一个递归函数,它接收一个AST节点和一个DataContext,然后返回执行结果。DataContext是关键,它提供了规则执行时所需的上下文信息(如user.level, cart.total),并允许Action修改其中的值。

// file: shared/src/commonMain/kotlin/com/yourapp/rules/engine/RuleEngine.kt
package com.yourapp.rules.engine

import com.yourapp.rules.engine.model.*
import com.yourapp.rules.engine.platform.logError

// DataContext负责提供和修改变量值
// 在真实项目中,这会是一个更复杂的类,可能支持嵌套对象
class DataContext(private val state: MutableMap<String, Any>) {
    fun getVar(name: String): Any? = state[name]
    fun setVar(name: String, value: Any) {
        state[name] = value
    }
}

class RuleEngine(private val rules: List<Rule>) {
    
    fun execute(context: DataContext) {
        rules.forEach { rule ->
            try {
                if (evaluateCondition(rule.condition, context)) {
                    executeActions(rule.actions, context)
                }
            } catch (e: Exception) {
                // 生产级的错误处理至关重要
                logError("RuleEngine", "Failed to execute rule ${rule.ruleId}: ${e.message}")
            }
        }
    }

    private fun evaluateCondition(condition: Condition, context: DataContext): Boolean {
        return when (condition) {
            is Condition.CTrue -> true
            is Condition.CFalse -> false
            is Condition.Not -> !evaluateCondition(condition.condition, context)
            is Condition.And -> evaluateCondition(condition.left, context) && evaluateCondition(condition.right, context)
            is Condition.Or -> evaluateCondition(condition.left, context) || evaluateCondition(condition.right, context)
            is Condition.Comparison -> {
                val leftVal = resolveValue(condition.left, context)
                val rightVal = resolveValue(condition.right, context)
                compareValues(condition.op, leftVal, rightVal)
            }
        }
    }

    private fun resolveValue(value: Value, context: DataContext): Any {
        return when (value) {
            is Value.VInt -> value.value
            is Value.VDouble -> value.value
            is Value.VString -> value.value
            is Value.VBool -> value.value
            is Value.VVar -> context.getVar(value.name) ?: throw IllegalStateException("Variable '${value.name}' not found in context")
        }
    }

    // compareValues 和 executeActions 的实现会很长,包含大量的类型检查和转换
    // ...
}

这个解释器是整个方案的核心,它的健壮性、性能和正确性直接决定了系统的成败。单元测试在这里至关重要,我们需要覆盖所有DSL的语法结构和边界情况。

4. CI/CD 流水线

自动化是确保这个复杂系统可靠运行的关键。我们的CI/CD流水线需要协同Haskell后端和KMP移动端。

graph TD
    subgraph "Git Repository"
        A[Push to `rules/` directory] --> B{CI Trigger};
    end

    subgraph "Haskell Toolchain CI"
        B --> C[Step 1: Lint & Test DSL];
        C --> D[Step 2: Transpile DSL to JSON];
        D --> E[Archive JSON Artifact];
    end
    
    subgraph "KMP Interpreter CI"
        E --> F[Step 3: Trigger KMP CI];
        F --> G[Download JSON Artifact];
        G --> H["Step 4: Run Interpreter Regression Tests (using new JSON)"];
    end

    subgraph "Deployment Pipeline"
        H -- On Success --> I{Manual Approval for Prod};
        H -- On Success --> J[Deploy to Staging MongoDB];
        I --> K[Deploy to Production MongoDB];
    end

    subgraph "Mobile App"
        L[App Starts] --> M{Fetch latest rule from backend};
        J -.-> M;
        K -.-> M;
        M --> N[Cache locally];
        N --> O[Execute with RuleEngine];
    end

这个流水线确保了每次规则的变更都经过了三个层次的验证:

  1. 静态验证: Haskell编译器和测试套件确保DSL语法正确,逻辑结构合法。
  2. 转译验证: 生成的JSON符合预定义的schema。
  3. 动态回归验证: 最关键的一步。KMP解释器加载新的规则JSON,并在一系列固定的上下文场景下运行,断言其输出与预期一致。这能有效防止新的规则破坏已有的、不相关的逻辑。

只有通过所有自动化检查的规则,才会被部署到预发环境,等待人工审核后推向生产。

架构的扩展性与局限性

这个架构并非银弹,它有明确的适用边界。

扩展性:

  • DSL功能扩展: 我们可以随时在Haskell中为DSL添加新的ConditionActionValue类型。例如,增加一个FetchAPI的Action。但这需要同步更新KMP解释器,并要求客户端强制升级到一个兼容新功能的版本。版本管理是这里的关键。
  • 性能优化: 对于计算密集型的规则,KMP解释器可以进一步优化。例如,对于频繁执行的规则,可以实现一个简单的JIT,将其动态编译成更高效的中间代码。
  • 跨平台: 由于解释器是纯Kotlin代码,将它移植到Kotlin/JS以支持Web,或移植到桌面端,都是完全可行的。

局限性:

  • 不适用于UI逻辑: 这个架构专注于业务逻辑,不适合用来动态渲染UI。动态UI有更成熟的方案,如Server-Driven UI。
  • 调试的痛苦: 如前所述,端到端的调试链路非常长。我们必须在KMP解释器中投入巨大精力来构建详尽的日志和遥测系统,以便在出现问题时能远程追踪逻辑的执行路径和上下文状态。
  • 安全风险: 尽管DSL是受限的,但下发的逻辑依然需要被视为不可信输入。必须通过HTTPS传输,并建议对规则JSON进行签名校验,防止中间人攻击篡改逻辑。
  • 初始复杂度: 整个系统的启动成本极高,只适用于那些业务逻辑极其复杂、变化极其频繁,且对性能和离线能力有严苛要求的应用场景。对于大多数应用而言,这是一种过度设计。

  目录