Сервис загрузки файлов на Golang

    В ходе разработки серверной части сервиса загрузки файлов на Golang родилось отдельное приложение – pavo. В задачи приложения входит загрузка целых файлов, по одному или несколько за раз, кусочная загрузка файла(chunked upload), конвертер изображений. Реализована загрузка данных через multipart/form-data и загрузка файла в бинарном виде в теле запроса. Для работы в production окружении используется nginx для авторизации и обработки медленных соединений. В качестве клиентской библиотеки можно использовать jQuery File Uploader.

    Установка


    Установка компилятора

    Для установки приложения необходим компилятор Golang. Инструкцию по его установке можно найти на официальном сайте. Так же необходимо настроить переменную окружения $GOPATH.

    Для примера, как это можно сделать в MacOS:
    1. Устанавливаем компилятор;

      $ brew install go
      $ mkdir $HOME/go
    2. Настраиваем переменную окружения, отредактировав профайл пользователя.

      # Add this line in your .zshrc or .bash_profile
      export GOPATH=$HOME/go
      export PATH=$PATH:$GOPATH/bin
      


    Установка систем контроля версиями

    Репозитории с кодом в сообществе не централизованы. Используются различные системы контроля версий:

    Пример установки в MacOS:

    $ brew install git mercurial svn bazaar


    Установка ImageMagick

    Для конвертации изображений на сервер используется ImageMagick:

    $ brew install imagemagick


    Установка приложения

    При первой установке запустите команду в консоли:

    $ go get github.com/kavkaz/pavo

    При обновлении приложения и зависимых библиотек:

    $ go get -u github.com/kavkaz/pavo/...


    Быстрый старт


    Для того, чтобы посмотреть, как работает приложение с базовым примером, запустим в консоли команду:
    $ pavo --storage=$GOPATH/src/github.com/kavkaz/pavo/dummy/root_storage

    Тем самым мы запустили приложение с корневой директорией в указанном через опцию --storage каталоге. Сервис c базовым примером будет доступен по адресу localhost:9073/example/jfu-basic.html. Для указания другого хоста и порта используйте консольную опцию --host.

    Подробности протокола


    Типовой ответ сервера при загрузке изображения:

    {
        "files": [
            {
                "dir": "/image/2014/6s/1c5cnx",
                "name": "original_user_filename.jpg",
                "type": "image",
                "versions": {
                    "original": {
                        "filename": "original-1qeh.jpg",
                        "height": 420,
                        "size": 28057,
                        "url": "/image/2014/6s/1c5cnx/original-1qeh.jpg",
                        "width": 300
                    },
                    "thumbnail": {
                        "filename": "thumbnail-1qef.jpg",
                        "height": 90,
                        "size": 3566,
                        "url": "/image/2014/6s/1c5cnx/thumbnail-1qef.jpg",
                        "width": 120
                    }
                }
            }
        ],
        "status": "ok"
    }

    Самый распространённый способ загрузки файлов на сервер – использование форм. В таком случае запрос представляется следующим образом:
    POST /files HTTP/1.1
    Content-Length: 21929
    Content-Type: multipart/form-data; boundary=----5XhQf4IXV9Q26uHM
    
    ------5XhQf4IXV9Q26uHM
    Content-Disposition: form-data; name="files[]"; filename="pic.jpg"
    Content-Type: image/jpeg
    
    ...bytes...

    В заголовке Content-Type передаётся значение boundary, которое служит для разделения значений в теле запроса. Таким образом за один запрос можно передать несколько файлов. jQuery File Upload имеет соответствующую опцию, для множественной отправки файлов.

    Современный подход позволяет отправлять бинарные данные, используя на клиенской стороне запрос типа XHR. Запрос, который увидит сервер, выглядит следующим образом:
    POST /files HTTP/1.1
    Content-Length: 21744
    Content-Disposition: attachment; filename="pic.jpg"
    
    ...bytes...

    Таким способом можно передать за один запрос только один файл, имя которого будет доступно в заголовке Content-Disposition.

    Для загрузки больших файлов на клиентской стороне формируется пачка запросов с частями исходного файла. Пример запроса:
    POST /files HTTP/1.1
    Content-Length: 10240
    Content-Range: bytes 0-10239/36431
    Content-Disposition: attachment; filename="pic.jpg"
    Cookie:pavo=377cb76c-2538-40d3-a3d0-13d86d206ba7
    
    ...bytes...

    Имя исходного файла и значение cookie по ключу pavo используется для идентификации промежуточного файла с загруженными частями оригинала. Заголовок Content-Range содержит информацию о том, какую часть файла предаёт клиент и каков размер оригинального файла. Если загружается последний кусок, то сервер завершает процедуру загрузки и формирует ответ с данными о полученном файле и его версиях.

    Код приложения


    Приложение написано на языке Golang. В качестве веб-фреймворка используется Gin. Код разделен на два пакета(upload и attachment) и основое приложение (исполняемый файл). Пакет upload отвечает за загрузку исходных файлов или куска файла. Пакет attachment отвечает за создание конечной директории для хранения файла и его версий, конвертацию изображений, формирование данных. Основное приложение запускает веб-сервер и реализует роль контроллера.

    Исходный код с небольшими примера в тестах доступен на github.

    Опции приложения


    Приложение имеет опции запуска --host и --storage. Указывают host:port для запуска веб сервера и корневую директорию хранилища соответственно.

    Все запросы на загрузку приложение принимает на адрес /files. В query_string параметре converts можно передать параметры конвертации для изображений. Например:

    POST /files?converts={"pic":"400x300"}

    Для всех файлов устанавливается версия по умолчанию original. Для изображений добавляется thumbnail со значением 120x90.

    Production environment


    Для работы в production окружении желательно использовать веб сервер nginx. В задачи веб сервера будет входить приём запросов от клиентов, запись тела во временный файл, авторизация запроса на основном приложении, отправка заголовков исходного запроса на приложение pavo.

    Рекомендуемая конфигурация nginx:

    server {
        listen 80;
        server_name pavo.local;
        
        access_log /usr/local/var/log/nginx/pavo/access.log;
        error_log /usr/local/var/log/nginx/pavo/error.log notice;
        
        location /auth {
            internal;
            proxy_method GET;
            proxy_set_header Content-Length "";
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass_request_body off;
            proxy_pass http://localhost:3000/auth/url/in/your/app;
            client_max_body_size 0;
        }
    
        location /files {
            auth_request /auth;
        
            client_body_temp_path     /tmp;
            client_body_in_file_only  on;
            client_body_buffer_size   521K;
            client_max_body_size      10G;
        
            proxy_pass_request_headers on;
            proxy_set_header X-FILE $request_body_file;
            proxy_pass_request_body off;
            proxy_set_header Content-Length 0;
            proxy_pass http://127.0.0.1:9073;
        }
        
        location / {
            root /Path/To/Root/Of/Storage;
        }
    }


    От клиента на веб сервер приходит запрос. Nginx дожидается получения всего запроса (эта его особенность не позволяет реализовать полноценный progress bar с проксированием на сервер приложения). После получения запроса тело будет записано во временный файл в директорию, определенную опцией client_body_temp_path.

    Перед отправкой запроса на сервер приложения pavo будет произведена авторизация. Для этого используется модуль ngx_http_auth_request_module. Будет сделан подзапрос на location /auth, который в свою очередь проксирует заголовки исходного запроса на сервер основного приложения. В случае успешной авторизации сервер должен вернуть пустое тело с кодом статуса ответа 200.

    Далее в заголовки исходного запроса добавляется новая пара, ключ X-File, а значение – путь до временного файла с телом запроса. И только после этого получившийся запрос (заголовки и пустое тело) отправляется на прилоежение pavo. Оно обрабатывает запрос, сохраняя файлы, и возвращает ответ с данными о загруженных файлах в JSON формате.

    Заключение


    Сервис задумывался как самостоятельное приложение в инфраструктуре веб проекта, которое берёт на себя роль загрузки и раздачи файлов, конвертации изображений, видео и аудио. С интерфейсом через HTTP Json API.

    UPDATE: Исправлены настройки заголовков проксируемых запросов в конфигурации nginx.
    • +21
    • 16,2k
    • 9
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 9
    • 0
      multipart/form-data позволяет вместе с файлом отправить и другие данные, составляющие комплексный запрос. К тому же HTML5 умеет создавать FormData объекты, не привязанные к DOM, которые благополучно скармливаются jQuery.
      • 0
        Но при этом multipart/form-data должен кто-то парсить, чтобы вытаскивать от туда файл и другие данные. Так что уже не получится схемы, когда сохранением файла занимается исключительно nginx.
      • +1
        В location /files видимо должно быть тоже proxy_pass_request_body off;, чтобы не передавать тело запроса, а только путь к файлу.
        • 0
          Да, стоит добавить. Тогда уже и proxy_set_header Content-Length "";
          • 0
            Только 0, а не "".
            • 0
              В доках написано использовать пустую строку. На самом деле работает и то, и то.
              • +1
                Там так написано потому, что ещё задано proxy_method GET;. Это важный момент, поскольку у GET запросов, как известно, Content-Length быть не должно. В частности, у вас для location /auth установка proxy_set_header Content-Length 0; некорректна, поскольку auth_request тоже посылает GET запрос.

                Если не менять тип запроса, то для POST запросов без тела лучше использовать значение 0, а не спиливать заголовок совсем. В противном случае бекенд будет вынужден определять окончание запроса по закрытию соединения, что не очень оптимально и делает невозможным включение keep-alive в этом месте.
        • 0
          client_body_buffer_size   521K;

          521 или все-таки 512?

          • 0
            А вы внимательный.

            Не принципиально.

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