0

I am writing json files with gson (in Java), but I also want to read them back in. And, I have some data representing key,value pairs (where the key is only known at runtime), so I want to use a pair class to represent them, I am currently using the pair class from org.apache.commons.lang3.tuple, but it doesn't have a noArg constructor like gson needs to read the data back in, and thus the code fails when I do that step. Is there some other class that would work better? Here is a small version of the code I want.

import org.apache.commons.lang3.tuple.Pair;
import com.google.gson.GsonBuilder;
import com.google.gson.GsonBuilder;

class JsonFile { ArrayList<Event> events; 
  Public void diff(JsonFile other) {
    // code not shown, but checks some things for being equal
  } 
}

class Event { String type; // of event
  Parameters params;
  Public Event(String type, Parameters params) { 
    this.type = type;
    this.params = params;
  }
}

class Parameters extends ArrayList<Pair<String, Object>> {}

JsonFile eventsToWrite; // populated by code
JsonFile eventstoDiff; // a stored copy of what the file "should" look like

// ...
// Some code filling eventsToWrite
   Parameters params = new Parameters();
   params.add(Pair.of("xyzzy", "is a magic name"));
   params.add(Pair.of("foo", "bar");
   Event event = new Event("lispy event", params);
   eventsToWrite.add(event);
// ...

Reader reader new BufferedReader(new FileReader(new File("toDiff.json")));
GsonBuilder bld = new GsonBuilder();
Gson gsonRead = bld.create();
eventsToDiff = gsonRead.fromJson(reader, JsonFile.class); // this fails
eventsToWrite.diff(eventsToDiff);
intel_chris
  • 708
  • 1
  • 6
  • 17
  • Worse, it turns out when gson writes out the array of pairs, it simply writes {} as is the pair has no content. – intel_chris Jan 28 '21 at 19:14

4 Answers4

1

I see several options:

  1. You can use MutablePair instead of Pair. It should work out of the box.
  2. You might use Map<String,Object> instead of ArrayList<Pair<String, Object>>. This solution is valid only if parameter keys are unique.
  3. You can create your own Parameter class.
  4. If you insist on using Pair and you want to keep it immutable (actually it makes sense), you would need to implement your own TypeAdapter.
  5. Last but not least: use Jackson instead of Gson.
Mafor
  • 9,668
  • 2
  • 21
  • 36
1

TL;DR: Gson can't use Pair because there is no a way to instantiate it (at least Gson is aware of: unless you tell Gson to use Pair.of).

Sorry to say but you have a few issues with your solution.

  • Don't get bound to ArrayList at the declaration site where you can use List.
  • Extending Parameters from ArrayList is rather a bad choice (is ArrayList that special? what if you want LinkedList in the future? what if you need a Set instead? which set?) -- prefer aggregation and encapsulate the collection.
  • Pair can be replaced with Map.Entry -- use the most generalized type as possible (similar to the previous item).
    • Suprisingly, using a List for such pairs/entries may be better than using a Map (what if there's a need to store multiple keys in multimap fashion?)
  • Gson, unfortunately, won't let you serialize/deserialize Object easily because there is no way to reconstruct the original object from the JSON (how should Gson know which class to deserialize with whereas there are tens thousand classes? which one is preferrable? why pay for heuristics or baking in type tags right into JSON to let Gson the original object class (including parameterization if possible)?) -- see some more comments on a similar issue at Why GSON fails to convert Object when it's a field of another Object?.

One of possible ways to make it work with Gson is both migrating to string-string pairs (so that Gson could know how to make a JSON roundtrip for both keys and values) and writing a pair-aware type adapter factory.

final class PairTypeAdapterFactory
        implements TypeAdapterFactory {

    private static final TypeAdapterFactory instance = new PairTypeAdapterFactory();

    private PairTypeAdapterFactory() {
    }

    static TypeAdapterFactory getInstance() {
        return instance;
    }

    @Override
    @Nullable
    public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
        if ( !Pair.class.isAssignableFrom(typeToken.getRawType()) ) {
            return null;
        }
        final ParameterizedType parameterizedType = (ParameterizedType) typeToken.getType();
        final Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
        final TypeAdapter<Pair<Object, Object>> pairTypeAdapter = resolveTypeAdapter(gson, actualTypeArguments[0], actualTypeArguments[1]);
        @SuppressWarnings("unchecked")
        final TypeAdapter<T> typeAdapter = (TypeAdapter<T>) pairTypeAdapter;
        return typeAdapter;
    }

    private <L, R> TypeAdapter<Pair<L, R>> resolveTypeAdapter(final Gson gson, final Type leftType, final Type rightType) {
        @SuppressWarnings("unchecked")
        final TypeAdapter<L> leftTypeAdapter = (TypeAdapter<L>) gson.getDelegateAdapter(this, TypeToken.get(leftType));
        @SuppressWarnings("unchecked")
        final TypeAdapter<R> rightTypeAdapter = (TypeAdapter<R>) gson.getDelegateAdapter(this, TypeToken.get(rightType));
        return new TypeAdapter<Pair<L, R>>() {
            @Override
            public void write(final JsonWriter out, final Pair<L, R> value)
                    throws IOException {
                out.beginArray();
                leftTypeAdapter.write(out, value.getLeft());
                rightTypeAdapter.write(out, value.getRight());
                out.endArray();
            }

            @Override
            public Pair<L, R> read(final JsonReader in)
                    throws IOException {
                in.beginArray();
                final L left = leftTypeAdapter.read(in);
                final R right = rightTypeAdapter.read(in);
                in.endArray();
                return Pair.of(left, right);
            }
        }
                .nullSafe();
    }

}

The code above is pretty straight-forward and it also uses arrays to pack pairs into because you might want to store duplicate keys and save some space (additionally, a two-element array is most likely a good carrier for pairs).

@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
@ToString
final class Event {

    String type;
    List<Pair<String, String>> params;

}
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
@ToString
final class JsonFile {

    List<Event> events;

}
private static final Gson gson = new GsonBuilder()
        .disableHtmlEscaping()
        .disableInnerClassSerialization()
        .registerTypeAdapterFactory(PairTypeAdapterFactory.getInstance())
        .create();

@Test
public void test() {
    final JsonFile before = new JsonFile();
    final List<Pair<String, String>> params = new ArrayList<>();
    params.add(Pair.of("xyzzy", "is a magic name"));
    params.add(Pair.of("foo", "bar"));
    final Event event = new Event("lispy event", params);
    before.events = new ArrayList<>();
    before.events.add(event);
    final String json = gson.toJson(before, JsonFile.class);
    final JsonFile after = gson.fromJson(json, JsonFile.class);
    Assertions.assertEquals(before, after);
}

The intermediate JSON is as follows:

{
  "events": [
    {
      "type": "lispy event",
      "params": [
        [
          "xyzzy",
          "is a magic name"
        ],
        [
          "foo",
          "bar"
        ]
      ]
    }
  ]
}

Another (somewhat crazy) idea on using Gson is implementing an heuristics type adapter for Object that are annotated with a custom type-use annotation, say, @Heuristics (these kind of annotations came along with Java 8). This would allow you to parameterize the pairs like this: Pair<String, @Heuristics Object>, but as far as I know, Gson is unable to carry type use annotations within type tokens, so type adapters factories can't get them and apply a custom type adapter (caution: implementing such a type adapter for Object, even those that are not annotated, may cause unwanted global effects).


According to your new information on that, I may be implemented like this (without much commentary, but can be reimplemented if there are more another requirements):

@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
@ToString
final class JsonFile {

    List<Event<?>> events;

}
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
@ToString
final class Event<T> {

    String type;
    Map<String, T> params;

}
final class EventTypeAdapterFactory
        implements TypeAdapterFactory {

    private final Map<String, Class<?>> typeMap;

    private EventTypeAdapterFactory(final Map<String, Class<?>> typeMap) {
        this.typeMap = typeMap;
    }

    static TypeAdapterFactory getInstance(final Map<String, Class<?>> typeMap) {
        return new EventTypeAdapterFactory(typeMap);
    }

    @Override
    @Nullable
    public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
        if ( !Event.class.isAssignableFrom(typeToken.getRawType()) ) {
            return null;
        }
        final Map<String, TypeAdapter<?>> typeAdapterMap = typeMap.entrySet()
                .stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> gson.getDelegateAdapter(this, TypeToken.getParameterized(Map.class, String.class, e.getValue()))));
        final TypeAdapter<Event<?>> typeAdapter = new TypeAdapter<Event<?>>() {
            @Override
            public void write(final JsonWriter out, final Event<?> value)
                    throws IOException {
                out.beginObject();
                out.name("type");
                out.value(value.type);
                out.name("params");
                @Nullable
                @SuppressWarnings("unchecked")
                final TypeAdapter<Object> paramsTypeAdapter = (TypeAdapter<Object>) typeAdapterMap.get(value.type);
                if ( paramsTypeAdapter == null ) {
                    throw new IllegalStateException(value.type + " is not mapped to a specific type");
                }
                paramsTypeAdapter.write(out, value.params);
                out.endObject();
            }

            @Override
            public Event<?> read(final JsonReader in) {
                final JsonObject root = Streams.parse(in)
                        .getAsJsonObject();
                final String type = root.getAsJsonPrimitive("type")
                        .getAsString();
                @Nullable
                @SuppressWarnings("unchecked")
                final TypeAdapter<Object> paramsTypeAdapter = (TypeAdapter<Object>) typeAdapterMap.get(type);
                if ( paramsTypeAdapter == null ) {
                    throw new IllegalStateException(type + " is not mapped to a specific type");
                }
                final JsonObject rawParams = root.getAsJsonObject("params");
                @SuppressWarnings("unchecked")
                final Map<String, ?> params = (Map<String, ?>) paramsTypeAdapter.fromJsonTree(rawParams);
                return new Event<>(type, params);
            }
        }
                .nullSafe();
        @SuppressWarnings("unchecked")
        final TypeAdapter<T> castTypeAdapter = (TypeAdapter<T>) typeAdapter;
        return castTypeAdapter;
    }

}
public final class EventTypeAdapterFactoryTest {

    private static final Gson gson = new GsonBuilder()
            .disableHtmlEscaping()
            .disableInnerClassSerialization()
            .registerTypeAdapterFactory(EventTypeAdapterFactory.getInstance(ImmutableMap.of("lispy event", String.class)))
            .create();

    @Test
    public void test() {
        final JsonFile before = new JsonFile();
        final Map<String, Object> params = new LinkedHashMap<>();
        params.put("xyzzy", "is a magic name");
        params.put("foo", "bar");
        final Event<Object> event = new Event<>("lispy event", params);
        before.events = new ArrayList<>();
        before.events.add(event);
        final String json = gson.toJson(before, JsonFile.class);
        System.out.println(json);
        final JsonFile after = gson.fromJson(json, JsonFile.class);
        Assertions.assertEquals(before, after);
    }

}
{
  "events": [
    {
      "type": "lispy event",
      "params": {
        "xyzzy": "is a magic name",
        "foo": "bar"
      }
    }
  ]
}
  • Thanks for the detailed comments and the pointer. I am still absorbing them. I use ArrayList rather than list, because it provides me with an Iterator, which I need in code I didn't show. – intel_chris Jan 28 '21 at 21:38
  • Same thing about the need for Object. Parameters is actually intended to be a tagged union. The "type" field is the tag, but there are multiple classes the second object can be. The previous solution before I switched used distinct variables for each object type I needed to carry. But my consumer of the json wants to see it as, ``` "params": [ "xyzzy": "is a magic name", "foo": "bar" ] ``` – intel_chris Jan 28 '21 at 21:50
  • 1) `[ "xyzzy": "is a magic name", "foo": "bar" ]` is not a JSON. 2) You didn't mention the role of the `type` field to name the related tagged union. Of course, Gson is not unable to do that out of box. Again it was and it is not clear why should a single type field cover all second type fields at once. I guess the `type` field better should be of a part of the pair, making it a triplet, so that `..., Object>` could handle tagged unions properly. You **must** have a type adapter (serializer/deserializer pair) to let Gson be aware of that specifics. Having it an `Object` is useless. – terrorrussia-keeps-killing Jan 29 '21 at 04:40
  • @intel_chris See the update in the comment and in the answer. – terrorrussia-keeps-killing Jan 29 '21 at 05:16
  • Yes, sorry for being unclear in my question. Even calling it a tagged union is somewhat of a stretch. They are a variety of things, sometimes the names of variables in a dynamically typed language, sometimes the names of options to commands, etc. But they really are ordered items in lists and sometimes the type name is repeated, and the second argument is definitely is not always a string and my code doesn't care, because I don't do anything with it other than "report it" via Json. I just want my Json to look like my client wants. name,value pairs in an array.... – intel_chris Jan 29 '21 at 19:12
  • But your final result is fine, although I now have a solution that my client likes. And, I can't explain too much without violating my NDA. – intel_chris Jan 29 '21 at 19:17
1

If you check Pair source code you would see that it is an abstract class having no fields but some getters and setters. GSON requires fields (and an abstract class or an interface cannot be instantied so fails when deserializing). See this for example.

Now what Mafor's answer suggests as a first option -MutablePair - is an implementation of this abstract class Pair. It has fields for Gson to use.

public L left;
public R right;

It is a working choice .

Jackson can use getters & setters. However, if you declare the type as Pair and not as MutablePair there are no setters (and no fields) then deserialization might still fail. So Jackson solely might not be an answer. And if you use MutablePair also Gson works.

I think you should consider create your own MyPair class rather than try to find some existing "standard"

@Getter
@Setter
public class MyPair {
    private String left;
    private Object right;
}

Or refactor your design to use Map when there are no conflicting keys or when conflicting keys appear, use - for example - some suitable implementation of interface MultiValuedMap

pirho
  • 11,565
  • 12
  • 43
  • 70
  • This is useful. I didn't try mutable pair as my client suggested that I make each parameter a map, which is probably more overhead than mutablepair, but I get the output Json the client wants, which is what I really care about, as I have no idea what the client is using to read it nor why they want what they want (and don't have reason to care beyond giving them what they want). – intel_chris Jan 29 '21 at 19:04
0

What I ended up with (after discussing this with my client) is essentially this:

@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
final class Event {
    String type;
    List<Map<String, Object>> params;  
}

The map only maps one thing, so MutablePair as per @Mafor's suggestion probably would be better, but my client asked for Map, even though it has just one pair per map and is overkill.

Also, as discussed with @fluffy, I use ArrayList to get the .iterator() member function for the code in diff, which compares the parameters in order (same parameters in a different order would mean something else (e.g. copy A B is not the same as copy B A).

intel_chris
  • 708
  • 1
  • 6
  • 17