Speicherung einer großen Anzahl von Dateien

Bild

Gute Gesundheit, Hawkers! Während der Arbeit an einem Dating-Site-Projekt wurde es notwendig, die Speicherung von Benutzerfotos zu organisieren. Gemäß der Leistungsbeschreibung ist die Anzahl der Fotos pro Benutzer auf 10 Dateien begrenzt. Aber es kann Zehntausende von Benutzern geben. Besonders wenn man bedenkt, dass das Projekt in seiner jetzigen Form bereits ab dem Beginn der "Null" existiert. Das heißt, die Datenbank enthält bereits Tausende von Benutzern. Soweit ich weiß, reagiert fast jedes Dateisystem sehr negativ auf eine große Anzahl von untergeordneten Knoten in einem Ordner. Aus Erfahrung kann ich sagen, dass Probleme nach 1000-1500 Dateien / Ordnern im übergeordneten Ordner beginnen.

Haftungsausschluss. Ich habe vor dem Schreiben des Artikels gegoogelt und verschiedene Lösungen für das zur Diskussion stehende Problem gefunden (zum Beispiel hier oder hier ). Aber ich habe keine einzige Lösung gefunden, die genau zu meiner passt. Außerdem teile ich in diesem Artikel nur meine eigenen Erfahrungen bei der Lösung des Problems.

Theorie


Neben der Speicheraufgabe als solche gab es in der Arbeitserklärung auch eine Bedingung, wonach Bildunterschriften und Bildunterschriften für Fotos hinterlassen werden mussten. Natürlich können Sie nicht auf eine Datenbank verzichten. Das heißt, wir erstellen zunächst eine Tabelle, in die wir die Metadatenzuordnung (Signaturen, Titel usw.) mit den Dateien auf der Festplatte schreiben. Jede Datei entspricht einer Zeile in der Datenbank. Dementsprechend hat jede Datei eine Kennung.

Ein kleiner Exkurs. Lassen Sie uns über das automatische Inkrementieren sprechen. Eine Dating-Site kann ein Dutzend oder zweitausend Benutzer haben. Die Frage ist, wie viele Benutzer das Projekt im Allgemeinen während der gesamten Dauer seines Bestehens durchlaufen. Zum Beispiel ist das aktive Publikum von dating-ru mehrere hunderttausend. Stellen Sie sich jedoch vor, wie viele Benutzer während der Laufzeit dieses Projekts noch übrig waren. Wie viele Benutzer sind bisher nicht aktiviert? Und jetzt fügen Sie unsere Gesetzgebung hinzu, die uns verpflichtet, Informationen über Benutzer mindestens sechs Monate lang zu speichern ... Früher oder später endet 4 mit einem Cent UNSIGNED INT . Daher ist es am besten, BIGINT als Primärschlüssel zu verwenden.

Versuchen wir nun, uns eine Reihe vom Typ BIGINT vorzustellen . Das sind 8 Bytes. Jedes Byte reicht von 0 bis 255. 255 untergeordnete Knoten sind für jedes Dateisystem ganz normal. Das heißt, wir nehmen die Dateikennung in hexadezimaler Darstellung und teilen sie in Teile von zwei Zeichen auf. Wir verwenden diese Chunks als Ordnernamen, letzterer als Namen der physischen Datei. GEWINN!

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

Elegant und einfach. Die Dateierweiterung ist hier nicht wichtig. Auf jeden Fall wird die Datei von einem Skript bereitgestellt, das dem Browser einen bestimmten MIME-Typ gibt, den wir auch in der Datenbank speichern. Durch Speichern von Informationen über die Datei in der Datenbank können Sie außerdem den Pfad für den Browser neu definieren. Angenommen, die Datei, die wir haben, befindet sich tatsächlich relativ zum Projektverzeichnis entlang des Pfads /content/files/0f/65/84/10/67/68/19/ff.file . In die Datenbank können Sie eine URL schreiben, z. B. /content/users/678/files/somefile . SEOs sind im Moment wahrscheinlich ziemlich gelächelt. All dies ermöglicht es uns, uns keine Gedanken mehr darüber zu machen, wo die Datei physisch abgelegt werden soll.

Tabelle in der Datenbank


Zusätzlich zu der Kennung, dem MIME-Typ, der URL und dem physischen Speicherort speichern wir md5- und sha1-Dateien in der Tabelle, um bei Bedarf dieselben Dateien herauszufiltern. Natürlich müssen wir auch Entitätsbeziehungen in dieser Tabelle speichern. Angenommen, die Benutzer-ID, zu der die Dateien gehören. Und wenn das Projekt nicht sehr groß ist, können wir im selben System beispielsweise Fotos von Waren speichern. Daher speichern wir auch den Namen der Entitätsklasse, zu der der Datensatz gehört.

Apropos Vögel. Wenn Sie den Ordner mit .htaccess für den externen Zugriff schließen, kann die Datei nur über das Skript abgerufen werden. Und im Skript wird es möglich sein, den Zugriff auf die Datei zu bestimmen. Mit Blick auf die Zukunft werde ich sagen, dass in meinem CMS (in dem das oben genannte Projekt gerade abgesägt wird) der Zugriff durch grundlegende Benutzergruppen bestimmt wird, von denen ich 8 habe - Gäste, Benutzer, Manager, Administratoren, inaktive, blockierte, gelöschte und Superadministratoren. Super-Admin kann absolut alles, so dass es nicht an der Bestimmung des Zugriffs beteiligt ist. Wenn der Benutzer ein Superadministrator-Flag hat, ist er ein Superadministrator. Alles ist einfach. Das heißt, wir werden den Zugriff auf die verbleibenden sieben Gruppen bestimmen. Der Zugriff ist einfach - entweder geben Sie die Datei oder nicht geben. Insgesamt können Sie ein Feld vom Typ TINYINT nehmen .

Und noch etwas. Gemäß unserem Gesetz müssen wir benutzerdefinierte Bilder physisch speichern. Das heißt, wir müssen die Bilder irgendwie als gelöscht markieren, anstatt sie physisch zu löschen. Es ist am bequemsten, für diese Zwecke ein Bitfeld zu verwenden. In solchen Fällen verwende ich normalerweise ein Feld vom Typ INT . Sozusagen reservieren. Darüber hinaus habe ich bereits eine Tradition darin, die DELETED- Flagge im 5. Bit vom Ende an zu platzieren. Aber es spielt wieder keine Rolle.

Was haben wir als Ergebnis:

 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; 

Dispatcher-Klasse


Jetzt müssen wir eine Klasse erstellen, mit der wir Dateien hochladen. Die Klasse sollte die Möglichkeit bieten, Dateien zu erstellen, Dateien zu ersetzen / zu ändern und Dateien zu löschen. Darüber hinaus sollten zwei Punkte berücksichtigt werden. Erstens kann das Projekt von Server zu Server übertragen werden. In der Klasse müssen Sie also eine Eigenschaft definieren, die das Stammverzeichnis der Dateien enthält. Zweitens ist es sehr unangenehm, wenn jemand eine Tabelle in der Datenbank knallt. Sie müssen also die Möglichkeit der Datenwiederherstellung bereitstellen. Mit dem ersten ist im Allgemeinen alles klar. Für die Datensicherung behalten wir uns nur das vor, was nicht wiederhergestellt werden kann.

ID - vom physischen Speicherort der Datei wiederhergestellt
entity_type - nicht wiederhergestellt
Entität - nicht wiederhergestellt
mime - restauriert mit finfo extension
md5 - wird aus der Datei selbst wiederhergestellt
sha1 - aus der Datei selbst wiederhergestellt
Datei - vom physischen Speicherort der Datei wiederhergestellt
URL - nicht wiederhergestellt
Meta - nicht wiederhergestellt
Größe - aus der Datei selbst wiederhergestellt
Erstellt - Sie können Informationen aus einer Datei entnehmen
aktualisiert - Sie können Informationen aus einer Datei entnehmen
Zugang - nicht wiederhergestellt
Flags - nicht wiederhergestellt

Sie können Metainformationen sofort verwerfen. Es ist nicht kritisch für die Funktionsweise des Systems. Für eine schnellere Wiederherstellung müssen Sie den MIME-Typ noch speichern. Gesamt: Entitätstyp, Entitäts-ID, MIME, URL, Zugriff und Flags. Um die Zuverlässigkeit des Systems zu erhöhen, speichern wir Sicherungsinformationen für jeden Zielordner separat im Ordner selbst.

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


Betrachten Sie einige Punkte:

- realRoot - Der vollständige Pfad zum Ordner, wobei das Dateisystem mit einem Schrägstrich endet.
- webRoot - Der Pfad vom Stammverzeichnis der Site ohne führenden Schrägstrich (siehe unten, warum).
- Als DBMS verwende ich die MySQLi- Erweiterung.
- Tatsächlich werden die Informationen aus dem Array $ _FILES als erstes Argument an die Upload- Methode übergeben.
- Wenn Sie die Aktualisierungsmethode aufrufen, um die ID einer vorhandenen Datei zu übergeben, wird diese ersetzt, wenn das Eingabearray in tmp_name nicht leer ist.
- Sie können die Flags von Dateien gleichzeitig löschen und ändern. Anstatt die Dateikennung zu übergeben, müssen Sie dazu entweder ein Array mit Bezeichnern oder eine durch Kommas getrennte Zeichenfolge übergeben.

Routing


Tatsächlich handelt es sich um einige Zeilen in htaccess im Stammverzeichnis der Site (es wird davon ausgegangen, dass mod_rewrite aktiviert ist):

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

"Inhalt" ist in meinem Fall der Ordner im Stammverzeichnis der Site. Natürlich können Sie den Ordner auch anders benennen. Und natürlich index.php selbst, in meinem Fall im Inhaltsordner gespeichert:

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

Nun, wir selbst schließen das Dateisystem selbst vor externem Zugriff. Legen Sie die .htaccess Datei mit nur einer Zeile im Stammverzeichnis des Ordners content/files ab:

 Deny from all 

Zusammenfassung


Mit dieser Lösung können Sie Leistungseinbußen beim Dateisystem aufgrund einer Erhöhung der Anzahl der Dateien vermeiden. Zumindest die Probleme in Form von Tausenden von Dateien in einem Ordner können definitiv vermieden werden. Gleichzeitig können wir den Zugriff auf Dateien an lesbaren Adressen organisieren und steuern. Plus Einhaltung unserer düsteren Gesetzgebung. Machen Sie sofort eine Reservierung, diese Lösung ist KEINE vollständige Möglichkeit, Inhalte zu schützen. Denken Sie daran: Wenn etwas im Browser abgespielt wird, kann es kostenlos heruntergeladen werden.

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


All Articles