Pull to refresh

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

Reading time11 min
Views5K

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

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

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

    "require": {
        "filp/whoops": "^2.12",
        "illuminate/database": "^5.1.8",
        "league/plates": "^3.4",
        "monolog/monolog": "^2.2",
        "php-di/php-di": "^6.3",
        "slim/php-view": "^3.1",
        "slim/psr7": "^1.4",
        "slim/slim": "4.*"
    },

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

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

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

Первоначальная настройка и перенос проекта на 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.

Пакеты 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')
    <script src="{{asset('/packages/tinymce5/tinymce.min.js')}}" defer></script>
    <script src="{{asset('/js/scripts/admin_tiny_mce5.js')}}" defer></script>
@endpush

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

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

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

Базовый контроллер пользователя. Архитектура приложения (организация-дисциплина-тема-задание) предусматривает частую передачу в методы контроллеров значений (организация и дисциплина). Выхода виделось 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');

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

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 параметров.

Код
// ССЫЛКА
$('#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.

В итоге, сделал свою систему проверки, которая создает массив из слов, исключая знаки препинания, потом считает количество одинаковых слов и кодирует их 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.

При вводе значения в поле 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 и протестировать почему нет связи я не мог. На просторах Интернета есть симуляторы/эмуляторы устройств, однако все они работали, а на телефоне не работало.

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

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

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

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

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

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

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

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

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

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

Часть 1. Аналог Moodle или как преподаватель-юрист создавал собственную систему дистанционного обучения. Часть 1. Начало

Часть 2. Создание аналога Moodle. Реализация API для прототипа SPA. Межсайтовые запросы. Первые проблемы архитектуры

Часть 3. Выявление технических методов повышения уникальности текста с помощью PHP (в рамках создания собственной СДО)

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

Часть 5. Переход на ReactJs, внедрение flux, SOLID и интеграция в Laravel.

Часть 6. Внедрение нейронных сетей в работу СДО.

Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments13

Articles