Как стать автором
Обновить

MapStruct — смаппь меня, если сможешь

Уровень сложностиПростой
Время на прочтение15 мин
Количество просмотров21K

Приветствую всех читателей публикации! Я являюсь автором телеграмм канала "Заметки джависта". Сегодня хочу поделиться своим опытом работы с MapStruct, проложив основу и базовые принципы для начала работы с библиотекой.

Исходный код проекта: github

В этой статье мы разберемся с такими понятиями как DTO, Mapping, а также примерами их использования (в самом конце вы увидите полезные ссылки на доп источники по теме).

MapStruct
MapStruct

Я потратил на изучение данной библиотеки немало нервных клеток, и уверен, что узнал далеко не все способы и лайфхаки, но постарался донести информацию с практической стороны, чтобы вы с самого старта не испытывали "нежданчиков" и сэкономили свое время в попытках найти работающий способ.

Скрытый текст

СПОЙЛЕР: в данном проекте я не использую слой service, а обращаюсь к repository напрямую, и также максимально упростил реализацию, чтобы сфокусироваться на рассматриваемой теме.

Начальные данные

Смоделируем ситуацию: у нас есть некий сервис, который работает с пользователями. Сохраняет, обновляет и возвращает данные о пользователях. Стандартный набор полей сущности User выглядит так: id, username, email, password.

сущность User
сущность User

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

Вопрос: как вы считаете, стоит ли включать в ответ клиенту поле password при успешном сохранении пользователя?

Скрытый текст

Спойлер: нет!

Конечно, можно обнулить поле password, и передать его клиенту - но мы можем забыть выполнить обнуление пароля, и передать его в ответе - а там на стороне клиента данные могут спокойно перехватить и использовать в своих целях (помните, что клиент это не только браузер - могут быть другие приложения, которые используют наш API).

Именно поэтому мы создадим дополнительный объект UserResponse, который и является DTO, и будет иметь только те поля, которые должен получить клиент, исключая поле password, как показано на рисунке ниже:

сущность UserResponse
сущность UserResponse

Мы, как разработчики, должны заботиться о безопасности на своей стороне. Если есть возможность и необходимость скрыть некоторые поля, которые как пример не нужны, либо представляют особую важность (в данном случае поле password - пароль пользователя), то лучше не включать их в конечный ответ клиенту (клиентом может быть наш микросервис, либо браузер, либо любой другой сервис, который обращается к нашему серверу)

Для понимания: клиент-серверная архитектура предполагает, что любой, кто обращается к серверу, является клиентом. А тот, кто принимает те самые запросы и обрабатывает их, является сервером. То есть клиентом и сервером могут быть сервисы, которые взаимодействуют между собой, даже в рамках одной структуры.

примерная схема взаимодействия клиента и сервера
примерная схема взаимодействия клиента и сервера

Таким образом мы видим, что DTO (Data transfer object) - объект передачи данных, смысл которого заключается в хранении промежуточных данных. Когда мы возвращаем данные клиенту, обычно передаем DTO обьект (если того требует логика), это как раз все данные (в нашем случае id, username, email).

По сути, DTO - это обычный Java объект, который не является частью бизнес модели (тогда как наш User - представление реального пользователя с набором данных), но служит неким хранилищем для данных, которые надо передать (отсюда и название - объект передачи данных).

Mapping: что ты, черт возьми, такое?

Теперь мы двигаемся к следующему вопросу: как нам собственно привести объект User к объекту UserResponse?

На этом этапе в игру вступает понятие маппинг (eng: mapping), которое подразумевает процесс преобразования объекта X в объект Y, в нашем случае это преобразование User ---> UserResponse.

Простыми словами: маппинг нужен чтобы преобразовывать одни объекты и поля к другим объектам.

MapStruct: смаппь меня, если сможешь

MapStruct - это библиотека, которая по сути и является маппером. Мапперов бывает много, но самый распространенный и удобный - MapStruct.

С помощью маппера нам не придется делать преобразование объектов руками (создавать новый объект, класть туда нужные поля, и возвращать наш DTO), все делается автоматически, ну или почти..

Зачем нам вообще нужен маппер, к чему лишние технологии?

Скрытый текст

Хороший вопрос. В процессе работы над реальными задачами, вам понадобится добавлять дополнительную логику (устанавливать значения по умолчанию, как пример текущее время, или другие константы / enum-значения, преобразовывать данные (шифровать пароль, генерировать никнейм), и т.п).

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

Теория - хорошо, а практика - еще лучше!

Для начала нам надо выбрать систему сборки для проекта (в данном примере я использую Gradle, но пример подключения библиотеки для Maven будет также приведен ниже).

Gradle:

dependencies {
    //...

    // MapStruct
    implementation 'org.mapstruct:mapstruct:1.5.5.Final'
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
}

Maven:

<properties>
    <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
</properties>
...
<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
    </dependency>
</dependencies>
...
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${org.mapstruct.version}</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

Создадим сущность User:

@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String email;
    private String password;
}

А также UserResponse (dto) для ответа клиенту:

public record UserResponse(
        Long id,
        String username,
        String email) {
}

Не пугайтесь, если увидели record впервые: это встроенное ключевое слово в Java, которое позволяет создать неизменяемый класс с final полями (мы не можем изменить поля после создания объекта), а также автоматически сгенерированными hashcode()&equals() + toString() + get() методами.

UserMapper - наш первый маппер

Теперь создадим interface UserMapper, который как раз таки и будет содержать основную логику конвертации (маппинга) сущности в DTO:

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface UserMapper {

    UserResponse toUserResponse(User user); //map User to UserResponse

List<UserResponse> toUserResponseList(List<User> users); //map list of User to list of UserResponse
}

Как видно, ничего сложного:

  • Объявляем интерфейс (или абстрактный класс) с именем 'Mapper' в конце (это скорее правило хорошего тона, но не является обязательным условием), а также помещаем в папку mapper (чтобы отделить классы проекта по зонам ответственности)

  • Добавляем аннотацию @Mapper (org.mapstruct.Mapper), по которой MapStruct будет генерировать реализацию (под капотом MapStruct будет искать все классы, которые помечены данной аннотацией, и генерировать реализацию по заданным внутри этого интерфейса/абстрактного класса правилам), и указываем componentModel (он нужен в том случае, если мы используем Spring-framework в нашем проекте)

  • Добавляем абстрактные методы в наш интерфейс, реализацию которых возьмет на себя MapStruct

Без Spring:

Если мы не используем Spring, нужно объявить переменную INSTANCE внутри UserMapper interface, которая позволит получить нам реализацию из интерфейса:

@Mapper
public interface UserMapper {

    UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); //add instance to call mapper

    UserResponse toUserResponse(User user); //map User to UserResponse

    List<UserResponse> toUserResponseList(List<User> users); //map list of User to list of UserResponse
}

Контроллеры

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

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserRepository userRepository;
    private final UserMapper userMapper;

    @GetMapping
    public ResponseEntity<List<UserResponse>> findAll() {
        List<User> users = userRepository.findAll();
        List<UserResponse> userResponseList = userMapper.toUserResponseList(users);
        return ResponseEntity.ok(userResponseList);
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
        Optional<User> user = userRepository.findById(id);
        return user.isPresent()
                ? ResponseEntity.ok(userMapper.toUserResponse(user.get()))
                : ResponseEntity.notFound().build();
    }
}

Когда мы используем Spring, нам достаточно добавить аннотацию @RequiredArgsConstructor от Lombok, которая сгенерирует конструктор для всех final полей - в данном случае это обьекты UserRepository, UserMapper (Spring подставит нам эти обьекты благодаря принципу DI)

Без Spring:

Если мы не используем Spring - получить реализацию UserMapper мы можем с помощью объявления переменной этого типа в том классе, где хотим использовать конвертер:

private final UserMapper mapper = UserMapper.INSTANCE;

Благодаря библиотеке MapStruct, которую мы добавили ранее, во время запуска наш проект будет сканирован, и для всех интерфейсов, обозначенных как @Mapper, будет сгенерирован класс, который реализует все методы, объявленные в нашем маппере.

Теперь посмотрим, какой код сгенерировал нам MapStruct (для этого нажмем shift + shift в IntelijIdea и введем имя UserMapperImpl в поиске Classes)

сгенерированная MapStruct имплементация метода toUserResponse()
сгенерированная MapStruct имплементация метода toUserResponse()

Как видно, реализация интерфейсного метода максимально проста: вначале идет проверка source-объекта на null, а затем создаются переменные с null значениями, и поочередно вызываются get() методы у target-объекта.

Стопп.. source, target, ты о чем вообще?

Что-ж, давайте вспомним объявление первого метода в нашем UserMapper:

UserResponse toUserResponse(User user);

Source объект - это источник, ОТКУДА мы берем данные, в нашем случае это User.

Target объект - это результат, КУДА мы подставляем данные, в нашем случае это UserResponse.

В конце сгенерированной реализации мы видим, что MapStruct берет данные ИЗ User и ставит их В UserResponse. Таким образом, он берет только те поля, которые нужны для создания объекта UserResponse, а в нашем случае поля password у UserResponse нет, значит оно игнорируется!

Теперь посмотрим, какую реализацию сгенерировал MapStruct для получения списка объектов:

сгенерированная MapStrcut имплементация метода toUserResponseList()
сгенерированная MapStrcut имплементация метода toUserResponseList()

Как вы можете заметить, MapStruct делает довольно хитрую вещь: он проходит по списку users и просто вызывает уже сгенерированный ранее метод toUserResponse(), вот и все!

То есть для того, чтобы получить List<UserResponse>, нам нужно только создать сам метод конвертации User ---> UserResponse, а для конвертации всего списка таких же объектов MapStruct пройдется по всем элементам, и вызовет уже существующий метод.


Я уже сохранил 1 запись в базу, через POST метод (его вы увидите дальше, когда будем изучать способ более продвинутого маппинга):

На уровне базы данных сохранились все данные - ID, email, password, username.

Теперь сделаем GET запрос на получение информации о пользователе (напомню, что мы ожидаем ответ БЕЗ поля password, так как скрыли его на этапе маппинга, и передаем клиенту DTO UserResponse):

ответ на запрос получения пользователя с ID = 1
ответ на запрос получения пользователя с ID = 1

Как видно, мы не получили секретное поле password, которое хотели скрыть для клиента. Все сработало как надо - в БД все сохранилось как и должно, а в качестве ответа нам пришел UserResponse.

Что же произошло под капотом? Ответ прост: вначале мы получили оригинальную сущность User, с паролем и другими полями, а затем создали новый обьект (DTO) UserResponse, в котором скрыли поле password (этого поля попросту нет).

Более сложный маппинг

Мы разобрались, как выполнять базовый маппинг (конвертацию), но как добавить какую-то логику?

К примеру, при сохранении User, мы хотим поменять его пароль (как и делают в реальных приложениях: пароль шифруют по скрытому алгоритму, и сохраняют его в зашифрованном виде, а при попытке входа в систему, шифруют пароль который пришел из запроса, и сравнивают с уже зашифрованным паролем в Базе Данных).

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

Для начала создадим новую DTO - UserCreateDto, которая будет содержать данные, необходимые для создания пользователя:

В данном примере мы могли бы не создавать UserCreateDto, т.к он содержит те же поля, что и User, но в реальных проектах User имеет аудит-поля, (такие как createdAt, updatedAt, и др.), а также другие данные, которые создаются по умолчанию (к примеру статус пользователя, прим: CREATED) , поэтому желательно создавать отдельную DTO (но также не стоит плодить много DTO-шек, старайтесь везде придерживаться некого баланса).

Создадим UserCreateDto:

public record UserCreateDto(
        String username,
        String email,
        String password) {
} 

Добавим метод для преобразования UserCreateDto в User:

User fromUserCreateDto(UserCreateDto userCreateDto);

А также дополним наш UserController, добавив endpoint для сохранения пользователя:

@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody UserCreateDto userCreateDto) {
    User user = userMapper.fromUserCreateDto(userCreateDto);
    User savedUser = userRepository.save(user);
    UserResponse userResponse = userMapper.toUserResponse(savedUser);
    return ResponseEntity.ok(userResponse);
}

@Mapping - кто ты, воин?

Знакомьтесь, Джо Блэк.. Шучу, @Mapping.

@Mapping - основная аннотация, с которой вы будете сталкиваться большую часть времени. Я не буду приводить тысячи примеров, т.к это займет много времени, и статья превратится в документацию.

Надеюсь, вы еще не забыли про target и source: они нужны чтобы указывать, какие поля нам брать для маппинга.

@Mapping(defaultValue)

Рассмотрим несложный пример:

@Mapping(target = "password", defaultValue = "pass123")
User fromUserCreateDto(UserCreateDto userCreateDto);
Скрытый текст

Здесь мы даем инструкцию MapStruct, что хотим установить в поле password нашего target объекта User значение по умолчанию ("pass123"), таким образом пароль из userCreateDto будет игнорироваться, и каждый вызов fromUserCreateDto будет возвращать нам User с полем password = "pass123"

Делаем запрос на сохранение нашего User с паролем "our-custom-password-222":

ответ после создания сущности User
ответ после создания сущности User

Так как в ответе нам не приходит пароль (мы скрываем его благодаря маппингу, как было сказано ранее), кажется что ничего не поменялось. А теперь заглянем в базу данных:

состояние базы данных после создания сущности User
состояние базы данных после создания сущности User

Как видно, мы создавали пользователя с паролем "our-custom-password-222", а по факту в базу сохранились совсем другое значение поля password - "test123".

Разберемся, что же произошло:

  1. Мы послали запрос на создание User с полями (первый POST-запрос) на картинке выше

  2. На уровне контроллера нашего приложения вызвался код:

User user = userMapper.fromUserCreateDto(userCreateDto);
User savedUser = userRepository.save(user);

В ходе которого мы преобразовали UserCreateDto в User, а в процессе установили значение в поле password самым в User установился пароль со значением defaultValue = "test123", которое было указано в аннотации @Mapping.

Таким образом, defaultValue  устанавливает значение, если source == null, а если нужно вне зависимости от условий - это constant. В данном примере source мы не указали, поэтому установилось дефолтное значение.

Пример использования constant:

@Mapping(target = "allowed", constant = "Boolean.FALSE")

@Mapping(ignore)

Этот параметр максимально примитивен - он говорит MapStruct, что данное поле мы должны игнорировать:

@Mapping(target = "email", ignore = true)
UserResponse toUserResponse(User user);

С помощью "ignore" мы говорим MapStruct: игнорируй поле email при маппинге. В таком случае значение user.email будет проигнорировано, и в UserResponse не будет установлено это значение (это может использоваться для того, чтобы MapStruct не ругался на то, что таких полей нету у source-объекта)

Результат сохранения User будет выглядеть так:

как видно, благодаря игнорированию, поле "email" является пустым
как видно, благодаря игнорированию, поле "email" является пустым

Однако в случае, когда мы имеем множество полей, которые необходимо проигнорировать при маппинге, прописывать @Mapping(ignore = true) слишком долго.

И для такой проблемы у MapStruct есть решение: дополнительное свойство unmappedTargetPolicy у @Mapper, которое указывает, как должен реагировать MapStruct на те поля, которых он не нашел в объекте source:

  • ERROR - пробрасывание ошибки в случае, когда нужное поле отсутствует

  • WARN (default) - неприятное красное сообщение, которое пробрасывается по умолчанию, когда вы собираете свой проект

  • IGNORE - игнорировать все поля, которые не удалось смаппить (такой же эффект, как от "ignore = true")

В коде это будет выглядеть так:

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING,
        unmappedTargetPolicy = ReportingPolicy.IGNORE
)

@Mapping(expression)

Expression - способ задавать любой Java код. Мы также можем вызывать методы, как внутри UserMapper, так и методы библиотеки.

Как пример, попробуем установливать текущее время в поле password вместо оригинального пароля:

@Mapping(target = "password", expression = "java(LocalDate.now().toString())")
User fromUserCreateDto(UserCreateDto userCreateDto);

Важно!

Необходимо поменять наш конфиг, чтобы добавить в импорты LocalDate.class (иначе MapStruct не найдет класс для подстановки в expression, и выкинет ошибку, можете проверить)

Сохраним пользователя через POST и посмотрим, какие данные лежат в БД:

как видно, в базе данных в поле password установилась дата
как видно, в базе данных в поле password установилась дата

Как видно, в поле password установилось значение текущей даты, несмотря на то, что при создании указывался пароль.

@Named и методы по умолчанию

Представим, что expression нам не хватает - мы хотим реализовать более сложную логику. На помощь приходят аннотации @Named и дефолтные методы.

Давайте создадим default-метод, который будет создавать пароль по следующей логике: брать некую строку, и добавлять к ней свое значение.

default String getPasswordFromUsername(String username) {
    return username + " generatedPassword222";
}

Тогда наш @Mapping также немного поменяется:

@Mapping(target = "password", expression = "java(getPasswordFromUsername(userCreateDto.username()))")
User fromUserCreateDto(UserCreateDto userCreateDto);

В данном примере мы указываем имя метода внутри нашего UserMapper, а также вызываем get()-метод у userCreateDto (геттеры у record-классов именуются без get-префикса)

Делаем запрос, ии..

получаем такую картину
получаем такую картину

Что за приколы, скажете вы?

Все просто:

MapStruct сканирует интерфейс на методы, и если видит, что возвращаемое значение и аргумент метода совпадают - смело применяет это ко всем полям. В нашем случае все поля являются String - и MapStruct смело накидал нам ненужной логики.

Давайте исправлять! На помощь идет @Named - аннотация, которая позволяет решить множество проблем, а именно:

  • Помогает отличить методы, даже если их возвращаемое значение и аргумент совпадают

  • Позволяет нам использовать qualifiedByName - но к ней мы перейдем чуть позже

Перепишем наш метод:

@Named("getPasswordFromUsername")
default String getPasswordFromUsername(String username) {
    return username + " generatedPassword222";
}

И получаем ожидаемый результат:

ответ на создание сущности User
ответ на создание сущности User

@Mapping(qualifiedByName)

Предыдущий способ, при котором мы вызывали default getPasswordFromUsername() метод через expression - не самый лучший вариант. Его стоит использовать, если мы хотим добавить какую-то логику к значению, которое возвращает метод. В других случаях рекомендуется использовать qualifiedByName()

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

Изменим наш UserMapper:

@Mapping(target = "password", qualifiedByName = "getPasswordFromUsername", source = "username")
User fromUserCreateDto(UserCreateDto userCreateDto);

@Named("getPasswordFromUsername")
default String getPasswordFromUsername(String username) {
    return username + " generatedPassword222";
}

Разберемся, какие изменения произошли:

  1. Мы убрали expression, и указали qualifiedByName = "getPasswordFromUsername", где значением является имя, которое стоит в аннотации @Named

  2. Мы указали source = "username", что дает инструкцию для MapStruct взять username из userCreateDto, когда тот будет вызывать метод getPasswordFromUsername(), и передать его в качестве параметра

Таким образом, qualifiedByName служит неким уточнением, которое говорит MapStruct, какой именно метод мы хотим использовать для преобразования значений. Если данный метод принимает какие-либо аргументы, мы можем указать их в source.

Вынесение логики маппинга в отдельные классы

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

Представим, что вам нужно сделать отдельный класс для UserMapper (в нем будут собраны все методы, которые нужны для маппинга, а ваш UserMapper будет чист как младенец, вызывая методы из созданного util-класса)

Такое разбиение позволит:

  1. Убрать все вспомогательные методы из UserMapper, что позволит нам видеть, какие поля маппятся, и какие методы вызываются, тем самым увеличив чистоту и понимание кода

  2. Избежать танцев с бубном, если нужно внедрить бины в mapper. В интерфейсе это сделать либо невозможно, либо можно, но такого способа я еще не нашел (потому делал abstract class и добавлял туда нужные бины, помечая аннотацией @Autowired)

    Итак, начнем!

Создадим UserMapperUtil в папке ustils.mapper, куда вынесем все методы для маппинга:

@Named("UserMapperUtil")
@Component
@RequiredArgsConstructor
public class UserMapperUtil {

    private final Random randomGenerator;

    @Named("getPasswordFromUsername")
    public String getPasswordFromUsername(String username) {
        return username + randomGenerator.nextInt(5000) + 2222;
    }
}

Мы указали @Named на уровне класса и метода, чтобы в UserMapper вызывать нужные нам методы по имени класса и метода.

Бин Random был создан в ApplicationConfig классе, чтобы продемонстрировать суть такого подхода - мы можем внедрять нужные нам зависимости в Util классы, тем самым вынося всю логику из UserMapper в утилитные классы.

@Configuration
public class ApplicationConfig {

    @Bean
    public Random randomGenerator() {
        return new Random(System.currentTimeMillis() / 72);
    }
}

Финальное изменение - поменяем интерфейс UserMapper:

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING,
        uses = {
                UserMapperUtil.class
        },
        imports = {
                LocalDate.class
        })
public interface UserMapper {

    UserResponse toUserResponse(User user); //map User to UserResponse

    List<UserResponse> toUserResponseList(List<User> users); //map list of User to list of UserResponse

    @Mapping(target = "password", qualifiedByName = {"UserMapperUtil", "getPasswordFromUsername"}, source = "username")
    User fromUserCreateDto(UserCreateDto userCreateDto);
}

Здесь мы добавили uses в @Mapper конфигурацию, чтобы MapStruct подгрузил этот класс в целях маппинга, а самое главное - обратились к методу утилитного класса, используя значения, указанные в @Named у UserMapperUtil в qualifiedByName = {"UserMapperUtil", "getPasswordFromUsername"}

Теперь запускаем наш проект снова, и пробуем сохранить User:

результат шифрования пароля с помощью randomGenerator
результат шифрования пароля с помощью randomGenerator

Как видите, все сработало! Наш randomGenerator сгенерировал значение и добавил к username.

Ссылки и полезные материалы

Eсли вы дотянули до этого момента - поздравляю!

Надеюсь, что после прочтения этого материала вам стало чуть понятнее и проще, а если хочется изучить
MapStruct получше, листайте вниз!

Список источников, которые помогут вам разобрать тему MapStruct еще больше:

Теги:
Хабы:
Всего голосов 10: ↑8 и ↓2+8
Комментарии35

Публикации

Истории

Работа

Java разработчик
347 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань