如果您不确定某个值是否可以为空,则处理来自 API 的可能空值

Handling possible null values from an API if you are not sure if a value could be null

moshi 1.11.0

我从 sportsapidata API

中填充了以下数据 class
@JsonClass(generateAdapter = true)
data class PlayerEntity(
        @Json(name = "player_id")
        val playerId: Int,
        val firstname: String,
        val lastname: String,
        val birthday: String,
        val age: Int,
        val weight: Int,
        val height: Int,
)

问题是,来自 API 的数据对于某些值可能为空,想知道创建数据时的最佳做法是什么 class。

下面的数据显示 weightheight 为空,但在其他情况下也可能是其他值。

  "data": [
    {
      "player_id": 2497,
      "firstname": "Jay-Alistaire Frederick",
      "lastname": "Simpson",
      "birthday": "1988-12-01",
      "age": 32,
      "weight": 85,
      "height": 180,
    },
    {
      "player_id": 2570,
      "firstname": "Simranjit",
      "lastname": "Singh Thandi",
      "birthday": "1999-10-11",
      "age": 21,
      "weight": null,
      "height": null,
    }]

将所有值都设置为 nullable types 以便可以将 null 分配给它们会更好吗

@JsonClass(generateAdapter = true)
data class PlayerEntity(
        @Json(name = "player_id")
        val playerId: Int?,
        val firstname: String?,
        val lastname: String?,
        val birthday: String?,
        val age: Int?,
        val weight: Int?,
        val height: Int?,
)

如果可以缺少属性,我会让它们可以为空。当 null 更准确地表示实际数据时,为什么要将默认值 0 分配给体重和身高? playerId 属性 可能是个例外,因为如果没有标识符,播放器实体可能不太有用。

它归结为您想对播放器实体执行的操作。如果可空属性使实体使用起来更加复杂,您当然可以使用默认值。 Kotlin 支持可空类型和默认值。

最后,Moshi supports both reflection and codegen for Kotlin。两种方法都有利有弊。使用反射,您可以省略 JsonClass 注释,您的代码将如下所示:

// build.gradle.kts
implementation("com.squareup.moshi:moshi:1.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.11.0")


// Kotlin code
import com.squareup.moshi.Json
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory


fun main() {
    val jsonPlayers = """
        {
            "data": [
                {
                    "player_id": 2497,
                    "firstname": "Jay-Alistaire Frederick",
                    "lastname": "Simpson",
                    "birthday": "1988-12-01",
                    "weight": 85,
                    "height": 180
                },
                {
                    "player_id": 2570,
                    "firstname": "Simranjit",
                    "lastname": "Singh Thandi",
                    "birthday": "1999-10-11",
                    "age": 21,
                    "weight": null,
                    "height": null
                }
            ]
        }
        """

    val moshi = Moshi.Builder()
            .addLast(KotlinJsonAdapterFactory())
            .build()

    val playerListAdapter = moshi.adapter(PlayerList::class.java)
    val players = playerListAdapter.fromJson(jsonPlayers)

    println("Players:")
    players?.data?.forEach { println(it) }
}


data class PlayerList(
        val data: List<PlayerEntity>
)


data class PlayerEntity(
        @Json(name = "player_id")
        val playerId: Int,
        val firstname: String?,
        val lastname: String?,
        val birthday: String?,
        val age: Int = 0,
        val weight: Int?,
        val height: Int?
)

问题来自这样一个事实,即 PlayerEntity 混合了两个职责:

  • 这是一个数据传输对象。因此,它应该遵守 API 合同的任何内容。如果它允许所有字段为空,那么这些字段应该可以为空。
  • 它是域模型的一部分(鉴于 Entity 后缀,我假设如此)。因此,它应该遵守您的域规则。当然,您不希望仅仅因为 API 允许它们而拥有可为空的字段。

这种混合职责是一种捷径。只要两个模型非常相似并且您不牺牲域一致性,就可以接受。

如果未设置 weight 字段,您的域逻辑是否仍然有效?大概吧。然后继续,让它可以为空。这就是可空类型的用途。他们清楚地传达了价值可能缺失的信息。我不会在这里使用任何默认值:它会要求你记住每次使用它时检查字段值是否等于默认值(我猜 wight = 0 在你的域中没有多大意义) .可空类型会让编译器提醒你检查。

如果未设置 playerId 字段,您的域逻辑是否仍然有效?我觉得不是。那么它不应该为空,你需要拒绝这个值。拒绝它的简单方法将使提交的文件不可为空。库(在你的例子中是 moshi)会抛出一些你需要处理的难看的错误。

一些更复杂的场景怎么样,比方说 age?正如其中一条评论中提到的,它可以从 birthday 中计算出来。但是,如果 API 有时 returns birthday,有时 age,有时两者兼而有之,有时又 none 呢?假设您实际上对 age 字段感兴趣,但没有它您仍然可以生活。好吧,逻辑变得有点复杂,您肯定不希望每次访问 age 字段时都处理它。在那种情况下,考虑将 PlayerEntity 拆分为 PlayerEntityPlayerDto,引入一种 anti-corruption-layer (a mapper simply speaking). The key point is to keep your domain pure and to deal with all the uncertainties at the boundaries. If you prefer not to have two Player classes, you might consider creating a custom-type-adapter.

更新:

关于其中一条评论的声明:

A null value = an absent value. The class constructor itself will asign the default value if it receives null, this is a Kotlin thing, it doesn't matter if it is moshi, jackson or gson.

肯定不是这样的,null和absent是不一样的。既不适合 Kotlin 本身,也不适合 Moshi。考虑以下片段:

data class Data(var field: String? = "Test")

@Test
fun test() {

    val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
    val jsonAdapter = moshi.adapter(Data::class.java);

    println("Kotlin constructor, missing field: ${Data()}")
    println("Kotlin constructor, null: ${Data(null)}")
    println("Moshi, missing field: ${jsonAdapter.fromJson("{}")}")
    println("Moshi, null: ${jsonAdapter.fromJson("""{"field": null}""")}")
}
Kotlin constructor, missing field: Data(field=Test)
Kotlin constructor, null: Data(field=null)
Moshi, missing field: Data(field=Test)
Moshi, null: Data(field=null)

如果 field 不可为 null,尝试反序列化 {"field": null} 将引发异常,即使该字段具有默认值也是如此。

对我来说,处理可空字段的最简单方法是使用这个 plugin 来生成所有必要的 类 我需要这样的 JSON数据。要管理 Non-Null 和 Nullable 字段,您只需要 select Advance 并遵循此配置。 不要忘记 select Annotaion 选项卡下的正确注释。