Use the non-default constructor with Jerkson?

2019-09-02 10:53发布

问题:

I need to serialize/deserialize a Scala class with structure something like the following:

@JsonIgnoreProperties(ignoreUnknown = true, value = Array("body"))
case class Example(body: Array[Byte]) {

    lazy val isNativeText = bodyIsNativeText
    lazy val textEncodedBody = (if (isNativeText) new String(body, "UTF-8") else Base64.encode(body))

    def this(isNativeText: Boolean, textEncodedBody: String) = this((if(isNativeText) str.getBytes("UTF-8") else Base64.decode(textEncodedBody)))

    def bodyIsNativeText: Boolean = // determine if the body was natively a string or not

}

It's main member is an array of bytes, which MIGHT represent a UTF-8 encoded textual string, but might not. The primary constructor accepts an array of bytes, but there is an alternate constructor which accepts a string with a flag indicating whether this string is base64 encoded binary data, or the actual native text we want to store.

For serializing to a JSON object, I want to store the body as a native string rather than a base64-encoded string if it is native text. That's why I use @JsonIgnoreProperties to not include the body property, and instead have a textEncodedBody that gets echoed out in the JSON.

The problem comes when I try to deserialize it like so:

val e = Json.parse[Example]("""{'isNativeText': true, 'textEncodedBody': 'hello'}""")

I receive the following error:

com.codahale.jerkson.ParsingException: Invalid JSON. Needed [body], but found [isNativeText, textEncodedBody].

Clearly, I have a constructor that will work...it just is not the default one. How can I force Jerkson to use this non-default constructor?

EDIT: I've attempted to use both the @JsonProperty and @JsonCreator annotation, but jerkson appears to disregard both of those.

EDIT2: Looking over the jerkson case class serialization source code, it looks like a case class method with the same name as its field will be used in the way that a @JsonProperty would function - that is, as a JSON getter. If I could do that, it would solve my problem. Not being super familiar with Scala, I have no idea how to do that; is it possible for a case class to have a user-defined method with the same name as one of its fields?

For reference, here is the code below that leads me to this conclusion...

private val methods = klass.getDeclaredMethods
                                .filter { _.getParameterTypes.isEmpty }
                                .map { m => m.getName -> m }.toMap

  def serialize(value: A, json: JsonGenerator, provider: SerializerProvider) {
    json.writeStartObject()
    for (field <- nonIgnoredFields) {
      val methodOpt = methods.get(field.getName)
      val fieldValue: Object = methodOpt.map { _.invoke(value) }.getOrElse(field.get(value))
      if (fieldValue != None) {
        val fieldName = methodOpt.map { _.getName }.getOrElse(field.getName)
        provider.defaultSerializeField(if (isSnakeCase) snakeCase(fieldName) else fieldName, fieldValue, json)
      }
    }
    json.writeEndObject()
  }

回答1:

Correct me if I'm wrong, but it looks like Jackson/Jerkson will not support arbitrarily nested JSON. There's an example on the wiki that uses nesting, but it looks like the target class must have nested classes corresponding to the nested JSON.

Anyway, if you're not using nesting with your case classes then simply declaring a second case class and a couple implicit conversions should work just fine:

case class Example(body: Array[Byte]) {
    // Note that you can just inline the body of bodyIsNativeText here
    lazy val isNativeText: Boolean = // determine if the body was natively a string or not
}

case class ExampleRaw(isNativeText: Boolean, textEncodedBody: String)

implicit def exampleToExampleRaw(ex: Example) = ExampleRaw(
    ex.isNativeText,
    if (ex.isNativeText) new String(ex.body, "UTF-8")
    else Base64.encode(ex.body)
)

implicit def exampleRawToExample(raw: ExampleRaw) = Example(
    if (raw.isNativeText) raw.textEncodedBody.getBytes("UTF-8")
    else Base64.decode(textEncodedBody)
)

Now you should be able to do this:

val e: Example = Json.parse[ExampleRaw](
  """{'isNativeText': true, 'textEncodedBody': 'hello'}"""
)

You could leave the original methods and annotations you added to make the JSON generation continue to work with the Example type, or you could just convert it with a cast:

generate(Example(data): ExampleRaw)

Update:

To help catch errors you might want to do something like this too:

case class Example(body: Array[Byte]) {
    // Note that you can just inline the body of bodyIsNativeText here
    lazy val isNativeText: Boolean = // determine if the body was natively a string or not
    lazy val doNotSerialize: String = throw new Exception("Need to convert Example to ExampleRaw before serializing!")
}

That should cause an exception to be thrown if you accidentally pass an instance of Example instead of ExampleRaw to a generate call.