Долгое время муссировалась и продолжает муссироваться в кругах разных каркасов идея сервисов. Во многих фреймворках сервисы стали самостоятельными единицами кода (как скажем, в Symfony о чем мы еще поговорим). В Rails же паттерн Service Object выглядит очень просто, так как никаких «самостоятельных единиц» именуемых сервисами, в нем нет.

В этой статье я покажу сначала разработку страницы, использующей сервис в Symfony 3.3.9 а затем — то же самое в Rails. Сразу скажу читатели раздела Rails будут слегка разочарованы — «он опять одну строчку написал». Ну извините, это же пример.

Несмотря на то что я являюсь ROR разработчиком, каркас Symfony мне очень понравился. Я считаю его документацию и продукты такие как twig — лучшими в нише. Надеюсь, с выходом версии 4 такая тенденция не утеряется. А выход этот уже произошел.

Итак, что будет за задача?

Почешем там где у нас чешется — есть у меня просто папка в локальной файловой системе, там находится куча шахматных книжек. Я хочу сделать страницу где бы они перечислялись.

Для этого мы и используем сервисы — в нашем случае сервис какой возвращает список книг (файлов) в удобном для нас виде. Минуя директории.

Делаем это на Symfony

Программисты на чистом PHP сразу же бросятся делать readdir и много чего другого в стиле «да я щас за пять минут». Мы тоже не будем усложнять, но использовать мы будем нормальный подход — генератор DirectoryIterator.

Хотя, как уже не единожды заметили симфонисты, лучшей практикой именно для Symfony будет использование специального компонента для нахождения файлов - Finder

Итак, у нас есть Symfony 3.3.9. Создадим-ка контроллер книг, какой собственно будет запускаться для отображения страницы

<?php
// src/AppBundle/Controller/BooksController.php

namespace AppBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use AppBundle\Service\DirectoryLister;

class BooksController extends AbstractController
{
    /**
     * @Route("/books/list", name="books_list")
     */
    public function listAction(DirectoryLister $dirService)
    {
        $files = $dirService->getFileList();
        return $this->render('books/list.html.twig', ['files' => $files]);
    }
}

Как видим, контроллер у нас простой. Он обращается к сервису, какой внедряется к нему в качестве зависимости с помощью DI, получает список файлов. А потом просто отображает шаблон, передав туда этот список.

Обратите внимание, что класс контроллера наследован от AbstractController. Это налагает ограничение на вызовы сервисов из сервис-контейнера внутри экшена, и делает разработку более строгой. Теперь, наследовав так, мы можем только совершать инжект зависимости (при помощи type-hint) но не вызывать эту зависимость из сервис-контейнера внутри экшена.

Эти действия предназначены для более строгой разработки, чтобы не было случаев когда у нас есть экшн какой делает 10 дел и распухает в «метод-Бог» а мы этого или не видим, или не желаем видеть. В случае же инжектов сразу ясно — если у вас параметры метода содержат 20 инжектов значит происходит что-то очень нехорошее и вам нужно срочно рефакторить приложение.

Займемся сочинением непосредственно нашего сервиса:

<?php
// src/AppBundle/Service/DirectoryLister.php

namespace AppBundle\Service;
use DirectoryIterator;
class DirectoryLister
{
    private $path;
    public function __construct($path)
    {
        $this->path = $path;
    }
    public function getFileList()
    {
        $directoryIteratorInstance = new DirectoryIterator($this->path);
        foreach ($directoryIteratorInstance as $fileNode) {
            if (!$fileNode->isDir()) {
                $files[] = $fileNode->getFilename();
            }
        }
        sort($files);
        return $files;
    }
}

В случае же использования Finder, исполняемый код метода выглядел бы примерно так (подсказал Кирилл Несмеянов):

public function getFileList(): iterable
{
    yield from (new Finder())->files()->in($this->path);
}

Сервис наш должен быть многоцелевым, применяться неоднократно, повторно использоваться. По этому в него с помощью аргумента конструктора внедряется параметр path какой указывает ИЗ КАКОЙ директории собственно, возвращать список файлов. В Symfony это называется Autowiring. Так же отметьте, что мы используем генератор DirectoryIterator, а не наколенный перебор файлов в директории. Это дает нам удобные методы isDir и другие.

Куда же теперь поместить параметр пути к файлам? Симфони дает однозначный ответ, это файл

app/config/parameters.yml

Он по умолчанию не ставится на контроль версий git, и вообще предназначен для «переносных» меняющихся настроек. Вот туда-то мы и положим значение для сервиса, какое он автоматически прочитает:

parameters:
    #other params
    books_path: /home/izotoff/Документы/Шахматы

Но это еще не все. В файле

app/config/Services.yml

нам необходимо указать, что наш сервис принимает этот параметр:

AppBundle\Service\DirectoryLister:
    arguments:
        $path: "%books_path%"

Отлично! Все почти готово. Если сейчас обратиться по адресу /app_dev.php/books/list вашего хоста, выскочит ошибка о том что не найден шаблон twig. Поправим это:

Все теперь работает. Полюбуемся на результат, зайдя на хост (у меня это symfony.local) то есть мой адрес будет http://symfony.local/app_dev.php/books/list а продакшн http://symfony.local/books/list. Не забудьте перед запуском продакшна сделать регенерацию кеша симфони (в том числе он кеширует роуты):

bin/console cache:clear --env=prod

Сделав все эти манипуляции с кешем, или зайдя как на скрине — через app_dev.php то есть с поддержкой дебаг тулбара, мы увидим следующую милую картину: (посмотреть увеличенную):

Список книг локальной ФС с сервисом на Symfony

Делаем это на Ruby on Rails

Уфф, тут кода будет меньше, и телодвижений тоже. Но это не значит что Symfony чем-то хуже или что он громоздок. Концепция сервисов как практикуемых единиц очень полезна и хороша. В Rails же мы начнем с простого. Сначала создадим само приложение:

rails new booksdemo && cd booksdemo

А теперь создадим-ка контроллер, для простоты с всего одним действием, как и в Symfony:

rails g controller Books index

Чтобы использовать паттерн Service Object, по сути в Rails достаточно положить файл представляющий «сервис» куда-нибудь в такое место, где он будет автоматически загружен. Создадим файл-сервис в app/services, по умолчанию этой папки нет но сейчас появится:

mkdir app/services && touch app/services/directory_list_service.rb

Теперь у нас есть файл, какой будет загружен Rails самостоятельно. Заполним его:

#app/services/directory_list_service.rb
class DirectoryListService
  attr_reader :files

  def initialize(path)
    @files = Dir.glob(path).select { |filename| File.file?(filename) }.map { |fullpath| File.basename fullpath }.sort
  end


end

Вот и весь сервис. За счет гибкости Ruby мы получили гораздо меньше кода для перебора директории.

Используем его в контроллере:

#app/controllers/books_controller.rb
class BooksController < ApplicationController
  def index
    @files = DirectoryListService.new('/home/izotoff/Документы/Шахматы/*').files
  end
end

И наконец, заполним вид (view):

<h1>Книги (<%= @files.count %>)</h1>
<% @files.each do |file| %>
	<div><%= file %></div>
<% end %>

Вот и все! Теперь мы увидим мало отличающуюся от Symfony-реализации картину, запустив

rails s

и перейдя на http://localhost:3000/books/index :

Список книг локальной ФС с сервисом на Ruby on Rails

Тоже можно увеличить.

Теперь поговорим о гибкости решения. Во-первых поскольку мы в сервисе используем Dir.glob то мы ставим звездочку в конце нашего пути к файлам. Во-вторых этот самый путь мы прописываем прямо там, где сервис используется — в контроллере. Это не совсем гибко! Но идеология Rails состоит как раз в том чтобы избавляться от внешних файлов настроек, даже таких безобидных как parameters.yml в Symfony. Да, мы конечно можем все-таки оформить путь к файлам как статичную настройку. Но делать это по крайней мере, в данной статье мы не будем. Не потому что «и так сойдет» а потому что это Rails. И не факт что другой контроллер запросит уже иной путь, тогда наше решение выиграет по гибкости так как путь можно указывать создавая класс объекта-сервиса.

Подведя итоги, скажу что оба каркаса хоть я и за ROR, прекрасно справляются с задачей, просто каждый своим путем. В Symfony можно тоже сделать сервис гибким, чтобы передавать в него параметр при вызове каждый раз но это я оставил «за кулисами» статьи, предпочтя автосвязывание аргументов из файла настроек.

Делаем это на Yii2

Так как я общаюсь и уважаю многих разработчиков на фреймворке Yii2, камрад lavros любезно написал гайд как сделать задачу на yii2. И это оказалось очень интересным, так как он показал сильные стороны фреймворка, например генерация кода консольным gii но об этом ниже. Вот этот гайд, передаю слово автору.

Сделаем тоже самое на Yii2. Создадим контроллер, действие и представление. Реализуем сервис по получению списка файлов из указанной директории. Внедрим сервис-объект через конструктор в контроллер, получим данные в действии контроллера и отразим в представлении.

Итак, поехали!

В качестве шаблона приложения воспользуемся yii2-app-basic.

Развернём шаблон yii2-app-basic с помощью composer:

composer create-project --prefer-dist yiisoft/yii2-app-basic books.local

Приложение развернули. Создадим контроллер, действие и представление для будущей страницы. В Yii2 есть инструмент для генерации кода — Gii. Имеется web версия и консольная, мы воспользуемся консольной.

Для этого перейдём в каталог с приложением и выполним команду:

cd books.local
./yii gii/controller --controllerClass="app\controllers\BookController" --actions=list

Результатом работы команды будет:

Running 'Controller Generator'...



The following files will be generated:
[new] controllers/BookController.php
[new] views/book/list.php


Ready to generate the selected files? (yes|no) [yes]:y


Files were generated successfully!
Generating code using template "/path/to/books.local/vendor/yiisoft/yii2-gii/generators/controller/default"...
generated controllers/BookController.php
generated views/book/list.php
done!

Страницу создали. Запустим приложение чтобы посмотреть на страницу:

./yii serve

Откроем браузер по адресу: http://localhost:8080?r=book/list, на странице увидим:

Созданная нами страница в Yii2

Хорошо, страница есть. Теперь реализуем сервис-объект для получения списка файлов. Создадим каталог для сервисов — services, в нём файл DirectoryListerService.php:

mkdir services && cd services && touch DirectoryListerService.php

Реализуем метод getFileList() для получения списка файлов. В Yii2 есть помощник по работе с файлами — FileHelper. Воспользуемся FileHelper для получения файлов в указанной директории.

Отредактируем файл DirectoryListerService.php:

<?php

namespace app\services;

use yii\helpers\FileHelper;

class DirectoryListerService
{
    private $path;
    
    public function __construct($path)
    {
        $this->path = $path;
    }
    
    public function getFileList()
    {
    return FileHelper::findFiles($this->path, ['recursive' => false]);
    }
}

Сервис-объект реализован, подключим его в нашем контроллере controllers/BookController.php. Можно подключить явно, создав в методе actionList() экземпляр класса app\services\DirectoryListerService, но мы пойдём другим путём — воспользуемся внедрением зависимости через конструктор:

Для начала настроим контейнер зависимостей в конфигурации приложения, отредактируем config/web.php:

<?php
//...
$config = [
  //  ...
    'container' => [
        'singletons' => [
            'app\services\DirectoryListerService' => [
                ['class' => 'app\services\DirectoryListerService'],
                ['/home/izotoff/Документы/Шахматы'],
            ],
        ],
    ],
  //  ...
];

Объект описали как singleton, то есть, где бы мы не внедряли наш сервис-объект, экземляр будет создаваться один раз.

Внедрим в конструкторе contorllers\BookController.php:

<?php

namespace app\controllers;

use app\services\DirectoryListerService;

class BookController extends \yii\web\Controller
{
    protected $directoryListerService;

    public function __construct($id, $module, DirectoryListerService $directoryListerService, $config = [])
    {
        $this->directoryListerService = $directoryListerService;
        parent::__construct($id, $module, $config);
    }

 //   ...
}

Теперь при создании контроллера сервис DirectoryListerService будет создаваться автоматически.

Отредактируем метод BookController::actionList(), получим список файлов, посчитаем количество найденых файлов и передадим в представление:

<?php

//...

class BookController extends \yii\web\Controller
{
    //...
    
    public function actionList()
    {
        $files = $this->directoryListerService->getFileList();
        $numFiles = count($files);
        sort($files);
        
        return $this->render('list', [
            'files' => $files,
            'numFiles' => $numFiles
        ]);
    }
}

Оформим представление действия views/book/list.php:

<?php

use yii\helpers\Html;

/* @var $this yii\web\View */
?>
<h1>Книги (<?= $numFiles ?>)</h1>

<ol>
<?php foreach ($files as $file): ?>
    <li><?= Html::a(basename($file), "file://{$file}") ?></li>
<?php endforeach ?>
</ol>

Готово. Обновляем страницу в браузере, смотрим результат.

Он будет таким же, как и в предыдущих примерах, только снабжен yii2 debug toolbar. Похожее мы видели в скриншоте для symfony — там тоже оставлен был тулбар для отладки.

В целом, мне очень понравился подход yii2 и это было действительно не сложно и не долго, при условии что это делает знакомый с документацией и возможностями yii2 человек.

Спасибо lavros за важный гайд! Уверен, некоторых это даже подстегнет изучать Yii2.

Список книг на Zend Expressive

Камрад Ми}{алы4 любезно продолжил нашу традицию и во мгновение ока написал гайд как сделать это же на Zend Expressive. Передаю слово ему.

Для решения задачи по отображению списка книг из директории используется Zend Expressive с модульной структорой, Zend ServiceManager, FastRoute, Twig, Whoops. Все эти пакеты можно выбрать на этапе создания нового проекта, когда вы выполняете команду

composer create-project zendframework/zend-expressive-skeleton expressive

Создадим сервис в модуле App (модуль App создается по умолчанию после создания пустого проекта Zend Expressive):

<?php

namespace App\Service;

use DirectoryIterator;

class BookService
{
    public function getList(string $path): array
    {
        if (!is_dir($path)) {
            return [];
        }

        $dir = new DirectoryIterator($path);

        foreach ($dir as $item) {
            if ($item->isFile()) {
                $files[] = $item->getFilename();
            }
        }

        sort($files);

        return $files;
    }
}

Зарегистрируем наш сервис:

<?php
// src/App/src/ConfigProvider.php

public function getDependencies()
{
    return [
        ...
    
        'factories'  => [
            ...
            
            Service\BookService::class => \Zend\ServiceManager\Factory\InvokableFactory::class,
            
            ...
        ],
        
        ...
    ];
}

Создадим действие BookAction в модуле App:

<?php

// src/App/src/Action/BooksAction.php

namespace App\Action;

use App\Service\BookService;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface as ServerMiddlewareInterface;
use Zend\Diactoros\Response\HtmlResponse;
use Psr\Http\Message\ServerRequestInterface;
use Zend\Expressive\Template\TemplateRendererInterface;

class BooksAction implements ServerMiddlewareInterface
{
    private const BOOKS_DIR = __DIR__ . '/../../../../data/books';
    
    /**
     * @var BookService
     */
    private $bookService;
    /**
     * @var TemplateRendererInterface
     */
    private $tpl;

    public function process(ServerRequestInterface $request, DelegateInterface $delegate)
    {
        return new HtmlResponse($this->tpl->render('app::books', [
            'books' => $this->bookService->getList(self::BOOKS_DIR),
        ]));
    }

    public function __construct(BookService $bookService, TemplateRendererInterface $tpl)
    {
        $this->bookService = $bookService;
        $this->tpl = $tpl;
    }
}

Зарегистрируем наше действие:

<?php
// src/App/src/ConfigProvider.php

public function getDependencies()
{
    return [
        ...
    
        'factories'  => [
            ...
            
            Action\BooksAction::class => \Zend\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory::class,
            
            ...
        ],
        
        ...
    ];
}

Зарегистрируем маршрут для действия:

<?php
// config/routes.php

//...

$app->get('/books', App\Action\BooksAction::class, 'books');

Выполним переход по нашему только что добавленному маршруту http://localhost:8080/books :

Список книг локальной ФС с сервисом на Zend Expressive

(посмотреть увеличенную картинку):

Готово!

P.S. Процесс можно ускороить используя Command-Line Tool.