Выбор фреймворка и переход на Laravel в рамках создания собственной СДО (часть 4)

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

Возник вопрос перехода на PHP фреймворк (бэкенд) и библиотеку/фреймворк JS (фронтенд). О переходе на ReactJS в следующей части.

Так как ранее я изобретал велосипед в виде создания собственного фреймворка, то изначально хотел перейти на микрофреймворк SlimPhp 4, который основан на рекомендациях (стандартах) PSR-7 (Request и Response), PSR-15 (Middleware), PSR-11 (Dependency Container/Injection) и т.д. Из коробки фреймворк содержит собственную реализацию указанных стандартов, которые можно заменить на свои или реализации других фреймворков.

В то же время, тестирование показало, что время ответа сервера TTFB (Time to First Bite) на собственном фреймворке у меня доходило до 12ms, тогда как в SlimPhp 4 уже имело значение около 60ms, а Laravel после установки без кеширования заставляет ждать около 100ms в тех же условиях, что значительно больше.

Начав адаптировать свой проект, я обнаружил, что в SlimPhp 4 из коробки нет реализации шаблонизатора (представления), ORM для работы с БД, миграций, валидации, интерфейса командной строки и т.д. — все приходилось ставить в виде зависимостей composera и настраивать. В какой момент я понял, что это не мое — разрабатываемый проект требовал гораздо больше возможностей и тратить время на изобретение, по сути, опять своего кастомного фреймворка, не по Закону Парето как-то получается. Тем более хотелось побыстрее получить готовое решение.

Кандидатами из числа фреймворков-комбайнов PHP были: Symfony, Laravel и Yii2. Выбор пал на Laravel в виду хорошей документации, в том числе на русском языке, большим комьюнити, его относительной простотой и прозрачностью.

image-loader.svg

Первоначальная настройка и перенос проекта на Laravel 8.0 прошел практически безболезненно: сразу была настроена русификация валидации путем копирования папки из GitHub с языком ru в lang (в resourses), создана папка со своими функциями, которые загружались собственным классом, зарегистрированном в сервис-провайдере — зарегистрирован в config/app и добавлен в папку App\Config\Providers класс App\Providers\HelpersLoaderProvider: class, который из папки Helpers загружал файлы (функции, константы и свои классы). Код метода register () класса провайдера:

    public function register()
    {
        foreach (glob(app_path() . '/Helpers/*.php') as $file) {
            require_once($file);
        }
    }

Что пришлось сильно править:

  • полностью переделана архитектура базы данных в виде отношений (реализованы все виды связей кроме полиморфных);

  • созданы миграции;

  • модели пришлось править под новую структуру БД и синтаксис Laravel;

  • переписаны представления с массивов на объекты;

  • переделаны события и слушатели, очереди, отправка почты и т.д.;

Что не понравилось… Несмотря на огромные возможности фреймворка, не очень понравилось:

  • наличие множества магических методов, в том числе фасадов, которые не позволяют быстро ориентироваться во внутреннем ядре;

  • некоторая перегруженность фреймворка не всегда нужными пакетами и расширениями типа Pusher, Jetstream, Tailwind CSS, Inertia и т.д.

  • не всегда понятна внутренняя логика работы сторонних пакетов, расширяющих функционал Laravel. К примеру, авторизация Breeze при установке и публикации не регистрирует маршруты в файле web.php, что не всегда удобно.

  • реализация очередей (Jobs), предназначенных для выполнения длительных операций не позволяет запустить службу диспетчер процессов Supervisor на Shared хостинге, которым пользуются большинство — надо переходить на VPS.

image-loader.svg

Пакеты composer. При работе с фреймворком не хотелось устанавливать пакеты, предназначенные для Laravel и сборки, например готовые интеграции шаблона AdminLte, Bootstrap, Socket — так как это на мой взгляд усложняло прозрачность архитектуры приложения. В документации указанных пакетов, как правило описан процесс установки и использования, но процесс внутреннего устройства освещен очень поверхностно, кроме того, имеются и отдельные недостатки такого подхода (описано немного ниже).

В связи с чем, все пакеты и зависимости устанавливались не специальные (не исключительно для Laravel) с последующей ручной интеграцией в Laravel. К примеру, установка редактора TinyMCE 5 возможна из специального пакета для Laravel с последующей регистрацией нового сервис-провайдера, публикации ресурсов и добавлении пакета в контейнер. В чем я вижу минус такого подхода — при замене пакета придется вспоминать порядок установки и удалять в ручном режиме сделанные изменения.

Сборщик Webpack. От использования сборщика скриптов и стилей Laravel Mix я тоже отказался по причине того, что скриптов в приложении немного, а библиотеки типа Bootstrap, JQuery, SocketIo уже, итак, минимизированы, а использовать препроцессоры и переменные в файлах стилей необходимости не было. Максимум, что я бы выиграл — это по 1 файлу js и css и теоретически более быстрая загрузка приложения (в связи с особенностями протокола http загрузка 1 файла лучше, чем загрузка нескольких). Однако, я бы получил приложение, которое нельзя быстро подправить. К примеру, с телефона можно зайти на хостинг и поменять значение CSS, либо скрипт — пришлось бы каждый раз заново все собирать.

В моем случае я пошел по другому пути — тот же TineMCE был скопирован в папку публичного доступа и в тех местах, где необходимо его подключение прописывался код в шаблонизаторе Blade:

@push('scripts')
    
    
@endpush

Таким образом подключался не только плагин, но и файл настроек и его инициализации именно на данной странице. Как по мне — это удобнее. Все скрипты имеют значение defer. Атрибут defer сообщает браузеру, что он должен продолжать обрабатывать страницу, строить DOM и загружать скрипт в фоновом режиме, а затем запустить этот скрипт, когда он загрузится.

В представлении, где необходимо выполнение скриптов, внизу страницы выполнялся пользовательский JS:

window.addEventListener('load', function () {
…
}

image-loader.svg

Базовый контроллер пользователя. Архитектура приложения (организация-дисциплина-тема-задание) предусматривает частую передачу в методы контроллеров значений (организация и дисциплина). Выхода виделось 2: работа с сессией, либо создание дефолтных параметров маршрута. Я решил попробовать второй вариант. Класс базового контроллера пользователя получал из адресной строки параметры организации и дисциплины и формировал параметры маршрутов (route), если они явным образом не передавались в представлении, а также задавал свойства для классов-наследников, использующих данные значения.

        $discipline = Discipline::where('prefix', Route::current()
        ->parameter('discipline_prefix'))
        ->first();
        $organization = Organization::where('prefix', Route::current()
        ->parameter('organization_prefix'))
        ->first();
        $this->organization = $organization;
        $this->discipline = $discipline;
        URL::defaults(['organization_prefix' => $organization->prefix ?? null, 'discipline_prefix' => $discipline->prefix ?? null]);

Адаптивный вид. Для демонстрации различного содержимого на разных устройствах использовалась функция:

if (!function_exists('ismobile')) {
    function isMobile() {
$isMobile = preg_match("/(android|avantgo|blackberry|bolt|boost|cricket|docomo|fone|hiptop|mini|mobi|palm|phone|pie|tablet|up\.browser|up\.link|webos|wos)/i", $_SERVER["HTTP_USER_AGENT"]);
        return $isMobile;
    }
}

Функция запускалась директивой Blade:

        Blade::if('mobile', function () {
            return isMobile();
        });

В шаблоне Blade при разграничении показа содержимого на разных устройствах указывалось для мобильного: @mobile… @endmobile. Однако следует учитывать, что данный подход не будет работать, если в контроллере, принимающим запрос по методу POST (например, при отправке формы) установлен редирект 302:

return redirect()->route('show.decisions.task', ['task_id'=>$task_id])->with('success', 'Ваше мнение учетно. Спасибо.');

Редирект содержит заголовки сервера, поэтому вид после редиректа переключится на десктоп.

Выход из ситуации — записывать значение в сессию при входе на сайт:

         if(!session()->has('isMobile')) {
            session(['isMobile' => $isMobile]);
        }
        return session('isMobile');

image-loader.svg

Сортировка. Для сортировки тем и заданий требовался учет цифро-буквенных значений (стандартные функции и методы работы с коллекциями не позволяют это сделать корректно), поэтому была написана функция:

function sortAW($a, $b)
{
    if (is_numeric(mb_substr($a->name, 0, 1)) && is_numeric(mb_substr($b->name, 0, 1))) {
        return ((int)$a->name - (int)$b->name);
    } else {
        return (strcmp($a->name, $b->name) < 0) ? -1 : 1;
    }
}

Сессии. Для решения задач временной авторизации пользователя и хранения других данных приложения было решено использовать сессию Laravel. Самым простым путем записи сессии мне показалось это сделать в базовом контроллере, однако я столкнулся с тем, что в версии Laravel > 5.4 объект Request недоступен в конструкторе файла контроллера, так как еще не отработали все посредники. Решение — создание посредника и помещение его в Pipeline ниже проверки csrf и старта сессии.

        if ($request->has('loginGuest')) {
            session(['loginGuest' => $request->get('loginGuest')]);
        } else if (!session('loginGuest')) {
            session(['loginGuest' => rand(100, 999)]);
        }

Файловые менеджеры. В качестве файлового менеджера использован responsive_filemanager, который позволяет работать с изображениями и документами. Для каждого пользователя нужна своя папка.

Учитывая, что файловый менеджер загружается с использованием Iframe, самым простым путем мне показалась передача информации с использованием GET параметров.

image-loader.svgКод

// ССЫЛКА
$('#frame_files').attr('src', '/packages/responsive_filemanager/filemanager/dialog.php?path=img-cover&user=' + user_email + '&multiple=false&relative_url=1&type=1&field_id=new-img-org')

// НАСТРОЙКИ СКРИПТА
if (isset($_GET['path']) || isset($_GET['admin']) || isset($_GET['user'])) {
    $_SESSION["RF"]["subfolder"] = "";
//    exit();
}

if (isset($_GET['path'])) {
    if ($_GET['path'] == 'img-cover') {
        if (!is_dir($_SERVER['DOCUMENT_ROOT']. '/public/storage/uploads/users/' . $_GET['user'] . '/images/')) {
            mkdir($_SERVER['DOCUMENT_ROOT']. '/public/storage/uploads/users/' . $_GET['user'] . '/images/', 0777, true);
        }
        $_SESSION["RF"]["subfolder"] = "uploads/users/" . $_GET['user'] . "/images/";
    }
    if ($_GET['path'] == 'new-doc') {
        if (!is_dir($_SERVER['DOCUMENT_ROOT']. '/public/storage/uploads/users/' . $_GET['user'] . '/documents/')) {
            mkdir($_SERVER['DOCUMENT_ROOT']. '/public/storage/uploads/users/' . $_GET['user'] . '/documents/', 0777, true);
        }
        $_SESSION["RF"]["subfolder"] = "uploads/users/" . $_GET['user'] . "/documents/";
    }
}

if (isset($_GET['fm']) || isset($_GET['tinymce'])) {
    if (!is_dir($_SERVER['DOCUMENT_ROOT']. '/public/storage/uploads/users/' . $_GET['user'])) {
        mkdir($_SERVER['DOCUMENT_ROOT']. '/public/storage/uploads/users/' . $_GET['user'], 0777, true);
    }
    $_SESSION["RF"]["subfolder"] = "uploads/users/" . $_GET['user'];
}

Для доступа ко всей файловой системе на хостинге был внедрен Elfinder и AFM. Для работы с базой SQL — Adminer.

 Проверка уникальности. Для проверки уникальности решений (в том числе парсинге документов Word) изначально использовалась функция PHP similar_text, которая проверяет степень схожести 2 строк. Однако, для решения необходимых задач оказалось, что работает она медленно. Как вариант, можно было реализовать проверку уникальности либо с помощью Supervisor (как отмечал выше — на моем хостинге его установка недоступна), либо с помощью заданий Crone.

image-loader.svg

В итоге, сделал свою систему проверки, которая создает массив из слов, исключая знаки препинания, потом считает количество одинаковых слов и кодирует их 3 первыми буквами. К примеру, при наличии в тексте 2 слов «пример, пример» в базу данных попадет массив с элементом «при6–2». Впоследствии массивы сравнивались функцией array_intersect_assoc.

Код

if (!function_exists('textArray')) {
    function textArray($s)
    {
        $str_arr = preg_split("/[.,!:?\s+-]/", $s, -1, PREG_SPLIT_NO_EMPTY);
        $arr_count_words = array_count_values($str_arr);

        $arr_min_count_words = [];
        foreach ($arr_count_words as $key => $i)
        {
            if (mb_strlen($key) < 4) {
                $arr_min_count_words[$key] = $i;
            } else {
                $k = mb_strtolower($key);
                //$k = strtolower($key);
                $first_char = mb_substr($k, 0, 3);
                $k_length = mb_strlen($k);
                $arr_min_count_words[$first_char . $k_length] = $i;
            }
        }
        return $arr_min_count_words;
    }
}

$intersect = array_intersect_assoc($arr_this_decision, $arr_other_decision);

Таблицы DataTable. Таблица заданий позволяет проводить различные сортировки по полям БД. Кроме того, мне нужен был функционал живого поиска текста в контенте заданий. Реализовано это было с помощью плагина DataTable, который, как я уже ранее отмечал устанавливался не из специального репозитория для Laravel, а просто настраивался без привязки к фреймворку. Также были установлены и интегрированы плагины Selectize и Select2.

image-loader.svg

При вводе значения в поле input отправлялся fetch POST запрос, возвращающий объект с данными для построения таблицы.

Код

            $filter_all_tasks = Task::where(function ($query) use ($search, $section_id) {
                if ((int)$section_id) {
                    $query->whereIn('section_id', function ($query) use ($section_id) {
                        $query->from('sections')->where('id', 'like', $section_id)->select('id')->get();
                    });
                }
            })
                ->where(function ($query) use ($search, $discipline_id) {
                    if ((int)$discipline_id) {
                        $query->whereHas('disciplines', function ($query) use ($discipline_id) {
                            $query->where('disciplines.id', 'like', $discipline_id);
                        });
                    }
                })
                ->where(function ($query) use ($search) {
                    $query->whereHas('disciplines.organization', function ($query) {
                        $query->where('organization_id', $this->organization->id);
                    })
                        ->orwhereDoesntHave('disciplines.organization')
                        ->orWhereDoesntHave('section');
                })
                ->where('user_id', 'like', $user_id)
                ->where(function ($query) use ($search) {
                    $query->where('content', 'like', $search)->orWhere('name', 'like', $search);
                })
                ->with(['section', 'disciplines'])
                ->select('*', 'tasks.id as id')
                ->orderBy($order, $request->order['0']['dir'])
                ->get();

Socket. Для создания интерактивных заданий, контроля деятельности пользователей онлайн были использованы сокеты. Изначально сервер сокетов запускался с помощью доступа по SSH и демона PHP (библиотеки Rachet, а позже Workerman). Последняя библиотека показала себя весьма неплохо. Однако, я все же перешел на NodeJs c Express и SocketIo.

Стоит упомянуть о сложности, на решение которой я потратил несколько часов. На всех устройствах, кроме IPhone сокеты работали хорошо, однако с яблочной продукцией коннекта не было. Все сертификаты были прописаны верно, сервер https работал, обмен между сокетами был кроме IPhone.

let https = require('https');
 let server = https.createServer({
     key: fs.readFileSync('./user.txt'),
     cert: fs.readFileSync('./server.txt'),
     ca: fs.readFileSync('./ca.txt'),
     requestCert: false,
     rejectUnauthorized: false
 }, app);

Сложность была для меня в том, что у меня нет IPhone и протестировать почему нет связи я не мог. На просторах Интернета есть симуляторы/эмуляторы устройств, однако все они работали, а на телефоне не работало.

image-loader.svg

Решение было найдено на форуме: необходимо цепочку сертификатов поместить в 1 файл с основным сертификатом. Получается 2 файла: 1 файл с ключом сертификата и 1 объединенный файл. Все заработало.

Видеосвязь WebRtc. Видеосвязьсделана с использованием WebRTC. Возможна демонстрация как рабочего стола, конкретного окна, так и Web камеры. И снова я столкнулся с проблемой на IPhone.

image-loader.svg

Оказалось, что при конфиге stun сервера использовалась запись:

let  servers = {«iceServers»:[{«url — обязательно должно быть urls»: «stun: stun.l.google.com:19302»}]};

Распознавание речи. Для реализации технологий распознавания и синтеза речи использовались Web Speech API, возможностей которых вполне хватало для реализации задач диалога.

image-loader.svg

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

image-loader.svg

 Чат система и Telegram. Чат и система общения в приложении построена на стандартной системе оповещений Laravel (Notifications). В дополнение используется бот Telegram, который с помощью WebHook передает информацию на сайт (id чата и сообщения пользователей).

image-loader.svg

GPS. Docker. Дляреализации заданий на местности была создана трекинговая система, основанная на картах OpenStreetMap и библиотеке Leaflet, работающая тайлами карт. Возможности библиотеки для картографических приложений вполне достаточны — это работа с маркерами, областями, кластерами и т.д. Для навигации была использована библиотека Leaflet Routing Machine. К сожалению, построение маршрутов на бесплатных серверах было лимитировано, поэтому с помощью Docker была создана своя маршрутизация OSRM (нужной области России).

image-loader.svg

Nginx. В связи с появлением значительного количества микросервисов (сокеты, веб-серверы, Docker…) было решено использовать Nginx в качестве прокси-сервера. Прокидывание к нужному сервису осуществлялось как с помощью адресной строки запроса, так и порту. Огромный плюс такого решения — настройка SSL в 1 месте, а во всех сервисах. Учитывая, что используется LetsEncrypt, приходилось раз в 3 месяца менять файлы сертификатов.

Конец 4 части.

© Habrahabr.ru