В последнем кодревью я сказал, что на месте разработчика приложения не хотел бы, чтобы библиотека тащила за собой 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:
- конструктор копирования JsonUser(User copyFrom), как у джавовых коллекций;
- конструктор, собирающий объект по компонентам JsonUser(String name, String surname, List<Invention> inventions);
- статический метод (User) -> JSONObject;
- метод copy, который создаёт копию объекта, но обновляет определённые поля (проблематично в Джаве без именованых параметров и значений по умолчанию);
- мутаторы a. k. a. сеттеры.
Для превращения List в JSONArray, соответственно:
- JsonList(List<E> copyFrom, Function<E, JSON> toJson);
- ???;
- статический метод (List<T>, Funtction<T, JSON>) -> JSONArray;
- персистентные коллекции, которые, по аналогии с copy, можно обновлять копированием (но в Android SDK их нет);
- MutableJsonList<E>, который поддерживает методы add и set.
Конструкторы копирования
Первые два варианта разберу вместе, т. к. один хорошо выражается через другой; для листов — первый вариант, т. к. второго не существует:
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 или любые другие синтаксические деревья.
Когда нужно конвертировать сущность в обе стороны, работать с деревьями напрямую становится больно, многословно и страшно.