Русский
Русский
English
Статистика
Реклама

Api

Обновляемся на новую версию API Android по наставлению Google

27.05.2021 20:23:24 | Автор: admin

Скоро выходит Android 12, но в этом августе уже с 11-й версии разработчикам придётся использовать новые стандарты доступа приложений к внешним файлам. Если раньше можно было просто поставить флаг, что ваше приложение не поддерживает нововведения, то скоро они станут обязательными для всех. Главный фокус повышение безопасности.

Переход на новую версию API довольно трудоёмкая операция, требующая больших затрат на её поддержку при введении крупных апдейтов. Далее расскажу немного про наш переход и возникшие при этом трудности.

Что происходит

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

В Android есть внутреннее Internal Storage (IS) и внешнее хранилище External Storage (ES). Исторически это были встроенная память в телефоне и внешняя SD-карта, поэтому ES был больше, но медленнее и дешевле. Отсюда и разделение настройки и критически важное записывали в IS, а в ES хранили данные и большие файлы, например, медиа. Потом ES тоже стал встраиваться в телефон, но разделение, по крайней мере логическое, осталось.

У приложения всегда есть доступ к IS, и там оно может делать что угодно. Но эта папка только для конкретного приложения и она ограничена в памяти. К ES нужно было получать доступ и, кроме манипуляции со своими данными, можно было получить доступ к данным других приложений и производить с ними любые действия (редактировать, удалять или украсть).

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

В Android решили всё это переделать ещё в 10-й версии, а в 11-й это стало обязательным.

Чтобы минимизировать риски для пользователя в Google решили внедрить Scoped Storage (SS) в ES. Возможность проникнуть в папки других приложений убрали, а доступ есть только к своим данным теперь это сугубо личная папка. А IS с 10-й версии ещё и зашифрована по умолчанию.

В Android 11 Google зафорсировала использование SS когда таргет-версия SDK повышается до 30-й версии API, то нужно использовать SS, иначе будут ошибки, связанные с доступом к файлам. Фишка Android в том, что можно заявить совместимость с определённой версией ОС. Те, кто не переходили на 11, просто говорили, что пока не совместимы с этой версий, но теперь нужно начать поддерживать нововведения всем. С осени не получится заливать апдейты, если не поддерживаешь Android 11, а с августа нельзя будет заливать новые приложения.

Если SS не поддерживается (обычно это для девайсов ниже 10-й версии), то для доступа к данным других приложений требуется получить доступ к чтению и записи в память. Иначе придётся получать доступ к файлам через Media Content, Storage Access Framework или новый, появившийся в 11-м Android, фреймворк Datasets в зависимости от типа данных. Здесь тоже придётся получать разрешение доступа к файлу, но по более интересной схеме. Когда расшариваемый файл создаёшь сам, то доступ к нему не нужен. Но если переустановить приложение доступ к нему опять потребуется. К каждому файлу система привязывает приложение, поэтому когда запрашиваешь доступ, его может не оказаться. Особо беспокоиться не нужно, это сложно отследить, поэтому лучше просто сразу запрашивать пермишен.

Media Content, SAF и Datasets относятся к Shared Storage (ShS). При удалении приложения расшаренные данные не удаляются. Это полезно, если не хочется потерять нужный контент.

Хотя даже при наличии SS можно дать доступ к своим файлам по определённой технологии через FileProvider можно указать возможность получения доступа к своим файлам из другого приложения. Это нормально, потому что файлы расшаривает сам разработчик.

Также добавилась фича если приложение не использовалось несколько месяцев, то снимаются все пермишены и доступы к системным элементам. По best practice разрешение запрашивается по требованию, поэтому мы просто перед выполнением какого-либо действия проверяем, есть ли у нас пермишены. Если нет, то запрашиваем.

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

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

Перейдём к практике.

Переход на новую версию

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

Для этого выделили в общий интерфейс работу с файлами, реализация которого зависела от версии API.

interface FilesManipulator {    fun createVideoFile(fileName: String, copy: Copier): Uri    fun createImageFile(fileName: String, copy: Copier): Uri    fun createFile(fileName: String, copy: Copier): Uri    fun getPath(uri: Uri): String    fun deleteFile(uri: Uri)}

FilesManipulator представляет собой интерфейс, который знает, как работать с файлами и предоставляет разработчику API для записи информации в файл. Copier это интерфейс, который разработчик должен реализовать, и в который передаётся поток вывода. Грубо говоря, мы не заботимся о том, как создаются файлы, мы работаем только с потоком вывода. Под капотом до 10-й версии Android в FilesManipulator происходит работа с File API, после 10-й (и включая её) MediaStore API.

Рассмотрим на примере сохранения картинки.

fun getContentValuesForImageCreating(fileName: String): ContentValues {    return ContentValues().apply {        put(MediaStore.Images.Media.DISPLAY_NAME, fileName)        put(MediaStore.Images.Media.IS_PENDING, FILE_WRITING_IN_PENDING)        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + File.separator + appFolderName)    }}fun createImageFile(fileName: String, copy: Copier): Uri {    val contentUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)    val contentValues = getContentValuesForImageCreating(fileName)    val uri = contentResolver.insert(contentUri, contentValues)         ?: throw IllegalStateException("New image file insert error")    downloadContent(uri, copy)    return uri}fun downloadContent(uri: Uri, copy: Copier) {    try {        contentResolver.openFileDescriptor(uri, FILE_WRITE_MODE)                .use { pfd ->                    if (pfd == null) {                        throw IllegalStateException("Got nullable file descriptor")                    }                    copy.copyTo(FileOutputStream(pfd.fileDescriptor))                }        contentResolver.update(uri, getWriteDoneContentValues(), null, null)    } catch (e: Throwable) {        deleteFile(uri)        throw e    }}fun getWriteDoneContentValues(): ContentValues {    return ContentValues().apply {        put(MediaStore.Images.Media.IS_PENDING, FILE_WRITING_DONE)    }}

Так как операция сохранения медиафайлов достаточно длительная, то целесообразно использовать MediaStore.Images.Media.IS_PENDING, которая при установлении значения 0 не дает видеть файл приложениям, отличного от текущего.

По сути, вся работа с файлами реализована через эти классы. Шаринг в другие приложения автоматически сохраняют медиа в память устройства и последующая работа с URI уже происходит по новому пути. Но есть такие SDK, которые ещё не успели перестроиться под новые реалии и до сих пор используют File API для проверки медиа. В этом случае используем кеш из External Storage и при необходимости провайдим доступ к файлу через FileProvider API.

Помимо ограничений с памятью в приложениях, таргетированных на 30-ю версию API, появилось ограничение на видимость приложения. Так как iFunny использует шаринг во множество приложений, то данная функциональность была сломана полностью. К счастью, достаточно добавить в манифест query, открывающую область видимости к приложению, и можно будет также полноценно использовать SDK.

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

<manifest  ><queries><intent>    <action android:name="android.intent.action.SENDTO" />    <data android:scheme="smsto, mailto" /></intent>    <package android:name="com.twitter.android" />    <package android:name="com.snapchat.android" />    <package android:name="com.whatsapp" />    <package android:name="com.facebook.katana" />    <package android:name="com.instagram.android" />    <package android:name="com.facebook.orca" />    <package android:name="com.discord" />    <package android:name="com.linkedin.android" /></queries></manifest>

После проверок запуска UI-тестов на девайсах с версиями API 29-30 было выявлено, что они также перестали корректно отрабатываться.

Первоначально в LogCat обнаружил, что приложение не может приконнектиться к процессу Orchestrator и выдает ошибку java.lang.RuntimeException: Cannot connect to androidx.test.orchestrator.OrchestratorService.

Эта проблема из разряда видимости других приложений, поэтому достаточно было добавить строку <package android:name="androidx.test.orchestrator" /> .

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

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

Так как нам нужно использовать этот пермишен только для тестов, то нам условия подходят. Поэтому я быстренько написал свой ShellCommandExecutor, который выполняет команду adb shell appops set --uid PACKAGE_NAME MANAGE_EXTERNAL_STORAGE allow на создании раннера тестов.

На Android 11 тесты удачно запустились и стали проходить без ошибок.

После попытки запуска на 10-й версии Android обнаружил, что отчет Allure также перестал сохраняться в память девайса. Посмотрев issue Allure, обнаружил, что проблема известная, как и с 11-й версией. Достаточно выполнить команду adb shell appops set --uid PACKAGE_NAME LEGACY_STORAGE allow. Сказано, сделано.

Запустил тесты всё еще не происходит сохранения в память отчёта. Тогда я обнаружил, что в манифесте WRITE_EXTERNAL_STORAGE ограничен верхней планкой до 28 версии API, то есть запрашивая работу памятью мы не предоставили все разрешения. После изменения верхней планки (конечно, для варианта debug) и запроса пермишена на запись тесты удачно запустились и отчёт Allure сохранился в память устройства.

Добавлены следующие определения пермишенов для debug-сборки.

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /><uses-permission    android:name="android.permission.WRITE_EXTERNAL_STORAGE"    android:maxSdkVersion="29"    tools:node="replace" />

После всех вышеописанных манипуляций с приложением, можно спокойно устанавливать targetSdkVersion 30, загружать в Google Play и не беспокоиться про дедлайн, после которого загружать приложения версией ниже станет невозможно.

Подробнее..

Небезопасный сервис про безопасность

02.06.2021 10:16:01 | Автор: admin

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

Есть у меня и своя история провала - о сомнительных решениях, которые привели к утечке персональных данных пользователей. Сегодня эти решения вызывают недоумение, но пять лет назад нашей неискушенной команде они казались приемлемыми.

Мы делали продукт о безопасности. Эдакая народная карта противоправных действий, на которой пользователи могли отметить подозрительные события и подписаться на уведомления в интересующих районах.

Продукт включал в себя приложения Android/iOS, фронт и бэк. Я писала мобилки, двое моих коллег занимались фронтом и бэком соответственно.

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

Утро перед пресс-конференцией. Заблаговременно выпущены релизы в AppStore и Google Play, фронт и бэк задеплоены. Боевая база данных потихоньку набирает пользователей - наша команда, тестировщики, представители заказчика и случайные люди, которые сами нашли продукт, хотя реклама еще не давалась.

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

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

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

Мы переглядывались круглыми глазами и не понимали, как такое могло произойти. Опытные разработчики среди читателей уже наверняка догадываются, где мы налажали проектируя базу и API, чтобы случилось то, что случилось.

Выстрелили два фактора - целочисленный id у пользователей и вечный токен, который генерировался однозначно из этого id.

Поясню подробнее на примере.

Вася создал учетную запись. База данных присвоила Васе id под номером 1, сервер взял этот id, сгенерировал из него токен и отдал Васе. Вася сохранил токен и ходит с ним на сервер за приватными данными. Теперь мы очистили базу. Аккаунта Васи больше не существует, но токен на его устройстве всё ещё хранится. В сервисе регистрируется первый новый пользователь Аня. База теперь уже Ане выдает id под номером 1, сервер генерирует из этого id такой же токен. В результате у Васи хранится токен Ани, и Вася может делать запросы от имени ничего не подозревающей Ани.

На момент реализации проекта мы знали о том, что можно использовать uuid вместо целочисленных id, что токен должен содержать срок действия и рефреш, но посчитали эти нюансы избыточными.

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

За годы работы в IT я убедилась - любой практический опыт, а тем более опыт ошибок, со временем перерастает в профессиональное чутье. А теоретические знания без практики быстро меркнут, поэтому важно действовать и не бояться оступиться. Люди ошибаются, это нормально. Не нормально - не анализировать свои ошибки.

Подробнее..

Наиболее полное руководство по практическому использованию Web Speech API

27.05.2021 10:19:54 | Автор: admin

В этой статье я хочу поделиться с вами результатами изучения основных возможностей Web Speech API (далее WSA).


Введение


WSA это экспериментальная технология, состоящая из двух интерфейсов: SpeechSynthesis (интерфейс для перевода текста в речь) и SpeechRecognition (интерфейс для распознавания речи).


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


Что касается поддержки, то вот что об этом говорит Can I use:




Как мы видим, SpeechSynthesis поддерживается большинством современных браузеров. К сожалению, поддержка SpeechRecognition оставляет желать лучшего. Я говорю "к сожалению", поскольку SpeechRecognition предоставляет более интересные возможности с точки зрения практического применения в веб-приложениях, в чем вы сами убедитесь после прочтения статьи. Полагаю, такая ситуация обусловлена сложностью технической реализации распознавания речи.


Мы с вами реализуем 4 простых приложения, по 2 на каждый интерфейс:


  1. Плеер для озвучивания текста со всеми возможностями, которые предоставляет SpeechSynthesis, включая выбор языка (голоса)
  2. Страницу с возможностью озвучивания ее текстового содержимого
  3. "Диктофон" для распознавания речи и ее перевода в текст с автоматическим и кастомным форматированием
  4. Одностраничное приложение с голосовым управлением, в том числе, переключение страниц, прокрутка и изменение цветовой темы (или схемы), используемой на сайте

Если вам это интересно, то прошу следовать за мной.


Плеер для озвучивания текста


Наша разметка будет выглядеть следующим образом:


<div id="wrapper">  <h1>Speech Synthesis - Player</h1>  <label    >Text:    <textarea id="textarea">Привет! Как дела?</textarea>  </label>  <label    >Voice:    <select id="select"></select>  </label>  <label    >Volume:    <input id="volume" type="range" min="0" max="1" step="0.1" value="1" />    <span>1</span>  </label>  <label    >Rate:    <input id="rate" type="range" min="0" max="3" step="0.5" value="1" />    <span>1</span>  </label>  <label    >Pitch:    <input id="pitch" type="range" min="0" max="2" step="0.5" value="1" />    <span>1</span>  </label>  <div id="buttons">    <button class="speak">Speak</button>    <button class="cancel">Cancel</button>    <button class="pause">Pause</button>    <button class="resume">Resume</button>  </div></div>

У нас имеется поле для ввода текста (textarea), который мы будем озвучивать; выпадающий список (select) для выбора голоса, которым будет озвучиваться текст; инпуты-диапазоны для установки громкости (volume), скорости (rate) воспроизведения и высоты голоса (pitch), а также кнопки для запуска (speak), отмены (cancel), приостановления (pause) и продолжения (resume) воспроизведения. В общем, ничего особенного.


Обратите внимание на атрибуты инпутов min, max, step и value. Значения данных атрибутов взяты из черновика спецификации, но некоторые из них отданы на откуп производителям браузеров, т.е. зависят от конкретной реализации. Также обратите внимание на наличие почти у всех элементов идентификаторов. Мы будем использовать эти id для прямого доступа к элементам в скрипте в целях сокращения кода, однако в реальных приложениях так лучше не делать во избежание загрязнения глобального пространства имен.


Переходим к JavaScript. Создаем экземпляр SpeechSynthesisUtterance ("utterance" можно перевести как "выражение текста словами"):


const U = new SpeechSynthesisUtterance()

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


let voices = speechSynthesis.getVoices()

При вызове метода getVoices() возникает событие voiceschanged. Обрабатываем это событие для "настоящего" получения голосов и формирования выпадающего списка:


speechSynthesis.onvoiceschanged = () => {  voices = speechSynthesis.getVoices()  populateVoices(voices)}

Объект голоса (назовем его так) выглядит следующим образом:


0: SpeechSynthesisVoice  default: true  lang: "de-DE"  localService: false  name: "Google Deutsch"  voiceURI: "Google Deutsch"

Реализуем функцию формирования выпадающего списка:


function populateVoices(voices) {  // Перебираем голоса и создаем элемент `option` для каждого  // Текстовым содержимым `option` является название голоса, а значением - индекс голоса в массиве голосов  voices.forEach((voice, index) => {    select.options[index] = new Option(voice.name, index)  })  // Делаем голосом по умолчанию `Google русский`  // Он нравится мне больше, чем голос от `Microsoft`  const defaultVoiceIndex = voices.findIndex(    (voice) => voice.name === 'Google русский'  )  select.selectedIndex = defaultVoiceIndex  // После этого инициализируем обработчики событий  initializeHandlers()}

Функция инициализации обработчиков событий выглядит так:


function initializeHandlers() {  // Ниже перечислены почти все события, которые возникают при работе с рассматриваемым интерфейсом  U.onstart = () => console.log('Started')  U.onend = () => console.log('Finished')  U.onerror = (err) => console.error(err)  // Мне не удалось добиться возникновения этих событий  U.onpause = () => console.log('Paused')  U.onresume = () => console.log('Resumed')  // Обработка изменения настроек  wrapper.onchange = ({ target }) => {    if (target.type !== 'range') return    handleChange(target)  }  // Обработка нажатия кнопок  buttons.addEventListener('click', ({ target: { className } }) => {    // SpeechSynthesis предоставляет таким методы как `speak()`, `cancel()`, `pause()` и `resume()`    // Вызов метода `speak()` требует предварительной подготовки    // Кроме того, мы проверяем наличие текста в очереди на озвучивание с помощью атрибута `speaking`    // Есть еще два атрибута: `pending` и `paused`, но они не всегда возвращают корректные значения    switch (className) {      case 'speak':        if (!speechSynthesis.speaking) {          convertTextToSpeech()        }        break      case 'cancel':        return speechSynthesis.cancel()      case 'pause':        return speechSynthesis.pause()      case 'resume':        return speechSynthesis.resume()      default:        return    }  })}

Обработка изменения настроек:


function handleChange(el) {  el.nextElementSibling.textContent = el.value}

Функция преобразования текста в речь:


function convertTextToSpeech() {  // Получаем текст  const trimmed = textarea.value.trim()  if (!trimmed) return  // Передаем его экземпляру `SpeechSynthesisUtterance`  U.text = trimmed  // Получаем выбранный голос  const voice = voices[select.value]  // Передаем голос и другие настройки экземпляру  U.voice = voice  // язык  U.lang = voice.lang  // громкость  U.volume = volume.value  // скорость  U.rate = rate.value  // высота голоса  U.pitch = pitch.value  // Запускаем озвучивание!  speechSynthesis.speak(U)}

После установки всех настроек наш экземпляр SpeechSynthesisUtterance выглядит следующим образом:


SpeechSynthesisUtterance  lang: "ru-RU"  onboundary: null  onend: () => console.log('Finished')  onerror: (err) => console.error(err)  onmark: null  onpause: () => console.log('Paused')  onresume: () => console.log('Resumed')  onstart: () => console.log('Started')  pitch: 1  rate: 1  text: "Привет! Как дела?"  voice: SpeechSynthesisVoice { voiceURI: "Google русский", name: "Google русский", lang: "ru-RU", localService: false, default: false }  volume: 1

Уже в процессе написания статьи я решил добавить управление с помощью клавиатуры:


window.onkeydown = ({ key }) => {  switch (key.toLowerCase()) {    case 's':      if (!speechSynthesis.speaking) {        convertTextToSpeech()      }      break    case 'c':      return speechSynthesis.cancel()    case 'p':      return speechSynthesis.pause()    case 'r':      return speechSynthesis.resume()    default:      return  }}

Поиграть с кодом можно здесь:



Страница с возможность озвучивания текстового содержимого


Что касается SpeechSynthesis, то с точки зрения практического использования данного интерфейса, сложно что-то придумать, кроме создания страниц с возможностью озвучивания размещенного на них текста. Этим мы и займемся.


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


Наша разметка будет выглядеть так:


<div id="wrapper">  <h1>Speech Synthesis - Page Reader</h1>  <div>    <button class="play" tabindex="1"></button>    <p>      JavaScript  мультипарадигменный язык программирования. Поддерживает      объектно-ориентированный, императивный и функциональный стили.      Является реализацией спецификации ECMAScript (стандарт ECMA-262).    </p>  </div>  <div>    <button class="play" tabindex="2"></button>    <p>      JavaScript обычно используется как встраиваемый язык для программного      доступа к объектам приложений. Наиболее широкое применение находит в      браузерах как язык сценариев для придания интерактивности      веб-страницам.    </p>  </div>  <div>    <button class="play" tabindex="3"></button>    <p>      Основные архитектурные черты: динамическая типизация, слабая      типизация, автоматическое управление памятью, прототипное      программирование, функции как объекты первого класса.    </p>  </div></div>

У нас имеется три блока (div) с кнопками для озвучивания текста (play) и, собственно, текстом, который будет озвучиваться (первые три абзаца статьи по JavaScript из Википедии). Обратите внимание, что я добавил кнопкам атрибут tabindex, чтобы переключаться между ними с помощью tab и нажимать с помощью space. Однако, имейте ввиду, что использовать атрибут tabindex не рекомендуется по причине того, что браузер использует переключение фокуса с помощью tab для повышения доступности.


С вашего позволения, я приведу код скрипта целиком:


// Это часть должна быть вам знакома по предыдущему примеруlet voices = speechSynthesis.getVoices()let defaultVoicespeechSynthesis.onvoiceschanged = () => {  voices = speechSynthesis.getVoices()  defaultVoice = voices.find((voice) => voice.name === 'Google русский')  wrapper.addEventListener('click', handleClick)  window.addEventListener('keydown', handleKeydown)}const PLAY = 'play'const PAUSE = 'pause'const RESUME = 'resume'function handleClick({ target }) {  switch (target.className) {    case PLAY:      // При нажатии кнопки `play` в момент озвучивания другого текста,      // нам нужно прекратить текущее озвучивание перед началом нового      speechSynthesis.cancel()      const { textContent } = target.nextElementSibling      // Об этой части см. ниже      textContent.split('.').forEach((text) => {        const trimmed = text.trim()        if (trimmed) {          const U = getUtterance(target, text)          speechSynthesis.speak(U)        }      })      break    case PAUSE:      // CSS-класс кнопки отвечает за отображаемую рядом с ней иконку      // ``- ожидание/стоп, `` - озвучивание/воспроизведение, `` - пауза      // иконка `` используется в качестве индикатора кнопки, находящейся в фокусе      target.className = RESUME      speechSynthesis.pause()      break    case RESUME:      target.className = PAUSE      speechSynthesis.resume()      break    default:      break  }}// При нажатии `escape` прекращаем озвучивание текста// Можете добавить свои контролыfunction handleKeydown({ code }) {  switch (code) {    case 'Escape':      return speechSynthesis.cancel()    default:      break  }}function getUtterance(target, text) {  const U = new SpeechSynthesisUtterance(text)  U.voice = defaultVoice  U.lang = defaultVoice.lang  U.volume = 1  U.rate = 1  U.pitch = 1  // Я специально не стал скрывать смену иконок при начале/окончании воспроизведения  U.onstart = () => {    console.log('Started')    target.className = PAUSE  }  U.onend = () => {    console.log('Finished')    target.className = PLAY  }  U.onerror = (err) => console.error(err)  return U}

Тонкий момент в приведенном коде это преобразование озвучиваемого текста в массив предложений (разделителем является точка (.)), перебор массива, и воспроизведение каждого предложения по отдельности (точнее, помещение всех предложений одного за другим в очередь на озвучивание) textContent.split('.').forEach(...). Причина такого решения заключается в том, что озвучивание длинного текста тихо обрывается примерно на 220 символе (в Chrome). Черновик спецификации для такого случае предусматривает специальную ошибку text-to-long (текст слишком длинный), но данной ошибки не возникает, озвучивание просто резко прекращается, причем, для восстановления работы SpeechSynthesis зачастую приходится перезагружать вкладку (при запущенном сервере для разработки даже это не всегда срабатывает). Возможно, вам удастся найти другое решение.


Поиграть с кодом можно здесь:



"Диктофон" для распознавания речи


После того, как мы обстоятельно рассмотрели SpeechSynthesis, можно переходить ко второму, немного более сложному, но, вместе с тем, и более интересному интерфейсу, входящему в состав WSA SpeechRecoginition.


Вот наша начальная разметка:


<div id="wrapper">  <h1>Speech Recognition - Dictaphone</h1>  <textarea id="final_text" cols="30" rows="10"></textarea>  <input type="text" id="interim_text" />  <div id="buttons">    <button class="start">Старт</button>    <button class="stop">Стоп</button>    <button class="abort">Сброс</button>    <button class="copy">Копия</button>    <button class="clear">Очистить</button>  </div></div>

У нас имеется поле для вставки финального (распознанного) текста (final_text) и поле для вставки промежуточного (находящегося в процессе распознавания) текста (interim_text), а также панель управления (buttons). Выбор элементов textarea и input для хранения текста обусловлен как вариативностью промежуточных результатов, которые меняются на лету, так и необходимостью внесения небольших правок в распознанный текст, что связано с естественным несовершенством перевода устной речи в текст. Стоит отметить, что, в целом, Chrome очень неплохо справляется с задачей распознавания речи и автоматическим форматированием распознанного текста.


Кроме кнопок для управления распознаванием речи (start, stop и abort), мы реализуем возможность копирования распознанного текста в буфер обмена с помощью Clipboard API и очистки соответствующего поля.


Начнем с определения переменных для финального текста и индикатора распознавания:


let final_transcript = ''let recognizing = false

Далее, создаем и настраиваем экземпляр SpeechRecognition:


// Напоминаю, что `WSA`, в целом, и, особенно, `SpeechRecognition` являются экпериментальнымиconst speechRecognition =  window.SpeechRecognition || window.webkitSpeechRecognition// Создаем экземпляр `SpeechRecognition`const recognition = new speechRecognition()// Свойство `continuous` определяет, продолжается ли распознавание речи после получения первого финального результатаrecognition.continuous = true// обработка промежуточных результатовrecognition.interimResults = true// максимальное количество альтернатив распознанного словаrecognition.maxAlternatives = 3// языкrecognition.lang = 'ru-RU'

Добавляем обработчики событий запуска, ошибки и окончания распознавания речи:


recognition.onstart = () => {  console.log('Распознавание голоса запущено')}recognition.onerror = ({ error }) => {  console.error(error)}recognition.onend = () => {  console.log('Распознавание голоса закончено')  // Снова запускаем распознавание, если индикатор имеет значение `true`  if (!recognizing) return  recognition.start()}

SpeechRecognition хорошо справляется с распознаванием слов, фраз и целых предложений, он даже выполняет вполне уместную "капитализацию" строки. Но он не "понимает" знаков препинания. Для того, чтобы помочь ему в этом определим словарь в виде объекта:


const DICTIONARY = {  точка: '.',  запятая: ',',  вопрос: '?',  восклицание: '!',  двоеточие: ':',  тире: '-',  абзац: '\n',  отступ: '\t'}

Предполагается, что для решение задач, связанных с форматированием, будут использоваться специальные объекты SpeechGrammar и SpeechGrammarList, а также специальный синтаксис JSpeech Grammar Format, однако, в данной статье мы ограничимся обычным объектом.


Вы можете добавить в словарь любые пары ключ/значение, которые посчитаете нужными. Обратите внимание на то, что все ключи в нашем словаре представлены одним словом. Дело в том, что рассматриваемый интерфейс плохо справляется с ключами, которые состоят более чем из одного слова, например, "вопросительный знак", "восклицательный знак" и т.п. Я понимаю, что пара вопрос: '?' не лучшее решение, но для примера сойдет.


Также нам потребуются две функции для редактирования промежуточного и финального результатов:


function editInterim(s) {  return s    .split(' ')    .map((word) => {      word = word.trim()      return DICTIONARY[word.toLowerCase()] ? DICTIONARY[word.toLowerCase()] : word    })    .join(' ')}function editFinal(s) {  return s.replace(/\s{1,}([\.+,?!:-])/g, '$1')}

Функция editInterim() разбивает фразу на массив слов, перебирает слова, удаляет пробелы в начале и конце слова и заменяет символом из словаря при совпадении. Обратите внимание, что мы приводим слово в нижний регистр только при поиске совпадения. Если мы сделаем это в строке word = word.trim(), то нивелируем автоматическую "капитализацию" строки, выполняемую браузером.


Функция editFinal() удаляет пробел перед каждым из указанных символов мы разделяем слова пробелами при объединении в функции editInterim(). Следует отметить, что по умолчанию каждое распознанное слово с двух сторон обрамляется пробелами.


Итак, событие, которое интересует нас больше всего это result. Реализуем его обработку:


recognition.onresult = (e) => {  // Промежуточные результаты обновляются на каждом цикле распознавания  let interim_transcript = ''  // Перебираем результаты с того места, на котором остановились в прошлый раз  for (let i = e.resultIndex; i < e.results.length; i++) {    // Атрибут `isFinal` является индикатором того, что речь закончилась    if (e.results[i].isFinal) {      // Редактируем промежуточные результаты      const result = editInterim(e.results[i][0].transcript)      // и добавляем их к финальному результату      final_transcript += result    } else {      // В противном случае, записываем распознанные слова в промежуточный результат      interim_transcript += e.results[i][0].transcript    }  }   // Записываем промежуточные результаты в `input`  interim_text.value = interim_transcript  // Редактируем финальный результат  final_transcript = editFinal(final_transcript)  // и записываем его в `textarea`  final_text.value = final_transcript}

Событие распознавания выглядит следующим образом:


SpeechRecognitionEvent  bubbles: false  cancelBubble: false  cancelable: false  composed: false  currentTarget: SpeechRecognition {grammars: SpeechGrammarList, lang: "ru-RU", continuous: true, interimResults: true, maxAlternatives: 3, }  defaultPrevented: false  emma: null  eventPhase: 0  interpretation: null  isTrusted: true  path: []  resultIndex: 1  // здесь нас интересуют только результаты  results: SpeechRecognitionResultList {0: SpeechRecognitionResult, 1: SpeechRecognitionResult, length: 2}  returnValue: true  srcElement: SpeechRecognition {grammars: SpeechGrammarList, lang: "ru-RU", continuous: true, interimResults: true, maxAlternatives: 3, }  target: SpeechRecognition {grammars: SpeechGrammarList, lang: "ru-RU", continuous: true, interimResults: true, maxAlternatives: 3, }  timeStamp: 59862.61999979615  type: "result"

Результаты (SpeechRecognitionResultList) выглядят так:


results: SpeechRecognitionResultList  0: SpeechRecognitionResult    0: SpeechRecognitionAlternative      confidence: 0.7990190982818604      transcript: "привет"    isFinal: true    length: 1  length: 1

Вот почему для получения результата мы обращаемся к e.results[i][0].transcript. На самом деле, поскольку мы указали maxAlternatives = 3, во всех результатах будет представлено по три SpeechRecognitionAlternative. Первым (с индексом 0) всегда будет наиболее подходящий результат с точки зрения браузера.


Все, что нам осталось сделать, это реализовать обработку нажатия кнопок:


buttons.onclick = ({ target }) => {  switch (target.className) {    case 'start':      // Обнуляем переменную для финального результата      final_transcript = ''      // Запускаем распознавание      recognition.start()      // Устанавливаем индикатор распознавания в значение `true`      recognizing = true      // Очищаем `textarea`      final_text.value = ''      // Очищаем `input`      interim_text.value = ''      break    case 'stop':      // Останавливаем распознавание      recognition.stop()      // Устанавливаем значение индикатора распознавания в значение `false`      recognizing = false      break    case 'abort':      // Прекращаем распознавание      recognition.abort()      recognizing = false      break    case 'copy':      // Копируем текст из `textarea` в буфер обмена      navigator.clipboard.writeText(final_text.value)      // Сообщаем об этом пользователю      target.textContent = 'Готово'      const timerId = setTimeout(() => {        target.textContent = 'Копия'        clearTimeout(timerId)      }, 3000)      break    case 'clear':      // Обнуляем переменную для финального результата      final_transcript = ''      // Очищаем `textarea`      final_text.value = ''      break    default:      break  }}

Тестируем. Нажимаем на кнопку "Старт", дожидаемся, когда красная точка рядом с фавиконкой перестанет мигать (о готовности к распознаванию можно сообщать пользователю через обработку события speechstart), произносим фразу (произношение должно быть максимально четким и ясным), например, "привет точка как дела вопрос". В input на какое-то время появляется "Привет точка Как дела вопрос", затем этот промежуточный результат редактируется и переносится в textarea в виде "Привет. Как дела?". Отлично, наша машина нас понимает.


Поиграть с кодом можно здесь:



Подумал о том, что было бы неплохо реализовать функцию для удаления последнего распознанного слова на тот случай, если результат распознавания получился некорректным. Для этого потребуется пара удалить: () => removeLastWord() (если добавить эту пару в словарь, то потребуется дополнительная проверка typeof DICTIONARY[word] === 'function') и примерно такая операция:


function removeLastWord() {  const oldStr = final_text.value  const newStr = oldStr.substring(0, oldStr.lastIndexOf(' '))  final_text.value = newStr}

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


Одностраничное приложение с голосовым управлением


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


Мы будем генерировать страницы программным способом, поэтому разметка нам не потребуется.


Создаем директорию pages с тремя файлами:


// home.jsexport default /*html*/ `<div id="wrapper">  <div>Section 1</div>  <div>Section 2</div>  <div>Section 3</div>  <div>Section 4</div>  <div>Section 5</div>  <div>Section 6</div>  <div>Section 7</div>  <div>Section 8</div>  <div>Section 9</div></div>`// product.jsexport default /*html*/ `<h1>This is the Product Page</h1>`// about.jsexport default /*html*/ `<h1>This is the About Page</h1>`

Основной скрипт начинается с импорта страниц и генерации домашней страницы:


import HomePage from './pages/home.js'import ProductPage from './pages/product.js'import AboutPage from './pages/about.js'const { body } = documentbody.innerHTML = HomePage

Нам потребуются константы для прокрутки страницы и объект с операциями:


// Константы для прокруткиconst DOWN = 'down'const UP = 'up'const RIGHT = 'right'const LEFT = 'left'// Операцииconst ACTIONS = {  // операции переключения страниц  home: () => (body.innerHTML = HomePage),  product: () => (body.innerHTML = ProductPage),  about: () => (body.innerHTML = AboutPage),  // операции прокрутки  down: () => scroll(DOWN),  up: () => scroll(UP),  left: () => scroll(LEFT),  right: () => scroll(RIGHT),  // операции переключения цветовой темы  light: () => body.removeAttribute('class'),  dark: () => (body.className = 'dark')}

Далее следуют настройки SpeechRecognition и обработчики событий начала, ошибки и окончания распознавания, аналогичные рассмотренным в предыдущем разделе (кроме настройки языка для управления страницей мы будем использовать американский английский: recognition.lang = 'en-US').


Обработка события result:


recognition.onresult = (e) => {  // Перебираем результаты  for (let i = e.resultIndex; i < e.results.length; i++) {    if (e.results[i].isFinal) {      const result = e.results[i][0].transcript.toLowerCase()      // Выводим слова в консоль, чтобы убедиться в корректности распознавания (как выяснилось, слово `product` очень плохо распознается, возможно, у меня проблемы с его правильным произношением)      console.log(result)      // Преобразуем фразу в массив, перебираем слова и выполняем соответствующие операции      result        .split(' ')        .forEach((word) => {          word = word.trim().toLowerCase()          // ACTION[word] - это функция          return ACTIONS[word] ? ACTIONS[word]() : ''        })    }  }}

Функция для выполнения прокрутки выглядит следующим образом:


function scroll(direction) {  let newPosition  switch (direction) {    case DOWN:      newPosition = scrollY + innerHeight      break    case UP:      newPosition = scrollY - innerHeight      break    case RIGHT:      newPosition = scrollX + innerWidth      break    case LEFT:      newPosition = scrollX - innerWidth      break    default:      break  }  if (direction === DOWN || direction === UP) {    scrollTo({      top: newPosition,      behavior: 'smooth'    })  } else {    scrollTo({      left: newPosition,      behavior: 'smooth'    })  }}

Наконец, добавляем обработку нажатия клавиш клавиатуры:


window.addEventListener('keydown', (e) => {  e.preventDefault()  switch (e.code) {    // Нажатие пробела запускает распознавание    case 'Space':      recognition.start()      recognizing = true      break    // Нажатие `escape` останавливает распознавание    case 'Escape':      recognition.stop()      recognizing = false    default:      break  }})

Проверяем работоспособность приложения. Нажимаем пробел, дожидаемся готовности браузера к распознаванию речи, произносим следующие фразы:


  • home, product, about переключение страниц
  • dark, light переключение цветовой темы
  • down, up, left, right выполнение прокрутки к соответствующему разделу страницы (имеет видимый эффект только на домашней странице, иногда приходится добавлять слово scroll, например, scroll down)

Поиграть с кодом можно здесь:



Заключение


Как мы видим, WSA существенно расширяет арсенал JavaScript, связанный с добавлением динамики, "оживлением" веб-страниц. Сложно сказать, насколько широкое распространение получит данная технология и насколько активно она будет использоваться. Несмотря на то, что WSA это технология общего назначения, очевидно, что она внесет серьезный вклад, в первую очередь, в развитие так называемого доступного Интернета.


Мы с вами рассмотрели все основные возможности, предоставляемые WSA. Единственное, что мы не сделали, это не реализовали совместное использование SpeechSynthesis и SpeechRecognition. Первое, что приходит на ум это голосовой помощник, который реагирует на ключевые слова, встречающиеся во фразе, произнесенной пользователем, отвечая определенными шаблонами и задавая уточняющие вопросы, при необходимости. Более простой вариант: волшебный шар задаешь любой вопрос, предполагающий однозначный ответ, программа какое-то время "думает" и отвечает да, нет или, например, не знаю, спросите позже. Пусть это будет вашим домашним заданием, все необходимые знания для этого у вас имеются. Обязательно поделитесь результатом в комментариях.




Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Настраиваем нагрузочное тестирование с Artillery.io

30.05.2021 02:08:09 | Автор: admin

Не так давно я начала писать тесты для нагрузочного тестирования с помощью инструмента artillery. Раньше писала тесты для K6, поэтому расскажу о преимуществах artillery по сравнению с К6, а также напишу пошаговый сценарий для настройки проекта с нуля.

Шаг 1. Установка

npm install g artillery@latest

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

npm i artillery-plugin-expect

Шаг 2. Создаем config

Target url, environment

Файл конфигурации нагрузочного тестирования это все, что нам нужно сделать для запуска тестов:

config:  target: "https://yourapp.com/api"

Если вам нужно использовать разные environment в ваших тестах, они прописываются сразу после target url:

config:  target: "https://bestapp.com/api"  environments:    dev:      target: "https://bestapp.dev.com/api"    qa:      target: "https://bestapp.qa.com/api"

Фазы тестирования

Инструмент artillery позволяет использовать несколько вариантов последовательных нагрузок на ваше приложение. Фаза тестирования состоит из: duration время одной фазы; arrivalRate количество пользователей, добавляемых каждую секунду; ramptTo до какого количества пользователей в секунду будет нарастать нагрузка; name - имя для обозначения ваших фаз.

phases:    - duration: 30      arrivalRate: 1      rampTo: 20      name: test1

Если у вас только один target url, то фазы нагрузочного тестирования размещаются сразу после него, если несколько environment, то добавляйте фазы в каждую переменную окружения.

Плагины

Добавляем плагин для ожидаемого результата:

plugins:    expect: {}

Авторизация

Инструмент поддерживает базовую авторизацию с помощью username и password:

- get:    url: "/auth"    auth:      user: username      pass: password

Также можно вставить свой авторизационный header:

- post:    url: "/auth"    headers:      Authorization: Basic secretKey

Шаг 3. Первый тест

Все тесты пишутся в разделе scenarios. Каждый сценарий имеет название, метод (GET, POST, PUT, DELETE и др.), url для каждого endpoint, body в формате json, а также необходимые проверки.

Пример простого теста:

scenarios:    - name: "My first test"       flow:         - post:            url: "/endpoint1"            json:              id: value        expect:           - statusCode: 200          - contentType: json           - equals:              - respMessage: "OK"

В equals добавляются все проверки, основанные на ответе вашего бэкэнда.

Шаг 4. Запуск тестов

Запуск нагрузочных тестов очень простой:

artillery run yourConfig.yml

Эту строку можно добавить в раздел scripts в package.json для быстрого запуска тестов.

-e <env> - запускает тесты для различных environment,
--quiet убирает все полученные результаты из консоли,
-o result.json добавляет результаты тестирования в файл отчета.

Создание html отчета

Отчет создается буквально в одну строку:

artillery report result.json

Сравнение с K6

Главное преимущество artillery это очень легкая настройка первого запуска тестов. Не надо переписывать тесты в формате, определяемом для K6, не надо писать bat-файл для того, чтобы запускать несколько тестов и сохранять результаты в отдельную папку. Достаточно сконфигурировать один файл.

Artillery работает с node.js, поэтому скрипт для запуска можно вставить в package.json.

Можно импортировать переменные из cvs-файла, брать переменные из полученного результата.

Отчет с графиками и диаграммами добавляется в одну команду и входит в бесплатную версию artillery.

Подробнее..

Идеальный инструмент для создания прогрессивных веб-приложений или Все, что вы хотели знать о Workbox. Часть 2

21.06.2021 12:17:59 | Автор: admin

image


Что такое Workbox?


Workbox (далее WB) это библиотека (точнее, набор библиотек), основной целью которой является "предоставление лучших практик и избавление от шаблонного кода при работе с сервис-воркерами" (далее СВ).


Если вы впервые слышите о СВ, то перед изучением данного руководства настоятельно рекомендуется ознакомиться со следующими материалами:



WB предоставляет следующие возможности:


  • предварительное кэширование
  • кэширование во время выполнения
  • стратегии (кэширования)
  • обработка (перехват сетевых) запросов
  • фоновая синхронизация
  • помощь в отладке

Это вторая часть руководства. Вот ссылка на первую часть.


Модули, предоставляемые WB


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


  • workbox-background-sync: фоновая синхронизация, позволяющая выполнять сетевые запросы в режиме офлайн
  • workbox-broadcast-update: отправка уведомлений об обновлении кэша (через Broadcast Channel API)
  • workbox-cacheable-response: фильтрация кэшируемых запросов на основе статус-кодов или заголовков ответов
  • workbox-core: изменение уровня логгирования и названий кэша. Содержит общий код, используемый другими модулями
  • workbox-expiration: установка лимита записей в кэше и времени жизни сохраненных ресурсов
  • workbox-google-analytics: фиксация действий пользователей на странице в режиме офлайн
  • workbox-navigation-preload: предварительная загрузка запросов, связанных с навигацией
  • workbox-precaching: предварительное кэширование ресурсов и управление их обновлением
  • workbox-range-request: поддержка частичных ответов
  • workbox-recipes: общие паттерны использования WB
  • workbox-routing: обработка запросов с помощью встроенных стратегий кэширования или колбэков
  • workbox-strategies: стратегии кэширования во время выполнения, как правило, используемые совместно с workbox-routing
  • workbox-streams: формирование ответа на основе нескольких источников потоковой передачи данных
  • workbox-window: регистрация, управление обновлением и обработка событий жизненного цикла СВ

workbox-background-sync


Иногда запрос на отправку данных на сервер проваливается. Это может произойти из-за потери соединения или из-за "упавшего" сервера. В любом случае, было бы здорово иметь возможность автоматически повторять выполнение такого запроса.


Новый BackgroundSync API отличное решение для такой ситуации. Когда СВ обнаруживает провалившийся запрос, он может регистрировать возникновение события sync, отправляемого брузером при восстановлении соединения. Данное событие отправляется даже если пользователь вышел из приложения, что делает этот подход гораздо более эффективным, чем традиционные способы повторного выполнения провалившихся запросов.


Браузеры, поддерживающие BackgroundSync API, автоматически отправляют повторный запрос от вашего имени через интервал, определяемый браузером. В браузерах, которые не поддерживают указанную технологию, рассматриваемый модуль отправит повторный запрос при очередном запуске СВ.


Базовое использование


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


import { BackgroundSyncPlugin } from 'workbox-background-sync'import { registerRoute } from 'workbox-routing'import { NetworkOnly } from 'workbox-strategies'const bgSyncPlugin = new BackgroundSyncPlugin('myQueueName', {  maxRetentionTime: 24 * 60, // Попытка выполнения повторного запроса будет выполнена в течение 24 часов (в минутах)})registerRoute(  /\/api\/.*\/*.json/,  new NetworkOnly({    plugins: [bgSyncPlugin],  }),  'POST')

Продвинутое использование


Рассматриваемый модуль предоставляет класс Queue, который, после инстанцирования, может использоваться для хранения провалившихся запросов. Такие запросы записываются в IndexedDB и извлекаются из нее при восстановлении соединения.


Создание очереди


import { Queue } from 'workbox-background-sync'const queue = new Queue('myQueueName') // название очереди должно быть уникальным

Название очереди используется как часть названия "тега", который получает register() глобального SyncManager. Оно также используется как название "объектного хранилища" IndexedDB.


Добавление запроса в очередь


import { Queue } from 'workbox-background-sync'const queue = new Queue('myQueueName')self.addEventListener('fetch', (event) => {  // Клонируем запрос для безопасного чтения  // при добавлении в очередь  const promiseChain = fetch(event.request.clone()).catch((err) => {    return queue.pushRequest({ request: event.request })  })  event.waitUntil(promiseChain)})

После добавления в очередь, запрос будет автоматически выполнен повторно при получении СВ события sync (или при следующем запуске СВ в браузерах, которые не поддерживают фоновую синхронизацию).


workbox-cacheable-response


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


Рассматриваемый модуль позволяет определять пригодность ответа для кэширования на основе статус-кода или присутствия заголовка с определенным значением.


Кэширование на основе статус-кода


import { registerRoute } from 'workbox-routing'import { CacheFirst } from 'workbox-strategies'import { CacheableResponsePlugin } from 'workbox-cacheable-response'registerRoute(  ({ url }) =>    url.origin === 'https://example.com' && url.pathname.startsWith('/images/'),  new CacheFirst({    cacheName: 'image-cache',    plugins: [      new CacheableResponsePlugin({        statuses: [0, 200]      })    ]  }))

Данная настройка указывает WB кэшировать любые ответы со статусом 0 или 200 при обработке запросов к https://example.com.


Кэширование на основе заголовка


import { registerRoute } from 'workbox-routing'import { StaleWhileRevalidate } from 'workbox-strategies'import { CacheableResponsePlugin } from 'workbox-cacheable-response'registerRoute(  ({ url }) => url.pathname.startsWith('/path/to/api/'),  new StaleWhileRevalidate({    cacheName: 'api-cache',    plugins: [      new CacheableResponsePlugin({        headers: {          'X-Is-Cacheable': 'true'        }      })    ]  }))

При обработке ответов на запросы к URL, начинающемуся с /path/to/api/, проверяется, присутствует ли в ответе заголовок X-Is-Cacheable (который добавляется сервером). Если заголовок присутствует и имеет значение true, такой ответ кэшируется.


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


Разумеется, указанные техники определения пригодности ответов для кэширования можно комбинировать:


import { registerRoute } from 'workbox-routing'import { StaleWhileRevalidate } from 'workbox-strategies'import { CacheableResponsePlugin } from 'workbox-cacheable-response'registerRoute(  ({ url }) => url.pathname.startsWith('/path/to/api/'),  new StaleWhileRevalidate({    cacheName: 'api-cache',    plugins: [      new CacheableResponsePlugin({        statuses: [200, 404],        headers: {          'X-Is-Cacheable': 'true'        }      })    ]  }))

При использовании встроенной стратегии без явного определения cacheableResponse.CacheableResponsePlugin, для проверки валидности ответа используются следющие критерии:


  • staleWhileRevalidate и networkFirst: ответы со статусом 0 (непрозрачные ответы) и 200 считаются валидными
  • cacheFirst: только ответы со статусом 200 считаются валидными

По умолчанию заголовки ответа для определения его валидности не используются.


Продвинутое использование


Для определения логики кэширования за пределами стратегии можно использовать класс CacheableResponse:


import { CacheableResponse } from 'workbox-cacheable-response'const cacheable = new CacheableResponse({  statuses: [0, 200],  headers: {    'X-Is-Cacheable': 'true'  }})const response = await fetch('/path/to/api')if (cacheable.isResponseCacheable(response)) {  const cache = await caches.open('api-cache')  cache.put(response.url, response)} else {  // Ответ не может быть кэширован}

workbox-expiration


Данный плагин позволяет ограничивать количество ресурсов, сохраняемых в кэше, а также время их хранения.


Ограничение количества записей в кэше


import { registerRoute } from 'workbox-routing'import { CacheFirst } from 'workbox-strategies'import { ExpirationPlugin } from 'workbox-expiration'registerRoute(  ({ request }) => request.destination === 'image',  new CacheFirst({    cacheName: 'image-cache',    plugins: [      new ExpirationPlugin({        // ограничиваем количество записей в кэше        maxEntries: 20      })    ]  }))

При достижении лимита удаляются самые старые записи.


Ограничение времени хранения ресурсов в кэше


import { registerRoute } from 'workbox-routing'import { CacheFirst } from 'workbox-strategies'import { ExpirationPlugin } from 'workbox-expiration'registerRoute(  ({ request }) => request.destination === 'image',  new CacheFirst({    cacheName: 'image-cache',    plugins: [      new ExpirationPlugin({        // ограничиваем время хранения ресурсов в кэше        maxAgeSeconds: 24 * 60 * 60      })    ]  }))

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


Продвинутое использование


Класс CacheExpiration позволяет отделять логику ограничения от других модулей. Для установки ограничений создается экземпляр названного класса:


import { CacheExpiration } from 'workbox-expiration'const cacheName = 'my-cache'const expirationManager = new CacheExpiration(cacheName, {  maxAgeSeconds: 24 * 60 * 60,  maxEntries: 20})

Затем, при обновлении записи в кэше, вызывается метод updateTimestamp() для обновления "возраста" записи.


await openCache.put(request, response)await expirationManager.updateTimestamp(request.url)

Для проверки всех записей в кэше на предмет их соответствия установленным критериям вызывается метод expireEntries():


await expirationManager.expireEntries()

workbox-precaching


СВ позволяет записывать файлы в кэш во время установки. Это называется предварительным кэшированием, поскольку контент кэшируется перед использованием СВ.


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


WB предоставляет простой и понятный API для реализации этого паттерна и эффективной загрузки ресурсов.


При первом запуске приложения workbox-precaching "смотрит" на загружаемые ресурсы, удаляет дубликаты и регистрирует соответствующие события СВ для загрузки и хранения ресурсов. URL, которые содержат информацию о версии (версионную информацию) (например, хэш контента) используются в качестве ключей кэша без дополнительной модификации. К ключам кэша URL, которые не содержат такой информации, добавляется параметр строки запроса, представляющий хэш контента, генерируемый WB во время выполнения.


workbox-precaching делает все это при обработке события install СВ.


При повторном посещении приложения пользователем, при наличии нового СВ с другими предварительно кэшируемыми ресурсами workbox-precaching оценивает новый список и определяет, какие ресурсы являются новыми, а какие нуждаются в обновлении на основе версионной информации. Добавление новых ресурсов или обновление существующих выполняется при обработке следующего события install.


Новый СВ не будет использоваться для ответов на запросы до его активации. В событии activate workbox-precaching определяет кэшированные ресурсы, отсутствующие в новом списке URL, и удаляет их из кэша.


Обработка предварительно кэшированных ответов


Вызов precacheAndRoute() или addRoute() создает маршрутизатор, который определяет совпадения запросов с предварительно кэшированными URL.


В этом маршрутизаторе используется стратегия "сначала кэш".


Порядок вызова названных методов имеет важное значение. Обычно, они вызываются в начале файла с кодом СВ, перед регистрацией и дополнительными маршрутизаторами, определяемыми с помощью registerRoute(). Если вызвать сначала registerRoute(), то любой маршрутизатор, совпавший с входящим запросом, независимо от стратегии, определенной в этом маршрутизаторе, будет использован для формирования ответа.


Список предварительно кэшируемых ресурсов


workbox-precaching ожидает получения массива объектов со свойствами url и revision. Данный массив иногда называют "манифестом предварительного кэширования":


import { precacheAndRoute } from 'workbox-precaching'precacheAndRoute([  { url: '/index.html', revision: '383676' },  { url: '/styles/app.0c9a31.css', revision: null },  { url: '/scripts/app.0d5770.js', revision: null },  // другие записи])

Свойства revision второго и третьего объектов имеют значения null. Это объясняется тем, что версионная информация этих объектов является частью значений их свойств url.


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


Версионирование позволяет рассматриваемому модулю определять необходимость обновления кэшированного ресурса.


Обратите внимание: для генерации списка предварительно кэшируемых ресурсов следует использовать один из встроенных инструментов WB: workbox-build, workbox-webpack-plugin или workbox-cli. Создавать такой список вручную очень плохая идея.


Автоматическая обработка входящих запросов


При поиске совпадения входящего запроса с кэшированным ресурсом workbox-precaching автоматически выполняет некоторые манипуляции с URL.


Например, запрос к / оценивается как запрос к index.html.


Игнорирование параметров строки запроса


По умолчанию игнорируются параметры поиска, которые начинаются с utm_ или точно совпадают с fbclid. Это означает, что запрос к /about.html?utm_campaign=abcd оценивается как запрос к /about.html.


Игнорируемые параметры указываются в настройке ignoreURLParametersMatching:


import { precacheAndRoute } from 'workbox-precaching'precacheAndRoute(  [    { url: '/index.html', revision: '383676' },    { url: '/styles/app.0c9a31.css', revision: null },    { url: '/scripts/app.0d5770.js', revision: null }  ],  {    // Игнорируем все параметры    ignoreURLParametersMatching: [/.*/]  })

Основной файл директории


По умолчанию основным файлом директории считается index.html. Именно поэтому запросы к / оцениваются как запросы к /index.html. Это поведение можно изменить с помощью настройки directoryIndex:


import { precacheAndRoute } from 'workbox-precaching'precacheAndRoute(  [    { url: '/index.html', revision: '383676' },    { url: '/styles/app.0c9a31.css', revision: null },    { url: '/scripts/app.0d5770.js', revision: null },  ],  {    directoryIndex: null  })

"Чистые" URL


По умолчанию к запросу добавляется расширение .html. Например, запрос к /about оценивается как /about.html. Это можно изменить с помощью настройки cleanUrls:


import { precacheAndRoute } from 'workbox-precaching'precacheAndRoute([{ url: '/about.html', revision: 'b79cd4' }], {  cleanUrls: false})

Кастомные манипуляции


Настройка urlManipulation позволяет кастомизировать логику определения совпадений. Эта функция должна возвращать массив возможных совпадений:


import { precacheAndRoute } from 'workbox-precaching'precacheAndRoute(  [    { url: '/index.html', revision: '383676' },    { url: '/styles/app.0c9a31.css', revision: null },    { url: '/scripts/app.0d5770.js', revision: null }  ],  {    urlManipulation: ({ url }) => {      // Логика определения совпадений      return [alteredUrlOption1, alteredUrlOption2]    }  })

workbox-routing


СВ может перехватывать сетевые запросы. Он может отвечать браузеру кэшированным контентом, контентом, полученным из сети, или контентом, генерируемым СВ.


workbox-routing это модуль, позволяющий "связывать" поступающие запросы с функциями, формирующими на них ответы.


При отправке сетевого запроса возникает событие fetch, которое регистрирует СВ для формирования ответа на основе маршрутизаторов и обработчиков.


Обратите внимание на следующее:


  • важно указывать метод запроса. По умолчанию перехватываются только GET-запросы
  • маршрутизаторы должны регистрироваться в правильном порядке. При наличии нескольких "роутов" для одного запроса, сработает только первый из них

Определение совпадений и обработка запросов


В WB "роут" это две функции: функция "определения совпадения" и функция "обработки запроса".


WB предоставляет некоторые утилиты для помощи в реализации названных функций.


Функция определения совпадения принимает ExtendableEvent, Request и объект URL. Возврат истинного значения из этой функции означает совпадение. Например, вот пример определения совпадения с конкретным URL:


const matchCb = ({ url, request, event }) => {  return (url.pathname === '/special/url')}

Функция обработки запроса принимает такие же параметры + аргумент value, который имеет значение, возвращаемое из первой функции:


const handlerCb = async ({ url, request, event, params }) => {  const response = await fetch(request)  const responseBody = await response.text()  return new Response(`${responseBody} <!-- Глядите-ка! Новый контент. -->`, {    headers: response.headers  })}

Обработчик должен возвращать промис, разрешающийся Response.


Регистрация колбэков выглядит следующим образом:


import { registerRoute } from 'workbox-routing'registerRoute(matchCb, handlerCb)

Единственным ограничением является то, что функция определения совпадения должна возвращать истинное значение синхронно. Это связано с тем, что Router должен синхронно обрабатывать событие fetch или передавать его другим обработчикам.


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


import { registerRoute } from 'workbox-routing'import { StaleWhileRevalidate } from 'workbox-strategies'registerRoute(  matchCb,  new StaleWhileRevalidate())

Определение совпадений с помощью регулярного выражения


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


import { registerRoute } from 'workbox-routing'registerRoute(  new RegExp('/styles/.*\\.css'),  handlerCb)

Для запросов из одного источника данная "регулярка" будет регистрировать совпадения для следующих URL:



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



Для решения этой проблемы можно использовать такое регулярное выражение:


new RegExp('https://cdn\\.third-party-site\\.com.*/styles/.*\\.css')

Для регистрации совпадений как с локальными, так и со сторонними URL можно использовать wildcard, но при этом следует проявлять особую осторожность.


Роут для навигации


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


import { createHandlerBoundToURL } from 'workbox-precaching'import { NavigationRoute, registerRoute } from 'workbox-routing'// Предположим, что страница `/app-shell.html` была предварительно кэшированаconst handler = createHandlerBoundToURL('/app-shell.html')const navigationRoute = new NavigationRoute(handler)registerRoute(navigationRoute)

При посещении пользователем вашего сайта, запрос на получение страницы будет считаться навигационным, следовательно, ответом на него будет кэшированная страница /app-shell.html.


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


import { createHandlerBoundToURL } from 'workbox-precaching'import { NavigationRoute, registerRoute } from 'workbox-routing'const handler = createHandlerBoundToURL('/app-shell.html')const navigationRoute = new NavigationRoute(handler, {  allowlist: [    new RegExp('/blog/')  ],  denylist: [    new RegExp('/blog/restricted/')  ]})registerRoute(navigationRoute)

Обратите внимание, что denyList имеет приоритет перед allowList.


Обработчик по умолчанию


import { setDefaultHandler } from 'workbox-routing'setDefaultHandler(({ url, event, params }) => {  // ...})

Обработчик ошибок


import { setCatchHandler } from 'workbox-routing'setCatchHandler(({ url, event, params }) => {  // ...})

Обработка не-GET-запросов


import { registerRoute } from 'workbox-routing'registerRoute(  matchCb,  handlerCb,  // определяем метод  'POST')registerRoute(  new RegExp('/api/.*\\.json'),  handlerCb,  // определяем метод  'POST')

workbox-strategies


Стратегия кэширования это паттерн, определяющий порядок формирования СВ ответа на запрос (после возникновения события fetch).


Вот какие стратегии предоставляет рассматриваемый модуль.


Stale-While-Revalidate


Данная стратегия возвращает ответ из кэша (при наличии ответа в кэше) или из сети (при отсутствии кэшированного ответа). Сетевой запрос используется для обновления кэша. Такой запрос выполняется независимо от возраста кэшированного ответа.


import { registerRoute } from 'workbox-routing'import { StaleWhileRevalidate } from 'workbox-strategies'registerRoute(  ({url}) => url.pathname.startsWith('/images/avatars/'),  new StaleWhileRevalidate())

Cache-Fisrt


Данная стратегия отлично подходит для больших статических редко изменяемых ресурсов.


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


import { registerRoute } from 'workbox-routing'import { CacheFirst } from 'workbox-strategies'registerRoute(  ({ request }) => request.destination === 'style',  new CacheFirst())

Network-First


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


import { registerRoute } from 'workbox-routing'import { NetworkFirst } from 'workbox-strategies'registerRoute(  ({ url }) => url.pathname.startsWith('/social-timeline/'),  new NetworkFirst())

Network-Only


import { registerRoute } from 'workbox-routing'import { NetworkOnly } from 'workbox-strategies'registerRoute(  ({url}) => url.pathname.startsWith('/admin/'),  new NetworkOnly())

Cache-Only


import { registerRoute } from 'workbox-routing'import { CacheOnly } from 'workbox-strategies'registerRoute(  ({ url }) => url.pathname.startsWith('/app/v2/'),  new CacheOnly())

Настройка стратегии


Каждая стратегия позволяет кастомизировать:


  • название кэша
  • лимит записей в кэше и время их "жизни"
  • плагины

Название кэша


import { registerRoute } from 'workbox-routing'import { CacheFirst } from 'workbox-strategies'registerRoute(  ({ request }) => request.destination === 'image',  new CacheFirst({    cacheName: 'image-cache',  }))

Плагины


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


  • workbox-background-sync
  • workbox-broadcast-update
  • workbox-cacheable-response
  • workbox-expiration
  • workbox-range-requests

import { registerRoute } from 'workbox-routing'import { CacheFirst } from 'workbox-strategies'import { ExpirationPlugin } from 'workbox-expiration'registerRoute(  ({ request }) => request.destination === 'image',  new CacheFirst({    cacheName: 'image-cache',    plugins: [      new ExpirationPlugin({        // Хранить ресурсы в течение недели        maxAgeSeconds: 7 * 24 * 60 * 60,        // Хранить до 10 ресурсов        maxEntries: 10      })    ]  }))

WB также позволяет создавать и использовать собственные стратегии.


workbox-recipies


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


Рецепты


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


Резервный контент


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


По умолчанию резервная страница должна иметь название offline.html.


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


Рецепт


import { offlineFallback } from 'workbox-recipes'import { setDefaultHandler } from 'workbox-routing'import { NetworkOnly } from 'workbox-strategies'setDefaultHandler(  new NetworkOnly())offlineFallback()

Паттерн


import { setCatchHandler, setDefaultHandler } from 'workbox-routing'import { NetworkOnly } from 'workbox-strategies'const pageFallback = 'offline.html'const imageFallback = falseconst fontFallback = falsesetDefaultHandler(  new NetworkOnly())self.addEventListener('install', event => {  const files = [pageFallback]  if (imageFallback) {    files.push(imageFallback)  }  if (fontFallback) {    files.push(fontFallback)  }  event.waitUntil(self.caches.open('workbox-offline-fallbacks').then(cache => cache.addAll(files)))})const handler = async (options) => {  const dest = options.request.destination  const cache = await self.caches.open('workbox-offline-fallbacks')  if (dest === 'document') {    return (await cache.match(pageFallback)) || Response.error()  }  if (dest === 'image' && imageFallback !== false) {    return (await cache.match(imageFallback)) || Response.error()  }  if (dest === 'font' && fontFallback !== false) {    return (await cache.match(fontFallback)) || Response.error()  }  return Response.error()}setCatchHandler(handler)

Подготовка кэша


Данный рецепт позволяет записывать определенные URL в кэш во время установки СВ. Она может использоваться в качестве альтернативы предварительного кэширования в случае, когда нам заранее известен список URL для сохранения.


Рецепт


import { warmStrategyCache } from 'workbox-recipes'import { CacheFirst } from 'workbox-strategies'// Здесь может испоьзоваться любая стратегияconst strategy = new CacheFirst()const urls = [  '/offline.html']warmStrategyCache({urls, strategy})

Паттерн


import { CacheFirst } from 'workbox-strategies'// Здесь может использоваться любая стратегияconst strategy = new CacheFirst()const urls = [  '/offline.html',]self.addEventListener('install', event => {  // `handleAll` возвращает два промиса, второй промис разрешается после добавления всех элементов в кэш  const done = urls.map(path => strategy.handleAll({    event,    request: new Request(path),  })[1])  event.waitUntil(Promise.all(done))})

Кэширование страницы


Данный рецепт позволяет СВ отвечать на запрос на получение HTML-страницы с помощью стратегии "сначала сеть". При этом, СВ оптимизируется таким образом, что в случае отсутствия подключения к сети, возвращает ответ из кэша менее чем за 4 секунды. По умолчанию запрос к сети выполняется в течение 3 секунд. Настройка warmCache позволяет подготовить ("разогреть") кэш к использованию.


Рецепт


import { pageCache } from 'workbox-recipes'pageCache()

Паттерн


import { registerRoute } from 'workbox-routing'import { NetworkFirst } from 'workbox-strategies'import { CacheableResponsePlugin } from 'workbox-cacheable-response'const cacheName = 'pages'const matchCallback = ({ request }) => request.mode === 'navigate'const networkTimeoutSeconds = 3registerRoute(  matchCallback,  new NetworkFirst({    networkTimeoutSeconds,    cacheName,    plugins: [      new CacheableResponsePlugin({        statuses: [0, 200]      })    ]  }))

Кэширование статических ресурсов


Данный рецепт позволяет СВ отвечать на запросы на получение статических ресурсов, таких как JavaScript, CSS и веб-воркеры с помощью стратегии "считается устаревшим после запроса" (ответ возвращается из кэша, после чего кэш обновляется). Поддерживается разогрев кэша (warmCache).


Рецепт


import { staticResourceCache } from 'workbox-recipes'staticResourceCache()

Паттерн


import { registerRoute } from 'workbox-routing'import { StaleWhileRevalidate } from 'workbox-strategies'import { CacheableResponsePlugin } from 'workbox-cacheable-response'const cacheName = 'static-resources'const matchCallback = ({ request }) =>  // CSS  request.destination === 'style' ||  // JavaScript  request.destination === 'script' ||  // веб-воркеры  request.destination === 'worker'registerRoute(  matchCallback,  new StaleWhileRevalidate({    cacheName,    plugins: [      new CacheableResponsePlugin({        statuses: [0, 200]      })    ]  }))

Кэширование изображений


Данный рецепт позволяет СВ отвечать на запросы на получение изображений с помощью стратегии "сначала кэш". По умолчанию кэшируется до 60 изображений в течение 30 дней. Поддерживается разогрев кэша.


Рецепт


import { imageCache } from 'workbox-recipes'imageCache()

Паттерн


import { registerRoute } from 'workbox-routing'import { CacheFirst } from 'workbox-strategies'import { CacheableResponsePlugin } from 'workbox-cacheable-response'import { ExpirationPlugin } from 'workbox-expiration'const cacheName = 'images'const matchCallback = ({ request }) => request.destination === 'image'const maxAgeSeconds = 30 * 24 * 60 * 60const maxEntries = 60registerRoute(  matchCallback,  new CacheFirst({    cacheName,    plugins: [      new CacheableResponsePlugin({        statuses: [0, 200]      }),      new ExpirationPlugin({        maxEntries,        maxAgeSeconds      })    ]  }))

Кэширование гугл-шрифтов


Данный рецепт кэширует таблицу стилей для шрифтов с помощью стратегии "считается устаревшим после запроса" и сами шрифты с помощью стратегии "сначала кэш". По умолчанию кэшируется до 30 шрифтов в течение 1 года.


Рецепт


import { googleFontsCache } from 'workbox-recipes'googleFontsCache()

Паттерн


import { registerRoute } from 'workbox-routing'import { StaleWhileRevalidate } from 'workbox-strategies'import { CacheFirst } from 'workbox-strategies'import { CacheableResponsePlugin } from 'workbox-cacheable-response'import { ExpirationPlugin } from 'workbox-expiration'const sheetCacheName = 'google-fonts-stylesheets'const fontCacheName = 'google-fonts-webfonts'const maxAgeSeconds = 60 * 60 * 24 * 365const maxEntries = 30registerRoute(  ({ url }) => url.origin === 'https://fonts.googleapis.com',  new StaleWhileRevalidate({    cacheName: sheetCacheName  }))// Кэшируем до 30 шрифтов с помощью стратегии "сначала кэш" и храним кэш в течение 1 годаregisterRoute(  ({ url }) => url.origin === 'https://fonts.gstatic.com',  new CacheFirst({    cacheName: fontCacheName,    plugins: [      new CacheableResponsePlugin({        statuses: [0, 200],      }),      new ExpirationPlugin({        maxAgeSeconds,        maxEntries      })    ]  }))

Быстрое использование


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


import {  pageCache,  imageCache,  staticResourceCache,  googleFontsCache,  offlineFallback} from 'workbox-recipes'pageCache()googleFontsCache()staticResourceCache()imageCache()offlineFallback()

workbox-window


Данный модуль выполняется в контексте window. Его основными задачами является следующее:


  • упрощение процесса регистрации и обновления СВ в наиболее подходящие для этого моменты жизненного цикла СВ
  • помощь в обнаружении наиболее распространенных ошибок, совершаемых разработчиками при работе с СВ
  • облегчение коммуникации между кодом СВ и кодом, запускаемым в window

Использование CDN


<script type="module">import { Workbox } from 'https://storage.googleapis.com/workbox-cdn/releases/6.1.5/workbox-window.prod.mjs'if ('serviceWorker' in navigator) {  const wb = new Workbox('/sw.js')  wb.register()}</script>

Использование сборщика модулей


Установка


yarn add workbox-window# илиnpm i workbox-window

Использование


import { Workbox } from 'workbox-window'if ('serviceWorker' in navigator) {  const wb = new Workbox('/sw.js')  wb.register()}

Примеры


Регистрация СВ и уведомление пользователя о его активации


const wb = new Workbox('/sw.js')wb.addEventListener('activated', (event) => {  // `event.isUpdate` будет иметь значение `true`, если другая версия СВ  // управляет страницей при регистрации данной версии  if (!event.isUpdate) {    console.log('СВ был активирован в первый раз!')    // Если СВ настроен для предварительного кэширования ресурсов,    // эти ресурсы могут быть получены здесь  }})// Региструем СВ после добавления обработчиков событийwb.register()

Уведомление пользователя о том, что СВ был установлен, но ожидает активации


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


const wb = new Workbox('/sw.js')wb.addEventListener('waiting', (event) => {  console.log(    `Новый СВ был установлен, но он не может быть активирован, пока все вкладки браузера не будут закрыты или перезагружены`  )})wb.register()

Уведомление пользователя об обновлении кэша


Модуль workbox-broadcast-update позволяет информировать пользователей об обновлении контента. Для получения этой информации в браузере используется событие message с типом CACHE_UPDATED:


const wb = new Workbox('/sw.js')wb.addEventListener('message', (event) => {  if (event.data.type === 'CACHE_UPDATED') {    const { updatedURL } = event.data.payload    console.log(`Доступна новая версия ${updatedURL}!`)  }})wb.register()

Отправка СВ списка URL для кэширования


В некоторых приложениях имеет смысл кэшировать только те ресурсы, которые используются посещенной пользователем страницей. Модуль workbox-routing принимает список URL и кэширует их на основе правил, определенных в маршрутизаторе.


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


const wb = new Workbox('/sw.js')wb.addEventListener('activated', (event) => {  // Получаем `URL` текущей страницы + все загружаемые страницей ресурсы  const urlsToCache = [    location.href,    ...performance      .getEntriesByType('resource')      .map((r) => r.name)  ]  // Передаем этот список СВ  wb.messageSW({    type: 'CACHE_URLS',    payload: { urlsToCache }  })})wb.register()

Практика


В этом разделе представлено несколько сниппетов, которые можно использовать в приложениях "как есть", а также краткий обзор готовых решений для разработки PWA, предоставляемых такими фреймворками для фронтенда, как React и Vue.


Для обеспечения работы приложения в режиме офлайн требуется не только СВ и его регистрация в основной файле приложения, но и так называемый "манифест".


О том, что такое манифест можно почитать здесь, здесь и здесь.


Как правило, манифест (и СВ) размещаются на верхнем уровне (в корневой директории) проекта. Манифест может иметь расширение .json или .webmanifest (лучше использовать первый вариант).


Манифест


{  "name": "Название приложения",  "short_name": "Краткое название (будет указано под иконкой приложения при его установке)",  "scope": "/", // зона контроля СВ, разные страницы могут обслуживаться разными СВ  "start_url": ".", // начальный URL, как правило, директория, в которой находится index.html, в котором регистрируется СВ  "display": "standalone",  "orientation": "portrait",  "background_color": "#f0f0f0",  "theme_color": "#3c3c3c",  "description": "Описание приложения",  // этих иконок должно быть достаточно для большинства девайсов  "icons": [    {      "src": "./icons/64x64.png",      "sizes": "64x64",      "type": "image/png"    },    {      "src": "./icons/128x128.png",      "sizes": "128x128",      "type": "image/png"    },    {      "src": "./icons/256x256.png",      "sizes": "256x256",      "type": "image/png",      "purpose": "any maskable"    },    {      "src": "./icons/512x512.png",      "sizes": "512x512",      "type": "image/png"    }  ],  "serviceworker": {    "src": "./service-worker.js" // ссылка на файл с кодом СВ  }}

Ручная реализация СВ, использующего стратегию "сначала кэш"


// Название кэша// используется для обновления кэша// в данном случае, для этого достаточно изменить версию кэша - my-cache-v2const CACHE_NAME = 'my-cache-v1'// Критические для работы приложения ресурсыconst ASSETS_TO_CACHE = [  './index.html',  './offline.html',  './style.css',  './script.js']// Предварительное кэширование ресурсов, выполняемое во время установки СВself.addEventListener('install', (e) => {  e.waitUntil(    caches      .open(CACHE_NAME)      .then((cache) => cache.addAll(ASSETS_TO_CACHE))  )  self.skipWaiting()})// Удаление старого кэша во время активации нового СВself.addEventListener('activate', (e) => {  e.waitUntil(    caches      .keys()      .then((keys) =>        Promise.all(          keys.map((key) => {            if (key !== CACHE_NAME) {              return caches.delete(key)            }          })        )      )  )  self.clients.claim()})// Обработка сетевых запросов/*  1. Выполняется поиск совпадения  2. Если в кэше имеется ответ, он возвращается  3. Если ответа в кэше нет, выполняется сетевой запрос  4. Ответ на сетевой запрос кэшируется и возвращается  5. В кэш записываются только ответы на `GET-запросы`  6. При возникновении ошибки возвращается резервная страница*/self.addEventListener('fetch', (e) => {  e.respondWith(    caches      .match(e.request)      .then((response) =>          response || fetch(e.request)            .then((response) =>              caches.open(CACHE_NAME)                .then((cache) => {                  if (e.request.method === 'GET') {                    cache.put(e.request, response.clone())                  }                  return response                })          )      )      .catch(() => caches.match('./offline.html'))  )})

Конфигурация Webpack


Пример настройки вебпака для производственной сборки прогрессивного веб-приложения.


Предположим, что в нашем проекте имеется 4 директории:


  • public директория со статическими ресурсами, включая index.html, manifest.json и sw-reg.js
  • src директория с кодом приложения
  • build директория для сборки
  • config директория с настройками, включая .env, paths.js и webpack.config.js

В файле public/sw-reg.js содержится код регистрации СВ:


if ('serviceWorker' in navigator) {  window.addEventListener('load', () => {    navigator.serviceWorker      .register('./service-worker.js')      .then((reg) => {        console.log('СВ зарегистрирован: ', reg)      })      .catch((err) => {        console.error('Регистрация СВ провалилась: ', err)      })  })}

В файле config/paths.js осуществляется экспорт путей к директориям с файлами приложения:


const path = require('path')module.exports = {  public: path.resolve(__dirname, '../public'),  src: path.resolve(__dirname, '../src'),  build: path.resolve(__dirname, '../build')}

Допустим, что в качестве фронтенд-фреймворка мы используем React, а также, что в проекте используется TypeScript. Тогда файл webpack.config.js будет выглядеть следующим образом:


const webpack = require('webpack')// импортируем пути к директориям с файлами приложенияconst paths = require('../paths')// плагин для копирования статических ресурсов в директорию сборкиconst CopyWebpackPlugin = require('copy-webpack-plugin')// плагин для обработки `index.html` - вставки ссылок на стили и скрипты, добавления метаданных и т.д.const HtmlWebpackPlugin = require('html-webpack-plugin')// плагин для обеспечения прямого доступа к переменным среды окруженияconst Dotenv = require('dotenv-webpack')// плагин для минификации и удаления неиспользуемого CSSconst MiniCssExtractPlugin = require('mini-css-extract-plugin')// плагин для сжатия изображенийconst ImageminPlugin = require('imagemin-webpack-plugin').default// плагин для добавления блоков кодаconst AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin')// Плагин для генерации СВconst { GenerateSW } = require('workbox-webpack-plugin')// настройки Babelconst babelLoader = {  loader: 'babel-loader',  options: {    presets: ['@babel/preset-env', '@babel/preset-react'],    plugins: [      '@babel/plugin-proposal-class-properties',      '@babel/plugin-syntax-dynamic-import',      '@babel/plugin-transform-runtime'    ]  }}module.exports = {  // режим сборки  mode: 'production',  // входная точка  entry: {    index: {      import: `${paths.src}/index.js`,      dependOn: ['react', 'helpers']    },    react: ['react', 'react-dom'],    helpers: ['immer', 'nanoid']  },  // отключаем логгирование  devtool: false,  // результат сборки  output: {    // директория сборки    path: paths.build,    // название файла    filename: 'js/[name].[contenthash].bundle.js',    publicPath: './',    // очистка директории при каждой сборке    clean: true,    crossOriginLoading: 'anonymous',    module: true  },  resolve: {    alias: {      '@': `${paths.src}/components`    },    extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx', '.json']  },  experiments: {    topLevelAwait: true,    outputModule: true  },  module: {    rules: [      // JavaScript, React      {        test: /\.m?jsx?$/i,        exclude: /node_modules/,        use: babelLoader      },      // TypeScript      {        test: /.tsx?$/i,        exclude: /node_modules/,        use: [babelLoader, 'ts-loader']      },      // CSS, SASS      {        test: /\.(c|sa|sc)ss$/i,        use: [          'style-loader',          {            loader: 'css-loader',            options: { importLoaders: 1 }          },          'sass-loader'        ]      },      // статические ресурсы - изображения и шрифты      {        test: /\.(jpe?g|png|gif|svg|eot|ttf|woff2?)$/i,        type: 'asset'      },      {        test: /\.(c|sa|sc)ss$/i,        use: [          MiniCssExtractPlugin.loader,          {            loader: 'css-loader',            options: { importLoaders: 1 }          },          'sass-loader'        ]      }    ]  },  plugins: [    new CopyWebpackPlugin({      patterns: [        {          from: `${paths.public}/assets`        }      ]    }),    new HtmlWebpackPlugin({      template: `${paths.public}/index.html`    }),    // это позволяет импортировать реакт только один раз    new webpack.ProvidePlugin({      React: 'react'    }),    new Dotenv({      path: './config/.env'    }),    new MiniCssExtractPlugin({      filename: 'css/[name].[contenthash].css',      chunkFilename: '[id].css'    }),    new ImageminPlugin({      test: /\.(jpe?g|png|gif|svg)$/i    }),    // Добавляем код регистрации СВ в `index.html`    new AddAssetHtmlPlugin({ filepath: `${paths.public}/sw-reg.js` }),    // Генерируем СВ    new GenerateSW({      clientsClaim: true,      skipWaiting: true    })  ],  optimization: {    runtimeChunk: 'single'  },  performance: {    hints: 'warning',    maxEntrypointSize: 512000,    maxAssetSize: 512000  }}

Здесь вы найдете шпаргалку по настройке вебпака. Пример полной конфигурации вебпака для JS/React/TS-проекта можно посмотреть здесь.


React PWA


Для того, чтобы получить готовый шаблон React-приложения с возможностями PWA, достаточно выполнить команду:


yarn create react-app my-app --template pwa# илиnpx create-react-app ...

Или, если речь идет о TypeScript-проекте:


yarn create react-app my-app --template pwa-typescript# илиnpx create-react-app ...

Кроме прочего, в директории src создаются файлы service-worker.ts и serviceWorkerRegister.ts (последний импортируется в index.tsx), а в директории public файл manifest.json.


Затем, перед сборкой проекта с помощью команды yarn build или npm run build, в файл src/index.tsx необходимо внести одно изменение:


// доserviceWorkerRegistration.unregister();// послеserviceWorkerRegistration.register();

Подробнее об этом можно прочитать здесь.


Vue PWA


С Vue дела обстоят еще проще.


Глобально устанавливаем vue-cli:


yarn global add @vue/cli# илиnpm i -g @vue/cli

Затем, при создании шаблона проекта с помощью команды vue create my-app, выбираем Manually select features и Progressive Web App (PWA) Support.


Кроме прочего, в директории src создается файл registerServiceWorker.ts, который импортируется в main.ts. Данный файл содержит ссылку на файл service-worker.js, который, как и manifest.json, автоматически создается при сборке проекта с помощью команды yarn build или npm run build. Разумеется, содержимое обоих файлов можно кастомизировать.

Подробнее..

Перевод Десятикратное улучшение производительности React-приложения

14.06.2021 16:14:45 | Автор: admin

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


Около года назад в Techgoise я получил возможность поработать с большим React-приложением. Мы получили (унаследовали) готовую кодовую базу, внесли основные правки и начали добавлять в приложение новые интересные возможности.


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


В данной статье я расскажу о том, как нам удалось добиться уменьшения этой цифры с 1,5 Гб до 150 Мб, что, как следствие, привело к улучшению производительности почти в 10 раз, и мы больше никогда не сталкивались с Ошибкой.


Поиск узких мест в производительности


Существует большое количество инструментов и библиотек для обнаружения узких мест в приложении. Мы испытали большое количество таких инструментов. Ниже представлено три из них, которые оказались наиболее полезными.


1. Профилирование компонентов с помощью расширения для Google Chrome


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


2. Снимки используемой памяти в Firefox


Данный инструмент предоставляет снимок "кучи", которая используется текущей вкладкой браузера. Древовидное представление снимка показывает следующие вещи:


  1. Объекты: объекты JavaScript и DOM, такие как функции, массивы или, собственно, объекты, а также типы DOM, такие как Window и HTMLDivElement.
  2. Скрипты: источники JavaScript-кода, загружаемые страницей.
  3. Строки.
  4. Другое: внутренние объекты, используемые SpiderMonkey.

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


3. Пакет why-did-you-render


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


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


Как же нам удалось решить эту задачу?


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


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


1. Удаление встроенных функций


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


import Child from 'components/Child'const Parent = () => ( <Child onClick={() => {   console.log('Случился клик!') }} />)export default Parent

В нашем коде имеется встроенная функция. С такими функциями сопряжено 2 главных проблемы:


  1. Они запускают повторный рендеринг компонента даже в случае, когда пропы остались прежними.
  2. Это, в свою очередь, увеличивает расход памяти.

В основном, это связано с тем, что в данном случае метод "передается по ссылке", поэтому на каждом цикле рендеринга создается новая функция и изменяется ссылка на нее. Это присходит даже при использовании PureComponent или React.memo().


Решение: выносим встроенные функции из рендеринга компонента.


import Child from 'components/Child'const Parent = () => { const handleClick = () => {   console.log('Случился клик!') } return (   <Child onClick={handleClick} /> )}

Это позволило снизить расход памяти с 1,5 Гб до 800 Мб.


2. Сохранение состояния при отсутствии изменений хранилища Redux


Как правило, для хранения состояния мы используем хранилище Redux. Предположим, что мы повторно обращаемся к API и получаем те же данные. Должны ли мы в этом случае обновлять хранилище? Короткий ответ нет. Если мы это сделаем, то компоненты, использующие такие данные будут повторно отрисованы, поскольку изменилась ссылка на данные.


В унаследованной кодовой базе для этого использовался такой хак: JSON.stringify(prevProps.data) !== JSON.stringify(this.props.data). Однако, на нескольких страницах он не давал желаемого эффекта.


Решение: перед обновлением состояния в хранилище Redux проводим эффективное сравнение данных. Мы обнаружили два хороших пакета, отлично подходящих для решения этой задачи: deep-equal и fast-deep-equal.


Это привело с уменьшению Цифры с 800 до 500 Мб.


3. Условный рендеринг компонентов


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


import { useState } from 'react'import { Modal, Button } from 'someCSSFramework'const Modal = ({ isOpen, title, body, onClose }) => { const [open, setOpen] = useState(isOpen || false) const handleClick =   typeof onClose === 'function'     ? onClose     : () => { setOpen(false) } return (   <Modal show={open}>     <Button onClick={handleClick}>x<Button>     <Modal.Header>{title}</Modal.Header>     <Modal.Body>{body}</Modal.Body>   </Modal> )}

Мы обнаружили, что большое количество таких компонентов перерисовывалось без необходимости. Среди них были огромные компоненты с большим количеством дочерних компонентов и связанных с ними вызовами API.


Решение: рендеринг таких компонентов по условию (условный рендеринг). Также можно рассмотреть вариант с "ленивой" (отложенной) загрузкой кода таких компонентов.


Это привело к снижению расхода памяти с 500 до 150 Мб.


Перепишем приведеный выше пример:


import { useState } from 'react'import { Modal, Button } from 'someCSSFramework'const Modal = ({ isOpen, title, body, onClose }) => { const [open, setOpen] = useState(isOpen || false) const handleClick =   typeof onClose === 'function'     ? onClose     : () => { setOpen(false) } // условный рендеринг if (!open) return null return (   <Modal show={open}>     <Button onClick={handleClick}>x<Button>     <Modal.Header>{title}</Modal.Header>     <Modal.Body>{body}</Modal.Body>   </Modal> )}

4. Удаление ненужных await и использование Promise.all()


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


Обычно, для получения начальных данных мы обращаемся к API. Представьте, что для инициализации приложение требуется получить данные от 3-5 API, как в приведенном ниже примере. Методы get... в примере связанны с соответствующими запросами к API:


const userDetails = await getUserDetails()const userSubscription = await getUserSubscription()const userNotifications = await getUserNotifications()

Решение: для одновременного выполнения запросов к API следует использовать Promise.all(). Обратите внимание: это будет работать только в том случае, когда ваши данные не зависят друг от друга и порядок их получения не имеет значения.


В нашем случае это увеличило скорость начальной загрузки приложения на 30%.


const [ userSubscription, userDetails, userNotifications] = await Promise.all([ getUserSubscription(), getUserDetails(), getUserNotifications()])

Рассмотренные в данной статье приемы по оптимизации производительности React-приложения это лишь вершина айсберга. О более тонких приемах мы поговорим в одной из следующих статей.


Заключение


Итак, для повышения производительности React-приложения необходимо придерживаться следующих правил:


  1. Избегайте использования встроенных функций. В небольших приложениях это не имеет особого значения, но по мере роста приложения это негативно отразится на скорости работы приложения.
  2. Помните о том, что иммутабельность (неизменяемость) данных это ключ к предотвращению ненужного рендеринга.
  3. В отношении скрытых компонентов вроде модальных окон и раскрывающихся списков следует применять условный или отложенный рендеринг. Такие компоненты не используются до определенного момента, но их рендеринг влияет на производительность.
  4. По-возможности, отправляйте параллельные запросы к API. Их последовательное выполнение занимает гораздо больше времени.

Спустя 3 недели разработки (включая тестирование), мы, наконец, развернули продакшн-версию приложения. С тех пор мы ни разу не сталкивались в ошибкой "Aw! Snap".


Благодарю за внимание и хорошего дня!




Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Перевод WebRTC для любопытных (часть 1)

02.06.2021 08:23:30 | Автор: admin

Данный материал - это вольный перевод онлайн-книги webrtcforthecurious.com. Вся книга будет разбита на отдельные статьи, которые будут выходить в разное время. В основном я это сделал для себя в будущем, так как в свободное от работы время почти целиком и полностью занимаюсь проектом в основе которого именно WebRTC.

Книга довольно поверхностно объясняет как работает WebRTC "под капотом", для подробностей надо читать RFC. Ссылки на RFC различных используемых протоколов буду приводить. Стоит особо отметить главу "Отладка", где неплохо описываются идеи того, как отлаживать различные проблемы с сетью, задержками и прочим.

Итак, часть 1 - вводная.


Что такое Webrtc?

Web Real-Time Communication или сокращенно WebRTC, является и протоколом и API одновременно. WebRTC как протокол - это множество правил для безопасного обмена информацией (обычно медиа) в режиме дуплекс между двумя агентами в сети. WebRTC как API в свою очередь позволяет разработчикам работать с протоколом. API формально определено только для JavaScript.

Такое же разделение и в отношении HTTP и Fetch API: WebRTC-протокол - это как HTTP, а WebRTC API - это как Fetch API в данном случае.

Помимо JavaScript протокол WebRTC реализован также и на других языках програмирования. Можно найти множество реализаций серверов, библиотек, реализующих протокол, примером может стать реализация на go pion/webrtc. Пишется реализация и на rust: https://github.com/webrtc-rs/webrtc (проект довольно интересный, потому что это переписываение pion/webrtc на rust).

Протокол WebRTC поддерживается в IETF в группе rtcweb. API WebRTC задокументировано в W3C как webrtc-pc.

Приемущества WebRTC

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

Итак, приемущества WebRTC:

  • Открытый стандарт

  • Множество различных реализаций

  • Можно работать прямо из браузера

  • Обязательное шифрование

  • NAT Traversal

  • Перепрофилированная существующая технология, то есть не изобретали колес, когда делали WebRTC

  • Контроль за перегруженностью

  • Задержка (latency, имеется в виду задержка аудио и/или видеопотока) в пределах 1 секунды

WebRTC это набор разных технологий

Это тема, для объяснения которой потребуется целая книга. Для начала разобъем ее на четыре части:

  • Сигналинг

  • Соединение пиров

  • Безопасность

  • Общение пиров

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

Интересный факт в WebRTC это то, что каждый шаг использует множество других протоколов!

WebRTC объединияет множество существующих технологий. В этом смысле WebRTC - это скорее комбинация в различных конфигурациях хорошо изученных технологий, которые существуют с начала 2000-х годов.

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

Сигналинг или как агенты находят друг друга в сети

Когда запускается WebRTC-агент, он не знает с кем ему соединиться, и какого рода информацией он будет обмениваться. Сигналинг (Signaling) решает эту проблему! Сигналинг нужен для того, чтобы два агента могли найти и вызвать друг друга в сети перед тем, как начать обмен информацией.

Сигналинг использует существующий протокол SDP (Session Description Protocol). SDP это простой текстовый протокол. Каждое SDP-сообщение состоит из пар ключ-значение, расположенных в строгом порядке (rfc4566), которые в свою очередь составляют набор медиа-секций. SDP-сообщения, которыми обмениваются WebRTC-агенты содержит такую информацию как:

  • адреса IP и порты агентов, по которым можно соединиться с агентом (это т.н. ICE-кандидаты)

  • сколько аудио и видео треков агент желает отправить

  • какие аудио и видео кодеки поддерживает каждый из агентов

  • значения используемые во время соединения (uFrag/uPwd).

  • значения используемые для безопасности (отпечаток сертификата)

Отметим, что сигналинг обычно работает как бы в сторонке; то есть приложения не используют WebRTC для обмена SDP сообщениями. Тут подходит любой способ обмена этими сообщениями: REST, Websocket, да хоть письмом по почте можно отправить другому пиру SDP-сообщение, а тот в свою очередь отправит свое. В своем приложении для тестов я вообще использовал firebase для сигналинга.

Установка соединения и NAT Traversal с помощью STUN/TURN

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

ICE (Interactive Connectivity Establishment) - это протокол предваряющий WebRTC. ICE позволяет установить связь между двумя агентами. Эти агенты могуть быть в одной и той же сети, а могут быть и на противоположных концах света. ICE решает проблему установления прямой связи без использования промежуточного сервера.

Настоящая магия здесь это т.н. NAT Traversal и STUN/TURN сервера. Обе эти концепции необходимы для соединения с ICE агентом из другой сетки. Далее мы изучим этот вопрос глубже.

Как только связь между двумя агентами установлена, WebRTC переходит к установлениею шифрованного канала передачи. Далее этот канал будет использован для передачи аудио/видео и данных.

Шифрование передачи информации с помощью DTLS и SRTP

После того как мы установили дуплексную связь между двумя пирами через ICE, нам необходимо установить шифрованное соединение для обеспечения безопасности при передаче данных. Это обеспечиватся двумя протоколами, которые также предваряют WebRTC: DTLS (Datagram Transport Layer Security), который просто добавляет слой TLS над UPD. (TLS - криптографический протокол используемый для безопасного обмена через https). Второй протокол - это SRTP (Secure Real-time Transport Protocol).

Сначала WebRTC выполняет DTLS-"рукопожатие" через соединение установленное ранее через ICE. В отличие от HTTPS WebRTC не использует CA для сертификатов. Вместо этого просто сверяет отпечатки сертификатов, полученных в ходе обмена SDP-сообщениями на этапе сигналинга. Установленное DTLS соединение далее используется для DataChannel, для обмена простыми данными - бинарными или текстовыми, например сообщения в чате.

Для видео/аудио в WebRTC используется другой протокол: RTP. Для шифрования RTP-пакетов используется протокол SRTP. SRTP сессия инициализируется с помощью ключей шифрования полученных в ходе DTLS сессии (rfc5764). Далее мы обсудим, почему для медиа-данных используется свой собственный протокол.

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

Общение между пирами через RTP и SCTP

Сейчас мы имеем два WebRTC-агента с безопасным двунаправленным соединением. Давайте начнем взаимодействие! И снова мы используем уже существующие протоколы: RTP (Real-time Transport Protocol), и SCTP (Stream Control Transmission Protocol). Используйте RTP для обмена аудио/видео шифрованным по протоколу SRTP и SCTP для обмена DataChannel-сообщениями, шифрованными с помощью DTLS.

RTP сам по себе очень минимален, но предоставляет все необходимое для стриминга в реальном времени. Важно то, что RTP предоставляет разработчику гибкость в управлении потерями пакетов, задержками и перегрузками так, как он (разработчик) пожелает. Далее мы будем обсуждать по этой теме в части про медиа.

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

WebRTC это набор протоколов

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

Рис.1. WebRTC Agent DiagramРис.1. WebRTC Agent Diagram

Кратко: как работает WebRTC (API)

В этой части показано как JavaScript API отображается на протокол. Это не демонстрация WebRTC API, а скорее некоторый набросок для создания у вас ментальной модели, как все работает вместе. Если вы не знакомы с каким-либо из пунктов, не переживайте, можете вернуться сюда когда узнаете больше!

new RTCPeerConnection

RTCPeerConnection - это основа для установления WebRTC-сессии. Объект RTCPeerConnection реализует "под капотом" все протоколы, упомянутые выше. Здесь инициализируются все подсистемы, и пока что ничего больше не происходит.

addTrack

Метод addTrack создает новый RTP-поток. Для потока генерируется случайный Synchronization Source (SSRC). Созданный RTP поток будет затем описан в Session Description-сообщении внутри медиа-секции после вызова createOffer метода. Каждый вызов addTrack создает новый SSRC и добавляет медиа-секцию в SDP-сообщение.

Сразу после того, как SRTP сессия установлена, зашифрованные медиа-пакеты начнут отправляеться через ICE.

createDataChannel

createDataChannel создает новый SCTP-поток, если еще не был добавлен. По умолчанию SCTP выключен, но инициализируется как только одна из сторон потребует data channel.

Сразу после того, как DTLS сессия установлена, SCTP пакеты начнут отправляться через ICE.

createOffer

createOffer генерирует Session Description для отправки удаленному пиру.

Вызов createOffer ничего не меняет на локальном пире.

setLocalDescription

setLocalDescription фиксирует все, что менялось в созданном RTCPeerConnection для локального пира. Методы addTrack, createDataChannel и другие осуществляют временные изменения до тех пор, пока метод setLocalDescription не будет вызван. В этот метод нужно передавать строку session description сгенерированную методомcreateOffer.

После вызова setLocalDescription сгенерированное SDP-сообщение также отправляется на удаленный пир (выше обусждалось, что это можно делать любым способом), и далее на удаленном пире SDP-сообщение (offer) передается в метод setRemoteDescription. Удаленный пир в свою очередь отправляет свой локальный SDP в ответ (answer), который также нужно передать локально в setRemoteDescription.

addIceCandidate

addIceCandidate позволяет WebRTC-агенту добавить больше удаленных ICE-кандидатов.

ontrack

ontrack - это колбек (функция обратного вызова), который срабатывает как только RTP-пакет был получен от удаленного пира.

oniceconnectionstatechange

oniceconnectionstatechange - это также колбек, который отражает состояние ICE агента. Любые проблемы с сетью отражаются через этот колбек.

onstatechange

onstatechange - этот колбек служит для отслеживания окончания сбора всех ICE-кандидатов. Как только аргумент этого обратного вызова станет null, все ICE-кандидаты собраны.

В следующей части разберем Signaling и SDP.

Подробнее..

Перевод Optional.stream()

08.06.2021 20:05:53 | Автор: admin

На этой неделе я узнал об одной интересной "новой" возможности Optional, о которой хочу рассказать в этом посте. Она доступна с Java 9, так что новизна ее относительна.

Давайте начнем со следующей последовательности для вычисления общей цены заказа:

public BigDecimal getOrderPrice(Long orderId) {    List<OrderLine> lines = orderRepository.findByOrderId(orderId);    BigDecimal price = BigDecimal.ZERO;           for (OrderLine line : lines) {        price = price.add(line.getPrice());       }    return price;}
  • Предоставьте переменную-аккумулятор для цены

  • Добавьте цену каждой строки к общей цене

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

public BigDecimal getOrderPrice(Long orderId) {    List<OrderLine> lines = orderRepository.findByOrderId(orderId);    return lines.stream()                .map(OrderLine::getPrice)                .reduce(BigDecimal.ZERO, BigDecimal::add);}

Давайте сосредоточимся на переменной orderId : она может содержать null.

Императивный способ обработки nullзаключается в том, чтобы проверить его в начале метода - и в конечном итоге сбросить:

public BigDecimal getOrderPrice(Long orderId) {    if (orderId == null) {        throw new IllegalArgumentException("Order ID cannot be null");    }    List<OrderLine> lines = orderRepository.findByOrderId(orderId);    return lines.stream()                .map(OrderLine::getPrice)                .reduce(BigDecimal.ZERO, BigDecimal::add);}

Функциональный способ заключается в том, чтобы обернуть orderId в Optional. Вот как выглядит код с использованием Optional:

public BigDecimal getOrderPrice(Long orderId) {    return Optional.ofNullable(orderId)                                        .map(orderRepository::findByOrderId)                               .flatMap(lines -> {                                                    BigDecimal sum = lines.stream()                        .map(OrderLine::getPrice)                        .reduce(BigDecimal.ZERO, BigDecimal::add);                return Optional.of(sum);                                       }).orElse(BigDecimal.ZERO);                            }
  1. Оберните orderId в Optional

  2. Найдите соответствующие строки заказа

  3. Используйте flatMap(), чтобы получить Optional<BigDecimal>; map() получит Optional<Optional<BigDecimal>>

  4. Нам нужно обернуть результат в Optional, чтобы он соответствовал сигнатуре метода.

  5. Если Optional не содержит значения, сумма равна 0

Optional делает код менее читабельным! Я считаю, что понятность должна быть всегда важнее стиля кода.

К счастью, Optional предлагает метод stream() (начиная с Java 9). Он позволяет упростить функциональный конвейер:

public BigDecimal getOrderPrice(Long orderId) {    return Optional.ofNullable(orderId)            .stream()            .map(orderRepository::findByOrderId)            .flatMap(Collection::stream)            .map(OrderLine::getPrice)            .reduce(BigDecimal.ZERO, BigDecimal::add);}

Вот краткая информация о типе на каждой строке:

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


Перевод материала подготовлен в рамках курса "Java Developer. Basic". Приглашаем всех желающих на день открытых дверей онлайн, где можно подробнее узнать о формате и программе обучения, а также познакомиться с преподавателем.

Подробнее..

Чиним проблемы нагрузок в Go с помощью настройки пула HTTP-соединений

09.06.2021 20:16:20 | Автор: admin

Привет, меня зовут Иван, я занимаюсь бэкенд-разработкой в Ozon пишу микросервисы для личного кабинета продавца. Сегодня я расскажу небольшую историю проблемы, вызванную неправильной настройкой пула соединений: что случилось, как мы это обнаружили и как исправлять такие проблемы в микросервисах на Go.

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

В упрощенном виде его работу можно представить так:

  1. Проверить аутентификацию и авторизацию с помощью HTTP-запроса в сервис аутентификации

  2. Спроксировать запрос в нужный сервис, который определяем по пути и методу запроса пользователя

Иллюстрация работы API GatewayИллюстрация работы API Gateway

Конец декабря время роста нагрузок и числа ошибок

Настал конец декабря. Вместе с ним к нам в поддержку стали приходить вот такие обращения:

При работе в ЛК возникают постоянные ошибки системные по 10-20 раз на дню и больше. Просьба исправить и наладить работу площадки.

Ошибки возникали на стороне API Gateway. Мы полезли в логи за подробностями и увидели ошибки, похожие на таймауты обращения к сервису аутентификацию:

{err_type: context.deadlineExceededError, err: context deadline exceeded}{err_type: *errors.errorString, err: context canceled}

Трейсы в Jaeger показали ровно такую же картину мы не дожидались ответа от сервиса аутентификации за 2 секунды. Поэтому между нами и разработчиками сервиса аутентификации произошёл примерно такой диалог:

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

- Ничего подобного, у нас все норм мы за 200 миллисекунд отвечаем в 99% запросов. А вот вы по какой-то причине часто преждевременно обрываете соединение.

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

Скриншот с множеством ошибок Cancelled by clientСкриншот с множеством ошибок Cancelled by client

Итого, мы имеем:

  1. Используемый нами сервис аутентификации стабильно отрабатывает за 200 миллисекунд.

  2. Многие наши обращения к этому сервису таймаутят за 2 секунды.

Причина проблемы: дефолтные настройки в Go

В это время один из авторов нашего API Gateway отметил, что очень давно заметил аномалию: сервис открывает неожиданно много соединений к удаленным портам. При запуске команды из-под контейнера видно:

$ ss -natp state time-wait | awk '{print $4}' | sort -nr | uniq -c | sort -nr | head1053 10.20.49.117:801030 10.20.49.92:801016 10.20.49.91:801014 10.20.54.129:801013 10.20.53.213:801008 10.20.53.173:80969 10.20.53.172:80

Эта команда показывает количество TCP-сокетов в состоянии TIME_WAIT до разных удалённых портов. Если коротко, то состояние TIME_WAIT это де-факто закрытое клиентом соединение. Linux по возможности предотвращает повторное использование этих пар на протяжении 60 секунд, чтобы защититься от того, что старые пакеты помешают вновь установленному TCP-соединению.

Но для нас важно другое. Само существование TCP-соединения означает, что соединение установилось и закрылось. Если такая ситуация происходит массово, то мы имеем дело с накладными расходами на DNS-резолвинг и установку соединения. В результате этого время HTTP-запроса может увеличиваться. Избежать эту проблему помогают пулы соединении. В Go для этои цели используется абстракция http.Transport.

Здесь мы вплотную приближаемся к истокам проблемы. Мы для всех клиентских запросов использовали http.DefaultTransport. Он обладает следующими параметрами:

var DefaultTransport RoundTripper = &Transport{    Proxy: ProxyFromEnvironment,    DialContext: (&net.Dialer{        Timeout:   30 * time.Second,        KeepAlive: 30 * time.Second,    }).DialContext,    ForceAttemptHTTP2:     true,    MaxIdleConns:          100,    IdleConnTimeout:       90 * time.Second,    TLSHandshakeTimeout:   10 * time.Second,    ExpectContinueTimeout: 1 * time.Second,}

Среди перечисленных выше параметров к настройке пула соединений имеют отношения два:

  • MaxIdleConns число соединений, которое разрешается иметь в состоянии Idle (т.е. открытых TCP-соединений, которые в данный момент не используются);

  • IdleConnTimeout время, через которое закрываются такие неактивные соединения.

Однако в DefaultTransport совершенно не указан другой ключевой параметр MaxIdleConnsPerHost. Он отвечает за то, сколько неактивных TCP-соединений допускается устанавливать на один хост.

При этом если MaxIdleConnsPerHost не указан, тогда используется значение по умолчанию:

const DefaultMaxIdleConnsPerHost = 2

Поскольку мы использовали именно http.DefaultTransport для всех запросов, мы получили следующую проблему.

Представим, что нам одновременно понадобилось установить 10 соединений до сервиса аутентификации. Тогда хотя бы для 8 из них будут открыты и вскоре тут же закрыты TCP-соединения, из-за ограничения MaxIdleConnsPerHost. Если такая ситуация будет повторяться часто, у нас будет больше накладных расходов на один HTTP-запрос, поскольку для него понадобится новое соединение. Из-за этого вероятность таймаутов возрастает.

Решение: отдельный транспорт с особенными настройками

Чтобы решить проблему, мы сделали следующее:

  • Выделили отдельный транспорт под сервисы аутентификации и авторизации, чтобы транспорт под сервис аутентификации не вытеснял остальные сервисы.

  • Утилизировали выделенный пул на полную сделали так, чтобы значение MaxIdleConnsPerHost соответствовало значению MaxIdleConns:

func createOneHostTransport() *http.Transport {    result := http.DefaultTransport.(*http.Transport).Clone()    result.MaxIdleConnsPerHost = result.MaxIdleConns    return result}
График response time обращения к сервису аутентификацииГрафик response time обращения к сервису аутентификации

Тут видно значительное уменьшение 0.99-квантиля по времени обращения графиков (голубой цвет) с 2-3 секунд до менее 300 миллисекунд. Должен признать, даже после этого мы изредка видели таймауты при обращении к сервису аутентификации. Но теперь мы хотя бы видели эти же таймауты на графиках другого сервиса.

Но почему в Go такие настройки по умолчанию?

Возможно, у вас сейчас возник вопрос: зачем делать такие настройки, которые потом приходится исправлять? Неужели разработчики языка Go и библиотек к нему не подумали о том, как это будет использоваться на практике?

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

Если бы приложения могли говорить...

Чтобы лучше понять предназначение параметра MaxIdleConnsPerHost, представим, что компоненты системы научились говорить друг с другом. Тогда если бы мы выставили значение MaxIdleConnsPerHost равным MaxIdleConns, между нашим приложением и пулом соединений мог бы произойти такой диалог:

Приложение: http.Transport, привет! Нам тут сотня пользователей пришла одновременно и им всем вдруг срочно понадобилось посмотреть инфу о своём профиле. Установи, пожалуйста, сотню соединений к user-service

http.Transport: Конечно, вот получи сотню установленных соединений под запросы! Но я их не буду ещё полторы минуты закрывать, вдруг пригодятся .

Приложение: Всё сработало, спасибо! Только вот теперь им всем вдруг захотелось посмотреть информацию о товаре. Пожалуйста, установи соединение с product-service.

http.Transport: Да, не вопрос держи. Но только я их у себя в пуле соединений хранить не буду, поскольку он уже полностью забит соединениями до user-service, которые тебе больше не нужны.

Приложение: (_)

Разбираемся с непонятными таймаутами в Go: чеклист

Если вдруг вы сталкиваетесь с непонятными таймаутами, попробуйте следующее:

  1. Проверьте метрики по скорости выполнения запросов к сторонним сервисам по HTTP. Если таких метрик нет, заведите пригодятся.

  2. Если видите расхождения в таймаутах клиента и сервера, проверьте количество соединений TIME_WAIT.

  3. Если вы обнаружили много соединений в состоянии TIME_WAIT, это с высокой вероятностью означает, что пул соединений настроен неверно. Обратите внимание на то, что настройки по умолчанию в Go не очень хорошо подходят под большое количество запросов к ограниченному набору сервисов.

  4. Для хоста с неоправданно большим числом запросов к нему рассмотрите вариант заведения отдельного транспорта.

Что ещё почитать по теме

Подробнее..

Гугл финанс перестал транслировать данные российских акций что делать?

15.06.2021 06:21:37 | Автор: admin

С 5 июня 2021 года сайт гугла, и самое главное гугл таблицы - перестали отдавать данные с Московской биржи.

При попытке получить котировки с префиксом MCX, например для Сбербанка, формулой из гугл таблиц =GOOGLEFINANCE("MCX:SBER") теперь всегда возвращается результат #N/A.

А при поиске любой российской бумаги на сайте Google находятся все рынки, кроме Московской биржи:

Попытка поиска котировки Sberbank of Russia на сайте https://www.google.com/finance/quote/MCX:SBER Попытка поиска котировки Sberbank of Russia на сайте https://www.google.com/finance/quote/MCX:SBER

В этой статье я рассказываю как получать в гугл таблицу биржевые данные с помощью API Московской биржи.

Копирование формул из таблицы-примера в ваши собственные таблицы

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

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

Моя таблица с примером получения данных с Московской биржиМоя таблица с примером получения данных с Московской биржи

Я использую регион Соединенные Штаты, а если по умолчанию ваш регион Россия, то формулы корректно НЕ копируются!

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

  • Откройте файл в Google Таблицах на компьютере.

  • Нажмите Файл затем Настройки таблицы.

  • Выберите нужные варианты в разделах "Региональные настройки".

  • Нажмите Сохранить настройки.

    Как изменить региональные настройки и параметры расчетовКак изменить региональные настройки и параметры расчетов

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

Получение названий акций и облигаций

Гугл таблица с примерами автоматического получения имени для разных классов активовГугл таблица с примерами автоматического получения имени для разных классов активов

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

=IMPORTxml(    "https://iss.moex.com/iss/engines/stock/markets/" &      IFS(                 or(            B3 = "TQOB",            B3 = "EQOB",            B3 = "TQOD",            B3 = "TQCB",            B3 = "EQQI",            B3 = "TQIR"        ),        "bonds",                 or(            B3 = "TQTF",            B3 = "TQBR",            B3 = "SNDX",            B3 = "TQIF"        ),        "shares"    )  & "/boards/" & B3 & "/securities.xml?iss.meta=off&iss.only=securities&securities.columns=SECID,SECNAME",      "//row[@SECID='" & A3 & "']/@SECNAME")

Получение цен акций и облигаций

Гугл таблица с примерами автоматического получения цен акций и облигацийГугл таблица с примерами автоматического получения цен акций и облигаций

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

=IMPORTxml(    "https://iss.moex.com/iss/engines/stock/markets/" &      IFS(                 or(            B10 = "TQOB",            B10 = "EQOB",            B10 = "TQOD",            B10 = "TQCB",            B10 = "EQQI",            B10 = "TQIR"        ),        "bonds",                 or(            B10 = "TQTF",            B10 = "TQBR",            B10 = "SNDX",            B10 = "TQIF"        ),        "shares"    )  & "/boards/" & B10 & "/securities.xml?iss.meta=off&iss.only=securities&securities.columns=SECID,PREVADMITTEDQUOTE",      "//row[@SECID='" & A10 & "']/@PREVADMITTEDQUOTE")

Получение даты и значения дивиденда для акций

Гугл таблица с примерами автоматического получения дат и значений дивидендов для акций Гугл таблица с примерами автоматического получения дат и значений дивидендов для акций

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

=iferror(     INDEX(         IMPORTxml(            "http://iss.moex.com/iss/securities/" & A22 & "/dividends.xml?iss.meta=off",            "//row[@secid='" & A22 & "']/@value"        )  ,         ROWS(            IMPORTxml(                "http://iss.moex.com/iss/securities/" & A22 & "/dividends.xml?iss.meta=off",                "//row[@secid='" & A22 & "']/@value"            )        )  ,        1    )  ,    "нет")

Получение даты купона и значения для облигаций

Гугл таблица с примерами автоматического получения дат купонов и значений для облигацийГугл таблица с примерами автоматического получения дат купонов и значений для облигаций

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

=IMPORTxml(    "https://iss.moex.com/iss/engines/stock/markets/" &      IFS(                 or(            B12 = "TQOB",            B12 = "EQOB",            B12 = "TQOD",            B12 = "TQCB",            B12 = "EQQI",            B12 = "TQIR"        ),        "bonds",                 or(            B12 = "TQTF",            B12 = "TQBR",            B12 = "SNDX",            B12 = "TQIF"        ),        "shares"    )  & "/boards/" & B12 & "/securities.xml?iss.meta=off&iss.only=securities&securities.columns=SECID,NEXTCOUPON,COUPONVALUE",      "//row[@SECID='" & A17 & "']/@COUPONVALUE")

Получение даты оферты

Гугл таблица с примерами автоматического получения дат оферт для облигацийГугл таблица с примерами автоматического получения дат оферт для облигаций

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

=IFNA(     IMPORTxml(        "https://iss.moex.com/iss/engines/stock/markets/" &          IFS(                         or(                B27 = "TQOB",                B27 = "EQOB",                B27 = "TQOD",                B27 = "TQCB",                B27 = "EQQI",                B27 = "TQIR"            ),            "bonds",                         or(                B27 = "TQTF",                B27 = "TQBR",                B27 = "SNDX",                B27 = "TQIF"            ),            "shares"        )  & "/boards/" & B27 & "/securities.xml?iss.meta=off&iss.only=securities&securities.columns=SECID,OFFERDATE",          "//row[@SECID='" & A27 & "']/@OFFERDATE"    )  ,    "нет")

Источник данных

Если вы хотите разобраться во всех нюансах работы - откуда берутся данные, то вы, также как и я можете обратиться к официальной документации к информационно-статистическому серверу Московской Биржи (ИСС / ISS). Правда, изучая этот документ, вы можете обнаружить что большая часть интересных функций, приведенная в этой статье, в документе никак не отображена.

Итоги

Король умер, да здравствует король! Считалось, что трон никогда не должен пустовать, поэтому после смерти короля сразу же объявлялся следующий правитель.

Как и в случае c сервисом Google Финансы, который перестал выдавать российские результаты мы видим что можно использовать API Московской биржи, которое предоставляет широкие возможности.

Эти формулы работают только за счет API Московской биржи, с которой я никак не связан. Использую ИСС Мосбиржи только в личных информационных интересах.

Автор: Михаил Шардин,

15 июня 2021 г.

Подробнее..

Визуализация аналитики APIM Gravitee в Grafana

30.05.2021 10:22:52 | Автор: admin

Бесспорно, интерфейс Gravitee представляет достаточно наглядные и удобные средства визуализации работы шлюзов Gravitee. Но в любом случае, возникает потребность предоставить доступ к этим инструментам службе мониторинга, владельцам или потребителям API и при этом они могут находится вне закрытого контура, в котором расположен менеджер API. Да и иметь всю доступную информацию по различным API на одном экране всегда удобнее.
Видеть происходящее на шлюзах, при этом не вдаваясь в особенности пользовательского интерфейса Gravitee, а администраторам - не тратить время на создание пользователей и разделение ролей и привилегий внутри Gravitee.
На Хабре уже была пара статей посвященных APIM Gravitee, тут и тут. По этому, в своей заметке, буду подразумевать, что читатель уже знаком с процессом установки/настройки APIM Gravitee и Grafana, рассмотрю только процесс настройки их интеграции.

Почему нельзя пойти простым путём?

По умолчанию, хранилищем для аналитики Gravitee является ElasticSearch. Информация накапливается в четырёх различных индексах, с посуточной разбивкой:

  • gravitee-request-YYYY.MM.DD - здесь хранится информация по каждому запросу (аналог access.log в nginx). Это наша основная цель;

  • gravitee-log-YYYY.MM.DD - здесь уже хранится более подробная информация о запросе (при условии, что включена отладка, см. рисунок ниже). А именно полные заголовки запросов и ответов, а также полезная нагрузка. В зависимости от настроек, логироваться может как обмен между потребителем и шлюзом, так и/или шлюзом и поставщиком API;

    Экран включения/отключения расширенного логированияЭкран включения/отключения расширенного логирования
  • gravitee-monitor-YYYY.MM.DD - этот нас не интересует;

  • gravitee-health-YYYY.MM.DD - этот нас не интересует.

И казалось бы, что может быть проще: подключай ElasticSearch в качестве источника данных в Grafana и визуализируй, но не всё так просто.
Во первых, в индексе хранятся только идентификаторы объектов, т.е. человеко-читаемых имён поставщиков и потребителей, вы там не увидите. Во вторых, получить полную информацию соединив данные из двух источников непосредственно в интерфейсе Grafana, крайне проблематично. Gravitee хранит информацию о настройках и статистику своей работы в разных местах. Настройки, в MongoDB или PostgreSQL, по сути статическая информация. Таким образом в одном месте у нас (в терминах Grafana) - таблица, в другом - временной ряд.

B как же быть?

Большим преимуществом СУБД PostgreSQL является богатый набор расширений для работы с внешними источниками данных, в том числе и с ElasticSearch (тут). Благодаря этому интеграция сводится к тому, что Grafana общается с единственным источником данных - СУБД PostgreSQL, которая в свою очередь получает данные из ElasticSearch и обогащает их информацией и делает читаемой для администратора или любого другого бенефициара.
Схематически это будет выглядеть следующим образом (рисунок ниже).

Схема взаимодействия модулей GraviteeСхема взаимодействия модулей Gravitee

Ну что же, за дело!

Все ниже описанные действия актуальны для следующей конфигурации: CentOS 7, APIM Gravitee 3.6, СУБД PostgreSQL 11, ElasticSearch 7.+

Начнём с интеграции PostgreSQL и ElasticSearch. Сам процесс интеграции достаточно прост и делится на следующие шаги:

  1. Устанавливаем расширение multicorn11 и если не установлен pip, то ставим и его:

    yum install multicorn11 python3-pip
    
  2. Далее из pip-репозитория, устанавливаем библиотеку python3 для работы с ElasticSearch:

    pip3 install pg_es_fdw
    
  3. Далее, переходим к настройке PostgreSQL. Подключаемся целевой БД и добавляем расширение multicorn и подключаем необходимую библиотеку:

    GRANT USAGE on FOREIGN DATA WRAPPER multicorn TO gatewaytest;GRANT USAGE ON FOREIGN SERVER multicorn_es TO gatewaytest;
    
     CREATE EXTENSION multicorn; CREATE SERVER multicorn_es FOREIGN DATA WRAPPER multicorn  OPTIONS (wrapper 'pg_es_fdw.ElasticsearchFDW');
    
  4. Выдаём права, непривилегированному пользователю. В нашем случае это logreader:

    GRANT USAGE on FOREIGN DATA WRAPPER multicorn TO logreader;GRANT USAGE ON FOREIGN SERVER multicorn_es TO logreader;
    
  5. Для удобства, создадим отдельную схему logging, владельцем которой будет наш пользователь logreader:

    CREATE SCHEMA logging AUTHORIZATION logreader;
    
  6. Создадим родительскую таблицу, к которой мы будем подключать новые индексы и удалять не актуальные:

    CREATE TABLE logging.requests (  id varchar(36),  "@timestamp" timestamp with time zone,  api varchar(36),  "api-response-time" int,  application varchar(36),  custom json,  endpoint text,  gateway varchar(36),  "local-address" varchar(16),  method int,  path text,  plan varchar(36),  "proxy-latency" int,  "remote-address" varchar(16),  "request-content-length" int,  "response-content-length" int,  "response-time" int,  sort text,  status int,  subscription varchar(36),  uri text,  query TEXT,  score NUMERIC) PARTITION BY RANGE("@timestamp");
    

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

  7. Для подключения и отключения индексов, создадим небольшой shell-скрипт и будем запускать его раз в сутки через cron:

    #!/bin/shNEWPART=${1:-$(date +'%Y.%m.%d')}OLDPART=$(date --date='14 days ago' +'%Y.%m.%d')curl http://gateway.corp/testpsql gateway -U logreader -c "CREATE FOREIGN TABLE logging.\"requests_${NEWPART}\"PARTITION OF logging.requests   FOR VALUES FROM ('${NEWPART} 00:00:00') TO ('${NEWPART} 23:59:59')SERVER multicorn_esOPTIONS (host 'els-host',  port '9200',  index 'gravitee-request-${NEWPART}',  rowid_column 'id',  query_column 'query',  query_dsl 'false',    score_column 'score',  sort_column 'sort',  refresh 'false',  complete_returning 'false',  timeout '20',  username 'elastic-ro',  password 'Sup3rS3cr3tP@ssw0rd');"    psql gateway -U gatewaydev -c "drop foreign table logging.\"requests_${OLDPART}\""
    

    Немного пояснений:

    • NEWPART - текущая дата, для формирования имени партиции , при подключении нового индекса из ElasticSearch;

    • OLDPART - дата истекшего, неактуально индекса, здесь это 14 дней (определяется исходя из настроек ES Curator). Удалять партиции, ссылающиеся на несуществующие - обязательно. В противном случае запросы, к родительской таблице, будут прерываться с ошибками;

    • Вызов 'curl http://gateway.corp/test', необходим для того, что бы создавался индекс текущего дня, так как он создаётся в момент первого обращения к любому поставщику API. Если его не создать, то это будет приводить к ошибке, описанной выше. Такая проблема больше актуальна для тестовых стендов и стендов разработки;

    • Затем, создаём партицию на индекс текущего дня;

    • И на последнем шаге - удаляем неактуальный индекс.

    • Проверяем что всё работает

      TABLE logging.requests LIMIT 1;
      

      Если всё правильно, то должны получить похожий результат

    -[ RECORD 1 ]-----------+-------------------------------------id                      | 55efea8a-9c91-4a61-afea-8a9c917a6133@timestamp              | 2021-05-16 00:00:02.025+03api                     | 9db39338-1019-453c-b393-381019f53c72api-response-time       | 0application             | 1custom                  | {}endpoint                | gateway                 | 7804bc6c-2b72-497f-84bc-6c2b72897fa9local-address           | 10.15.79.29method                  | 3path                    | plan                    | proxy-latency           | 2remote-address          | 10.15.79.27request-content-length  | 0response-content-length | 49response-time           | 2sort                    | status                  | 401subscription            | uri                     | /testquery                   | score                   | 1.0
    

Рисуем графики

И вот мы подошли к тому, ради чего всё и делалось - визуализируем статистику Gravitee. Благодаря тому, что для доступа к аналитике используется единая точка входа, а именно СУБД PostgreSQL, это даёт дополнительные возможности. Например, выводить статическую информацию: количество поставщиков, количество потребителей и их статусы; количество и состояние подписок; параметры конфигурации для поставщика и многое другое, наряду с динамическими данными.
В том числе хотелось бы отметить, что у поставщиков и потребителей имеется раздел Metadata, которые можно заполнять кастомными данными и так же выводить в дашборды Grafana.

Вот тут:

Раздел Metadata в GraviteeРаздел Metadata в Gravitee

А вот так это можно отобразить в Grafana:

Вариант отображения Metadata в GrafanaВариант отображения Metadata в Grafana
SELECT  name "Наименование",  value "Значение"FROM  metadataWHERE  reference_id='${apis}'

Пример комплексного экрана

Вариант комплексного экранаВариант комплексного экрана

APIs (статика) - общее количество поставщиков и количество активных.

SELECT COUNT(*) AS "Всего" FROM apis;SELECT COUNT(*) AS "Активных" FROM apis WHERE lifecycle_state='STARTED';

Для Applications, запросы составляются по аналогично, только из таблицы applications

API Hits - количество вызовов по каждому поставщику. Тут уже немного по сложнее

SELECT  date_trunc('minute',"@timestamp") AS time,  apis.name,ee с Grafana  COUNT(*)FROM  logging.requests alJOIN  apis ON al.api = apis.idWHERE  query='@timestamp:[$__from TO $__to]'GROUP BY 1,2

Average response time by API - среднее время ответа, по каждому поставщику считается аналогичным способом.

SELECT  date_trunc('minute',"@timestamp") AS time,  apis.name,  AVG(al."api-response-time")FROM  logging.requests alJOIN  apis ON al.api = apis.idWHERE  query='@timestamp:[$__from TO $__to]'GROUP BY 1,2

Еще один интересный показатель Hits, by gateways, это равномерность распределения запросов по шлюзам. Считается так:

SELECT  date_trunc('minute',"@timestamp") as time,  al."local-address",  COUNT(*)FROM  logging.requests alWHERE  query='@timestamp:[$__from TO $__to]'GROUP BY 1,2
График распределения запросов по шлюзамГрафик распределения запросов по шлюзам

Заключение

Приведённое выше решение, по моему субъективному мнению, нисколько не уступает стандартным средствам визуализации APIM Gravitee, а ограничено лишь фантазией и потребностями.
Учитывая то, что Grafana, обычно является центральным объектом инфраструктуры мониторинга, то преимущества такого решения очевидны: более широкий охват, более высокая плотность информации и простая кастомизация визуальных представлений.

P.S.

В ближайшее время, планируется ещё статья по интеграции Gravitee с ActiveDirectory. Процесс достаточно прост, но как всегда, есть нюансы.

Конструктивная критика, пожелания и предложения приветствуются!

Подробнее..

Как синхронизировать сценарий без транзакций? Штатными средствами Java

15.06.2021 02:14:19 | Автор: admin

Давайте представим, что вы параноик, и параноик вдвойне, когда дело касается многопоточности. Предположим, что вы делаете backend некого функционала приложения, а приложение переодически дергает на вашем серверы какие-то методы. Все вроде хорошо, но есть одно но. Что если ваш функционал напрямую зависит от каких-либо других данных, того же банального профиля например? Встает вопрос, как гарантировать то, что сценарий отработает именно так, как вы планировали и не будет каких-либо сюрпризов? Транзакции? Да это можно использовать, но что если Вы фантастический параноик и уже представляете как к вам на сервер летит 10 запросов к одному методу от разных клиентов и все строго в одно время. А в этот момент бизнес-логика данного метода завязана на 100500 разных данных. Как всем этим управлять? Можно просто синхронизировать метод и все. Но что если летят еще и те запросы, держать которые нет смысла? Тут уже начинаются костыли. Я пару раз уже задавался подобным вопросом, и были интересно, ведь задача до абсурда простая и повседневная (если вы заботитесь о том, чтобы не было логических багов конечно же :). Сегодня решил подумать, как это можно очень просто и без костылей реализовать. И решение вышло буквально на 100 строк кода.

Немного наглядного примера

Давайте предположим, что есть водитель и есть пассажир. Водитель не может менять машину до тех пор, пока клиент, например подтверждает поездку. Это что получается, клиент соглашался на поездку с одними характеристиками машины, а по факту у водителя другая машина? Не дела! Можно организовать что-то подобное:

String result = l.lock(new ArrayList<Locker.Item>() {{    add(new Locker.Item(SimpleType.TRIP, 1));    add(new Locker.Item(SimpleType.USER, 2));}}, () -> {    // Тут выполняем отмену поездки и держим водителя на привязи    // Кстати если кто-то где-то вызовет USER=2 (водитель), то он также будет ждать    // ну или кто-то обратится к поездке TRIP=1    // А если обратится к USER=3, то уже все будет нормально :)    // так как никто не блокировал третьего пользователя :)    return "Тут любой результат :)";    });

Элегантно и просто! :)

Исходники тут - https://github.com/GRIDMI/GRIDMI.Sync

Камнями не бросаться! :)

Подробнее..

Перевод Альтернатива ML-Agents интегрируем нейросети в Unity-проект с помощью PyTorch C API

05.06.2021 10:15:16 | Автор: admin


Кратко объясню, что будет происходить в этой статье:

  • покажу, как использовать PyTorch C++ API для интеграции нейросети в проект на движке Unity;
  • сам проект я подробно описывать не буду, это не имеет значения для данной статьи;
  • использую готовую модель нейросети, преобразовав её трассировку в бинарник, который будет подгружаться в рантайме;
  • покажу, что такой подход существенно облегчает деплой сложных проектов (например, нет проблем с синхронизацией сред Unity и Python).

Добро пожаловать в реальный мир


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

Другими словами, мне нужно превратить исследовательский проект на PyTorch в готовое решение, способное вместе с движком Unity работать в боевых условиях.

Можно несколькими способами интегрировать нейронную сеть в Unity. Я предлагаю использовать C++ API для PyTorch (под названием libtorch) для создания нативной разделяемой библиотеки, которую затем можно будет подключить к Unity как плагин. Существуют и другие подходы (например, использовать ML-Agents), которые в определённых случаях могут быть проще и эффективнее. Но преимущество моего подхода заключается в том, что он обеспечивает большую гибкость и даёт больше возможностей.

Допустим, у вас есть какая-то экзотическая модель и вы просто хотите использовать существующий PyTorch-код (который был написан без намерения общаться с Unity); или ваша команда разрабатывает собственную модель и не хочет отвлекаться на мысли о Unity. В обоих случаях код модели может быть сколь угодно сложным и использовать все возможности PyTorch. А если вдруг дело дойдёт до интеграции, в игру вступит C++ API и завернёт всё в библиотеку без малейшего изменения изначального PyTorch-кода модели.

Итак, мой подход сводится к четырём ключевым шагам:

  1. Настройка окружения.
  2. Подготовка нативной библиотеки (C++).
  3. Импорт функций из библиотеки / подключение плагина (Unity / C#).
  4. Сохранение / развёртывание модели.


ВАЖНО: поскольку я делал проект, сидя под Linux, некоторые команды и настройки отталкиваются от этой ОС; но не думаю, что здесь что-либо должно слишком зависеть от неё. Поэтому вряд ли подготовка библиотеки под Windows вызовет трудности.

Настройка окружения


Прежде чем устанавливать libtorch, убедитесь, что у вас есть

  • CMake

А если хотите использовать GPU, вам потребуются:


С CUDA могут возникнуть сложности, потому что драйвер, библиотеки и прочая хурма должны дружить между собой. И вам придётся поставлять эти библиотеки вместе с Unity-проектом чтобы всё работало из коробки. Так что для меня это самая неудобная часть. Если вы не планируете использовать GPU и CUDA, то знайте: вычисления замедлятся в 50100 раз. И даже если у пользователя довольно слабый графический процессор лучше с ним, чем без него. Даже если ваша нейросеть включается в работу довольно редко, эти редкие включения приведут к задержке, которая будет раздражать пользователя. Возможно, в вашем случае всё будет иначе, но нужен ли вам этот риск?

После того, как вы установили означенное выше ПО, пора загрузить и (локально) установить libtorch. Необязательно устанавливать для всех пользователей: можно просто поместить её в каталог своего проекта и обратиться к нему при запуске CMake.

Подготовка нативной библиотеки


Следующий шаг конфигурирование CMake. Я взял за основу пример из документации PyTorch и изменил его так, чтобы после сборки мы получали библиотеку, а не исполняемый файл. Положите этот файл в корневой каталог вашего проекта с нативной библиотекой.

CMakeLists.txt

cmake_minimum_required(VERSION 3.0 FATAL_ERROR)project(networks)find_package(Torch REQUIRED)set(CMAKE_CXX_FLAGS ${CMAKE_CXX_FLAGS} ${TORCH_CXX_FLAGS})add_library(networks SHARED networks.cpp)target_link_libraries(networks ${TORCH_LIBRARIES})set_property(TARGET networks PROPERTY CXX_STANDARD 14)if (MSVC)file(GLOB TORCH_DLLS ${TORCH_INSTALL_PREFIX}/lib/*.dll)add_custom_command(TARGET networksPOST_BUILDCOMMAND ${CMAKE_COMMAND} -E copy_if_different${TORCH_DLLS}$<TARGET_FILE_DIR:example-app>)endif (MSVC)

Исходный код библиотеки будет размещён в networks.cpp.

В этом подходе есть ещё одна приятная особенность: нам пока не нужно думать, какую именно нейронную сеть мы хотим использовать с Unity. Причина (немного забегая вперед) заключается в том, что мы в любой момент можем запустить сеть в Python, получить её трассировку и просто сказать libtorch применить эту трассировку для этих входов. Поэтому, можно сказать, что наша нативная библиотека просто обслуживает некий чёрный ящик, работая с вводом-выводом.

Но если вы хотите усложнить задачу и, например, реализовать обучение сети прямо во время работы среды Unity, то вам придётся написать на C++ архитектуру сети и обучающий алгоритм. Однако это выходит за рамки данной статьи, поэтому для получения дополнительной информации я отсылаю вас к соответствующему разделу документации PyTorch и репозиторию с примерами кода.

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

networks.cpp

#include <torch/script.h>#include <vector>#include <memory>extern C{// This is going to store the loaded networktorch::jit::script::Module network;

Чтобы вызывать функции нашей библиотеки непосредственно из Unity, нужно передать информацию об их точках входа. В Linux я использую для этого __attribute__((visibility(default))). В Windows для этого существует спецификатор __declspec( dllexport ), но, честно говоря, я не проверял, работает ли он там.

Итак, начнём с функции загрузки трассировки нейросети с диска. Файл имеет относительный путь он лежит в корневом каталоге проекта Unity, а не в Assets/. Так что будьте внимательны. Вы также можете просто передать имя файла из Unity.
extern __attribute__((visibility(default))) void InitNetwork(){network = torch::jit::load(network_trace.pt);network.to(at::kCUDA); // If we're doing this on GPU}

Теперь перейдём к функции, которая кормит сеть входными данными. Напишем на С++ код, который использует указатели (ими управляет Unity) для перегонки данных туда и обратно. В этом примере я полагаю, что моя сеть имеет входы и выходы фиксированной размерности и запрещаю Unity менять это. Здесь, например, я возьму Tensor {1,3,64,64} и Tensor {1,5,64,64} (например, такая сеть нужна для сегментации пикселей RGB-изображений на 5 групп).

В общем случае вам придётся передать информацию о размерности и объёме данных, чтобы избежать переполнения буфера.

Чтобы преобразовать данные в формат, с которым работает libtorch, мы используем функцию torch::from_blob. Она принимает массив чисел с плавающей запятой и описание тензора (с указанием размерности) и возвращает созданный Тензор.

Нейросети могут принимать несколько входных аргументов (например, вызов forward () принимает x, y, z в качестве входных данных). Чтобы справиться с этим, все входные тензоры упаковываются в вектор стандартной библиотеки шаблонов torch::jit::IValue (даже если аргумент только один).

Чтобы получить данные из тензора, проще всего обработать их поэлементно, но если из-за этого упадёт скорость обработки, для оптимизации процесса чтения данных можно использовать Tensor::accessor. Хотя лично мне это не понадобилось.

В результате для моей нейросети получается вот такой простой код:

extern __attribute__((visibility(default))) void ApplyNetwork(float *data, float *output){Tensor x = torch::from_blob(data, {1,3,64,64}).cuda();std::vector<torch::jit::IValue> inputs;inputs.push_back(x);Tensor z = network.forward(inputs).toTensor();for (int i=0;i<1*5*64*64;i++)output[i] = z[0][i].item<float>();}}

Чтобы скомпилировать код, следуйте указаниям в документации, создайте подкаталог build/ и запустите следующие команды:

cmake -DCMAKE_PREFIX_PATH=/absolute/path/to/libtorch <strong>..</strong>cmake --build <strong>.</strong> --config Release

Если всё пойдёт хорошо, будут созданы файлы libnetworks.so или networks.dll, которые вы сможете разместить в Assets/Plugins/ вашего Unity-проекта.

Подключение плагина к Unity


Для импорта функций из библиотеки используем DllImport. Первая функция, которая нам понадобится, это InitNetwork(). При подключении плагина Unity вызовет именно её:

using System.Runtime.InteropServices;public class Startup : MonoBehaviour{...[DllImport(networks)]private static extern void InitNetwork();void Start(){...InitNetwork();...}}

Чтобы движок Unity (С#) мог обмениваться данными с библиотекой (C++), я поручу ему всю работу по управлению памятью:

  • выделю память под массивы нужного размера на стороне Unity;
  • передам адрес первого элемента массива в функцию ApplyNetwork (её тоже перед этим нужно импортировать);
  • просто позволю адресной арифметике C++ обращаться к этой памяти при получении или отправке данных.

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

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

[DllImport(networks)]private static extern void ApplyNetwork(ref float data, ref float output);void SomeFunction() {float[] input = new float[1*3*64*64];float[] output = new float[1*5*64*64];// Load input with whatever data you want...ApplyNetwork(ref input[0], ref output[0]);// Do whatever you want with the output...}

Сохранение модели


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

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

Так может выглядеть Python-код для нашей простой модели:

import torchimport torch.nn as nnimport torch.nn.functional as Fclass Net(nn.Module):def __init__(self):super().__init__()self.c1 = nn.Conv2d(3,64,5,padding=2)self.c2 = nn.Conv2d(64,5,5,padding=2)def forward(self, x): z = F.leaky_relu(self.c1(x)) z = F.log_softmax(self.c2(z), dim=1)return zКод не очень красивый, конечно, но, думаю, идея понятна.Сохранить (экспортировать) модель с текущими значениями коэффициентов можно так:network = Net().cuda()example = torch.rand(1, 3, 32, 32).cuda()traced_network = torch.jit.trace(network, example)traced_network.save(network_trace.pt)

Развёртывание модели


Мы сделали статическую библиотеку, но для развёртывания этого недостаточно: в проект нужно включить дополнительные библиотеки. К сожалению, у меня нет 100-процентной уверенности в том, какие именно библиотеки нужно включить обязательно. Я выбрал libtorch, libc10, libc10_cuda, libnvToolsExt и libcudart. В сумме они добавляют 2 Гб к изначальному размеру проекта.

LibTorch vs ML-Agents


Я считаю, что для многих проектов, особенно в области исследований и прототипирования, действительно стоит выбрать ML-Agents, плагин, созданный специально для Unity. Но когда проекты становятся более сложными, нужно подстраховаться на случай, если что-то пойдёт не так. А такое случается нередко

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

Мне пришлось основательно покопаться в Python API для ML-Agents. Некоторые операции, которые я использовал в моих нейросетях, например 1d свёртка и операции транспонирования, не поддерживались в Barracuda (это библиотека трассировки, которую в настоящее время использует ML-Agents).

Проблема, с которой я столкнулся, заключалась в том, что ML-Agents собирает запросы от агентов в течение некого временного интервала, а затем для оценки отправляет, к примеру, в Jupyter notebook. Однако некоторые из моих нейросетей зависели от результатов работы других моих сетей. И, чтобы получить оценку всей цепочки моих нейронных сетей, мне пришлось бы каждый раз, делая запрос, ждать какое-то время, получать результат, делать другой запрос, ждать, получать результат и так далее. Кроме того, порядок включения этих сетей в работу нетривиально зависел от ввода данных пользователем. А это означало, что я не мог просто последовательно запускать нейросети.

Кроме того, в некоторых случаях объём данных, который мне нужно было отправить, должен был варьироваться. А ML-Agents больше рассчитан на фиксированную размерность для каждого агента (вроде бы её можно менять на лету, но я отношусь к этому скептически).

Я мог бы сделать что-то вроде вычисления последовательности вызова нейросетей по запросу, отправляя соответствующие входные данные для Python API. Но из-за этого мой код, как на стороне Unity, так и на стороне Python, стал бы слишком сложным, или вовсе избыточным. Поэтому я решил изучить подход с использованием libtorch и не прогадал.

Если бы раньше кто-нибудь попросил меня встроить в Unity-проект предсказательную модель GPT-2 или MAML, я бы посоветовал ему постараться обойтись без этого. Реализация такой задачи с использованием ML-Agents слишком сложна. Но теперь я могу найти или разработать любую модель с PyTorch, а потом завернуть её в нативную библиотеку, которая подключается к Unity как обычный плагин.



Облачные серверы от Маклауд быстрые и безопасные.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Продолжаем знакомство с APIM Gravitee

27.05.2021 20:23:24 | Автор: admin

Всем привет! Меня всё ещё зовут Антон. В предыдущейстатьея провел небольшой обзор APIM Gravitee и в целом систем типа API Management. В этой статье я расскажу,как поднять ознакомительный стенд APIM Gravitee (https://www.gravitee.io), рассмотрим архитектуру системы, содержимое docker compose file, добавим некоторые параметры, запустим APIM Gravitee и сделаем первую API. Статья немного погружает в технические аспекты и может быть полезна администраторам и инженерам, чтобы начать разбираться в системе.

Архитектура

Для ознакомительного стенда будем использовать простейшую архитектуру

Все в докере, в том числе и MongoDB и Elasticsearch. Чтобы сделать полноценную среду для тестирования крайне желательно компоненты MongoDB и Elasticsearch вынести за пределы Docker. Также для простоты манипулирования настройками можно вынести конфигурационные файлы Gateway и Management API: logback.xml и gravitee.yml.

docker-compose.yml

Среду для начальных шагов будем поднимать, используяdocker-compose file, предоставленный разработчиками наgithub. Правда,внесемнесколькокорректив.

docker-compose.yml# Copyright (C) 2015 The Gravitee team (<http://gravitee.io>)## Licensed under the Apache License, Version 2.0 (the "License");# you may not use this file except in compliance with the License.# You may obtain a copy of the License at##<http://www.apache.org/licenses/LICENSE-2.0>## Unless required by applicable law or agreed to in writing, software# distributed under the License is distributed on an "AS IS" BASIS,# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.# See the License for the specific language governing permissions and# limitations under the License.#version: '3.5'networks:frontend:name: frontendstorage:name: storagevolumes:data-elasticsearch:data-mongo:services:mongodb:image: mongo:${MONGODB_VERSION:-3.6}container_name: gio_apim_mongodbrestart: alwaysvolumes:- data-mongo:/data/db- ./logs/apim-mongodb:/var/log/mongodbnetworks:- storageelasticsearch:image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-7.7.0}container_name: gio_apim_elasticsearchrestart: alwaysvolumes:- data-elasticsearch:/usr/share/elasticsearch/dataenvironment:- http.host=0.0.0.0- transport.host=0.0.0.0- xpack.security.enabled=false- xpack.monitoring.enabled=false- cluster.name=elasticsearch- bootstrap.memory_lock=true- discovery.type=single-node- "ES_JAVA_OPTS=-Xms512m -Xmx512m"ulimits:memlock:soft: -1hard: -1nofile: 65536networks:- storagegateway:image: graviteeio/apim-gateway:${APIM_VERSION:-3}container_name: gio_apim_gatewayrestart: alwaysports:- "8082:8082"depends_on:- mongodb- elasticsearchvolumes:- ./logs/apim-gateway:/opt/graviteeio-gateway/logsenvironment:- gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000- gravitee_ratelimit_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000- gravitee_reporters_elasticsearch_endpoints_0=http://elasticsearch:9200networks:- storage- frontendmanagement_api:image: graviteeio/apim-management-api:${APIM_VERSION:-3}container_name: gio_apim_management_apirestart: alwaysports:- "8083:8083"links:- mongodb- elasticsearchdepends_on:- mongodb- elasticsearchvolumes:- ./logs/apim-management-api:/opt/graviteeio-management-api/logsenvironment:- gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000- gravitee_analytics_elasticsearch_endpoints_0=http://elasticsearch:9200networks:- storage- frontendmanagement_ui:image: graviteeio/apim-management-ui:${APIM_VERSION:-3}container_name: gio_apim_management_uirestart: alwaysports:- "8084:8080"depends_on:- management_apienvironment:- MGMT_API_URL=http://localhost:8083/management/organizations/DEFAULT/environments/DEFAULT/volumes:- ./logs/apim-management-ui:/var/log/nginxnetworks:- frontendportal_ui:image: graviteeio/apim-portal-ui:${APIM_VERSION:-3}container_name: gio_apim_portal_uirestart: alwaysports:- "8085:8080"depends_on:- management_apienvironment:- PORTAL_API_URL=http://localhost:8083/portal/environments/DEFAULTvolumes:- ./logs/apim-portal-ui:/var/log/nginxnetworks:- frontend

Первичные системы, на основе которых строится весь остальной сервис:<o:p>

  1. MongoDB - хранение настроек системы, API, Application, групп, пользователей и журнала аудита.

  2. Elasticsearch(Open Distro for Elasticsearch) - хранение логов, метрик, данных мониторинга.

MongoDB

docker-compose.yml:mongodb

mongodb:image: mongo:${MONGODB_VERSION:-3.6}<o:p>container_name: gio_apim_mongodb<o:p>restart: always<o:p>volumes:<o:p>- data-mongo:/data/db<o:p>- ./logs/apim-mongodb:/var/log/mongodb<o:p>networks:<o:p>- storage<o:p>

С MongoDB всё просто поднимается единственный экземпляр версии 3.6, если не указано иное, с volume для логов самой MongoDB и для данных в MongoDB.<o:p>

Elasticsearch

docker-compose.yml:elasticsearch

elasticsearch:image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-7.7.0}<o:p>container_name: gio_apim_elasticsearchrestart: alwaysvolumes:- data-elasticsearch:/usr/share/elasticsearch/dataenvironment:- http.host=0.0.0.0- transport.host=0.0.0.0<o:p>- xpack.security.enabled=false- xpack.monitoring.enabled=false- cluster.name=elasticsearch- bootstrap.memory_lock=true- discovery.type=single-node- "ES_JAVA_OPTS=-Xms512m -Xmx512m"ulimitsmemlock:soft: -1hard: -1nofile: 65536networks:- storage

elasticsearch:

elasticsearch:image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-7.7.0}container_name: gio_apim_elasticsearchrestart: alwaysvolumes:- data-elasticsearch:/usr/share/elasticsearch/data<o:p>environment:- http.host=0.0.0.0- transport.host=0.0.0.0- xpack.security.enabled=false- xpack.monitoring.enabled=false- cluster.name=elasticsearch- bootstrap.memory_lock=true- discovery.type=single-node- "ES_JAVA_OPTS=-Xms512m -Xmx512m"ulimits:memlock:soft: -1hard: -1nofile: 65536networks:- storage

С Elasticsearch также всё просто поднимается единственный экземпляр версии 7.7.0, если не указано иное, с volume для данных в Elasticsearch. Сразу стоит убрать строки xpack.security.enabled=false и xpack.monitoring.enabled=false, так как хоть они и указаны как false, Elasticsearch пытается найти XPack и падает. Исправили ли этот баг в новых версиях не понятно, так что просто убираем их, или комментируем. Также стоит обратить внимание на секцию ulimits, так как она требуется для нормальной работы Elasticsearch в docker.<o:p>

Дальше уже поднимаются компоненты сервиса:

  1. Gateway

  2. Management API

  3. Management UI

  4. Portal UI

Gateway/APIM Gateway

docker-compose.yml:gatewaygateway:image: graviteeio/apim-gateway:${APIM_VERSION:-3}container_name: gio_apim_gatewayrestart: alwaysports:- "8082:8082"depends_on:- mongodb- elasticsearchvolumes:- ./logs/apim-gateway:/opt/graviteeio-gateway/logs      environment:    - gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000      - gravitee_ratelimit_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000- gravitee_reporters_elasticsearch_endpoints_0=http://elasticsearch:9200networks:- storage- frontend<o:p>

С Gateway всё несколько сложнее поднимается единственный экземпляр версии 3, если не указано иное. Если мы посмотрим, что лежит наhub.docker.com, то увидим, что у версий 3 и latest совпадают хеши. Дальше мы видим, что запуск данного сервиса, зависит от того, как будут работать сервисы MongoDB иElasticsearch. Самое интересное, что если Gateway запустился и забрал данные по настроенным API из mongodb, то дальше связь с mongodb и elasticsearch не обязательна. Только в логи будут ошибки сыпаться, но сам сервис будет работать и соединения обрабатывать согласно той версии настроек, которую последний раз закачал в себя Gateway. В секции environment можно указать параметры, которые будут использоваться в работе самого Gateway, для переписывания данных из главного файла настроек: gravitee.yml. Как вариант можно поставить теги, тенанты для разграничения пространств, если используется Open Distro for Elasticsearch вместо ванильного Elasticsearch. Например, так мы можем добавить теги, тенанты и включить подсистему вывода данных о работе шлюза.

environment:- gravitee_tags=service-tag #включаемтег: service-tag- gravitee_tenant=service-space #включаемтенант: service-space- gravitee_services_core_http_enabled=true # включаем сервис выдачи данных по работе Gateway  - gravitee_services_core_http_port=18082 # порт сервиса- gravitee_services_core_http_host=0.0.0.0 # адрес сервиса- gravitee_services_core_http_authentication_type=basic # аутентификация либо нет, либо basic - логин + пароль  - gravitee_services_core_http_authentication_type_users_admin=password #логин: admin,пароль: password  Чтобы к подсистеме мониторинга был доступ из вне, надо ещё открыть порт 18082.ports:- "18082:18082"Management API/APIM APIdocker-compose.yml:management_apimanagement_api:image: graviteeio/apim-management-api:${APIM_VERSION:-3}container_name: gio_apim_management_apirestart: alwaysports:- "8083:8083"links:- mongodb- elasticsearchdepends_on:- mongodb- elasticsearchvolumes:- ./logs/apim-management-api:/opt/graviteeio-management-api/logsenvironment:- gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000  - gravitee_analytics_elasticsearch_endpoints_0=http://elasticsearch:9200 networks:    - storage- frontend

ManagementAPI это ядро всей системы и предоставляет службы для управления и настройкиAPI, логи, аналитики и веб-интерфейсовManagementUIиPortalUI. Зависит от MongoDB и Elasticsearch. Также можно через секцию environment указать параметры, которые будут использоваться в работе самого ядра системы. Дополним наши настройки:

environment:- gravitee_email_enable=true # включаем возможность отправлять письма- gravitee_email_host=smtp.domain.example # указываем сервер через который будем отправлять письма- gravitee_email_port=25 # указываем порт для сервера- gravitee_email_username=domain.example/gravitee #логиндлясервера- gravitee_email_password=password #парольдлялогинаотсервера  - gravitee_email_from=noreply@domain.example # указываем от чьего имени будут письма- gravitee_email_subject="[Gravitee.io] %s" #указываемтемуписьма

Management UI/APIM Console

docker-compose.yml:apim_consolemanagement_ui:image: graviteeio/apim-management-ui:${APIM_VERSION:-3}container_name: gio_apim_management_uirestart: alwaysports:- "8084:8080"depends_on:- management_apienvironment:- MGMT_API_URL=http://localhost:8083/management/organizations/DEFAULT/environments/DEFAULT/      volumes:    - ./logs/apim-management-ui:/var/log/nginxnetworks:- frontend

Management UI предоставляет интерфейс для работы администраторам и разработчикам. Все основные функции можно осуществлять и выполняя запросы непосредственно к REST API. По опыту могу сказать, что в переменной MGMT_API_URL вместоlocalhostнадо указать IP адрес или название сервера, где вы это поднимаете, иначе контейнер не найдет Management API.

Portal UI/APIM Portaldocker-compose.yml:apim_portalportal_ui:image: graviteeio/apim-portal-ui:${APIM_VERSION:-3}container_name: gio_apim_portal_uirestart: alwaysports:- "8085:8080"depends_on:- management_apienvironment:- PORTAL_API_URL=http://localhost:8083/portal/environments/DEFAULTvolumes:- ./logs/apim-portal-ui:/var/log/nginxnetworks:- frontend

Portal UI это портал для менеджеров. Предоставляет доступ к логам, метрикам и документации по опубликованным API. По опыту могу сказать, что в переменной PORTAL_API_URL вместоlocalhostнадо указать IP-адрес или название сервера, где вы это поднимаете, иначе контейнер не найдет Management API.<o:p>

Теперь соберем весь файл вместе.

docker-compose.yml

# Copyright (C) 2015 The Gravitee team (<http://gravitee.io>)## Licensed under the Apache License, Version 2.0 (the "License");# you may not use this file except in compliance with the License.# You may obtain a copy of the License at##         <http://www.apache.org/licenses/LICENSE-2.0>## Unless required by applicable law or agreed to in writing, software# distributed under the License is distributed on an "AS IS" BASIS,# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.# See the License for the specific language governing permissions and# limitations under the License.#version: '3.5'networks:  frontend:    name: frontend  storage:    name: storagevolumes:  data-elasticsearch:  data-mongo:services:  mongodb:    image: mongo:${MONGODB_VERSION:-3.6}    container_name: gio_apim_mongodb    restart: always    volumes:      - data-mongo:/data/db      - ./logs/apim-mongodb:/var/log/mongodb    networks:      - storage  elasticsearch:    image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-7.7.0}    container_name: gio_apim_elasticsearch    restart: always    volumes:      - data-elasticsearch:/usr/share/elasticsearch/data    environment:      - http.host=0.0.0.0      - transport.host=0.0.0.0      - cluster.name=elasticsearch      - bootstrap.memory_lock=true      - discovery.type=single-node      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"    ulimits:      memlock:        soft: -1        hard: -1      nofile: 65536    networks:      - storage  gateway:    image: graviteeio/apim-gateway:${APIM_VERSION:-3}    container_name: gio_apim_gateway    restart: always    ports:      - "8082:8082"      - "18082:18082"    depends_on:      - mongodb      - elasticsearch    volumes:      - ./logs/apim-gateway:/opt/graviteeio-gateway/logs    environment:      - gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000      - gravitee_ratelimit_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000      - gravitee_reporters_elasticsearch_endpoints_0=http://elasticsearch:9200         - gravitee_tags=service-tag # включаем тег: service-tag         - gravitee_tenant=service-space # включаем тенант: service-space         - gravitee_services_core_http_enabled=true # включаем сервис выдачи данных по работе Gateway         - gravitee_services_core_http_port=18082 # порт сервиса         - gravitee_services_core_http_host=0.0.0.0 # адрес сервиса          - gravitee_services_core_http_authentication_type=basic # аутентификация либо нет, либо basic - логин + пароль         - gravitee_services_core_http_authentication_type_users_admin=password # логин: admin, пароль: password    networks:      - storage      - frontend  management_api:    image: graviteeio/apim-management-api:${APIM_VERSION:-3}    container_name: gio_apim_management_api    restart: always    ports:      - "8083:8083"    links:      - mongodb      - elasticsearch    depends_on:      - mongodb      - elasticsearch    volumes:      - ./logs/apim-management-api:/opt/graviteeio-management-api/logs    environment:      - gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000      - gravitee_analytics_elasticsearch_endpoints_0=http://elasticsearch:9200         - gravitee_email_enable=true # включаем возможность отправлять письма         - gravitee_email_host=smtp.domain.example # указываем сервер через который будем отправлять письма         - gravitee_email_port=25 # указываем порт для сервера         - gravitee_email_username=domain.example/gravitee # логин для сервера         - gravitee_email_password=password # пароль для логина от сервера         - gravitee_email_from=noreply@domain.example # указываем от чьего имени будут письма          - gravitee_email_subject="[Gravitee.io] %s" # указываем тему письма    networks:      - storage      - frontend  management_ui:    image: graviteeio/apim-management-ui:${APIM_VERSION:-3}    container_name: gio_apim_management_ui    restart: always    ports:      - "8084:8080"    depends_on:      - management_api    environment:      - MGMT_API_URL=http://localhost:8083/management/organizations/DEFAULT/environments/DEFAULT/    volumes:      - ./logs/apim-management-ui:/var/log/nginx    networks:      - frontend  portal_ui:    image: graviteeio/apim-portal-ui:${APIM_VERSION:-3}    container_name: gio_apim_portal_ui    restart: always    ports:      - "8085:8080"    depends_on:      - management_api    environment:      - PORTAL_API_URL=http://localhost:8083/portal/environments/DEFAULT    volumes:      - ./logs/apim-portal-ui:/var/log/nginx    networks:      - frontend

Запускаем

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

vCPU: 4

RAM: 4 GB

HDD: 50-100 GB

Для работы Elasticsearch, MongoDB и Gravitee Gateway нужно примерно по 0.5 vCPU, больше только лучше. Примерно тоже самое и с оперативной памятью - RAM. Остальные сервисы по остаточному принципу. Для хранения настроек много места не требуется, но учитывайте, что в MongoDB еще хранятся логи аудита системы. На начальном этапе это будет в пределах 100 MB. Остальное место потребуется для хранения логов в Elasticsearch.

docker-compose up -d # если не хотите видеть логиdocker-compose up # если хотите видеть логи и как это все работает

Как только в логах увидите строки:

gio_apim_management_api_dev | 19:57:12.615 [graviteeio-node] INFO  i.g.r.a.s.node.GraviteeApisNode - Gravitee.io - Rest APIs id[5728f320-ba2b-4a39-a8f3-20ba2bda39ac] version[3.5.3] pid[1] build[23#2f1cec123ad1fae2ef96f1242dddc0846592d222] jvm[AdoptOpenJDK/OpenJDK 64-Bit Server VM/11.0.10+9] started in 31512 ms.

Можно переходить по адресу: http://ваш_адрес:8084/.

Нужно учесть, что Elasticsearch может подниматься несколько дольше, поэтому не пугайтесь если увидите такое "приглашение":

Надо просто ещё немного подождать. Если ошибка не ушла, то надо закапываться в логи и смотреть, что там за ошибки. Видим такое приглашение отлично!

Вводим стандартный логин и пароль: admin/admin и мы в системе!

Первичная настройка

Настройки самой системы

Переходим в менюSettings PORTAL Settings

Здесь можно настроить некоторый параметры системы. Например: доступные методы аутентификации наших клиентов: Keyless, API_KEY, Oauth2 или JWT. Подробно их мы рассмотрим скорее всего в третьей статье, а пока оставим как есть. Можно подключить Google Analytics. Время обновления по задачам и нотификациям. Где лежит документация и много ещё чего по мелочи.

Добавление tags и tenant

ПереходимвменюSettings GATEWAY Shardings Tags

Здесь надо добавить теги, по которым у нас будут различаться шлюзы. Нажимаем "+" и добавляем тег и его описание.

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

ПереходимвменюSettings GATEWAY Tenants

То же самое и с настройкой тенантов. Только тут нет кнопки "+", но есть серенькая надпись "New tenant", которую надо нажать для добавления нового тенанта. Естественно, данный тенант должен быть создан в Open Distro for Elasticsearch, и к нему должны быть выданы права.

Добавление пользователей

Переходим вSettings USER MANAGEMENT Users

Здесь можно добавлять пользователей, вот только работать это будет, если у нас настроена рассылка по email. Иначе новым пользователям не придёт рассылка от системы со ссылкой на сброс пароля. Есть ещё один способ добавить пользователя, но это прям хардкод-хардкод!

В файле настроек Management API: gravitee.yml есть такой кусочек настроек:

security:  providers:  # authentication providers    - type: memory      # password encoding/hashing algorithm. One of:      # - bcrypt : passwords are hashed with bcrypt (supports only $2a$ algorithm)      # - none : passwords are not hashed/encrypted      # default value is bcrypt      password-encoding-algo: bcrypt      users:        - user:          username: admin          password: $2a$10$Ihk05VSds5rUSgMdsMVi9OKMIx2yUvMz7y9VP3rJmQeizZLrhLMyq          roles: ORGANIZATION:ADMIN,ENVIRONMENT:ADMIN

Здесьперечисленытипыхранилищдляпользователей: memory, graviteeиldap.Данные из хранилищаmemoryберутся из файла настроек:gravitee.yml. Данные из хранилищаgraviteeхранятся вMongoDB. Для хранения пользовательских паролей, по умолчанию используется тип хеширования BCrypt с алгоритмом $2a$. В представленных настройках мы видим пользователя: admin с хешированным паролем: admin и его роли. Если мы будем добавлять пользователей через UI, то пользователи будут храниться в MongoDB и тип их будет уже gravitee.

Создание групп пользователей

Переходим вSettings USER MANAGEMENT Groups

При нажатии на "+" получаем возможность добавить группу и пользователей в эту группу.

Проверка доступных шлюзов

При переходе в меню Gateways у нас отображаются все шлюзы, которые у нас есть в доступе.

Здесь мы видим настройки шлюза. В частности, Sharding tags и Tenant. Их мы добавили чуть раньше.

Естественно, есть возможность мониторинга состояния шлюза. Данные по мониторингу хранятся в Elasticsearch в отдельном индексе.

Публикация первого API

Для публикации первого API нам сначала потребуется сделать какой-нибудь backend с API.

BackEnd с API, балеринами и Swagger.

Возьмём FastAPI и сделаем простейшее backend с API.

#!/bin/env python3import uvicornfrom fastapi import FastAPIapp = FastAPI()@app.get('/')@app.get('/{name}')def read_root(name: str = None):    """    Hello world    :return: str = Hello world    """    if name:        return {"Hello": name}    return {"Hello": "World"}@app.get("/items/{item_id}")@app.post("/items/{item_id}")@app.put("/items/{item_id}")def gpp_item(item_id: str):    """    Get items    :param item_id: id    :return: dict    """    return {"item_id": item_id}if __name__ == "__main__":    uvicorn.run(app, host="0.0.0.0", port=8000)

Это иAPIможно назвать с трудом, но для примера работы вполне хватит.

Теперь запустим его! Можно даже на том же сервере.

python3 main.py

Теперь, если мы зайдем на этот серверhttp://backend_server:8000/,мы увидим приветствие миру! Если подставим своё имя, типа так:http://backend_server:8000/Anton, то приветствовать уже будут вас! Если же хотите познать всю мощьFastAPI, то сразу заходите на адрес:http://backend_server:8000/docsилиhttp://backend_server:8000/redoc. На этих страницах можно найти все методы, с которым работает данное API и также ссылку на swagger файл. Копируем URL до swagger файла.

В прошлый раз мы создавали наш план вручную. Было несколько утомительно. Сегодня мы сделаем все быстрее, через импорт swagger файла!

На главном экране Gravitee нажимаем большой "+", выбираем "IMPORT FROM LINK", вставляем URL и нажимаем "IMPORT".

Получается как-то так

Нажимаем "IMPORT"!

Почти полностью сформированный API! Теперь немного доработаем напильником...

Для начала нажимаем "START THE API" чтобы API запустить.

Переходим в "Plans" и нажимаем "+".

Создаем тестовый план.

Тип аутентификации выбираем Keyless (public) и нажимаем "NEXT".

Ограничения по количеству запросов и путям пропускаем. Нажимаем "NEXT".

Политики нам тоже пока не нужны. Нажимаем "SAVE".

План создан, но пока находиться в стадии "Staging"

Нажимаем на кнопку публикации плана - синее облачко со стрелочкой вверх! Подтверждаем кнопкой "PUBLISH"

И получаем опубликованный план и какую-то желтую полоску с призывом синхронизировать новую версию API.

Нажимаем "deploy your API" и подтверждаем наше желание "OK"

Переходим вAPIs Proxy Entrypoints

Здесь можно указать точки входа для нашего API и URL пути. У нас указан только путь "/fastapi". Можно переключиться в режим "virtual-hosts" и тогда нам будет доступен вариант с указанием конкретных серверов и IP. Это может быть актуально для серверов с несколькими сетевыми интерфейсами.

ВAPIs Proxy GENERAL CORSможнопроизвестинастройкиCross-origin resource sharing.

ВAPIs Proxy GENERAL Deploymentsнадоуказатьвсеsharding tags,которыебудутиспользоватьсяэтимAPI.

ВAPIs Proxy BACKEND SERVICES Endpointsможно указать дополнительные точки API и настроить параметры работы с ними.

Сейчас нас интересуют настройки конкретной Endpoint, поэтому нажимаем на нижнюю шестеренку.

Исправляем "Target" на http://backend_server:8000/, устанавливаем tenant, сохраняем и деплоим!

ВAPIs Proxy Deploymentsнадо указать те sharding tags, которые могут использовать данное API. После этого необходимо вернуться в созданный план и в списке Sharding tags выбрать тег "service-tag".

ВAPIs Designможно указать политики, которые будут отрабатывать при обработке запросов.

ВAPIs Analytics Overviewможно смотреть статистику по работе данного конкретного API.

ВAPIs Analytics Logsможно настроить логи и потом их смотреть.

ВAPIs Auditможно посмотреть, как изменялись настройки API.

Остальное пока рассматривать не будем, на работу оно не влияет.

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

Переходим наhttp://gravitee_host:8082/fastapi/ , и вам покажется приветствие миру:

Также сразу можно заглянуть вAPIs Analytics Overview/Logsдля просмотра статистики обработки запросов.

Заключение

Итак, поздравляю всех, кто дочитал до сюда и остался в живых! Теперь вы знаете, как поднять ознакомительный стенд APIM Gravitee, как его настроить, создать новое API из swagger файла и проверить, что всё работает. Вариантов настройки шлюзов, точек входа и выхода, сертификатов, балансировок нагрузки и записи логов много. В одной статье всего и не расскажешь. Так что в следующей статье я расскажу о более продвинутых настройках системы APIM Gravitee. В Телеграмме есть канал по данной системе:https://t.me/gravitee_ru, вопросы по нюансам настройки можно задавать туда.

Подробнее..

Как написать удобный API 10 рекомендаций

25.05.2021 14:04:29 | Автор: admin

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

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

1. Не используйте глаголы в URL *

* - если это одна из CRUD-операций.

За действие с ресурсом отвечают CRUD-методы запроса: POST - создать (create), GET - получить (read), PUT/PATH - обновить (update), DELETE - удалить (ну вы поняли). Плохо:

POST /users/{userId}/delete - удаление пользователяPOST /bookings/{bookingId}/update - обновление бронировки

Хорошо:

DELETE /users/{userId}PUT /bookings/{bookingId}

2. Используйте глаголы в URL

Плохо:

POST /users/{userId}/books/{bookId}/create - добавить книгу пользователю

Хорошо:

POST /users/{userId}/books/{bookId}/attachPOST /users/{userId}/notifications/send - отправить уведомление пользователю

3. Выделяйте новые сущности

Выше есть пример добавления книги пользователю, возможно, логика вашего приложения подразумевает список избранного, тогда роут может быть и таким:

POST /wishlist/{userId}/{bookId}

4. Используйте один идентификатор ресурса *

* - если ваша структура данных это позволяет.

Это значит если у вас есть записи вида один ко многим, например
бронь -> путешественники (booking->travellers), вам будет достаточно передавать в запросе идентификатор путешественника.

Плохо:

# получение данных путешественникаGET /bookings/{bookingId}/travellers/{travellerId}

Хорошо:

GET /bookings/travellers/{travellerId}

Так же замечу что /bookings/travellers/ лучше чем просто /travellers хорошо придерживаться иерархии данных в своем API.

5. Все ресурсы во множественном числе

Плохо:

GET /user/{userId} - получение данных пользователяPOST /ticket/{ticketId}/book - бронирование билета

Хорошо:

GET /users/{userId}POST /tickets/{ticketId}/book

6. Используйте HTTP-статусы по максимуму

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

  • 400 Bad Request - клиент отправил неверный запрос, например, отсутствует обязательный параметр запроса.

  • 401 Unauthorized - клиенту не удалось пройти обязательную аутентификацию на сервере для обработки запроса.

  • 403 Forbidden - клиент аутентифицирован, но не имеет разрешения на доступ к запрошенному ресурсу.

  • 404 Not Found - запрошенный ресурс не существует.

  • 409 Conflict - этот ответ отправляется, когда запрос конфликтует с текущим состоянием сервера.

  • 500 Internal Server Error - на сервере произошла общая ошибка.

  • 503 Service Unavailable - запрошенная услуга недоступна.

7. Модификаторы получения ресурса

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

Пример: /quizzes и /quizzes/passed. Здесь quizzes - ресурс (викторины), passed - модификатор (пройденные).

Плохо:

GET /passed-quizzes - получение пройденных викторинGET /booked-tickets - получение забронированных билетовPOST /gold-users - создание премиум пользователя

Хорошо:

GET /tickets/bookedPOST /users/gold

8. Выберите одну структуру ответов

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

Плохо:

GET /book/{bookId}{    "name": "Harry Potter and the Philosopher's Stone",    "genre": "fantasy",    "status": 0, # статус вашего приложения    "error": false,     ...}

Хорошо:

GET /book/{bookId}{    "status": 0,    "message": "ok",    "data": {...}}

В этом примере 3 поля универсальны и могут использоваться для любого ответа от апи. status, message - собственный статус и сообщение приложения по которому клиент сможет ориентироваться, эти поля сообщат ему дополнительную информацию о процессе обработки запроса, но не данные ресурса. Например, в нашем приложении в один момент времени, пользователь может проходить только одну викторину. Тогда запрос на начало новой может выдать 409й статус а в полях status и message дополнительную информацию почему была получена ошибка.

9. Все параметры и json в camelCase

9.1 В параметрах запросов
Плохо:

GET /users/{user-id}GET /users/{user_id}GET /users/{userid}

Хорошо:

GET /users/{userId}POST /ticket/{ticketId}/gold

9.2 В теле ответа или принимаемого запроса
Плохо:

{    "ID": "fb6ad842-bd8d-47dd-b7e1-68891d8abeec",    "Name": "soccer3000",    "provider_id": 1455,    "Created_At": "25.05.2020"}

Хорошо:

{    "id": "fb6ad842-bd8d-47dd-b7e1-68891d8abeec",    "Name": "soccer3000",    "providerId": 1455,    "createdAt": "25.05.2020"}

10. Пользуйтесь Content-Type

Плохо:

GET /tickets.jsonGET /tickets.xml

Хорошо:

GET /tickets// и в хедереСontent-Type: application/json// илиСontent-Type: application/xml

Заключение

Перечисленные выше рекомендации это далеко не весь список способов сделать API лучше. Для дальнейшего изучения рекомендую разобрать спецификации REST API и список кодов http-статусов (вы удивитесь на сколько их много и какие ситуации они охватывают).

А в комментариях, предлагаю написать свою рекомендацию по построению REST API которую вы считаете важной.

Подробнее..

Как я сделал Discord бота для игровой гильдии с помощью .NET Core

05.06.2021 18:10:05 | Автор: admin
Батрак предупреждает о том что к гильдии присоединился игрокБатрак предупреждает о том что к гильдии присоединился игрок

Вступление

Всем привет! Недавно я написал Discord бота для World of Warcraft гильдии. Он регулярно забирает данные об игроках с серверов игры и пишет сообщения в Discord о том что к гильдии присоединился новый игрок или о том что гильдию покинул старый игрок. Между собой мы прозвали этого бота Батрак.

В этой статье я решил поделиться опытом и рассказать как сделать такой проект. По сути мы будем реализовывать микросервис на .NET Core: напишем логику, проведем интеграцию с api сторонних сервисов, покроем тестами, упакуем в Docker и разместим в Heroku. Кроме этого я покажу как реализовать continuous integration с помощью Github Actions.

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

Для понимания материала, от вас ожидается хотя бы минимальный опыт создания веб сервисов с помощью фреймворка ASP.NET и небольшой опыт работы с Docker.

План

На каждом шаге будем постепенно наращивать функционал.

  1. Создадим новый web api проект с одним контроллером /check. При обращении к этому адресу будем отправлять строку Hello! в Discord чат.

  2. Научимся получать данные о составе гильдии с помощью готовой библиотеки или заглушки.

  3. Научимся сохранять в кэш полученный список игроков чтобы при следующих проверках находить различия с предыдущей версией списка. Обо всех изменениях будем писать в Discord.

  4. Напишем Dockerfile для нашего проекта и разместим проект на хостинге Heroku.

  5. Посмотрим на несколько способов сделать периодическое выполнение кода.

  6. Реализуем автоматическую сборку, запуск тестов и публикацию проекта после каждого коммита в master

Шаг 1. Отправляем сообщение в Discord

Нам потребуется создать новый ASP.NET Core Web API проект.

Создание нового проекта - это одна из фундаментальных вещей которые я не буду подробно расписывать. При работе над проектом используйте сервис Github для хранения кода. В дальнейшем мы воспользуемся несколькими возможностями Github.

Добавим к проекту новый контроллер

[ApiController]public class GuildController : ControllerBase{    [HttpGet("/check")]    public async Task<IActionResult> Check(CancellationToken ct)    {        return Ok();    }}

Затем нам понадобится webhook от вашего Discord сервера. Webhook - это механизм отправки событий. В данном случае, то это адрес к которому можно слать простые http запросы с сообщениями внутри.

Получить его можно в пункте integrations в настройках любого текстового канала вашего Discord сервера.

Создание webhookСоздание webhook

Добавим webhook в appsettings.json нашего проекта. Позже мы унесем его в переменные окружения Heroku. Если вы не знакомы с тем как работать с конфигурацией в ASP Core проектах предварительно изучите эту тему.

{"DiscordWebhook":"https://discord.com/api/webhooks/****/***"}

Теперь создадим новый сервис DiscordBroker, который умеет отправлять сообщения в Discord. Создайте папку Services и поместите туда новый класс, эта папка нам еще пригодится.

По сути этот новый сервис делает post запрос по адресу из webhook и содержит сообщение в теле запроса.

public class DiscordBroker : IDiscordBroker{    private readonly string _webhook;    private readonly HttpClient _client;    public DiscordBroker(IHttpClientFactory clientFactory, IConfiguration configuration)    {        _client = clientFactory.CreateClient();        _webhook = configuration["DiscordWebhook"];    }    public async Task SendMessage(string message, CancellationToken ct)    {        var request = new HttpRequestMessage        {            Method = HttpMethod.Post,            RequestUri = new Uri(_webhook),            Content = new FormUrlEncodedContent(new[] {new KeyValuePair<string, string>("content", message)})        };        await _client.SendAsync(request, ct);    }}

Как видите, мы используем внедрение зависимостей. IConfiguration позволит нам достать webhook из конфигов, а IHttpClientFactory создать новый HttpClient.

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

Не забудьте что новый класс нужно будет зарегистрировать в Startup.

services.AddScoped<IDiscordBroker, DiscordBroker>();

А также нужно будет зарегистрировать HttpClient, для работы IHttpClientFactory.

services.AddHttpClient();

Теперь можно воспользоваться новым классом в контроллере.

private readonly IDiscordBroker _discordBroker;public GuildController(IDiscordBroker discordBroker){  _discordBroker = discordBroker;}[HttpGet("/check")]public async Task<IActionResult> Check(CancellationToken ct){  await _discordBroker.SendMessage("Hello", ct);  return Ok();}

Запустите проект, зайдите по адресу /check в браузере и убедитесь что в Discord пришло новое сообщение.

Шаг 2. Получаем данные из Battle.net

У нас есть два варианта: получать данные из настоящих серверов battle.net или из моей заглушки. Если у вас нет аккаунта в battle.net, то пропустите следующий кусок статьи до момента где приводится реализация заглушки.

Получаем реальные данные

Вам понадобится зайти на https://develop.battle.net/ и получить там две персональных строки BattleNetId и BattleNetSecret. Они будут нужны нам чтобы авторизоваться в api перед отправкой запросов. Поместите их в appsettings.

Подключим к проекту библиотеку ArgentPonyWarcraftClient.

Создадим новый класс BattleNetApiClient в папке Services.

public class BattleNetApiClient{   private readonly string _guildName;   private readonly string _realmName;   private readonly IWarcraftClient _warcraftClient;   public BattleNetApiClient(IHttpClientFactory clientFactory, IConfiguration configuration)   {       _warcraftClient = new WarcraftClient(           configuration["BattleNetId"],           configuration["BattleNetSecret"],           Region.Europe,           Locale.ru_RU,           clientFactory.CreateClient()       );       _realmName = configuration["RealmName"];       _guildName = configuration["GuildName"];   }}

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

Кроме этого, нужно создать в appsettings проекта две новых записи RealmName и GuildName. RealmName это название игрового мира, а GuildName это название гильдии. Их будем использовать как параметры при запросе.

Сделаем метод GetGuildMembers чтобы получать состав гильдии и создадим модель WowCharacterToken которая будет представлять собой информацию об игроке.

public async Task<WowCharacterToken[]> GetGuildMembers(){   var roster = await _warcraftClient.GetGuildRosterAsync(_realmName, _guildName, "profile-eu");   if (!roster.Success) throw new ApplicationException("get roster failed");   return roster.Value.Members.Select(x => new WowCharacterToken   {       WowId = x.Character.Id,       Name = x.Character.Name   }).ToArray();}
public class WowCharacterToken{  public int WowId { get; set; }  public string Name { get; set; }}

Класс WowCharacterToken следует поместить в папку Models.

Не забудьте подключить BattleNetApiClient в Startup.

services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();

Берем данные из заглушки

Для начала создадим модель WowCharacterToken и поместим ее в папку Models. Она представляет собой информацию об игроке.

public class WowCharacterToken{  public int WowId { get; set; }  public string Name { get; set; }}

Дальше сделаем вот такой класс

public class BattleNetApiClient{    private bool _firstTime = true;    public Task<WowCharacterToken[]> GetGuildMembers()    {        if (_firstTime)        {            _firstTime = false;            return Task.FromResult(new[]            {                new WowCharacterToken                {                    WowId = 1,                    Name = "Артас"                },                new WowCharacterToken                {                    WowId = 2,                    Name = "Сильвана"                }            });        }        return Task.FromResult(new[]        {            new WowCharacterToken            {                WowId = 1,                Name = "Артас"            },            new WowCharacterToken            {                WowId = 3,                Name = "Непобедимый"            }        });    }}

Он возвращает зашитый в него список игроков. При первом вызове метода мы вернем один список, при последующих другой. Это нужно нам что смоделировать изменчивое поведение api. Этой заглушки хватит чтобы продолжить делать проект.

Сделайте интерфейс и подключите все что мы создали в Startup.

services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();

Выведем результаты в Discord

После того как мы сделали BattleNetApiClient, им можно воспользоваться в контроллере чтобы вывести кол-во игроков в Discord.

[ApiController]public class GuildController : ControllerBase{  private readonly IDiscordBroker _discordBroker;  private readonly IBattleNetApiClient _battleNetApiClient;  public GuildController(IDiscordBroker discordBroker, IBattleNetApiClient battleNetApiClient)  {     _discordBroker = discordBroker;     _battleNetApiClient = battleNetApiClient;  }  [HttpGet("/check")]  public async Task<IActionResult> Check(CancellationToken ct)  {     var members = await _battleNetApiClient.GetGuildMembers();     await _discordBroker.SendMessage($"Members count: {members.Length}", ct);     return Ok();  }}

Шаг 3. Находим новых и ушедших игроков

Нужно научиться определять какие игроки появились или пропали из списка при последующих запросах к api. Для этого мы можем закэшировать список в InMemory кэше (в оперативной памяти) или во внешнем хранилище.

Если закэшировать список в InMemory кэше, то мы потеряем его при перезапуске приложения. Поэтому позже мы подключим базу данных Redis как аддон в Heroku и будем кешировать туда.

А пока что подключим InMemory кэш в Startup.

services.AddMemoryCache(); 

Теперь в нашем распоряжении есть IDistributedCache, который можно подключить через конструктор. Я предпочел не использовать его напрямую , а написать для него обертку. Создайте класс GuildRepository и поместите его в новую папку Repositories.

public class GuildRepository : IGuildRepository{    private readonly IDistributedCache _cache;    private const string Key = "wowcharacters";    public GuildRepository(IDistributedCache cache)    {        _cache = cache;    }    public async Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)    {        var value = await _cache.GetAsync(Key, ct);        if (value == null) return Array.Empty<WowCharacterToken>();        return await Deserialize(value);    }    public async Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)    {        var value = await Serialize(characters);        await _cache.SetAsync(Key, value, ct);    }        private static async Task<byte[]> Serialize(WowCharacterToken[] tokens)    {        var binaryFormatter = new BinaryFormatter();        await using var memoryStream = new MemoryStream();        binaryFormatter.Serialize(memoryStream, tokens);        return memoryStream.ToArray();    }    private static async Task<WowCharacterToken[]> Deserialize(byte[] bytes)    {        await using var memoryStream = new MemoryStream();        var binaryFormatter = new BinaryFormatter();        memoryStream.Write(bytes, 0, bytes.Length);        memoryStream.Seek(0, SeekOrigin.Begin);        return (WowCharacterToken[]) binaryFormatter.Deserialize(memoryStream);    }}

Теперь можно написать сервис который будет сравнивать новый список игроков с сохраненным.

public class GuildService{    private readonly IBattleNetApiClient _battleNetApiClient;    private readonly IGuildRepository _repository;    public GuildService(IBattleNetApiClient battleNetApiClient, IGuildRepository repository)    {        _battleNetApiClient = battleNetApiClient;        _repository = repository;    }    public async Task<Report> Check(CancellationToken ct)    {        var newCharacters = await _battleNetApiClient.GetGuildMembers();        var savedCharacters = await _repository.GetCharacters(ct);        await _repository.SaveCharacters(newCharacters, ct);        if (!savedCharacters.Any())            return new Report            {                JoinedMembers = Array.Empty<WowCharacterToken>(),                DepartedMembers = Array.Empty<WowCharacterToken>(),                TotalCount = newCharacters.Length            };        var joined = newCharacters.Where(x => savedCharacters.All(y => y.WowId != x.WowId)).ToArray();        var departed = savedCharacters.Where(x => newCharacters.All(y => y.Name != x.Name)).ToArray();        return new Report        {            JoinedMembers = joined,            DepartedMembers = departed,            TotalCount = newCharacters.Length        };    }}

В качестве возвращаемого результата используется модель Report. Ее нужно создать и поместить в папку Models.

public class Report{   public WowCharacterToken[] JoinedMembers { get; set; }   public WowCharacterToken[] DepartedMembers { get; set; }   public int TotalCount { get; set; }}

Применим GuildService в контроллере.

[HttpGet("/check")]public async Task<IActionResult> Check(CancellationToken ct){   var report = await _guildService.Check(ct);   return new JsonResult(report, new JsonSerializerOptions   {      Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.Cyrillic)   });}

Теперь отправим в Discord какие игроки присоединились или покинули гильдию.

if (joined.Any() || departed.Any()){   foreach (var c in joined)      await _discordBroker.SendMessage(         $":smile: **{c.Name}** присоединился к гильдии",         ct);   foreach (var c in departed)      await _discordBroker.SendMessage(         $":smile: **{c.Name}** покинул гильдию",         ct);}

Эту логику я добавил в GuildService в конец метода Check. Писать бизнес логику в контроллере не стоит, у него другое назначение. В самом начале мы делали там отправку сообщения в Discord потому что еще не существовало GuildService.

На первом скриншоте в статье вы видели что я вывел больше информации об игроке. Ее можно получить если воспользоваться библиотекой ArgentPonyWarcraftClient

await _warcraftClient.GetCharacterProfileSummaryAsync(_realmName, name.ToLower(), Namespace);

Я решил не добавлять в статью больше кода в BattleNetApiClient, чтобы статья не разрослась до безумных размеров.

Unit тесты

У нас появился класс GuildService с нетривиальной логикой, который будет изменяться и расширяться в будущем. Стоит написать на него тесты. Для этого нужно будет сделать заглушки для BattleNetApiClient, GuildRepository и DiscordBroker. Я специально просил создавать интерфейсы для этих классов чтобы можно было сделать их фейки.

Создайте новый проект для Unit тестов. Заведите в нем папку Fakes и сделайте три фейка.

public class DiscordBrokerFake : IDiscordBroker{   public List<string> SentMessages { get; } = new();   public Task SendMessage(string message, CancellationToken ct)   {      SentMessages.Add(message);      return Task.CompletedTask;   }}
public class GuildRepositoryFake : IGuildRepository{    public List<WowCharacterToken> Characters { get; } = new();    public Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)    {        return Task.FromResult(Characters.ToArray());    }    public Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)    {        Characters.Clear();        Characters.AddRange(characters);        return Task.CompletedTask;    }}
public class BattleNetApiClientFake : IBattleNetApiClient{   public List<WowCharacterToken> GuildMembers { get; } = new();   public List<WowCharacter> Characters { get; } = new();   public Task<WowCharacterToken[]> GetGuildMembers()   {      return Task.FromResult(GuildMembers.ToArray());   }}

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

Первый тест на GuildService будет выглядеть так:

[Test]public async Task SaveNewMembers_WhenCacheIsEmpty(){   var wowCharacterToken = new WowCharacterToken   {      WowId = 100,      Name = "Sam"   };      var battleNetApiClient = new BattleNetApiApiClientFake();   battleNetApiClient.GuildMembers.Add(wowCharacterToken);   var guildRepositoryFake = new GuildRepositoryFake();   var guildService = new GuildService(battleNetApiClient, null, guildRepositoryFake);   var changes = await guildService.Check(CancellationToken.None);   changes.JoinedMembers.Length.Should().Be(0);   changes.DepartedMembers.Length.Should().Be(0);   changes.TotalCount.Should().Be(1);   guildRepositoryFake.Characters.Should().BeEquivalentTo(wowCharacterToken);}

Как видно из названия, тест позволяет проверить что мы сохраним список игроков, если кэш пуст. Заметьте, в конце теста используется специальный набор методов Should, Be... Это методы из библиотеки FluentAssertions, которые помогают нам сделать Assertion более читабельным.

Теперь у нас есть база для написания тестов. Я показал вам основную идею, дальнейшее написание тестов оставляю вам.

Главный функционал проекта готов. Теперь можно подумать о его публикации.

Шаг 4. Привет Docker и Heroku!

Мы будем размещать проект на платформе Heroku. Heroku не позволяет запускать .NET проекты из коробки, но она позволяет запускать Docker образы.

Чтобы упаковать проект в Docker нам понадобится создать в корне репозитория Dockerfile со следующим содержимым

FROM mcr.microsoft.com/dotnet/sdk:5.0 AS builderWORKDIR /sourcesCOPY *.sln .COPY ./src/peon.csproj ./src/COPY ./tests/tests.csproj ./tests/RUN dotnet restoreCOPY . .RUN dotnet publish --output /app/ --configuration ReleaseFROM mcr.microsoft.com/dotnet/core/aspnet:3.1WORKDIR /appCOPY --from=builder /app .CMD ["dotnet", "peon.dll"]

peon.dll это название моего Solution. Peon переводится как батрак.

О том как работать с Docker и Heroku можно прочитать здесь. Но я все же опишу последовательность действий.

Вам понадобится создать аккаунт в Heroku, установить Heroku CLI.

Создайте новый проект в heroku и свяжите его с вашим репозиторием.

heroku git:remote -a project_name

Теперь нам необходимо создать файл heroku.yml в папке с проектом. У него будет такое содержимое:

build:  docker:    web: Dockerfile

Дальше выполним небольшую череду команд:

# Залогинимся в heroku registryheroku container:login# Соберем и запушим образ в registryheroku container:push web# Зарелизим приложение из образаheroku container:release web

Можете открыть приложение в браузере с помощью команды:

heroku open

После того как мы разместили приложение в Heroku, нужно подключить базу данных Redis для кэша. Как вы помните InMemory кэш будет исчезать после перезапуска приложения.

Установите для нашего Heroku приложения бесплатный аддон RedisCloud.

Строку подключения для Redis можно будет получить через переменную окружения REDISCLOUD_URL. Она будет доступна, когда приложение будет запущено в экосистеме Heroku.

Нам нужно получить эту переменную в коде приложения.

Установите библиотеку Microsoft.Extensions.Caching.StackExchangeRedis.

С помощью нее можно зарегистрировать Redis реализацию для IDistributedCache в Startup.

services.AddStackExchangeRedisCache(o =>{   o.InstanceName = "PeonCache";   var redisCloudUrl = Environment.GetEnvironmentVariable("REDISCLOUD_URL");   if (string.IsNullOrEmpty(redisCloudUrl))   {      throw new ApplicationException("redis connection string was not found");   }   var (endpoint, password) = RedisUtils.ParseConnectionString(redisCloudUrl);   o.ConfigurationOptions = new ConfigurationOptions   {      EndPoints = {endpoint},      Password = password   };});

В этом коде мы получили переменную REDISCLOUD_URL из переменных окружения системы. После этого мы извлекли адрес и пароль базы данных с помощью класса RedisUtils. Его написал я сам:

public static class RedisUtils{   public static (string endpoint, string password) ParseConnectionString(string connectionString)   {      var bodyPart = connectionString.Split("://")[1];      var authPart = bodyPart.Split("@")[0];      var password = authPart.Split(":")[1];      var endpoint = bodyPart.Split("@")[1];      return (endpoint, password);   }}

На этот класс можно сделать простой Unit тест.

[Test]public void ParseConnectionString(){   const string example = "redis://user:password@url:port";   var (endpoint, password) = RedisUtils.ParseConnectionString(example);   endpoint.Should().Be("url:port");   password.Should().Be("password");}

После того что мы сделали, GuildRepository будет сохранять кэш не в оперативную память, а в Redis. Нам даже не нужно ничего менять в коде приложения.

Опубликуйте новую версию приложения.

Шаг 5. Реализуем циклическое выполнение

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

Есть несколько способов это реализовать:

Самый простой способ - это сделать задание на сайте https://cron-job.org. Этот сервис будет слать get запрос на /check вашего приложения каждые N минут.

Второй способ - это использовать Hosted Services. В этой статье подробно описано как создать повторяющееся задание в ASP.NET Core проекте. Учтите, бесплатный тариф в Heroku подразумевает что ваше приложение будет засыпать после того как к нему некоторое время не делали запросов. Hosted Service перестанет работать после того как приложение заснет. В этом варианте вам следует перейти на платный тариф. Кстати, так сейчас работает мой бот.

Третий способ - это подключить к проекту специальные Cron аддоны. Например Heroku Scheduler. Можете пойти этим путем и разобраться как создать cron job в Heroku.

Шаг 6. Автоматическая сборка, прогон тестов и публикация

Во-первых, зайдите в настройки приложения в Heroku.

Там есть пункт Deploy. Подключите там свой Github аккаунт и включите Automatic deploys после каждого коммита в master.

Поставьте галочку у пункта Wait for CI to pass before deploy. Нам нужно чтобы Heroku дожидался сборки и прогонки тестов. Если тесты покраснеют, то публикация не случится.

Сделаем сборку и прогонку тестов в Github Actions.

Зайдите в репозиторий и перейдите в пункт Actions. Теперь создайте новый workflow на основе шаблона .NET

В репозитории появится новый файл dotnet.yml. Он описывает процесс сборки.

Как видите по его содержимому, задание build будет запускаться после пуша в ветку master.

on:  push:    branches: [ master ]  pull_request:    branches: [ master ]

Содержимое самого задания нас полностью устраивает. Если вы вчитаетесь в то что там происходит, то увидите что там происходит запуск команд dotnet build и dotnet test.

    steps:    - uses: actions/checkout@v2    - name: Setup .NET      uses: actions/setup-dotnet@v1      with:        dotnet-version: 5.0.x    - name: Restore dependencies      run: dotnet restore    - name: Build      run: dotnet build --no-restore    - name: Test      run: dotnet test --no-build --verbosity normal

Менять в этом файле ничего не нужно, все уже будет работать из коробки.

Запуште что-нибудь в master и посмотрите что задание запускается. Кстати, оно уже должно было запуститься после создания нового workflow.

Отлично! Вот мы и сделали микросервис на .NET Core который собирается и публикуется в Heroku. У проекта есть множество точек для развития: можно было бы добавить логирование, прокачать тесты, повесить метрики и. т. д.

Надеюсь данная статья подкинула вам пару новых идей и тем для изучения. Спасибо за внимание. Удачи вам в ваших проектах!

Подробнее..
Категории: C , Net , Api , Docker , Dotnet , Discord , Bot , Бот , Heroku , Микросервис , Wow

Перевод 4 технических решения, которые делают API сервис успешным

03.06.2021 12:19:11 | Автор: admin
Есть API, которые в целом как-то работают, но имеют проблемы с безопасностью, документацией или валидацией данных. Автор статьи объясняет, почему в современных реалиях это недопустимо, и даёт рекомендации по исправлению недостатков.



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

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

А если от публичного API напрямую зависит доход компании, то ставки по-настоящему высоки. Эта мысль подробнее раскрыта в книге Непрерывное развитие API. Правильные решения в изменчивом технологическом ландшафте (Мехди Меджуи, Ронни Митра и др):


а мы пойдём дальше...

Разработчики могут быть в основном сосредоточены на том, чтобы в API была реализована вся запланированная функциональность. Но существуют нефункциональные требования, которые часто выполнены не в полной мере и требуют особого внимания.

На мой взгляд, для всех API обязательно качественное выполнение следующих нефункциональных требований:

  • Безопасность
  • Документация
  • Валидация
  • Тестирование

Безопасность


Наверное, не стоит объяснять, почему безопасность оказалась на первом месте. Я выделил четыре наиболее важных задачи по обеспечению безопасности:

  1. Использование HTTPS с SSL-сертификатами
  2. Совместное использование ресурсов из разных источников (CORS)
  3. Аутентификация и JSON Web Tokens
  4. Авторизация и привилегии доступа


* Здесь и далее сузим контекст до REST и JSON API.

1. Использование HTTPS с SSL-сертификатами


Сейчас HTTPS с использованием SSL-сертификатов является де факто стандартом безопасности. Для генерации сертификатов лично я использую Let's Encrypt бесплатный, центр автоматизированный сертификации от некоммерческой организации Internet Security Research Group (ISRG).

Эти сертификаты гарантируют, что данные, идущие от вашего API к пользователю, будут зашифрованы.

2. Совместное использование ресурсов из разных источников (CORS)


Чтобы обеспечить безопасность запросов к другим источникам, браузеры используют механизм под названием CORS. Аббревиатура CORS расшифровывается как Cross-Origin Resource Sharing технология совместного использования ресурсов разных источников. Несмотря на то, что браузеры (в силу правила одинакового источника) не позволяют получать доступ к ресурсам из разных источников, CORS позволяет обойти эти ограничения и при этом быть уверенным, что доступ к ресурсам будет безопасным.

Пользовательские агенты (к примеру, браузеры) на основе значений дополнительных заголовков для CORS в HTTP-запросе могут выполнять запросы к другим источникам, которые без CORS были бы заблокированы.



Приведём пример:

HTML страница, обслуживаемая сервером с http://domain-a.com, запрашивает src по адресу http://domain-b.com/image.jpg .

Многие страницы загружают ресурсы вроде CSS-стилей, изображений и скриптов с разных доменов, соответствующих разным CDN (сетям доставки контента).

Сегодня достаточно популярна реализация CORS для Node.js.

3. Аутентификация и JSON Web Tokens (JWT)


Существует несколько подходов к аутентификации пользователя API, но один из лучших это использование JWT. Эти токены подписываются с использованием различных криптографических алгоритмов.

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

Для создания токена необходимо определить заголовок (header) с общей информацией по токену, полезные данные (payload), такие как id пользователя, его роль и так далее, а также подписи (signature). Простыми словами, JWT это лишь строка в следующем формате header.payload.signature.

Когда клиент входит в систему, служба управления идентификацией предоставляет клиенту JWT. Затем клиент может использовать этот токен для выполнения запросов к API. У API есть доступ к публичному ключу или секрету, который он использует для проверки токена.

Для проверки можно использовать, например, библиотеку jsonwebtoken. Приводим ниже код на JavaScript:

import jwt from 'jsonwebtoken'

export default function (req, res, next) {

// req.headers.authorization Bearer token

const token = extractToken(req)

jwt.verify(token, SECRET, { algorithms: ['HS256'] }, (err, decoded) => {

if (err) { next(err) }

req.session = decoded

next()

})

}

Больше информации про JWT, библиотеки и поддерживаемые языки программирования на сайте JWT.io.

4. Авторизация и привилегии доступа


Аутентификация важна, но так же важна и авторизация: имеет ли клиент, прошедший аутентификацию и получивший JWT, привилегию выполнить тот или иной запрос?

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

Скоуп это текстовый блок (обычно разделённый пробелами), который описывает привилегии доступа конечной точки API. Там описаны Ресурсы и Действия, которые к ним можно применять. Такая формализация хорошо работает для REST / JSON API, поскольку они очень похожи по структуре.

RESOURCE: ACTION (например, ARTICLE: WRITE или ARTICLE: READ, где ARTICLE это ресурс, а READ и WRITE действия).

Это позволяет разработчикам API сосредоточиться на функциях, а не на ролях или пользователях. Служба управления идентификацией может связывать роли и пользователей с определёнными скоупами, а затем вместе с JWT отправлять скоуп клиенту.

Спать спокойно


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

Документация


Что может быть хуже отсутствия документации? Устаревшая документация!

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

Документация по API должна включать три базовых раздела:

  1. Вводная часть (README)
  2. Техническое описание (Спецификации)
  3. Примеры использования (Getting started и другие подобные подразделы)

1. README


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

Об этом пишут в файле README. Этот файл обычно тоже размещают в репозитории, он даёт разработчикам отправную точку для работы с вашим проектом.

README должен содержать:

  1. Описание API
  2. Ссылки на технические справочники и руководства
  3. Руководство по настройке для разработчика
  4. Руководство для тестировщика
  5. Руководство по развёртыванию
  6. Управление зависимостями
  7. Руководство для контрибьюторов
  8. Нормы поведения
  9. Лицензия
  10. Благодарность

Будьте лаконичны в своем README; вам не нужно объяснять все нюансы, но предоставить достаточно информации, чтобы разработчики могли погрузиться в ваш проект.

2. Спецификации


В REST / JSON API каждая конечная точка представляет собой функцию, созданную с определённой целью. Важно иметь техническую документацию, описывающую каждую конечную точку, входные и выходные данные и особенности работы сервиса с разными клиентами.

Создать собственную документацию для API можно на основе OpenAPI.

Это спецификация и полноценный фреймворк для описания, создания, использования и визуализации веб-сервисовREST. Его задача позволить системам документации синхронизировать свои обновления с изменениями на сервере. Методы, параметры, модели и другие элементы посредством OpenAPI интегрируются с программным обеспечением сервера и всё время с ним синхронизируются.

3. Примеры использования


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

Отличный способ познакомить пользователей с вашим API создать подраздел Getting started (Начало работы). Это поможет разобраться с типовыми сценариями использования и на их примере оценить преимущества вашего API.

Три кита


Документация ключевой компонент любого API. Создавая документацию, помните о трёх китах успешного документирования API README, спецификации и примеры использования. И будет вам (и вашим пользователям) счастье!

Валидация данных


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

Лучшая стратегия запускать проверку до того, как ваш сервис произведёт какие-то операции с данными. Когда клиент отправляет вашему API свои данные, например email, дату и имя, убедитесь, что это действительно адрес электронной почты, дата правильно отформатирована, а строка соответствует требованиям к длине. Эта простая проверка сделает работу вашего сервиса более безопасной и организованной.

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

Вы можете реализовать валидацию руками, но для этой цели также можно использовать и такие библиотеки, как Lodash или Ramda. Они отлично подходят для небольших объектов данных. Такие библиотеки, как Joi, Yup или Zod, работают ещё лучше, поскольку они позволяют описать общую схему валидации, сэкономив время и усилия. Если вам нужно что-то не зависящее от конкретного языка программирования, посмотрите на JSON Schema.

Лучше перебдеть


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

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

Тестирование


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

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

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

Для интеграционного тестирования я использую Tape, Test-server и Fetch-mock. Эти библиотеки позволяют запускать изолированные тесты для конечных точек API, проходя путь от запроса до ответа.

Игра в имитацию


Другие типы тестирования и проверки типов, безусловно, тоже полезны, интеграционное тестирование даёт наибольшее преимущество: оно показывает высокую эффективность при небольших тратах человеко-часов на написание и сопровождение тестов. А использование таких инструментов, как Fetch-mock, может обеспечить полную имитацию обмена данными.

Не останавливайтесь на достигнутом


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



Облачные серверы от Маклауд быстрые и безопасные.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

WebUSB. Прошейся из браузера

31.05.2021 12:16:05 | Автор: admin


Разработчики хромиума постоянно пилят огромное количество классных API для разных технологий и железяк. Тут и Web Share, и работа со звуком, Bluetooth, NFC, WebOTP и много чего ещё, более-менее полный список со статусами реализации можно посмотреть здесь. Но больше всего среди них впечатляет WebUSB API, о настоящих возможностях которого я узнал совсем недавно. Оказывается, с его помощью можно не только открывать странички с подключенных устройств, но и прошивать их. Это открывает новый крутейший сценарий работы со всякой мелкой электроникой.

Пара слов про WebUSB


Главной фишкой WebUSB считается возможность использовать USB-девайсы так, чтобы они при подключении открывали какой-то заданный сайт или запускали веб-приложение в хроме вылезает уведомление, из которого можно его открыть. Главное преимущество в том, что можно писать кроссплатформенный код, который можно запустить сразу, без каких-либо инсталляторов и траты времени на выбор нужных файлов для ОС и архитектуры. Нажал на кнопку, получил результат.

WebUSB вышел в релиз в 2017 году, а в последний год его поддержка заметно выросла, так что ещё через годик он наверняка уже не будет восприниматься как какая-то диковинка. Кстати, это не полностью продукт гугла: в разработке стандарта активно участвовала Mozilla, но пока что, как и Apple, не торопится внедрять технологию в свой браузер из-за потенциальных уязвимостей. Конечно, API глубоко протестировано и запускается (очевидно) только под https, но из-за огромной мощности и количества потенциальных векторов атаки их точку зрения можно поддержать. С другой стороны, у 70% пользователей WebUSB уже давно живёт в браузере, и о критических уязвимостях пока не слышно.

Как и в большинстве новых API, WebUSB можно явно разрешить или запретить HTTP-заголовком Feature-Policy или атрибутом allow в iframe:

  Feature-Policy: fullscreen "*"; usb "none"; payment "self" https://payment.example.com

  <iframe allowpaymentrequest allow="usb; fullscreen"></iframe>

К тому же, получить доступ к списку устройств можно только через функциюnavigator.usb.requestDevice(), которая огромное модальное окно и запускается только в ответ на действие пользователя (вроде нажатия на кнопку). То есть злоумышленники не получат у вас доступ к девайсам, пока вы явно не вызовете их список и не выберете какой-нибудь вручную.



DFU


Полностью описывать механизм работы и нюансы API можно долго, и для этого есть web.dev и стандарт. Самое интересное в firmware. Так как железку, раздающую сайт, надо ещё научить это делать, разработчики, чтобы два раза не вставать, запилили возможность прошивать её прямо из браузера точнее, не сломали эту возможность :)


zhovner многим открыл глаза этим видео в том числе и мне

К USB-устройству обеспечен прямой доступ, со списком интерфейсов, VID/PID и другой информацией. Данные можно считывать и записывать управляющие команды, отслеживать состояния и так далее. Что из этого следует? Что тут же найдётся сумрачный гений, который запилит удобный инструмент, утилизирующий эти возможности! Так и появился PoC webdfu, позволяющий прошивать устройства в DFU-режиме.



DFU (Device Firmware Upgrade) это спецификация, созданная для упрощения обновления прошивки на USB-устройствах. Она поддерживается почти на всех ОС, работает в специальных программах вроде DfuSe и в оригинальной dfu-util. Как нетрудно догадаться из названия, webdfu служит драйвером USB-DFU для хрома. Он может:

  • Загружать текущую прошивку с устройства (upload)
  • Записывать новую прошивку на устройство (download)
  • Переключать устройство в режим DFU
  • Выполнять upload/download совместимо с STM'овской DfuSe

Не может:

  • Открыть устройство, если нет прав на доступ к нему или отсутствует базовый драйвер (Windows изрядно портит этим нервы)
  • Работать с файлами форматов DfuSe
  • Загружать прошивку в формате .dfu
  • Обрабатывать ошибки записи

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

Потестить это чудо техники можно с любым DFU-совместимым устройством, есть готовый проект dapboot для STM32F103, там вообще ни строчки писать не нужно, только собрать.

Ссылки





На правах рекламы


Виртуальные серверы с мгновенной активацией на Linux или Windows. Сервер готов к работе через минуту после оплаты!

Присоединяйтесь к нашему чату в Telegram.

Подробнее..

Мифология REST

02.06.2021 12:04:43 | Автор: admin

Мифология REST


Матчасть


Мало какая технология в истории IT вызывала столько ожесточённых споров, как REST. Самое удивительное при этом состоит в том, что спорящие стороны, как правило, совершенно не представляют себе предмет спора.


Начнём с самого начала. В 2000 году один из авторов спецификаций HTTP и URI Рой Филдинг защитил докторскую диссертацию на тему Архитектурные стили и дизайн архитектуры сетевого программного обеспечения, пятая глава которой была озаглавлена как Representational State Transfer (REST). Диссертация доступна по ссылке.


Как нетрудно убедиться, прочитав эту главу, она представляет собой довольно абстрактный обзор распределённой сетевой архитектуры, вообще не привязанной ни к HTTP, ни к URL. Более того, она вовсе не посвящена правилам дизайна API; в этой главе Филдинг методично перечисляет ограничения, с которыми приходится сталкиваться разработчику распределённого сетевого программного обеспечения. Вот они:


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

Всё, на этом определение REST заканчивается. Дальше Филдинг конкретизирует некоторые аспекты имплементации систем в указанных ограничениях, но все они точно так же являются совершенно абстрактными. Буквально: ключевая информационная абстракция в REST ресурс; любая информация, которой можно дать наименование, может быть ресурсом.


Ключевой вывод, который следует из определения REST по Филдингу, вообще-то, таков: любое сетевое ПО в мире соответствует принципам REST, за очень-очень редкими исключениями.


В самом деле:


  • очень сложно представить себе систему, в которой не было бы хоть какого-нибудь стандартизованного интерфейса взаимодействия, иначе её просто невозможно будет разрабатывать;
  • раз есть интерфейс взаимодействия, значит, под него всегда можно мимикрировать, а значит, требование независимости имплементации клиента и сервера всегда выполнимо;
  • раз можно сделать альтернативную имплементацию сервера значит, можно сделать и многослойную архитектуру, поставив дополнительный прокси между клиентом и сервером;
  • поскольку клиент представляет собой вычислительную машину, он всегда хранит хоть какое-то состояние и кэширует хоть какие-то данные;
  • наконец, code-on-demand вообще лукавое требование, поскольку всегда можно объявить данные, полученные по сети, инструкциями на некотором формальном языке, а код клиента их интерпретатором.

Да, конечно, вышеприведённое рассуждение является софизмом, доведением до абсурда. Самое забавное в этом упражнении состоит в том, что мы можем довести его до абсурда и в другую сторону, объявив ограничения REST неисполнимыми. Например, очевидно, что требование code-on-demand противоречит требованию независимости клиента и сервера клиент должен уметь интерпретировать код с сервера, написанный на вполне конкретном языке. Что касается правила на букву S (stateless), то систем, в которых сервер вообще не хранит никакого контекста клиента в мире вообще практически нет, поскольку ничего полезного для клиента в такой системе сделать нельзя. (Что, кстати, постулируется в соответствующем разделе прямым текстом: коммуникация не может получать никаких преимуществ от того, что на сервере хранится какой-то контекст.)


Наконец, сам Филдинг внёс дополнительную энтропию в вопрос, выпустив в 2008 году разъяснение, что же он имел в виду. В частности, в этой статье утверждается, что:


  • REST API не должно зависеть от протокола;
  • разработка REST API должна фокусироваться на описании медиатипов, представляющих ресурсы; при этом клиент вообще ничего про эти медиатипы знать не должен;
  • в REST API не должно быть фиксированных имён ресурсов и операций над ними, клиент должен извлекать эту информацию из ответов сервера.

Короче говоря, REST по Филдингу подразумевает, что клиент, получив каким-то образом ссылку на точку входа REST API, далее должен быть в состоянии полностью выстроить взаимодействие с API, не обладая вообще никаким априорным знанием о нём, и уж тем более не должен содержать никакого специально написанного кода для работы с этим API.


Оставляя за скобками тот факт, что Филдинг весьма вольно истолковал свою же диссертацию, просто отметим, что ни одна существующая система в мире не удовлетворяет описанию REST по Филдингу-2008.


Здравое зерно REST


Нам неизвестно, почему из всех обзоров абстрактной сетевой архитектуры именно диссертация Филдинга обрела столь широкую популярность; очевидно другое: теория Филдинга, преломившись в умах миллионов программистов (включая самого Филдинга), превратилась в целую инженерную субкультуру. Путём редукции абстракций REST применительно конкретно к протоколу HTTP и стандарту URL родилась химера RESTful API, конкретного смысла которой никто не знает.


Хотим ли мы тем самым сказать, что REST является бессмысленной концепцией? Отнюдь нет. Мы только хотели показать, что она допускает чересчур широкую интерпретацию, в чём одновременно кроется и её сила, и её слабость.


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


Что же правильного в REST-подходе к дизайну API (таком, как он сформировался в коллективном сознании широких масс программистов)? То, что такой дизайн позволяет добиться более эффективного использования времени времени программистов и компьютеров.


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


У протокола HTTP есть очень важное достоинство: он предоставляет стороннему наблюдателю довольно подробную информацию о том, что произошло с запросом и ответом, даже если этот наблюдатель ничего не знает о семантике операции:


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

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


Почему это полезно? Потому что современный стек взаимодействия между клиентом и сервером является (как предсказывал Филдинг) многослойным. Разработчик пишет код поверх какого-то фреймворка, который отправляет запросы; фреймворк базируется на API языка программирования, которое, в свою очередь, обращается к API операционной системы. Далее запрос (возможно, через промежуточные HTTP-прокси) доходит до сервера, который, в свою очередь, тоже представляет собой несколько слоёв абстракции в виде фреймворка, языка программирования и ОС; к тому же, перед конечным сервером, как правило, находится веб-сервер, проксирующий запрос, а зачастую и не один. В современных облачных архитектурах HTTP-запрос, прежде чем дойти до конечного обработчика, пройдёт через несколько абстракций в виде прокси и гейтвеев. Если бы все эти агенты трактовали мета-информацию о запросе одинаково, это позволило бы обрабатывать многие ситуации оптимальнее тратить меньше ресурсов и писать меньше кода.


(На самом деле, в отношении многих технических аспектов промежуточные агенты и так позволяют себе разные вольности, не спрашивая разработчиков. Например, свободно менять Accept-Encoding и Content-Length при проксировании запросов.)


Каждый из аспектов, перечисленных Филдингом в REST-принципах, позволяет лучше организовать работу промежуточного ПО. Ключевым здесь является stateless-принцип: промежуточные прокси могут быть уверены, что метаинформация запроса его однозначно описывает.


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


// Получение профиляGET /meCookie: session_id=<идентификатор сессии>// Удаление профиляGET /delete-meCookie: session_id=<идентификатор сессии>

Почему такая система неудачна с точки зрения промежуточного агента?


  1. Сервер не может кэшировать ответы; все /me для него одинаковые, поскольку он не умеет получать уникальный идентификатор пользователя из куки; в том числе промежуточные прокси не могут и заранее наполнить кэш, так как не знают идентификаторов сессий.
  2. На сервере сложно организовать шардирование, т.е. хранение информации о разных пользователях в разных сегментах сети; для этого опять же потребуется уметь обменивать сессию на идентификатор пользователя.

Первую проблему можно решить, сделав операции более машиночитаемыми, например, перенеся идентификатор сессии в URL:


// Получение профиляGET /me?session_id=<идентификатор сессии>// Удаление профиляGET /delete-me?session_id=<идентификатор сессии>

Шардирование всё ещё нельзя организовать, но теперь сервер может иметь кэш (в нём будут появляться дубликаты для разных сессий одного и того же пользователя, но хотя бы ответить из кэша неправильно будет невозможно), но возникнут другие проблемы:


  1. URL обращения теперь нельзя сохранять в логах, так как он содержит секретную информацию; более того, появится риск утечки данных со страниц пользователей, т.к. одного URL теперь достаточно для получения данных.
  2. Ссылку на удаление пользователя клиент обязан держать в секрете. Если её, например, отправить в мессенджер, то робот-префетчер мессенджера удалит профиль пользователя.

Как же сделать эти операции правильно с точки зрения REST? Вот так:


// Получение профиляGET /user/{user_id}Authorization: Bearer <token>// Удаление профиляDELETE /user/{user_id}Authorization: Bearer <token>

Теперь URL запроса в точности идентифицирует ресурс, к которому обращаются, поэтому можно организовать кэш и даже заранее наполнить его; можно организовать маршрутизацию запроса в зависимости от идентификатора пользователя, т.е. появляется возможность шардирования. Префетчер мессенджера не пройдёт по DELETE-ссылке; а если он это и сделает, то без заголовка Authorization операция выполнена не будет.


Наконец, неочевидная польза такого решения заключается в следующем: промежуточный сервер-гейтвей, обрабатывающий запрос, может проверить заголовок Authorization и переслать запрос далее без него (желательно, конечно, по безопасному соединению или хотя бы подписав запрос). И, в отличие от схемы с идентификатором сессии, мы всё ёщё можем свободно организовывать кэширование данных в любых промежуточных узлах. Более того, агент может легко модифицировать операцию: например, для авторизованных пользователей пересылать запрос дальше как есть, а неавторизованным показывать публичный профиль, пересылая запрос на специальный URL, ну, скажем, GET /user/{user_id}/public-profile для этого достаточно всего лишь дописать /public-profile к URL, не изменяя все остальные части запроса. Для современных микросервисных архитектур возможность корректно и дёшево модифицировать запрос при маршрутизации является самым ценным преимуществом в концепции REST.


Шагнём ещё чуть вперёд. Предположим, что гейтвей спроксировал запрос DELETE /user/{user_id} в нужный микросервис и не дождался ответа. Какие дальше возможны варианты?


Вариант 1. Можно сгенерировать HTML-страницу с ошибкой, вернуть её веб-серверу, чтобы тот вернул её клиенту, чтобы клиент показал её пользователю, и дождаться реакции пользователя. Мы прогнали через систему сколько-то байтов и переложили решение проблемы на конечного потребителя. Попутно заметим, что при этом на уровне логов веб-сервера ошибка неотличима от успеха и там, и там какой-то немашиночитаемый ответ со статусом 200 и, если действительно пропала сетевая связность между гейтвеем и микросервисом, об этом никто не узнает.


Вариант 2. Можно вернуть веб-серверу подходящую HTTP-ошибку, например, 504, чтобы тот вернул её клиенту, чтобы клиент обработал ошибку и, сообразно своей логике, что-то предпринял по этому поводу, например, отправил запрос повторно или показал ошибку пользователю. Мы прокачали чуть меньше байтов, попутно залогировав исключительную ситуацию, но теперь переложили решение на разработчика клиента это ему надлежит не забыть написать код, который умеет работать с ошибкой 504.


Вариант 3. Гейтвей, зная, что метод DELETE идемпотентен, может сам повторить запрос; если исполнить запрос не получилось проследовать по варианту 1 или 2. В этой ситуации мы переложили ответственность за решение на архитектора системы, который должен спроектировать политику перезапросов внутри неё (и гарантировать, что все операции за DELETE действительно идемпотенты), но мы получили важное свойство: система стала самовосстанавливающейся. Теперь она может сама побороть какие-то ситуации, которые раньше вызывали исключения.


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


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


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


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


Заметим, что многочисленные советы как правильно разрабатывать REST API, которые можно найти в интернете, никак не связаны с изложенными выше принципами, а зачастую и противоречат им:


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


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

  2. Используйте HTTP-глаголы для описания действий того, что происходит с ресурсом это правило попросту ставит телегу впереди лошади. Глагол указывает всем промежуточным агентам, является ли операция (не)модифицирующей, (не)кэшируемой, (не)идемпотентной и есть ли у запроса тело; вместо того, чтобы выбирать строго по этим четырём критериям, предлагается воспользоваться какой-то мнемоникой если глагол подходит к смыслу операции, то и ок. Это в некоторых случаях просто опасно: вам может показаться, что DELETE /list?element_index=3 прекрасно описывает ваше намерение удалить третий элемент списка, но т.к. эта операция неидемпотентна, использовать метод DELETE здесь нельзя;


  3. Используйте POST для создания сущностей, GET для доступа к ним, PUT для полной перезаписи, PATCH для частичной и DELETE для удаления вновь мнемоника, позволяющая на пальцах прикинуть, какие побочные эффекты возможны у какого из методов. Если попытаться разобраться в вопросе глубже, то получится, что вообще-то этот совет находится где-то между бесполезен и вреден:


    • использовать метод GET в API имеет смысл тогда и только тогда, когда вы можете указать заголовки кэширования; если выставить Cache-Control в no-cache то получится просто неявный POST; если их не указать совсем, то какой-то промежуточный агент может взять и додумать их за вас;
    • создание сущностей желательно делать идемпотентным, в идеале за PUT (например, через схему с драфтами);
    • частичная перезапись через PATCH опасная и двусмысленная операция, лучше её декомпозировать через более простые PUT;
    • наконец, в современных системах сущности очень редко удаляются скорее архивируются или помечаются скрытыми, так что и здесь PUT /archive?entity_id будет уместнее.

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


  5. Используйте множественное число для сущностей, приписывайте слэш в конце URL и тому подобные советы по стилистике кода, не имеющие никакого отношения к REST.



Осмелимся в конце этого раздела сформулировать четыре правила, которые действительно позволят вам написать хорошее REST API:


  1. Соблюдайте стандарт HTTP, особенно в части семантики методов, статусов и заголовков.
  2. Используйте URL как ключ кэша и ключ идемпотентности.
  3. Проектируйте архитектуру так, чтобы для организации маршрутизации запросов внутри многослойной системы было достаточно манипулировать частями URL (хост, путь, query-параметры), статусами и заголовками.
  4. Рассматривайте сигнатуры вызовов HTTP-методов вашего API как код, и применяйте к нему те же стилистические правила, что и к коду: сигнатуры должны быть семантичными, консистентными и читабельными.

Преимущества и недостатки REST


Главное преимущество, которое вам предоставляет REST возможность положиться на то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее без необходимости писать какой-то дополнительный код. Немаловажно уточнить, что, если вы этими преимуществами не пользуетесь, никакой REST вам не нужен.


Главным недостатком REST является то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее даже если вы их об этом не просили. Более того, так как стандарты HTTP являются сложными, концепция REST непонятной, а разработчики программного обеспечения неидеальными, то промежуточные агенты могут трактовать метаданные запроса неправильно. Особенно это касается каких-то экзотических и сложных в имплементации стандартов.


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


О метапрограммировании и REST по Филдингу


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


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


Пока сильный ИИ не разработан, мы всё-таки настаиваем на том, что код работы с API должен писать живой человек, который опирается на подробную документацию, а не догадки о смысле гиперссылок в ответе сервера.


--


Это черновик будущей главы книги о разработке API. Работа ведётся на Github. Англоязычный вариант этой же главы опубликован на medium. Я буду признателен, если вы пошарите его на реддит я сам не могу согласно политике платформы.

Подробнее..
Категории: Api , Rest , Restful api

17 интересных (и забавных) API для вашего проекта

17.06.2021 10:17:44 | Автор: admin


В этой статье я собрал несколько интересных сервисов с API, многие из которых вы можете использовать как основу для вашего приложения. Другие же отлично подходят как уникальные фичи, которые помогут разнообразить опыт использования вашей программы.


Numbers


Страница: http://numbersapi.com/


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


number


Также есть факты о датах и годах.


Использование API: http://numbersapi.com/<number>/<type>, где number число, а type тип факта (trivia факт из жизни, math математический факт, date и year вопрос про дату (в формате MM/DD) и год). Например, получить факт о 25 октября можно по
запросу http://numbersapi.com/10/25/date: October 25th is the day in 1760 that George III becomes King of Great Britain.


С помощью этого сервиса можно сделать виджет "Этот день в истории".


База вопросов для викторин


Сайт: http://jservice.io


Если вы хотели создать викторину, то вы знаете, насколько сложно найти хорошие вопросы. Теперь же вы можете получать их в удобном формате по одному запросу. Для многих вопросов указана сложность, категория и достоверность вопроса (то есть количество людей, которые посчитали вопрос некорректным).


Получение вопроса: http://jservice.io/api/random?count=1


Пример вопроса (фрагмент):


{  "id": 92576,  "answer": "7",  "question": "When writing, many Europeans cross it; most Americans don't",  "value": 800,  "airdate": "2008-12-08T12:00:00.000Z",  "created_at": "2014-02-14T01:58:09.356Z",  "updated_at": "2014-02-14T01:58:09.356Z",  "category_id": 8472,  "game_id": null,  "invalid_count": null,  "category": {    "id": 8472,    "title": "a number from 1 to 10",    "created_at": "2014-02-11T23:28:10.844Z",    "updated_at": "2014-02-11T23:28:10.844Z",    "clues_count": 10  }}

Чем заняться, когда скучно


Документация: http://www.boredapi.com/documentation


Сервис BoredAPI может предложить занятие на любой случай жизни: бесплатные, социальные, доступные везде и так далее.


>> http://www.boredapi.com/api/activity?minparticipants=5&maxparticipants=8  - поиск активности с участием от 5 до 8 человек{"activity":"Play basketball with a group of friends","type":"social","participants":5,"price":0,"link":"","key":"8683473","accessibility":0.7}```>> http://www.boredapi.com/api/activity?type=diy  - выбор из категории DIY{"activity":"Learn woodworking","type":"diy","participants":1,"price":0.3,"link":"","key":"9216391","accessibility":0.3}

Колода карт


Примеры: https://deckofcardsapi.com


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


>> https://deckofcardsapi.com/api/deck/new/shuffle/?deck_count=3  - создание стопки карт из 3 колод по 52 карты"deck_id": "c3hmntqq4wne", "remaining": 156>> https://deckofcardsapi.com/api/deck/c3hmntqq4wne/draw/?count=1  - достать и вернуть одну карту{"code": "6D", "image": "https://deckofcardsapi.com/static/img/6D.png", "value": "6", "suit": "DIAMONDS"}

В репозитории проекта вы можете найти картинки карт и мастей, которые можно использовать в своих проектах: https://github.com/crobertsbmw/deckofcards/tree/master/static/img


6d


http.cat


Домашняя страница: https://http.cat


Здесь собраны картинки с котиками для каждого HTTP кода, с помощью которых можно украсить страницы с ошибками. Получить соответствующую картинку можно по запросу https://http.cat/<status_code>. Также исходные файлы находятся в открытом репозитории на Github, поэтому вы можете скачать все картинки из папки https://github.com/httpcats/http.cat/tree/master/public/images-original и использовать их локально.


Мои любимые картинки:




Интересный факт: домен .cat выделен под сайты на каталонском языке, но люди из интернета нашли и ему ещё одно применение, и теперь на нём создаются сайты про котов.


Поиск аниме


Документация: https://kitsu.docs.apiary.io/#introduction/json:api


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


Пример работы с API: поиск всех результатов, содержащих слово Tokio: https://kitsu.io/api/edge/anime?filter[text]=tokio


Один из результатов (убраны некоторые аттрибуты):


{  "id": "8271",  "type": "anime",  "attributes": {    "synopsis": "Tokyo has become a cruel and merciless citya place where vicious creatures called ghouls...",    "titles": {      "en": "Tokyo Ghoul",      "en_jp": "Tokyo Ghoul",      "en_us": "Tokyo Ghoul"    },    "canonicalTitle": "Tokyo Ghoul"  },  "favoritesCount": 3599,  "popularityRank": 30,  "ageRating": "R",  "posterImage": {    "original": "https://media.kitsu.io/anime/poster_images/8271/original.jpg?1597694836"  },  "episodeCount": 12,  "episodeLength": 24,  "totalLength": 288}

Картинки с котами, собаками и лисами


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


Картинки котиков


https://aws.random.cat/meow возвращается только ссылка на картинку или гифку. Ссылки случайные и ведут на сторонние сервера.


Каждая картинка из базы имеет номер, но API не даёт номер, а получить картинку по номеру только открыв сайт https://aws.random.cat/view/<number> и достать картинку из исходного текста страницы. Совсем простенький скрипт для этого (для более быстрого исполнения я не использую re):


from requests import getnum = int(input())source = get(f"https://aws.random.cat/view/{num}").textif "id=\"cat" in source:    print(source.split("src=\"")[1].split("\"")[0])else:    print("Incorrect id")

Картинки собачек


https://random.dog/woof.json возвращает размер картинки (или gif или видео) в байтах и ссылку на неё. Все ссылки выглядят как https://random.dog/<random name>


Другие варианты запросов:


  • /woof получить название какой-нибудь случайной картинки (случайная строка)
  • /doggos названия всех картинок
  • /upload предложить свою картинку.

Картинки лисичек


https://randomfox.ca/floof/ возвращает 2 варианта ссылки на картинку. Пути к картинкам выглядят так: https://randomfox.ca/images/<id>.jpg, где id число от 1 до 121. Также все картинки есть в репозитории: https://github.com/xinitrc-dev/randomfox.ca/tree/master/images. Это позволяет вручную выбрать интересные картинки и использовать только их.



База знаний о Покемонах


Страница: https://pokeapi.co


Этот сервис позвляет легко получить информацию о покемонах, их способностях и типах, а также о контенте про покемонов: играх и прочем.


Формат запросов: https://pokeapi.co/api/v2/<type>/<name or id>, например https://pokeapi.co/api/v2/ability/2.


Фрагмент из ответа на запрос информации про Ditto (https://pokeapi.co/api/v2/pokemon/ditto):



{  "abilities": [    {      "ability": {        "name": "limber",        "url": "https://pokeapi.co/api/v2/ability/7/"      },      "is_hidden": false,      "slot": 1    },    {      "ability": {        "name": "imposter",        "url": "https://pokeapi.co/api/v2/ability/150/"      },      "is_hidden": true,      "slot": 3    }  ]}

Be like Bill


Репозиторий: https://github.com/gautamkrishnar/Be-Like-Bill


В меме "Be like Bill" описываются какие-то положительные качества Билла и в конце звучит рекомендация "Быть как он". API позволяет генерировать картинки с заданными или рандомными текстами.


Примеры:


  • https://belikebill.ga/billgen-API.php?default=1&name=Otter случайное качество для имени Otter


  • https://belikebill.ga/billgen-API.php?text=Use%20this%20power%20wisely заданный текст


Bakon Ipsum


Документация: https://baconipsum.com/json-api/


Сервис позволяет использовать сгенерированные тексты вместо стандартного Lorem Ipsum. Можно задать количество предложений или параграфов. Пример запроса:


https://baconipsum.com/api/?type=all-meat&sentences=2 создание текста с 2 предложениями.


Tenderloin short ribs pork chop shankle chuck, cow boudin tongue. Pancetta pastrami pork loin beef ribs, cupim tenderloin filet mignon corned beef.

Шутки


Под эту категорию подходит сразу несколько сервисов с похожими API, поэтому приведу только примеры запросов к API:


Гиковские шутки (часто про Чака Норриса): https://geek-jokes.sameerkumar.website/api?format=json:
There are only 10 types of people in the world: those that understand binary and those that don't.


Шутки от папы: curl https://icanhazdadjoke.com:
Why do you never see elephants hiding in trees? Because they're so good at it.


Шутки с конструкцией сетап-панчлайн: https://official-joke-api.appspot.com/random_joke:
"setup":"I couldn't get a reservation at the library...","punchline":"They were fully booked."


Yes or No


Главная страница: https://yesno.wtf


Если вы хотите красиво отвечать на вопросы да или нет, то этот сервис идеально подходит для вас. Для получения рандомной гифки можно использовать https://yesno.wtf/api. Ответом вам будет "answer" и ссылка на гиф. Также с шансом 1 к 10000 может выпасть ответ maybe. Чтобы получить определённый ответ, можно указать ?force=<answer>, параметр answer может
быть yes, no или maybe.



Вся информация о мультсериале "Рик и Морти"


Главная страница: https://rickandmortyapi.com


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


import ramapiprint(ramapi.Character.filter(name='morty',                              status='dead'))  # получить список мёртвых персонажей, у которых в имени есть Морти. 

Фрагмент ответа


{  "results": [    {      "id": 43,      "name": "Big Morty",      "status": "Dead",      "species": "Human",      "gender": "Male",      "image": "https://rickandmortyapi.com/api/character/avatar/43.jpeg",      "episode": [        "https://rickandmortyapi.com/api/episode/28"      ]    }  ]}

Выводы


В интернете сейчас есть много бесплатных сервисов с API, которые могут разнообразить ваш сайт или послужить основой для реализации интересных фич. Возможно, вы захотите добавить что-то в свой проект прямо сейчас?




Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Категории

Последние комментарии

© 2006-2021, personeltest.ru