image

Я дауншифтер. Так получилось, что последние три года мы с женой и младшим ребёнком наслаждаемся сельским пейзажем за окном, свежим воздухом и пением птиц. Удобства в доме, оптический интернет от местного провайдера, мощный бесперебойник и нагрянувший ковид неожиданно сделали идею переезда из мегаполиса не такой уж странной.

Пока я увлеченно занимался веб разработкой, где-то на фоне жена периодически жаловалась на проблемы выбора школы для ребёнка. И тут (вдруг) ребёнок подрос и школьный вопрос встал ребром. Ладно, значит, время пришло. Давайте вместе разберёмся, что же все-таки не так с системой образования в бывшей 1/6 части суши, и что мы с вами можем с этим сделать?

Традиционные методы очного обучения я оставлю за рамками этой статьи. Скажу только, что у обычных школ есть как неоспоримые преимущества, так и серьезные недостатки, к которым, кстати, в последнее время добавилась вынужденная самоизоляция. Здесь мы рассмотрим варианты дистанционного и семейного образования, которые, по целому ряду причин, в последнее время привлекают все больше родителей.

Внесу ясность: дистанционное обучение подразумевает занятия в обычной школе с помощью «дистанционных образовательных технологий» (ДОТ), а семейное означает добровольный уход из школы и обучение только силами семьи (по сути, это старый добрый экстернат). Впрочем, в любом случае ребёнка нужно прикрепить к какой-либо из доступных школ, как минимум, для сдачи промежуточных аттестаций.

А теперь немного наблюдений из жизни. С вынужденным переводом на дистанционку детей, уже учившихся в обычной школе, все грустно. Школьники воспринимают этот подарок судьбы как своего рода каникулы, родители не привыкли следить за дисциплиной во время занятий и в результате общая успеваемость неизбежно падает.

С первоклашками, особенно в случае семейной формы, у родителей, пожалуй, появляется шанс поставить ребёнка «на рельсы», используя естественный интерес и эффект новизны. Лично для меня добиться самостоятельности — главная задача. Сидеть и делать с ребёнком домашку я считаю верхом глупости не совсем разумным. Конечно, если вы хотите, чтобы ваши дети чего-то добились в жизни и не висели у вас на шее. Я хочу, поэтому моя цель — научить ребёнка учиться, правильно задавать вопросы и вообще, думать своей головой.

Ближе к делу. Выбираем государственную школу


Пожалуй, семейное образование мне нравится больше из-за возможности выбрать программу и график обучения. Да и физически посещать школу можно реже. Но выбрать государственную школу, поговорить с директором о прикреплении ребёнка и получить приказ о зачислении в первый класс нужно уже в конце зимы, чтобы в сентябре не было сюрпризов. Хотя, с юридической точки зрения, закон об образовании вроде бы не требует ежегодных аттестаций, «дедлайны», по моему опыту, отлично мотивируют, поэтому пусть будут аттестации. Вряд ли любая школа примет нас с распростертыми объятьями, но найти достойный вариант в ближайшем городе мы сможем, я уверен.

Выбираем учебную программу


Именно выбираем. Пытаться составить программу самостоятельно, не имея профильного образования, не разумно. Хотя существуют государственные образовательные ресурсы, такие как Российская Электронная Школа (РЭШ) и Московская Электронная Школа (МЭШ), которых в теории могло было бы хватить, но… Оба варианта предоставляют планы уроков, видеозаписи, тесты и учебные пособия. Вот чего мне не удалось найти, так это самих учебников, даже по обязательной программе.

И тут нет самого главного: общения. Обучить ребёнка, показывая ему бесконечные видеоролики и заставляя ставить галочки в тестах, не получится. Значит, нужно либо проводить уроки полностью самостоятельно, либо выбрать одну из онлайн школ.

Выбираем онлайн школу


Мы почти вернулись к тому, с чего начали. Дистанционка? Ладно, присмотримся к ней повнимательней. Как вообще можно организовать учебный процесс удаленно? Тут возникает много вопросов, я подниму только ключевые:

* Живое общение. Что предлагают школы? Скайп, в лучшем случае Тимс. Уроки по Скайпу? Серьёзно? Если я не ошибаюсь, на дворе 2020-й. Открыть перед первоклашкой несколько окон с красивыми разноцветными кнопочками и ждать, что он на них не нажмет, а будет пол-дня послушно слушать скучного дядю или тетю? Ни разу таких детей не видел. А вы?

* Домашка. Точнее, как она попадает к учителю на проверку? На самом деле, это действительно сложный вопрос, возможно, даже не решаемый в принципе. Существующие варианты:

  1. Написать в тетрадке, сфоткать и отправить учителю. Бр-р-р, не хочу заставлять учителей ломать глаза в попытках прочесть мутные фотки с мобильников, сделанные, как правило, по какому-то неписанному закону в темноте.
  2. Отправить скан. Полумера, в общем случае невозможная из-за отсутствия у родителей нужного оборудования.
  3. Оцифровать рукописный ввод с помощью дигитайзера или планшета. Так себе вариант, но об этом чуть позже.
  4. Напечатать текст. В принципе, допустимо, но вот как ребёнок введёт с клавиатуры, например, математическую или химическую формулу? Никак. Плюс, для более продвинутых деток, проблема с плагиатом.
  5. Выполнить онлайн тест. Это, безусловно, самый популярный вариант. Полагаю, большинство школ, включая РЭШ и МЭШ, ориентируются на него. На практике это означает скорее дрессировку, чем обучение. Дети учатся ставить галочки в правильном месте. За бортом остаются предметы, требующие любой формы творчества, например, сочинения, а также диктанты и непопулярное теперь по неведомой мне причине чистописание. Сюда же можно отнести умение отстаивать своё мнение.

* Оценки. Очевидно, выставленные на уроке и при проверке домашних заданий оценки должны попадать в электронный дневник, доступный родителям. И они туда попадают. Вот только не сразу. Я поинтересовался у старших детей, закончивших один из престижных лицеев златоглавой (по иронии судьбы, с информационным уклоном), почему так? Ответ, честно сказать, меня удивил. Оказывается, учителя записывают оценки на бумажку, а после уроков вбивают их в этот самый электронный дневник на государственном портале. И это в то время, как Теслы Илона Маска бороздят просторы космоса…

Ладно, пора провести небольшое техническое исследование и проверить, может существуют объективные причины такого положения дел?

Давайте определим требования к гипотетической идеальной платформе для обучения. На самом деле, все просто: дети должны оставаться на уроке, сосредоточившись на том, что говорит и показывает учитель, при необходимости отвечая на вопросы и при желании поднимая руку. По сути, нам нужно окно на полный экран с потоком с учительской камеры, презентацией или интерактивной доской. Самый простой способ добиться этого — использовать технологию WebRTC (real-time communications, коммуникации в реальном времени). Эта штука работает в любом более-менее современном браузере, не требует покупки дополнительного оборудования и, к тому же, обеспечивает хорошее качество связи. И да, этот стандарт требует асинхронного программирования как минимум потому, что необходимый JS метод navigator.mediaDevices.getUserMedia() возвращает промис. Вроде все понятно, приступаю к реализации.

Лирическое отступление о выборе фреймворка
В последнее время я все чаше слышу мнение о том, что «чистый» JavaScript шагнул далеко вперед, проблем с кроссбраузерностью уже нет и фреймворки на фронтэнде не нужны вообще. Особенно часто критике подвергается jQuery. Хорошо, стряхнем пыль со старого доброго JS и сравним:

// Выбрать элемент
element = $(selector);
element = document.querySelector(selector);

// Выбрать элемент внутри элемента
element2 = element.find(selector2);
element2 = element.querySelector(selector2);

// Скрыть элемент
element.hide();  // добавляет стиль display: none
element.classList.add('hidden');

Тут нужно пояснить, что CSS классу «hidden», при желании, можно прописать свойства opacity и transition, что даст эффект fadeIn/fadeOut на чистом CSS. Отлично, давно хотел отказаться от JS анимации!

// Слушать событие onClick
element.click(e => { ... });
element.onclick = (e) => { ...  }

// Переключить класс
element.toggleClass(class_name);
element.classList.toggle(class_name);

// Создать div
div = $("<div>");
div = document.createElement("div");

// Вставить созданный div в element
// (это не опечатка, можно писать одинаково)
element.append(div);
element.append(div);

И т.д и т.п. В целом, код на чистом JS получается немного более многословным, но такова цена повышения производительности и снижения трафика. Нет, я никого не агитирую, но для эксперимента использую «нативный» JS с удовольствием!

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

Пример базовой реализации простого видеочата, использующего топологию «full mesh»
'use strict';

(function () {
    const selfView = document.querySelector('#self-view'),
        remoteMaster = document.querySelector('#remote-master'),
        remoteSlaves = document.querySelector('#remote-slaves');

    let localStream,
        selfStream = null,
        socket = null,
        selfId = null,
        connections = {};

    // ***********************
    // UserMedia & DOM methods
    // ***********************

    const init = async () => {
        try {
            let stream = await navigator.mediaDevices.getUserMedia({
                audio: true, video: {
                    width: { max: 640 }, height: { max: 480 }
                }
            });
            localStream = stream;

            selfStream = new MediaStream();

            stream.getVideoTracks().forEach(track => {
                selfStream.addTrack(track, stream); // track.kind == 'video'
            });
            selfView.querySelector('video').srcObject = selfStream;

        } catch (e) {
            document.querySelector('#self-view').innerHTML =
                '<i>Веб камера и микрофон не найдены</i>';
            console.error('Local stream not found: ', e);
        }
        wsInit();
    }

    const createRemoteView = (id, username) => {
        let iDiv = document.querySelector('#pc' + id);
        if (!iDiv) {
            iDiv = document.createElement('div');
            iDiv.className = 'remote-view';
            iDiv.id = 'pc' + id;

            let iVideo = document.createElement('video');
            iVideo.setAttribute('autoplay', 'true');
            iVideo.setAttribute('playsinline', 'true');

            let iLabel = document.createElement('span');

            iDiv.append(iVideo);
            iDiv.append(iLabel);

            if (!remoteMaster.querySelector('video')) {
                remoteMaster.append(iDiv);
                iLabel.textContent = 'Ведущий';
            } else {
                remoteSlaves.append(iDiv);
                iLabel.textContent = username;
            }
            remoteMaster.style.removeProperty('display');
        }
    }

    // *******************************
    // Signaling (Web Socket) methods
    // *******************************

    const wsInit = () => {
        socket = new WebSocket(SIGNALING_SERVER_URL);

        socket.onopen = function (e) {
            log('[socket open] Соединение установлено');
        }

        socket.onmessage = function (event) {
            log('[socket message] Данные получены с сервера', event);

            wsHandle(event.data);
        }

        socket.onclose = function (event) {
            if (event.wasClean) {
                log('[close] Соединение закрыто чисто, ' +
                    `код=${event.code} причина=${event.reason}`);
            } else {
                log('[socket close] Соединение прервано', event);
            }
            clearInterval(socket.timer);
        }

        socket.onerror = function (error) {
            logError('[socket error]', error);
        }

        socket.timer = setInterval(() => {
            socket.send('heartbeat');
        }, 10000);
    }

    const wsHandle = async (data) => {
        if (!data) {
            return;
        }
        try {
            data = JSON.parse(data);
        } catch (e) {
            return;
        }

        switch (data.type) {
            case 'handshake':
                selfId = data.uid;
                if (!Object.keys(data.users).length) {
                    createRemoteView(selfId, 'Ведущий');
                    remoteMaster.querySelector('video').srcObject =
                        selfStream;
                    selfView.remove();
                    break;
                } else {
                    selfView.style.removeProperty('display');
                }
                for (let id in data.users) {
                    await pcCreate(id, data.users[id]);
                }
                break;
            case 'offer':
                await wsHandleOffer(data);
                break;
            case 'answer':
                await wsHandleAnswer(data)
                break;
            case 'candidate':
                await wsHandleICECandidate(data);
                break;
            default:
                break;
        }
    }

    const wsHandleOffer = async (data) => {
        let pc = null;

        if (!connections[data.src]) {
            await pcCreate(data.src, data.username);
        }

        pc = connections[data.src].pc;

        // We need to set the remote description to the received SDP offer
        // so that our local WebRTC layer knows how to talk to the caller.
        let desc = new RTCSessionDescription(data.sdp);

        pc.setRemoteDescription(desc).catch(error => {
            logError('handleOffer', error);
        });

        await pc.setLocalDescription(await pc.createAnswer());

        wsSend({
            type: 'answer',
            target: data.src,
            sdp: pc.localDescription
        });

        connections[data.src].pc = pc; // ???
    }

    const wsHandleAnswer = async (data) => {
        log('*** Call recipient has accepted our call, answer:', data);

        let pc = connections[data.src].pc;

        // Configure the remote description,
        // which is the SDP payload in our 'answer' message.

        let desc = new RTCSessionDescription(data.sdp);
        await pc.setRemoteDescription(desc).catch((error) => {
            logError('handleAnswer', error);
        });
    }

    const wsHandleICECandidate = async (data) => {
        let pc = connections[data.src].pc;

        let candidate = new RTCIceCandidate(data.candidate);

        log('*** Adding received ICE candidate', candidate);

        pc.addIceCandidate(candidate).catch(error => {
            logError('handleICECandidate', error);
        });
    }

    const wsSend = (data) => {
        if (socket.readyState !== WebSocket.OPEN) {
            return;
        }
        socket.send(JSON.stringify(data));
    }

    // ***********************
    // Peer Connection methods
    // ***********************

    const pcCreate = async (id, username) => {
        if (connections[id]) {
            return;
        }
        try {
            let pc = new RTCPeerConnection(PC_CONFIG);

            pc.onicecandidate = (event) =>
                pcOnIceCandidate(event, id);
            pc.oniceconnectionstatechange = (event) =>
                pcOnIceConnectionStateChange(event, id);
            pc.onsignalingstatechange =  (event) =>
                pcOnSignalingStateChangeEvent(event, id);
            pc.onnegotiationneeded = (event) =>
                pcOnNegotiationNeeded(event, id);
            pc.ontrack = (event) =>
                pcOnTrack(event, id);

            connections[id] = {
                pc: pc,
                username: username
            }

            if (localStream) {
                try {
                    localStream.getTracks().forEach(
                        (track) => connections[id].pc.addTransceiver(track, {
                            streams: [localStream]
                        })
                    );
                } catch (err) {
                    logError(err);
                }
            } else {
                // Start negotiation to listen remote stream only
                pcOnNegotiationNeeded(null, id);
            }
            createRemoteView(id, username);
        } catch (error) {
            logError('Peer: Connection failed', error);
        }
    }

    const pcOnTrack = (event, id) => {
        let iVideo = document.querySelector('#pc' + id + ' video');
        iVideo.srcObject = event.streams[0];
    }

    const pcOnIceCandidate = (event, id) => {
        let pc = connections[id].pc;

        if (event.candidate && pc.remoteDescription) {
            log('*** Outgoing ICE candidate: ' + event.candidate);
            wsSend({
                type: 'candidate',
                target: id,
                candidate: event.candidate
            });
        }
    }

    const pcOnNegotiationNeeded = async (event, id) => {
        let pc = connections[id].pc;
        try {
            const offer = await pc.createOffer();

            // If the connection hasn't yet achieved the "stable" state,
            // return to the caller. Another negotiationneeded event
            // will be fired when the state stabilizes.
            if (pc.signalingState != 'stable') {
                return;
            }

            // Establish the offer as the local peer's current
            // description.
            await pc.setLocalDescription(offer);

            // Send the offer to the remote peer.
            wsSend({
                type: 'offer',
                target: id,
                sdp: pc.localDescription
            });
        } catch(err) {
            logError('*** The following error occurred while handling' +
                ' the negotiationneeded event:', err);
        };
    }

    const pcOnIceConnectionStateChange = (event, id) => {
        let pc = connections[id].pc;
        switch (pc.iceConnectionState) {
            case 'closed':
            case 'failed':
            case 'disconnected':
                pcClose(id);
                break;
        }
    }

    const pcOnSignalingStateChangeEvent = (event, id) => {
        let pc = connections[id].pc;

        log('*** WebRTC signaling state changed to: ' + pc.signalingState);

        switch (pc.signalingState) {
            case 'closed':
                pcClose(id);
                break;
        }
    }

    const pcClose = (id) => {
        let remoteView = document.querySelector('#pc' + id);

        if (connections[id]) {
            let pc = connections[id].pc;
            pc.close();
            delete connections[id];
        }
        if (remoteView) {
            remoteView.remove();
        }
    }

    // *******
    // Helpers
    // *******

    const log = (msg, data) => {
        if (!data) {
            data = ''
        }
        console.log(msg, data);
    }

    const logError = (msg, data) => {
        if (!data) {
            data = ''
        }
        console.error(msg, data);
    }

    init();
})();


Сервер сигнализации выполнен на Python фреймвоке aiohttp и представляет собой простую «вьюху», тривиально проксирующую запросы WebRTC. Соединение с сервером в этом примере выполнено на веб сокетах. Ну и, в дополнение, через канал сигнализации передаются данные простого текстового чата.

Пример реализации сервера сигнализации
import json
from aiohttp.web import WebSocketResponse, Response
from aiohttp import WSMsgType
from uuid import uuid1
from lib.views import BaseView


class WebSocket(BaseView):
    """ Process WS connections """

    async def get(self):
        username = self.request['current_user'].firstname or 'Аноним'

        room_id = self.request.match_info.get('room_id')

        if room_id != 'test_room' and
            self.request['current_user'].is_anonymous:
            self.raise_error('forbidden')  # @TODO: send 4000

        if (self.request.headers.get('connection', '').lower() != 'upgrade' or
            self.request.headers.get('upgrade', '').lower() != 'websocket'):
            return Response(text=self.request.path)  # ???

        self.ws = WebSocketResponse()
        await self.ws.prepare(self.request)

        self.uid = str(uuid1())

        if room_id not in self.request.app['web_sockets']:
            self.request.app['web_sockets'][room_id] = {}

        self.room = self.request.app['web_sockets'][room_id]

        users = {}
        for id, data in self.room.items():
            users[id] = data['name']

        ip = self.request.headers.get(
            'X-FORWARDED-FOR',
            self.request.headers.get('X-REAL-IP',
            self.request.remote))

        msg = {
            'type': 'handshake',
            'uid': str(self.uid),
            'users': users, 'ip': ip}
        await self.ws.send_str(json.dumps(msg, ensure_ascii=False))

        self.room[self.uid] = {'name': username, 'ws': self.ws}

        try:
            async for msg in self.ws:
                if msg.type == WSMsgType.TEXT:
                    if msg.data == 'heartbeat':
                        print('---heartbeat---')
                        continue

                    try:
                        msg_data = json.loads(msg.data)

                        if 'target' not in msg_data or
                            msg_data['target'] not in self.room:
                            continue

                        msg_data['src'] = self.uid

                        if 'type' in msg_data and 'target' in msg_data:
                            if msg_data['type'] == 'offer':
                                msg_data['username'] = username
                        else:
                            print('INVALID DATA:', msg_data)
                    except Exception as e:
                        print('INVALID JSON', e, msg)

                    try:
                        await self.room[msg_data['target']]['ws'].send_json(
                            msg_data);
                    except Exception as e:
                        if 'target' in msg_data:
                            self.room.pop(msg_data['target'])

        finally:
            self.room.pop(self.uid)

        return self.ws


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

Я так увлекся современными возможностями видеосвязи, что чуть не забыл о самом главном предмете в классе — интерактивной доске. Впрочем, базовая реализация настолько тривиальна, что я не стану загромождать ею эту статью. Просто добавляем canvas, слушаем события перемещения мыши onmousemove (ontouchmove для планшетов) и отправляем полученные координаты всем подключенным точкам через тот же сервер сигнализации.

Тестируем интерактивную доску


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

Для начала я взял старенький планшет Galaxy Tab на андроиде 4.4, самодельный стилус и первые попавшиеся прописи в качестве фона для canvas. Дополнительные программы не устанавливал. Результат меня обескуражил: мой планшет абсолютно не пригоден для письма! То есть водить по нему пальцем — без проблем, а вот попасть стилусом в контур буквы, даже такой огромной, как на картинке ниже, уже проблема. Плюс гаджет начинает тупить в процессе рисования, в результате чего линии становятся ломанными. Плюс мне не удалось заставить ребёнка не опирать запястье на экран, отчего под рукой остается дополнительная мазня, а сам планшет начинает тормозить еще больше. Итог: обычный планшет для письма на доске не подходит. Максимум его возможностей — двигать пальцем по экрану достаточно крупные фигуры. Но предлагать это школьникам поздновато.

Ладно, у нас ведь чисто теоретическое исследование, верно? Тогда берем дигитайзер (он же графический планшет) Wacom Bamboo формата A8, и наблюдаем за ребёнком.

Замечу, что мой подопытный шести лет от роду получил ноутбук, да еще с графическим пером первый раз в жизни. На получение базовых навыков обращения с пером у нас ушло минут десять, а уже на втором уроке ребёнок пользовался планшетом вполне уверенно, самостоятельно стирал с доски, рисовал рожицы, цветочки, нашу собаку и даже начал тыкать доступные в эпсилон окрестности кнопки, попутно задавая вопросы типа «А зачем в школе поднимают руку?». Вот только результат неизменно оставлял желать лучшего. Дело в том, что дизайнеры и художники для прорисовки элемента максимально увеличивают фрагмент изображения, что и делает линии точными. Здесь же мы должны видеть доску целиком, в масштабе 1:1. Тут и взрослый не попадет в линию. Вот что получилось у нас:

image

Итоговый вердикт: ни о каком рукописном вводе не может быть и речи. А если мы хотим «поставить руку» нашим детям, нужно добиваться этого самостоятельно, на бумаге, школа в этом никак не поможет.

Надо сказать, что ребёнок воспринял все мои эксперименты с восторгом и, более того, с тех пор ходит за мной хвостиком и просит «включить прописи». Уже хорошо, полученный навык ему пригодится, только совсем для других целей.

Так или иначе, в результате экспериментов я фактически получил MVP — минимально жизнеспособный продукт (minimum viable product), почти пригодный для проведения онлайн уроков, с видео/аудио конференцией, общим экраном, интерактивной доской, простым текстовым чатом и кнопкой «поднять руку». Это на случай, если у ребёнка вдруг не окажется микрофона. Да, такое бывает, особенно у детей, не выучивших уроки.

Но в этой бочке меда, к сожалению, есть пара ложек дёгтя.

Тестируем WebRTC


Ложка №1. Поскольку наша видеосвязь использует прямые подключения между клиентами, нужно первым делом проверить масштабируемость такого решения. Для теста я взял старенький ноутбук с двухядерным i5-3230M на борту, и начал подключать к нему клиентов с отключенными веб камерами, то есть эмулируя режим один-ко-многим:

image

Как видите, подопытный ноут в состоянии более-менее комфортно вещать пяти клиентам (при загрузке CPU в пределах 60%). И это при условии снижения разрешения исходящего видеопотока до 720p (640x480px) и frame rate до 15 fps. В принципе, не так уж и плохо, но при подключении класса из нескольких десятков учеников от «фулл меша» придется отказаться в пользу каскадирования, то есть каждый из первых пяти клиентов проксирует поток следующим пяти и так далее.

Ложка №2. Для создания прямого интерактивного подключения (ICE) между клиентами им нужно обойти сетевые экраны и преобразователи NAT. Для этого WebRTC использует сервер STUN, который сообщает клиентам внешние параметры подключения. Считается, что в большинстве случаев этого достаточно. Но мне почти сразу же «повезло»:

Как видите, отладчик ругается на невозможность ICE соединения и требует подключения TURN сервера, то есть ретранслятора (relay). А это уже ДОРОГО. Простым сервером сигнализации тут не обойтись. Вывод — придётся пропускать все потоки через медиа сервер.

Медиа сервер


Для тестирования я использовал aiortc. Интересная разработка, позволяет подключить браузер напрямую к серверу через WebRTC. Отдельная сигнализация не нужна, можно использовать канал данных самого соединения. Это работает, все мои тестовые точки подключились без проблем. Но вот с производительностью беда. Простое эхо видео/аудио потока с теми же ограничениями 720p и 15fps съели 50% моего виртуального CPU на тестовом VDS. Причём, если увеличить нагрузку до 100%, видео поток не успевает выгружаться клиентам и начинает забивать память, что в итоге приводит к остановке сервера. Очевидно, Питон, который мы любим использовать для задач обработки ввода/вывода, не очень подходит для «CPU bound». Придётся поискать более специализированное решение, например, Янус или Джитси.

В любом случае, полноценное решение потребует выделенных серверов, по моим прикидкам, из расчёта 1 ядро на комнату (класс). Это уже стоит денег и выходит за рамки простого тестирования, поэтому в этом месте первую фазу моего исследования я буду считать завершённой.

Выводы


1. Мягко говоря, странно видеть на официальном портале Российской Федерации инструкции по скачиванию и ссылки на регистрацию в программе бывшего потенциального врага №1 (тут про Microsoft Teams). И это в эпоху санкций и импортозамещения.
Нет, лично я за дружбу народов и вообще всяческую толерантность, но неужели только у меня от такой «интеграции» встают волосы дыбом? Разве нет наших разработок?

2. Интеграция МЭШ/РЭШ со школами. Вообще-то, разработчики МЭШ молодцы, даже с Яндекс.репетитором интеграцию сделали. А как быть с выставлением оценок в реальном времени во время уроков, когда будет API? Или я чего-то не знаю?

3. Выбирая дистанционную или семейную форму обучения, нужно быть честным с собой: переложить ответственность за образование ребёнка на школу не получится. Вся работа по проведению уроков (в случае семейного образования), поддержанию дисциплины и самоорганизованности (в любом случае) полностью ложится на родителей. Нужно отдавать себе в этом отчёт и найти время на занятия. Впрочем, в больших семьях с этим проблем быть не должно.

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

Или есть смысл довести до логического конца начатую здесь разработку и организовать свою школу? Что вы думаете? Есть единомышленники, имеющие профильные знания и опыт в области образования?

Полезные ссылки:
Российская Электронная Школа
Московская Электронная Школа
Библиотека МЭШ
Разработчику