python

История одного тестового задания

  • четверг, 14 мая 2015 г. в 02:11:03
http://habrahabr.ru/post/257817/

Некоторое время назад, листая просторы хабра, я наткнулся на вакансию «Python Backend Разработчик». В ней больше всего меня подкупило расположение офиса — он был рядом с домом, и я написал отклик. Ответ пришел быстро с вопросом о том, не готов ли я выполнить тестовое задание. Я ответил, что подумаю, если мне его пришлют. Письма с заданием не было недели две.

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

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

Фактически приложение должно состоять из одной страницы, но по желанию можно разделить на несколько (отдельно форму загрузки).
Необходимые элементы — форма загрузки фотографии и таблица со списком загруженных фото. Авторизация не обязательна.

Форма загрузки:
Текстовое поле для ввода названия фото
Выбор файла

Таблица:
Превью фото (необходимо сделать уменьшенную копию фото (миниатюру); также данное превью должно являться ссылкой на оригинальное/полное изображение, которое открывается по клику на превью)
Название фото (которое пользователь указывает при загрузке)
Производитель и модель камеры (из EXIF, если присутствует)
Размер файла
Дата создания фото (из EXIF)
Дата загрузки фото
Кнопка удаления

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

Итак, в качестве веб-фреймворка для Python был выбран Tornado, я с ним давно знаком. Мы будем поднимать несколько backend серверов, поэтому нам понадобится балансер и Supervisor. Изначально я думал о HAProxy в качестве балансера, но тут меня осенило, что картинки может хорошо раздавать NGINX. В итоге в начале архитектура мне показалась такой: NGINX балансирует соединения и раздает статику с диска, 4 сервера Tornado обрабатывают запросы, Redis синхронизирует backend.

На Tornado упала ноша анализа поступающих картинок и создание миниатюр. В задании не сказано, какие форматы необходимо поддерживать, поэтому я поискал описание EXIF в википедии, где упоминаются форматы TIFF и JPEG. Если это все, то дела не так уж плохи, библиотека Pillow для Python поддерживает оба формата, а также EXIF метаданные. Но есть нюанс — TIFF изображения не открываются браузером. Это делает невозможным открытие оригинального файла в браузере, поэтому я решил перекодировать эти изображения в JPEG и дополнительно сохранить вместе с полученным файлом EXIF данные, из которых можно было бы восстановить всю необходимую информацию для отображения в таблице.

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

Решение с метаданными в JPEG мне показалось красивым, и, хотя Pillow вполне умеет сохранять EXIF в JPEG, сами метаданные при этом должны быть уже в бинарном формате. То есть, Pillow выдает метаданные в виде словаря, но вот из словаря в метаданные никак не умеет. Была найдена библиотека Gexiv2, так же работающая с метаданными, но ее установка потребовала сноровки.

Попытка собрать Gexiv2 из исходников много раз приводила к ошибкам об отсутствующих библиотеках. В поиске очередной такой библиотеки я наткнулся на установочный пакет этой библиотеки для Ubuntu. Но и тут возникла проблема. Python на систему я установил через pyenv, и запускать скрипты собирался из virtualenv, но в таком случае установленный в систему Gexiv2 оказывается недоступен. Есть определенные танцы с бубном на эту тему, но уже потратив час на Gexiv2, я решил отказаться от virtualenv и использовать системный Python 2.7.6.

Gexiv2 успешно редактирует EXIF в файлах, но с данными в памяти у него туго. А я принципиально не хотел дважды обращаться к файлу: один раз на запись JPEG, второй раз на запись в этот же файл метаданных. Я еще не знал, что меня ждет. А ждало меня следующее — в документации Gexiv2 были перечислены поддерживаемые форматы, такие как EXV, CR2, CRW и многие другие. Таким образом Pillow уже не справлялось с задачей чтения загружаемых изображений. Так я нашел ImageMagick, и соответствующий адаптер под Python — Wand.

Wand выглядел многообещающе — поддержка множество форматов, чтение EXIF, относительно простая установка. Но чтобы сохранять JPEG со своими метаданными мне все равно нужен Pillow. Потратив некоторое время мне повезло найти библиотеку piexif, которая помогала редактировать метаданные в Pillow, и одной проблемой стало меньше. Потратив несколько часов можно сесть и программировать.

Алгоритм был простой, Wand загружает картинку из памяти, выдает EXIF данные, потом Wand отдает буфер RGB, считаем его md5 хеш чтобы проверить на дубликаты, конвертируем буфер в JPEG и сохраняем со своими метаданными, плюс сохраняем миниатюру. Конечно же соответствующе обновляем данные в Redis. Осталось проверить. Однако найти в интернете картинки с метаданными, да еще и свежими — проблема. И я потратил еще немало времени на поиск программы, которая бы хорошо редактировала EXIF данные.

И вот, первый JPEG семпл готов, загружаем — работает! А вот второй семпл, CR2 файл размером 7MB выдал несколько сюрпризов. Первый — Wand не смог его прочитать из буфера, ему потребовалась подсказка формата в виде расширения исходного файла. Но и тут проблема, библиотека стала писать, что не находит какой-то временный файл. Опять поиски, оказалось нужно установить утилиту ufraw, и файл прочитался. За 11 секунд. А потом в JPEG вывалилось нечто больше похожее на шум чем на исходную картинку.

Изначально я грешил на Wand, мне казалось, что он криво конвертирует картинку в RGB буфер, однако, запустив калькулятор, я обнаружил что буфер ровно в 2 раза больше чем необходимо — то есть на канал приходится не 8, а 16 бит. Ура, одна строчка и все работает. Но что делать с долгой загрузкой файла? Даже если серверов будет четыре, такое же количество больших CR2 файлов просто сделают сервис недоступным.

В течение первого дня я потратил немалую часть времени на установку системы, на поиск и установку различных библиотек, на исследование предметной области, но в итоге написал всего лишь 150 строк кода. И в итоге тестовое задание было провалено — результат работы приложения был неприемлем. А больной организм просился отдыхать.

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

Начал я со сборки Upload модуля NGINX, и, конечно же, безуспешно. Провозившись некоторое время, я понял, что автор его забросил, и на последней версии этот модуль не заработает. Ну и ладно — пусть Tornado сервер сохраняет входящие файлы на диск. Далее фактически копипаста в новые скрипты обработки изображения. Конфигурируем Supervisor и NGINX, допиливаем скрипт установки. И оно работает.

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

В итоге я считаю, что при относительно низкой нагрузке данное приложение будет работать стабильно, а на мощном сервере с множеством ядер можно говорить о хорошей производительности. К сожалению, данное тестовое задание было наполнено по большей части поиском библиотек и администрированием, в нем очень мало программирования. Что хотел выяснить работодатель этим заданием мне не очень понятно. Может требовалось написать свою библиотеку для получения EXIF данных? И нельзя назвать такое тестовое задание небольшим — по времени вышло более 8 часов. Сильно бы упростило задачу внесение конкретики по поддерживаемым форматам изображений, более развернутое объяснение целевого использования приложения.

Исходники можно посмотреть на github. А я дальше болеть.