Хранение большого количества файлов

в 14:34, , рубрики: php, велосипедостроение, социальные сети, хранение файлов

image

Доброго здравия, читатели! В процессе работы над проектом сайта знакомств возникла необходимость организовать хранение фотографий пользователей. По условиям ТЗ количество фотографий одного пользователя ограничено 10 файлами. Но пользователей-то могут быть десятки тысяч. Особенно учитывая то, что проект в его нынешнем виде существует аж с начала «нулевых». То есть там уже тысячи пользователей в базе. Почти любая файловая система, насколько мне известно, очень негативно реагирует на большое количество дочерних узлов в папке. По опыту могу сказать, что проблемы начинаются уже после 1000-1500 файлов/папок в родительской папке.

Дисклеймер. Я погуглил перед написанием статьи и обнаружил несколько решений обсуждаемого вопроса (например, тут или тут). Но не нашёл ни одного решения, в точности соответствующего моему. Кроме того, в данной статье я лишь делюсь собственным опытом решения задачи.

Теория

Помимо как таковой задачи хранения было ещё условие в ТЗ, согласно которому нужна была возможность оставлять к фотографиям подписи и заголовки. Само собой, без БД тут не обойтись. То есть первое, что мы делаем — это создаём таблицу, в которой прописываем сопоставление мета-данных (подписи, тайтлы и т.п.) с файлами на диске. Каждому файлу соответствует одна строка в БД. Соответственно, у каждого файла есть идентификатор.

Небольшое отступление. Поговорим про автоинкремент. На сайте знакомств может быть и десяток-другой тысяч пользователей. Вопрос в том, сколько вообще пользователей проходит через проект за всё время его существования. Например, активная аудитория «датинг-ру» составляет несколько сотен тысяч. Однако, только вообразите себе сколько пользователей удалилось за время жизни этого проекта; сколько пользователей не активировано до сих пор. А теперь приплюсуйте наше законодательство, обязывающее хранить информацию о пользователях не менее полугода… Рано или поздно 4 с копейками миллиарда UNSIGNED INT закончатся. По сему лучше всего для primary-ключа брать BIGINT.

А теперь попробуем представить себе число типа BIGINT. Это 8 байт. Каждый байт — это от 0 до 255. 255 дочерних нод — это вполне нормально для любой файловой системы. То есть берём идентификатор файла в шестнадцатеричном представлении, разбиваем оное на чанки по два символа. Используем эти чанки, как названия папок, причём последний в качестве имени физического файла. PROFIT!

0f/65/84/10/67/68/19/ff.file

Элегантно и просто. Расширение файла тут не принципиально. Всё равно файл будет отдаваться скриптом, который будет отдавать браузеру в частности MIME-тип, который мы тоже будем хранить в базе. Кроме того, хранение информации о файле в базе позволяет переопределять путь к нему для браузера. Скажем, файл у нас реально расположен относительно каталога проекта по пути /content/files/0f/65/84/10/67/68/19/ff.file. А в базе можно прописать ему URL, например, /content/users/678/files/somefile. SEO-шники сейчас, наверное, довольно улыбнулись. Всё это позволяет нам не беспокоиться больше о том, где размещать файл физически.

Таблица в БД

Помимо идентификатора, MIME-типа, URL и физического расположения мы будем хранить в таблице md5 и sha1 файлов для отсеивания одинаковых файлов при необходимости. Само собой нам нужно также хранить в этой таблице связи с сущностями. Допустим, ID пользователя, к которому относятся файлы. А если проект не шибко большой, то в той же системе мы можем хранить, скажем, фотографии товаров. По сему будем также хранить название класса сущности, к которой относится запись.

Кстати, о птичках. Если закрыть папку при помощи .htaccess для доступа извне, то файл можно будет получить только через скрипт. А в скрипте можно будет определить доступ к файлу. Немного забегая вперёд, скажу, что в моей CMS (на которой сейчас и пилится вышеупомянутый проект) доступ определяется базовыми пользовательскими группами, коих у меня 8 — гости, пользователи, менеджеры, админы, неактивированные, заблокированные, удалённые и супер-админы. Супер-админу можно абсолютно всё, так что его в определении доступа оный не участвует. Если есть у юзера флаг супер-админа, значит он супер-админ. Всё просто. То есть определять доступы будем оставшимся семи группам. Доступ простой — либо отдавать файл, либо не отдавать. Итого можно взять поле типа TINYINT.

И ещё один момент. Согласно нашему законодательству нам придётся физически хранить пользовательские картинки. То есть нам нужно как-то помечать картинки, как удалённые, вместо физического удаления. Удобнее всего для этих целей использовать битовое поле. Я обычно в таких случаях использую поле типа INT. Чтобы с запасом, так сказать. Притом у меня есть уже устоявшаяся традиция размещать флаг DELETED в 5-м бите с конца. Но это не принципиально опять таки же.

Что мы имеем в итоге:

create table `files` (
  `id`          bigint not null auto_increment, -- Первичный ключ
  `entity_type` char(32) not null default '', -- Тип сущности
  `entity`      bigint null, -- ID сущности
  `mime`        char(32) not null default '', -- MIME-тип
  `md5`         char(32) not null default '', -- MD5
  `sha1`        char(40) not null default '', -- SHA1
  `file`        char(64) not null default '', -- Физическое расположение
  `url`         varchar(250) not null default '', -- URL
  `meta`        text null, -- Мета-данные в формате JSON или сериализованного массива
  `size`        bigint not null default '0', -- Размер
  `created`     datetime not null, -- Дата создания
  `updated`     datetime null, -- Дата редактирования
  `access`      tinyint not null default '0', -- Битовый доступ
  `flags`       int not null default '0', -- Флаги
  primary key (`id`),
  index (`entity_type`),
  index (`entity`),
  index (`mime`),
  index (`md5`),
  index (`sha1`),
  index (`url`)  
) engine = InnoDB;

Класс-диспетчер

Теперь нам нужно создать класс, при помощи которого мы будем файлы загружать. Класс должен обеспечивать возможность создавать файлы, заменять/изменять файлы, удалять файлы. Кроме того, стоит учесть два момента. Во-первых, проект может быть перенесён с сервера на сервер. Значит в классе нужно определить свойство, содержащее корневую директорию файлов. Во-вторых, будет очень неприятно, если кто-нибудь грохнет таблицу в БД. Значит нужно предусмотреть возможность восстановления данных. С первым всё в общем-то понятно. Что же касается резервирования данных, то резервировать мы будем только то, что нельзя восстановить.

ID — восстанавливается из физического расположения файла
entity_type — не восстанавливается
entity — не восстанавливается
mime — восстанавливается при помощи расширения finfo
md5 — восстанавливается из самого файла
sha1 — восстанавливается из самого файла
file — восстанавливается из физического расположения файла
url — не восстанавливается
meta — не восстанавливается
size — восстанавливается из самого файла
created — можно взять информацию из файла
updated — можно взять информацию из файла
access — не восстанавливается
flags — не восстанавливается

Сразу можно отбросить мета-информацию. Она не критична для функционирования системы. И для более оперативного восстановления всё же нужно сохранять MIME-тип. Итого: тип сущности, ID сущности, MIME, URL, доступ и флаги. Дабы повысить надёжность системы, будем хранить резервную информацию по каждой конечной папке отдельно в самой папке.

Код класса

<?php

class BigFiles
{
    const FLAG_DELETED = 0x08000000; // Пока только флаг "Удалён"

    /** @var mysqli $_db */
    protected $_db       = null;
    protected $_webRoot  = '';
    protected $_realRoot = '';

    function __construct(mysqli $db = null) {
        $this->_db = $db;
    }

    /**
     * Установка/чтение корня для URL-ов
     * @param string $v  Значение
     * @return string
     */
    public function webRoot($v = null) {
        if (!is_null($v)) {
            $this->_webRoot = $v;
        }
        return $this->_webRoot;
    }

    /**
     * Установка/чтение корня для файлов
     * @param string $v  Значение
     * @return string
     */
    public function realRoot($v = null) {
        if (!is_null($v)) {
            $this->_realRoot = $v;
        }
        return $this->_realRoot;
    }

    /**
     * Загрузка файла
     * @param array  $data    Данные запроса
     * @param string $url     URL виртуальной папки
     * @param string $eType   Тип сущности
     * @param int    $eID     ID сущности
     * @param mixed  $meta    Мета-данные
     * @param int    $access  Доступ
     * @param int    $flags   Флаги
     * @param int    $fileID  ID существующего файла
     * @return bool
     * @throws Exception
     */
    public function upload(array $data, $url, $eType = '', $eID = null, $meta = null, $access = 127, $flags = 0, $fileID = 0) {
        $meta = is_array($meta) ? serialize($meta) : $meta;
        if (empty($data['tmp_name']) || empty($data['name'])) {
            $fid = intval($fileID);
            if (empty($fid)) {
                return false;
            }
            $meta = empty($meta) ? 'null' : "'" . $this->_db->real_escape_string($meta) . "'";
            $q = "`meta`={$meta},`updated`=now()";
            $this->_db->query("UPDATE `files` SET {$q} WHERE (`id` = {$fid}) AND (`entity_type` = '{$eType}')");
            return $fid;
        }
        // File data
        $meta  = empty($meta) ? 'null' : "'" . $this->_db->real_escape_string($meta) . "'";
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime  = finfo_file($finfo , $data['tmp_name']);
        finfo_close($finfo);
        // FID, file name
        if (empty($fileID)) {
            $eID = empty($eID) ? 'null' : intval($eID);
            $q = <<<sql
insert into `files` set
    `mime`       = '{$mime}',
    `entity`     = {$eID},
    `entityType` = '{$eType}',
    `created`    = now(),
    `access`     = {$access},
    `flags`      = {$flags}
sql;
            $this->_db->query($q);
            $fid = $this->_db->insert_id;
            list($ffs, $fhn) = self::fid($fid);
            $url = $this->_webRoot . $url . '/' . $fid;
            $fdir = $this->_realRoot . $ffs;
            self::validateDir($fdir);
            $index = self::getIndex($fdir);
            $index[$fhn] = array($fhn, $mime, $url, ($eID == 'null' ? 0 : $eID), $access, $flags);
            self::setIndex($fdir, $index);
            $fname = $ffs . '/' . $fhn . '.file';
        } else {
            $fid = intval($fileID);
            $fname = $this->fileName($fid);
        }
        // Move file
        $fdir = $this->_realRoot . $fname;
        if (!move_uploaded_file($data['tmp_name'], $fdir)) {
            throw new Exception('Upload error');
        }
        $q = '`md5`='' . md5_file($fdir) . '',`sha1`='' . sha1_file($fdir) . '','
           . '`size`=' . filesize($fdir) . ',`meta`=' . $meta . ','
           . (empty($fileID) ? "`url`='{$url}',`file`='{$fname}'" : '`updated`=now()');
        $this->_db->query("UPDATE `files` SET {$q} WHERE (`id` = {$fid}) AND (`entity_type` = '{$eType}')");
        return $fid;
    }

    /**
     * Чтение файла
     * @param string $url         URL
     * @param string $basicGroup  Базовая группа пользователя
     * @throws Exception
     */
    public function read($url, $basicGroup = 'anonimous') {
        if (!ctype_alnum(str_replace(array('/', '.', '-', '_'), '', $url))) {
            header('HTTP/1.1 400 Bad Request');
            exit;
        }
        $url = $this->_db->real_escape_string($url);
        $q = "SELECT * FROM `files` WHERE `url` = '{$url}' ORDER BY `created` ASC";
        if ($result = $this->_db->query($q)) {
            $vars = array();
            $ints = array('id', 'entity', 'size', 'access', 'flags');
            while ($row = $result->fetch_assoc()) {
                foreach ($ints as $i) {
                    $row[$i] = intval($row[$i]);
                }
                $fid = $row['id'];
                $vars[$fid] = $row;
            }
            if (empty($vars)) {
                header('HTTP/1.1 404 Not Found');
                exit;
            }
            $deleted = false;
            $access  = true;
            $found   = '';
            $mime    = '';
            foreach ($vars as $fdata) {
                $flags   = intval($fdata['flags']);
                $deleted = ($flags & self::FLAG_DELETED) != 0;
                $access  = self::granted($basicGroup, $fdata['access']);
                if (!$access || $deleted) {
                    continue;
                }
                $found   = $fdata['file'];
                $mime    = $fdata['mime'];
            }
            if (empty($found)) {
                if ($deleted) {
                    header('HTTP/1.1 410 Gone');
                    exit;
                } elseif (!$access) {
                    header('HTTP/1.1 403 Forbidden');
                    exit;
                }
            } else {
                header('Content-type: ' . $mime . '; charset=utf-8');
                readfile($this->_realRoot . $found);
                exit;
            }
        }
        header('HTTP/1.1 404 Not Found');
        exit;
    }

    /**
     * Удаление файла (файлов) из хранилища
     * @param mixed $fid  Идентификатор(ы)
     * @return bool
     * @throws Exception
     */
    public function delete($fid) {
        $fid = is_array($fid) ? implode(',', $fid) : $fid;
        $q = "delete from `table` where `id` in ({$fid})";
        $this->_db->query($q);
        $result = true;
        foreach ($fid as $fid_i) {
            list($ffs, $fhn) = self::fid($fid_i);
            $fdir = $this->_realRoot . $ffs;
            $index = self::getIndex($fdir);
            unset($index[$fhn]);
            self::setIndex($fdir, $index);
            $result &= unlink($fdir . '/'. $fhn . '.file');
        }
        return $result;
    }

    /**
     * Помечает файл(ы) флагом "удалено"
     * @param int  $fid    Идентификатор(ы)
     * @param bool $value  Значение флага
     * @return bool
     */
    public function setDeleted($fid, $value=true) {
        $fid = is_array($fid) ? implode(',', $fid) : $fid;
        $o = $value ? ' | ' . self::FLAG_DELETED : ' & ' . (~self::FLAG_DELETED);
        $this->_db->query("update `files` set `flags` = `flags` {$o} where `id` in ({$fid})");
        return true;
    }

    /**
     * Имя файла
     * @param int $fid  Идентификатор
     * @return string
     * @throws Exception
     */
    public function fileName($fid) {
        list($ffs, $fhn) = self::fid($fid);
        self::validateDir($this->_realRoot . $ffs);
        return $ffs . '/' . $fhn . '.file';
    }

    /**
     * Обработка идентификатора файла.
     * Возвращает массив с папкой к файлу и шестнадцатиричное представление младшего байта.
     * @param int $fid  Идентификатор файла
     * @return array
     */
    public static function fid($fid) {
        $ffs = str_split(str_pad(dechex($fid), 16, '0', STR_PAD_LEFT), 2);
        $fhn = array_pop($ffs);
        $ffs = implode('/', $ffs);
        return array($ffs, $fhn);
    }

    /**
     * Проверка каталога файла
     * @param string $f  Полный путь к каталогу
     * @return bool
     * @throws Exception
     */
    public static function validateDir($f) {
        if (!is_dir($f)) {
            if (!mkdir($f, 0700, true)) {
                throw new Exception('cannot make dir: ' . $f);
            }
        }
        return true;
    }

    /**
     * Чтение резервного индекса
     * @param string $f  Полный путь к файлу резервного индекса
     * @return array
     */
    public static function getIndex($f) {
        $index = array();
        if (file_exists($f . '/.index')) {
            $_ = file($f . '/.index');
            foreach ($_ as $_i) {
                $row = trim($_i);
                $row = explode('|', $row);
                array_walk($row, 'trim');
                $rid = $row[0];
                $index[$rid] = $row;
            }
        }
        return $index;
    }

    /**
     * Запись резервного индекса
     * @param string $f      Полный путь к файлу резервного индекса
     * @param array  $index  Массив данных индекса
     * @return bool
     */
    public static function setIndex($f, array $index) {
        $_ = array();
        foreach ($index as $row) {
            $_[] = implode('|', $row);
        }
        return file_put_contents($f . '/.index', implode("rn", $_));
    }

    /**
     * Проверка доступности
     * @param string $group  Название группы (см. ниже)
     * @param int    $value  Значение доступов
     * @return bool
     */
    public static function granted($group, $value=0) {
        $groups = array('anonimous', 'user', 'manager', 'admin', 'inactive', 'blocked', 'deleted');
        if ($group == 'root') {
            return true;
        }
        foreach ($groups as $groupID => $groupName) {
            if ($groupName == $group) {
                return (((1 << $groupID) & $value) != 0);
            }
        }
        return false;
    }
}

Рассмотрим некоторые моменты:

realRoot — полный путь до папки с файловой системой оканчивающийся слешем.
webRoot — путь от корня сайта без ведущего слеша (ниже увидите почему).
— В качестве СУБД я использую расширение MySQLi.
— По сути в метод upload первым аргументом передаётся информация из массива $_FILES.
— Если при вызове метода update передать ID существующего файла, он будет заменён, если в tmp_name входного массива будет непустым.
— Удалять и менять флаги файлов можно сразу по несколько штук. Для этого нужно передать вместо идентификатора файла либо массив с идентификаторами, либо строку с оными через запятую.

Маршрутизация

Собственно всё сводится к нескольким строчкам в htaccess в корне сайта (подразумевается, что mod_rewrite включен):

RewriteCond %{REQUEST_URI} ^/content/(.*)$
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.+)$ content/index.php?file=$1 [L,QSA]

«content» — это папка в корне сайта в моём случае. Само собой Вы можете назвать папку по-другому. Ну и конечно же сам index.php, хранящийся в моём случае в папке content:

<?php
    $dbHost = '127.0.0.1';
    $dbUser = 'user';
    $dbPass = '****';
    $dbName = 'database';

    try {
        if (empty($_REQUEST['file'])) {
            header('HTTP/1.1 400 Bad Request');
            exit;
        }
        $userG = 'anonimous';
        // Вот тут будем определять группу юзера; любое решение на Ваш выбор
        $files = new BigFiles(new mysqli($dbHost,$dbUser,$dbPass,$dbName));
        $files->realRoot(dirname(__FILE__).'/files/');
        $files->read($_REQUEST['file'],$userG);
    } catch (Exception $e) {
        header('HTTP/1.1 500 Internal Error');
        header('Content-Type: text/plain; charset=utf-8');
        echo $e->getMessage();
        exit;
    }

Ну и само собой закроем саму файловую систему от внешнего доступа. Положим в корень папки content/files файл .htaccess с одной лишь строчкой:

Deny from all

Итог

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

Автор: XanderBass

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js