Эта статья только первая из цикла "прохожу тестовые задания". Подобными заметками я хочу показать другим начинающим программистам, с чем им придется столкнуться при собеседованиях на работу. Сам я изучаю питон (и не только) уже порядка 4 лет, но это только теория с практикой на своих пет-проектах, что, как оказалось, с реальным программированием не имеет ничего общего. Итак, хватит лирики.

Задача и попытки её решить

Сразу скажу, компанию и какие-либо имена я называть не буду. Задание было довольно простое, написать связку клиент сервер на сокетах с помощью pyZMQ для транслирования видео. Проблема нарисовалась сразу, я ни разу не работал с этой библиотекой. Я пошел смотреть уроки, читать документацию и смотреть примеры других людей на гитхабе с использованием этого фреймворка. Для начала решил сделать простой стрим, который посылал бы числа на клиента, а тот их выводил на экран, всё просто. Но ZMQ так не думает, сразу возникли проблемы с кодировкой. У меня банально не работала элементарная связка send_string и recv_string. Я даже решил создать топик на stackoverflow. И, о чудо - мне никто не ответил. Отчаявшись, я полез на гитхаб в поисках какой-либо реализации клиент-серверного приложения. Там-то я и нашел ответы на мои вопросы: нужно послать pyobject. Этим же способом можно отправлять видео. После череды багов я сделал это:

Оно и правда работает
Оно и правда работает

Сервер:

import zmq
import cv2

context = zmq.Context()
socket = context.socket(zmq.PUSH)

socket.bind("tcp://*:8000")
print('server started...')

try:
    while True:
        # получим объект видео
        cap = cv2.VideoCapture('example.mp4')
        while (cap.isOpened()):
            # разбиваем по фреймам
            ret, frame = cap.read()
            if ret:
                # передаём по одному фрейму, один фрейм это картинка
                socket.send_pyobj(frame)
            else:
                # Сместим курсор на 0 фрейм
                cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
                continue
finally:
    socket.close()

Клиент:

import zmq
import cv2

context = zmq.Context()
socket = context.socket(zmq.PULL)

socket.connect("tcp://localhost:8000")
print("client started")

# Настройки для отображения видео
down_width = 400
down_height = 400
down_points = (down_width, down_height)
print('Чтобы закрыть видео нажмите q')
try:
    while True:
        image = socket.recv_pyobj()
        image = cv2.resize(image, down_points, interpolation=cv2.INTER_LINEAR)
        # показываем по одной картинке, что в итоге сложится в видео
        cv2.imshow('frame', image)

        if cv2.waitKey(1) & 0xFF == ord('q'):
            break

finally:
    cv2.destroyAllWindows()
    socket.close()

По сути мы разбиваем видео на фреймы и передаём их поочередно клиенту, он их по одному воспроизводит и получается, вроде как, стрим. Когда мы доходим до конца видео, то переносим курсор cap.set(cv2.CAP_PROP_POS_FRAMES, 0) на начальный фрейм и всё по новой. Вроде и видос транслируется, и даже поток есть, тз сделано.

Тут и начинаются проблемки, по сути тз мне дали из одного предложения, но с одной устной поправкой "есть пространство к творчеству". Многие из вас скажут: "тестовое задание и нужно для того, чтобы выпендриваться и показывать вообще все свои навыки программирования". И я скажу, что вы правы, но к сожалению, за 4 года в университете меня научили только решать простенькие алгоритмы и не более.

Поэтому, когда я скинул задание, то получил отказ и фидбэк. Огромное спасибо за это. Ведь благодаря замечаниям эта статья получит продолжение, где я попытаюсь исправить всё, на что мне указали.

Что ж тут не так?

Миша, всё х**ня, давай по новой. Ладно всё не так плохо, вот прямая цитата

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

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

Я стал лучшим программистом, чем был вчера. Наверное...

С логированием всё понятно, его нужно добавить и вообще практика хорошая, лучше чем пользоваться принтами. В качестве инструмента, я не стал брать стандартную библиотеку logging, так как все говорят, что она хорошая, но недостаточно. Loguru мой выбор. Спасибо гайдерам на ютубе и хабру за пояснение теории логирования.

Окей, логи написаны, что там дальше? Обрывы соединения и несколько серверов. Непонятно зачем дублировать это, если библиотека zmq уже об этом позаботилась за нас, ну ладно, добавим обработку подобных ситуаций, чтобы программа не падала, а только оповещала нас об событии. Обернем инициализацию сокета в try except и добавим логи к этому.
Готово:

    try:
        context = zmq.Context()
        socket = context.socket(zmq.PUSH)
        logger.debug('Создание сокета')
        socket.bind("tcp://*:8000")
        logger.debug('Сервер запущен...')
    except Exception as e:
        logger.error(f'{e}')
        return

Окей, теперь нельзя включить 2 сервера, до этого тоже нельзя было, так что непонятно, что я вообще сделал, ну хотя бы логируется. С разрывами соединения вообще непонятно, по сути связка Push-pull работает по принципу сервер отправляет данные и кто угодно может присоединиться и принимать их, если принимать некому, то ничего и не отправляют. Короче, надеюсь в комментариях мне подскажут, что я должен был сделать в рамках данной библиотеки.

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

Вроде достаточно чтобы запустить и оно работало
Вроде достаточно чтобы запустить и оно работало

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

Результат

Сервер:

import zmq
import cv2
from loguru import logger

# настройки логгера, а именно папка сохранения логов, в каком виде вести логоирование, через какое время удалять логи
logger.add('logs/server.log', format='{time} {level} {message}', level='INFO', rotation='1 week')


def main():
    try:
        # Создание сокета
        context = zmq.Context()
        socket = context.socket(zmq.PUSH)
        # Привязка к сокету определенного хоста
        logger.debug('Создание сокета')
        socket.bind("tcp://*:8000")
        logger.debug('Сервер запущен...')
    except Exception as e:
        logger.error(f'{e}')
        return

    try:
        logger.info(f'Начало трансляции')
        while True:
            # получим объект видео
            cap = cv2.VideoCapture('example.mp4')
            while (cap.isOpened()):
                # разбиваем по фреймам
                ret, frame = cap.read()
                if ret:
                    # передаём по одному фрейму, один фрейм это картинка
                    socket.send_pyobj(frame)
                else:
                    # Сместим курсор на 0 фрейм
                    logger.debug('Видео началось заново')
                    cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
                    continue
    except Exception as e:
        logger.error(f'{e}')
    finally:
        # закрываем сокет
        socket.close()


if __name__ == '__main__':
    main()

Клиент:

import zmq
import cv2
from loguru import logger

# настройки логгера, а именно папка сохранения логов, в каком виде вести логоирование, через какое время удалять логи
logger.add('logs/client.log', format='{time} {level} {message}', level='INFO', rotation='1 week')


def main():
    # Соединение с сервером
    context = zmq.Context()
    socket = context.socket(zmq.PULL)

    socket.connect("tcp://localhost:8000")
    logger.debug("Присоеденились")

    # Настройки для отображения видео
    down_width = 400
    down_height = 400
    down_points = (down_width, down_height)
    print('Чтобы закрыть видео нажмите q')
    try:
        while True:
            # Принимаем по картинке
            image = socket.recv_pyobj()
            image = cv2.resize(image, down_points, interpolation=cv2.INTER_LINEAR)
            # показываем по одной картинке, что в итоге сложится в видео
            cv2.imshow('frame', image)

            if cv2.waitKey(1) & 0xFF == ord('q'):
                logger.info('Пользователь отключился')
                break
    except Exception as e:
        logger.error(f'{Exception}')

    finally:
        # Закроем все окна и все сокеты
        cv2.destroyAllWindows()
        socket.close()


if __name__ == '__main__':
    main()

Заключение

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

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

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


  1. bombe
    16.08.2022 11:50
    +2

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

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

    # Закроем все окна и все сокеты

    Зачем? Разве из следующих двух строк это не очевидно? Такой комментарий только захламляет код, его "чуточку сложнее" воспринимать.

    Комментарий, дублирующий документацию сторонних библиотек (как работа логгера): зачем? В крайнем случае - ссылка на документацию, и то навряд ли нужно, ибо обычный программист работающий со сторонними библиотеками в курсе, что почти в 99.99% случаев у библиотек адекватная документация и если что-то не понятно - то нужно искать объяснения использования именно там, а не у Вас в коде.

    # Настройки для отображения видео

    А вот это - случай поинтереснее. Если бы так случилось, что программисту дают впервые смотреть этот код и он не знает что он должен делать - тут комментарий оправдан, потому что именование переменных немного странное. Что значит down_points ? Что это за нижние точки? Зачем две переменные down_width и down_height, если они используются только единожды? Задел на будущее? Почему они используются в cv2.resize, ресайз не предусматривает увеличение, только уменьшение? Но, опять же, тут лучше не комментировать, а просто переназвать переменные, которые будут отражать суть для чего они были инициализированы.


    1. Areso
      16.08.2022 14:09

      если они используются только единожды

      А я с вами не соглашусь.

      Магические числа - это знатный антипаттерн, поэтому наличие переменной лучше, чем её отсутствие.


      1. bombe
        16.08.2022 16:01
        +1

        frameSize = (400, 400)

        Как Вам такой вариант без дополнительных переменных и магических чисел? Дополнительная переменная в данном случае уместна только в том случае, когда она используется более одного раза, или, например, попадает в скоуп извне. Ну или всё равно на используемую память приложением. Иначе переменная должна своим именем говорить, что в ней.

        наличие переменной лучше

        Что Вы можете сказать о переменной down_width, не зная контекста? И лучше ли в данном случае? Лично мне данное имя в контексте последующего кода говорит о том, что ресайз на увеличение не должен происходить, и задумка именно на уменьшение размера картинки (и стоило бы добавить проверку, так ли это на самом деле, если я, к примеру, хочу транслировать видео 320х240).


        1. Flild Автор
          16.08.2022 16:23

          согласен с вами, спасибо за комментарий


        1. Areso
          16.08.2022 16:54

          frameSize = (400, 400)

          Отличный вариант. И название переменной тоже хорошее.

          Но она тоже используется только раз, что нарушает ваше же пожелание.

          Смотрите в чём заключается антипаттерн:

          • либо объявляете переменную и присваиваете ей значение

          • либо вы пишите комментарий, что это за число и на что влияет, а если комментария нет, то это антипаттерн.

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

          Опять же - наименование переменных, это навык, который прокачивается с опытом, и неудачно выбранное название переменной не является плохим паттерном, а является именно плохим названием конкретной переменной.


          1. bombe
            16.08.2022 17:16
            +1

            В целом, Вы полностью правы и я согласен, что в данном конкретном случае нарушается то, о чём я говорю =)

            Но давайте снимем розовые очки и представим ситуацию из реального мира. Во-первых, стоит проверить, а нужно ли вообще вызывать cv2.resize? Возможен вариант, что frameSize[0] и frameSize[1] идентичны входящему размеру, следовательно, нужна проверка использующая frameSize. При чём, в случае идентичности размеров нужна единожды, а сам вызов ресайза в цикле будет вредным (если Вам, конечно же, важно быстродействие и не всё равно на лишние такты и память). Хотя тут тоже можно возразить, что совсем ничего не мешает использовать дополнительные переменные, которые, в целом не нужны.

            Я к чему. Я согласен, что магические числа - плохо. Но я не согласен, что в данном конкретном случае использование frameSize не оправдано. Просто всунуть tuple вместо переменной в ресайз - как раз таки будет "магическими числами". И комментарий совсем не поможет.


            1. Lepeshka
              16.08.2022 19:42
              +1

              И раз уж это python, то для именования переменных лучше будет использовать snake_case


  1. Coytes
    16.08.2022 14:25
    +1

    Перед исполнением тестового хорошим правилом будет задавать вопросы по нему, чтобы уточнить ТЗ (scoping) и собрать дополнительные детали, а не "ОК, ясно, сделаю". Это покажет другой стороне что вы умеете общаться и договариваться "на берегу", а не чисто исполнитель "от а до я", и даст вам больше шансов пройти на следующий этап. Опять же, можно будет сказать что "я сделал о чем мы договаривались, и даже больше", и попросить более развернутый ответ по отказу.


  1. mbait
    17.08.2022 02:52

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


  1. anonymous
    00.00.0000 00:00


  1. Urub
    17.08.2022 14:58

    вариант трансляции потока с камеры без лишних логов и комментариев )

    #server
    import zmq, cv2
    def main():
        socket = zmq.Context().socket(zmq.PUSH)
        socket.bind("tcp://*:8000")
        while True:
            cap = cv2.VideoCapture(0)
            while (cap.isOpened()):
                ret, frame = cap.read()
                socket.send_pyobj(frame)
    if __name__ == '__main__':
        main()
    
    #client
    import zmq, cv2
    def main():
        socket = zmq.Context().socket(zmq.PULL)
        socket.connect("tcp://localhost:8000")
        while True:
            image = socket.recv_pyobj()
            image = cv2.resize(image, (800, 600))
            cv2.imshow('frame', image)
            if cv2.waitKey(1) & 0xFF == ord('q'):
                break
    if __name__ == '__main__':
        main()