Получив рабочий прототип (начало здесь) системы дистанционного обучения, включающий следующие виды заданий: тест, диалог, редактирование документа, деловая игра (квест), автопроверка решений по ключевым словам, было принято решение развивать проект дальше.
Возник вопрос перехода на 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 части.
Часть 4. Выбор фреймворка и переход на Laravel в рамках создания собственной СДО
Часть 5. Переход на ReactJs, внедрение flux, SOLID и интеграция в Laravel.
Часть 6. Внедрение нейронных сетей в работу СДО.
Комментарии (13)
Levitskyi
02.12.2021 07:20Мне одно непонятно - это всё изучено и написано одними руками преподавателя-юриста? Я бы почитал отдельную статью на эту тему.
pavel_smagin Автор
02.12.2021 07:22Если рассматривать программирование как хобби, то мне кажется - не имеет значение ни профессия, ни образование. Надо делать то, что приносит удовольствие.
Levitskyi
02.12.2021 12:58Да я не об этом. Чтоб изучить хотя бы на минимальном уровне всё то что описано в этой и предыдущих 3-х статьях нужно ну очень много времени и сил, особенно если ты не программист. Как это можно осуществить в разумные сроки занимаясь основной работой, которая не связана с IT - не представляю. Да, копипаст кусков кода из инета никто не отменял, но вот например относительно неплохо зная php и более-менее владея js, мне например не удалось понять философию node.js - видимо не хватило терпения. А тут такой список технологий, фреймворки, ООП, паттерны проектирования, чтоб познать весь этот дзен нужны многие годы имхо.
Вот потому и интересно - неужели кто-то занимаясь основной работой, умудрился разобраться во всей этой кухне в какие-то разумные сроки.
pavel_smagin Автор
02.12.2021 14:41На самом деле если есть цель быть просто пользователем библиотек и фреймворков, то не это не занимает много времени. Можно посмотреть пару видеокурсов и вперед, однако если хочется разобраться как это работает изнутри, сделать свои костюмные решения, взаимные интеграции - то да, придется постепенно во все вникать. У меня ушло много времени до момента перехода на ReactJs, Laravel - мог бы конечно, перейти раньше, сэкономил бы много времени, но хотел сделать сначала все сам. Так что, совмещая с основной работой думаю, что потратил около 5 лет и пару десятков проектов (именно необычные проекты, а не повторение создания своего блога, магазина и т.д.).
Насчет JS - nodeJs использую чисто для микросервисных задач. У nodeJs также есть недостатки, которые мешают запускать на нем, к примеру, библиотеки ИИ на TensorFlow, поэтому использую его для Socket, а веб сервер у меня где-то Nginx, где-то Apache.
trawl
02.12.2021 14:00создана папка со своими функциями, которые загружались собственным классом, зарегистрированном в сервис-провайдере
Но можно же просто добавить эти файлы в
composer.json
:{ "autoload": { "psr-4": { "App\\": "src", }, "files": ["func/file1.php", "func/file2.php"] } }
Это, конечно, на спичках экономия, но уже на один провайдер меньше при инициализации
pavel_smagin Автор
02.12.2021 14:27Да, конечно можно, но мне показалось, что проще 1 раз прописать папку, из которой загружать файлы и не контролировать composer.json. Если бы все файлы были классами, то да, тут поможет autoload, но, к примеру, файл с функциями - у меня просто как файл, поэтому придется прописывать его вручную и следить за именами.
trawl
03.12.2021 14:20Так оно, конечно, работает, но ответственность провайдера - наполнить контейнер необходимыми сервисами. А подгрузить файлы функций/классов - это всё же ответственность автолоадера. Можно просто добавить файл, который так же запрашивает glob и подключает файлы из результата, а уже один этот файл добавить в composer.json
m34
Для Laravel 100ms многовато. Тут наверное вопросы к мощности виртуалки, версии php, продакшн оптимизации (route:cache, config:cache и пр).
10ms для PHP это быстро и на сложном приложении очень дорого. Тут нужна экспертиза в вопросе либо вагон времени и желания. Чтобы код был сопровождаемым и с ним было приятно работать.
pavel_smagin Автор
Согласен про время 100ms (это без кеша), поэтому привел сравнение всех 3 фреймворков на одном железе. Все таки Laravel немного избыточен в плане задействования не всегда нужных классов (все равно часть ресурсов на это тратится).
m34
Есть еще Lumen, наверняка слышали. Правда если в него перенести все, что нужно, то получится почти Laravel. Хотя какой-то мелкий прирост все равно будет.
pavel_smagin Автор
Да, конечно изучал. Это по сути тот же Laravel только для API.