How i can recover a Map of objects from json?

2019-07-21 14:07发布

问题:

I have this class

public static class SomeClass {
    public SomeClass(String field) {
        this.field = field;
    }

    private final String field;

    public String getField() {
        return field;
    }
}

I have also this test (edited)

@Test
public void testStringifyMapOfObjects() {
    Map<String, SomeClass> original = Maps.newTreeMap();
    original.put("first", new SomeClass("a"));
    original.put("second", new SomeClass("b"));
    String encoded = JsonUtil.toJson(original);
    Map<String, SomeClass> actual = JsonUtil.fromJson(encoded, Map.class);
    Assert.assertEquals("{'first':{'field':'a'},'second':{'field':'b'}}", encoded.replaceAll("\\s", "").replaceAll("\"", "'"));
    Assert.assertEquals(original.get("first"), actual.get("first"));
}

The test fails with

junit.framework.AssertionFailedError: expected:<eu.ec.dgempl.eessi.facade.transport.test.TestToolTest$SomeClass@6e3ed98c> but was:<{field=a}>
    at junit.framework.Assert.fail(Assert.java:47)
    at junit.framework.Assert.failNotEquals(Assert.java:277)
    at junit.framework.Assert.assertEquals(Assert.java:64)
    at junit.framework.Assert.assertEquals(Assert.java:71)
    at eu.ec.dgempl.eessi.facade.transport.test.TestToolTest.testStringifyMapOfObjects(TestToolTest.java:90)

Can I make json to properly serialize objects as the values of the map or should I use something else?

edited

public class JsonUtil {
    private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(JsonUtil.class);

    public static <T> String toJson(T data) {
        ObjectMapper mapper = new ObjectMapper();
        mapper.configure(Feature.INDENT_OUTPUT, true);
        try {
            return mapper.writeValueAsString(data);
        } catch (IOException e) {
            LOG.warn("can't format a json object from [" + data + "]", e);
            return null;
        }
        //
        // return Json.stringify(Json.toJson(data));
    }

    public static <T> T fromJson(String description, Class<T> theClass) {
        try {
            JsonNode parse = new ObjectMapper().readValue(description, JsonNode.class);
            T fromJson = new ObjectMapper().treeToValue(parse, theClass);
            return fromJson;
        } catch (JsonParseException e) {
            // throw new RuntimeException("can't parse a json object of type " + theClass.getName() + " from [" + description + "]", e);
            LOG.warn("can't parse a json object from [" + description + "]", e);
            return null;
        } catch (JsonMappingException e) {
            // throw new RuntimeException("can't parse a json object of type " + theClass.getName() + " from [" + description + "]", e);
            LOG.warn("can't parse a json object from [" + description + "]", e);
            return null;
        } catch (IOException e) {
            // throw new RuntimeException("can't parse a json object of type " + theClass.getName() + " from [" + description + "]", e);
            LOG.warn("can't parse a json object from [" + description + "]", e);
            return null;
        }
    }
}

回答1:

You are running into a problem related to Java generics. To summarize, when deserializing data into a non-reifiable type (aka a type for which actual type information is not available at runtime) you need to use a supertype token. You can get more detail about what a supertype token is (and why you need to use one) by reading these SO posts:

  • Pass parameterized type to method as argument
  • Error using Jackson and JSON
  • Deserialize JSON to ArrayList using Jackson

And also from the Jackson documentation:

  • Data Binding With Generics
  • TypeReference Javadoc

The basic problem is that when you use a typical generic object, the actual type parameters for the object aren't available at runtime. Therefore Jackson doesn't know which actual class to instantiate and deserialize your data into.

The easiest way to get around the problem would be adding an overload to your JSON utility class, that accepts a type reference (as opposed to a Class<T>). For example:

public static <T> T fromJson(String json, TypeReference<T> typeRef) {
     if(json == null || typeRef == null) return null;

     return new ObjectMapper().readValue(json, typeRef);
}

To be used as such:

Map<String, SomeClass> actual = JsonUtil.fromJson(
    encoded,
    new TypeReference<Map<String, SomeClass>>(){});


回答2:

I discovered that the simplest solution is to create a "container" class that will contain the map. This is working probably because the container has enough type details for the map, as opposed to the case when a map is used directly.

public static class SomeClass {
    private final String field;

    private SomeClass() {
        this("wrong");
    }
    public SomeClass(String field) {
        this.field = field;
    }

    public String getField() {
        return field;
    }
    @Override
    public String toString() {
        return "SomeClass[" + field + "]";
    }
}

public static class SomeClassContainer {
    private final Map<String, SomeClass> all = Maps.newTreeMap();

    public Map<String, SomeClass> getAll() {
        return all;
    }
}

After this ... the updated test is

@Test
public void testStringifyMapOfObjects() {
    SomeClassContainer original = new SomeClassContainer();
    original.getAll().put("first", new SomeClass("a"));
    original.getAll().put("second", new SomeClass("b"));
    String encoded = JsonUtil.toJson(original);
    System.out.println(encoded);
    SomeClassContainer actual = JsonUtil.fromJson(encoded, SomeClassContainer.class);
    System.out.println(ObjectUtils.toString(actual));
    Assert.assertEquals("{'all':{'first':{'field':'a'},'second':{'field':'b'}}}", encoded.replaceAll("\\s", "").replaceAll("[\"]", "'"));
    Assert.assertEquals("class eu.ec.dgempl.eessi.facade.transport.test.TestToolTest$SomeClass", actual.getAll().get("first").getClass().toString());
    Assert.assertEquals(original.getAll().get("first").toString(), actual.getAll().get("first").toString());
    Assert.assertEquals(original.getAll().get("second").toString(), actual.getAll().get("second").toString());
}