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

Большой API для экспериментов и front проектов или памятка о том, как я создавал проект

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

Кто я?

Это моя первая статься, а потому, как мне кажется, будет хорошим шагом начать с того, кто я такой. Меня зовут Юрий, я учусь на программиста и попутно сам стараюсь изучать всё возможное про мою специальность, а моя специальность - это .Net. Влюбился в него и вцепился с первого взгляда, прям как к php слова "скоро умрёт" .Несколько месяцев назад, а именно в сентябре, я решил, что необходимо начать делать какой-нибудь большой учебный проект для закрепления моих знаний да и для изучения новых. Я вообще отношу себя к практикам, обожаю сначала что-то делать, а только потом читать. А вдруг я и сразу всё хорошо сделаю, и потом даже читать дополнительные источники не придется :). Но это почти никогда не бывает, так что не пишите в комментариях, что я упускаю важный момент в изучении. Если возвращаясь к проекту, то сделать его я хотел, но я считал себя истинным бэкендером: притрагиваться к фронденту я вообще не слишком хотел. А потому я решил обратиться к своим друзьям программистам, чтоб хоть кто-то занялся фрондент частью. К счастью такой человек у меня был: React разработчик. И также ещё один бэкендер на C#. Идея командой разработки им понравилось, я взял проект из своего списка проектов (да, у меня такой есть) и начали разработку.

Проектирование

Первый месяц ушёл на составления тестового дизайна: всё равно я знал, что один фронтендер решит всё по-своему сделать. Но даже так на это ушло много времени, но и вместе с тем обдумывалось вообще, что будет в проекте.

Теперь о самом проекте, мы дали ему названия Author Verse. Его фишка в том, что пользователи могли бы создавать свои книги, вставляю в него любой контент, то есть из обычной книги можно было бы сделать легко комикс из картинок, мангу, аудиокнигу, манга-аудиокнига-комикс сразу??? или придумать вовсе свой жанр. И главная фишка: писатели могли бы делать разветвление сюжета на любую часть главы, на любую другую часть из других глав своей книги и, наверное самое прикольное, но которое пришло мне в голову уже на последних этапах разработки, это ответвлению в другие книги. Конечно, раз мы говорим о том, что могут быть ссылки на другие книги, то все книги на сайте должны быть бесплатные.

Ссылка на дизайн проекта вот здесь:
https://www.figma.com/file/6iWHrnGmfTOCgTHi0ATXIL?type=design

Главная страница проекта
Главная страница проекта

Разработка

После разработки дизайна, я быстренько создал API проект на .Net и создал начальные модели для книги, главы, секций для главы, выборов, жанров, тэгов, пользователей, комментариев, от которых к концу проекта ничего от себя прошлых не осталось. Но остались ли они теми же моделями, что я создал изначально??? Если что, то это просто отсылка на корабль Тесея (если не знаете, советую почитать: заставляет задуматься).

Это был далеко не первым моим проектом на .Net, но первый такой волнительный. Меня буквально распирало от одной только мысли, что я нашёл себе дело на несколько месяцев, благодаря котором я выучу много всего нового и которым я буду хвастаться на собеседованиях.

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

Для проекта использовался шаблон MVC, но в нашем случае просто MС, потому что представления на сервере нет. Выбор MVC даже не был выбором. Кроме него ещё есть минимальное API, но для больших проектов он не подходит, иначе вы просто изнасилуете файл Program, где будут как раз и находится все эндпоинты. Стоит ли объяснять, что это не удобно и противоречит первому принципу SOLID - принципу единственной ответственности, когда каждый объект должен делать лишь только что-то одно. Лично мне нравятся принципы SOLID и их я впервые полноценно изучил именно тогда, когда уже активно разрабатывал данный проект.

Информацию нужно где-то хранить, и пусть это будет самая интегрированная база данных к .Net, а именно Microfost SQL Server. Именно она полностью поддерживает все плюшки Entity framework, о котором мы поговорим далее. В дебри баз данных я не опускался, а потому пояснить за разницу между MY SQL, MS SQL и Posgre SLQ я не смогу. Но до этого ещё дойдет время. Если вы хотите увидеть ролик по их отличиям, то обязательно ставьте лайки, подписывайтесь на канал и оставляйте комментарии или что там ещё говорят обычно в роликах на ютубе...

Далее шло подключение к этой самой базе данных, используя конечно же Entity framework. Неужели майки его просто так его 20 лет разрабатывали и могу сказать, что 20 лет прошли не даром. Он реально хорош. Если кто не знает, то он позволяет работать с таблицами в базе данных в виде объектов в самом коде, где поля класса это столбцы таблиц в базе данных. Таким образом работая с кодом мы не заботимся о составлении SQL запросов в базу данных. Они составляются автоматически в зависимости от того, какие поля класса мы хотим взять и каким образом. Минус очевидный - снижение скорости работы, но для бизнеса, где важна скорость разработки, а не работа программы, он идеальный вариант. (Ещё наверное стоит сказать, что скорость работы программы важна, но она стоит уже после скорости разработки). Да и замедление незначительное. Но знать про которое обязаны, ведь лучшим способом оптимизировать LINQ (внутренний язык C# для работы с базой данных и не только) запрос является написать обычный SQL запрос в базу данных.

Для управления авторизацией и регистрацией без лишних заминок был выбран Identity framework, который внутри уже содержит множество методов и классов для работы с пользователями, их проверки, а также множество бесполезных таблиц, создаваемых при этом в базе данных. Многие мне не пригодились. Вначале разработки я думал, что смогу использовать эти таблицы, но нет. Все возможности этого Фреймворка оказались не нужны даже в том проекте, в который я хотел впихивать всё и использовать всё лишь бы для обучения.

Регистрацию я хотел видеть самую разную на сайте, а потому кроме обычной регистрацией по почте, есть ещё и регистрация через google и microsoft. Для обучения и понимания, как это работает, сойдёт.

Далее создавались контроллеры для книг, жанров, тэгов. Здесь ничего интересного не было, просто создавался контроллер и затем методы со множеством различных отправляемых DTO на клиент. Эх, если бы тогда я узнал про GraphQL и его команды, позволяющие пользователям самим решать, какие данные ему нужны, что гораздо уменьшат код и избавляет его от многочисленных DTO классов и методов для отправки одной и той же сущности, но с разными полями, лишь бы по сети не передавалось лишних байтов. Ещё позже, относительно недавно я читал книгу Ultimate ASP. Net Core Web API, в которой показывалось, как создать похожий подход как в GraphQL, но только с использованием обычных http запросов. Но такой подход я нигде больше не видел, кроме как этой книги и потому не хочу говорить, что так делать надо. Всё же у этого есть и свои минусы. А что касается GraphQL, то он потом появиться в проекте, но ближе к концу данной темы.

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


https://github.com/MoienTajik/AspNetCore-Developer-Roadmap/blob/master/ReadMe.ru.md


Для начинающих разработчиков будет полезно.

Что касается форума, то его я бы хотел сделать с использованием Redis. И, о Боже, как же я не ошибся. Я узнал про Redis из одного ролика на ютубе и мне сразу понравилась его идея, что данные не хранятся на жестком диске, а хранятся в оперативной памяти, из-за чего доступ к ней ооооочень быстрый. Конечно, есть способы сохранить данные на диск и даже использовать эту базу как основную, но мне было важно именно не записывать данные в базу данных, а иметь к ней быстрый доступ. И с этой целью он справился просто великолепно. Благодаря тому, что в redis есть встроенные типы данных, такие как строки, множества, сортированные множества, хэши, листы, он идеальный, чтоб сохранять данные о пользователей, которые могут часто выходить и входить в чаты, для их подсчёт и просто передачи данных между разными проектами.

Но сразу же при подключении redis я столкнулся с проблемой: он был доступен только на Linux. У меня был опыт работы с линуском, но я не хотел его всё равно использовать, потому что вряд-ли мои коллеги по разработке одобрили бы такое. Тогда я нашёл вторую мою главную находку - это Docker. Возможность скачивать образы разных проектов, баз данных, возможность сохранять свои проекты, а потом запускать их на любом пк в любое время без установки дополнительных библиотек и файлов и геморроя. Одна команда и всё готово. И самое важное: там был redis.

В целом среди Nosql баз данных я бы хотел ещё в данный проект включить ElasticSearch в качестве обёртки над MS SQL для быстрого поиска книг и тому подобное. Если кто не знает, то это база данных, которая служит для быстрого получения информации и для работы с большими её объёмами. Последнее как раз и стало тем, почему я просто использовал индексы в базе данных вместо её использования. Я просмотрел множество вакансий на .Net разработчика и я почти не видел там Elastic. Его применяют уже именно для работы с прям большими данными, когда обычные бд уже не справляются. И такое бывает довольно редко. Но для машинного обучения он идеально подойдёт.

Также после подключения докера, я создал файл docker-compose, который бы позволял легко управлять и запускать все свои образы в докере одной командой в одном месте.

version: '3.4'

networks:
  serverapp:

services:
  db:
    container_name: authorverse-db
    image: mcr.microsoft.com/mssql/server:2022-latest
    ports:
        - 1433:1433
    environment:
        - ACCEPT_EULA=Y
        - MSSQL_SA_PASSWORD=S3cur3P@ssW0rd!
    networks:
        - serverapp
    healthcheck:
        test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "S3cur3P@ssW0rd!" -Q "SELECT 1" || exit 1
        interval: 5s
        timeout: 3s
        retries: 10
        start_period: 10s

  redis:
    container_name: authorverse-redis
    image: redis
    ports:
        - 6379:6379 
    networks:
        - serverapp

  server:
    container_name: authorverse-server
    build:
      context: .
      target: final
      dockerfile: AuthorVerseServer/Dockerfile
    ports:
      - 7069:7069
      - 5288:5288
    depends_on:
        db:
            condition: service_healthy
    environment:
      - ASPNETCORE_ENVIRONMENT=development
    networks:
      - serverapp
  forum:
    container_name: authorverse-forum
    build:
      context: .
      target: final
      dockerfile: AuthorVerseForum/Dockerfile
    ports:
      - 7070:7070
    environment:
      - ASPNETCORE_ENVIRONMENT=development
    networks:
      - serverapp

Так выглядит файл docker-compose, который у меня получился, состоящий из базы данных, самого главного проекта, проекта форума и redis. Также во время запуска основного сервера, происходят запросы в базу данных, чтоб понять, запустилась ли она уже. И если да, то только тогда программе разрешено дальше запускать основной сервер. Сделано это для того, чтоб основной сервер сразу же не ложился, что не смог подключиться к бд, так как она только запускалась. А запускалась она дольше, чем сервер. Из-за этого было необходимо искусственно приостановить запуск сервера.

Возвращаемся к форуму. Теперь я мог полноценно и удобно создать свою микро сервисную архитектуру, и форум как раз идеально подходил, чтоб вынести его в отдельный контейнер, потому что он работал на WebSockets, пока основной сервер на http запросах. И я не хотел смешивать эти два мира в одном безумии.

Но тогда я столкнулся с дилеммой: делать ли в форуме подключение к базе данных. И если да, то как? Необходимо было бы тогда дублировать все модели и подключаться ещё раз к бд, а это уже двойная разработка, ведь при любом изменении бд, пришлось бы вносить изменения в код там и там. Что не очень удобно. Просто подключиться к бд и дать доступ к некоторым таблицам тоже не вышло бы, потому что таблицы для форумов связаны с другими таблицами, по крайней мере с таблицами Users. Вы ведь не будете спорить, что у сообщения не должно быть авторов?

Таким образом, я оставил подключение к бд у основного сервера, а в форуме, например, при отправке нового сообщения от пользователя в чат, оно бы отправлялось по http в контроллер в основной сервер, где уже там оно бы и сохранялось в базу данных. Но таким образом, я столкнулся с проблемой, что сообщения должны передаваться по сети на основной сервер. Тут то я решил применить redis, и записывать сообщения в redis, а передавать по сети только сгенерированный ключ, по которому можно будет получить данные. Идея была спонтанная, и мне просто было интересно посмотреть, какие будут различия в скорости.
Для этой цели я решил использовать программу для стресс тестирования K6, работающая через js. В ней я создавал множества юзеров и пытался отправить 1000 сообщений. Вначале я испытывал вариант с простой отправкой сообщений через http и взял недлинное сообщения для теста длинной около 30 символов. Медианное значение всех тестов составило 20 секунд на отправку 1000 сообщений.

Далее я переделал код для второго способа для сохранения сообщения в redis и отправке простого ключа. И медианное значение нескольких тестов оказалось 18 секунд на 1000 запросов и при одном и том же количество пользователей. 2 секунды для 1000 запросов разница может быть и не слишком большая, но ведь сообщения могут быть длинные и чаще всего они такие и будут, а потому в ходе тестов было принято решение использовать redis для временного хранения сообщения. Позже и эта часть претерпела изменения, когда я стал изучать gRPC. Разработка от гугла, которая как раз и позволяет общаться между различными подами максимально быстро и с минимально передаваемыми данными по сети. В ходе тестов было выявлено, что новомодный сервис показал скорость 19-20 секунд. Что медленнее, чем через redis, но gRPC был добавлен в проект ближе к концу, когда в форуме ещё появились новые функции, замедляющие его. Через Redis я тогда уже не тестировал, а жаль. Надеюсь вы меня не побьёте.

Ещё интересной фишкой форума, является использование встроенных процедур в базе данных для записи новых сообщения. Чтоб каждый раз не отправлять целый sql запрос в базу данных, отправляются лишь параметры. Ещё особенностью является то, что процедуры добавляются в базу данных через миграции entity framework при первом запуске проекта. Благодаря этому не нужно создавать процедуру в базе данных, при любой очистке базе данных. А после подключения докера у меня это происходило очень часто. Благо одна команда docker-compose down для удаления всех контейнеров, и docker-compose up --build для их создания. И база данных сама перепишется. Особенно при изменении seed и ещё чего в бд.

Следующей интересной задачей стало проектирование содержимого главы книги. Так как оно может быть разным и динамичным: от простого текста до предела ваших фантазий, было принято решение использовать наследование между моделями таблиц, отвечающих за содержимое. Какое бы содержимое не было бы разным, общие поля всё равно были бы у всех у них. Благодаря этому можно сделать общего предка и использовать подход TPC, позволяющий каждую дочернюю модель от нашего предка представить в отдельной таблице. Что позволит нам потом добавлять сколь угодно типов контента для главы, что соответствует 2 принципу SOLID - код должен быть открыт для расширения и закрыт для изменения. То есть добавления нового функционала должно быть за счёт добавления нового кода, а не изменения уже написанного.

На словах может быть сложно, но выглядело в коде это примерно вот так:

    public class ChapterSection
    {
        public required ContentBase ContentBase { get; set; }   
        public ICollection<SectionChoice> SectionChoices { get; set; } = new List<SectionChoice>();
    }

Здесь создается секция главы, в которой должен быть контент и выборы при желании.

    public class ContentBase
    {
        [Key]
        public int ContentId { get; set; }
        [JsonIgnore]
        public ChapterSection ChapterSection { get; set; }
    }

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

     public class AudioContent : ContentBase
      {
            public required string Url { get; set; }
      }
   public class TextContent : ContentBase
    {
        public required string Text { get; set; }
    }

и так далее.

Тестирование

Также хочется поговорить по поводу командой разработки. Для составления списка работ был выбран сайт Jira, в котором можно легко и бесплатно создать проект, создавать списки задач с описанием, время выполнения, распределять роли, важность задач и смотреть итоговую статистику. В целом было удобно ей пользоваться, хоть и не всегда необходимо. Для выдачи работы второму бэкендеру очень хорошо показали себя unit тесты. И, наверное, подход экстремального программирования, когда тесты пишутся вначале работы. Благодаря этому можно не объяснять, что должно должно быть в реализуемом методе, а показать на примере тестов, что должно быть в методе, что должно возвращать при определенных ситуациях и тому подобное. В целом, сама тема тестирования очень интересная. Что касается unit тестов, то они мне показались удобными только при командой разработке, как я описал выше, но для самостоятельной разработки, начиная с этого проекта, я всегда использовал интеграционное тестирование. Вначале создаешь один тест, причём не окунаясь внутрь самой реализации метода, а просто смотришь так, как будто бы он есть, что он принимает некоторый список входных параметров и что-то возвращает. Так как у нас API, то мы буквально обращались в тесте к нужному эндпоинту в проекте и проверяли, правильные ли данные он отправляет нам обратно. Плюс в том, что не надо знать, какие действия выполняются внутри, то есть тест ведёт себя как полноценный клиент. Не надо писать большое количество кода, как для unit тестов, и всегда есть возможность проверить работает ли данный метод одним нажатием для запуска теста.

    // как будто бы конструктор
    _client = factory.CreateClient();

    string jwtToken = _token.GenerateJwtToken("admin");
    _client.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("Bearer", jwtToken);
    // 

    [Fact]
    public async Task GetFriends_Ok_ReturnsOkResult()
    {
        // Arrange
        var uri = "api/Account/Friends";

        // Act
        var response = await _client.GetAsync(uri);
        var content = await response.Content.ReadAsStringAsync();
        var friends = JsonConvert.DeserializeObject<ICollection<FriendDTO>>(content);

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.NotNull(friends);

        foreach (var friend in friends)
        {
            Assert.False(string.IsNullOrEmpty(friend.Id), "id друга отсутсвует");
            Assert.False(string.IsNullOrEmpty(friend.UserName), "должен быть User Name");
            Assert.True(friend.FriendShipTime != DateOnly.MinValue, "Невозможная дата");
        }
    }

В Arrange части подготавливаются входные данные, в нашем случае это адресная строка и userId, кодированный в токен, который ложится в заголовок клиента в конструкторе. В Act происходит само действие, в нашем случае на указанный url отправляется запрос. В Assert части идёт проверка всех полученных данных.

Интересные заметки

Насчёт создания остальной части проекта безусловно имелись проблемы, но они были не такие уж и значительные. Из интересного могу ещё сказать про пагинацию. Дело в том, что во время её создания я думал, что будет достаточно просто пропускать из начала какое-то количество книг, например 10, которые будут отображаться на странице, и затем брать это же количество. То есть linq формула для вычисления диапазона будет такая: Books. Skip(page - 1 * 10).Take(10). Но потом я узнал, что бывают разные способы пагинации и что они нужны для того, чтоб избегать проблемы, про которую я раньше не слышал и даже не осознавал: при добавлении новых книг во время того, как какой-то другой пользователь переходит по страницам, есть вероятность, что пользователю из-за добавленной книги поменяется порядок расположения книг, из-за чего на страницах ему могут попадаться одни и те же книги, которые он уже видел. Да и в целом нумерация будет другая. Это проблему я осознал, но не переделывал в этом проекте.

Ещё для удобства работы с пагинацией серверу удобнее прикреплять сведения о страница, такие как, есть ли следующая страница, есть ли предыдущая страница, текущая страница, в заголовок ответа, чтоб не перегружать само тело запроса. А ещё может быть стоит сделать такой ендпоинт не только Get http, но Body http, когда тело запроса всегда возвращается пустым, чтоб пользователь мог получить только информацию о страницах в заголовке запроса и без тела, уменьшая трафик по сети при необходимости.

Для проверки работоспособности программы и документации на ранних этапах применялся Swagger - встроенный в .Net API средство, запускаемая при дефолтных настройках при старте программы. Однако я бы рекомендовал для любых проектах использовать Postman, так как он содержит в себе больше настроек, больше уже встроенных вариаций авторизации через API да и в целом позволяет сохранять запросы в памяти. И по моему опыту, Swagger, которые каждый раз запускается при старте программы лучше выключить в конфигурациях проекта, уж слишком бесит, а если вам надо, то url у него не такой уж и сложный, чтоб найти.

Пример страницы Postman для проекта
Пример страницы Postman для проекта

Ещё для лучшей разработки я бы рекомендовал попробовать Rider вместо Visual Studio. Шедевр от Jet Brains сразу покорил моё сердце, который для средних-больших проектов прям очень удобный. Он содержит в себе просмотрщик различных баз данных, не только какой-то MS SQL, но и даже Redis, что сильно помогает при отладке. Также в нём больше различных функций при редактировании кода, генерации кода. Очень часто использую такую функцию, когда я изменяю название или список входных параметров у метода в репозитории, тыкаю на несколько сочетаний клавиш и изменения соотносятся и с интерфейсом и во всех местах, где вызывается этот метод. В плане тестирования там всё так же хорошо, как и в VS, в плане отладки я бы сказал, что Rider показывает больше информации на экране, но каждый раз бесит, когда я прошу его сделать один шаг по моему коду, он переходит внутрь какого-то метода из добавленной библиотеки и продолжает свой путь там. Пришлось ставить больше точек остановы, но всё равно опыт приятный. Но и у VS отладка тоже хорошая. А с точки зрения производительности однозначно Rider впереди, он тратит меньше ресурсов да и имеет внутри себя режим энергосбережения или что-то похожее, после чего тратит ещё меньше ресурсов и работает ещё быстрее.

Эксперименты

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

Таким вот способом в проект были добавлены gRPC и GraphQL, но только для самой большой сущности Book. Благодаря этому я понял, что GraphQL позволяет легко, просто и всего одним методом с разными атрибутами заменять множество запросов, которые раньше делались через REST API с использованием DTO. Это достигается за счёт того, что клиент теперь сам решает, какие поля модели он хотел бы получить. Даже пагинация встроенная там есть, а говорить о сортировке и фильтрации и не приходится. Однако таким подходом нужно быть очень внимательным и всё равно ограничивать поля, которые пользователь может брать с сервера, ведь порой он может взять так много данных, что это положит сервер, Например, те данные, которые формируют круговую зависимость: в дочерних объектах есть ссылки на родительскую.

Общий доступ и установка

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

Для запуска проекта требуется сохранить себе репозиторий с гитхаба:

https://github.com/Odinson137/AuthorVerseServer.

После этого скачиваем Docker и/или убеждаемся, что он открыт и работает.
Переходим в проект и пишем команду:

docker-compose up


После этого начнется загрузка всех нужных образов с докера и создание своих собственных контейнеров.

Дальше проект должен запуститься и в базу данных должны добавиться базовая информация для всех таблиц. Для теста всех имеющихся endpoints можно войти в Swagger по данной ссылке:

http://localhost:7069/swagger/index.html

Если хотите протестировать GraphQL, но необходимо обратиться к адресу:

http://localhost:5288/graphql

В репозитории проекта также есть подробное объяснение того, как запустить и работать с данным проектом.

На этом всё. Если у вас имеются вопросы про статью, указание на ошибки, вопросы по коду, указание на ошибки в коде, то пишите в комментарии. Я всегда рад получать критику за своё детище) А также можете со мной связаться по этой почте:
yura.buryy137@gmail.com

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

Публикации

Истории

Работа

.NET разработчик
61 вакансия

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

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
11 сентября
Митап по BigData от Честного ЗНАКа
Санкт-ПетербургОнлайн
14 сентября
Конференция Practical ML Conf
МоскваОнлайн
19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
25 сентября
Конференция Yandex Scale 2024
МоскваОнлайн
28 – 29 сентября
Конференция E-CODE
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн