Сериализация класса в query string

Есть задача сериализации кастомного класса в query string (параметры в URL строке). Какие-то поля класса должны быть обязательно, какие-то не обязательно. Например:

public class UserQueryParameters {
    String name; // *
    int age; // Не обязательно
    String sex; // Не обязательно
}

Надо как-то объявить, какие поля сериализовать (в реалиях java это маршаллинг называется, вроде), какие нет, а затем преобразовывать получившуюся структуру в строку вида

name=John&age=23

Как это можно реализовать?


Пробовал с использованием lombok аннотаций:

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class UserQueryParameters {
    String name; // *
    int age; // Не обязательно
    String sex; // Не обязательно
}

Далее через билдер формирование инстанса класса с заполненными полями:

UserQueryParameters params = UserQueryParameters.builder()
        .name("John")
        .sex("m")
        .build();

И формирование query string путём рефлексии:

import java.lang.reflect.Field;

// ...

for (Field field : params.getClass().getDeclaredFields()) {
    if (field.get(params) != null) {
        addToQueryString(field.get(params));
    }
}

Но, очевидно, такое решение не подходит, т.к. для int поля, например, будет не null, а 0.


Ответы (2 шт):

Автор решения: Aziz Umarov

Поскольку вы обрабатываете строковый запрос. Я бы сделал сначала сериализацию в промежуточный класс со строковыми полями. Чтобы не получить необработанную ошибку на конвертации.

public class ResuestUserQueryParameters {
    String name; 
    String age; 
    String sex; 
}

А уже далее обрабатывал его, проверка на реквайред и прочие значения.

А поскольку обычно сами реквесты уже по умолчанию сделают это за вас. (распарсят запрос на строки). Вам по сути нужно самому проверить и обработать параметры

→ Ссылка
Автор решения: Pak Uula

Собственно, ваш вопрос содержит половину ответа :)

Надо как-то объявить, какие поля сериализовать (в реалиях java это маршаллинг называется, вроде), какие нет

Для этого в java используется механизм аннотаций. Вам нужно создать собственные аннотации, пометить ими поля в классах, и затем в маршаллере пройтись reflection-ом по полям объекта класса, выбрать из них помеченные аннотацией, и сгенерировать из помеченных полей пары key=value для запроса.

Я сделал простенький пример. Создал аннотацию QueryField с двумя параметрами

  • required определяет, обязательное поле или нет. По умолчанию true - это сделано для того, чтобы явным образом обозначать необязательные поля.
  • name задаёт, какое имя использовать при сериализации. Если не задано, то используется имя поля.

Вот пример, как пользоваться аннотацией. Класс с полями age, name и sex. Обязательно только поле name, поле sex сериализуется как gender.

    public static class TestData {
        @QueryField
        public String name; // *
        @QueryField(required = false)
        public Integer age; // Не обязательно
        @QueryField(name = "gender", required = false)
        public String sex; // Не обязательно
    }

Аннотация в java определяется ключевым словом @interface:

package org.example.query;

import java.lang.annotation.*;

// Keep annotation in generated byte code
@Retention(RetentionPolicy.RUNTIME)
// Compiler hint: the annotation should be applied to fields only
@Target(ElementType.FIELD)
public @interface QueryField {
    // Custom field name in the generated query string
    public String name() default "";
    // If `true` the field value must be non-null 
    public boolean required() default true;
}

(подробнее в Java Language Specification)

Теперь можно было бы перейти к написанию сериализатора, но, как поклонник Test Driven Development, я начну с теста. Он великоват для вставки сюда, поэтому я выложил его в pastebin.

Поясню лишь идею теста.

  1. Подготовить данные.
  2. Сгенерировать строку.
  3. Так как порядок полей в строке, вообще говоря, не определён, то вместо сравнения с эталонной строкой лучше строку разобрать и сложить значения в контейнер.
  4. Потом в тесте проверить, какие именно значения обнаружились в контейнере.
  5. Заодно проверить, что ничего лишего там не завелось.
    // Тест для сериализатора с одним отсуствующим необязательным полем.
    @Test
    void test_serialize() throws SerializerException {
        TestData sample = new TestData();
        sample.age = 23;
        sample.name = "John";
        
        String actual = Serializer.buildQuery(sample);
        
        Map<String, String> data = parseQuery(actual);
        
        assertTrue(data.containsKey("age"));
        assertTrue(data.containsKey("name"));
        // Проверяем, что других полей нет.
        assertEquals(2, data.keySet().size());
        
        assertEquals("John", data.get("name"));
        assertEquals("23", data.get("age"));
        
    }

Теперь, когда тест готов, можно написать простенький сериализатор для иллюстрации идеи.

Там всё просто. Сначала перебираем поля объекта и ищем поля с аннотацией QueryField. Если такое поле найдено, пытаемся извлечь поле. В ответ JRE может выкинуть исключения - обрабатываем.

Затем проверяем, вдруг поле пустое и одновременно необязательное. Такие поля пропускаем. Про пустое обязательное поле кидаем исключение.

Затем конвертируем значение в строку (на всяк пожарный все опасные символы экранируем) и подбираем имя - либо заданное в аннотации, либо полученное из имени поля. Добавляем пару "name=valueText" в строку результата. Профит!

    public static String buildQuery(Object obj) throws SerializerException {
        StringBuilder result = new StringBuilder();
        
        for (java.lang.reflect.Field fld : obj.getClass().getFields()) {
            QueryField annot = fld.getAnnotation(QueryField.class);
            if (annot == null) {
                // This field has no "QueryField" annotation, ignore it.
                continue;
            }
            
            Object fldVal;
            try {
                fldVal = fld.get(obj);
            } catch (IllegalArgumentException e) {
                throw new RuntimeException("Should never happen", e);
            } catch (IllegalAccessException e) {
                throw new SerializerException("Failed to access field "+fld.getName(), e);
            }
            
            if (fldVal == null) {
                if (annot.required()) {
                    throw new SerializerException("Required field is null");
                } else {
                    // Ignore optional fields
                    continue;
                }
            }
            
            String valText = urlEncode(fldVal.toString());
            
            String valName = annot.name();
            if (valName.length() == 0) {
                // No custom name, use field name instead
                valName = fld.getName();
            }
            valName = urlEncode(valName); 
            
            result.append('&').append(valName).append('=').append(valText);
        }
        return result.substring(1);
    }

Как-то так оно делается.

→ Ссылка