Интеграция Learn.co с партнерской организацией

В рамках проекта, над которым мы работаем с партнерской организацией, мы разрабатываем новый рабочий процесс для авторов учебных программ, чтобы создать учебный план, который будет размещен на нашей платформе Learn.co. Доступ к создаваемым ими урокам будет осуществляться через их собственную систему управления обучением, но они будут обслуживаться через Learn. Это позволит студентам, проходящим курс через партнерскую организацию, использовать все функции, входящие в комплект Learn.

Некоторые сведения о системе учебных программ Learn: весь контент, составляющий учебную программу в Flatiron School, хранится в репозиториях GitHub. Чтобы ученик завершил урок, он должен выполнить ответвление репозитория, написать код для завершения урока, запустить свой код для любых тестов, содержащихся в репозитории, и, наконец, открыть запрос на вытягивание, чтобы сигнализировать, что они готовы перейти к следующему уроку. Платформа Learn.co облегчает это обучение на основе тестов, а благодаря таким функциям, как IDE в браузере, студент может делать все это, не выходя из браузера.

Для этого нового рабочего процесса разработки учебной программы мы создали новое приложение Phoenix. Когда автор учебной программы создает новую часть учебной программы, задача этого приложения - организовать последовательность шагов за кулисами:

  • На GitHub создается новый пустой репозиторий, куда будет добавлен урок.
  • Мы извлекаем код начала урока (он же «шаблон» урока) из соответствующего репозитория шаблонов и клонируем его во временный каталог git на нашем сервере. Затем мы устанавливаем удаленный каталог временного каталога на вновь созданный пустой репозиторий уроков на GitHub, фиксируем код и отправляем его на удаленный компьютер.
  • Learn.co получает уведомление об этом новом содержании и синхронизирует свою базу данных.
  • Партнерская организация получает уведомление об успешном создании урока.

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

В этом сообщении блога будет рассказано, как мы использовали Phoenix Channels, клиент Websocket, который входит в стандартную комплектацию приложения Phoenix, а также React Hooks и Context API на интерфейсе, что позволило нам улучшить UX, обеспечивая некоторую обратную связь и прозрачность. пользователю по завершении этого процесса. Серверная часть будет транслировать сообщение по каналу для конкретного урока после завершения каждого шага, а интерфейс будет обновлять состояние, чтобы отображать прогресс для пользователя в режиме реального времени.

Примечание. В этом посте мы будем реализовывать функции с помощью React Hooks. Для использования хуков у вас должна быть установлена ​​16.7.0-alpha.0 версия как react, так и react-dom. Хуки - это экспериментальное предложение по React, и на момент написания этой статьи они еще не готовы к производству.

Интерфейс: использование клиента Phoenix Channel в приложении React

Клиент Phoenix JavaScript предоставляет API для установления соединения Websocket с приложением Phoenix.

import { Socket } from ‘phoenix’
const socket = new Socket(webSocketUrl, {params: options})
socket.connect()

В приложении React этот socket объект должен быть доступен на любой произвольной глубине в дереве компонентов, где бы нам ни требовалось транслировать или получать сообщения по каналу. В то же время соединение Websocket - это довольно серьезная проблема, отличная от бизнес-логики или логики представления многих отдельных компонентов. Это идеальный вариант использования React Context API. Вместо того, чтобы передавать объект socket в качестве опоры через различные компоненты среднего уровня, его можно поместить в контекст и использовать из контекста по мере необходимости.

На следующих этапах мы:

  • создать контекст;
  • создать SocketProvider компонент, задача которого - вставить соединение сокета в контекст;
  • и создайте пользовательский хук useChannel, который будет захватывать объект socket из контекста, присоединяться к указанному каналу и отвечать на любые сообщения, передаваемые по этому каналу.

Создание контекста

Обычный механизм передачи данных компоненту в React - от родителя к потомку через свойства. « Контекст предоставляет способ передавать данные через дерево компонентов без необходимости передавать реквизиты вручную на каждом уровне ». Создать новый контекст довольно просто:

import { createContext } from ‘react’
const SocketContext = createContext()
export default SocketContext

Компонент SocketProvider

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

<SocketProvider wsUrl={“/socket”} options={{ token }}>
  <App {…props} />
</SocketProvider>

Он должен получить два реквизита. Во-первых, URL-адрес, по которому он будет делать HTTP-запрос к серверу в качестве первого шага в рукопожатии Websocket. Затем он может получить любые дополнительные параметры, например токен для аутентификации. Маршрут по умолчанию для приложения Phoenix - “/socket”.

Внутри компонент инициализирует объект socket, установит его в SocketContext, отобразит все дочерние компоненты и запустит запрос подтверждения после того, как он будет смонтирован. Традиционно последнее требование вынуждает реализовать это как компонент класса для доступа к методам жизненного цикла компонента, таким как componentDidMount. Использование нового Hooks API позволит нам определить компонент как функциональный компонент; нет необходимости в этом и методах жизненного цикла.

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

useEffect(() => { socket.connect() }, [options, wsUrl])

После создания объекта socket нам нужно убедиться, что он доступен для дочерних компонентов, поскольку он инкапсулирует все поведение, которое нам потребуется для присоединения и взаимодействия по каналу. Чтобы предоставить объект сокета для использования дочерними компонентами, мы можем использовать компонент Context.Provider, чтобы установить его в контекст:

<SocketContext.Provider value={socket}>
 { children }
</SocketContext.Provider>

Вот полный SocketProvider:

Затем мы будем использовать значение, которое мы установили в контексте в ловушке useChannel.

Пользовательский обработчик useChannel

Компоненту, функциональность которого зависит от соединения Websocket, потребуется способ отвечать на сообщения, которые он получает, и соответствующим образом обновлять свое состояние. Хуки позволяют любому компоненту React подключаться ко всем функциям React, включая состояние компонента. Что касается обновления внутреннего хранилища данных в ответ на дискретные сообщения, здесь действительно полезно подумать о шаблоне функции редуктора из Redux.

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

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

const state = useChannel(‘channelName’, reducer, initialState)

… Таким образом позволяя компоненту поддерживать состояние, которое всегда будет синхронизировано с последними сообщениями, полученными по каналу.

Мы можем разбить это на два отдельных этапа: обработка сообщений и обновление состояния в ответ на эти сообщения.

Присоединение к каналу и обработка сообщений

Хук контекста очень прост в использовании. Он возвращает то, что мы ранее передали как свойство value компоненту Context.Provider:

const socket = useContext(SocketContext)

Далее, если мы еще не присоединились к каналу, нам нужно создать его и присоединиться к нему:

const channel = socket.channel(channelName, {client: ‘browser’})
channel.join()
  .receive(“ok”, ({messages}) => console.log(‘successfully joined channel’, messages || ‘’))
  .receive(“error”, ({reason}) => console.error(‘failed to join channel’, reason))

Клиент Phoenix JS предоставляет способ обработки определенного сообщения, транслируемого по этому каналу, используя имя события сообщения и функцию обработчика обратного вызова:

channel.on(“some_msg”, msg => console.log(“Message Received”))

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

В качестве обходного пути мы можем определить переопределяемый channel.onMessage метод, который позволит нам обрабатывать все события, отправленные по каналу:

channel.onMessage = (event, payload) => {
  console.log(`the event "${event}" just happened`)
  return payload
}

На этом этапе мы можем успешно ответить на полученные сообщения и готовы обновить состояние.

Обновление состояния с помощью useReducer

Reducer Hook работает примерно так, как и следовало ожидать, если вы знакомы с Redux:

const [state, dispatch] = useReducer(reducer, initialState)

Он получает функцию редуктора и некоторое начальное состояние. Он возвращает состояние и функцию отправки, которую мы можем захватить с помощью некоторого деструктурирования массива. Функция отправки может использоваться для отправки действия (сообщения) редуктору. Когда сообщение получено, мы вызываем dispatch с событием сообщения и полезной нагрузкой.

Вот полный хук useChannel. Обратите внимание на следующие моменты:

  • возвращает состояние из хука useReducer;
  • точно так же, как useEffect использовался в SocketProvider компоненте вместо метода жизненного цикла, мы можем сделать то же самое здесь для присоединения к каналу;
  • useEffect может возвращать функцию, которая будет вызываться на этапе очистки, как и componentWillUnmount. Здесь мы можем вызвать channel.leave().

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

Когда сообщение получено, оно вызывает отправку сообщения; dispatch вызывает предоставленную функцию редуктора с текущим состоянием, а затем сообщение возвращает новое состояние. Хук возвращает это состояние, которое всегда будет соответствовать последним сообщениям.

Помните, что в компоненте мы можем запланировать использование крючка, например…

const state = useChannel(‘channelName’, reducer, initialState)

… Тем самым предоставляя этому компоненту доступ к значению состояния. Магия ловушек заключается в том, что при изменении этого значения компонент будет повторно отрисован, в результате чего пользовательский интерфейс всегда будет синхронизироваться с последними данными.

Прежде чем собирать все это вместе и использовать ловушку в компоненте, мы рассмотрим настройку серверной части для широковещательной рассылки сообщений.

Серверная часть: рассылка сообщений с помощью Phoenix

Бэкэнд представляет собой зонтичный проект Elixir, серию связанных приложений Elixir. Веб-приложение Phoenix называется развертыванием. Внутри процесс создания урока называется развертыванием урока.

Phoenix упрощает настройку подключения к Websocket, а документация - отличный ресурс. Мы предпримем следующие шаги:

  • добавление маршрута канала;
  • создание модуля канала;
  • трансляция сообщений из других приложений в зонтике.

Добавить маршрут канала

Маршрут для первоначального рукопожатия уже настроен для нас в файле lib/deployer_web/endpoint.ex, созданном во время создания приложения Phoenix.

socket “/socket”, DeployerWeb.UserSocket

Внутри модуля UserSocket мы добавим конкретный маршрут канала. Раскомментируйте пример маршрута, который предоставляет Phoenix, и обновите его до приведенного ниже кода:

channel “deployment:*”, DeployerWeb.DeploymentChannel

“*” здесь указывает маршрут с подстановочными знаками. “deployment:”, за которым следует что-нибудь, будет обрабатываться DeploymentChannel, который мы создадим на следующем шаге. В этом примере мы добавим uuid урока, чтобы обеспечить уникальное название канала для каждого урока.

Создайте канал

В каталоге lib/channels создайте новый файл с именем deployment_channel.ex и добавьте следующий код:

defmodule DeployerWeb.DeploymentChannel do
  use Phoenix.Channel
  
  def join(“deployment:” <> _lesson_uuid , _message, socket) do
   {:ok, socket}
  end
end

Это точка, в которой интерфейс и серверная часть устанавливают свое соединение. Функция соединения обрабатывает запрос, отправленный путем вызова channel.join() во внешнем интерфейсе; а ответ {:ok, socket} - это то, что отправляется клиенту для выполнения действий.

Создаваемая нами функция не требует особой настройки. Клиент не будет транслировать какие-либо сообщения на сервер, но здесь вы можете добавить обработчики входящих сообщений, отправляемых по каналу, если это необходимо.

Рассылка сообщений с сервера

Когда наше приложение Phoenix получает `POST /lesson` запрос, оно вызывает другое дочернее приложение LessonBuilder в нашем зонтике, чтобы запустить процесс создания урока. Это дочернее приложение содержит код, который должен транслировать клиенту сообщения о состоянии создания урока. Это создает проблему, поскольку приложение LessonBuilder не является приложением Phoenix и не знает, как транслировать сообщения или иным образом взаимодействовать с каналами веб-приложения. Чтобы решить эту проблему, мы передаем функцию трансляции из веб-приложения в качестве обратного вызова с некоторыми связанными аргументами, чтобы ее можно было вызывать всякий раз и везде, где каждый шаг завершен.

Нам нужна библиотечная функция Конечный модуль broadcast/3. Мы создадим анонимную функцию, которая обертывает функцию broadcast/3 и передает имя канала, имя события и получает полезную нагрузку позже при каждом вызове. Использование замыкания позволяет нам сохранить логику каналов в веб-приложении и не допустить ее утечки за границы приложений. Функциональное программирование на победу!

Вот упрощенный пример контроллера и последующего вызова:

Как показано здесь, всякий раз, когда шаг завершается, мы транслируем “step_complete” event на канале для этого конкретного развертывания урока. Тип шага отправляется вместе с полезной нагрузкой. Они соответствуют пунктам маркированного списка создания уроков, перечисленным во вводном разделе. Они одни из

  • ‘create_lesson_repo’
  • ‘create_lesson_contents’
  • ‘update_learn_create_curriculum’
  • ‘update_partner_create_curriculum’

Ответ на эти рассылки в интерфейсе - это последняя часть, которая нам нужна для завершения этой функции.

Собираем все вместе: создание компонента React

Компонент CreationStatus отобразит серию CreationStatusItem компонентов. Каждый из них получит свойство status, имеющее значение “pending”, “active” или “complete”, и свойство label, описывающее шаг. CreationStatusItem - это чисто презентационный компонент, основная задача которого - отображать различную разметку и стили, связанные с разными статусами.

const CreationStatus = ({ lessonUuid }) => {
  const initialState = {
    createRepo: 'pending',
    createContents: 'pending',
    updateLearn: 'pending',
    updatePartner: 'pending'
  }

  return (
    <div className='segment'>
      <div className='list'>
        <CreationStatusItem 
          status={initialState.createRepo}    
          label='Creating Remote Repository' 
        />
        <CreationStatusItem 
          status={initialState.createContents}
          label='Pushing Template Contents'
        />
        <CreationStatusItem 
          status={initialState.updateLearn}
          label='Updating Learn.co'
        />
        <CreationStatusItem 
          status={initialState.updatePartner}
          label='Updating Partner Organization' 
        />
      </div>
    </div>
  )
}

Статус каждого элемента необходимо будет обновлять в режиме реального времени по мере отправки сообщений по каналу. Здесь вам пригодится ловушка useChannel. Мы реализовали его для возврата объекта состояния, возвращаемого функцией редуктора. Этот объект будет иметь ключи createRepo, createContents, updateLearn и updatePartner, и значения этих ключей будут меняться, когда сообщение будет получено по каналу и оттуда отправлено редуктору. Начиная с “pending”, статус каждого шага должен стать “active” и, наконец, “complete”.

Мы можем использовать деструктуризацию ES6, чтобы получить эти значения статуса из объекта, возвращаемого ловушкой. Когда значение изменяется, компонент будет повторно отрисован, тем самым гарантируя, что пользовательский интерфейс всегда в актуальном состоянии:

В этом относительно простом на вид коде много чего происходит, давайте рассмотрим все шаги.

Выше в дереве компонентов SocketProvider устанавливает постоянное соединение Websocket с сервером. Это делает объект socket, клиент, через который мы взаимодействуем с соединением, доступным с помощью React Context API.

Затем, когда компонент CreationStatus визуализируется, вызывается ловушка useChannel. Код в хуке использует соединение сокета из контекста, создает канал, специфичный для текущего создаваемого урока, и присоединяется к нему с помощью channel.join(). Он также определяет метод channel.onMessage, который будет вызываться всякий раз, когда сообщение отправляется по каналу с функцией Endpoint.broadcast/3 на бэкэнде.

Когда сообщение получено, вызывается функция dispatch, возвращенная ловушкой useReducer. dispatch отправляет сообщение редуктору и соответствующим образом обновляет состояние. Ловушка useChanel возвращает обновленное состояние, делая его доступным для компонента, и с помощью ловушек запускает повторный рендеринг при его изменении.

Хуки позволяют реализовать здесь очень чистую реализацию. Легко понять, что представляет собой компонент; ответственность за конфигурацию Websocket инкапсулирована в ловушке useChannel, и она достаточно универсальна для повторного использования, поскольку не знает никакой информации о конкретной логике этой функции; логика обновления состояния делегируется функции редуктора, которая по определению является чистой функцией, которую очень легко тестировать. Давайте посмотрим на редуктор как на последний фрагмент кода:

Функция (и) редуктора

eventReducer обрабатывает событие initial ‘phx_reply’, сигнализирующее, что канал был успешно присоединен, и отмечает первый шаг как ‘active’. Отсюда каждое ‘step_complete’ событие вызывает stepReducer, который отмечает текущий шаг ‘complete’ и следующий шаг как ‘active’, пока все не будет завершено.

Все, что осталось сделать, это добавить немного анимации CSS и ...

Идем дальше: создание пакета use-phoenix-channel

Разве не было бы замечательно, если бы вы могли import { useChannel } работать над своими проектами на React? Что ж, вы можете! Мы завернули useChannel хук и SocketProvider компонент в пакет. Проверьте это на Github или npm.



Пакет содержит одну дополнительную функциональность, о которой мы кратко расскажем ниже:

Рассылка сообщений от клиента

Созданная нами реализация ловушки useChannel отлично подходит для нашего случая использования, когда клиент всегда получает сообщения, а сервер всегда отвечает за их отправку. Однако это не демонстрирует всей мощи протокола Websocket как метода полнодуплексной связи.

В традиционном цикле ответа на запрос существует неявное требование, чтобы клиент всегда инициировал весь процесс. Клиент делает запрос, а сервер отвечает. Мы видели, что патрубки позволяют течь в обратном направлении. После события, происходящего на сервере, клиенту транслировалось сообщение. Розетки позволяют потоку в обоих направлениях. Клиент или сервер могут начать цикл и отправлять данные другому или даже нескольким клиентам, подписанным на один и тот же канал.

Остались две небольшие детали, которые позволят useChannel стать полностью функциональным, полностью дуплексным крючком.

Во-первых, объект канала, созданный клиентом Phoenix JS, имеет метод push, который позволяет транслировать клиентскую сторону. Это JavaScript-эквивалент функции Endpoint.broadcast/3 в Phoenix. Его можно использовать как:

channel.push(“eventName”, payload)

Во-вторых, нет никаких конкретных правил в отношении возвращаемых значений пользовательских хуков. Если до того, как useChannel возвращал объект состояния, мы можем настроить его так, чтобы он возвращал как объект состояния, так и метод push.

Это точный образец, который мы видели в хуке useReducer, а также в других распространенных хуках, таких как useState:

[state, setState] = useState(initialValue)

Здесь возвращается массив, содержащий состояние в качестве первого элемента и setState в качестве второго. setState - это функция, обновляющая значение состояния. Внутри хука useChannel эту роль выполняет функция dispatch.

Мы можем следовать аналогичному шаблону и вернуть метод channel.push в качестве второго элемента из хука. Напомним, что dispatch вызывается всякий раз, когда сообщение получено по каналу. Таким образом, вызов push из компонента будет транслировать сообщение таким образом, что он обновит состояние любых других компонентов или клиентов, подписанных на этот канал. Вот пример использования этой функции в компоненте:

И, наконец, давайте взглянем на новый и улучшенный хук useChannel:

И вот оно!

Хотите работать в команде, ориентированной на миссию, которая любит работать с объединенной мощью Phoenix Channels и React Hooks? Мы нанимаем!

Чтобы узнать больше о Flatiron School, посетите веб-сайт, подпишитесь на нас в Facebook и Twitter и посетите нас на предстоящих мероприятиях рядом с вами.

Flatiron School - гордый член семьи WeWork. Посетите наши родственные технологические блоги WeWork Technology и Making Meetup.