RowMapper и ResultSetExtractor в Spring Boot

Вернуться назад

27 апреля 2018

Spring Boot предоставляет два интерфейса для обработки выборки из БД: RowMapper и ResultSetExtractor. Давайте разберём их назначение, а также выясним, чем они различаются на примере справочника городов и стран.

RowMapper

Чаще всего при работе со списками в restful-сервисах, построенных на Spring Boot, вы будете использовать RowMapper. Этот класс обрабатывает отдельно каждую запись, полученную из БД, и возвращает уже готовый объект - модель данных. В большинстве случаев его вполне хватает.

Создадим простенький rest-контроллер, который будет возвращать список всех стран, которые заведены у нас в БД. Определение таблицы в СУБД postgres выглядит следующим образом:

CREATE TABLE public.country
(
  id serial,
  name character varying(50NOT NULL,
  CONSTRAINT country_pk PRIMARY KEY (id)
)

Здесь тип serial представляет собой обычный integer, который автоматически увеличивается на 1 при добавлении каждой новой записи. То есть нет нужды при вставке явно указывать id.

Добавим туда несколько стран для примера:

insert into country (name) values ('Германия'); -- id = 1
insert into country (name) values ('Франция');  -- id = 2
insert into country (name) values ('Италия');   -- id = 3

Наша модель данных Country по типу и набору полей должна соответствовать структуре таблицы:

public class Country {

    private int id;
    private String name;

    // get- и set-методы...
}

Обработчик входящих запросов (контроллер) будет выглядеть так:

@RestController
@RequestMapping(value = "/geo", produces = MediaType.APPLICATION_JSON_VALUE)
public class GeoController {

    @Autowired
    private GeoDao geoDao;

    @GetMapping("/countries")
    public List<Country> getAllCountries() {
        return geoDao.getAllCountries();
    }
}

Здесь мы из контроллера сразу используем уровень работы с БД (GeoDao). В реальных приложениях между ними должен быть ещё сервисный слой, но для краткости я его пропущу, т.к. он довольно тривиален.

Слой работы с БД (dao):

@Repository
public class GeoDao {

    private static final String SQL_GET_ALL_COUNTRIES = "select * from country order by name";

    @Autowired
    private NamedParameterJdbcTemplate jdbcTemplate;

    @Autowired
    private CountryMapper countryMapper;

    public List<Country> getAllCountries() {
        return jdbcTemplate.query(SQL_GET_ALL_COUNTRIES, countryMapper);
    }
}

Код маппера:

@Component
public class CountryMapper implements RowMapper<Country> {

    @Override
    public Country mapRow(ResultSet rs, int i) throws SQLException {
        Country country = new Country();
        country.setId(rs.getInt("id"));
        country.setName(rs.getString("name"));
        return country;
    }
}

Здесь мы реализуем интерфейс RowMapper, типизированный нашей моделью Country. Каждую строку здесь обрабатываем отдельно при помощи ResultSet. В качестве результата возвращаем уже готовый объект.

Теперь мы готовы к тому, чтобы запустить наше приложение и выполнить GET-запрос по адресу http://127.0.0.1:8080/geo/countries. В ответ получим такой json:

[{
  "id"1,
  "name""Германия"
}, {
  "id"3,
  "name""Италия"
}, {
  "id"2,
  "name""Франция"
}]

ResultSetExtractor

Теперь создадим вторую таблицу с городами, причём каждый город привязан к одной из стран. То есть это отношение «один-ко-многим». Структура таблицы будет выглядеть так:

CREATE TABLE public.city
(
  id serial,
  country_id integer NOT NULL,
  name character varying NOT NULL DEFAULT 50,
  CONSTRAINT city_pk PRIMARY KEY (id),
  CONSTRAINT city_country_fk FOREIGN KEY (country_id)
      REFERENCES public.country (id) MATCH SIMPLE
      ON UPDATE NO ACTION ON DELETE NO ACTION
)

Связь с таблицей city происходит при помощи внешнего ключа country_id. Добавим в таблицу несколько записей:

insert into city (country_id, name) values (1'Берлин');
insert into city (country_id, name) values (2'Париж');
insert into city (country_id, name) values (3'Рим');
insert into city (country_id, name) values (1'Мюнхен');
insert into city (country_id, name) values (3'Венеция');

Предположим, что мы хотим возвращать города в таком виде, чтобы они были сгруппированы по странам. Сделать это на уровне БД можно при помощи join. Обратите внимание, что сортируем мы по названию страны.

select c.name as city_name, n.name as country_name from city c
 left join country n on c.country_id = n.id order by n.name

Таким образом, в каждой строке у нас будет и название города, и название страны, в которой этот город находится.

Очевидно, что для группировки по странам нужно работать в контексте всей выборки одновременно, однако RowMapper работает только на уровне одной строки. И тут нам на помощь приходит ResultSetExtractor.

@Component
public class CityExtractor implements ResultSetExtractor<Map<String, List<String>>> {

    @Override
    public Map<String, List<String>> extractData(ResultSet rs)
            throws SQLException, DataAccessException {
        Map<String, List<String>> data = new LinkedHashMap<>();
        while (rs.next()) {
            String country = rs.getString("country_name");
            data.putIfAbsent(country, new ArrayList<>());
            String city = rs.getString("city_name");
            data.get(country).add(city);
        }
        return data;
    }
}

Типизируем его составным объектом Map. Это справочник, где ключ - строка, а значение - список строк (List). Важно, что в качестве реализации интерфейса Map мы выбрали не HashMap, а LinkedHashMap, который сохраняет порядок добавления ключей. Это нам нужно для того, чтобы страны шли в алфавитном порядке.

Далее в цикле обходим все строки, пока они есть, при помощи rs.next(), каждый раз переходя на новую строку. Сначала берём из текущей строки страну, которая будет ключом в нашем справочнике. Затем добавляем этот ключ с пустым списком городов, если такого ключа ещё нет, при помощи удобного метода putIfAbsent(). Затем извлекаем из текущей строки название города и добавляем его в список соответствующей страны.

Метод в dao будет выглядеть довольно просто (не забудьте добавить sql-запрос, указанный выше):

    @Autowired
    private CityExtractor cityExtractor;

    public Map<String, List<String>> getGroupedCities() {
        return jdbcTemplate.query(SQL_GET_ALL_CITIES, cityExtractor);
    }

В контроллере также не будет ничего сложного:

    @GetMapping("/cities")
    public Map<String, List<String>> getGroupedCities() {
        return geoDao.getGroupedCities();
    }

Теперь мы готовы выполнить GET-запрос на адрес http://127.0.0.1:8080/geo/cities и получить такой красивый json:

{
  "Германия": ["Берлин""Мюнхен"],
  "Италия": ["Рим""Венеция"],
  "Франция": ["Париж"]
}

Выводы

На данном примере наглядно видно, что если мы хотим одновременно извлекать данные из нескольких таблиц, то тут на помощь приходит ResultSetExtractor, который работает в контексте всей выборки целиком, позволяя группировать данные. А если мы работаем с одной таблицей, то тут вполне хватит и обычного RowMapper.

Тэги: Java 8, PostgreSQL, SQL, Spring Boot, rest.


Исходники


Добавить комментарий

Ваше имя:
Текст комментария: