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

Возник вопрос перехода на 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. Внедрение нейронных сетей в работу СДО.

Комментарии (13)


  1. m34
    01.12.2021 20:33

    Для Laravel 100ms многовато. Тут наверное вопросы к мощности виртуалки, версии php, продакшн оптимизации (route:cache, config:cache и пр).

    10ms для PHP это быстро и на сложном приложении очень дорого. Тут нужна экспертиза в вопросе либо вагон времени и желания. Чтобы код был сопровождаемым и с ним было приятно работать.


    1. pavel_smagin Автор
      01.12.2021 20:36

      Согласен про время 100ms (это без кеша), поэтому привел сравнение всех 3 фреймворков на одном железе. Все таки Laravel немного избыточен в плане задействования не всегда нужных классов (все равно часть ресурсов на это тратится).


      1. m34
        01.12.2021 22:58

        Есть еще Lumen, наверняка слышали. Правда если в него перенести все, что нужно, то получится почти Laravel. Хотя какой-то мелкий прирост все равно будет.


        1. pavel_smagin Автор
          01.12.2021 23:01

          Да, конечно изучал. Это по сути тот же Laravel только для API.


  1. Levitskyi
    02.12.2021 07:20

    Мне одно непонятно - это всё изучено и написано одними руками преподавателя-юриста? Я бы почитал отдельную статью на эту тему.


    1. pavel_smagin Автор
      02.12.2021 07:22

      Если рассматривать программирование как хобби, то мне кажется - не имеет значение ни профессия, ни образование. Надо делать то, что приносит удовольствие.


      1. Levitskyi
        02.12.2021 12:58

        Да я не об этом. Чтоб изучить хотя бы на минимальном уровне всё то что описано в этой и предыдущих 3-х статьях нужно ну очень много времени и сил, особенно если ты не программист. Как это можно осуществить в разумные сроки занимаясь основной работой, которая не связана с IT - не представляю. Да, копипаст кусков кода из инета никто не отменял, но вот например относительно неплохо зная php и более-менее владея js, мне например не удалось понять философию node.js - видимо не хватило терпения. А тут такой список технологий, фреймворки, ООП, паттерны проектирования, чтоб познать весь этот дзен нужны многие годы имхо.

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


        1. pavel_smagin Автор
          02.12.2021 14:41

          На самом деле если есть цель быть просто пользователем библиотек и фреймворков, то не это не занимает много времени. Можно посмотреть пару видеокурсов и вперед, однако если хочется разобраться как это работает изнутри, сделать свои костюмные решения, взаимные интеграции - то да, придется постепенно во все вникать. У меня ушло много времени до момента перехода на ReactJs, Laravel - мог бы конечно, перейти раньше, сэкономил бы много времени, но хотел сделать сначала все сам. Так что, совмещая с основной работой думаю, что потратил около 5 лет и пару десятков проектов (именно необычные проекты, а не повторение создания своего блога, магазина и т.д.).

          Насчет JS - nodeJs использую чисто для микросервисных задач. У nodeJs также есть недостатки, которые мешают запускать на нем, к примеру, библиотеки ИИ на TensorFlow, поэтому использую его для Socket, а веб сервер у меня где-то Nginx, где-то Apache.


          1. m34
            03.12.2021 11:25

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


  1. trawl
    02.12.2021 14:00

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

    Но можно же просто добавить эти файлы в composer.json:

    {
      "autoload": {
        "psr-4": {
          "App\\": "src",
        },
        "files": ["func/file1.php", "func/file2.php"]
      }
    }
    

    Это, конечно, на спичках экономия, но уже на один провайдер меньше при инициализации


    1. pavel_smagin Автор
      02.12.2021 14:27

      Да, конечно можно, но мне показалось, что проще 1 раз прописать папку, из которой загружать файлы и не контролировать composer.json. Если бы все файлы были классами, то да, тут поможет autoload, но, к примеру, файл с функциями - у меня просто как файл, поэтому придется прописывать его вручную и следить за именами.


      1. trawl
        03.12.2021 14:20

        Так оно, конечно, работает, но ответственность провайдера - наполнить контейнер необходимыми сервисами. А подгрузить файлы функций/классов - это всё же ответственность автолоадера. Можно просто добавить файл, который так же запрашивает glob и подключает файлы из результата, а уже один этот файл добавить в composer.json


        1. pavel_smagin Автор
          03.12.2021 14:51

          Соглашусь с Вашим мнением.