Javanese Online

Как распарсить JSON в библиотеке для Android

В последнем кодревью я сказал, что на месте разработчика приложения не хотел бы, чтобы библиотека тащила за собой Gson. Я могу в приложении использовать какую-нибудь другую библиотеку, например, Moshi, а иметь их две разных совершенно ни к чему.

После этого мы с Никитой Куликовым обсудили вопрос «ужасный встроенный org.json vs. нормальная и привычная сторонняя библиотека», и я решил раскрыть тему подробнее.

Постановка задачи

Нужно написать библиотеку для Android, которой по долгу службы приходится парсить джейсоны из ответов сервера, а порой и формировать собственные джейсоны для запросов.

Варианты решения

Вариант идеальный, но утопический: использовать Lychee Persistence. Это способ описания данных, не привязанный к конкретной библиотеке или формату сериализации. От клиента потребуется только передать в библиотеку пару функций: (InputStream) -> TokenStream для парсинга и (src: TokenStream, dest: OutputStream) -> Unit для записи токенов в формате JSON. В Android это позволяет использовать встроенные android.util.JsonReader и JsonWriter.

Вариант сферический, в вакууме: тоже принять в конструктор пару функций, но для работы с деревьями джейсона, которые легко можно выразить через стандартные Map<String, ?> и List. Кроме того, что динамические типы придётся обрабатывать голыми руками, разные библиотеки могут отработать немного по-разному, что уменьшает повторяемость конструкции.

Вариант эгоистичный: использовать ту библиотеку, которая больше всего нравится. Она может добавить веса, но так кодить приятнее, да и получается предсказуемо и повторяемо. Этот вариант использовал автор библиотеки FitBitAuth, которую я рефакторил в последнем видео.

Вариант «нативный»: работать с JSON средствами Android SDK. Именно его я показал, когда рефакторил FitBitAuth. Здесь остановимся подробнее.

Как обернуть org.json и не сразу сойти с ума

Представим такой контракт:

public interface User {
    String name();
    String surname();
    List<Invention> inventions();
}

public interface Invention {
    String name();
    String type();
}

public static void main(String[] args) throws JSONException {
    JSONObject json = new JSONObject(("{" +
            "'name':'Hank'," +
            "'surname':'Rearden'," +
            "'inventions':[" +
                "{'name':'Metal of Rearden', 'type': 'alloy'}" +
            "]}").replace('\'', '"'));

    JsonUser user = new ????User(json);
    System.out.println(user);
 // name: Hank, surname: Rearden, inventions: Metal of Rearden (alloy)
}

В этом месте я должен поймать косые взгляды, выдержать зрительный контакт и объяснить, что я не собираюсь отдавать «привычные» DTOшки с полями, геттерами и сеттерами: поля — это деталь реализации, геттеры — самообман (смотрите, я могу что-нибудь в них написать, не поломав совместимость! — э, ты чего, наркоман, логику в геттеры совать?), а сеттеры — изменяемость, которую нужно ограничивать и применять с большой осторожностью.

Итак, ????User из примера выше — адаптер JSONObject к User:

// грязный способ сэкономить пару строчек наследованием
// для лентяев вроде меня
abstract class JsonObjWrap {
    protected final JSONObject json;
    protected JsonObjWrap(JSONObject json) {
        this.json = json;
    }
}
final class JsonUser extends JsonObjWrap implements User {
    public JsonUser(JSONObject json) { super(json); }

    @Override public String name() { return json.optString("name"); }
    @Override public String surname() { return json.optString("surname"); }
    @Override public List<Invention> inventions() {
        throw new UnsupportedOperationException("ой-ой");
    }

    @Override public String toString() {
        return String.format(
                "name: %s, surname: %s, inventions: %s",
                name(),
                surname(),
                inventions().stream().map(Object::toString).collect(joining(", "))
                // Ну да, я взял стримы, чтобы сэкономить вам время чтения,
                // а себе — время написания.
        );
    }
}
final class JsonInvention extends JsonObjWrap implements Invention {
    public JsonInvention(JSONObject json) { super(json); }

    @Override public String name() { return json.optString("name"); }
    @Override public String type() { return json.optString("type"); }

    @Override public String toString() {
        return String.format("%s (%s)", name(), type());
    }
}

Что можно сказать об этом кусочке кода? В принципе, json.optString("name"); не сильно многословнее, чем @SerializedName("name"), а также предоставляет больше контроля: json.optString("name", "Безымянный герой"), json.getString("name" /* поле приходит в JSON, инфа 146%*/).

Вообще, все автоматические инструменты для сериализации джавовых объектов, включая Serializable, Gson-Jackson-Moshi, Hibernate и т. д., пытаются спрятать сериализацию подальше, будто это нечто второстепенное, и позволить разработчику не думать о ней. Они хотят поддержать все типы данных, обработать все угловые случаи и учесть особенности разных языков программирования, лишь бы клиент библиотеки не думал об этом сам.

Особый интерес представляет конвертация JSONArray<->List. Я предлагаю завернуть JSONArray так же точно, как завернули JSONObject. Поскольку org.json родом из 2007, нельзя просто написать jsonArray.map(JsonInvention::new)

final class JsonList<E> extends AbstractList<E> implements RandomAccess {
    private final JSONArray json;
    private final Function<? super Object, ? extends E> fromJson;
    JsonList(JSONArray json, Function<? super Object, ? extends E> fromJson) {
        this.json = json;
        this.fromJson = fromJson;
    }
    @Override public final int size() {
        return json.length();
    }
    @Override public final E get(int index) {
        return fromJson.apply(json.opt(index));
    }
}

final class JsonUser extends JsonObjWrap implements User {
    // конструктор, имя, фамилия — без изменений
    @Override public List<Invention> inventions() {
        return new JsonList<>(
                json.optJSONArray("inventions"),
                json -> new JsonInvention((JSONObject) json)
        );
    }
}

Для чтения джейсонов на этом можно остановиться: какого-то «заоблачного бойлерплейта» я здесь не наблюдаю. Каст (JSONObject) json — то же, что и expected BEGIN_OBJECT but was %actual%, только теперь в наших силах обработать это любым способом. Мы получили больше контроля за меньше внешних зависимостей, написав пару утилит: JsonObjWrap и JsonList.

Пишем JSON

У JsonInvention и JsonUser уже есть поле JSONObject json. На нём можно вызвать toString() и получить, собственно, джейсон.

Теперь нужно придумать способ превращения объектов в JSONObject:

Для превращения List в JSONArray, соответственно:

Конструкторы копирования

Первые два варианта разберу вместе, т. к. один хорошо выражается через другой; для листов — первый вариант, т. к. второго не существует:

final class JsonUser extends JsonObjWrap implements User {
    public JsonUser(JSONObject json) { super(json); }
    public JsonUser(String name, String surname, Iterable<Invention> inventions) {
        super(new JSONObject());
        put(json, "name", name);
        put(json, "surname", surname);
        put(json, "inventions", new JsonList<>(
                inventions,
                JsonInvention.FROM_JSON,
                JsonInvention.TO_JSON
        ).json);
    }
    public JsonUser(User copyFrom) {
        this(copyFrom.name(), copyFrom.surname(), copyFrom.inventions());
    }

    @Override public String name() { return json.optString("name"); }
    @Override public String surname() { return json.optString("surname"); }
    @Override public List<Invention> inventions() {
        return new JsonList<>(
                json.optJSONArray("inventions"),
                JsonInvention.FROM_JSON
        );
    }

 // @Override public String toString() { … }

    static final Function<Object, JsonUser> FROM_JSON =
            json -> new JsonUser((JSONObject) json);
    static final Function<User, JSONObject> TO_JSON =
            user -> new JsonUser(user).json;
}
final class JsonInvention extends JsonObjWrap implements Invention {
    public JsonInvention(JSONObject json) { super(json); }
    public JsonInvention(String name, String type) {
        super(new JSONObject());
        put(json, "name", name);
        put(json, "type", type);
    }
    public JsonInvention(Invention copyFrom) {
        this(copyFrom.name(), copyFrom.type());
    }

    @Override public String name() { return json.optString("name"); }
    @Override public String type() { return json.optString("type"); }

 // @Override public String toString() { … }

    static final Function<Object, JsonInvention> FROM_JSON =
            json -> new JsonInvention((JSONObject) json);
    static final Function<Invention, JSONObject> TO_JSON =
            inv -> new JsonInvention(inv).json;
}

final class JsonList<E> extends AbstractList<E> implements RandomAccess {
    private final JSONArray json;
    private final Function<? super Object, ? extends E> fromJson;
    JsonList(JSONArray json, Function<? super Object, ? extends E> fromJson) {
        this.json = json;
        this.fromJson = fromJson;
    }
    JsonList(
            Iterable<E> copyFrom,
            Function<? super Object, ? extends E> fromJson,
            Function<? super E, ?> toJson) {
        this.json = new JSONArray();
        for (E e : copyFrom) json.put(toJson.apply(e));
        this.fromJson = fromJson;
    }
    @Override public final int size() {
        return json.length();
    }
    @Override public final E get(int index) {
        return fromJson.apply(json.opt(index));
    }
}

/**
 * Unchecks JSONException by rethrowing as NPE.
 * JSONObject#checkName throws JSONException for null names
 */
private static void put(JSONObject into, String key, Object what) {
    try { into.put(key, what); }
    catch (JSONException e) { throw new NullPointerException(e.getMessage()); }
}

Астрологи объявили неделю двунаправленного связывания данных. Количество кода увеличилось вдвое.

Проблема раз. На каждое поле теперь приходится по две строчки кода: put в copy-конструкторе и opt/get в геттере.

Проблема два. Строковый литерал для ключа в джейсоне используется в обеих строчках. Вынос его в константу будет стоить ещё одной строки. Многословно.

Проблема три. Мы эксплуатируем логику в конструкторе и выбрасываем сам объект: new JsonInvention(inv).json. Глупо вышло.

Статический метод

Как у джавовых коллекций в докотлиновые времена, так и у JSONArray/JSONObject нет фабричных методов, которые позволили бы за один вызов создать объект и наполнить его данными. Придётся их написать.

final class JsonUser extends JsonObjWrap implements User {
    public JsonUser(JSONObject json) { super(json); }

 // @Override name, surname, inventions, toString

    static final Function<Object, JsonUser> FROM_JSON =
            json -> new JsonUser((JSONObject) json);
    static final Function<User, JSONObject> TO_JSON =
            user -> jsonObjOf(
                    "name",
                    user.name(),
                    "surname",
                    user.surname(),
                    "inventions",
                    jsonArrayOf(user.inventions(), JsonInvention.TO_JSON)
            );
}
final class JsonInvention extends JsonObjWrap implements Invention {
    public JsonInvention(JSONObject json) { super(json); }

 // @Override name, type, toString()

    static final Function<Object, JsonInvention> FROM_JSON =
            json -> new JsonInvention((JSONObject) json);
    static final Function<Invention, JSONObject> TO_JSON =
            inv -> jsonObjOf("name", inv.name(), "type", inv.type());
}

static JSONObject jsonObjOf(
            String name1, Object value1, String name2, Object value2) {
    JSONObject o = new JSONObject();
    put(o, name1, value1);
    put(o, name2, value2);
    return o;
}
static JSONObject jsonObjOf(
            String name1, Object value1,
            String name2, Object value2,
            String name3, Object value3) {
    JSONObject o = new JSONObject();
    put(o, name1, value1);
    put(o, name2, value2);
    put(o, name3, value3);
    return o;
}
static <T> JSONArray jsonArrayOf(
            Iterable<T> copyFrom, Function<? super T, ?> toJson) {
    JSONArray a = new JSONArray();
    for (T t : copyFrom) a.put(toJson.apply(t));
    return a;
}

Проблема три с конструкторами решена; проблемы с дублированием кода и литералов в принципе неразрешимы в этом контексте.

Изменяемые объекты

Я когда-то уже писал, что это зашкварно, но всё же попробовал это реализовать. Не для эстетов:

final class JsonUser extends JsonObjWrap implements User {
    public JsonUser(JSONObject json) { super(json); }
    
 // @Override name, surname
    @Override public List<Invention> inventions() {
        return new JsonMutableList<>(
                json.optJSONArray("inventions"),
                JsonInvention.FROM_JSON,
                JsonInvention.TO_JSON
        );
    }

    public void rename(String newName) {
        put(json, "name", newName);
    }
    public void changeSurname(String newSurname) {
        put(json, "surname", newSurname);
    }
    public void addInvention(Invention invented) {
        json.optJSONArray("inventions").put(JsonInvention.TO_JSON.apply(invented));
    }
    public void changeInventions(List<Invention> inventions) {
        put(json, "inventions", jsonArrayOf(inventions, JsonInvention.TO_JSON));
    }

 // @Override public String toString() { … }
 // static final Function<Object, JsonUser> FROM_JSON = …
 // static final Function<User, JSONObject> TO_JSON = …
}

final class JsonMutableList<E> extends AbstractList<E> implements RandomAccess {
    private final JSONArray json;
    private final Function<? super Object, ? extends E> fromJson;
    private final Function<? super E, ?> toJson;
    JsonMutableList(
                JSONArray json,
                Function<? super Object, ? extends E> fromJson,
                Function<? super E, ?> toJson) {
        this.json = json;
        this.fromJson = fromJson;
        this.toJson = toJson;
    }
    JsonMutableList(
                List<E> copyFrom,
                Function<? super Object, ? extends E> fromJson,
                Function<? super E, ?> toJson) {
        this(new JSONArray(), fromJson, toJson);
        for (E e : copyFrom) json.put(toJson.apply(e));
    }
    @Override public final int size() {
        return json.length();
    }
    @Override public final E get(int index) {
        return fromJson.apply(json.opt(index));
    }
    @Override public final boolean add(E e) {
        json.put(toJson.apply(e));
        return true;
    }
    @Override public final void add(int index, E element) {
        throw new UnsupportedOperationException(
                "this is problematic and requires some work");
    }
    @Override public final E set(int index, E element) {
        return fromJson.apply(put(json, index, toJson.apply(element)));
    }
    @Override public final E remove(int index) {
        return fromJson.apply(json.remove(index));
    }
}
static Object put(JSONArray into, int index, Object what) {
    try {
        Object old = into.opt(index);
        into.put(index, what);
        return old;
    } catch (JSONException e) {
        // JSONArray checks argument for infinite Numbers
        throw new ArithmeticException(e.getMessage());
    }
}

Когда нам присваивают лист объектов, приходится конвертировать его целиком, как в предыдущем способе. Получилась каша.

В Котлине можно использовать property delegates: тогда на пару геттер-сеттер нужен будет только один строковый литерал, а вероятность опечататься вернётся к норме.

Выводы

Когда сущность нужно конвертировать только в одну сторону, можно оборачивать org.json или любые другие синтаксические деревья.

Когда нужно конвертировать сущность в обе стороны, работать с деревьями напрямую становится больно, многословно и страшно.

Комментарии к статье

{"type":"articleComments","id":"736e7977-50c1-423e-bea7-b6c37f43e624","comments":[]}

Javanese.Online в GitHub

Чаты и каналы в Telegram

RSS-лента