How to parse different ISO date/time formats with

2020-03-31 09:01发布

问题:

Our Rest API takes JSON input from several external parties. They all use "ISO-ish" formats, but the formatting of the time zone offset is slightly different. These are some of the most common formats we see:

2018-01-01T15:56:31.410Z
2018-01-01T15:56:31.41Z
2018-01-01T15:56:31Z
2018-01-01T15:56:31+00:00
2018-01-01T15:56:31+0000
2018-01-01T15:56:31+00

Our stack is Spring Boot 2.0 with Jackson ObjectMapper. In our data classes we use the type java.time.OffsetDateTime a lot.

Several developers have tried to achieve a solution that parses all of the above formats, none have been successful. Particularly the fourth variant with a colon (00:00) seems to be unparseable.

It would be great if the solution works without having to place an annotation on each and every date/time field of our models.

Dear community, do you have a solution?

回答1:

One alternative is to create a custom deserializer. First you annotate the respective field:

@JsonDeserialize(using = OffsetDateTimeDeserializer.class)
private OffsetDateTime date;

And then you create the deserializer. It uses a java.time.format.DateTimeFormatterBuilder, using lots of optional sections to deal with all the different types of offsets:

public class OffsetDateTimeDeserializer extends JsonDeserializer<OffsetDateTime> {

    private DateTimeFormatter fmt = new DateTimeFormatterBuilder()
        // date/time
        .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
        // offset (hh:mm - "+00:00" when it's zero)
        .optionalStart().appendOffset("+HH:MM", "+00:00").optionalEnd()
        // offset (hhmm - "+0000" when it's zero)
        .optionalStart().appendOffset("+HHMM", "+0000").optionalEnd()
        // offset (hh - "+00" when it's zero)
        .optionalStart().appendOffset("+HH", "+00").optionalEnd()
        // offset (pattern "X" uses "Z" for zero offset)
        .optionalStart().appendPattern("X").optionalEnd()
        // create formatter
        .toFormatter();

    @Override
    public OffsetDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        return OffsetDateTime.parse(p.getText(), fmt);
    }
}

I also used the built-in constant DateTimeFormatter.ISO_LOCAL_DATE_TIME because it takes care of the optional fraction of seconds - and the number of fractional digits seems to be variable as well, and this built-in formatter already takes care of those details for you.


I'm using JDK 1.8.0_144 and found a shorter (but not much) solution:

private DateTimeFormatter fmt = new DateTimeFormatterBuilder()
    // date/time
    .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
    // offset +00:00 or Z
    .optionalStart().appendOffset("+HH:MM", "Z").optionalEnd()
    // offset +0000, +00 or Z
    .optionalStart().appendOffset("+HHmm", "Z").optionalEnd()
    // create formatter
    .toFormatter();

Another improvement you can make is change the formatter to be static final, because this class is immutable and thread-safe.



回答2:

This is just about a quarter of an answer. I neither have experience with Kotlin nor Jackson, but I have a couple of solutions in Java that I’d like to contribute. I should be glad if you can fit them into a total solution somehow.

    String modifiedEx = ex.replaceFirst("(\\d{2})(\\d{2})$", "$1:$2");
    System.out.println(OffsetDateTime.parse(modifiedEx));

On my Java 9 (9.0.4) the one-arg OffsetDateTime.parse parses all of your example strings except the one with offset +0000 without colon. So my hack is to insert that colon and then parse. The above parses all of your strings. It doesn’t work readily in Java 8 (there were some changes from Java 8 to Java 9).

The nicer solution that works in Java 8 too (I have tested):

    DateTimeFormatter formatter = new DateTimeFormatterBuilder()
            .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
            .appendPattern("[XXX][XX][X]")
            .toFormatter();
    System.out.println(OffsetDateTime.parse(ex, formatter));

The patterns XXX, XX and X match +00:00, +0000 and +00, respectively. We need to try them in order from the longest to the shortest to make sure that all text is being parsed in all cases.



回答3:

Thank you very much for all your input!

I chose the deserializer suggested by jeedas combined with the formatter suggested by Ole V.V (because it's shorter).

class DefensiveIsoOffsetDateTimeDeserializer : JsonDeserializer<OffsetDateTime>() {
    private val formatter = DateTimeFormatterBuilder()
        .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
        .appendPattern("[XXX][XX][X]")
        .toFormatter()

    override fun deserialize(p: JsonParser, ctxt: DeserializationContext) 
      = OffsetDateTime.parse(p.text, formatter)

    override fun handledType() = OffsetDateTime::class.java
}

I also added a custom serializer to make sure we use the correct format when producing json:

class OffsetDateTimeSerializer: JsonSerializer<OffsetDateTime>() {
    override fun serialize(
        value: OffsetDateTime, 
        gen: JsonGenerator, 
        serializers: SerializerProvider
    ) = gen.writeString(value.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))

    override fun handledType() = OffsetDateTime::class.java
}

Putting all the parts together, I added a @Configuraton class to my spring classpath to make it work without any annotations on the data classes:

@Configuration
open class JacksonConfig {

  @Bean
  open fun jacksonCustomizer() = Jackson2ObjectMapperBuilderCustomizer { 
    it.deserializers(DefensiveIsoOffsetDateTimeDeserializer())
    it.serializers(OffsetDateTimeSerializer())
  }
}