1
\$\begingroup\$

I originally posted this on Stack overflow and was told to post it here, so here it is. Here's the stack overflow question: https://stackoverflow.com/questions/66528393/generic-class-for-a-gson-linkedhashmap

I have been working on this solution for months and I have come to the conclusion that there is no clean way to achieve what I am trying to achieve. I feel as though my education in polymorphism is failing me, so I've come to StackOverflow to get a second opinion. Sorry if this seems long and convoluted. That's been my brain for the past couple of months and at this point I'm out of ideas. I'm hoping somebody can take a look and see that I could've avoided all this mess by doing it some other way.

What I am trying to achieve is two generic classes: One that can represent any "saveable" object, and one that can represent a list of saveable objects (or what I call a "store"). A saveable object can save itself using GSON, and a store can also save itself using GSON to a JSON file. The difference being that saveable objects are generically representing any GSON object that can be saved, whereas stores are extending from saveables to become a saveable hash map of objects via IDs.

An example output I am looking for is as so:

Imagine I have an object with a uuid string field and a name string field. I want to be able to create a Store, which is a LinkedHashMap, of these objects, but also extend a Saveable to allow the objects to be saved as so:

test.json

{"dbf39199209e466ebed0061a3491ed9e":{"uuid":"dbf39199209e466ebed0061a3491ed9e","name":"Example Name"}}

I would also like to be able to load this JSON back into the objects via the Store's load method.

An example code usage would be like so:

Store<User> users = new Store<>();
users.load();
users.add(new User("dbf39199209e466ebed0061a3491ed9e", "Example Name"));
users.save();

My Attempts

Saveables

What I expect a "saveable" object to be able to do is as follows: provide a non-argumented method for saving and provide a non-argumented method for loading. A saveable object represents any object that can be saved via GSON. It contains two fields: a Gson gson object and a Path location. I provide those in the constructor of my saveable. I then want to provide two methods: a Saveable#save() method and a Saveable#load() method (or a static Saveable#load() method, I am indifferent). The way you use a Saveable object is by extending it (so it is abstract) to another object representing something, say, TestSaveable, and then the usage is as so:

TestSaveable saveable = new TestSaveable(8);
saveable.save(); // Saves data
saveable.setData(4);
saveable = saveable.load(); // Loads old data

I also would like a saveable object to be able to handle a generic, such as an integer (think of the last example but with an integer generic). This would allow me to execute my next plan for Stores.

My attempt at an implementation was the following:

public abstract class Saveable {

    private transient Gson gson;
    private transient Path location;

    public Saveable(Gson gson, Path location) {
        this.gson = gson;
        this.location = location;
    }

    @SuppressWarnings("unchecked")
    public <T extends Saveable> T save() throws IOException {
        if (location.getParent() != null) {
            Files.createDirectories(location.getParent());
        }
        Files.write(location, gson.toJson(this).getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, LinkOption.NOFOLLOW_LINKS);
        return (T) this;
    }

    protected <T extends Saveable> T load(Class<T> clazz, @NotNull Class<?>... generics) throws IOException {
        if (!Files.exists(location)) {
            return this.save();
        } else {
            InstanceCreator<Saveable> creator = type -> this;
            Type type = TypeToken.getParameterized(clazz, generics).getType();
            Gson newGson = gson.newBuilder().registerTypeAdapter(type, creator).create();
            return newGson.fromJson(Files.newBufferedReader(location), type);
        }
    }

}

Unfortunately, this attempt failed in my goal, because upon making my TestSaveable class users still had to pass the generic through for loading:

public class TestSaveable<T> extends Saveable {

    public boolean testBool = false;
    public T value;

    public TestSaveable(T value) {
        super(new Gson(), Path.of("test.json"));
        this.value = value;
    }

    public final TestSaveable<T> load(Class<T> generic) throws IOException {
        return super.load(TestSaveable.class, generic);
    }

}

However, through this I did get a fairly clean implementation with the exception of little to no type checking at all and constantly having to add supressions for it:

public class Test {

    public static void main(String[] args) {
        try {
            TestSaveable<Integer> storeB4 = new TestSaveable<>(5).save();
            storeB4.value = 10;
            TestSaveable<Integer> store = storeB4.load(Integer.class);
            System.out.println("STORE: " + store);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

Stores

Stores are an extension of saveables. A store is a LinkedHashMap which will quickly and easily save all of the objects in it as a map in GSON. Unfortunately, I'm not even sure where to start on this. I cannot extend two objects (the two being a LinkedHashMap<String, T> and a Saveable), but I also cannot use interfaces for the Saveable object.

I previously tried the following using the IStorable and ISaveable classes as an alternative to the abstract Saveable class I've shown you above, but this resulted in another very ugly and non-robust solution to my issue.

Saveable.java

public class Saveable {

    // Suppress default constructor
    private Saveable() {}

    // Save a class to the specified location using the specified gson
    public static <T extends ISaveable> T save(T instance) throws IOException {
        Files.createDirectories(instance.getLocation().getParent());
        Files.write(instance.getLocation(), instance.getGson().toJson(instance).getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, LinkOption.NOFOLLOW_LINKS);
        return instance;
    }

    // Load a file from the specified location using the specified gson and cast it to the specified class using the specified generic
    public static <T extends ISaveable> ISaveable load(Path location, Gson gson, Class<T> clazz, Class<?> genericClazz) throws IOException {
        if (!Files.exists(location)) {
            return null;
        } else {
            TypeToken<?> type = genericClazz == null ? TypeToken.get(clazz) : TypeToken.getParameterized(clazz, genericClazz);
            ISaveable saveable = gson.fromJson(Files.newBufferedReader(location), type.getType());
            saveable.setGson(gson);
            saveable.setLocation(location);
            return saveable;
        }
    }

}

ISaveable.java

public interface ISaveable {

    // Gson
    Gson getGson();
    void setGson(Gson gson);

    // Location
    Path getLocation();
    void setLocation(Path location);

}

IStorable.java

public interface IStoreable {

    String getUuid();

}

Store.java

public class Store<T extends IStoreable> extends LinkedHashMap<String, T> implements ISaveable {

    private transient Path location;
    private transient Gson gson;

    public Store(Path location, Gson gson) {
        this.location = location;
        this.gson = gson;
    }
    public Store() {
        this.location = null;
        this.gson = null;
    }

    public Store<T> put(T value) {
        this.put(value.getUuid(), value);
        return this;
    }

    public Store<T> remove(T value) {
        this.remove(value.getUuid());
        return this;
    }

    public Store<T> save() throws IOException {
        return Saveable.save(this);
    }

    @SuppressWarnings("unchecked")
    public static <T extends IStoreable> Store<T> load(Path location, Gson gson, Class<T> genericClazz) throws IOException {
        ISaveable saveable = Saveable.load(location, gson, Store.class, genericClazz);
        if (saveable == null) {
            return new Store<T>(location, gson).save();
        } else {
            return (Store<T>) saveable;
        }
    }

}

This solution achieved me almost the result I was looking for, but fell short quickly on the loading process as well as just not being a robust solution, excluding the hundreds of Java practices I'm sure to have ruined at this point:

Store<ExampleStoreable> store = Store.load(Paths.get("storetest.json"), new Gson(), ExampleStoreable.class);
store.put(new ExampleStoreable("Example Name"));
store.save();

And before I get any comments saying I shouldn't be posting this on StackOverflow: if not here, where else? Please help point me in the right direction, I'd love to not be left in the dark.

Thanks if anyone is able to help and if not I understand. This isn't the easiest question by any means.

\$\endgroup\$
0
3
\$\begingroup\$

Why do you need to provide a non-argument save and load methods? This means that you are packing storage logic into the data object and it violates the single responsibility principle.

Personally I do not even want to see annotations that are specific to certain storage mechanism in a data objects (and I am aware that it is a level of fundamentalism not shared by many people). Why should the data object know about the storage mechanism? Data should not be aware of how or where it is stored. That is the responsibility of the persistence layer. And once the persistence layer changes to the newest fashionable serialization format, all the data objects would have to change even though their main purpose and responsibility has not changed a bit.

Because your ISaveable interface requires the Path parameter it is also very limited in where the data can be stored. E.g. everything has to go to a file in a file system. Should someone want to store the data to a generic OutputStream, a completely different implementation has to be written, so the framework isn't very flexible.

So I suggest that unless you have very good reasons for the way you are implementing the storage (the convenience of calling one method is not it, that can be achieved with other designs too), I suggest that you move the storage mechanism into a separate persistence layer and implement the serialization by writing a TypeAdapter to each class that you or whoever uses the storage mechanism, stores to the system. Most likely the majority of data can use the default TypeAdapters that come with Gson but for complex types you have to provide a way to do custom serialization. And if your data objects change, you have to be able to provide a transformation from the now-incompatible-storage-format to the data model.

\$\endgroup\$
1
  • \$\begingroup\$ I appreciate the insightful response. I actually found a working solution that I think may help explain what I was seeking for better. This is actually what I was going for - I wanted the Saveable object to not have any knowledge of the objects it needs to save, allowing for any object extending Saveable to simply run .save() and .load() without any knowledge of the upper classes (only handling the responsibility of saving and loading). GSON handles all type conversation and mapping. See my stackoverflow answer for what I'm talking about, I'll update it to include an example usage too. \$\endgroup\$ – FireController1847 Mar 9 at 7:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.