Холодной весенней ночью я наконец-таки перешел на 33 уровень. Потирая свои программистские ручки, я уже готовился объять всю сферу сериализации, десериализации JSON, но, к сожалению, ничего не понял. Текст лекции не запомнился, а задачи решались как-то интуитивно. В связи с этим решил полезть в дебри Jackson Framework и разобраться, что же такое этот JSON.

Все свои познания постараюсь изложить практико-ориентированно и лаконично в формате шпаргалки (как для себя, так и для читателей).

Jackson Framework

Путешествие в Jackson Annotations

Первое, что мы встречаем на пути в JSON — это аннотация @JsonAutoDetect. На первый взгляд легкая аннотация, но с ней автору пришлось разбираться дольше всего. Аннотация имеет 5 нужных нам методов:

fieldVisibility() - сериализует поля только с указанным модификатором доступа
getterVisibility()/setterVisibility() - сериализует поля, у которых геттер/сеттер имеет указанный модификатор доступа
isGetterVisibility() - отдельная реализация для булевских геттеров

Важно понимать, что методы работают дизъюнктивно. Т.е. если поле соответствует хотя бы одному из указанных в аннотации параметров, то оно попадет в JSON.

Попытайтесь ответить, что выведет данный код, если мы создадим экземляр, используя конструктор без параметров:


@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY,
        getterVisibility        = JsonAutoDetect.Visibility.PUBLIC_ONLY,
        setterVisibility        = JsonAutoDetect.Visibility.PUBLIC_ONLY,
        isGetterVisibility      = JsonAutoDetect.Visibility.PROTECTED_AND_PUBLIC)
public class HeadClass {
    public String name;
    private Map<String, String> properties;
    public Queue<String> queue;
    protected List<String> list;
    private int age;
    private int number;
    private boolean isHead;

    protected HeadClass(int age) {
        this.age = age;
    }

    public HeadClass() {}

    Map<String, String> getProperties() {
        return properties;
    }

    protected boolean isHead() {
        return isHead;
    }

    protected void setProperties(Map<String, String> properties) {
        this.properties = properties;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public int getAge() {
        return age;
    }
}

А если убрать isGetterVisibility?

Четыре перечисленных метода конфигурировали процесс сериализации. Пятый же в свою очередь регулирует процесс десериализации:
creatorVisibility() - самый сложный метод для понимания. Он работает с конструкторами и с фабричными методами (методы, которые создают объект при обращении к ним). Рассмотрим пример:


@JsonAutoDetect(creatorVisibility = JsonAutoDetect.Visibility.PROTECTED_AND_PUBLIC)
public class HeadClass {
    public String name;
    public int id;

    HeadClass(@JsonProperty(value = "name") String name, @JsonProperty(value = "id") int id) {
        this.name = name;
        this.id = id;
    }

    protected HeadClass(String name) {
        this.name = name;
        this.id = 123;
    }

    protected HeadClass(int id) {
        this.id = id;
        this.name = "Yes!";
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
public static void main(String[] args) throws Exception {
    ObjectMapper mapper = new ObjectMapper();
    String forDeserialize = "{\"name\":\"No!\",\"id\":123}";
    StringReader reader = new StringReader(forDeserialize);

    HeadClass headClass1 = (HeadClass) mapper.readValue(reader, HeadClass.class);
}

Важное замечание по механизму десериализации! Когда мы пытаемся создать объект из JSON, то будет осуществляться поиск конструктора требуемого класса с таким же набором параметров, что и в JSON объекте. В примере выше наш JSON объект состоит из двух параметров: name, id. Угадайте, к какому конструктору он обратится. И да, если мы скомпилируем этот код, он выдаст ошибку, а почему? Потому что мы ограничили видимость конструктора (видны только конструкторы с модификатором protected, public). Если вы удалите creatorVisibility, то он заработает.

Возникает вопрос. А что за @JsonProperty в конструкторе. Об этом думаю рассказать в следующей части статьи.

@JsonProperty - маппинг между Java и JSON

Собственно, вот мы и добрались до одной из самых полезных аннотаций в Jackson. @JsonProperty позволяет нам явно указать, какое имя поля в JSON соответствует полю в Java-классе. Это особенно удобно, когда имена не совпадают или когда мы хотим обеспечить обратную совместимость.

Посмотрим на простой пример:


public class User {
    @JsonProperty("user_name")
    private String name;
    
    @JsonProperty("user_age")
    private int age;
    
    // геттеры и сеттеры
}

При сериализации объекта User получим JSON вида:


{
  "user_name": "Иван",
  "user_age": 25
}

А что, если API возвращает поле то с одним именем, то с другим? Бывает такое в жизни, поверьте мне. Тут нас спасет свойство value с массивом альтернативных имен:


@JsonProperty(value = "name", alternate = {"username", "user_name", "fullName"})
private String name;

При десериализации Jackson будет искать в JSON любое из указанных имен.

@JsonIgnore и @JsonIgnoreProperties - скрываем лишнее

Иногда нам нужно скрыть какие-то поля от сериализации. Например, пароли, служебную информацию или просто данные, которые не должны попасть в API.


public class User {
    private String name;
    
    @JsonIgnore
    private String password;
    
    @JsonIgnore
    private Date lastLoginDate;
    
    // остальные поля
}

А если полей много и проще указать, что игнорировать на уровне класса:


@JsonIgnoreProperties({"password", "internalId", "debugInfo"})
public class User {
    private String name;
    private String password;
    private long internalId;
    private String debugInfo;
    // остальные поля
}

Есть еще полезная штука - @JsonIgnoreProperties(ignoreUnknown = true). Она спасает, когда в JSON приходят поля, которых нет в нашем классе. Без этой аннотации Jackson выбросит исключение.

@JsonFormat - форматируем даты и числа

С датами в JSON всегда проблемы. То они приходят в виде строки, то как timestamp, то в каком-то экзотическом формате. @JsonFormat решает эту проблему:


public class Event {
    @JsonFormat(pattern = "dd-MM-yyyy HH:mm:ss")
    private Date eventDate;
    
    @JsonFormat(pattern = "dd.MM.yyyy")
    private LocalDate startDate;
    
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "#.##")
    private double price;
}

Кстати, часто приходится работать с timezone. Тогда пишем так:


@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Europe/Kiev")
private Date createdAt;

Собираем все вместе - практический пример

Давайте теперь создадим что-то более-менее реальное. Представим, что мы разрабатываем API для интернет-магазина:


@JsonIgnoreProperties(ignoreUnknown = true)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class Product {
    @JsonProperty("product_id")
    private Long id;
    
    @JsonProperty(value = "name", alternate = {"title", "product_name"})
    private String name;
    
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "#.##")
    private BigDecimal price;
    
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate createdDate;
    
    @JsonIgnore
    private String internalNotes;
    
    private List<String> categories;
    
    // конструктор для Jackson
    public Product() {}
    
    public Product(@JsonProperty("product_id") Long id, 
                   @JsonProperty("name") String name) {
        this.id = id;
        this.name = name;
        this.createdDate = LocalDate.now();
    }
    
    // геттеры и сеттеры...
}

Этот класс умеет работать с JSON вида:


{
  "product_id": 12345,
  "title": "Awesome Product",
  "price": "99.99",
  "createdDate": "2023-12-01",
  "categories": ["electronics", "gadgets"],
  "some_unknown_field": "will be ignored"
}

Полезные трюки, которые экономят время

За время работы с Jackson накопилось несколько лайфхаков:

1. Глобальная конфигурация ObjectMapper. Вместо аннотаций на каждом классе можно настроить поведение глобально:


ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

2. @JsonInclude - контролируем, что попадает в JSON:


@JsonInclude(JsonInclude.Include.NON_NULL)
public class Response {
    private String message;
    private String error; // не попадет в JSON, если null
}

3. @JsonCreator для десериализации в immutable объекты:


public class ImmutableUser {
    private final String name;
    private final int age;
    
    @JsonCreator
    public ImmutableUser(@JsonProperty("name") String name, 
                        @JsonProperty("age") int age) {
        this.name = name;
        this.age = age;
    }
}

Что дальше?

Jackson — это такой монстр, что сразу и не разберёшься, но возможностей у него вагон. Мы пока поковыряли базовые аннотации, которые решают процентов 80 задач на практике. В следующий раз хочу замахнуться на кастомные сериализаторы, полиморфизм и как это всё прикрутить к Spring Framework — будет огонь!

А пока мой совет: просто берите и кодьте. Замутите пару классов, поиграйтесь с разными аннотациями, попробуйте запихнуть даты в разные форматы. Я сам так в своё время знакомился с Jackson — писал, ломал, чинил, пока не стало ясно, как оно работает.

И помните - если что-то не работает, первым делом проверьте, есть ли у вас геттеры/сеттеры или конструктор по умолчанию. Это самые частые источники проблем для новичков.