一、先说个真实场景

做电商系统的朋友应该都有体会,订单这个东西远没有想象中简单。一个订单从创建到最终完结,中途要经历待支付、已支付、发货、送达、取消这么多个阶段,而且每个阶段还带着各自独有的附加信息。比如已发货就得关联一个物流单号,已取消就得记录一下取消的原因,已支付可能还要存支付渠道和支付时间。

我见过不少团队用枚举加一堆可空字段来表示订单状态。刚开始确实顺手,业务稍微复杂一点就开始难受了。你根本说不清楚程序里哪些地方漏掉了某个状态的分支,也不确定某个状态下哪些字段是有意义的、哪些字段根本就是空的。举个很常见的例子,一个待支付订单里,物流单号字段是null,这本身问题不大,可怕的是后面有人拿着这个null去做查询、去做展示,bug一下子就冒出来了。而且这种bug还不是必现的,得等特定状态组合才会触发,排查起来特别费劲。

后来我换到F#,接触到可区分联合,才觉得在这个问题上找到了顺手的工具。这篇文章不聊虚的,直接讲我怎么用可区分联合给订单领域建模,然后把实际生产环境里最折磨人的序列化和版本兼容问题一个个拆开说清楚。

二、可区分联合到底是个啥

用大白话讲,可区分联合就是明确地告诉你:当前的值一定是下面几种情况里的某一种,而且每一种情况自己声明需要随身携带哪些数据。

拿订单状态来对比一下。用C#写,你可能先定义一个枚举,然后在订单实体里塞上物流单号、取消原因、支付时间这些可空字段。哪个状态该用哪个字段,全靠约定,没人检查。

用F#的可区分联合,订单状态可以这样写:

// 技术栈:F# + Newtonsoft.Json
// 定义一个订单状态的可区分联合
type OrderStatus =
    | Pending                      // 待支付,不额外带数据
    | Paid of string               // 已支付,带支付单号
    | Shipped of trackingNo: string // 已发货,带物流单号
    | Delivered                    // 已送达,不额外带数据
    | Cancelled of reason: string  // 已取消,带取消原因

你看,每个状态需要什么数据,全部写在类型定义里了。Shipped就必须要一个物流单号,Cancelled就必须要一个原因。你想构造一个没物流单号的Shipped?编译器根本不让过。

配合上F#强大的模式匹配,处理不同状态的时候,逻辑会变得非常清晰:

// 技术栈:F# + Newtonsoft.Json
// 根据订单状态生成一段人话描述
let describeStatus (status: OrderStatus) =
    match status with
    | Pending ->
        "订单还在等待买家付款"
    | Paid _ ->
        "买家已经付款啦,准备安排发货"
    | Shipped trackingNo ->
        sprintf "订单已发货,物流单号是 %s" trackingNo
    | Delivered ->
        "订单已妥投,感谢购买"
    | Cancelled reason ->
        sprintf "订单已取消,原因是:%s" reason

注意match表达式里,分支一个都不能少。你哪天新增了一个状态,编译器会立刻在每一个match的地方提醒你:这里还差一个新分支。这一点对维护老代码来说简直是救命稻草,后面讲版本兼容的时候还会细说。

三、给订单领域建模

3.1 先把基础类型定出来

订单领域光有状态还不够,还得有订单项、金额、收货信息这些东西。我用F#记录类型把整个订单模型串起来:

// 技术栈:F# + Newtonsoft.Json
open System

// 订单项
type OrderItem = {
    ProductId: int
    ProductName: string
    Quantity: int
    UnitPrice: decimal
}

// 金额
type Money = {
    Amount: decimal
    Currency: string
}

// 收货信息
type ShippingAddress = {
    Receiver: string
    Phone: string
    Province: string
    City: string
    Detail: string
}

// 订单聚合根
type Order = {
    OrderId: string
    CustomerId: int
    Items: OrderItem list
    TotalAmount: Money
    Address: ShippingAddress
    Status: OrderStatus
    CreatedAt: DateTime
}

这种建模方式最大的价值在于,不合法或不完整的状态在类型层面就表达不出来。比如订单一定要有收货地址,那Address类型本身就不允许缺失。订单项数量不能是负数?你可以在构造的时候做校验,类型系统也帮你在编译期挡掉一部分手误。

3.2 状态流转怎么控制

订单状态不是随便乱跳的。待支付能变成已支付或已取消,已支付能变成已发货,已发货能变成已送达。但绝不允许从已支付直接跳到已送达,也不允许从已取消再变回待支付。

用可区分联合写状态流转,我可以把所有规则集中到一个函数里:

// 技术栈:F# + Newtonsoft.Json
// 尝试把订单从当前状态迁移到目标状态
let transition (current: OrderStatus) (target: OrderStatus) =
    match current, target with
    | Pending, Paid -> Ok target
    | Pending, Cancelled _ -> Ok target
    | Paid _, Shipped _ -> Ok target
    | Shipped _, Delivered -> Ok target
    | _ ->
        Error (sprintf "不允许从 %A 流转到 %A" current target)

这个函数返回Ok或者Error,调用方可以根据结果决定是落库还是提示用户。比到处散落着if判断要可靠得多,至少所有合法路径都清清楚楚写在match里。

3.3 来一个完整例子

我们把前面定义的模型拼起来,模拟一个订单从创建到发货的过程:

// 技术栈:F# + Newtonsoft.Json
open System

// 构造一个待支付的订单
let createPendingOrder () =
    {
        OrderId = "ORD-20240001"
        CustomerId = 10086
        Items = [
            { ProductId = 101; ProductName = "机械键盘"; Quantity = 1; UnitPrice = 399m }
            { ProductId = 102; ProductName = "鼠标垫"; Quantity = 2; UnitPrice = 29m }
        ]
        TotalAmount = { Amount = 457m; Currency = "CNY" }
        Address = {
            Receiver = "张三"
            Phone = "13800001111"
            Province = "浙江省"
            City = "杭州市"
            Detail = "西湖区某街道某号"
        }
        Status = Pending
        CreatedAt = DateTime.UtcNow
    }

let order = createPendingOrder ()

// 走一遍状态流转:待支付 -> 已支付 -> 已发货
let paidResult =
    transition order.Status (Paid "PAY-889900")

let shippedResult =
    match paidResult with
    | Ok newStatus ->
        transition newStatus (Shipped "SF-1234567890")
    | Error msg ->
        failwith msg

// 用模式匹配看看最终的状态描述
match shippedResult with
| Ok finalStatus -> printfn "%s" (describeStatus finalStatus)
| Error msg -> printfn "流转失败:%s" msg

整个过程类型安全、逻辑集中,状态机长什么样一眼就能看明白。

四、序列化:看着简单,坑不少

模型建好了,接下来就要面对一个实际生产绕不开的问题:序列化。订单要存数据库,要发消息给其他服务,还要在浏览器端展示,通通离不开JSON。可区分联合在F#内存里用着舒服,一旦要跨进程传输,麻烦就来了。

4.1 默认序列化结果长啥样

先用Newtonsoft.Json直接序列化一个订单看看:

// 技术栈:F# + Newtonsoft.Json
open Newtonsoft.Json

let orderJson = JsonConvert.SerializeObject(order)
printfn "%s" orderJson

拿到的JSON大概是下面这个样子:

{
  "OrderId": "ORD-20240001",
  "CustomerId": 10086,
  "Items": [
    { "ProductId": 101, "ProductName": "机械键盘", "Quantity": 1, "UnitPrice": 399.0 },
    { "ProductId": 102, "ProductName": "鼠标垫", "Quantity": 2, "UnitPrice": 29.0 }
  ],
  "TotalAmount": { "Amount": 457.0, "Currency": "CNY" },
  "Address": {
    "Receiver": "张三",
    "Phone": "13800001111",
    "Province": "浙江省",
    "City": "杭州市",
    "Detail": "西湖区某街道某号"
  },
  "Status": { "Pending": [] },
  "CreatedAt": "2024-05-20T08:30:00Z"
}

注意到没有,Status序列化出来变成了一个以Pending为键、空数组为值的对象。这是因为可区分联合的默认序列化会把所有case都包一层对象,没有数据的case就挂个空数组。这结构说不上错,但真的很丑,而且对下游很不友好。下游是Java服务的话,看到这种JSON直接傻眼,你还得专门给他们写文档解释。

4.2 坑一:结构不稳定

默认序列化的另一个麻烦在于结构不稳定。同样是订单状态,有数据的case和没数据的case,序列化出来的形状完全不一样:

{ "Pending": [] }
{ "Shipped": "SF-123" }
{ "Cancelled": "库存不足" }

如果case带多个字段,又会变成数组形式。下游消费方拿到同一个字段,时而看到字符串,时而看到数组,时而是空数组,解析逻辑写得想骂人。这种结构不统一的JSON,也正是很多线上事故的根源。

4.3 坑二:老数据读不出来

这一点才是真正的噩梦。生产环境里数据库已经躺着几百万条订单,全都是老格式。比如老版本里订单状态还是一个数字枚举,0表示待支付、1表示已支付。现在你把模型升级成了可区分联合,想直接把老数据反序列化成新的Order类型,Newtonsoft.Json默认行为直接报错,根本读不出来。

就算你老老实实地用当前版本的新格式存数据,半年后你给OrderStatus加了一个新case,比如加一个退款中的中间状态。这时候老数据里没有这个case,新数据里有,两边反序列化都能工作,但只要格式稍有变化,整个读取链路就被卡死。版本兼容这件事,偷不得懒。

五、版本兼容的实战方案

5.1 先约定一个稳定的传输格式

踩过坑之后我的做法是:对外传输的JSON,永远不用默认序列化结果,而是自己定一套稳定的格式。说白了就是给可区分联合设计一个统一的对外Schema。

我的基本原则有三条:

第一,状态用字符串表示,不要用数字。数字在版本演进过程中特别容易错位,字符串至少可读性强。

第二,有附加信息的case,统一用对象结构,不能一会儿字符串一会儿数组。

第三,所有case都用同一个外层结构,让下游可以写一次解析逻辑,不用为每个case单独判断。

按照这三条原则,订单状态序列化成对象加状态名加可选字段的格式最省心。

5.2 写一个自定义转换器

给F#加自定义序列化行为,我一般继承Newtonsoft.Json的JsonConverter,写一个专门的OrderStatusConverter:

// 技术栈:F# + Newtonsoft.Json
open Newtonsoft.Json
open Newtonsoft.Json.Linq

// 工具函数:安全地从JSON对象里取字符串字段
let getString (jo: JObject) (fieldName: string) (defaultValue: string) =
    match jo.TryGetValue(fieldName) with
    | true, token -> token.ToString()
    | false, _ -> defaultValue

type OrderStatusConverter() =
    inherit JsonConverter()

    override this.CanConvert(objectType) =
        // 只处理订单状态这个类型
        objectType = typeof<OrderStatus>

    override this.WriteJson(writer, value, serializer) =
        match value :?> OrderStatus with
        | Pending ->
            serializer.Serialize(writer, {| status = "pending" |})
        | Paid paymentNo ->
            serializer.Serialize(writer, {| status = "paid"; paymentNo = paymentNo |})
        | Shipped trackingNo ->
            serializer.Serialize(writer, {| status = "shipped"; trackingNo = trackingNo |})
        | Delivered ->
            serializer.Serialize(writer, {| status = "delivered" |})
        | Cancelled reason ->
            serializer.Serialize(writer, {| status = "cancelled"; reason = reason |})

    override this.ReadJson(reader, objectType, existingValue, serializer) =
        let token = JToken.Load(reader)
        match token.Type with
        | JTokenType.String ->
            // 兼容老版本:直接是一个状态字符串,比如 "paid"
            match token.ToString().ToLowerInvariant() with
            | "pending" -> Pending
            | "paid" -> Paid ""
            | "shipped" -> Shipped ""
            | "delivered" -> Delivered
            | "cancelled" -> Cancelled ""
            | unknown ->
                raise (JsonSerializationException $"无法识别的订单状态:{unknown}")
        | JTokenType.Object ->
            let jo = token :?> JObject
            let statusName = getString jo "status" ""
            match statusName with
            | "pending" -> Pending
            | "paid" -> Paid (getString jo "paymentNo" "")
            | "shipped" -> Shipped (getString jo "trackingNo" "")
            | "delivered" -> Delivered
            | "cancelled" -> Cancelled (getString jo "reason" "未填写原因")
            | unknown ->
                raise (JsonSerializationException $"无法识别的订单状态:{unknown}")
        | JTokenType.Integer ->
            // 兼容骨灰级老数据:0待支付,1已支付,2已发货,3已送达,4已取消
            match token.ToObject<int>() with
            | 0 -> Pending
            | 1 -> Paid ""
            | 2 -> Shipped ""
            | 3 -> Delivered
            | 4 -> Cancelled ""
            | n -> raise (JsonSerializationException $"无法识别的订单状态编号:{n}")
        | JTokenType.Null ->
            // 状态字段为null的老数据,这里选择抛异常提醒
            raise (JsonSerializationException "订单状态字段不能为null")
        | _ ->
            raise (JsonSerializationException "订单状态字段格式非法")

注意这里我做了三层兼容。第一,新格式是对象结构,有稳定的status字段,附加信息各自命名,结构统一、字段明确。第二,兼容了比较老但还活着的字符串格式,也就是只有paid这样的纯字符串。第三,连最初用数字枚举存的那批老数据也一并抢救回来了。

5.3 把这个转换器接到实际项目里

转换器写好了,使用方式很简单。序列化的时候把转换器塞进设置里:

// 技术栈:F# + Newtonsoft.Json
open Newtonsoft.Json

// 配置序列化设置,把自定义转换器挂上去
let settings = JsonSerializerSettings()
settings.Converters.Add(OrderStatusConverter())

// 序列化与反序列化都走同一套设置
let orderJson = JsonConvert.SerializeObject(order, settings)
let orderObj = JsonConvert.DeserializeObject<Order>(orderJson, settings)

还是那句话,示例里用空字符串兜底是为了展示兼容思路,真实项目建议把缺失字段当成数据异常处理,不要让脏数据悄悄混进内存。

5.4 加新case也没那么可怕

版本演进的时候,给可区分联合加一个新case,比如加一个退款中的状态:

// 技术栈:F# + Newtonsoft.Json
// 新版订单状态,在原有基础上新增了 Refunding
type OrderStatus =
    | Pending
    | Paid of string
    | Shipped of string
    | Delivered
    | Cancelled of string
    | Refunding of amount: decimal   // 新增:退款中,记录退款金额

这时候做三件事就够了:

第一,在转换器的WriteJson里补上Refunding的分支,序列化格式继续走约定好的结构。

第二,在ReadJson里补上反序列化分支,老分支全部保留不动。

第三,把代码里所有match这个类型的表达式挨个补上Refunding分支。这一步编译器会帮你找全,不用自己拍脑袋回忆哪里用到了订单状态。

我那会儿加这个case,花的时间主要都在补match分支,但这恰恰是一种安全感。因为编译器已经替你确认过:所有该处理的地方都处理了。相比老办法里靠人肉搜索状态判断来排查,效率高得不是一点半点。

六、关联技术串讲

6.1 和System.Text.Json怎么比

除了Newtonsoft.Json,微软自家的System.Text.Json在.NET生态里用得也很多。它性能好、默认行为更安全,API也一直在完善。但在F#可区分联合这块,System.Text.Json的支持力度一直比较委婉,官方没有提供开箱即用的DU转换,很多时候要自己写JsonConverterFactory,还要处理泛型参数、运行时类型注册这些细节。

如果你的项目从零开始,并且全部服务都是.NET,用System.Text.Json配自研转换器完全可行。但我个人经验是,在涉及兼容老数据、要同时兼容字符串和对象格式的复杂场景里,Newtonsoft.Json的JsonConverter更直白一些,调整空间也更大。各花入各眼,关键是转换逻辑要自己做主,不要依赖默认序列化。

6.2 和枚举、联合类型在其他语言的对比

有些朋友会问:C#也有联合类型(OneOf这类库),TypeScript也有可辨识联合,为什么不直接用它们?我的看法是,F#可区分联合是编译器原生支持的,内置模式匹配和穷尽检查,这是第三方库模仿不来的。TypeScript的可辨识联合在类型层面确实好用,但它是结构类型系统,运行时没有对应的标签,序列化的时候依然要自己维护判别字段。而F#这类标签联合天然就带着case的标签信息,配合自定义序列化器,跑起来很省心。

七、应用场景、优点与缺点

7.1 适合的应用场景

可区分联合最擅长的是那些状态清晰、分支明确的领域模型。我实际用下来,下面这几类场景收益最大:

订单状态机,这是最典型的应用。除了订单,支付单、退款单、工单、审批流这些流程类业务都适用。

领域事件。系统内部通过消息解耦的时候,事件类型天然适合用可区分联合来表达。比如订单已创建、订单已支付、库存已扣减,每种事件带各自的数据。

表达式和命令模式。解析器、配置规则、命令分发这类场景,树形结构的表达用可区分联合也是无出其右。一个表达式可以是数字、加法、乘法,这种递归结构用DU写起来特别顺手。

7.2 优点

穷尽匹配是最大的加分项。编译器强迫你把所有case处理完,少一个分支就编译失败,把一类运行时bug直接消灭在编译期。

不可能的状态无法表达。可区分联合保证了有数据就是有数据,没数据就是没数据,不存在某个字段被塞了一个null的暧昧状态。

领域知识集中。状态定义、状态流转规则、状态描述逻辑,全都可以集中在和订单状态相关的几个函数里,别人接手代码也容易看懂。

版本演进的编译器辅助。新加case时所有需要修改的点,编译器都会提示,不用担心漏改。

7.3 缺点

序列化需要额外投入。可区分联合的默认序列化结果不适合直接对外,你得自己写转换器,还要做周全的兼容设计,这部分工作量不算小。

跨语言协作成本高。F#是.NET世界里的语言,如果下游是Java、Go或者Node.js,对方看到你的DU格式需要理解半天。所以对外传输一定要走稳定Schema,并把文档写清楚。

不是所有数据结构都适合DU。如果你的业务状态之间本来就是连续变化的,或者附加数据大量重叠,硬用DU会弄得很别扭,这时候用记录类型加校验反而更合适。

学习曲线客观存在。模式匹配、递归类型这些概念对新手有门槛,团队里如果只有一两个人熟F#,长期维护会比较吃力。

八、注意事项

自己写JSON转换器的时候,我建议做好这几点:

反序列化时坚决不静默吞掉未知值。有些团队图省事,遇到未知状态直接返回待支付,这会让脏数据悄悄进入系统,还会掩盖上游的格式错误。宁可抛异常,让监控报警把问题暴露出来,也不要默默降级。

转换器里要处理null。JToken.Load读到的节点可能是Null类型,忘了处理的话,老数据里状态字段为null的订单会在反序列化时炸出一些很费解的异常。我在ReadJson里专门加了个判断,要么返回一个默认值,要么抛一个明确错误,反正不能让它裸奔。

不要轻易改已上线的字段名。status、trackingNo这些字段一旦发布出去,就是和下游的契约。真要改名,得做老数据的双写兼容,成本很高。

用版本号字段兜底。如果订单数据要长期留存,可以在根节点加个schemaVersion字段。将来有大的结构变化时,你至少还有一条退路:针对不同版本走不同的反序列化分支。

九、总结

回到最初的问题:用F#可区分联合给订单领域建模,序列化多态和版本兼容这些麻烦事到底能不能顺畅解决?

我的体会是能,但有一个前提,就是不要指望默认序列化,也不要指望一次建模就能高枕无忧。可区分联合本身为你省掉了大把表示非法状态和遗漏分支的烦恼,这部分体验是真香。而跨进程传输和长期演进这块,老老实实写一套自定义转换器,把序列化格式掌握在自己手里,把老数据的老格式兼容做好,版本升级就不再是每次都要提心吊胆的大工程。

领域建模不光是选一个类型,更是一场和未来的博弈。可区分联合加上良好的序列化设计,至少让你在这盘博弈里,多数时候能保持淡定。