Сколково изнутри в 17:00
Сколково изнутри в 17:00

Сегодня, я хочу поделиться опытом и рассказать про участие в командном хакатоне от совкомбанка. Вкратце опишу задачу — командой до 5 человек сделать внутренний сервис для подбора персонала и ведения HR деятельности. Кому интересен опыт участия и немного изнанки хакатонов — прошу под кат).

Подготовка — пол дела

На первом этапе команду я решил собирать из участников прошлого хакатона в котором участвовал (DatsArt занял 46из340место) и кинул клич в телеграм чате, сразу состав получился следующий: 2 frontend + 2 python + 1 java разработчики. Т.к мы собирались писать http приложение и в наши планы не входило писать REST API на java spring или python, я переключился на роль бекендера, который сделает апишки для веб мордочки и CRUD операциями над БД на php 8.2 + symfony, фронт сделает интерфейсы на vue 3.3 + TS + pinia, питонисты будут отвечать за алгоритм нахождения и подсчет релевантности в представленных резюме, а джавист напишет прослойку которая будет искать резюме на сторонних сайтах типа hh или superjob. Дополнительные сервисы планировались как отдельные HTTP сервисы в своих контейнерах. Об этом позже.

Перед хакатоном было достаточно времени, чтобы подготовиться, я немного успел почитать про разработку на Symfony и примерить на себя роль бекенд разработчика (кто со мной знаком, в курсе, что последние 10 лет я работаю фронтенд разработчиком\тимлидом и для меня должность бекенд разработчика на симфони в новинку) Почему я не выбрал почти родной мне js\ts? — цель хакатона не просто показать «смотри как могу на ноде» но и предоставить рабочий проект, который не стыдно будет поддерживать в ентерпрайзе и собеседовать людей не на свой велосипед на ноде, так что Symfony с ее документацией, доктриной и остальными возможностями — мне показались отличным вариантом. Также каждому разработчику я приобрел VDS на время хакатона + mysql бд как сервис.

Веселые старты

Не считая непривычной мне роли. На последней QA сессии перед хакатоном, организаторы донесли мысль, что ДИЗАЙН ПРЕЗЕНТАЦИИ ОЧЕНЬ ВАЖЕН и я как капитан команды, принял неудобное для одного из разработчиков python решение — заменить на дизайнера. Добровольно никто место не хотел сдавать, а проигрывать мне не хотелось и пришлось принимать решение такое. Так к нам присоединился дизайнер интерфейсов, который подавал заявку на платформе CodenRock по нашему объявлению.

Пол пути к победе

В первый же день начала хакатона, после прочтения задания (а оно совпадало на 90% со спойлером задания) второй python разработчик говорит нам, что не хочет подвести команду и не вывезет и покидает нас) Я прошу организаторов конкурса вернуть нам первого python разработчика, такая вот сантабарбара) Но мы все же в полном составе проходим на старте хакатона.

Основные сущности в нашем проекте были Вакансии, Резюме, События (встречи, собеседования), заявки на согласование

  • Вакансии — кого, куда, за сколько ищем

  • Резюме — складируем резюме кандидатов в свою БД для ведения отчетности и архива, позволяем выбирать кандидатов на вакансию из доступных резюме

  • События — абстрактные события — дата начала, участники, тип события (встреча, переговоры, собеседование)

  • Департаменты — сущность с title и возможностью прикреплять к ней учетки пользователей

  • Навыки — ключевые навыки которые проходят как теги сквозь пользователей, вакансии, резюме

  • Пользователи — с ролями и уровнями доступа, указанием в каком департаменте работает и какие навыки имеет.

Логика работы очень простая, заводим вакансию, назначаем ответственного, ответственный добавляет резюме, отбирает кандидатов, назначает встречи и согласования + строим графики и всякие отчетики сколько успеем для красивого дешборда.

На пол пути к победе

На третий день хакатона мы имели рабочий swagger основные экраны для CRUD операций над сущностями, в сумме проработав часов 8 над кодом. Ниже приведу пример контроллера который у меня получался (они все одинаковые получились почти). Больше работать не получалось т. к. я не брал выходных на хакатон и полноценно работал на основной работе полный рабочий день.

Пример ApiSkillsController.php
<?php

namespace App\Controller;

use App\Entity\Skill;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use OpenApi\Attributes as OAT;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use App\Service\ApiFrontendService;

class ApiSkillsController extends AbstractController
{
    private ApiFrontendService $apiFrontendService;
    public function __construct(EntityManagerInterface $entityManager, ValidatorInterface $validator) {
        $this->apiFrontendService = new ApiFrontendService($entityManager, $validator);
    }
    /**
     * Список навыков
     */
    #[OAT\Get(
        path: '/api/skills',
        security: ['X-AUTH-TOKEN'],
        operationId: 'app_api_skill',
        description: 'Список всех навыков',
        tags: ['Skills'],
        responses: [
            new OAT\Response(
                response: 200,
                description: 'All skills',
                content: new OAT\JsonContent(
                    type: 'array',
                    items: new OAT\Items(ref: "#/components/schemas/Skill")
                )
            ),
        ]
    )]
    #[IsGranted('ROLE_USER')]
    #[Route('/api/skills', name: 'app_api_skill', methods: ['GET'])]
    public function index(): JsonResponse
    {
        return $this->apiFrontendService->getAllEntity('App\Entity\Skill');
    }

    /**
     * Создание навыка.
     */
    #[OAT\Post(
        path: '/api/skills',
        security: ['X-AUTH-TOKEN'],
        operationId: 'app_api_skill_create',
        description: 'Заведение нового навыка',
        tags: ['Skills'],
        parameters: [
            new OAT\RequestBody(
                required: true,
                content: new OAT\JsonContent(ref: "#/components/schemas/Skill")
                   
            )],
        responses: [
            new OAT\Response(
                response: 200,
                description: 'Entity созданного навыка',
                content: new OAT\JsonContent(
                    type: 'array',
                    items: new OAT\Items(ref: "#/components/schemas/Skill")
                )
            ),
        ]
    )
    ]
    #[IsGranted('ROLE_USER')]
    #[Route('/api/skills', name: 'app_api_skill_create', methods: ['POST'])]
    public function create(Request $request, EntityManagerInterface $em, ValidatorInterface $validator): JsonResponse
    {
        $rq = json_decode($request->getContent());
        $skill = new Skill();
        $skill->setTitle($rq->title);
       
        $errors = $validator->validate($skill);
        if (count($errors) > 0) {
            return new JsonResponse([
                'errors' => array_map(function ($error) {
                    return [
                        'property' => $error->getPropertyPath(),
                        'message' => $error->getMessage()
                    ];
                }, iterator_to_array($errors))
            ]);
        }
        $em->getRepository(Skill::class)->save($skill, true);

        return new JsonResponse([
            'data' => $skill->asArray(),
            'errors' => []
        ]);
    }

    /**
     * Редактирование навыка.
     */
    #[OAT\Put(
        path: '/api/skills/{id}',
        security: ['X-AUTH-TOKEN'],
        operationId: 'app_api_skill_edit',
        description: 'Редактирование навыка',
        tags: ['Skills'],
        parameters: [
            new OAT\RequestBody(
                required: true,
                content: new OAT\JsonContent(ref: "#/components/schemas/Skill")
                   
            )],
        responses: [
            new OAT\Response(
                response: 200,
                description: 'Entity навыка',
                content: new OAT\JsonContent(
                    type: 'array',
                    items: new OAT\Items(ref: "#/components/schemas/Skill")
                )
            ),
        ]
    )]
    #[IsGranted('ROLE_USER')]
    #[Route('/api/skills/{id}', name: 'app_api_skill_edit', methods: ['PUT'])]
    public function editDepartament(int $id, Request $request, EntityManagerInterface $em, ValidatorInterface $validator): JsonResponse
    {
        $rq = json_decode($request->getContent());
        $repo = $em->getRepository(Skill::class);
        $departament = $repo->findOneBy([
            'id' => $id
        ]);
        if(!$departament) {
            return new JsonResponse([
                'errors' => ['Invalid ID']
            ]);
        }
        $departament->setTitle($rq->title);
        $errors = $validator->validate($departament);
        if (count($errors) > 0) {
            return new JsonResponse([
                'errors' => array_map(function ($error) {
                    return [
                        'property' => $error->getPropertyPath(),
                        'message' => $error->getMessage()
                    ];
                }, iterator_to_array($errors))
            ]);
        }
        $repo->save($departament, true);
        
        return new JsonResponse([
            'data' => $departament->asArray(),
            'errors' => []
        ]);
    }

    /**
     * Получение навыка по ID
     */
    #[OAT\Get(
        path: '/api/skills/{id}',
        description: 'Получение навыка по ID',
        tags: ['Skills'],
        responses: [
            new OAT\Response(
                response: 200,
                description: 'Departament entity',
                content: new OAT\JsonContent(ref: "#/components/schemas/Skill")
            ),
        ]
    )]
    #[IsGranted('ROLE_USER')]
    #[Route('/api/skills/{id}', methods: ['GET'])]
    public function get(int $id): JsonResponse
    {
        return $this->apiFrontendService->getEntityById('App\Entity\Skill', $id);
    }
}

Очень понравилось использовать OpenApi\Attributes — прописываешь атрибуты прямо рядом с кодом и документация строится сама! Очень круто! Если кто подскажет на хабре, как сделать так чтобы Shemas генерировались с Entity и их не приходилось писать в nelmio_api_doc.yaml, будет очень хорошо) Также для уменьшения кода я сделал простенький сервис для типовых операций

ApiFrontendService.php
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Frontend сервис для унификации ответов JSON
 */
class ApiFrontendService
{
    private EntityManagerInterface $entityManager;
    private ValidatorInterface $validator;
    
    public function __construct(EntityManagerInterface $entityManager, ValidatorInterface $validator)
    {
        $this->entityManager = $entityManager;
        $this->validator = $validator;
    }

    /**
     * Получение Entity по Entity.ID
     * @return JsonResponse(Entity[])
     */
    public function getAllEntity(string $className): JsonResponse
    {
        return new JsonResponse(
            array_map(function($entity) {
                return $entity->asArray();
            }, $this->entityManager->getRepository($className)->findAll())
        );
    }

    /**
     * Получение Entity по критерию
     * @return JsonResponse(Entity[])
     */
    public function getEntityListByCriteria(string $className, array $criteria): JsonResponse
    {
        return new JsonResponse(
            array_map(function($entity) {
                return $entity->asArray();
            }, $this->entityManager->getRepository($className)->findBy($criteria))
        );
    }

    /**
     * Получение Entity по наличию memberValue в memberProp 
     * @return JsonResponse(Entity[])
     */
    public function getEntityMembersColllection(string $className, string $propName, int $memberValue): JsonResponse
    {
        $members = $this->entityManager->getRepository($className)->createQueryBuilder('e')
            ->where(':memeber_value MEMBER OF e.'.$propName)
            ->setParameter('memeber_value', $memberValue)
            ->getQuery()
            ->getResult();
        if(!$members) {
            return new JsonResponse(null, 200);
        }
        return new JsonResponse(array_map(function($entity) {
            return $entity->asArray();
        }, $members));
    }

    /**
     * Получение Entity по Entity.ID
     * @return JsonResponse(Entity)
     */
    public function getEntityById(string $className, int $id): JsonResponse
    {
        $entity = $this->entityManager->getRepository($className)->findOneBy([
            'id' => $id
        ]);
        if (!$entity) {
            return new JsonResponse(null, 404);
        }
        return new JsonResponse($entity->asArray());
    }

    /**
     * Сохранние сущности
     * @return JsonResponse(Entity|Errors[{property:string,message:string}])
     */
    public function saveEntity(string $className, $entity): JsonResponse
    {
        $errors = $this->validator->validate($entity);

        if (count($errors) > 0) {
            return new JsonResponse([
                'errors' => array_map(function ($error) {
                    return [
                        'property' => $error->getPropertyPath(),
                        'message' => $error->getMessage()
                    ];
                }, iterator_to_array($errors))
            ], 400);
        }
        
        $this->entityManager->getRepository($className)->save($entity, true);

        return new JsonResponse($entity->asArray(), 200);
    }
}

Был еще вариант использовать api‑platform, но меня он испугал своими правилами для его кастомизации помимо CRUD, уж слишком много кода чтобы описать 1 action для API, профит с генерацией CRUD не перебивает оверхед с возней с этим бандлом в дальнейшем, по крайней мере во время хакатона у меня)

На фронте помимо формочек, все работало через набор pinia stores, пример одного из сторов на фронте. В остальном же обычный бутстрап, не вижу смысла показывать листинг. Ссылка покликать наш результат есть в конце статьи.

UsersStore.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import http from '@/http';
export const useUsersStore = defineStore('users', () => {
    /**
     * Список пользователей
     */
    const list = ref([] as any[]);

    /**
     * Загрузка списка пользователей
     * @returns Promise<AxiosResponse> with Users[] entity
     */
    const fetchList = async function fetchList():Promise<any[]> {
        const response = await http.get('/api/users');
        if(response.status === 200 && response.data) {
            list.value = response.data;
        }
        return response.data;
    };

    /**
     * Получение профиля пользователя по ID
     * @param id User.id
     * @returns Promise<AxiosResponse> with User entity
     */
    const fetchUser = async function fetchUser(id:number):Promise<any> {
        return await http.get(`/api/user/${id}`);
    };
    
    /**
     * Создание нового пользователя
     * @param user object
     * @returns Promise<AxiosResponse> with User entity
     */
    const create = async (user:any):Promise<any> => await http.post('/api/users/new', user);

    /**
     * Редактирование пользователя
     * @param id number
     * @param user object
     * @returns Promise<AxiosResponse> with User entity
     */
    const update = async (id:number, user:any):Promise<any> => await http.put(`/api/users/${id}`, user);

    /**
     * Фильтр по пользователю
     * @param id number userId
     * @returns Users[]
     */
    const getUserById = (id:Number) => {
        return list.value.filter(u => u.id === id)[0];
    }

  return { list, fetchList, fetchUser, create, update, getUserById }
});

Первые 3 дня я не особо синхронизировался с командой, делая упор на выдачу документации к API и скорейшей интеграции с их сервисами. На третий день, интересуясь, что сделали ребята (python и java) я выясняю, что сделали — ничего. И ничего делать в целом не будут.Причины я выяснять не стал, как и обсуждать их решение. В этот момент я понимаю, что нас теперь условно трое) дизайнер, фронтенд и я. Принимаю решение продолжать работу, но уже беру на себя часть интерфейсов связанных с созданием вакансии и их наполнением фикстурами переключаю свое внимание на то, чтобы хотябы экраны в минимальном виде у нас все были и работали или что‑то показывали. В место внешнего сервиса на python, берем моки с stats.hh.ru и строим псевдоаналитику уже, частично на моках и частично на реальных данных (что мы сами добавили в сервис).

День перед финалом

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

Пример просмотра вакансии
Экран просмотра вакансии
Экран просмотра вакансии

Список резюме:

Экран со списком резюме
Экран со списком резюме
Стартовый экран пользователя
Стартовый экран пользователя

Дизайнер за время хакатона нарисовал SVG-логотип и подготовил пачку аватарок нагенерированных нейросетью. Весь остальной дизайн это просто bootstrap.

Финал и подведение итогов

Краткая статистика хакатона
Краткая статистика хакатона

Из 216 команд, которые в итоге осилили 42 решений, в финал прошли 13, а наше решение попало в ТОП 10 на 9 место. Из сильных сторон нашего решения отметили легкость поднятия проекта (npm run devна фронте и symfony server:startдля бекенда) Наличие `docker‑compose` файла для деплоя и taskfile.yaml как общий набор команд для репозитория. Также отметили подход к авторизации через X-AUTH-TOKEN в заголовках, саму реализацию токена мы вынесли за рамки проекта и подразумевали, что нам токен отдаст внешний сервис авторизации внутри банковской системы. Из слабых — общая недоделанность аналитики и воронки кандидатов, слабая проработка ролевой модели. Потыкать интерфейсы можно на демо стенде mehunt.ru. Из подарков для топ 10 участников презентовали набор мерча — толстовка\рюкзак\сумка на пояс и футболка — но по факту дали всем только сумки на пояс и футболки + грамота и пакет) вроде и мелочи, но это в целом единственный момент который меня смутил в организации. Еще смутило само Сколково, такой огромный город который типа заброшенного, только не заброшенный, просто пустой ТЦ с редкими зеваками, но это к самому хакатону отношения не имеет.

Пару слов о команде победителей — на мой взгляд заслуженные и единственные участники, кто настолько серьезно подошел к решению задачи и выполнили все в срок с кучей фишек. Ребята брали отпуска с основной работы, много хакатонили и мало спали) + были сработаны в других хакатонах. Так что заслуженное первое место с приличным отрывом.

Выводы которые я сделал для себя

  1. Лучше готовиться с командой, не просто обсудить, но и тестово что‑то поделать.

  2. Как капитан команды больше уделять времени пояснениям и составлению ТЗ для менее опытных коллег, чтобы они не простаивали

  3. Постоянная связь и поддержка внутри команды очень важны

  4. Иметь заготовки для типичных и операций, а также базовые докер контейнеры — очень полезно

  5. Больше внимания уделять самой аналитике и консультациям со специалистами по теме хакатона, это сильно помогает.

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

Буду рад ответить на ваши вопросы и комментарии) а также найти желающих принимать совместное участие в хакатонах в любой роли, ну или я готов присоединиться к вам ) welcome in tg @dstrokov

P. S. Пост про релиз веб‑компонента wc‑wysiwyg будет совсем скоро, публичный черновик поста уже доступен на webislife.ru, а сам релиз в гите уже 1.0.4:)

Комментарии (8)


  1. FanatPHP
    02.06.2023 19:01
    -3

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


    Большое спасибо и побольше бы таких статей!


    1. strokoff Автор
      02.06.2023 19:01
      +2

      Спасибо, но думаю опытные симфони разработчики еще придут и аргументированно насуют мне ) аргументов, как сделать код лучше и что можно было бы использовать еще. Но пока минусующие за низкий технический материал и неконструктивное общение молчат, хотя ревью и конструктив всегда приветствую!


  1. deifos
    02.06.2023 19:01
    +3

    Интересная статья. почаще бы так делились опытом, еще и с примерами чистого кода


    1. strokoff Автор
      02.06.2023 19:01
      +1

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


  1. LyuMih
    02.06.2023 19:01
    +2

    Согласен. Быть капитаном команды в хакатонах - это отдельный вид искусства.

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


  1. farrow
    02.06.2023 19:01
    +2

    как сделать так чтобы Shemas генерировались с Entity

    Я пару лет уже не писал, но помню брали тег @Model, и с ним работало.


    Не минусил, но если интересуют замечания, то могу немного набросать из того, что сразу бросилось в глаза:


    private ApiFrontendService $apiFrontendService;

    Вы используете новую версию PHP, там можно свойство в конструктор внести, чтобы было меньше бойлерплейта. И apiFrontendService можно сразу на вход конструктора запросить, резолв зависимостей — задача фреймворка, симфони эту работу хорошо делает.


    Request $request

    Возможно для описанных примеров был бы удобнее doctrine-конвертер, он позволяет подать на вход контроллера сразу сущность.


    Запросы к бд прямо в сервисе или контроллере, логика изменений в контроллере (editDepartament) вместо сервиса — эти моменты могут вызвать расстройство у опытных читателей, но тут можно оговориться, что у нас логики мало, сущности единообразные, плюс у нас хакатон, поэтому мы не выносим запросы в классы-репозитории и где-то ленимся еще сильнее и не выносим обработку в сервис. Такая оговорка также подскажет новичкам, что не стоит код копировать как есть в свои тестовые.


    А вот возврат JsonResponse из сервиса, на мой взгляд, более грубое действие. Формат отдачи, как и код возврата — это работа контроллера. Сервис лучше держать удобным для обращения из других внешних точек: вызовов из консольной команды или из консюмера, а не только из контроллера. К тому же это довольно быстрая правка даже в контексте хакатона.


    И последнее: на хакатоне можно писать как угодно, т.к. мы спешим, и инструменты настраивать некогда, но для статьи код желательно причесать под PSR-12: добавить strict_types, поправить отступы (первые строчки разные даже у приложенных двух файлов), скобки ")]" у OAT где-то на одной строке, где-то отдельно на разных. Это мелочи, но с ними код читается приятнее и выглядит более привычно.


    За статью спасибо, легко читается. Взять новый для себя язык и фреймворк для хакатона — смело, справиться — вообще круто, видно опыт в IT. Отдельно хочется отметить swagger-аннотации: приятно видеть, что сделана не только апишка, но и дока к ней для удобства участников команды.


    1. strokoff Автор
      02.06.2023 19:01

      Спасибо вам за замечания! по сути ради таких комментов и пишу статьи, т.к. в живую фидбека по своему коду получаю очень мало и начал стараться обмениваться опытом в формате статей

      Единственное, в чем вы не правы это

      И последнее: на хакатоне можно писать как угодно, т.к. мы спешим, и инструменты настраивать некогда

      Популярное заблуждение. Быстрокодить можно на хакатонах, где важен результат выполнения функций, а не сама функция. Здесь же недельный хакатон результатом которого должна быть функциональная система - иначе я бы наделал CRUD на fastify + nodejs и вообще бы не парился со слоями - контроллер, репозиторий, сервисы, орм и тп, есть варианты сделать это куда проще без поднятия Symfony)


      1. farrow
        02.06.2023 19:01

        Согласен, тут некорректно выразился: под "как угодно" подразумевалось только менее строгое следование стилю оформления кода/форматированию. Т.е. часть, которая относится к переносам/отступам, и на которые обычно прикручивают чекеры/автоформатеры. Благодаря автоформатерам эту часть можно откладывать на попозже и поддерживаемость системы не сильно пострадает, если мы во время хакатона сэкономим время на этом этапе, т.к. настроенный позже инструмент легко приведет все файлы к нужному стилю