@Delphinum

Как организовать доступ по канонам DDD?

Подскажите, как правильно организовать доступ к файлам, не нарушая принципов DDD?

Требования следующие:
Iyv9B2vMy2_FIosgvb9Gq5R8o4n9TSlCIItMq0JpМодель предметной области
  • Система организует загрузку, хранение и доступ к файлам
  • Владелец файла имеет доступ ко всем своим файлам, и к доступным ему файлам других владельцев
  • Пользователь имеет доступ к доступным ему файлам
  • Доступ к файлам может быть предоставлен как по директории, в которую файл загружен, так и по владельцу. Другими словами пользователь либо выбирает каталог, либо владельца, тем самым получая все доступные ему файлы
  • Доступ к файлам определяется полем hidden файла, а так же тем, вызваны ли методы hideFiles у автора и каталога


Задача:
Как правильно организовать систему доступа к файлам, чтобы:
  1. Возможно было предоставлять и забирать доступ к конкретному файлу
  2. Возможно было предоставлять и забирать доступ ко всем файлам автора или директории


Что именно не понятно:
Простейшим решением будет проставлять всем файлам поле hidden = true при вызове методов hideFiles у автора или каталога, но что делать, если необходимо данный файл запретить к просмотру навсегда, но при этом временно скрыть все файлы автора? То есть когда придет время показать все файлы автора, тот файл, просмотр которого запрещен навсегда будет так же показан, а следовательно нельзя использовать одно и то же поле hidden как для скрытия файла, так и для скрытия всех файлов автора. С другой стороны реализовывать файлу три поля: hidden, owner_hidden, directory_hidden - не очень удобно, ведь проще вынести поля: author_owner и directory_hidden - в классы Owner и Directory, или это неверный подход?

Одновременно хочется оставаться в контексте DDD и использовать Simple Object, а не уходить в область запросов к БД.
  • Вопрос задан
  • 286 просмотров
Пригласить эксперта
Ответы на вопрос 1
@vova07
Приветствую!
Мне кажется вы чуток неправильно следуете принципам ДДД.
Точнее вы неправильно проектируете свой код.
Если вы пишите по ДДД то вам не надо задумываться об полях в БД или о других инфраструктурных вещах.
Вам надо писать домен таким каким он есть в рамках вашего понимания.

Исходя из вашего текста я бы это написал это примерно как в примере что я привел. Хотя это было все написано на коленке без анализа юз кэйсов пользователя, без анализа домена и так далее. Так что трактируйте мой код как пример, или как черновик.

<?php

/**
 * "Domain/Directories/File.php"
 */
final class File
{
  /**
   * Я использую название файла как его уникальный идентификатор.
   * Надо учитывать что этот идентификатор уникален в рамках одной директории (папки).
   * В случае если это сложно отслеживать можно использовать UUID как ИД и атрибут $name как допольнительнй.
   *
   * @var string
   */
  private $name;
  /**
   * @var bool
   */
  private $hidden = false;

  /**
   * @param string $name
   */
  public function __construct(string $name)
  {
    $this->name = $name;
  }

  /**
   * Скрываем временно файл.
   */
  public function hide() : void
  {
    $this->hidden = true;
  }

  /**
   * Востанавливаем доступ к срытому файлу.
   */
  public function show() : void
  {
    $this->hidden = false;
  }

  /**
   * @return string
   */
  public function getName() : string
  {
    return $this->name;
  }

  /**
   * @return bool
   */
  public function isHidden() : bool
  {
    return $this->hidden;
  }
}

/**
 * Aggregate Root.
 * По моегму видению файл не может существовать без родительской папке.
 * По этому папка и есть наш аггрегат.
 *
 * "/Domain/Directories/Directory.php"
 */
final class Directory
{
  /**
   * Название директории (папки) играет роль уникального идентификатора.
   * Так-же как и в случае с файлом этот идентифактор может быть уникален только в рамках другой директории (папки).
   * В случае если это сложно отслеживать можно использовать UUID как ИД и атрибут $name как допольнительнй.
   *
   * @var string
   */
  private $name;
  /**
   * @var File[]
   */
  private $files = [];
  /**
   * @var array
   */
  private $removedFiles = [];
  /**
   * @var string
   */
  private $ownerId;
  /**
   * @var bool
   */
  private $hidden = false;

  /**
   * @param string $name
   */
  public function __construct(string $name, string $ownerId)
  {
    $this->name = $name;
    $thi->owner = $ownerId;
  }

  /**
   * Этот метод не должен использоватся нигде больше кроме в MySQLStorage или в других сторэджах.
   * Это дополнительный метод который нужен потому что другие доменные методы которые описывают бизнес логику могут содержать например события.
   * Чтобы не выбрасывать все события при восстановлении используется такого рода хак.
   * Вы же можете сипользовать что-то другое если у вас есть более приемлемый вариант. 
   * Я пока лучше что-то не нашел.
   * 
   * @internal
   */
  public function reconstruct(bool $hidden, File ...$files) : void
  {
    $this->hidden = $hidden;

    foreach ($files as $file) {
      $this->files[$file->getName()] = $file;
    }
  }

  /**
   * @param File $file
   */
  public function addFile(File $file) : void
  {
    $this->files[$file->getName()] = $file;
  }

  /**
   * @param File[] $files
   */
  public function addFiles(Files ...$files) : void
  {
    foreach ($files as $file) {
      $this->addFile($file);
    }
  }

 /**
  * Это то что вы называете "запретить навсегда".
  * Надо трактировать удаление файле как `sof delete`.
  * То есть файл не удаляется реально а просто ставится какой небудь флаг типа `deletedAt` которое показывает время удаления.
  * Хочу заметить что это один из вариантов реализации, так как их много.
  * Может возникнуть вопрос почему мы не делаем это удаление через сам файл `File.php`
  * это потому что возможно этот файл удален в одной директиве а в другой нет.
  * То есть возможно реальный файл есть на диске а его сслки в БД дублируются или еще что, и каждая директория сама контролирует это для себя.
  * Вы можете удалить этот метод, и реальизовать свою логику как вам угодно.
  *
  * @param string $name
  */
  public function removeFile(string $name) : void
  {
    if (!isset($this->files[$name])) {
      throw new OutOfBoundsException('Invalid file ID');
    }

    $this->removedFiles[] = $name;
  }

  /**
   * Скрываем временно дирекорию.
   */
  public function hide() : void
  {
    $this->hidden = true;
  }

  /**
   * Востанавливаем доступ к срытой директории.
   */
  public function show() : void
  {
    $this->hidden = false;
  }

  /**
   * @return string
   */
  public function getName() : string
  {
    return $this->name;
  }

  /**
   * @return string
   */
  public function getOwnerId() : string
  {
    return $this->ownerId;
  }

  /**
   * @return File[]
   */
   public function getFiles() : array
   {
     return $this->files;
   }

   /**
    * @return array
    */
    public function getRemovedFiles() : array
    {
      return $this->removedFiles;
    }

   /**
    * @return bool
    */
   public function isHidden() : bool
   {
     return $this->hidden;
   }
}

/**
 * "Domain/Directories/Repository.php"
 */
final class Repository
{
  /**
   * @var Storage
   */
  private $storage;

  /**
   * @param Storage $storage
   */
  public function __construct(Storage $storage)
  {
    $this->storage = $storage;
  }

  /**
   * @param Directory $directory
   */
  public function store(Directory $directory) : void
  {
    $this->storage->store($directory);
  }

  /**
   * @return array
   */
  public function getDirectoriesByOwner(string $ownerId) : array
  {
    $this->storage->getDirectoriesByOwner($ownerId);
  }
}

/**
 * "Domain/Directories/Storage.php"
 */
interface Storage
{
  /**
   * @param Directory $directory
   */
  public function store(Directory $directory) : void;

  /**
   * @return array
   */
  public function getDirectoriesByOwner(string $ownerId) : array;
}

/**
 * "Infrastructure/Persistence/Directories/MySQLStorage.php"
 */
final class MySQLStorage implments Storage
{
  /**
   * Название поля в БД.
   */
  private CONST TABLE = 'Direcotry';

  /**
   * @var PDO
   */
  private $database;

  /**
   * @param PDO $database
   */
  public function __construct(PDO $database)
  {
    $this->database = $database;
  }
  /**
   * @param Directory $directory
   */
  public function store(Directory $directory) : void
  {
    // Провекра на сущестование директории в БД.
    if (/* Ваша проверка на наличие в БД */) {
      // Подгоавливаем директорию для сохранения.
      $this->prepareRowData($direcotry);
      // TODO: Обновите директорию в БД.
    } else {
      // Подгоавливаем директорию для сохранения.
      $this->prepareRowData($direcotry);
      /**
       * TODO: Создайте новую директорию в БД.
       * Этот процесс вероятнее всего в релационной БД будет содержать несколько шагов.
       * Добалвение самой директории (папки), добалвение всех файлов в этой директории возможно в отдельной таблице.
       * Ну и все другие операции которые уместны в этом сценарии.
       */
    }
  }

  /**
   * @return array
   */
  public function getDirectoriesByOwner(string $ownerId) : array
  {
    /**
     * Именно в этом методе и происходит выборка и фильтрация нужных директорий (папок) и файлов.
     * Вы легко можете выбрать все файлы кторые не удаленны и не скрытие. Или все, или те которые вы посчитаете нужным.
     * `WHERE Directory.isHidden = false AND File.isDeleted = false AND File.isHidden = false`
     * То есть хочу заметить что вы свободны использовать любую структуру БД, которая вам подходит.
     * Для целесности этой цепочки ниже после выборки данных вызывается метод преобразования из массива в Entity.
     */

    // Запрашиваем данные из БД6 после чего преобразовываем все в доменные сущности и возвращаем результат.

    $result = [];

    foreach ($rows as $row) {
      $result = $this->buildEntity($row);
    }

    return $result;
  }

  /**
   * Этот метод конвертирует Entity в массив для дальнейшего его сохранения.
   *
   * @return array
   */
  private function prepareRowData(Directory $directory) : array
  {
    $files = [];

    foreach ($direcotry->getFiles() as $file) {
      $files[] = [
        'name' => $file->getName(),
        'isHidden' => $file->isHidden(),
        'isDeleted' => in_array($file->getName(), $direcotry->getremovedFiles()),
      ];
    }

    return [
      'name' => $direcotry->getName(),
      'ownerId' => $direcotry->getOwnerId(),
      'isHidden' => $direcotry->isHidden(),
      'files' => $files,
    ];
  }

  /**
   * @param  array $row
   *
   * @return Directory
   */
  private function buildEntity(array $row) : Directory
  {
    $files = [];

    foreach ($row['files'] as $item) {
      $files[] = new File($item['name']);
    }
    
    $directory = new Directory($row['name']);
    $directory->reconstruct($row['isHidden'], ...$files);

    return $directory;
  }
}

/**
 * "Application/Directories/GetDirectoriesByOwner.php"
 * Это так называемый `Use Case` или в книжках `Application Service`.
 * Все наше приложение работает только через такие юз кэйсы, API, Console, Backend все что у нас есть вызывает такие юз кэйсы.
 * Пример:
 * $service = new GetDirectoriesByOwner(
 *   new Repository(
 *     new MySQLStorage($container[PDO::class])
 *   )
 * );
 */
final class GetDirectoriesByOwner
{
  /**
   * @var Repository
   */
  private $repository;

  /**
   * @param Repository $repository 
   */
  public function __construct(Repository $repository)
  {
    $this->repository = $repository;
  }

  /**
   * @param string $ownerId
   * 
   * @return array
   */
  public function handle(string $ownerId) : array
  {
    return $this->repository->getDirectoriesByOwner($ownerId);
  }
}
Ответ написан
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Похожие вопросы