Stockage d'un grand nombre de fichiers

image

Bonne santé, Hawkers! Dans le cadre du travail sur un projet de site de rencontres, il est devenu nécessaire d'organiser le stockage des photos des utilisateurs. Selon les termes de référence, le nombre de photos par utilisateur est limité à 10 fichiers. Mais il peut y avoir des dizaines de milliers d'utilisateurs. Surtout si l'on considère que le projet dans sa forme actuelle existe déjà depuis le début du "zéro". Autrement dit, il y a déjà des milliers d'utilisateurs dans la base de données. À ma connaissance, presque tous les systèmes de fichiers réagissent très négativement à un grand nombre de nœuds enfants dans un dossier. Par expérience, je peux dire que les problèmes commencent après 1000 à 1500 fichiers / dossiers dans le dossier parent.

Clause de non-responsabilité. J'ai cherché sur Google avant d'écrire l'article et j'ai trouvé plusieurs solutions au problème en discussion (par exemple, ici ou ici ). Mais je n'ai pas trouvé une seule solution qui correspond exactement à la mienne. De plus, dans cet article, je ne partage que ma propre expérience dans la résolution du problème.

Théorie


En plus de la tâche de stockage en tant que telle, il y avait également une condition dans l'énoncé des travaux, selon laquelle il était nécessaire de laisser des légendes et des légendes pour les photos. Bien sûr, vous ne pouvez pas vous passer d'une base de données. Autrement dit, la première chose que nous faisons est de créer un tableau dans lequel nous prescrivons le mappage des métadonnées (signatures, titres, etc.) avec les fichiers sur le disque. Chaque fichier correspond à une ligne de la base de données. En conséquence, chaque fichier a un identifiant.

Une petite digression. Parlons de l'auto-incrémentation. Un site de rencontres peut compter une douzaine ou deux mille utilisateurs. La question est de savoir combien d'utilisateurs passent généralement par le projet pendant toute la durée de son existence. Par exemple, le public actif de dating-ru est de plusieurs centaines de milliers. Cependant, imaginez simplement combien d'utilisateurs sont restés pendant la durée de vie de ce projet; combien d'utilisateurs ne sont pas activés jusqu'à présent. Maintenant, ajoutons à notre législation, qui nous oblige à stocker des informations sur les utilisateurs pendant au moins six mois ... Tôt ou tard, 4 avec un sou d' UNSIGNED INT se terminera. Par conséquent, il est préférable de prendre BIGINT pour la clé primaire.

Essayons maintenant d'imaginer un certain nombre de type BIGINT . C'est 8 octets. Chaque octet est compris entre 0 et 255. 255 nœuds enfants sont tout à fait normaux pour tout système de fichiers. Autrement dit, nous prenons l'identifiant du fichier en représentation hexadécimale, nous le divisons en morceaux de deux caractères. Nous utilisons ces morceaux comme noms de dossier, ces derniers comme nom de fichier physique. PROFIT!

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

Élégant et simple. L'extension de fichier n'est pas importante ici. Quoi qu'il en soit, le fichier sera fourni par un script qui donnera au navigateur un type MIME particulier, que nous stockerons également dans la base de données. De plus, le stockage d'informations sur le fichier dans la base de données vous permet de redéfinir le chemin d'accès au navigateur. Supposons que le fichier que nous avons se trouve réellement par rapport au répertoire du projet le long du chemin /content/files/0f/65/84/10/67/68/19/ff.file . Et dans la base de données, vous pouvez y écrire une URL, par exemple, /content/users/678/files/somefile . Les SEO sont probablement assez souriants en ce moment. Tout cela nous permet de ne plus nous soucier de l'emplacement physique du fichier.

Table dans la base de données


En plus de l'identifiant, du type MIME, de l'URL et de l'emplacement physique, nous stockons les fichiers md5 et sha1 dans le tableau pour filtrer les mêmes fichiers si nécessaire. Bien sûr, nous devons également stocker les relations d'entité dans cette table. Supposons l'ID utilisateur auquel appartiennent les fichiers. Et si le projet n'est pas très important, alors dans le même système, nous pouvons stocker, disons, des photographies de marchandises. Par conséquent, nous allons également stocker le nom de la classe d'entité à laquelle appartient l'enregistrement.

En parlant d'oiseaux. Si vous fermez le dossier à l'aide de .htaccess pour un accès externe, le fichier ne peut être obtenu que via le script. Et dans le script, il sera possible de déterminer l'accès au fichier. Pour l'avenir, je dirai que dans mon CMS (où le projet susmentionné est actuellement en cours de sciage), l'accès est déterminé par des groupes d'utilisateurs de base, dont j'ai 8 - invités, utilisateurs, gestionnaires, administrateurs, inactifs, bloqués, supprimés et super-administrateurs. Le super-administrateur peut absolument tout faire, il ne participe donc pas à la détermination de l'accès. Si l'utilisateur a un drapeau de super-administrateur, il est alors super-administrateur. Tout est simple. Autrement dit, nous déterminerons l'accès aux sept groupes restants. L'accès est simple - soit donner le fichier ou ne pas donner. Au total, vous pouvez prendre un champ de type TINYINT .

Et encore une chose. Selon notre loi, nous devrons stocker physiquement des images personnalisées. Autrement dit, nous devons en quelque sorte marquer les images comme supprimées, au lieu de les supprimer physiquement. Il est plus pratique d'utiliser un champ de bits à ces fins. Dans de tels cas, j'utilise généralement un champ de type INT . Pour réserver, pour ainsi dire. De plus, j'ai une tradition déjà établie de placer le drapeau SUPPRIMÉ dans le 5e bit depuis la fin. Mais cela n'a plus d'importance à nouveau.

Qu'avons-nous en conséquence:

 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; 

Classe Dispatcher


Maintenant, nous devons créer une classe avec laquelle nous allons télécharger des fichiers. La classe devrait permettre de créer des fichiers, de remplacer / modifier des fichiers, de supprimer des fichiers. En outre, il convient de considérer deux points. Tout d'abord, le projet peut être transféré de serveur en serveur. Donc, dans la classe, vous devez définir une propriété qui contient le répertoire racine des fichiers. Deuxièmement, ce sera très désagréable si quelqu'un frappe une table dans la base de données. Vous devez donc prévoir la possibilité de récupération des données. Avec le premier, tout est généralement clair. Quant à la sauvegarde des données, nous ne réserverons que ce qui ne peut pas être restauré.

ID - restauré à partir de l'emplacement physique du fichier
entity_type - non restauré
entité - non restaurée
mime - restauré à l'aide de l'extension finfo
md5 - est restauré à partir du fichier lui-même
sha1 - restauré à partir du fichier lui-même
fichier - restauré à partir de l'emplacement physique du fichier
URL - non restaurée
méta - non restauré
taille - restaurée à partir du fichier lui-même
créé - vous pouvez prendre des informations à partir d'un fichier
mis Ă  jour - vous pouvez extraire des informations d'un fichier
accès - non restauré
drapeaux - non restaurés

Vous pouvez immédiatement supprimer les méta-informations. Il n'est pas essentiel au fonctionnement du système. Et pour une récupération plus rapide, vous devez toujours enregistrer le type MIME. Total: type d'entité, ID d'entité, MIME, URL, accès et indicateurs. Afin d'augmenter la fiabilité du système, nous stockons les informations de sauvegarde pour chaque dossier de destination séparément dans le dossier lui-même.

Code de classe
 <?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("\r\n", $_)); } /** *   * @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; } } 


Considérez quelques points:

- realRoot - le chemin complet vers le dossier avec le système de fichiers se terminant par une barre oblique.
- webRoot - le chemin depuis la racine du site sans barre oblique (voir pourquoi ci-dessous).
- En tant que SGBD, j'utilise l'extension MySQLi .
- En fait, les informations du tableau $ _FILES sont passées comme premier argument à la méthode de téléchargement .
- Si vous appelez la méthode de mise à jour pour transmettre l'ID d'un fichier existant, elle sera remplacée si le tableau d'entrée dans tmp_name n'est pas vide.
- Vous pouvez supprimer et modifier les drapeaux de fichiers plusieurs à la fois. Pour ce faire, au lieu de passer l'identifiant du fichier, vous devez passer soit un tableau avec des identifiants, soit une chaîne avec eux, séparés par des virgules.

Acheminement


En fait, cela se résume à quelques lignes dans htaccess à la racine du site (on suppose que mod_rewrite est activé):

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

«Contenu» est le dossier à la racine du site dans mon cas. Bien sûr, vous pouvez nommer le dossier différemment. Et bien sûr index.php lui-même, stocké dans mon cas dans le dossier de contenu:

 <?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; } 

Eh bien, en soi, nous fermons le système de fichiers lui-même de l'accès externe. Placez le fichier .htaccess à la racine du dossier content/files avec une seule ligne:

 Deny from all 

Résumé


Cette solution évite la perte de performances du système de fichiers en raison d'une augmentation du nombre de fichiers. Au moins, les problèmes sous la forme de milliers de fichiers dans un dossier peuvent certainement être évités. Et en même temps, nous pouvons organiser et contrôler l'accès aux fichiers à des adresses lisibles par l'homme. De plus, le respect de notre sombre législation. Faites immédiatement une réservation, cette solution n'est PAS un moyen complet de protéger le contenu. N'oubliez pas: si quelque chose joue dans le navigateur, il peut être téléchargé gratuitement.

Source: https://habr.com/ru/post/fr423875/


All Articles