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

Tutorial

Перевод Как обеспечить глассморфизм с помощью HTML и CSS

27.04.2021 16:10:26 | Автор: admin

Вы, наверное, подумаете ну вот еще один тренд дизайна? Разве они у нас не каждый год появляются или около того?

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

Но давайте поговорим немного больше о глассморфизме.

Что такое глассморфизм?

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

Вот пример:

Это пример из библиотеки CSS UI, основанной на глассморфизме, называемой ui.glass.

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

Другим примером является редизайн Facebook Messenger App с использованием глассморфизма для MacOS:

Редизайн был выполнен Mikoaj Gaziowski на Dribbble.

Глассморфизм также используется такими компаниями как Apple и Microsoft

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

Microsoft также использует этот стиль в Fluent Design System, но они называют его "акриловым материалом", а не глассморфизмом.

Вот как это выглядит:

Итак, теперь, когда я вкратце познакомил вас с гласcморфизмом, позвольте показать, как можно применить этот эффект, используя только HTML и CSS.

Давайте начнем

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

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

Начнем с создания HTML-файла со следующим кодом:

<!DOCTYPE html><html lang="en"><head>  <meta charset="UTF-8">  <meta http-equiv="X-UA-Compatible" content="IE=edge">  <meta name="viewport" content="width=device-width, initial-scale=1.0">  <title>Glassmorphism effect</title></head><body>    <!-- code goes here --></body></html>

Здорово! Теперь давайте также добавим пользовательский стиль шрифта, включая Inter из Google Fonts:

<link rel="preconnect" href="http://personeltest.ru/aways/fonts.gstatic.com"><link href="http://personeltest.ru/aways/fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">

Настройка некоторых основных стилей и фона для тега body:

body {  padding: 4.5rem;  margin: 0;  background: #edc0bf;  background: linear-gradient(90deg, #edc0bf 0,#c4caef 58%);  font-family: 'Inter', sans-serif;}

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

<div class="card">    <h3 class="card-title">Glassmorphism is awesome</h3>    <p>A modern CSS UI library based on the glassmorphism design principles that will help you quickly design and build beautiful websites and applications.</p>    <a href="http://personeltest.ru/aways/ui.glass">Read more</a></div>

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

Перед тем, как применить эффект стекла, давайте сначала приведем в порядок некоторые интервалы и размеры стилей:

.card {  width: 400px;  height: auto;  padding: 2rem;  border-radius: 1rem;}.card-title {  margin-top: 0;  margin-bottom: .5rem;  font-size: 1.2rem;}p, a {  font-size: 1rem;}a {  color: #4d4ae8;  text-decoration: none;}

Здорово! Теперь, когда мы заложили основу для эффекта, давайте посмотрим, как мы сможем его применить.

Создание эффекта глассморфизма, используя HTML и CSS

Для применения эффекта нужны только два важных свойства CSS: прозрачный фон и свойства backdrop-filter: blur(10px); . Степень размытости или прозрачности может быть изменена в зависимости от ваших предпочтений.

Добавьте к элементу .card следующие стили:

.card {/* other styles */background: rgba(255, 255, 255, .7);-webkit-backdrop-filter: blur(10px);backdrop-filter: blur(10px);}

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

Давайте добавим изображение сразу после запуска тега body :

<img class="shape" src="http://personeltest.ru/aways/s3.us-east-2.amazonaws.com/ui.glass/shape.svg" alt="">

Затем примените следующие стили к элементу .shape с помощью CSS:

.shape {  position: absolute;  width: 150px;  top: .5rem;  left: .5rem;}

Потрясающе! Окончательный результат должен выглядеть так:

Если вам нужен код для этого туториала, посмотрите на этот код.

Поддержка браузера

Одним из недостатков нового тренда дизайна является то, что Internet Explorer не поддерживает свойство backdrop-filter, а Firefox отключает его по умолчанию.

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

Вы можете проверить поддержку браузеров на сайте caniuse.com.

Заключение

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

Мы с моим другом из Themesberg работали над новой библиотекой CSS UI, которая будет использовать новое направление глассморфизма в дизайне, называемое ui.glass. Она будет иметь открытый исходный код под лицензией MIT.

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

Спасибо за чтение! Оставьте свои мысли о глассморфизме в разделе комментариев ниже.


Прямо сейчас в OTUS открыт набор на онлайн-курс "HTML/CSS".

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

- Узнать подробнее о курсе "HTML/CSS"

- Смотреть вебинар CSS Reset ненужный артефакт или спасательный круг

Подробнее..

Google документы станут полновесными с 1 июня. Пишем скрипт для обхода этого ограничения

27.05.2021 18:04:31 | Автор: admin

Предыстория

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

Я часто работаю с Google документами, и при активном использовании дисковая квота закончится довольно быстро. Но есть и хорошая новость: документы, созданные до 1 июня 2021 года так и останутся невесомыми, поэтому вы не получите превышение квоты в одночасье.

У меня сразу возникла мысль сделать документов "в запас". Ниже я расскажу, как это можно осуществить, не тратя много времени и сил.

Пишем скрипт, создающий документы

Скрипт буду писать с помощью Google Apps Script.

Создаём новую таблицу Google, заходим в редактор скриптов (Инструменты - Редактор скриптов).

Создаём три файла

  • main.gs - основной код для создания файлов

  • menu.gs - код для создания пользовательского меню при открытии таблицы

  • index.html - шаблон страницы для отображения информации

Сначала в файле menu.gs создаём функцию onOpen(). Это простой триггер, который выполняется при открытии таблицы. Его задача - создать пользовательское меню, из которого можно запустить функцию.

function onOpen(e) { // Выполняется при открытии таблицы  SpreadsheetApp.getUi() // Получаем интерфейс пользователя      .createMenu('Меню')// Создаём меню      .addItem('Создать документы', 'main') // Создаём команду меню      .addToUi();// Добавляем меню в интерфейс}

В файле main.gs создадим функцию main(), которая и будет запускаться с кнопки в меню.

function main() { // Меню - Создать документы  // Создаём HTML документ из шаблона  let template = HtmlService.createTemplateFromFile(`index`);  // Показываем модальное окно с HTML   SpreadsheetApp.getUi()    .showModelessDialog(template.evaluate(),`Создаю документы...`);}

Пора разобраться с index.html. это обычный HTML файл, который отобразится в модальном окне. там можно использовать стили, скрипты и т.п.

index.html
<!doctype html><html lang="en">  <head>    <!-- Required meta tags -->    <meta charset="utf-8">    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">    <!-- Bootstrap CSS -->    <link rel="stylesheet" href="http://personeltest.ru/aways/maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">    <script>      let timer = function(){ // Обслуживает таймер        // Получаем текущее время        let now = (new Date()).getTime();                // Задаём режим обновления таймера - 1 раз в секунду        setInterval(function(){          // Получаем прошедшее время. now будет доступно из-за замыкания.          let time = (new Date()).getTime() - now;          // Вычисляем минуты          let minutes = Math.floor(time/60000);          // И секунды          let seconds = Math.floor(time%60000/1000);          // Обновляем данные в поле lifeTime          updateData("lifeTime", `${minutes<10?"0"+minutes:minutes}:${seconds<10?"0"+seconds:seconds}`);        },1000);      };            updateData = function(id, value){ // Обновляет информацию в одном элементе по id        let element = document.getElementById(id);        if (element) {          element.innerHTML = value;        };      };            refreshData = function(){ // Обновляем все данные        google.script.run.withSuccessHandler(function(data){        updateData("createTables", data.createTables); // Таблиц создано:        updateData("createDocs", data.createDocs);// Документов создано:        updateData("createForms", data.createForms);// Форм создано        updateData("createSlides", data.createSlides);// Презентаций создано        updateData("log", data.log);// Лог      }).getData(); // Получаем данные     };      setTimeout(setInterval(refreshData, 2000),1000); // Данные обновляем раз в 2 секунды      timer();// Запуск таймера      google.script.run.doMagic();// Запуск функции для создания документов    </script>  </head>  <body>    <div class=".container bg-dark text-white text-center">      <div class="row">        <div class="col">          Таблиц        </div>                <div class="col">          Документов        </div>                <div class="col">          Форм        </div>                <div class="col">          Презентаций        </div>      </div>      <div class="row">        <div class="col" id="createTables">          0        </div>        <div class="col" id="createDocs">          0        </div>                <div class="col" id="createForms">          0        </div>                <div class="col" id="createSlides">          0        </div>      </div>    </div>         <div class=".container bg-dark text-white">      <div class="row">        <div class="col text-right" id="label_lifeTime">          Время работы:          </div>                <div class="col  text-left" id="lifeTime">          00:00        </div>              </div>    </div>    <div bg-dark text-white id="label_log">Лог: </div>    <ul class="list-group" id="log">    </ul>  </body></html>

В ней подключаем стили, создаём скрипт, в котором три функции:

  • updateData(id, value) - ищет на странице элемент по id и обновляет содержимое

  • refreshData() - обновляет все данные на странице, кроме таймера

  • timer() - обслуживает таймер

Для создания документов в файле main.gs создаём универсальную функцию.

function create()
const FILES_TO_CREATE = 50;function create(filesToCreate = FILES_TO_CREATE, folderId, prefix="file_", app, key) {  // Получаем словарь ключ-значение для текущего скрипта.  let props = PropertiesService.getScriptProperties();  // Получаем значение для ключа data. Преобразуем в объект  let data = JSON.parse(props.getProperty(`data`));  try{    // Получаем директорию по id    let folder = DriveApp.getFolderById(folderId);    for(var i=0; i<filesToCreate; i++){ // Создаём filesToCreate документов      // Создаём документ, получаем его id      let ssId = app.create(`${prefix}${(new Date()).getTime()}`).getId();      // Получаем файл по его id      let ss = DriveApp.getFileById(ssId);      // Копируем файл в папку      folder.addFile(ss);      // Удаляем файл из корневой папки      DriveApp.getRootFolder().removeFile(ss);      // Увеличиваем счётчик созданных файлов      data[key]=1+data[key];      // Сохраняем новые данные      props.setProperty(`data`, JSON.stringify(data));    };  }catch(err){    // В случае ошибки - пишем её в лог    logToHtml(`Error: ${err}`, LOG_TYPES.danger);  };  // Возвращаем из функции количество созданных файлов  return +i;};

И конкретные реализации - для таблиц, документов, форм и презентаций.

Обёртки для функции create()
function createSheets(key) {  // Получаем id папки для таблиц  let folderId =  PropertiesService.getScriptProperties().getProperty(`sheetsFolder`);  // Создаём нужное количество таблиц  let count = create(FILES_TO_CREATE, folderId, `sheet_`, SpreadsheetApp, key);  // Сохраняем в лог  logToHtml(`${count} sheets were created`, LOG_TYPES.success);}function createDocs(key) {  let folderId =  PropertiesService.getScriptProperties().getProperty(`docsFolder`);  let count = create(FILES_TO_CREATE, folderId, `doc_`, DocumentApp, key);  logToHtml(`${count} docs were created`, LOG_TYPES.success);}function createForms(key) {  let folderId =  PropertiesService.getScriptProperties().getProperty(`formsFolder`);  let count = create(FILES_TO_CREATE, folderId, `form_`, FormApp, key);  logToHtml(`${count} forms were created`, LOG_TYPES.success);}function createSlides(key) {  let folderId =  PropertiesService.getScriptProperties().getProperty(`slidesFolder`);  let count = create(FILES_TO_CREATE, folderId, `slide_`, SlidesApp, key);  logToHtml(`${count} slides were created`, LOG_TYPES.success);}

Далее делаем функцию для создания папок.

function createFolders(){  // Получаем словарь ключ-значение для текущего скрипта  let props = PropertiesService.getScriptProperties();  // Задаём структуру папок  let folders = [    {key:`rootFolder`,   name:`Прозапас`                         },    {key:`sheetsFolder`, name:`Sheets`, parentFolder:`rootFolder`},    {key:`docsFolder`,   name:`Docs`,   parentFolder:`rootFolder`},    {key:`formsFolder`,  name:`Forms`,  parentFolder:`rootFolder`},    {key:`slidesFolder`, name:`Slides`, parentFolder:`rootFolder`},    ];  // Проходим по структуре и создаём папки    folders.forEach(folder=>{      if (!props.getProperty(folder.key)){ // Если папка ещё не создана        // Если есть параметр rootFolder, то используем его, иначе выбираем корневую папку        let parentFolder = folder.parentFolder?DriveApp.getFolderById(props.getProperty(folder.parentFolder)):DriveApp.getRootFolder();        // Создаём папку        let folderId = parentFolder.createFolder(folder.name).getId();        // Сохраняем информацию о ней        props.setProperty(folder.key, folderId);      };    });}

И функцию для создания всех типов документов:

Основная функция, которая запускает создание папок и документов
function doMagic(){  let props = PropertiesService.getScriptProperties();  // Структура данных  let data = {    createTables:0,     createDocs:0,     createForms:0,     createSlides:0,     startTime:new Date(),    log:``,  };  // Сохраняем данные в словарь скрипта  props.setProperty(`data`, JSON.stringify(data));    try{    createFolders(); // Создаём папки    createSheets(`createTables`);// Создаём таблицы    createDocs(`createDocs`);// Создаём документы    createForms(`createForms`);// Создаём формы    createSlides(`createSlides`);// Создаём презентации        // Сообщаем, что всё готово    SpreadsheetApp.getUi().alert(`Готово!`);  }catch(err){    // При ошибке сообщаем об этом    SpreadsheetApp.getUi().alert(`Ошибка! ${err}`);  };};

Остаётся создать функцию для получения данных - так модальное окно может получить актуальные данные для отображения.

function getData(){ // Получает данные из словаря  // Получаем словарь ключ-значение  let props = PropertiesService.getScriptProperties();  // Получаем нужные данные и преобразуем в объект  let data = JSON.parse(props.getProperty(`data`));  return data; //Возвращаем данные};

И функция для записи строки в лог:

const LOG_TYPES = { // Типы сообщений. Взято из bootstrap  primary:   "primary",  secondary: "secondary",  success:   "success",  danger:    "danger",  warning:   "warning",  info:      "info",  };function logToHtml(log, type = LOG_TYPES.primary){  // Получаем данные  let data = getData();  // Добавляем li тег (лог отображается в виде списка) с данными  data.log+=`<li class="list-group-item text-${type}">${log}</li>\n`;  // Сохраняем данные  let props = PropertiesService.getScriptProperties();  props.setProperty(`data`, JSON.stringify(data));};

Что получилось

Остаётся только запустить скрипт из меню. При первом запуске Google запросит права - это нормально. После этого откроется окно, в котором можно наблюдать за прогрессом.

Модальное окно для визуализации прогрессаМодальное окно для визуализации прогресса

Ограничения скрипта

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

  • Есть ограничение на количество ежедневно созданных документов. Рано или поздно будет появляться ошибка Exception: Служба была вызвана слишком много раз за день: docs create. Тогда скрипт можно запустить на следующий день


TL;DR

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

Спасибо за внимание. Буду рад получить фидбэк по коду. Удачи!

Подробнее..

Практическое знакомство с Deno разрабатываем REST API MongoDB Linux

26.01.2021 00:16:12 | Автор: admin

Всем привет. В этот раз я решил сделать нечто более интересное, чем очередной бот, поэтому далее я покажу как реализовать REST API с Deno, подключить и использовать MongoDB в качестве базы данных, и всё это запустить из под Linux.

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

Описание задачи

В качестве примера я выбрал Github Gists API и следующие методы:

  • [POST] Create a gist;

  • [GET] List public gists;

  • [GET] Get a gist;

  • [PATCH] Update a gist;

  • [DELETE] Delete a gist.

Создание проекта

Для начала мы добавляем файл api/mod.ts :

console.log('hello world');

И проверяем, что всё работает командой deno run mod.ts:

mod.tsmod.ts

Добавление зависимостей

Создаём файл api/deps.ts и добавляем следующие зависимости:

  • Пакет oak для работы с API;

  • Пакет mongo для работы с MongoDB;

/* REST API */export { Application, Router } from "&lt;https://deno.land/x/oak/mod.ts>";export type { RouterContext } from "&lt;https://deno.land/x/oak/mod.ts>";export { getQuery } from "&lt;https://deno.land/x/oak/helpers.ts>";/* MongoDB driver */export { MongoClient, Bson } from "&lt;https://deno.land/x/mongo@v0.21.0/mod.ts>";

Отступление: В отличие от NodeJS, авторы Deno отказались от поддержки npm и node_modules, а необходимые библиотеки подключаются по url и кешируются локально. Сами библиотеки можно найти в разделе Third Party Modules на сайте http://deno.land.

Добавление API Boilerplate

Далее, добавляем код для запуска API в файл mod.ts:

import { Application, Router } from "./deps.ts";const router = new Router();router  .get("/", (context) => {    context.response.body = "Hello world!";  });const app = new Application();app.use(router.routes());app.use(router.allowedMethods());await app.listen({ port: 8000 });

Причём функции Application и Router импортируем уже из локального файла deps.ts.

Проверим, что всё было сделано верно:

  • Запускаем приложение командой deno run --allow-net mod.ts;

  • Открываем в браузере http://localhost:8000;

  • Получаем страницу с сообщением 'Hello world!';

Отступление: Deno позиционируется как secure by default. Другими словами, у запускаемого приложения (скрипта) не будет доступа к сети (--allow-net, файловой системе (--allow-readи --allow-write, параметрам окружения (--allow-env) пока этот доступ явно не разрешён.

Добавление метода POST /gists

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

Прежде всего опишем контракт:

  • [POST] /gists

  • Параметры:

    • content: string | body;

  • Ответы:

    • 201 Created;

    • 400 Bad Request;

Обработчик

Добавляем папку handlers и файл create.ts, в котором будет расположен handler (обработчик) запроса:

import { RouterContext } from "../deps.ts";import { createGist } from "../service.ts";export async function create(context: RouterContext) {  if (!context.request.hasBody) {    context.throw(400, "Bad Request: body is missing");  }  const body = context.request.body();  const { content } = await body.value;  if (!content) {    context.throw(400, "Bad Request: content is missing");  }  const gist = await createGist(content);  context.response.body = gist;  context.response.status = 201;}

В этой функции мы:

  • Валидируем входные значения (request.hasBody и !content);

  • Вызываем функцию createGist нашего сервиса (добавим далее);

  • Возвращаем добавленный объект в ответе и 201 Created.

Сервис

Далее, нам необходимо передать управление из обработчика в сервис (добавляем service.ts):

import { insertGist } from "./db.ts";export async function createGist(content: string): Promise&lt;IGist> {  const values = {    content,    created_at: new Date(),  };  const _id = await insertGist(values);  return {    _id,    ...values,  };}interface IGist {  _id: string;  content: string;  created_at: Date;}

В данном случае функция принимает единственный аргумент content: string и возвращает объект, структура которого описывается интерфейсом IGist.

Репозиторий

Последним этапом обработки запроса является сохранение записи в MongoDB. Для этого мы добавляем файл db.ts и соответствующую функцию:

import { Collection } from "&lt;https://deno.land/x/mongo@v0.21.0/src/collection/collection.ts>";import { Bson, MongoClient } from "./deps.ts";async function connect(): Promise&lt;Collection&lt;IGistSchema>> {  const client = new MongoClient();  await client.connect("mongodb://localhost:27017");  return client.database("gist_api").collection&lt;IGistSchema>("gists");}export async function insertGist(gist: any): Promise&lt;string> {  const collection = await connect();  return (await collection.insertOne(gist)).toString();}interface IGistSchema {  _id: { $oid: string };  content: string;  created_at: Date;}

В этом файле мы:

  • Импортируем необходимые типы и функции для работы с MongoDB;

  • Подключаемся к базе данных gist_api в функции connect;

  • Описываем формат объектов, которые хранятся в коллекции gist_api интерфейсом IGistSchema;

  • Сохраняем объект методом insertOne и возвращаем его идентификатор (inserted id);

Запускаем экземпляр MongoDB

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

sudo systemctl start mongodsudo systemctl status mongod

Если всё было сделано верно, то получим следующий результат:

Отступление: Как установить MongoDB на Ubuntu

Запускаем приложение

  • Компилируем и запускаем приложение командой deno run --allow-net mod.ts;

  • Открываем Postman и вызываем метод нашего API:

Если всё сделано верно, то в качестве ответа получаем 201 Created и сохранённый объект с проставленным _id:

Отступление: Как вы могли заметить, в процессе разработки мы используем TypeScript без транспайлеров. Причина проста - Deno поддерживает TypeScript из коробки.

Добавление метода GET /gists

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

Прежде всего опишем контракт:

  • [GET] /gists

  • Параметры:

    • skip: string | query;

    • limit: string | query;

  • Ответы:

    • 200 OK;

Обработчик

Добавляем файл handlers/list.ts, в котором будет расположен handler (обработчик) запроса:

import { getQuery, RouterContext } from "../deps.ts";import { getGists } from "../service.ts";export async function list(context: RouterContext) {  const { skip, limit } = getQuery(context);  const gists = await getGists(+skip || 0, +limit || 0);  context.response.body = gists;  context.response.status = 200;}

В этой функции мы:

  • Получаем параметры с query string с помощь функции getQuery;

  • Вызываем функцию getGists нашего сервиса (добавим далее);

  • Возвращаем массив найденных объектов в ответе и 200 OK;

Отступление: Функция сервиса будет принимать аргументы типа number, в то время как в обработчик к нам приходят параметры типа string. Для этого мы делаем приведение типов следующей конструкцией +skip || 0 (корректные значения конвертируются, некорректные приводятся к NaN и игнорируются в пользу 0).

Сервис

Далее, передаём управление из обработчика в сервис:

export function getGists(skip: number, limit: number): Promise&lt;IGist[]> {  return fetchGists(skip, limit);}

В данном случае функция принимает аргументы skip: number и limit: number, и возвращает массив объектов, структура которых описывается интерфейсом IGist.

Репозиторий

Последним этапом обработки запроса является получение записей из MongoDB. Для этого мы добавляем функцию fetchGists в файл db.ts:

export async function fetchGists(skip: number, limit: number): Promise&lt;any> {  const collection = await connect();  return await collection.find().skip(skip).limit(limit).toArray();}

В этой функции мы:

  • Подключаемся к базе данных gist_api в функции connect;

  • Получаем все записи коллекции, пропускаем skip из них и возвращаем в кол-ве limit;

Запускаем приложение

  • Компилируем и запускаем приложение командой deno run --allow-net mod.ts;

  • Открываем Postman и вызываем метод нашего API:

Если всё сделано верно, то в качестве ответа получаем 200 OK и массив ранее добавленных объектов:

Добавление метода GET /gists/:id

Следующим методом мы получаем запись из базы данных по её идентификатору.

Прежде всего опишем контракт:

  • [GET] /gists/:id

  • Параметры:

    • id: string | path

  • Ответы:

    • 200 OK;

    • 400 Bad Request;

    • 404 Not Found.

Обработчик

Добавляем файл handlers/get.ts, в котором будет расположен handler (обработчик) запроса:

import { RouterContext } from "../deps.ts"import { getGist } from "../service.ts";export async function get(context: RouterContext) {    const { id } = context.params;    if(!id) {        context.throw(400, "Bad Request: id is missing");    }    const gist = await getGist(id);    if(!gist) {        context.throw(404, "Not Found: the gist is missing");    }    context.response.body = gist;    context.response.status = 200;}

В этой функции мы:

  • Проверяем наличие id и возвращаем 400 если он отсутствует;

  • Запрашиваем объект в базе данных функцией getGist и возвращаем 404 если он не найден (добавим далее);

  • Возвращаем найденный объект и 200 OK;

Сервис

Далее, передаём управление из обработчика в сервис:

export function getGist(id: string): Promise&lt;IGist> {    return fetchGist(id);}interface IGist {  _id: string;  content: string;  created_at: Date;}

В данном случае функция принимает аргумент id: string и возвращает объект, структура которого описывается интерфейсом IGist.

Репозиторий

Последним этапом обработки запроса является получение записи из MongoDB. Для этого мы добавляем функцию fetchGist в файл db.ts:

export async function fetchGist(id: string): Promise&lt;any> {  const collection = await connect();  return await collection.findOne({ _id: new Bson.ObjectId(id) });}

В этой функции мы:

  • Подключаемся к базе данных gist_api в функции connect;

  • Используем метод findOne для поиска записи удовлетворяющей фильтру по _id;

Запускаем приложение

  • Компилируем и запускаем приложение командой deno run --allow-net mod.ts;

  • Открываем Postman и вызываем метод нашего API:

Если всё сделано верно, то в качестве ответа получаем 200 OK и ранее добавленный объект:

Добавление метода PATCH /gists/:id

Следующим методом мы обновляем запись в базе данных по её идентификатору.

Как и прежде, начинаем с контракта:

  • [PATCH] /gists/:id

  • Параметры:

    • id: string | path

    • content: string | body

  • Ответы:

    • 200 OK;

    • 400 Bad Request;

    • 404 Not Found.

Обработчик

Добавляем файл handlers/update.ts, в котором будет расположен handler (обработчик) запроса:

import { RouterContext } from "../deps.ts";import { getGist, patchGist } from "../service.ts";export async function update(context: RouterContext) {  const { id } = context.params;  if (!id) {    context.throw(400, "Bad Request: id is missing");  }  const body = context.request.body();  const { content } = await body.value;  if (!content) {    context.throw(400, "Bad Request: content is missing");  }  const gist = await getGist(id);  if (!gist) {    context.throw(404, "Not Found: the gist is missing");  }  await patchGist(id, content);  context.response.status = 200;}

В этой функции мы:

  • По аналогии проверяем наличие id и возвращаем 400 если он отсутствует;

  • Валидируем входное значение !content;

  • Запрашиваем объект в базе данных функцией getGist и возвращаем 404 если он не найден;

  • Обновляем объект в базе данных функцией patchGist (добавим далее);

  • Возвращаем 200 OK.

Сервис

Далее, передаём управление из обработчика в сервис:

export async function patchGist(id: string, content: string): Promise&lt;any> {  return updateGist({ id, content });}interface IGist {  _id: string;  content: string;  created_at: Date;}

В данном случае функция принимает аргументы id: string и content: string, и возвращает any.

Репозиторий

Последним этапом обработки запроса является обновлении записи в MongoDB. Для этого мы добавляем функцию updateGist в файл db.ts:

export async function updateGist(gist: any): Promise&lt;any> {  const collection = await connect();  const filter = { _id: new Bson.ObjectId(gist.id) };  const update = { $set: { content: gist.content } };  return await collection.updateOne(filter, update);}

В этой функции мы:

  • Подключаемся к базе данных gist_api в функции connect;

  • Описываем фильтр filter объектов, которые мы хотим обновить;

  • Описываем инструкцию update, которую применяем для обновления найденных объектов;

  • Используем метод updateOne собрав всё воедино;

Запускаем приложение

  • Компилируем и запускаем приложение командой deno run --allow-net mod.ts;

  • Открываем Postman и вызываем метод нашего API:

Если всё сделано верно, то в качестве ответа получаем 200 OK:

Добавление метода DELETE /gists/:id

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

По традиции, начинаем с контракта:

  • [DELETE] /gists/:id

  • Параметры:

    • id: string | path

  • Ответы:

    • 204 No Content;

    • 404 Not Found.

Обработчик

Добавляем файл handlers/remove.ts, в котором будет расположен handler (обработчик) запроса:

import { RouterContext } from "../deps.ts";import { getGist, removeGist } from "../service.ts";export async function remove(context: RouterContext) {  const { id } = context.params;  if (!id) {    context.throw(400, "Bad Request: id is missing");  }  const gist = await getGist(id);  if (!gist) {    context.throw(404, "Not Found: the gist is missing");  }  await removeGist(id);  context.response.status = 204;}

В этой функции мы:

  • По аналогии проверяем наличие id и возвращаем 400 если он отсутствует;

  • Запрашиваем объект в базе данных функцией getGist и возвращаем 404 если он не найден;

  • Удаляем объект из базы данных функцией removeGist (добавим далее);

  • Возвращаем 204 No Content.

Сервис

Далее, передаём управление из обработчика в сервис:

export function removeGist(id: string): Promise&lt;number> {  return deleteGist(id);}

В данном случае функция принимает единственный аргумент id: string и возвращает number.

Репозиторий

Последним этапом обработки запроса является удаление записи из коллекции MongoDB. Для этого мы добавляем функцию deleteGist в файл db.ts:

export async function deleteGist(id: string): Promise&lt;any> {  const collection = await connect();  return await collection.deleteOne({ _id: new Bson.ObjectId(id) });}

В этой функции мы:

  • Подключаемся к базе данных gist_api в функции connect;

  • Используем метод deleteOne для удаления объекта удовлетворяющего фильтру по _id;

Запускаем приложение

  • Компилируем и запускаем приложение командой deno run --allow-net mod.ts;

  • Открываем Postman и вызываем метод нашего API:

Если всё сделано верно, то в качестве ответа получаем 204 No Content:

Отступление: В данном случае фактическое удаление объекта из коллекции выбрано для наглядности. В реальных приложениях я предпочитаю добавить и обновлять у объекта поле isDeleted: boolean.

FAQ

Вызывая методы API я всегда получаю только 404 Not Found

Убедитесь что вы не забыли сконфигурировать router в файле mod.ts соответствующими обработчиками:

import { Application, Router } from "./deps.ts";import { list } from "./handlers/list.ts";import { create } from "./handlers/create.ts";import { remove } from "./handlers/remove.ts";import { get } from "./handlers/get.ts";import { update } from "./handlers/update.ts";const app = new Application();const router = new Router();router  .post("/gists", create)  .get("/gists", list)  .get("/gists/:id", get)  .delete("/gists/:id", remove)  .patch("/gists/:id", update);app.use(router.routes());app.use(router.allowedMethods());await app.listen({ port: 8000 });

Вызывая методы API я получаю 500 Internal Server Error

Отловить ошибку можно следующим способом:

const app = new Application();app.use(async (ctx, next) => {  try {    await next();  } catch (err) {    console.log(err);  }});...

Ссылки

Заключение

Спасибо за то что дочитали до конца.

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

Подробнее..
Категории: Javascript , Typescript , Node.js , Linux , Deno , Tutorial , Rest api , Mongodb , Ubunty

Перевод React-компоненты шаблонов проектирования

17.03.2021 18:23:31 | Автор: admin

Введение

Эта документация поможет найти компромиссы между различными шаблонами (patterns) React, а также определить, когда использование каждого из них будет наиболее целесообразным. Нижеприведенные шаблоны позволят получить более практичный и многократно используемый код, придерживаясь принципов проектирования, таких как разделение ответственности, DRY (Dont repeat yourself - не повторяй себя) и повторное использование кода. Некоторые из этих шаблонов помогут решить проблемы, которые возникают в больших React приложениях, таких как пробрасывание (prop drilling) или управление состоянием. Каждый основной шаблон включает пример, размещенный на CodeSandBox.

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

Обзор

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

Подумайте о составных компонентах, таких как элементы <select> и <option> в HTML. Порознь, они не слишком много делают, но вместе они позволяют создать полноценный результат. (Кент С. Доддс)

Зачем использовать составные компоненты? Какую ценность они обеспечивают?

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

Пример

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

Мы создадим один родительский компонент (parent component), RadioImageForm, который будет отвечать за логику формы, и один дочерний, "подкомпонент", RadioInput, который будет отображать изображения радиовходов. Вместе они создадут один составной компонент.

{/* The parent component that handles the onChange events and managing the state of the currently selected value. */}<RadioImageForm>  {/* The child, sub-components.   Each sub-component is an radio input displayed as an image  where the user is able to click an image to select a value. */}  <RadioImageForm.RadioInput />  <RadioImageForm.RadioInput />  <RadioImageForm.RadioInput /></RadioImageForm>

В файле src/components/RadioImageForm.tsx мы имеем 1 основной компонент:

  1. RadioImageForm Сначала мы создаем родительский компонент, который будет управлять состоянием и обрабатывать события изменения формы. Потребитель компонента, другие инженеры, использующие компонент, могут подписаться на текущее выбранное значение радиовходов, с помощью функции поддержки обратного вызова (callback function prop), onStateChange. При каждом изменении формы компонент будет обрабатывать обновления радиовходов и предоставлять потребителю текущее значение.

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

  1. RadioInput Далее создадим статический компонент, элемент подмножества компонента RadioImageForm. RadioInput это статический компонент, который вызывается через точечную запись в синтаксисе, например, <RadioImageForm.RadioInput/>. Это позволяет потребителю нашего компонента легко получить доступ к нашим подкомпонентам и позволяет ему иметь представление о том, как RadioInput отображается в форме.

Компонент RadioInput является статическим свойством класса RadioImageForm. Составной компонент состоит из родительского компонента RadioImageForm и статического компонента RadioInput. Далее я буду называть статические компоненты подкомпонентами ( "sub-components.").

Давайте сделаем первые шаги по созданию нашего компонента RadioImageForm.

export class RadioImageForm extends React.Component<Props, State> {  static RadioInput = ({    currentValue,    onChange,    label,    value,    name,    imgSrc,    key,  }: RadioInputProps): React.ReactElement => (    //...  );  onChange = (): void => {    // ...  };  state = {    currentValue: '',    onChange: this.onChange,    defaultValue: this.props.defaultValue || '',  };  render(): React.ReactElement {    return (      <RadioImageFormWrapper>        <form>        {/* .... */}        </form>      </RadioImageFormWrapper>    )  }}

При создании многократно используемых компонентов мы хотим предоставить продукт, в котором потребитель имеет контроль над тем, где именно элементы отображаются в его коде. Для корректной работы компонентов RadioInput потребуется доступ к внутреннему состоянию, внутренней функции onChange, а также к пропсам пользователя (user's props). Но как передать эти данные подкомпонентам? Здесь в игру вступают React.children.map и React.cloneElement. Для подробного объяснения того, как это работает вы можете углубиться в документацию React:

Конечный результат RadioImageForm при использовании метода рендерингавыглядит следующим образом:

render(): React.ReactElement {  const { currentValue, onChange, defaultValue } = this.state;  return (    <RadioImageFormWrapper>      <form>        {          React.Children.map(this.props.children,             (child: React.ReactElement) =>              React.cloneElement(child, {                currentValue,                onChange,                defaultValue,              }),          )        }      </form>    </RadioImageFormWrapper>  )}

Следует отметить в этой имплементации:

  1. RadioImageFormWrapper наши стили компонентов со стилизованными компонентами. Мы можем это проигнорировать, так как стили CSS не имеют отношения к шаблону компонентов.

  2. React.children.map итерация выполняется через дочерние компоненты напрямую, что позволяет нам манипулировать каждым дочерним компонентом непосредственно.

  3. React.cloneElement из документов React docs:

  • Клонируйте React-элемент и возвращайте новый, используйте его в качестве отправной точки. Полученный элемент будет иметь пропс (props) оригинала, который будет плавно объединен с новым пропсом. Новые дочерние элементы заменят существующие.

С помощью React.children.map и React.cloneElement мы можем выполнять итерацию и манипулировать каждым из дочерних элементов. Таким образом, мы можем передавать дополнительные пропсы, которые четко определяем в этом процессе трансформации. В этом случае мы можем передать внутреннее состояние RadioImageForm каждому дочернему компоненту RadioInput. Так как React.cloneElement выполняет мягкое слияние, то любой пропс, определенный пользователем на RadioInput, будет передан компоненту.

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

static RadioInput = ({  currentValue,  onChange,  label,  value,  name,  imgSrc,  key,}: RadioInputProps) => (  <label className="radio-button-group" key={key}>    <input      type="radio"      name={name}      value={value}      aria-label={label}      onChange={onChange}      checked={currentValue === value}      aria-checked={currentValue === value}    />    <img alt="" src={imgSrc} />    <div className="overlay">      {/* .... */}    </div>  </label>);

Отметим, что в RadioInputProps мы однозначно определили в качестве образца, какие пропсы пользователь может передавать подкомпонентам RadioInput.

Затем пользователь компонента может ссылаться на RadioInput с помощью точечной записи синтаксиса (dot-syntax notation) в своем коде (RadioImageForm.RadioInput):

// src/index.tsx<RadioImageForm onStateChange={onChange}>  {DATA.map(    ({ label, value, imgSrc }): React.ReactElement => (      <RadioImageForm.RadioInput        label={label}        value={value}        name={label}        imgSrc={imgSrc}        key={imgSrc}      />    ),  )}</RadioImageForm>

Поскольку RadioInput является статическим свойством, он не имеет доступа к элементу RadioImageForm. Следовательно, вы не можете напрямую ссылаться на состояние или методы, определённые в классе RadioImageForm. Например, this.onChange не будет работать в следующем примере: static RadioInput = () => <input onChange={this.onChange} //

Заключение

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

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

Недостатки

В то время как мы создали удобный интерфейс для пользователей наших компонентов, тем не менее есть брешь в нашем проекте. Что если <RadioImageForm.RadioInput/> будет погребен в куче div-элементов? Что произойдет, если пользователь захочет переупорядочить компоновку? Компонент всё также отобразит, но радиовход не получит текущее значение из состояния RadioImageForm, что нарушит пользовательский функционал. Этот проект шаблона компонентов не является гибким, что подводит нас к нашему следующему варианту создания шаблона компонентов.

Составные компоненты CodeSandBox

Пример составных компонентов с функциональными компонентами и React хуками (React hooks):

Составные компоненты и функциональные компоненты CodeSandBox


Гибкие компоненты соединения

Обзор

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

Зачем использовать гибкие составные компоненты? Какую ценность они представляют?

С помощью гибких составных компонентов, мы можем неявно получить доступ к внутреннему состоянию компонента нашего класса, независимо от того, где они находятся в дереве компонентов. Другая причина использования гибких составных компонентов это когда несколько компонентов должны иметь общее состояние, независимо от их положения в дереве компонентов. Пользователь компонента должен иметь возможность гибко выбирать, где будут рендерится наши составные компоненты. Для этого мы будем использовать React's Context API.

Но сначала мы должны получить некоторый контекст (context) о React's Context API, прочитав официальные React docs.

Пример

Мы продолжим на примере формы радиоизображения и рефакторинга компонента RadioImageForm для создания гибкого составного компонентного шаблона. Вы можете проследить за конечным результатом в CodeSandBox.

Давайте создадим некоторый контекст для нашего компонента RadioImageForm, чтобы мы могли передавать данные дочерним компонентам (например, RadioInput) в любом месте в родительском дереве компонентов. Будем надеяться, что вы почистили React's Context, но вот краткое резюме из документа React's doc:

  • Контекст обеспечивает способ передачи данных через дерево компонентов без необходимости передавать реквизит вручную на каждом уровне.

Во-первых, мы называем метод React.createContext, предоставляя значения по умолчанию в нашем контексте. Затем мы присваиваем отображаемое имя объекту контекста. Мы добавим его в верхнюю часть нашего файла RadioImageForm.tsx.

const RadioImageFormContext = React.createContext({  currentValue: '',  defaultValue: undefined,  onChange: () => { },});RadioImageFormContext.displayName = 'RadioImageForm';
  1. Вызовом React.createContext мы создали контекстный объект, содержащий пару Provider и Consumer. Первые будут предоставлять данные вторым; в нашем примере Provider будет показывать наше внутреннее состояние подкомпонентам.

  2. Назначив displayName нашему контекстному объекту, мы можем легко различать компоненты контекста в React Dev Tool (React Developer Tools). Таким образом, вместо Context.Provider или Context.Consumer у нас будут RadioImageForm.Provider и RadioImageForm.Consumer. Это помогает повысить удобство чтения, если у нас есть несколько компонентов, использующих Context во время отладки.

Далее мы можем рефакторизовать рендер-функцию компонента RadioImageForm и удалить из него устаревшие функции React.children.map и React.cloneElement, а также выполнить рендеринг пропсов дочерних элементов.

render(): React.ReactElement {  const { children } = this.props;  return (    <RadioImageFormWrapper>      <RadioImageFormContext.Provider value={this.state}>        {children}      </RadioImageFormContext.Provider>    </RadioImageFormWrapper>  );}

RadioImageFormContext.Provider принимает один проп (prop-свойство) с именем value. Данные, передаваемые в проп это контекст, который мы хотим предоставить потомкам (descendants) этого Provider. Подкомпонентам необходим доступ к нашему внутреннему состоянию, а также к внутренней функции onChange. Назначив метод onChange, currentValue и defaultValue объекту state, мы можем передать this.state в контекстное значение.

  • ? Всякий раз, когда value меняется на что-то другое, оно осуществляет ререндеринг себя и всех своих потребителей. React - это постоянный рендеринг, поэтому, передавая объект в пропс value, он будет ререндерить (re-render) все дочерние компоненты, потому что объект переназначается на каждом рендеринге (созданием нового объекта). Это неизбежно может привести к проблемам с производительностью, потому что переданный в объект пропс value будет воссоздаваться каждый раз, когда дочерняя компонента ререндируется (re-renders) даже в том случае, если значения в объекте не изменились. НЕ ДЕЛАЙТЕ ЭТОГО: <RadioImageFormContext.Provider value={{ currentValue: this.state.currentValue, onChange: this.onChange }}>. Вместо этого передайте this.state, чтобы предотвратить лишний ререндеринг дочерних компонентов.

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

export class RadioImageForm extends React.Component<Props, State> {  static Consumer = RadioImageFormContext.Consumer;  //...

В качестве альтернативы, если у вас есть внешние компоненты, которые должны быть подписаны на контекст, вы можете экспортировать RadioImageFormContext.Consumer в файл, например, экспортировать const RadioImageFormConsumer = RadioImageFormContext.Consumer.

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

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

static SubmitButton = ({ onSubmit }: SubmitButtonProps) => (  <RadioImageForm.Consumer>    {({ currentValue }) => (      <button        type="button"        className="btn btn-primary"        onClick={() => onSubmit(currentValue)}        disabled={!currentValue}        aria-disabled={!currentValue}      >        Submit      </button>    )}  </RadioImageForm.Consumer>);

Следует отметить, что Consumer требует функцию в качестве дочерней; он использует шаблон render props, например ({ currentValue }) => (// Render content)). Эта функция получает текущее контекстное значение, подписываясь на изменения внутреннего состояния. Это позволяет нам явно указывать, какие данные нам нужны от Provider. Например, SubmitButton ожидает свойство currentValue, которое было ссылкой на класс RadioImageForm. Но теперь он получает прямой доступ к этим значениям через Context.

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

Благодаря этим изменениям пользователь нашего компонента может использовать наши составные компоненты (compound components) в любом месте дерева компонентов (component tree).

В файле src/index.tsx вы можете посмотреть, как потребитель нашего компонента может его использовать.

Заключение

Благодаря такой схеме мы можем разрабатывать компоненты, пригодные для многократного применения, при этом потребитель может гибко использовать наши компоненты в различных контекстах. Мы обеспечили удобный интерфейс, в котором потребитель компонента не нуждается в знании внутренней логики. С помощью Context API мы можем передать неявное состояние нашего компонента подкомпонентам независимо от их глубины в иерархии. Это дает пользователю контроль для улучшения их стилистического восприятия. И в этом вся прелесть компонентов Flexible Compound Components: они помогают отделить презентацию от внутренней логики. Реализация составных компонентов с помощью Контекстного API является более выгодной, и поэтому я бы рекомендовал начинать с шаблона Гибкие составные компоненты, а не Составные компоненты.

Гибкий составной компонент CodeSandBox

Пример гибких составных компонентов с функциональными компонентами и React hooks:

Гибкие составные компоненты и Функциональные компоненты CodeSandBox


Шаблон Provider

Обзор

Шаблон Провайдера (provider pattern) является элегантным решением для совместного использования данных в дереве компонентов React. Шаблон провайдера использует предыдущие концепции, две основные из которых контекстный API в React и рендеринг пропсов.

Для получения более подробной информации посетите React docs on Context API и Render Props.

Context API:

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

Рендеринг Пропсов (Render Props):

  • Термин "render prop" относится к технике разделения кода между React-компонентами, использующей реквизит (prop), значение которого является функцией.

Зачем использовать шаблон провайдера (provider pattern)? Какую ценность он представляет?

Шаблон провайдера это мощная концепция, которая помогает при проектировании сложного приложения, так как решает несколько задач. С помощью React мы имеем дело с однонаправленным потоком данных, и при объединении нескольких компонентов мы должны обеспечить пробрасывание пропов (prop drill) общего состояния от родительского уровня к дочерним. Это может привести к неприглядному спагетти-коду (spaghetti code).

Проблема загрузки и отображения общих данных на странице заключается в обеспечении этого общего состояния для дочерних компонентов, которые нуждаются в доступе к нему. С помощью React's Context API мы можем создать компонент поставщика данных, который будет заниматься извлечением данных и предоставлением общего состояния для всего дерева компонентов. Таким образом, несколько дочерних компонентов, независимо от того, насколько глубоко они расположены, могут получить доступ к одним и тем же данным. Сбор данных и отображение данных это две отдельные задачи. В идеале, один компонент имеет только одну задачу. Родительский компонент, обертывающий данные (провайдер), в первую очередь отвечает за сбор данных и обработку общего состояния, в то время как дочерние компоненты могут сосредоточиться на том, как рендировать эти данные. Компонент провайдера также может обрабатывать бизнес-логику нормализации (normalizing) и массирования (massaging) данных при отклике (response data), чтобы дочерние компоненты последовательно получали одну и ту же модель даже при обновлении конечных точек API и изменении модели отклика данных (response data model). Такое разделение обязанностей имеет большое значение при построении больших приложений, так как помогает в обслуживании и упрощает разработку. Другие разработчики могут легко определить сферу ответственности каждого компонента.

Некоторые могут задаться вопросом, почему бы не использовать библиотеку управления состоянием, такую как Redux, MobX, Recoil, Rematch, Unstated, Easy Peasy, или ряд других? Хотя эти библиотеки могут помочь в решении проблемы управления состоянием, нет необходимости в чрезмерном совершенствовании технологии решения этой проблемы. Внедрение библиотеки управления состоянием создает массу повторяющегося кода шаблонов, сложных потоков, которые необходимо изучить другим разработчикам, а также раздувает приложение, что увеличивает его размер. Я не говорю вам, что библиотека управления состоянием бесполезна, и что вы не должны ее использовать, но важно точно знать, какое преимущество она сможет обеспечить, чтобы обосновать использование новой библиотеки. Когда я инициализировал свое приложение с помощью React, я отказался от использования библиотеки управления состоянием, несмотря на то, что так происходило во всех других проектах React. Хотя мои потребности могут отличаться от других требований, я не увидел причин усложнять нашу кодовую базу (codebase) при помощи инструмента управления состоянием, который, возможно, придется освоить будущим разработчикам. Вместо этого я выбрал решение с использованием шаблона провайдера.

Пример

После этого длительного вступления, давайте рассмотрим пример. На этот раз мы создадим очень простое приложение, чтобы продемонстрировать, как мы можем легко делиться состоянием между компонентами и даже страницами, при этом придерживаясь таких принципов проектирования, как разделение проблем (SoC) и DRY (Don't Repeat Yourself). Вы можете проследить за конечным результатом в CodeSandBox. В нашем примере, мы создадим социальное приложение для собак, где наш пользователь сможет просматривать их профиль и список друзей собак.

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

// src/components/DogDataProvider.tsxinterface State {  data: IDog;  status: Status;  error: Error;}const initState: State = { status: Status.loading, data: null, error: null };const DogDataProviderContext = React.createContext(undefined);DogDataProviderContext.displayName = 'DogDataProvider';const DogDataProvider: React.FC = ({ children }): React.ReactElement => {  const [state, setState] = React.useState<State>(initState);  React.useEffect(() => {    setState(initState);    (async (): Promise<void> => {      try {        // MOCK API CALL        const asyncMockApiFn = async (): Promise<IDog> =>          await new Promise(resolve => setTimeout(() => resolve(DATA), 1000));        const data = await asyncMockApiFn();        setState({          data,          status: Status.loaded,          error: null        });      } catch (error) {        setState({          error,          status: Status.error,          data: null        });      }    })();  }, []);  return (    <DogDataProviderContext.Provider value={state}>      {children}    </DogDataProviderContext.Provider>  );};

Примечательно в этой имплементации:

  1. Сначала мы создаем контекстный объект DogDataProviderContext с помощью React Context API через React.createContext. Это будет использоваться для обеспечения состояния потребляющих компонентов с помощью пользовательского React's хук (hook), который мы применим позже.

  2. Назначив displayName нашему контекстному объекту, мы сможем легко различать компоненты контекста в React Dev Tool. Поэтому вместо Context.Provider в React Dev Tools мы будем использовать DogDataProvider.Provider. Это поможет повысить удобство чтения, если во время отладки мы используем несколько компонентов, использующих Context.

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

  4. Модель нашего состояния включает в себя наше креативно названное свойство данных, свойство статуса и свойство ошибки. С помощью этих трех свойств дочерние компоненты могут решать, какие состояния отображать: 1. состояние загрузки, 2. состояние загрузки с рендированными данными или 3. состояние ошибки.

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

Далее мы создадим наш пользовательский React hook в том же файле, в котором мы создали компонент DogDataProvider. Пользовательский хук (hook) будет предоставлять контекстное состояние от компонента DogDataProvider к потребляющим компонентам.

// src/components/DogDataProvider.tsxexport function useDogProviderState() {  const context = React.useContext(DogDataProviderContext);  if (context === undefined) {    throw new Error('useDogProviderState must be used within DogDataProvider.');  }  return context;}

Пользовательский хук использует [React.useContext](http://personeltest.ru/aways/reactjs.org/docs/hooks-reference.html#usecontext) для получения предоставленного контекстного значения из компонента DogDataProvider, и он вернет состояние контекста, когда мы его вызовем. Выставляя пользовательский хук, компоненты-потребители могут подписаться на состояние, которое управляется в компоненте данных провайдера.

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

Наконец, мы отображаем данные при загрузке в потребляющие компоненты. Сконцентрируемся на компоненте Profile, который загружается по маршруту личного каталога (home path), но вы также можете посмотреть примеры потребительских компонентов в DogFriends и Nav компонентах.

Сначала в файле index.tsx мы должны обернуть компонент DogDataProvider на корневом уровне (root level):

// src/index.tsxfunction App() {  return (    <Router>      <div className="App">        {/* The data provder component responsible         for fetching and managing the data for the child components.        This needs to be at the top level of our component tree.*/}        <DogDataProvider>          <Nav />          <main className="py-5 md:py-20 max-w-screen-xl mx-auto text-center text-white w-full">            <Banner              title={'React Component Patterns:'}              subtitle={'Provider Pattern'}            />            <Switch>              <Route exact path="/">                {/* A child component that will consume the data from                 the data provider component, DogDataProvider. */}                <Profile />              </Route>              <Route path="/friends">                {/* A child component that will consume the data from                 the data provider component, DogDataProvider. */}                <DogFriends />              </Route>            </Switch>          </main>        </DogDataProvider>      </div>    </Router>  );}

Затем в компоненте Profile мы можем использовать пользовательский хук

useDogProviderState:

const Profile = () => {  // Our custom hook that "subscirbes" to the state changes in   // the data provider component, DogDataProvider.  const { data, status, error } = useDogProviderState();  return (    <div>      <h1 className="//...">Profile</h1>      <div className="mt-10">        {/* If the API call returns an error we will show an error message */}        {error ? (          <Error errorMessage={error.message} />          // Show a loading state when we are fetching the data        ) : status === Status.loading ? (          <Loader isInherit={true} />        ) : (          // Display the content with the data           // provided via the custom hook, useDogProviderState.          <ProfileCard data={data} />        )}      </div>    </div>  );};

Следует отметить в этой имплементации:

  1. При получении данных мы покажем состояние загрузки.

  2. Если API запрос вернет ошибку, то мы покажем сообщение об ошибке.

  3. Наконец, после того, как данные будут получены и предоставлены через пользовательский хук, useDogProviderState, мы рендерируем компонент ProfileCard.

Заключение

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

Шаблон Provider с пользовательским примером

Поскольку React хуки были представлены для React v16.8, но если вам нужна поддержка версий ниже v16.8, то здесь представлен тот же пример без хуков: CodeSandBox.


В ближайшие дни в OTUS стартует сразу несколько курсов по JavaScript разработке. Узнайте подробнее о курсах по ссылкам ниже:

- JavaScript Developer. Basic

- JavaScript Developer. Professional

- React.js Developer

Подробнее..

Перевод Vulkan. Руководство разработчика. Window surface

26.01.2021 12:18:23 | Автор: admin
Я из IT-компании CGTribe и здесь я перевожу руководство к Vulkan API. Ссылка на оригинал vulkan-tutorial.com.

Моя следующая публикация посвящена переводу главы Window surface из раздела Drawing a triangle, подраздела Presentation.

Содержание
1. Вступление

2. Краткий обзор

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

4. Рисуем треугольник

  1. Подготовка к работе
  2. Отображение на экране
    • Window surface
    • Swap chain
    • Image views
  3. Основы графического конвейера (pipeline)
  4. Отрисовка
  5. Повторное создание цепочки показа

5. Буферы вершин

  1. Описание
  2. Создание буфера вершин
  3. Staging буфер
  4. Буфер индексов

6. Uniform-буферы

  1. Дескриптор layout и буфера
  2. Дескриптор пула и sets

7. Текстурирование

  1. Изображения
  2. Image view и image sampler
  3. Комбинированный image sampler

8. Буфер глубины

9. Загрузка моделей

10. Создание мип-карт

11. Multisampling

FAQ

Политика конфиденциальности


Window surface



Поскольку Vulkan API полностью независим от платформы, он не может напрямую взаимодействовать с оконной системой. Чтобы Vulkan мог выводить результат на экран, необходимо использовать стандартизованные расширения WSI (Window System Integration). В этой главе мы расскажем про одно из них VK_KHR_surface. Расширение предоставляет объект VkSurfaceKHR абстрактный тип поверхности для показа отрендеренных изображений. Эта поверхность будет создана при поддержке окна GLFW, полученного нами ранее.

VK_KHR_surface это расширение Vulkan уровня экземпляра. У нас оно уже подключено, поскольку находится в списке расширений, возвращаемых функцией glfwGetRequiredInstanceExtensions. В списке есть и другие расширения WSI, которые мы будем использовать в следующих главах.

Window surface нужно создать сразу после VkInstance, поскольку это может повлиять на выбор физического устройства. Нужно помнить, что window surfaces полностью опциональный компонент Vulkan. Вы можете обойтись без него, если вам нужен offscreen рендеринг. Это позволяет избежать таких хаков, как, например, создание невидимого окна для OpenGL.


Создание window surface


Начнем с того, что добавим новый член класса surface сразу после вызова debugMessenger.

VkSurfaceKHR surface;

Процесс создания объекта VkSurfaceKHR зависит от платформы. Так, например, для создания в Windows нужны дескрипторы HWND и HMODULE. Для разных платформ у Vulkan есть платформенно-зависимое дополнение к расширению, которое в Windows называется VK_KHR_win32_surface. Оно также автоматически включено в список расширений, возвращаемых функцией glfwGetRequiredInstanceExtensions.

Мы покажем, как можно использовать это расширение для создания surface в Windows, однако в руководстве оно нам не понадобится. В библиотеке GLFW, которую мы используем, есть функция-обертка glfwCreateWindowSurface, содержащая специфичный для платформы код. Но было бы не плохо увидеть, что происходит за кулисами.

Window surface это объект Vulkan, поэтому мы должны заполнить структуру VkWin32SurfaceCreateInfoKHR, чтобы создать его. В ней есть два важных параметра: hwndи hinstance дескрипторы окна и текущего процесса.

VkWin32SurfaceCreateInfoKHR createInfo{};createInfo.sType = VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR;createInfo.hwnd = glfwGetWin32Window(window);createInfo.hinstance = GetModuleHandle(nullptr);

Здесь для получения сырого HWND используется функцияglfwGetWin32Window,а для полученияHINSTANCE- функция GetModuleHandle.

После этого мы можем создать surface с помощью функции vkCreateWin32SurfaceKHR, в которую передаются следующие параметры: экземпляр Vulkan, информация о surface, кастомный аллокатор и указатель для записи результата. Технически это функция расширения WSI, но она используется так часто, что была включена в стандартный загрузчик Vulkan, поэтому вам не нужно загружать ее явно.

if (vkCreateWin32SurfaceKHR(instance, &createInfo, nullptr, &surface) != VK_SUCCESS) {    throw std::runtime_error("failed to create window surface!");}

Для других платформ подход аналогичен. Для Linux, например, используется функция vkCreateXcbSurfaceKHR.

Функция glfwCreateWindowSurface делает именно эту работу, но имеет свою реализацию для каждой платформы. Интегрируем ее в нашу программу. Для этого добавим функцию createSurface, которая вызывается из initVulkan сразу после createInstance и setupDebugMessenger.

void initVulkan() {    createInstance();    setupDebugMessenger();    createSurface();    pickPhysicalDevice();    createLogicalDevice();}void createSurface() {}

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

void createSurface() {    if (glfwCreateWindowSurface(instance, window, nullptr, &surface) != VK_SUCCESS) {        throw std::runtime_error("failed to create window surface!");    }}

В функцию передаются следующие параметры: VkInstance, указатель на окно GLFW, кастомный аллокатор и указатель для записи результата. Функция возвращает VkResult.

У GLFW нет специальной функции для уничтожения surface, но это легко можно сделать непосредственно с помощью Vulkan:

void cleanup() {        ...        vkDestroySurfaceKHR(instance, surface, nullptr);        vkDestroyInstance(instance, nullptr);        ...    }

Не забудьте уничтожить surface до VkInstance.


Проверка поддержки отображения


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

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

struct QueueFamilyIndices {    std::optional<uint32_t> graphicsFamily;    std::optional<uint32_t> presentFamily;    bool isComplete() {        return graphicsFamily.has_value() && presentFamily.has_value();    }};

Изменим функцию findQueueFamilies, чтобы найти семейство очередей с поддержкой отображения на surface нашего окна. Для проверки используем функцию vkGetPhysicalDeviceSurfaceSupportKHR, которая принимает следующие параметры: физическое устройство, индекс семейства очередей и surface. Добавим вызов функции в тот же цикл, в котором находится проверка VK_QUEUE_GRAPHICS_BIT:

VkBool32 presentSupport = false;vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport);

Затем проверим значение типа VkBool32 и сохраним индекс нужного нам семейства:

if (presentSupport) {    indices.presentFamily = i;}

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


Создание очереди отображения


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

VkQueue presentQueue; 

Нам нужно несколько VkDeviceQueueCreateInfo, чтобы создать очередь для каждого из семейств. Элегантный способ это сделать использовать std::set, чтобы выделить уникальные семейства из найденных:

#include <set>...QueueFamilyIndices indices = findQueueFamilies(physicalDevice);std::vector<VkDeviceQueueCreateInfo> queueCreateInfos;std::set<uint32_t> uniqueQueueFamilies = {indices.graphicsFamily.value(), indices.presentFamily.value()};float queuePriority = 1.0f;for (uint32_t queueFamily : uniqueQueueFamilies) {    VkDeviceQueueCreateInfo queueCreateInfo{};    queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;    queueCreateInfo.queueFamilyIndex = queueFamily;    queueCreateInfo.queueCount = 1;    queueCreateInfo.pQueuePriorities = &queuePriority;    queueCreateInfos.push_back(queueCreateInfo);}

Изменим структуру VkDeviceCreateInfo, чтобы она указывала на наш вектор:

createInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size());createInfo.pQueueCreateInfos = queueCreateInfos.data();

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

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

vkGetDeviceQueue(device, indices.presentFamily.value(), 0, &presentQueue);

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

Код C++
Подробнее..

Перевод Vulkan. Руководство разработчика. Swap chain

17.02.2021 18:10:52 | Автор: admin
Я продолжаю публиковать переводы руководства к Vulkan API (cсылка на оригинал vulkan-tutorial.com), и сегодня хочу поделиться переводом новой главы Swap chain из раздела Drawing a triangle, подраздела Presentation.

Содержание
1. Вступление

2. Краткий обзор

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

4. Рисуем треугольник

  1. Подготовка к работе
  2. Отображение на экране
  3. Основы графического конвейера (pipeline)
  4. Отрисовка
  5. Повторное создание цепочки показа

5. Буферы вершин

  1. Описание
  2. Создание буфера вершин
  3. Staging буфер
  4. Буфер индексов

6. Uniform-буферы

  1. Дескриптор layout и буфера
  2. Дескриптор пула и sets

7. Текстурирование

  1. Изображения
  2. Image view и image sampler
  3. Комбинированный image sampler

8. Буфер глубины

9. Загрузка моделей

10. Создание мип-карт

11. Multisampling

FAQ

Политика конфиденциальности


Swap chain



В Vulkan нет такого понятия, как default framebuffer, поэтому ему нужна инфраструктура с буферами, куда будут рендериться изображения перед выводом на экран. Такая инфраструктура называется swap chain, и ее нужно явно создать в Vulkan. Swap chain это очередь из изображений, ожидающих вывода на экран. Программа сначала запрашивает объект image(VkImage), в который будет рисовать, а после отрисовки отправляет его обратно в очередь. То, каким именно образом работает очередь, зависит от настроек, но основная задача swap chain синхронизировать вывод изображений с частотой обновления экрана.

Проверка поддержки swap chain


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

Для начала изменим функцию isDeviceSuitable, чтобы проверить, поддерживается ли расширение. Ранее мы уже работали со списком поддерживаемых расширений, поэтому сложностей возникнуть не должно. Обратите внимание, что заголовочный файл Vulkan предоставляет удобный макрос VK_KHR_SWAPCHAIN_EXTENSION_NAME, который определен как VK_KHR_swapchain. Преимущество этого макроса в том, что, если вы допустите ошибку в написании, компилятор вас об этом предупредит.

Начнем с того, что объявим список требуемых расширений.

const std::vector<const char*> deviceExtensions = {    VK_KHR_SWAPCHAIN_EXTENSION_NAME};

Для дополнительной проверки создадим новую функцию checkDeviceExtensionSupport, вызываемую из isDeviceSuitable:

bool isDeviceSuitable(VkPhysicalDevice device) {    QueueFamilyIndices indices = findQueueFamilies(device);    bool extensionsSupported = checkDeviceExtensionSupport(device);    return indices.isComplete() && extensionsSupported;}bool checkDeviceExtensionSupport(VkPhysicalDevice device) {    return true;}

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

bool checkDeviceExtensionSupport(VkPhysicalDevice device) {    uint32_t extensionCount;    vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr);    std::vector<VkExtensionProperties> availableExtensions(extensionCount);    vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, availableExtensions.data());    std::set<std::string> requiredExtensions(deviceExtensions.begin(), deviceExtensions.end());    for (const auto& extension : availableExtensions) {        requiredExtensions.erase(extension.extensionName);    }    return requiredExtensions.empty();}

Здесь я использовал std::set<std::string>, чтобы хранить имена требуемых, но еще не подтвержденных расширений. Вы также можете использовать вложенный цикл, как в функции checkValidationLayerSupport. Разница в производительности не существенна.

Теперь запустим программу и убедимся, что наша видеокарта годится для создания swap chain. Обратите внимание, что наличие очереди отображения уже подразумевает поддержку расширения swap chain. Тем не менее, лучше убедиться в этом явно.

Подключение расширений


Чтобы использовать swap chain, сначала нужно включить расширение VK_KHR_swapchain. Для этого немного изменим заполнение VkDeviceCreateInfo при создании логического устройства:

createInfo.enabledExtensionCount = static_cast<uint32_t>(deviceExtensions.size());createInfo.ppEnabledExtensionNames = deviceExtensions.data();

Запрос информации о поддержке swap chain


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

Всего необходимо выполнить проверку 3-х типов свойств:

  • Базовые требования (capabilities) surface, такие как мин/макс число изображений в swap chain, мин/макс ширина и высота изображений
  • Формат surface (формат пикселей, цветовое пространство)
  • Доступные режимы работы

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

struct SwapChainSupportDetails {    VkSurfaceCapabilitiesKHR capabilities;    std::vector<VkSurfaceFormatKHR> formats;    std::vector<VkPresentModeKHR> presentModes;};

А теперь создадим функцию querySwapChainSupport, которая заполняет эту структуру.

SwapChainSupportDetails querySwapChainSupport(VkPhysicalDevice device) {    SwapChainSupportDetails details;    return details;}

Начнем с surface capabilities. Их легко запросить, и они возвращаются в структуру VkSurfaceCapabilitiesKHR.

vkGetPhysicalDeviceSurfaceCapabilitiesKHR(device, surface, &details.capabilities);

Эта функция принимает созданные ранее VkPhysicalDevice и VkSurfaceKHR. Каждый раз, когда мы будем запрашивать поддерживаемый функционал, эти два параметра будут первыми, поскольку они являются ключевыми компонентами swap chain.

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

uint32_t formatCount;vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr);if (formatCount != 0) {    details.formats.resize(formatCount);    vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, details.formats.data());}

Убедитесь, что вы выделили достаточно места в векторе, чтобы получить все доступные форматы.

Этим же способом запросим поддерживаемые режимы работы с помощью функции vkGetPhysicalDeviceSurfacePresentModesKHR:

uint32_t presentModeCount;vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, nullptr);if (presentModeCount != 0) {    details.presentModes.resize(presentModeCount);    vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, details.presentModes.data());}

Когда вся необходимая информация будет в структуре, дополним функцию isDeviceSuitable, чтобы проверить, поддерживается ли swap chain. В рамках этого руководства, будем считать, что если есть хотя бы один поддерживаемый формат изображений и один поддерживаемый режим работы для window surface, значит swap chain поддерживается.

bool swapChainAdequate = false;if (extensionsSupported) {    SwapChainSupportDetails swapChainSupport = querySwapChainSupport(device);    swapChainAdequate = !swapChainSupport.formats.empty() && !swapChainSupport.presentModes.empty();}

Запрашивать поддержку swap chain нужно только после того, как вы убедитесь, что расширение доступно.

Последняя строка функции меняется на:

return indices.isComplete() && extensionsSupported && swapChainAdequate;

Выбор настроек для swap chain


Если swapChainAdequate имеет значение true, значит swap chain поддерживается. Но у swap chain может быть несколько режимов. Напишем несколько функций, чтобы подобрать подходящие настройки для создания наиболее эффективной swap chain.

Всего выделим 3 типа настроек:
  • формат surface (глубина цвета)
  • режим работы (условия для смены кадров на экране)
  • swap extent (разрешение изображений в swap chain)

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

Формат surface


Добавим функцию для выбора формата:

VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) {}

Позже мы будем передавать член formats из структуры SwapChainSupportDetails в качестве аргумента.

Каждый элемент availableFormats содержит члены format и colorSpace. Поле format определяет количество и типы каналов. Например, VK_FORMAT_B8G8R8A8_SRGB обозначает, что у нас есть B, G, R и альфа каналы по 8 бит, всего 32 бита на пиксель. С помощью флага VK_COLOR_SPACE_SRGB_NONLINEAR_KHR в поле colorSpace указывается, поддерживается ли цветовое пространство SRGB. Обратите внимание, что в ранней версии спецификации этот флаг назывался VK_COLORSPACE_SRGB_NONLINEAR_KHR.

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

Пройдемся по списку и проверим, доступна ли нужная нам комбинация:

for (const auto& availableFormat : availableFormats) {    if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {        return availableFormat;    }}

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

VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) {    for (const auto& availableFormat : availableFormats) {        if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {            return availableFormat;        }    }    return availableFormats[0];}

Режим работы


Режим работы, пожалуй, самая важная настройка swap chain, поскольку он определяет условия для смены кадров на экране.

Всего в Vulkan доступны четыре режима:

  • VK_PRESENT_MODE_IMMEDIATE_KHR: изображения, отправленные вашим приложением, немедленно отправляются на экран, что может приводить к артефактам.
  • VK_PRESENT_MODE_FIFO_KHR: изображения для вывода на экран берутся из начала очереди в момент обновления экрана. В то время, как программа помещает отрендеренные изображения в конец очереди. Если очередь заполнена, программа будет ждать. Это похоже на вертикальную синхронизацию, используемую в современных играх.
  • VK_PRESENT_MODE_FIFO_RELAXED_KHR: этот режим отличается от предыдущего только в одном случае, когда происходит задержка программы и в момент обновления экрана остается пустая очередь. Тогда изображение передается на экран сразу после его появления без ожидания обновления экрана. Это может привести к видимым артефактам.
  • VK_PRESENT_MODE_MAILBOX_KHR: это еще один вариант второго режима. Вместо того, чтобы блокировать программу при заполнении очереди, изображения в очереди заменяются новыми. Этот режим подходит для реализации тройной буферизации. С ней вы можете избежать появления артефактов при низком времени ожидания.

Гарантированно доступен только режим VK_PRESENT_MODE_FIFO_KHR, поэтому нам снова придется написать функцию для поиска лучшего доступного режима:

VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR>& availablePresentModes) {    return VK_PRESENT_MODE_FIFO_KHR;}

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

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

VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR>& availablePresentModes) {    for (const auto& availablePresentMode : availablePresentModes) {        if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR) {            return availablePresentMode;        }    }    return VK_PRESENT_MODE_FIFO_KHR;}

Swap extent


Осталось настроить последнее свойство. Для этого добавим функцию:

VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {}

Swap extent это разрешение изображений в swap chain, которое почти всегда совпадает с разрешением окна (в пикселях), куда рендерятся изображения. Допустимый диапазон мы получили в структуре VkSurfaceCapabilitiesKHR. Vulkan сообщает нам, какое разрешение мы должны выставить, с помощью поля currentExtent (соответствует размеру окна). Однако некоторые оконные менеджеры допускают использование разных разрешений. Для этого указывается специальное значение ширины и высоты в currentExtent максимальное значение типа uint32_t. В таком случае из промежутка между minImageExtent и maxImageExtent мы выберем разрешение, которое больше всего соответствует разрешению окна. Главное правильно указать единицы измерения.

В GLFW используется две единицы измерения: пиксели и экранные координаты. Так, разрешение {WIDTH, HEIGHT}, которое мы указали при создании окна, измеряется в экранных координатах. Но поскольку Vulkan работает с пикселями, разрешение swap chain тоже должно быть указано в пикселях. Если вы используете дисплей с высоким разрешением (например, дисплей Retina от Apple), экранные координаты не соответствуют пикселям: из-за более высокой плотности пикселей разрешение окна в пикселях выше, чем в экранных координатах. Так как Vulkan сам не исправит разрешение swap chain для нас, мы не можем использовать исходное разрешение {WIDTH, HEIGHT}. Вместо этого мы должны использовать glfwGetFramebufferSize, чтобы запросить разрешение окна в пикселях, прежде чем сопоставлять его с минимальным и максимальным разрешением изображений.

#include <cstdint> // Necessary for UINT32_MAX...VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {    if (capabilities.currentExtent.width != UINT32_MAX) {        return capabilities.currentExtent;    } else {        int width, height;        glfwGetFramebufferSize(window, &width, &height);        VkExtent2D actualExtent = {            static_cast<uint32_t>(width),            static_cast<uint32_t>(height)        };        actualExtent.width = std::max(capabilities.minImageExtent.width, std::min(capabilities.maxImageExtent.width, actualExtent.width));        actualExtent.height = std::max(capabilities.minImageExtent.height, std::min(capabilities.maxImageExtent.height, actualExtent.height));        return actualExtent;    }}

Функции max и min здесь используются для ограничения значений width и height в пределах доступных разрешений. Не забудьте подключить заголовочный файл <algorithm> для использования функций.

Создание swap chain


Теперь у нас есть вся необходимая информация для создания подходящей swap chain.

Создадим функцию createSwapChain и вызовем ее из initVulkan после создания логического устройства.

void initVulkan() {    createInstance();    setupDebugMessenger();    createSurface();    pickPhysicalDevice();    createLogicalDevice();    createSwapChain();}void createSwapChain() {    SwapChainSupportDetails swapChainSupport = querySwapChainSupport(physicalDevice);    VkSurfaceFormatKHR surfaceFormat = chooseSwapSurfaceFormat(swapChainSupport.formats);    VkPresentModeKHR presentMode = chooseSwapPresentMode(swapChainSupport.presentModes);    VkExtent2D extent = chooseSwapExtent(swapChainSupport.capabilities);}

Теперь нужно решить, сколько объектов image должно быть в swap chain. В реализации указывается минимальное количество, необходимое для работы:

uint32_t imageCount = swapChainSupport.capabilities.minImageCount;

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

uint32_t imageCount = swapChainSupport.capabilities.minImageCount + 1;

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

if (swapChainSupport.capabilities.maxImageCount > 0 && imageCount > swapChainSupport.capabilities.maxImageCount) {    imageCount = swapChainSupport.capabilities.maxImageCount;}

Swap chain это объект Vulkan, поэтому для его создания требуется заполнить структуру. Начало структуры нам уже знакомо:

VkSwapchainCreateInfoKHR createInfo{};createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;createInfo.surface = surface;

Сначала указывается surface, к которой привязан swap chain, далее информация для создания image объектов:

createInfo.minImageCount = imageCount;createInfo.imageFormat = surfaceFormat.format;createInfo.imageColorSpace = surfaceFormat.colorSpace;createInfo.imageExtent = extent;createInfo.imageArrayLayers = 1;createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;

В imageArrayLayers указывается число слоев, из которых состоит каждый image. Здесь всегда будет значение 1, если, конечно, это не стереоизображения. Битовое поле imageUsage указывает, для каких операций будут использоваться images, полученные из swap chain. В руководстве мы будем рендерить непосредственно в них, но вы можете сначала рендерить в отдельный image, например, для постобработки. В таком случае используйте значение VK_IMAGE_USAGE_TRANSFER_DST_BIT, а для переноса используйте операции перемещения в памяти (memory operation).

QueueFamilyIndices indices = findQueueFamilies(physicalDevice);uint32_t queueFamilyIndices[] = {indices.graphicsFamily.value(), indices.presentFamily.value()};if (indices.graphicsFamily != indices.presentFamily) {    createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT;    createInfo.queueFamilyIndexCount = 2;    createInfo.pQueueFamilyIndices = queueFamilyIndices;} else {    createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;    createInfo.queueFamilyIndexCount = 0; // Optional    createInfo.pQueueFamilyIndices = nullptr; // Optional}

Затем нужно указать, как обрабатывать объекты images, которые используются в нескольких семействах очередей. Это актуально для случаев, когда семейство с поддержкой графических операций и семейство с поддержкой отображения это разные семейства. Мы будем рендерить на image в графической очереди, а затем отправлять их в очередь отображения.

Есть два способа обработки image с доступом из нескольких очередей:

  • VK_SHARING_MODE_EXCLUSIVE: объект принадлежит одному семейству очередей, и право владения должно быть передано явно перед использованием его в другом семействе очередей. Такой способ обеспечивает самую высокую производительность.
  • VK_SHARING_MODE_CONCURRENT: объекты могут использоваться в нескольких семействах очередей без явной передачи права владения.

Если у нас несколько очередей, мы будем использовать VK_SHARING_MODE_CONCURRENT. Для этого способа требуется заранее указать, между какими семействами очередей будет разделено владение. Это можно сделать с помощью параметров queueFamilyIndexCount и pQueueFamilyIndices. Если семейство графических очередей и семейство очередей отображения совпадают, что случается чаще, используйте VK_SHARING_MODE_EXCLUSIVE.

createInfo.preTransform = swapChainSupport.capabilities.currentTransform;

Можно указать, чтобы к изображениям в swap chain применялось какое-либо преобразование из поддерживаемых (supportedTransforms в capabilities), например, поворот на 90 градусов по часовой стрелке или отражение по горизонтали. Чтобы не применять никаких преобразований, просто оставьте currentTransform.

createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;

Поле compositeAlpha указывает, нужно ли использовать альфа-канал для смешивания с другими окнами в оконной системе. Скорее всего, альфа-канал вам не понадобится, поэтому оставьте VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR.

createInfo.presentMode = presentMode;createInfo.clipped = VK_TRUE;

Поле presentMode говорит само за себя. Если мы выставим VK_TRUE в поле clipped, значит нас не интересуют скрытые пикселы (например, если часть нашего окна перекрыта другим окном). Вы всегда сможете выключить clipping, если вам понадобится прочитать пиксели, а пока оставим clipping включенным.

createInfo.oldSwapchain = VK_NULL_HANDLE;

Остается последнее поле oldSwapChain. Если swap chain станет недействительной, например, из-за изменения размера окна, ее нужно будет воссоздать с нуля и в поле oldSwapChain указать ссылку на старую swap chain. Это сложная тема, которую мы рассмотрим в одной из следующих глав. Пока представим, что у нас будет только одна swap chain.

Добавим член класса для хранения объекта VkSwapchainKHR:

VkSwapchainKHR swapChain;

Теперь надо просто вызвать vkCreateSwapchainKHR для создания swap chain:

if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapChain) != VK_SUCCESS) {    throw std::runtime_error("failed to create swap chain!");}

В функцию передаются следующие параметры: логическое устройство, информация о swap chain, опциональный кастомный аллокатор и указатель для записи результата. Никаких сюрпризов. Swap chain нужно уничтожить с помощью vkDestroySwapchainKHR до уничтожения устройства:

void cleanup() {    vkDestroySwapchainKHR(device, swapChain, nullptr);    ...}

Теперь запустим программу, чтобы убедиться, что swap chain была создана успешно. Если придет сообщение об ошибке или сообщение типа Не удалось найти vkGetInstanceProcAddress в SteamOverlayVulkanLayer.dll, зайдите в раздел FAQ.

Попробуем удалить строку createInfo.imageExtent = extent; с включенными слоями валидации. Один из уровней валидации сразу же обнаружит ошибку и уведомит нас:

image

Получение image из swap chain


Теперь, когда swap chain создана, осталось получить дескрипторы VkImages. Добавим член класса для хранения дескрипторов:

std::vector<VkImage> swapChainImages;

Объекты image из swap chain будут уничтожены автоматически после уничтожения самой swap chain, поэтому добавлять код очистки не нужно.

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

vkGetSwapchainImagesKHR(device, swapChain, &imageCount, nullptr);swapChainImages.resize(imageCount);vkGetSwapchainImagesKHR(device, swapChain, &imageCount, swapChainImages.data());

И последнее сохраним формат и разрешение изображений swap chain в переменные класса. Они понадобятся нам в дальнейшем.

VkSwapchainKHR swapChain;std::vector<VkImage> swapChainImages;VkFormat swapChainImageFormat;VkExtent2D swapChainExtent;...swapChainImageFormat = surfaceFormat.format;swapChainExtent = extent;

Теперь у нас есть image для отрисовки и вывода на экран. В следующей главе мы расскажем, как настроить image для использования в качестве render target-ов, и начнем знакомиться с графическим конвейером и командами рисования!

C++
Подробнее..

Пишем Slack бота для Scrum покера на Go. Часть 1

04.03.2021 00:14:15 | Автор: admin

Здравствуйте! Сегодня мы напишем Slack бота для Scrum покера на языке Go. Писать будем по возможности без фреймворков и внешних библиотек, так как наша цель разобраться с языком программирования Go и проверить, насколько этот язык удобен для разработки подобных проектов.

Дисклеймер

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

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

Хватит прелюдий, вперед в бой!

Итоговый результат

Анимация работы будущего бота

Для тех, кому читать код интересней, чем статью прошу сюда.

Структура приложения

Разобьем нашу программу на следующие слои. У нас предполагается слой взаимодействия (web), слой для рисования интерфейса средствами Slack UI Block Kit (ui), слой для сохранения / получения результатов (storage), а также место для хранения настроек (config). Давайте создадим следующие папки в проекте:

config/storage/ui/web/-- clients/-- server/main.go

Сервер

Для сервера будем использовать стандартный сервер из пакета http. Создадим структуру Server следующего вида в web -> server:

server.go
package serverimport ("context""log""net/http""os""os/signal""sync/atomic""time")type Server struct {  // Здесь мы будем определять все необходимые нам зависимости и передавать их на старте приложения в main.gohealthy        int32logger         *log.Logger}func NewServer(logger *log.Logger) *Server {return &Server{logger: logger,}}

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

server.go
func (s *Server) setupRouter() http.Handler {  // TODOrouter := http.NewServeMux()  return router}func (s *Server) Serve(address string) {server := &http.Server{Addr:         address,    Handler:      s.setupRouter(),ErrorLog:     s.logger, // Наш логгерReadTimeout:  5 * time.Second,WriteTimeout: 10 * time.Second,IdleTimeout:  15 * time.Second,}  // Создаем каналы для корректного завершения процессаdone := make(chan bool)quit := make(chan os.Signal, 1)  // Настраиваем сигнал для корректного завершения процессаsignal.Notify(quit, os.Interrupt)go func() {<-quits.logger.Println("Server is shutting down...")    // Эта переменная пригодится для healthcheck'а напримерatomic.StoreInt32(&s.healthy, 0)    // Даем клиентам 30 секунд для завершения всех операций, прежде чем сервер будет остановленctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)defer cancel()    // Информируем сервер о том, что не нужно держать существующие коннектыserver.SetKeepAlivesEnabled(false)    // Выключаем серверif err := server.Shutdown(ctx); err != nil {s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)}close(done)}()s.logger.Println("Server is ready to handle requests at", address)  // Переменная для проверки того, что сервер запустился и все хорошоatomic.StoreInt32(&s.healthy, 1)  // Запускаем серверif err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {s.logger.Fatalf("Could not listen on %s: %v\n", address, err)}  // Когда сервер остановлен и все хорошо, снова получаем управление и логируем результат<-dones.logger.Println("Server stopped")}

Теперь давайте создадим первый хэндлер. Создадим папку в web -> server -> handlers:

healthcheck.go
package handlersimport ("net/http")func Healthcheck() http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {w.Write("OK")})}

Добавим наш хэндлер в роутер:

server.go
// Наш код вышеfunc (s *Server) setupRouter() http.Handler {router := http.NewServeMux()router.Handle("/healthcheck",handlers.Healthcheck(),)  return router}// Наш код ниже

Идем в main.go и пробуем запустить наш сервер:

package mainimport ("log"  "os"  "go-scrum-poker-bot/web/server")func main() {  // Создаем логгер со стандартными флагами и префиксом "INFO:".   // Писать он будет только в stdoutlogger := log.New(os.Stdout, "INFO: ", log.LstdFlags)app := server.NewServer(logger)app.Serve(":8000")}

Пробуем запустить проект:

go run main.go

Если все хорошо, то сервер запустится на :8000 порту. Наш текущий подход к созданию хэндлеров позволяет передавать в них любые зависимости. Это нам еще пригодится, когда мы будем писать тесты. ;) Прежде чем идти дальше, нам нужно немного настроить нашу локальную среду, чтобы Slack смог с нами взаимодействовать.

NGROK

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

ngrok http 8000

Если все хорошо, то вы увидите что-то вроде этого:

ngrok by @inconshreveable                                                                                                            (Ctrl+C to quit)                                                                                                                                                     Session Status                online                                                                                                                 Account                       Sayakhov Ilya (Plan: Free)                                                                                             Version                       2.3.35                                                                                                                 Region                        United States (us)                                                                                                     Web Interface                 http://127.0.0.1:4040                                                                                                  Forwarding                    http://ffd3cfcc460c.ngrok.io -> http://localhost:8000                                                                  Forwarding                    https://ffd3cfcc460c.ngrok.io -> http://localhost:8000                                                                                                                                                                                                                      Connections                   ttl     opn     rt1     rt5     p50     p90                                                                                                          0       0       0.00    0.00    0.00    0.00     

Нас интересует строчка https://ffd3cfcc460c.ngrok.io. Она нам понадобится дальше.

Slash commands

Создадим наше приложение в Slack. Для этого нужно перейти сюда -> Create New App. Далее указываем имя GoScrumPokerBot и добавляем его в свой Workspace. Далее, нам нужно дать нашему боту права. Для этого идем в OAuth & Permissions -> Scopes и добавляем следующие права: chat:write, commands. Первый набор прав нужен, чтобы бот мог писать в каналы, а второй для slash команд. И наконец нажимаем на Reinstall to Workspace. Готово! Теперь идем в раздел Slash commands и добавляем нашу команду /poker .

В Request URL нужно вписать адрес из пункта выше + путь. Пусть будет так: https://ffd3cfcc460c.ngrok.io/play-poker.

Slash command handler

Теперь создадим хэндлер для обработки событий на только созданную команду. Идем в web -> server -> handlers и создаем файл play_poker.go:

func PlayPokerCommand() http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {    w.Header().Set("Content-Type", "application/json")    w.Write([]byte(`{"response_type": "ephemeral", "text": "Hello world!"}`))})}

Добавляем наш хэндлер в роутер:

server.go
func (s *Server) setupRouter() http.Handler {router := http.NewServeMux()router.Handle("/healthcheck",handlers.Healthcheck(),)router.Handle("/play-poker",handlers.PlayPokerCommand(),)  return router}

Идем в Slack и пробуем выполнить эту команду: /poker. В ответ вы должны получить что-то вроде этого:

Но это не единственный вариант взаимодействия со Slack. Мы также можем слать сообщения в канал. Этот вариант мне понравился больше и плюс у него больше возможностей в сравнении с ответом на команду. Например вы можете послать сообщение в фоне (если оно требует долгих вычислений). Давайте напишем наш http клиента. Идем в web -> clients. Создаем файл client.go:

client.go
package clients// Создадим новый тип для наших хэндлеровtype Handler func(request *Request) *Response// Создадим новый тип для middleware (о них чуть позже)type Middleware func(handler Handler, request *Request) Handler// Создадим интерфейс http клиентаtype Client interface {Make(request *Request) *Response}// Наша реализация клиентаtype BasicClient struct {client     *http.Clientmiddleware []Middleware}func NewBasicClient(client *http.Client, middleware []Middleware) Client {return &BasicClient{client: client, middleware: middleware}}// Приватный метод для всей грязной работыfunc (c *BasicClient) makeRequest(request *Request) *Response {payload, err := request.ToBytes() // TODOif err != nil {return &Response{Error: err}}  // Создаем новый request, передаем в него данныеreq, err := http.NewRequest(request.Method, request.URL, bytes.NewBuffer(payload))if err != nil {return &Response{Error: err}}  // Применяем заголовкиfor name, value := range request.Headers {req.Header.Add(name, value)}  // Выполняем запросresp, err := c.client.Do(req)if err != nil {return &Response{Error: err}}defer resp.Body.Close()  // Читаем тело ответаbody, err := ioutil.ReadAll(resp.Body)if err != nil {return &Response{Error: err}}err = nil  // Если вернулось что-то отличное выше или ниже 20x, то ошибкаif resp.StatusCode > http.StatusIMUsed || resp.StatusCode < http.StatusOK {err = fmt.Errorf("Bad response. Status: %d, Body: %s", resp.StatusCode, string(body))}return &Response{Status:  resp.StatusCode,Body:    body,Headers: resp.Header,Error:   err,}}// Наш публичный метод для запросовfunc (c *BasicClient) Make(request *Request) *Response {if request.Headers == nil {request.Headers = make(map[string]string)}    // Применяем middlewarehandler := c.makeRequestfor _, middleware := range c.middleware {handler = middleware(handler, request)}return handler(request)}

Теперь создадим файл web -> clients:

request.go
package clientsimport "encoding/json"type Request struct {URL     stringMethod  stringHeaders map[string]stringJson    interface{}}func (r *Request) ToBytes() ([]byte, error) {if r.Json != nil {result, err := json.Marshal(r.Json)if err != nil {return []byte{}, err}return result, nil}return []byte{}, nil}

Сразу напишем тесты к методу ToBytes(). Для тестов я взял testify/assert, так как без нее была бы куча if'ов, а меня они напрягают :) . К тому же, я привык к pytest и его assert, да и как-то глазу приятнее:

request_test.go
package clients_testimport ("encoding/json""go-scrum-poker-bot/web/clients""reflect""testing""github.com/stretchr/testify/assert")func TestRequestToBytes(t *testing.T) {  // Здесь мы делаем что-то вроде pytest.parametrize (жаль, что в Go нет сахара для декораторов, это было бы удобнее)testCases := []struct {json interface{}data []byteerr  error}{{map[string]string{"test_key": "test_value"}, []byte("{\"test_key\":\"test_value\"}"), nil},{nil, []byte{}, nil},{make(chan int), []byte{}, &json.UnsupportedTypeError{Type: reflect.TypeOf(make(chan int))}},}  // Проходимся по нашим тест кейсамfor _, testCase := range testCases {request := clients.Request{URL:     "https://example.com",Method:  "GET",Headers: nil,Json:    testCase.json,}actual, err := request.ToBytes()    // Проверяем результатыassert.Equal(t, testCase.err, err)assert.Equal(t, testCase.data, actual)}}

И нам нужен web -> clients:

response.go
package clientsimport "encoding/json"type Response struct {Status  intHeaders map[string][]stringBody    []byteError   error}// Я намеренно сделал универсальный метод, чтобы можно было привезти любой ответ к нужному и не писать каждый раз эти богомерзкие if err != nilfunc (r *Response) Json(to interface{}) error {if r.Error != nil {return r.Error}return json.Unmarshal(r.Body, to)}

И также, напишем тесты для метода Json(to interface{}):

response_test.go
package clients_testimport ("errors""go-scrum-poker-bot/web/clients""testing""github.com/stretchr/testify/assert")// Один тест на позитивный кейсfunc TestResponseJson(t *testing.T) {to := struct {TestKey string `json:"test_key"`}{}response := clients.Response{Status:  200,Headers: nil,Body:    []byte(`{"test_key": "test_value"}`),Error:   nil,}err := response.Json(&to)assert.Equal(t, nil, err)assert.Equal(t, "test_value", to.TestKey)}// Один тест на ошибкуfunc TestResponseJsonError(t *testing.T) {expectedErr := errors.New("Error!")response := clients.Response{Status:  200,Headers: nil,Body:    nil,Error:   expectedErr,}err := response.Json(map[string]string{})assert.Equal(t, expectedErr, err)}

Теперь, когда у нас есть все необходимое, нам нужно написать тесты для клиента. Есть несколько вариантов написания тестов для http клиента. Я выбрал вариант с подменой http транспорта. Однако есть и другие варианты, но этот мне показался удобнее:

client_test.go
package clients_testimport ("bytes""go-scrum-poker-bot/web/clients""io/ioutil""net/http""testing""github.com/stretchr/testify/assert")// Для удобства объявим новый типtype RoundTripFunc func(request *http.Request) *http.Responsefunc (f RoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {return f(request), nil}// Создание mock тестового клиентаfunc NewTestClient(fn RoundTripFunc) *http.Client {return &http.Client{Transport: RoundTripFunc(fn),}}// Валидный тестfunc TestMakeRequest(t *testing.T) {url := "https://example.com/ok"  // Создаем mock клиента и пишем нужный нам ответhttpClient := NewTestClient(func(req *http.Request) *http.Response {assert.Equal(t, req.URL.String(), url)return &http.Response{StatusCode: http.StatusOK,Body:       ioutil.NopCloser(bytes.NewBufferString("OK")),Header:     make(http.Header),}})  // Создаем нашего http клиента с замоканным http клиентомwebClient := clients.NewBasicClient(httpClient, nil)response := webClient.Make(&clients.Request{URL:     url,Method:  "GET",Headers: map[string]string{"Content-Type": "application/json"},Json:    nil,})assert.Equal(t, http.StatusOK, response.Status)}// Тест на ошибочный responsefunc TestMakeRequestError(t *testing.T) {url := "https://example.com/error"httpClient := NewTestClient(func(req *http.Request) *http.Response {assert.Equal(t, req.URL.String(), url)return &http.Response{StatusCode: http.StatusBadGateway,Body:       ioutil.NopCloser(bytes.NewBufferString("Bad gateway")),Header:     make(http.Header),}})webClient := clients.NewBasicClient(httpClient, nil)response := webClient.Make(&clients.Request{URL:     url,Method:  "GET",Headers: map[string]string{"Content-Type": "application/json"},Json:    nil,})assert.Equal(t, http.StatusBadGateway, response.Status)}

Отлично! Теперь давайте напишем middleware. Я привык для каждой, даже самой маленькой задачи, писать отдельную маленькую middleware. Так можно легко переиспользовать такой код в разных проектах / для разных API с разными требованиями к заголовкам / авторизации и так далее. Slack требует при отправке сообщений в канал указывать Authorization заголовок с токеном, который вы сможете найти в разделе OAuth & Permissions. Создаем в web -> clients -> middleware:

auth.go
package middlewareimport ("fmt""go-scrum-poker-bot/web/clients")// Токен будем передавать при определении middleware на этапе инициализации клиентаfunc Auth(token string) clients.Middleware {return func(handler clients.Handler, request *clients.Request) clients.Handler {return func(request *clients.Request) *clients.Response {request.Headers["Authorization"] = fmt.Sprintf("Bearer %s", token)return handler(request)}}}

И напишем тест к ней:

auth_test.go
package middleware_testimport ("fmt""go-scrum-poker-bot/web/clients""go-scrum-poker-bot/web/clients/middleware""testing""github.com/stretchr/testify/assert")func TestAuthMiddleware(t *testing.T) {token := "test"request := &clients.Request{Headers: map[string]string{},}handler := middleware.Auth(token)(func(request *clients.Request) *clients.Response {return &clients.Response{}},request,)handler(request)assert.Equal(t, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)}, request.Headers)}

Также в репозитории вы сможете найти middleware для логирования и установки Content-Type: application/json. Здесь я не буду приводить этот код в целях экономии времени и места :).

Давайте перепишем наш PlayPoker хэндлер:

play_poker.go
package handlersimport ("errors""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/clients""go-scrum-poker-bot/web/server/models""net/http""github.com/google/uuid")func PlayPokerCommand(webClient clients.Client, uiBuilder *ui.Builder) http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {    // Добавим проверку, что нам пришли данные из POST Form с текстом и ID каналаif r.PostFormValue("channel_id") == "" || r.PostFormValue("text") == "" {w.Write(models.ResponseError(errors.New("Please write correct subject"))) // TODOreturn}resp := webClient.Make(&clients.Request{URL:    "https://slack.com/api/chat.postMessage",Method: "POST",      Json: uiBuilder.Build( // TODO: Напишем builder позжеr.PostFormValue("channel_id"),uuid.New().String(),r.PostFormValue("text"),nil,false,),})if resp.Error != nil {w.Write(models.ResponseError(resp.Error)) // TODOreturn}})}

И создадим в web -> server -> models . Файл errors.go для быстрого формирования ошибок:

errors.go
package modelsimport ("encoding/json""fmt")type SlackError struct {ResponseType string `json:"response_type"`Text         string `json:"text"`}func ResponseError(err error) []byte {resp, err := json.Marshal(SlackError{ResponseType: "ephemeral",Text:         fmt.Sprintf("Sorry, there is some error happened. Error: %s", err.Error()),},)if err != nil {return []byte("Sorry. Some error happened")}return resp}

Напишем тесты для хэндлера:

play_poker_test.go
package handlers_testimport ("errors""go-scrum-poker-bot/config""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/server/handlers""go-scrum-poker-bot/web/server/models""net/http""net/http/httptest""net/url""strings""testing""github.com/stretchr/testify/assert")func TestPlayPokerHandler(t *testing.T) {config := config.NewConfig() // TODOmockClient := &MockClient{}uiBuilder := ui.NewBuilder(config) // TODOresponseRec := httptest.NewRecorder()router := http.NewServeMux()router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()request, err := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))request.Header.Set("Content-Type", "application/x-www-form-urlencoded")router.ServeHTTP(responseRec, request)assert.Nil(t, err)assert.Equal(t, http.StatusOK, responseRec.Code)assert.Empty(t, responseRec.Body.String())assert.Equal(t, true, mockClient.Called)}func TestPlayPokerHandlerEmptyBodyError(t *testing.T) {config := config.NewConfig()mockClient := &MockClient{}uiBuilder := ui.NewBuilder(config)responseRec := httptest.NewRecorder()router := http.NewServeMux()router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))payload := url.Values{}.Encode()request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))request.Header.Set("Content-Type", "application/x-www-form-urlencoded")router.ServeHTTP(responseRec, request)expected := string(models.ResponseError(errors.New("Please write correct subject")))assert.Equal(t, http.StatusOK, responseRec.Code)assert.Equal(t, expected, responseRec.Body.String())assert.Equal(t, false, mockClient.Called)}func TestPlayPokerHandlerRequestError(t *testing.T) {errMsg := "Error msg"config := config.NewConfig() // TODOmockClient := &MockClient{Error: errMsg}uiBuilder := ui.NewBuilder(config) // TODOresponseRec := httptest.NewRecorder()router := http.NewServeMux()router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))request.Header.Set("Content-Type", "application/x-www-form-urlencoded")router.ServeHTTP(responseRec, request)expected := string(models.ResponseError(errors.New(errMsg)))assert.Equal(t, http.StatusOK, responseRec.Code)assert.Equal(t, expected, responseRec.Body.String())assert.Equal(t, true, mockClient.Called)}

Теперь нам нужно написать mock для нашего http клиента:

common_test.go
package handlers_testimport ("errors""go-scrum-poker-bot/web/clients")type MockClient struct {Called boolError  string}func (c *MockClient) Make(request *clients.Request) *clients.Response {c.Called = truevar err error = nilif c.Error != "" {err = errors.New(c.Error)}return &clients.Response{Error: err}}

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

Теперь можно приступить к написанию UI строителя интерфейсов для Slack UI Block Kit. Там все довольно просто, но много однотипного кода. Отмечу лишь, что Slack API мне не очень понравился и было тяжело с ним работать. Сам UI Builder можно глянуть в папке ui здесь. А здесь, в целях экономии времени, я не буду на нем заострять внимания. Отмечу лишь, что в качестве якоря для понимания того, событие от какого сообщения пришло и какой был текст для голосования (его мы не будем сохранять у себя, а будем брать непосредственно из события) будем использовать block_id. А для определения типа события будем смотреть на action_id.

Давайте создадим конфиг для нашего приложения. Идем в config и создаем:

config.go
package configtype Config struct {App   *AppSlack *SlackRedis *Redis}func NewConfig() *Config {return &Config{App: &App{ServerAddress: getStrEnv("WEB_SERVER_ADDRESS", ":8000"),PokerRanks:    getListStrEnv("POKER_RANKS", "?,0,0.5,1,2,3,5,8,13,20,40,100"),},Slack: &Slack{Token: getStrEnv("SLACK_TOKEN", "FILL_ME"),},    // Скоро понадобитсяRedis: &Redis{Host: getStrEnv("REDIS_HOST", "0.0.0.0"),Port: getIntEnv("REDIS_PORT", "6379"),DB:   getIntEnv("REDIS_DB", "0"),},}}// Получаем значение из env или выставляем defaultfunc getStrEnv(key string, defaultValue string) string {if value, ok := os.LookupEnv(key); ok {return value}return defaultValue}// Получаем int значение из env или выставляем defaultfunc getIntEnv(key string, defaultValue string) int {value, err := strconv.Atoi(getStrEnv(key, defaultValue))if err != nil {panic(fmt.Sprintf("Incorrect env value for %s", key))}return value}// Получаем список (e.g. 0,1,2,3,4,5) из env или выставляем defaultfunc getListStrEnv(key string, defaultValue string) []string {value := []string{}for _, item := range strings.Split(getStrEnv(key, defaultValue), ",") {value = append(value, strings.TrimSpace(item))}return value}

И напишем тесты к нему. Будем тестировать только публичные методы:

config_test.go
package config_testimport (    "go-scrum-poker-bot/config"    "os"    "testing"    "github.com/stretchr/testify/assert")func TestNewConfig(t *testing.T) {    c := config.NewConfig()    assert.Equal(t, "0.0.0.0", c.Redis.Host)    assert.Equal(t, 6379, c.Redis.Port)    assert.Equal(t, 0, c.Redis.DB)    assert.Equal(t, []string{"?", "0", "0.5", "1", "2", "3", "5", "8", "13", "20", "40", "100"}, c.App.PokerRanks)}func TestNewConfigIncorrectIntFromEnv(t *testing.T) {    os.Setenv("REDIS_PORT", "-")    assert.Panics(t, func() { config.NewConfig() })}

Я намеренно сделал обязательность выставления значений по умолчанию, хотя это не самый правильный путь. Изменим main.go:

main.go
package mainimport ("fmt""go-scrum-poker-bot/config""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/clients"clients_middleware "go-scrum-poker-bot/web/clients/middleware""go-scrum-poker-bot/web/server"  "log""net/http""os""time")func main() {logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)config := config.NewConfig()builder := ui.NewBuilder(config)webClient := clients.NewBasicClient(&http.Client{Timeout: 5 * time.Second,},[]clients.Middleware{ // Наши middlewareclients_middleware.Auth(config.Slack.Token),clients_middleware.JsonContentType,clients_middleware.Log(logger),},)app := server.NewServer(logger,webClient,builder,)app.Serve(config.App.ServerAddress)}

Теперь при запуске команды /poker мы в ответ получим наш симпатичный минималистичный интерфейс.

Slack Interactivity

Давайте научимся реагировать на события при взаимодействии пользователя с ним. Зайдем Your apps -> Наш бот -> Interactivity & Shortcuts. В Request URL введем:

https://ffd3cfcc460c.ngrok.io/interactivity

Создадим еще один хэндлер InteractionCallback в web -> server -> handlers:

interaction_callback.go
package handlersimport ("go-scrum-poker-bot/storage""go-scrum-poker-bot/ui""go-scrum-poker-bot/ui/blocks""go-scrum-poker-bot/web/clients""go-scrum-poker-bot/web/server/models""net/http")func InteractionCallback(userStorage storage.UserStorage,sessionStorage storage.SessionStorage,uiBuilder *ui.Builder,webClient clients.Client,) http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {var callback models.Callback    // Об этом нижеdata, err := callback.SerializedData([]byte(r.PostFormValue("payload")))if err != nil {http.Error(w, err.Error(), http.StatusBadRequest)return}    // TODO: Скоро доберемся до нихusers := userStorage.All(data.SessionID)visible := sessionStorage.GetVisibility(data.SessionID)err = nil    // Определяем какое событие к нам поступило и реализуем немного логики исходя из негоswitch data.Action.ActionID {case ui.VOTE_ACTION_ID:users[callback.User.Username] = data.Action.SelectedOption.Valueerr = userStorage.Save(data.SessionID, callback.User.Username, data.Action.SelectedOption.Value)case ui.RESULTS_VISIBILITY_ACTION_ID:visible = !visibleerr = sessionStorage.SetVisibility(data.SessionID, visible)}if err != nil {http.Error(w, err.Error(), http.StatusInternalServerError)return}    // Шлем ответ перерисовывая интерфейс сообщения через response URL. Для пользователя все пройдет незаметноresp := webClient.Make(&clients.Request{URL:    callback.ResponseURL,Method: "POST",Json: &blocks.Interactive{ReplaceOriginal: true,Blocks:          uiBuilder.BuildBlocks(data.Subject, users, data.SessionID, visible),LinkNames:       true,},})if resp.Error != nil {http.Error(w, resp.Error.Error(), http.StatusInternalServerError)return}})}

Мы пока не определили наше хранилище. Давайте определим их интерфейсы и напишем тест на этот хэндлер. Идем в storage:

storage.go
package storagetype UserStorage interface {All(sessionID string) map[string]stringSave(sessionID string, username string, value string) error}type SessionStorage interface {GetVisibility(sessionID string) boolSetVisibility(sessionID string, state bool) error}

Я намеренно разбил логику на два хранилища, поскольку так удобнее тестировать и если будет нужно, то легко можно будет перевести например хранение голосов пользователей в базу данных, а настройки сессии оставить в Redis (как пример).

Теперь нужно создать модель Callback. Идем в web -> server -> models:

callback.go
package modelsimport ("encoding/json""errors""go-scrum-poker-bot/ui")type User struct {Username string `json:"username"`}type Text struct {Type string `json:"type"`Text string `json:"text"`}type Block struct {Type    string `json:"type"`BlockID string `json:"block_id"`Text    *Text  `json:"text,omitempty"`}type Message struct {Blocks []*Block `json:"blocks,omitempty"`}type SelectedOption struct {Value string `json:"value"`}type Action struct {BlockID        string          `json:"block_id"`ActionID       string          `json:"action_id"`Value          string          `json:"value,omitempty"`SelectedOption *SelectedOption `json:"selected_option,omitempty"`}type SerializedData struct {SessionID stringSubject   stringAction    *Action}type Callback struct {ResponseURL string    `json:"response_url"`User        *User     `json:"user"`Actions     []*Action `json:"actions"`Message     *Message  `json:"message,omitempty"`}// Грязно достаем ID сессии, но другого способа я не смог придуматьfunc (c *Callback) getSessionID() (string, error) {for _, action := range c.Actions {if action.BlockID != "" {return action.BlockID, nil}}return "", errors.New("Invalid session ID")}// Текст для голосованияfunc (c *Callback) getSubject() (string, error) {for _, block := range c.Message.Blocks {if block.BlockID == ui.SUBJECT_BLOCK_ID && block.Text != nil {return block.Text.Text, nil}}return "", errors.New("Invalid subject")}// Какое событие к нам пришлоfunc (c *Callback) getAction() (*Action, error) {for _, action := range c.Actions {if action.ActionID == ui.VOTE_ACTION_ID || action.ActionID == ui.RESULTS_VISIBILITY_ACTION_ID {return action, nil}}return nil, errors.New("Invalid action")}func (c *Callback) SerializedData(data []byte) (*SerializedData, error) {err := json.Unmarshal(data, c)if err != nil {return nil, err}sessionID, err := c.getSessionID()if err != nil {return nil, err}subject, err := c.getSubject()if err != nil {return nil, err}action, err := c.getAction()if err != nil {return nil, err}return &SerializedData{SessionID: sessionID,Subject:   subject,Action:    action,}, nil}

Давайте напишем тест на наш хэндлер:

interaction_callback_test.go
package handlers_testimport ("encoding/json""go-scrum-poker-bot/config""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/server/handlers""go-scrum-poker-bot/web/server/models""net/http""net/http/httptest""net/url""strings""testing""github.com/stretchr/testify/assert")func TestInteractionCallbackHandlerActions(t *testing.T) {config := config.NewConfig()mockClient := &MockClient{}mockUserStorage := &MockUserStorage{}mockSessionStorage := &MockSessionStorage{}uiBuilder := ui.NewBuilder(config)router := http.NewServeMux()router.Handle("/interactivity",handlers.InteractionCallback(mockUserStorage, mockSessionStorage, uiBuilder, mockClient),)actions := []*models.Action{{BlockID:        "test",ActionID:       ui.RESULTS_VISIBILITY_ACTION_ID,Value:          "test",SelectedOption: nil,},{BlockID:        "test",ActionID:       ui.VOTE_ACTION_ID,Value:          "test",SelectedOption: &models.SelectedOption{Value: "1"},},}  // Проверяем на двух разных типах событийfor _, action := range actions {responseRec := httptest.NewRecorder()data, _ := json.Marshal(models.Callback{ResponseURL: "test",User:        &models.User{Username: "test"},Actions:     []*models.Action{action},Message: &models.Message{Blocks: []*models.Block{{Type:    "test",BlockID: ui.SUBJECT_BLOCK_ID,Text:    &models.Text{Type: "test", Text: "test"},},},},})payload := url.Values{"payload": {string(data)}}.Encode()request, err := http.NewRequest("POST", "/interactivity", strings.NewReader(payload))request.Header.Set("Content-Type", "application/x-www-form-urlencoded")router.ServeHTTP(responseRec, request)assert.Nil(t, err)assert.Equal(t, http.StatusOK, responseRec.Code)assert.Empty(t, responseRec.Body.String())assert.Equal(t, true, mockClient.Called)}}

Осталось определить mock для наших хранилищ. Обновим файл common_test.go:

common_test.go
// Существующий кодtype MockUserStorage struct{}func (s *MockUserStorage) All(sessionID string) map[string]string {return map[string]string{"user": "1"}}func (s *MockUserStorage) Save(sessionID string, username string, value string) error {return nil}type MockSessionStorage struct{}func (s *MockSessionStorage) GetVisibility(sessionID string) bool {return true}func (s *MockSessionStorage) SetVisibility(sessionID string, state bool) error {return nil}

Добавив в роутер новый хэндлер:

server.go
// Существующий кодfunc (s *Server) setupRouter() http.Handler {router := http.NewServeMux()router.Handle("/healthcheck",handlers.Healthcheck(),)router.Handle("/play-poker",handlers.PlayPokerCommand(s.webClient, s.uiBuilder),)router.Handle("/interactivity",handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),)return router}// Существующий код

Все хорошо, но наш сервер никак не уведомляет нас о том, что к нему поступил запрос + если мы где-то поймаем панику, то сервер может упасть. Давайте это исправим через middleware. Создаем папку web -> server -> middleware:

log.go
package middlewareimport ("log""net/http")func Log(logger *log.Logger) func(http.Handler) http.Handler {return func(next http.Handler) http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {defer func() {logger.Printf("Handle request: [%s]: %s - %s - %s",r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(),)}()next.ServeHTTP(w, r)})}}

И напишем для нее тест:

log_test.go
package middleware_testimport ("bytes""go-scrum-poker-bot/web/server/middleware""log""net/http""net/http/httptest""os""strings""testing""github.com/stretchr/testify/assert")type logHandler struct{}func (h *logHandler) ServeHTTP(http.ResponseWriter, *http.Request) {}func TestLogMiddleware(t *testing.T) {var buf bytes.Bufferlogger := log.New(os.Stdout, "INFO: ", log.LstdFlags)  // Выставляем для логгера output наш буффер, чтобы все писалось в негоlogger.SetOutput(&buf)handler := &logHandler{}  // Берем mock recorder из стандартной библиотеки GoresponseRec := httptest.NewRecorder()router := http.NewServeMux()router.Handle("/test", middleware.Log(logger)(handler))request, err := http.NewRequest("GET", "/test", strings.NewReader(""))router.ServeHTTP(responseRec, request)assert.Nil(t, err)assert.Equal(t, http.StatusOK, responseRec.Code)  // Проверяем, что в буффер что-то пришло. Этого нам достаточно, чтобы понять, что middleware успешно отработалаassert.NotEmpty(t, buf.String())}

Остальные middleware можете найти здесь.

Ну и наконец слой хранения данных. Я решил взять Redis, так как это проще, да и не нужно для такого рода задач что-то большее, как мне кажется. Воспользуемся библиотекой go-redis и там же возьмем redismock для тестов.

Для начала научимся сохранять и получать всех пользователей переданной Scrum Poker сессии. Идем в storage:

users.go
package storageimport ("context""fmt""github.com/go-redis/redis/v8")// Шаблоны ключейconst SESSION_USERS_TPL = "SESSION:%s:USERS"const USER_VOTE_TPL = "SESSION:%s:USERNAME:%s:VOTE"type UserRedisStorage struct {redis   *redis.Clientcontext context.Context}func NewUserRedisStorage(redisClient *redis.Client) *UserRedisStorage {return &UserRedisStorage{redis:   redisClient,context: context.Background(),}}func (s *UserRedisStorage) All(sessionID string) map[string]string {users := make(map[string]string)  // Пользователей будем хранить в set, так как сортировка для нас не принципиальна.   // Заодно избавимся от необходимости искать дубликатыfor _, username := range s.redis.SMembers(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID)).Val() {users[username] = s.redis.Get(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username)).Val()}return users}func (s *UserRedisStorage) Save(sessionID string, username string, value string) error {err := s.redis.SAdd(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID), username).Err()if err != nil {return err}  // Голоса пользователей будем хранить в обычных ключах.   // Я сделал вечное хранение, но это легко можно поменять, изменив -1 на нужное значениеerr = s.redis.Set(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username), value, -1).Err()if err != nil {return err}return nil}

Напишем тесты:

users_test.go
package storage_testimport ("errors""fmt""go-scrum-poker-bot/storage""testing""github.com/go-redis/redismock/v8""github.com/stretchr/testify/assert")func TestAll(t *testing.T) {sessionID, username, value := "test", "user", "1"redisClient, mock := redismock.NewClientMock()usersStorage := storage.NewUserRedisStorage(redisClient)  // Redis mock требует обязательного указания всех ожидаемых команд и результаты их выполненияmock.ExpectSMembers(fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),).SetVal([]string{username})mock.ExpectGet(fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),).SetVal(value)assert.Equal(t, map[string]string{username: value}, usersStorage.All(sessionID))}func TestSave(t *testing.T) {sessionID, username, value := "test", "user", "1"redisClient, mock := redismock.NewClientMock()usersStorage := storage.NewUserRedisStorage(redisClient)mock.ExpectSAdd(fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),username,).SetVal(1)mock.ExpectSet(fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),value,-1,).SetVal(value)assert.Equal(t, nil, usersStorage.Save(sessionID, username, value))}func TestSaveSAddErr(t *testing.T) {sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")redisClient, mock := redismock.NewClientMock()usersStorage := storage.NewUserRedisStorage(redisClient)mock.ExpectSAdd(fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),username,).SetErr(err)assert.Equal(t, err, usersStorage.Save(sessionID, username, value))}func TestSaveSetErr(t *testing.T) {sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")redisClient, mock := redismock.NewClientMock()usersStorage := storage.NewUserRedisStorage(redisClient)mock.ExpectSAdd(fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),username,).SetVal(1)mock.ExpectSet(fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),value,-1,).SetErr(err)assert.Equal(t, err, usersStorage.Save(sessionID, username, value))}

Теперь определим хранилище для "покерной" сессии. Пока там будет лежать статус видимости голосов:

sessions.go
package storageimport ("context""fmt""strconv""github.com/go-redis/redis/v8")// Шаблон для ключейconst SESSION_VOTES_HIDDEN_TPL = "SESSION:%s:VOTES_HIDDEN"type SessionRedisStorage struct {redis   *redis.Clientcontext context.Context}func NewSessionRedisStorage(redisClient *redis.Client) *SessionRedisStorage {return &SessionRedisStorage{redis:   redisClient,context: context.Background(),}}func (s *SessionRedisStorage) GetVisibility(sessionID string) bool {value, _ := strconv.ParseBool(s.redis.Get(s.context, fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID)).Val(),)return value}func (s *SessionRedisStorage) SetVisibility(sessionID string, state bool) error {return s.redis.Set(s.context,fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID),strconv.FormatBool(state),-1,).Err()}

И сразу напишем тесты для только что созданных методов:

sessions_test.go
package storage_testimport ("errors""fmt""go-scrum-poker-bot/storage""strconv""testing""github.com/go-redis/redismock/v8""github.com/stretchr/testify/assert")func TestGetVisibility(t *testing.T) {sessionID, state := "test", trueredisClient, mock := redismock.NewClientMock()mock.ExpectGet(fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),).SetVal(strconv.FormatBool(state))sessionStorage := storage.NewSessionRedisStorage(redisClient)assert.Equal(t, state, sessionStorage.GetVisibility(sessionID))}func TestSetVisibility(t *testing.T) {sessionID, state := "test", trueredisClient, mock := redismock.NewClientMock()mock.ExpectSet(fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),strconv.FormatBool(state),-1,).SetVal("1")sessionStorage := storage.NewSessionRedisStorage(redisClient)assert.Equal(t, nil, sessionStorage.SetVisibility(sessionID, state))}func TestSetVisibilityErr(t *testing.T) {sessionID, state, err := "test", true, errors.New("ERROR")redisClient, mock := redismock.NewClientMock()mock.ExpectSet(fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),strconv.FormatBool(state),-1,).SetErr(err)sessionStorage := storage.NewSessionRedisStorage(redisClient)assert.Equal(t, err, sessionStorage.SetVisibility(sessionID, state))}

Отлично! Осталось изменить main.go и server.go:

server.go
package serverimport ("context""go-scrum-poker-bot/storage""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/clients""go-scrum-poker-bot/web/server/handlers""log""net/http""os""os/signal""sync/atomic""time")// Новый тип для middlewaretype Middleware func(next http.Handler) http.Handler// Все зависимости здесьtype Server struct {healthy        int32middleware     []Middlewarelogger         *log.LoggerwebClient      clients.ClientuiBuilder      *ui.BuilderuserStorage    storage.UserStoragesessionStorage storage.SessionStorage}// Добавляем их при инициализации сервераfunc NewServer(logger *log.Logger,webClient clients.Client,uiBuilder *ui.Builder,userStorage storage.UserStorage,sessionStorage storage.SessionStorage,middleware []Middleware,) *Server {return &Server{logger:         logger,webClient:      webClient,uiBuilder:      uiBuilder,userStorage:    userStorage,sessionStorage: sessionStorage,middleware:     middleware,}}func (s *Server) setupRouter() http.Handler {router := http.NewServeMux()router.Handle("/healthcheck",handlers.Healthcheck(),)router.Handle("/play-poker",handlers.PlayPokerCommand(s.webClient, s.uiBuilder),)router.Handle("/interactivity",handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),)return router}func (s *Server) setupMiddleware(router http.Handler) http.Handler {handler := routerfor _, middleware := range s.middleware {handler = middleware(handler)}return handler}func (s *Server) Serve(address string) {server := &http.Server{Addr:         address,Handler:      s.setupMiddleware(s.setupRouter()),ErrorLog:     s.logger,ReadTimeout:  5 * time.Second,WriteTimeout: 10 * time.Second,IdleTimeout:  15 * time.Second,}done := make(chan bool)quit := make(chan os.Signal, 1)signal.Notify(quit, os.Interrupt)go func() {<-quits.logger.Println("Server is shutting down...")atomic.StoreInt32(&s.healthy, 0)ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)defer cancel()server.SetKeepAlivesEnabled(false)if err := server.Shutdown(ctx); err != nil {s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)}close(done)}()s.logger.Println("Server is ready to handle requests at", address)atomic.StoreInt32(&s.healthy, 1)if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {s.logger.Fatalf("Could not listen on %s: %v\n", address, err)}<-dones.logger.Println("Server stopped")}
main.go
package mainimport ("fmt""go-scrum-poker-bot/config""go-scrum-poker-bot/storage""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/clients"clients_middleware "go-scrum-poker-bot/web/clients/middleware""go-scrum-poker-bot/web/server"server_middleware "go-scrum-poker-bot/web/server/middleware""log""net/http""os""time""github.com/go-redis/redis/v8")func main() {logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)config := config.NewConfig()  // Объявляем Redis клиентredisCLI := redis.NewClient(&redis.Options{Addr: fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port),DB:   config.Redis.DB,})  // Наш users storageuserStorage := storage.NewUserRedisStorage(redisCLI)  // Наш sessions storagesessionStorage := storage.NewSessionRedisStorage(redisCLI)builder := ui.NewBuilder(config)webClient := clients.NewBasicClient(&http.Client{Timeout: 5 * time.Second,},[]clients.Middleware{clients_middleware.Auth(config.Slack.Token),clients_middleware.JsonContentType,clients_middleware.Log(logger),},)  // В Server теперь есть middlewareapp := server.NewServer(logger,webClient,builder,userStorage,sessionStorage,[]server.Middleware{server_middleware.Recover(logger), server_middleware.Log(logger), server_middleware.Json},)app.Serve(config.App.ServerAddress)}

Запустим тесты:

go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic

Результат:

go tool cover -func coverage.txt
$ go tool cover -func coverage.txtgo-scrum-poker-bot/config/config.go:9:                                  NewConfig               100.0%go-scrum-poker-bot/config/helpers.go:10:                                getStrEnv               100.0%go-scrum-poker-bot/config/helpers.go:17:                                getIntEnv               100.0%go-scrum-poker-bot/config/helpers.go:26:                                getListStrEnv           100.0%go-scrum-poker-bot/main.go:22:                                          main                    0.0%go-scrum-poker-bot/storage/sessions.go:18:                              NewSessionRedisStorage  100.0%go-scrum-poker-bot/storage/sessions.go:25:                              GetVisibility           100.0%go-scrum-poker-bot/storage/sessions.go:33:                              SetVisibility           100.0%go-scrum-poker-bot/storage/users.go:18:                                 NewUserRedisStorage     100.0%go-scrum-poker-bot/storage/users.go:25:                                 All                     100.0%go-scrum-poker-bot/storage/users.go:34:                                 Save                    100.0%go-scrum-poker-bot/ui/blocks/action.go:9:                               BlockType               100.0%go-scrum-poker-bot/ui/blocks/button.go:11:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/context.go:9:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/section.go:9:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/select.go:10:                              BlockType               100.0%go-scrum-poker-bot/ui/builder.go:14:                                    NewBuilder              100.0%go-scrum-poker-bot/ui/builder.go:18:                                    getGetResultsText       100.0%go-scrum-poker-bot/ui/builder.go:26:                                    getResults              100.0%go-scrum-poker-bot/ui/builder.go:41:                                    getOptions              100.0%go-scrum-poker-bot/ui/builder.go:50:                                    BuildBlocks             100.0%go-scrum-poker-bot/ui/builder.go:100:                                   Build                   100.0%go-scrum-poker-bot/web/clients/client.go:22:                            NewBasicClient          100.0%go-scrum-poker-bot/web/clients/client.go:26:                            makeRequest             78.9%go-scrum-poker-bot/web/clients/client.go:65:                            Make                    66.7%go-scrum-poker-bot/web/clients/middleware/auth.go:8:                    Auth                    100.0%go-scrum-poker-bot/web/clients/middleware/json.go:5:                    JsonContentType         100.0%go-scrum-poker-bot/web/clients/middleware/log.go:8:                     Log                     87.5%go-scrum-poker-bot/web/clients/request.go:12:                           ToBytes                 100.0%go-scrum-poker-bot/web/clients/response.go:12:                          Json                    100.0%go-scrum-poker-bot/web/server/handlers/healthcheck.go:10:               Healthcheck             66.7%go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12:      InteractionCallback     71.4%go-scrum-poker-bot/web/server/handlers/play_poker.go:13:                PlayPokerCommand        100.0%go-scrum-poker-bot/web/server/middleware/json.go:5:                     Json                    100.0%go-scrum-poker-bot/web/server/middleware/log.go:8:                      Log                     100.0%go-scrum-poker-bot/web/server/middleware/recover.go:9:                  Recover                 100.0%go-scrum-poker-bot/web/server/models/callback.go:52:                    getSessionID            100.0%go-scrum-poker-bot/web/server/models/callback.go:62:                    getSubject              100.0%go-scrum-poker-bot/web/server/models/callback.go:72:                    getAction               100.0%go-scrum-poker-bot/web/server/models/callback.go:82:                    SerializedData          92.3%go-scrum-poker-bot/web/server/models/errors.go:13:                      ResponseError           75.0%go-scrum-poker-bot/web/server/server.go:31:                             NewServer               0.0%go-scrum-poker-bot/web/server/server.go:49:                             setupRouter             0.0%go-scrum-poker-bot/web/server/server.go:67:                             setupMiddleware         0.0%go-scrum-poker-bot/web/server/server.go:76:                             Serve                   0.0%total:                                                                  (statements)            75.1%

Неплохо, но нам не нужно учитывать в coverage main.go (мое мнение) и server.go (здесь можно поспорить), поэтому есть хак :). Нужно добавить в начало файлов, которые мы хотим исключить из оценки следующую строчку с тегами:

//+build !test

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

go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -tags=test

Результат:

go tool cover -func coverage.txt
$ go tool cover -func coverage.txtgo-scrum-poker-bot/config/config.go:9:                                  NewConfig               100.0%go-scrum-poker-bot/config/helpers.go:10:                                getStrEnv               100.0%go-scrum-poker-bot/config/helpers.go:17:                                getIntEnv               100.0%go-scrum-poker-bot/config/helpers.go:26:                                getListStrEnv           100.0%go-scrum-poker-bot/storage/sessions.go:18:                              NewSessionRedisStorage  100.0%go-scrum-poker-bot/storage/sessions.go:25:                              GetVisibility           100.0%go-scrum-poker-bot/storage/sessions.go:33:                              SetVisibility           100.0%go-scrum-poker-bot/storage/users.go:18:                                 NewUserRedisStorage     100.0%go-scrum-poker-bot/storage/users.go:25:                                 All                     100.0%go-scrum-poker-bot/storage/users.go:34:                                 Save                    100.0%go-scrum-poker-bot/ui/blocks/action.go:9:                               BlockType               100.0%go-scrum-poker-bot/ui/blocks/button.go:11:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/context.go:9:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/section.go:9:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/select.go:10:                              BlockType               100.0%go-scrum-poker-bot/ui/builder.go:14:                                    NewBuilder              100.0%go-scrum-poker-bot/ui/builder.go:18:                                    getGetResultsText       100.0%go-scrum-poker-bot/ui/builder.go:26:                                    getResults              100.0%go-scrum-poker-bot/ui/builder.go:41:                                    getOptions              100.0%go-scrum-poker-bot/ui/builder.go:50:                                    BuildBlocks             100.0%go-scrum-poker-bot/ui/builder.go:100:                                   Build                   100.0%go-scrum-poker-bot/web/clients/client.go:22:                            NewBasicClient          100.0%go-scrum-poker-bot/web/clients/client.go:26:                            makeRequest             78.9%go-scrum-poker-bot/web/clients/client.go:65:                            Make                    66.7%go-scrum-poker-bot/web/clients/middleware/auth.go:8:                    Auth                    100.0%go-scrum-poker-bot/web/clients/middleware/json.go:5:                    JsonContentType         100.0%go-scrum-poker-bot/web/clients/middleware/log.go:8:                     Log                     87.5%go-scrum-poker-bot/web/clients/request.go:12:                           ToBytes                 100.0%go-scrum-poker-bot/web/clients/response.go:12:                          Json                    100.0%go-scrum-poker-bot/web/server/handlers/healthcheck.go:10:               Healthcheck             66.7%go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12:      InteractionCallback     71.4%go-scrum-poker-bot/web/server/handlers/play_poker.go:13:                PlayPokerCommand        100.0%go-scrum-poker-bot/web/server/middleware/json.go:5:                     Json                    100.0%go-scrum-poker-bot/web/server/middleware/log.go:8:                      Log                     100.0%go-scrum-poker-bot/web/server/middleware/recover.go:9:                  Recover                 100.0%go-scrum-poker-bot/web/server/models/callback.go:52:                    getSessionID            100.0%go-scrum-poker-bot/web/server/models/callback.go:62:                    getSubject              100.0%go-scrum-poker-bot/web/server/models/callback.go:72:                    getAction               100.0%go-scrum-poker-bot/web/server/models/callback.go:82:                    SerializedData          92.3%go-scrum-poker-bot/web/server/models/errors.go:13:                      ResponseError           75.0%total:                                                                  (statements)            90.9%

Такой результат мне нравится больше :)

На этом пожалуй остановлюсь. Весь код можете найти здесь. Спасибо за внимание!

Подробнее..

Перевод Vulkan. Руководство разработчика. Графический конвейер

18.03.2021 14:16:42 | Автор: admin
Я переводчик в IT-компании CG Tribe, и я продолжаю выкладывать перевод руководства к Vulkan API (vulkan-tutorial.com).

Сегодня я хочу поделиться с вами переводом первых двух глав раздела, посвященного графическому конвейеру (Graphics pipeline basics), Introduction и Shader modules.

Содержание
1. Вступление

2. Краткий обзор

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

4. Рисуем треугольник

  1. Подготовка к работе
  2. Отображение на экране
  3. Графический конвейер (pipeline)
    • Вступление
    • Шейдерные модули
    • Непрограммируемые этапы
    • Проходы рендера
    • Заключение
  4. Отрисовка
  5. Повторное создание цепочки показа

5. Буферы вершин

  1. Описание
  2. Создание буфера вершин
  3. Staging буфер
  4. Буфер индексов

6. Uniform-буферы

  1. Дескриптор layout и буфера
  2. Дескриптор пула и sets

7. Текстурирование

  1. Изображения
  2. Image view и image sampler
  3. Комбинированный image sampler

8. Буфер глубины

9. Загрузка моделей

10. Создание мип-карт

11. Multisampling

FAQ

Политика конфиденциальности


Вступление


В течение следующих нескольких глав мы будем настраивать графический конвейер, который позволит нам нарисовать первый треугольник. Графический конвейер это последовательность операций, которые преобразуют вершины и текстуры мешей в пиксели в render target-ах.

Ниже представлена упрощенная схема:



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

Вершинный шейдер (vertex shader) вызывается для каждой вершины и используется, в основном, для трансформации вершин из локальной системы координат в экранную. Также он передает данные каждой вершины далее по конвейеру.

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

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

На этапе растеризации (rasterization) каждый примитив раскладывается на множество фрагментов. Фрагменты это пиксельные элементы, которые заполняют примитивы во фреймбуфере. Все фрагменты, которые выходят за пределы экрана, отсекаются, а атрибуты, переданные вершинным шейдером, интерполируются по фрагментам, как показано на рисунке. Фрагменты, находящиеся за другими фрагментами, как правило, тоже отсекаются из-за теста глубины (depth testing).

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

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

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

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

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

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

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

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

void initVulkan() {    createInstance();    setupDebugMessenger();    createSurface();    pickPhysicalDevice();    createLogicalDevice();    createSwapChain();    createImageViews();    createGraphicsPipeline();}...void createGraphicsPipeline() {}

Код C++


Шейдерные модули



В отличие от более ранних API шейдеры должны быть переданы в Vulkan в виде байт-кода, который называется SPIR-V. Он разработан для работы как с Vulkan, так и с OpenCL (оба Khronos API). SPIR-V можно использовать для создания графических и вычислительных шейдеров, но мы сосредоточимся только на шейдерах, используемых в графическом конвейере Vulkan.

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

Нам не придется писать байт-код вручную. Khronos выпустил собственный компилятор, независимый от производителя, который компилирует GLSL в SPIR-V. Он проверяет, соответствует ли шейдерный код стандарту, и создает бинарный файл SPIR-V для использования в вашей программе. Также этот компилятор можно использовать в качестве библиотеки для создания SPIR-V в рантайме (во время исполнения), но в руководстве мы не будем этого делать. Вместо glslangValidator.exe мы будем использовать glslc.exe от Google. Преимущество glslc в том, что он использует тот же формат командной строки, что и компиляторы GCC или Clang, и включает некоторые дополнительные функции, например, includes. Обе программы уже включены в Vulkan SDK, поэтому не нужно скачивать ничего дополнительно.

GLSL это язык для программирования шейдеров, синтаксис которого базируется на языке C. Вместо использования параметров для входных данных и возвращаемого значения для выходных, GLSL использует глобальные переменные. В GLSL есть много фич, полезных при работе с графикой, например, встроенные векторные и матричные типы. Также доступны такие операции, как векторное произведение, умножение вектора на матрицу или отражение вектора от плоскости. Векторный тип обозначается как vec с числом, указывающим количество элементов. Так, например, 3D позиция может быть сохранена в vec3. Вы можете получить доступ к отдельным компонентам через такие элементы, как .x, либо создать новый вектор из нескольких компонентов одновременно. Например, результатом выражения vec3 (1.0, 2.0, 3.0) .xy будет vec2. Конструкторы векторов также могут принимать комбинации векторных объектов и скалярных значений. Например, vec3 можно построить с помощью vec3 (vec2 (1.0, 2.0), 3.0).

Как уже было сказано, для отрисовки треугольника мы должны написать вершинный шейдер и фрагментный шейдер. В следующих двух пунктах мы рассмотрим код GLSL для каждого шейдера и покажем, как создать бинарные файлы SPIR-V и загрузить их в программу.

Вершинный шейдер


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

Координатами в пространстве отсечения называют четырехмерный вектор, принимаемый из вершинного шейдера, который преобразуется в нормализованные координаты устройства в результате деления вектора на его последний компонент. Нормализованные координаты устройства (normalized device coordinate) это гомогенные координаты, находящиеся в диапазоне [-1, 1], и проецирующиеся на фреймбуфер, как показано на картинке ниже:



Если до этого вы работали с OpenGL, вы можете заметить, что координата Y теперь зеркально отражена. А для координаты Z задан тот же диапазон, что и в Direct3D, от 0 до 1.

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



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

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

#version 450vec2 positions[3] = vec2[](    vec2(0.0, -0.5),    vec2(0.5, 0.5),    vec2(-0.5, 0.5));void main() {    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);}

Функция main вызывается для каждой вершины. Встроенная переменная gl_VertexIndex содержит порядковый номер (index) текущей вершины. Обычно это номер вершины в вершинном буфере, но в нашем случае мы используем его для обращения во встроенный массив. Координаты x и y, полученные из массива, объединяются с константными z и w. В результате мы получаем координаты в пространстве отсечения. Встроенная переменная gl_Position используется в качестве выходной.

Фрагментный шейдер


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

#version 450#extension GL_ARB_separate_shader_objects : enablelayout(location = 0) out vec4 outColor;void main() {    outColor = vec4(1.0, 0.0, 0.0, 1.0);}

Функция main вызывается для каждого фрагмента. Для указания цвета в GLSL используются четырехкомпонентные RGBA-векторы с диапазоном от 0 до 1 для каждого канала. В отличие от вершинного шейдера, во фрагментном шейдере нет встроенной переменной для вывода цвета фрагмента. Вам нужно указать свою собственную выходную переменную для каждого фреймбуфера, где модификатор layout (location = 0) указывает номер фреймбуфера. В нашем случае в переменную outColor, которая связана с первым (и единственным) фреймбуфером с номером 0, записывается красный цвет.

Цвет каждой вершины


Полностью красный треугольник выглядит слишком просто, может, сделаем его поинтереснее?

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

vec3 colors[3] = vec3[](    vec3(1.0, 0.0, 0.0),    vec3(0.0, 1.0, 0.0),    vec3(0.0, 0.0, 1.0));

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

layout(location = 0) out vec3 fragColor;void main() {    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);    fragColor = colors[gl_VertexIndex];}

Затем добавим соответствующую ему входную переменную во фрагментный шейдер:

layout(location = 0) in vec3 fragColor;void main() {    outColor = vec4(fragColor, 1.0);}

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

Компиляция шейдеров


Создадим каталог с именем shaders в корневом каталоге проекта и сохраним вершинный шейдер в файл с именем shader.vert. Фрагментный шейдер сохраним в файл с именем shader.frag. Для шейдеров GLSL нет официального расширения, но чаще всего используются .vert и .frag.

Содержимое файла shader.vert должно быть следующим:

#version 450#extension GL_ARB_separate_shader_objects : enablelayout(location = 0) out vec3 fragColor;vec2 positions[3] = vec2[](    vec2(0.0, -0.5),    vec2(0.5, 0.5),    vec2(-0.5, 0.5));vec3 colors[3] = vec3[](    vec3(1.0, 0.0, 0.0),    vec3(0.0, 1.0, 0.0),    vec3(0.0, 0.0, 1.0));void main() {    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);    fragColor = colors[gl_VertexIndex];}

А содержимое файла shader.frag следующим:

#version 450#extension GL_ARB_separate_shader_objects : enablelayout(location = 0) in vec3 fragColor;layout(location = 0) out vec4 outColor;void main() {    outColor = vec4(fragColor, 1.0);}

Теперь скомпилируем этот код в байт-код SPIR-V с помощью glslc.

Windows
Создайте файл compile.bat, который будет содержать следующее:

C:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.vert -o vert.spvC:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.frag -o frag.spvpause

Замените путь к glslc.exe на путь установки Vulkan SDK. Дважды щелкните по файлу, чтобы запустить его.

Linux
Создайте файл compile.sh, который будет содержать следующее:

/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.vert -o vert.spv/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.frag -o frag.spv

Замените путь к glslc на путь установки Vulkan SDK. Сделайте скрипт исполняемым с помощью команды chmod + x compile.sh и запустите его.

Эти две команды говорят компилятору прочитать исходный файл GLSL и вывести его в байт-код SPIR-V с помощью флага -o (output).

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

Компиляция шейдеров в командной строке один из самых простых способов, который мы будем использовать в руководстве, но вы можете скомпилировать шейдеры непосредственно из вашего кода. Для этого в Vulkan SDK есть libshaderc библиотека для компиляции кода GLSL в SPIR-V.

Загрузка шейдера


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

#include <fstream>...static std::vector<char> readFile(const std::string& filename) {    std::ifstream file(filename, std::ios::ate | std::ios::binary);    if (!file.is_open()) {        throw std::runtime_error("failed to open file!");    }}

Функция readFile будет считывать все байты из указанного файла и возвращать их в байтовом массиве, обернутом в std :: vector. При открытии файла используем два флага:

  • ate: установить указатель чтения на конец файла
  • binary: читать файл как двоичный (не использовать текстовые преобразования)

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

size_t fileSize = (size_t) file.tellg();std::vector<char> buffer(fileSize);

Затем мы можем вернуть указатель в начало файла и считать все байты за один вызов:

file.seekg(0);file.read(buffer.data(), fileSize);

И, наконец, закроем файл и возвратим буфер:

file.close();return buffer;

Теперь вызовем функцию readFile из createGraphicsPipeline, чтобы загрузить байт-код обоих шейдеров:

void createGraphicsPipeline() {    auto vertShaderCode = readFile("shaders/vert.spv");    auto fragShaderCode = readFile("shaders/frag.spv");}

Вы можете проверить, правильно ли загрузились файлы. Для этого можно напечатать в лог размер загруженных данных и сравнить с тем, что лежит на диске. Обратите внимание, что мы загружаем бинарные данные, а не текст, поэтому размер нельзя вычислять по нулевому байту в конце. Позже нам потребуется явно указывать размер полученных данных, и мы будем использовать для этого метод size контейнера std::vector.

Создание шейдерных модулей


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

VkShaderModule createShaderModule(const std::vector<char>& code) {}

Функция принимает буфер с байт-кодом в качестве параметра и создает из него VkShaderModule.

Создать шейдерный модуль несложно, для этого надо передать указатель на буфер с байт-кодом и размер этого буфера. Эта информация указывается в структуре VkShaderModuleCreateInfo. Единственная загвоздка в том, что размер байт-кода указывается в байтах, а тип указателя байт-кода uint32_t, вместо char. Поэтому необходимо преобразовать указатель с помощью reinterpret_cast, как показано ниже. Когда вы делаете подобные преобразования, необходимо убедиться, что размещение данных удовлетворяет требованиям по выравниванию uint32_t. К счастью для нас, данные хранятся в std :: vector, в котором дефолтный аллокатор уже гарантирует, что данные так или иначе будут удовлетворять требованиям.

VkShaderModuleCreateInfo createInfo{};createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;createInfo.codeSize = code.size();createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data());

VkShaderModule можно создать с помощью вызова vkCreateShaderModule:

VkShaderModule shaderModule;if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) {    throw std::runtime_error("failed to create shader module!");}

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

return shaderModule;

Шейдерный модуль всего лишь тонкая обертка для шейдерного байт-кода, загруженного из файла, и объявленных в нем функций. Компиляция и линковка байт-кода SPIR-V в машинный код не произойдет до тех пор, пока не будет создан графический конвейер. Это значит, что мы можем уничтожить шейдерные модули сразу после создания конвейера. Поэтому в функции createGraphicsPipeline сделаем их локальными переменными вместо членов класса:

void createGraphicsPipeline() {    auto vertShaderCode = readFile("shaders/vert.spv");    auto fragShaderCode = readFile("shaders/frag.spv");    VkShaderModule vertShaderModule = createShaderModule(vertShaderCode);    VkShaderModule fragShaderModule = createShaderModule(fragShaderCode);

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

  ...    vkDestroyShaderModule(device, fragShaderModule, nullptr);    vkDestroyShaderModule(device, vertShaderModule, nullptr);}


Связываем шейдеры и конвейер


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

Заполним структуру для вершинного шейдера в функции createGraphicsPipeline.

VkPipelineShaderStageCreateInfo vertShaderStageInfo{};vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;


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

vertShaderStageInfo.module = vertShaderModule;vertShaderStageInfo.pName = "main";

Следующие два члена указывают шейдерный модуль с кодом и вызываемую функцию, известную как точка входа (entrypoint). Это значит, что можно использовать один шейдерный модуль для нескольких фрагментных шейдеров, меняя поведение с помощью разных точек входа. Но мы будем придерживаться стандартной функции main.

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

А теперь сделаем практически то же самое для фрагментного шейдера:

VkPipelineShaderStageCreateInfo fragShaderStageInfo{};fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;fragShaderStageInfo.module = fragShaderModule;fragShaderStageInfo.pName = "main";

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

VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};

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

Код C++
Подробнее..

Перевод Vulkan. Руководство разработчика. Непрограммируемые стадии конвейера

26.04.2021 18:04:48 | Автор: admin
Я работаю переводчиком в компании CG Tribe в Ижевске и здесь публикую переводы Vulkan Tutorial (оригинал vulkan-tutorial.com) на русский язык.

Сегодня я хочу представить перевод новой главы раздела, посвященного графическому конвейеру (Graphics pipeline basics), которая называется Fixed functions.

Содержание
1. Вступление

2. Краткий обзор

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

4. Рисуем треугольник

  1. Подготовка к работе
  2. Отображение на экране
  3. Графический конвейер (pipeline)
  4. Отрисовка
  5. Повторное создание цепочки показа

5. Буферы вершин

  1. Описание
  2. Создание буфера вершин
  3. Staging буфер
  4. Буфер индексов

6. Uniform-буферы

  1. Дескриптор layout и буфера
  2. Дескриптор пула и sets

7. Текстурирование

  1. Изображения
  2. Image view и image sampler
  3. Комбинированный image sampler

8. Буфер глубины

9. Загрузка моделей

10. Создание мип-карт

11. Multisampling

FAQ

Политика конфиденциальности


Непрограммируемые стадии конвейера



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

Входные данные вершин


Структура VkPipelineVertexInputStateCreateInfo описывает формат данных вершин, которые передаются в вершинный шейдер. Есть два типа описаний:

  • Описание атрибутов: тип данных, передаваемый в вершинный шейдер, привязка к буферу данных и смещение в нем
  • Привязка (binding): расстояние между элементами данных и то, каким образом связаны данные и выводимая геометрия (повершинная привязка или per-instance) (см. Geometry instancing)

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

VkPipelineVertexInputStateCreateInfo vertexInputInfo{};vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;vertexInputInfo.vertexBindingDescriptionCount = 0;vertexInputInfo.pVertexBindingDescriptions = nullptr; // OptionalvertexInputInfo.vertexAttributeDescriptionCount = 0;vertexInputInfo.pVertexAttributeDescriptions = nullptr; // Optional

Члены pVertexBindingDescriptions и pVertexAttributeDescriptions указывают на массив структур, которые описывают вышеупомянутые данные для загрузки атрибутов вершин. Добавьте эту структуру в функцию createGraphicsPipeline сразу после shaderStages.

Input assembler


Структура VkPipelineInputAssemblyStateCreateInfo описывает 2 вещи: какая геометрия образуется из вершин и разрешен ли рестарт геометрии для таких геометрий, как line strip и triangle strip. Геометрия указывается в поле topology и может иметь следующие значения:

  • VK_PRIMITIVE_TOPOLOGY_POINT_LIST: геометрия отрисовывается в виде отдельных точек, каждая вершина отдельная точка
  • VK_PRIMITIVE_TOPOLOGY_LINE_LIST: геометрия отрисовывается в виде набора отрезков, каждая пара вершин образует отдельный отрезок
  • VK_PRIMITIVE_TOPOLOGY_LINE_STRIP: геометрия отрисовывается в виде непрерывной ломаной, каждая последующая вершина добавляет к ломаной один отрезок
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST: геометрия отрисовывается как набор треугольников, причем каждые 3 вершины образуют независимый треугольник
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP: геометрия отрисовывается как набор связанных треугольников, причем две последние вершины предыдущего треугольника используются в качестве двух первых вершин для следующего треугольника

Обычно вершины загружаются последовательно в том порядке, в котором вы их расположите в вершинном буфере. Однако с помощью индексного буфера вы можете изменить порядок загрузки. Это позволяет выполнить оптимизацию, например, повторно использовать вершины. Если в поле primitiveRestartEnable задать значение VK_TRUE, можно прервать отрезки и треугольники с топологией VK_PRIMITIVE_TOPOLOGY_LINE_STRIP и VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP и начать рисовать новые примитивы, используя специальный индекс 0xFFFF или 0xFFFFFFFF.

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

VkPipelineInputAssemblyStateCreateInfo inputAssembly{};inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;inputAssembly.primitiveRestartEnable = VK_FALSE;

Вьюпорт и scissors


Вьюпорт описывает область фреймбуфера, в которую рендерятся выходные данные. Почти всегда для вьюпорта задаются координаты от (0, 0) до (width, height).

VkViewport viewport{};viewport.x = 0.0f;viewport.y = 0.0f;viewport.width = (float) swapChainExtent.width;viewport.height = (float) swapChainExtent.height;viewport.minDepth = 0.0f;viewport.maxDepth = 1.0f;

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

minDepth и maxDepth определяют диапазон значений глубины для фреймбуфера. Эти значения должны находиться в диапазоне [0,0f, 1,0f], при этом minDepth может быть больше maxDepth. Используйте стандартные значения 0.0f и 1.0f, если не собираетесь делать ничего необычного.

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



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

VkRect2D scissor{};scissor.offset = {0, 0};scissor.extent = swapChainExtent;

Теперь нужно объединить информацию о вьюпорте и сциссоре, используя структуру VkPipelineViewportStateCreateInfo. На некоторых видеокартах можно использовать одновременно несколько вьюпортов и прямоугольников отсечения, поэтому информация о них передается в виде массива. Для использования сразу нескольких вьюпортов нужно включить соответствующую опцию GPU.

VkPipelineViewportStateCreateInfo viewportState{};viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;viewportState.viewportCount = 1;viewportState.pViewports = &viewport;viewportState.scissorCount = 1;viewportState.pScissors = &scissor;

Растеризатор


Растеризатор преобразует геометрию, полученную из вершинного шейдера, во множество фрагментов. Здесь также выполняется тест глубины, face culling, scissor тест и настраивается способ заполнения полигонов фрагментами: заполнение всего полигона, либо только ребра полигонов (каркасный рендеринг). Все это настраивается в структуре VkPipelineRasterizationStateCreateInfo.

VkPipelineRasterizationStateCreateInfo rasterizer{};rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;rasterizer.depthClampEnable = VK_FALSE;

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

rasterizer.rasterizerDiscardEnable = VK_FALSE;

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

rasterizer.polygonMode = VK_POLYGON_MODE_FILL;

polygonMode определяет, каким образом генерируются фрагменты. Доступны следующие режимы:

  • VK_POLYGON_MODE_FILL: полигоны полностью заполняются фрагментами
  • VK_POLYGON_MODE_LINE: ребра полигонов преобразуются в отрезки
  • VK_POLYGON_MODE_POINT: вершины полигонов рисуются в виде точек

Для использования этих режимов, за исключением VK_POLYGON_MODE_FILL, нужно включить соответствующую опцию GPU.

rasterizer.lineWidth = 1.0f;

В поле lineWidth задается толщина отрезков. Максимальная поддерживаемая ширина отрезка зависит от вашего оборудования, а для отрезков толще 1,0f требуется включить опцию GPU wideLines.

rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE;

Параметр cullMode определяет тип отсечения (face culling). Вы можете совсем отключить отсечение, либо включить отсечение лицевых и/или нелицевых граней. Переменная frontFace определяет порядок обхода вершин (по часовой стрелке или против) для определения лицевых граней.

rasterizer.depthBiasEnable = VK_FALSE;rasterizer.depthBiasConstantFactor = 0.0f; // Optionalrasterizer.depthBiasClamp = 0.0f; // Optionalrasterizer.depthBiasSlopeFactor = 0.0f; // Optional

Растеризатор может изменить значения глубины, добавив постоянное значение или сместив глубину в зависимости от наклона фрагмента. Обычно это используется при создании карты теней. Нам это не нужно, поэтому для depthBiasEnable установим VK_FALSE.

Мультисэмплинг


Структура VkPipelineMultisampleStateCreateInfo настраивает мультисэмплинг один из способов сглаживания (anti-aliasing). Он работает главным образом на краях, комбинируя цвета разных полигонов, которые растеризуются в одни и те же пиксели. Это позволяет избавиться от наиболее заметных артефактов. Основное преимущество мультисэмплинга в том, что фрагментный шейдер в большинстве случаев выполняется только один раз на пиксель, что гораздо лучше, например, чем рендеринг в большем разрешении с последующим уменьшением размеров. Чтобы использовать мультисэмплинг, необходимо включить соответствующую опцию GPU.

VkPipelineMultisampleStateCreateInfo multisampling{};multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;multisampling.sampleShadingEnable = VK_FALSE;multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;multisampling.minSampleShading = 1.0f; // Optionalmultisampling.pSampleMask = nullptr; // Optionalmultisampling.alphaToCoverageEnable = VK_FALSE; // Optionalmultisampling.alphaToOneEnable = VK_FALSE; // Optional

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

Тест глубины и тест трафарета


При использовании буфера глубины и/или трафаретного буфера нужно настроить их с помощью VkPipelineDepthStencilStateCreateInfo. У нас пока нет в этом необходимости, поэтому мы просто передадим nullptr вместо указателя на эту структуру. Мы вернемся к этому в главе, посвященной буферу глубины.

Смешивание цветов


Цвет, возвращаемый фрагментным шейдером, нужно объединить с цветом, уже находящимся во фреймбуфере. Этот процесс называется смешиванием цветов, и есть два способа его сделать:

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

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

VkPipelineColorBlendAttachmentState colorBlendAttachment{};colorBlendAttachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;colorBlendAttachment.blendEnable = VK_FALSE;colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; // OptionalcolorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ZERO; // OptionalcolorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD; // OptionalcolorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; // OptionalcolorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; // OptionalcolorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD; // Optional

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

if (blendEnable) {    finalColor.rgb = (srcColorBlendFactor * newColor.rgb) <colorBlendOp> (dstColorBlendFactor * oldColor.rgb);    finalColor.a = (srcAlphaBlendFactor * newColor.a) <alphaBlendOp> (dstAlphaBlendFactor * oldColor.a);} else {    finalColor = newColor;}finalColor = finalColor & colorWriteMask;

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

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

finalColor.rgb = newAlpha * newColor + (1 - newAlpha) * oldColor;finalColor.a = newAlpha.a;

Это может быть настроено с помощью следующих параметров:

colorBlendAttachment.blendEnable = VK_TRUE;colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD;colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE;colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO;colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD;

Все возможные операции вы можете найти в перечислениях VkBlendFactor и VkBlendOp в спецификации.

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

VkPipelineColorBlendStateCreateInfo colorBlending{};colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;colorBlending.logicOpEnable = VK_FALSE;colorBlending.logicOp = VK_LOGIC_OP_COPY; // OptionalcolorBlending.attachmentCount = 1;colorBlending.pAttachments = &colorBlendAttachment;colorBlending.blendConstants[0] = 0.0f; // OptionalcolorBlending.blendConstants[1] = 0.0f; // OptionalcolorBlending.blendConstants[2] = 0.0f; // OptionalcolorBlending.blendConstants[3] = 0.0f; // Optional

Если вы хотите использовать второй способ смешивания (побитовая операция), установите VK_TRUE для logicOpEnable. После этого вы сможете указать побитовую операцию в поле logicOp. Обратите внимание, что первый способ автоматически становится недоступным, как если бы в каждом подключенном фреймбуфере для blendEnable было установлено VK_FALSE! Обратите внимание, colorWriteMask используется и для побитовых операций, чтобы определить, содержимое каких каналов будут изменено. Вы можете отключить оба режима, как это сделали мы, в этом случае цвета фрагментов будут записаны во фреймбуфер без изменений.

Динамическое состояние


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

VkDynamicState dynamicStates[] = {    VK_DYNAMIC_STATE_VIEWPORT,    VK_DYNAMIC_STATE_LINE_WIDTH};VkPipelineDynamicStateCreateInfo dynamicState{};dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;dynamicState.dynamicStateCount = 2;dynamicState.pDynamicStates = dynamicStates;

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

Layout конвейера


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

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

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

VkPipelineLayout pipelineLayout;

Затем создадим объект в функции createGraphicsPipeline:

VkPipelineLayoutCreateInfo pipelineLayoutInfo{};pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;pipelineLayoutInfo.setLayoutCount = 0; // OptionalpipelineLayoutInfo.pSetLayouts = nullptr; // OptionalpipelineLayoutInfo.pushConstantRangeCount = 0; // OptionalpipelineLayoutInfo.pPushConstantRanges = nullptr; // Optionalif (vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout) != VK_SUCCESS) {    throw std::runtime_error("failed to create pipeline layout!");}

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

void cleanup() {    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);    ...}

Заключение


Это все, что нужно знать о непрограммируемых состояниях! Потребовалось немало усилий, чтобы настроить их с нуля, зато теперь вы знаете практически все, что происходит в графическом конвейере!

Для создания графического конвейера осталось создать последний объект проход рендера.
Подробнее..

Перевод Vulkan. Руководство разработчика. Проходы рендера (Render passes)

04.06.2021 18:05:38 | Автор: admin
Меня зовут Александра, я работаю в IT-компании CG Tribe в Ижевске и занимаюсь переводом Vulkan Tutorial на русский язык (ссылка на источник vulkan-tutorial.com).

Сегодня хочу поделиться переводом заключительных глав раздела, посвященного графическому конвейеру (Graphics pipeline basics), Render passes и Conclusion.

Содержание
1. Вступление

2. Краткий обзор

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

4. Рисуем треугольник

  1. Подготовка к работе
  2. Отображение на экране
  3. Графический конвейер (pipeline)
  4. Отрисовка
  5. Повторное создание цепочки показа

5. Буферы вершин

  1. Описание
  2. Создание буфера вершин
  3. Staging буфер
  4. Буфер индексов

6. Uniform-буферы

  1. Дескриптор layout и буфера
  2. Дескриптор пула и sets

7. Текстурирование

  1. Изображения
  2. Image view и image sampler
  3. Комбинированный image sampler

8. Буфер глубины

9. Загрузка моделей

10. Создание мип-карт

11. Multisampling

FAQ

Политика конфиденциальности


Проходы рендера




Подготовка


Прежде чем завершить создание графического конвейера нужно сообщить Vulkan, какие буферы (attachments) будут использоваться во время рендеринга. Необходимо указать, сколько будет буферов цвета, буферов глубины и сэмплов для каждого буфера. Также нужно указать, как должно обрабатываться содержимое буферов во время рендеринга. Вся эта информация обернута в объект прохода рендера (render pass), для которого мы создадим новую функцию createRenderPass. Вызовем эту функцию из initVulkan перед createGraphicsPipeline.

void initVulkan() {    createInstance();    setupDebugMessenger();    createSurface();    pickPhysicalDevice();    createLogicalDevice();    createSwapChain();    createImageViews();    createRenderPass();    createGraphicsPipeline();}...void createRenderPass() {}


Настройка буферов (attachments)


Мы используем только один цветовой буфер, представленный одним из images в swap chain.

void createRenderPass() {    VkAttachmentDescription colorAttachment{};    colorAttachment.format = swapChainImageFormat;    colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;}

Формат цветового буфера (поле format) должен соответствовать формату image из swap chain, и поскольку мы пока не задействуем мультисэмплинг, нам понадобится только 1 сэмпл.

colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;

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

  • VK_ATTACHMENT_LOAD_OP_LOAD: буфер будет содержать те данные, которые были помещены в него до этого прохода (например, во время предыдущего прохода)
  • VK_ATTACHMENT_LOAD_OP_CLEAR: буфер очищается в начале прохода рендера
  • VK_ATTACHMENT_LOAD_OP_DONT_CARE: содержимое буфера не определено; для нас оно не имеет значения

Мы будем использовать VK_ATTACHMENT_LOAD_OP_CLEAR, чтобы заполнить фреймбуфер черным цветом перед отрисовкой нового фрейма.

Для storeOp возможны только два значения:

  • VK_ATTACHMENT_STORE_OP_STORE: содержимое буфера сохраняется в память для дальнейшего использования
  • VK_ATTACHMENT_STORE_OP_DONT_CARE: после рендеринга буфер больше не используется, и его содержимое не имеет значения

Нам нужно вывести отрендеренный треугольник на экран, поэтому перейдем к операции сохранения:

colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;

loadOp и storeOp применяются к буферам цвета и глубины. Для буфера трафарета используются поля stencilLoadOp/stencilStoreOp. Мы не используем буфер трафарета, поэтому результаты загрузки и сохранения нас не интересуют.

colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;

Текстуры и фреймбуферы в Vulkan это объекты VkImage с определенным форматом пикселей, однако layout пикселей в памяти может меняться в зависимости от того, что вы хотите сделать с image.

Вот некоторые из наиболее распространенных layout-ов:

  • VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL: images используются в качестве цветового буфера
  • VK_IMAGE_LAYOUT_PRESENT_SRC_KHR: images используются для показа на экране
  • VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL: image принимает данные во время операций копирования

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

В initialLayout указывается layout, в котором будет image перед началом прохода рендера. В finalLayout указывается layout, в который image будет автоматически переведен после завершения прохода рендера. Значение VK_IMAGE_LAYOUT_UNDEFINED в поле initialLayout обозначает, что нас не интересует предыдущий layout, в котором был image. Использование этого значения не гарантирует сохранение содержимого image, но это и не важно, поскольку мы все равно очистим его. После рендеринга нам нужно вывести наш image на экран, поэтому в поле finalLayout укажем VK_IMAGE_LAYOUT_PRESENT_SRC_KHR.

Подпроходы (subpasses)


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

Каждый подпроход ссылается на один или несколько attachment-ов. Эти отсылки представляют собой структуры VkAttachmentReference:

VkAttachmentReference colorAttachmentRef{};colorAttachmentRef.attachment = 0;colorAttachmentRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;

В поле attachment указывается порядковый номер буфера в массиве, на который ссылается подпроход. Наш массив состоит только из одного буфера VkAttachmentDescription, его индекс равен 0. В поле layout мы указываем layout буфера во время подпрохода, ссылающегося на этот буфер. Vulkan автоматически переведет буфер в этот layout, когда начнется подпроход. Мы используем attachment в качестве буфера цвета, и layout VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL обеспечит нам самую высокую производительность.

Подпроход описывается с помощью структуры VkSubpassDescription:

VkSubpassDescription subpass{};subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;

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

subpass.colorAttachmentCount = 1;subpass.pColorAttachments = &colorAttachmentRef;

Директива layout(location = 0) out vec4 outColor ссылается именно на порядковый номер буфера в массиве subpass.pColorAttachments.

Подпроход может ссылаться на следующие типы буферов:

  • pInputAttachments: буферы, содержимое которых читается из шейдера
  • pResolveAttachments: буферы, которые используются для цветовых буферов с мультисэмплингом
  • pDepthStencilAttachment: буферы глубины и трафарета
  • pPreserveAttachments: буферы, которые не используются в текущем подпроходе, но данные которых должны быть сохранены


Проход рендера (render pass)


Теперь, когда буфер и подпроход, ссылающийся на этот буфер, описаны, мы можем создать сам проход рендера. Перед pipelineLayout создадим новую переменную-член класса для хранения объекта VkRenderPass:

VkRenderPass renderPass;VkPipelineLayout pipelineLayout;

Теперь создадим объект прохода рендера. Для этого заполним структуру VkRenderPassCreateInfo массивом буферов и подпроходами рендера. Обратите внимание, объекты VkAttachmentReference используют индексы из этого массива (прим. переводчика: видимо, имеется в виду массив renderPassInfo.pAttachments).

VkRenderPassCreateInfo renderPassInfo{};renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;renderPassInfo.attachmentCount = 1;renderPassInfo.pAttachments = &colorAttachment;renderPassInfo.subpassCount = 1;renderPassInfo.pSubpasses = &subpass;if (vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass) != VK_SUCCESS) {    throw std::runtime_error("failed to create render pass!");}

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

void cleanup() {    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);    vkDestroyRenderPass(device, renderPass, nullptr);    ...}

Мы проделали большую работу, и осталось лишь собрать все воедино, чтобы наконец-то создать графический конвейер!

C++ code / Vertex shader / Fragment shader


Заключение


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

  • Шейдеры: шейдерные модули, определяющие функционал программируемых стадий конвейера
  • Непрограммируемые стадии: структуры, описывающие работу конвейера на непрограммируемых стадиях, таких как input assembler, растеризатор, вьюпорт и функция смешивания цветов
  • Layout конвейера: описание uniform-переменных и push-констант, которые используются конвейером и которые могут обновляться динамически
  • Проход рендера (render pass): описания буферов (attachments), в которые будет производиться рендер

Все эти объекты полностью определяют функционал графического конвейера. С ними можно начать заполнение структуры VkGraphicsPipelineCreateInfo. Сделаем это в конце функции createGraphicsPipeline, но перед вызовами vkDestroyShaderModule, поскольку шейдерные модули будут использоваться во время создания конвейера.

VkGraphicsPipelineCreateInfo pipelineInfo{};pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;pipelineInfo.stageCount = 2;pipelineInfo.pStages = shaderStages;

Начнем с указателя на массив структур VkPipelineShaderStageCreateInfo.

pipelineInfo.pVertexInputState = &vertexInputInfo;pipelineInfo.pInputAssemblyState = &inputAssembly;pipelineInfo.pViewportState = &viewportState;pipelineInfo.pRasterizationState = &rasterizer;pipelineInfo.pMultisampleState = &multisampling;pipelineInfo.pDepthStencilState = nullptr; // OptionalpipelineInfo.pColorBlendState = &colorBlending;pipelineInfo.pDynamicState = nullptr; // Optional

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

pipelineInfo.layout = pipelineLayout;

После этого укажем layout конвейера, который является дескриптором Vulkan, а не указателем на структуру.

pipelineInfo.renderPass = renderPass;pipelineInfo.subpass = 0;

В конце сделаем ссылку на проход (render pass) и номер подпрохода (subpass), который используется в создаваемом пайплайне. Во время рендера можно использовать и другие объекты прохода, но они должны быть совместимы с нашим renderPass. Требования к совместимости вы можете найти здесь, однако в руководстве мы будем использовать только один проход.

pipelineInfo.basePipelineHandle = VK_NULL_HANDLE; // OptionalpipelineInfo.basePipelineIndex = -1; // Optional

Остались два параметра basePipelineHandle и basePipelineIndex. Vulkan позволяет создать производный графический конвейер из существующего конвейера. Суть в том, что создание производного конвейера не требует больших затрат, поскольку большинство функций берется из родительского конвейера. Также переключение между дочерними конвейерами одного родителя осуществляется намного быстрее. В поле basePipelineHandle вы можете указать дескриптор существующего конвейера, либо сделать отсылку к другому конвейеру, который будет создан по индексу, в поле basePipelineIndex. У нас только один конвейер, поэтому укажем VK_NULL_HANDLE и невалидный порядковый номер. Эти значения используются только в том случае, если в VkGraphicsPipelineCreateInfo в поле flags указано VK_PIPELINE_CREATE_DERIVATIVE_BIT.

Прежде чем завершить создание конвейера создадим член класса для хранения объекта VkPipeline:

VkPipeline graphicsPipeline;

И наконец создадим графический конвейер:

if (vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &graphicsPipeline) != VK_SUCCESS) {    throw std::runtime_error("failed to create graphics pipeline!");}

Функция vkCreateGraphicsPipelines содержит больше параметров, чем обычная функция создания объектов в Vulkan. За один вызов она позволяет создать несколько объектов VkPipeline из массива структур VkGraphicsPipelineCreateInfo.

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

Графический конвейер понадобится для всех операций рисования, поэтому он должен быть уничтожен в самом конце:

void cleanup() {    vkDestroyPipeline(device, graphicsPipeline, nullptr);    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);    ...}

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

C++ code / Vertex shader / Fragment shader
Подробнее..

Strapi сохранение файлов на Яндекс Object Storage

14.03.2021 20:05:08 | Автор: admin

Возможно Вы уже пробовали Strapi и он Вам даже понравился настолько, что Вы решили сделать deploy на одном из серверов.

Например я делал deploy на VPS с Node, nginx, pm2. Поскольку VPS обычно обладает скудными возможностями по хранению чего либо - то хочется хранить все свое добро на одном из современных хранилищ.

Strapi разработало для нас коннектор для backet Amazon S3. Данный плагин уже включен в стандартный репозиторий strapi, ранее он фигурировал как plugin, разработанный сообществом.

Как подключить Amazon S3 bucket Вы можете посмотреть здесь:
ролик от Alex
(на сегодня конфигурационные файлы надо писать несколько по другому - но общая канва сохраняется).

В данной статье мы рассмотрим подключение Яндекс Object Storage. Для начала Вам конечно надо зарегистрироваться на Яндекс cloud. После этого в консоли управления выбираете Object Storage.

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

После того как Вы нажмете на кнопку "Создать бакет" - Вы увидите его в списке бакетов:

Дальше нам будет необходимо создать статический ключ для обращения к Яндекс из нашего сервера Strapi. Нажмите на иконку с Вашим облаком (на фото вверху выделил красной рамкой) (у Вас может быть несколько облаков) и выберите Ваш каталог - в моем случае каталог называется default - нажмите на него.

Слева Вы увидите меню в котором есть пункт "Сервисные аккаунты" - нажмите на него и при необходимости создайте сервисный аккаунт (скорее всего Вы это сделали еще при регистрации)

Нажмите на Ваш сервисный аккаунт - перед Вами откроется страница, на которой уже будет необходимая нам кнопка.

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

Сохраните эти два значения в блокнот - они нам понадобятся далее.

Запустите консоль на своем компьютере и введите команду
npx create-strapi-app strapi-yandex-cloud --quickstart

Через некоторое время Strapi установится в директории strapi-yandex-cloud и откроется окно браузера в которое Вы должны ввести данные администратора. После этого окно браузера можно закрыть.

Прекратите выполнение запущенного сервера, перейдите в директорию Вашего приложения - strapi-yandex-cloud и установите дополнительный плагин:
npm i -S strapi-provider-upload-aws-s3

Теперь остался последний шаг - создать конфигурационный файл плагина. Создайте файл сonfig/plugins.js (файл plugins.js в директории config) следующего содержания:

module.exports = ({ env })=>({  upload: {    provider: 'aws-s3',    providerOptions: {      endpoint: 'https://storage.yandexcloud.net',      accessKeyId: env('AWS_ACCESS_KEY_ID'),      secretAccessKey: env('AWS_ACCESS_SECRET'),      region: env('AWS_REGION'),      params: {        Bucket: env('AWS_BUCKET'),      },    },  },});

Осталось сделать последний шаг. В корне проекта создайте файл .env в котором запишите данные Вашего бакета:

HOST=0.0.0.0PORT=1337AWS_ACCESS_KEY_ID=pg2ywMziH_9zeZfA7t3wAWS_ACCESS_SECRET="aTiO354YNpnO9zKjqBiP1U3nm3F3CoXGLYcldZBC"AWS_REGION="ru-central1"AWS_BUCKET="strapi-backet-test"

Здесь strapi-backet-test - это название бакета, который Вы создали, два длинных ключа - это статический ключ доступа, который Вы создали на предыдущем шаге.

Собственно на этом процесс завершен!
Запускаете Ваш сервер - npm run develop.
Открываете в браузере strapi Media Library, загружаете туда какое либо изображение.

Далее открываем в cloud.yandex.ru наш вновь созданный бакет и видим:

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

В Media Library можно помещать не только изображения, но и видео, pdf а также любые другие файлы. Конечно же в Strapi есть готовый Rest API для записи файлов в Media Library.

P.S. Хочу поблагодарить Александра Власова, оказавшего неоценимую помощь в настройке конфигурации для Яндекс облака.

Подробнее..

Screeps, есть ли жизнь после туториала?

15.05.2021 10:13:08 | Автор: admin

Screeps это ММО для програмистов (платное). сделан хаброчанином @artch

Что у вас есть после туториала?

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

четвертый урок туториала, даёт вам все инструменты для апгрейда контроллера. для этого вам достаточно добавить этот код в класс main.js в 21 строку

else {        var upgraders = _.filter(Game.creeps, (creep) => creep.memory.role == 'upgrader');        if(upgraders.length < 2){            var newName = 'Upgrader' + Game.time;            console.log('Spawning new upgrader: ' + newName);            Game.spawns['Spawn1'].spawnCreep([WORK,CARRY,MOVE], newName,                {memory: {role: 'upgrader'}});        }    }

для визуальной картинки, добавляем в main.js (в самый конец функции, она там всего одна)

    var controller = Game.spawns['Spawn1'].room.controller;    controller.room.visual.text('Tick '+Game.time+'\nLevel'+(controller.level+1)+' '+(controller.progress*100/controller.progressTotal)+'% Complete',            controller.pos.x + 1,            controller.pos.y,            {align: 'left', opacity: 0.8});

Видео, как запустить код туториала на локальном сервере.

Видео, добавляем правки

В результате, если не произойдет проблемы очередей, ваши скриперы/апгрейдеры доведут ваш контроллер до второго уровня, как в этом ролике

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

Подробнее..
Категории: Node.js , Nodejs , Tutorial , Screeps

Парящие Острова настраиваем стилизованные шейдера с помощью HDRP в Unity

08.05.2021 22:04:46 | Автор: admin

Автор статьи Maciej Hernik и главный редактор портала 80.lv Kirill Tokarev любезно позволили нам сделать этот перевод.

Maciej Hernik обсудил с нами детали его стилизованной сцены Парящие Острова: шейдеры для травы, деревьев и воды, Volume Overrides, текстурирование асcетов и многое другое.

Вступление

Всем привет! Меня зовут Maciej Hernik и я самоучка, художник по окружению (Level Artist) из Польшы. Сколько я себя помню, я всегда обожал игры. Мое знакомство с видеоиграми произошло, когда я увидел несколько на PS1. В то время я был ребенком, у меня была мечта, что однажды я буду делать свои собственные игры, хотя я еще и понятия не имел как их делать. Я стал старше, узнал что такое 3D арт и нашел это очень интересным и увлекательным занятием. Моя страсть к игровому арту дала мне возможность получить свою первую работу и в этот момент я понял, что хочу зарабатывать на жизнь созданием игрового арта и самих игр.

Парящие Острова: идея

В начале, эта работа предполагалась, как бессрочный проект вроде площадки для экспериментов с HDRP в Unity, это бы помогло мне чуть больше ознакомиться с инструментарием HDRP. Однако, когда я сделал первые итерации шейдеров растительности и показал их своим друзьям, они вдохновили меня сделать красочную сцену с использованием этих шейдеров. Тогда то я и решил сделать эту художественную работу в стиле волшебной сказки, вдохновленный игрой The Legend of Zelda. И в этой статье я хочу разобрать некоторые компоненты из этой работы.

Начало проекта

Сначала я использовал заглушки (Placeholders) для ландшафта и несколько ассетов вроде камней, деревьев и травы, для того, чтобы понять масштаб всего острова, а также форму Монолита и деревьев. Так же я понял, что наиболее подходящие мне тени и солнечные лучи, отбрасывали деревья именно треугольной формы.

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

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

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

Создание травы

Шейдер травы был одним из первых созданных для этого проекта. Однако он прошел через множество итераций, чтобы достичь наиболее подходящего эффекта. Я выбрал более процедурный подход, так как посчитал, что это будет наиболее сложный и интересный путь для создания травы. Для создания шейдера я использовал Shader Graph в Unity версии 2020.1.0f1.

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

  • Primary color + Shadow color (Главный цвет + Цвет тени)

  • Additional color (Дополнительный цвет)

  • Ground color ( Цвет земли)

  • Highlights (Блики)

Primary color и Shadow color разделены на две части. Одна часть это просто цвет травы, а вторая часть отвечает за нанесение поверх другого цвета. Это достигается за счет проекции текстуры на траву из вида сверху в мировых координатах (World Position). Additional color использовался, чтобы достичь большего разнообразия травы, потому что я чувствовал, что двух цветов будет недостаточно, особенно если смотреть сверху. Ground color нужен, чтобы достичь деликатной, почти незаметной имитации окружающего затенения (Ambient Occlusion) снизу травы, чтобы немного разбить элементы. В конечном итоге, я уменьшил его, потому что он создавал слишком сильный контраст между пучками травы и это не подходило для стилизации, по-моему мнению. Именно поэтому, этот эффект окружающего затенения (Ambient Occlusion) от Ground color, очень слабо использован в этой сцене. Последний слой создает блики (Highlights) в верхней части травы, зависящие от скорости перемещения текстуры маски.

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

Деревья

Я создал отдельный шейдер для деревьев и другой растительности, так как он немного отличается от шейдера травы. В этот раз использовался освещаемый шейдер как основу (Lit Master Shader). Благодаря этому я очень легко мог получить информацию от направления света, а также в HDRP освещаемый шейдер (Lit Master Shader) дает возможность управлять полупрозрачностью (Translucency) объекта.

Модель дерева состоит из простых плоскостей (Planes), но с измененными нормалями, чтобы достичь более мягкий вид и получить лучший контроль над распределением света по дереву. Я добился этого в Blender благодаря модификатору передачи данных (Data Transfer Modifier), который позволил мне перенести нормали c другого меша на плоскости (Planes) из которых состоит дерево.

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

Я также реализовал возможность регулировать цвет в зависимости от высоты объекта, по локальному значению Y. Это позволило мне создать симпатичный градиент сверху вниз, что также помогло сделать более мягкий переход от земли к дереву. В итоге, я решил добавить деликатное окружающее затенение (Ambient Occlusion) между листьями, чтобы немного разделить их.

Эффекты воды

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

Водопад на самом деле представляет из себя микс двух составляющих:

  • Вода с шейдером воды

  • Частицы пены и пузырьков

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

Я добавил немного частиц для симуляции пены и пузырьков, используя систему частиц Unity c кастомным мешем в виде сферы. Чтобы добиться финального вида, я поиграл с такими настройками как эмиссия, форма системы частиц и размер частиц в течение их времени жизни (Size Over Lifetime).

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

Ассеты не растительного происхождения

Аcсеты, такие как камни, мост и Монолит я сделал стандартным образом - смоделировал их в Blender, заскульптил высокополигональные модели (High Poly) в ZBrush, и запек их на низкополигональные меши (Low Poly) в Substance Painter.

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

Фокус в том, чтобы использовать Blur Slope фильтр на слое Baked Lighting в Substance Painter, который я кладу поверх всех остальных слоев. Это позволило мне достичь стилизованного неоднородного эффекта.

Очень важно знать, что слой Baked Lighting добавляет тени в текстуру, основываясь на освещении именно от окружения в самом Substance Painter. Из-за этого, я выбрал окружение, которое не такое направленное с точки зрения освещения.

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

Настройка Volume

Последняя часть проекта, о которой я хотел поговорить, это компонент Volume в Unity HDRP, он позволил мне отрегулировать освещение и пост-обработку (Post-Processing). В Volume я добавил несколько Overrides, что помогло мне достичь разных эффектов.

В виде Overrides были добавлены Visual Environment, HDRI Sky и Indirect Lighting Controller, это обеспечило мне немного окружающего освещения (Ambient Light) от неба. При использовании Indirect Lighting Controller я мог регулировать интенсивность окружающего освещения (Ambient Light), что было довольно удобно для меня.

Еще одна полезная опция, которую я действительно люблю использовать внутри Unity HDRP, это туман (Fog) и особенно объемный туман (Volumetric Fog). Я обнаружил, что лучший способ использовать его - это настроить пару компонентов Density Volume в сцене. Density Volume - это компонент, который позволяет вам вручную регулировать область, где будет отображаться туман и на сколько сильным он будет в этой области.

Я также добавил некоторые эффекты пост-обработки (Post-Processing) в Volume. Потому что мне казалось, что сцена была слишком темной и нужно было добавить больше свечения (Bloom), чтобы добиться сказочного вида, к которому я стремился. В итоге, сцена получила больше синих оттенков, особенно в тенях и я остался доволен конечным результатом.

Заключение

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

Если у вас есть какие-то вопросы об этой сцене или о творчестве в целом, то не стесняйтесь писать мне наArtStation.

Спасибо вам за прочтение! Пока!
Maciej Hernik, Level Artist

С оригинальной статьей можно ознакомиться на 80.lvздесь.

Перевод подготовлен при поддержке проектаAlmost There.

Подробнее..

Перевод Парящие Острова настраиваем стилизованные шейдера с помощью HDRP в Unity

09.05.2021 10:20:54 | Автор: admin

Автор статьи Maciej Hernik и главный редактор портала 80.lv Kirill Tokarev любезно позволили нам сделать этот перевод.

Maciej Hernik обсудил с нами детали его стилизованной сцены Парящие Острова: шейдеры для травы, деревьев и воды, Volume Overrides, текстурирование асcетов и многое другое.

Вступление

Всем привет! Меня зовут Maciej Hernik и я самоучка, художник по окружению (Level Artist) из Польши. Сколько я себя помню, я всегда обожал игры. Мое знакомство с видеоиграми произошло, когда я увидел несколько на PS1. В то время я был ребенком, у меня была мечта, что однажды я буду делать свои собственные игры, хотя я еще и понятия не имел как их делать. Я стал старше, узнал что такое 3D арт и нашел это очень интересным и увлекательным занятием. Моя страсть к игровому арту дала мне возможность получить свою первую работу и в этот момент я понял, что хочу зарабатывать на жизнь созданием игрового арта и самих игр.

Парящие Острова: идея

В начале, эта работа предполагалась, как бессрочный проект вроде площадки для экспериментов с HDRP в Unity, это бы помогло мне чуть больше ознакомиться с инструментарием HDRP. Однако, когда я сделал первые итерации шейдеров растительности и показал их своим друзьям, они вдохновили меня сделать красочную сцену с использованием этих шейдеров. Тогда то я и решил сделать эту художественную работу в стиле волшебной сказки, вдохновленный игрой The Legend of Zelda. И в этой статье я хочу разобрать некоторые компоненты из этой работы.

Начало проекта

Сначала я использовал заглушки (Placeholders) для ландшафта и несколько ассетов вроде камней, деревьев и травы, для того, чтобы понять масштаб всего острова, а также форму Монолита и деревьев. Так же я понял, что наиболее подходящие мне тени и солнечные лучи, отбрасывали деревья именно треугольной формы.

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

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

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

Создание травы

Шейдер травы был одним из первых созданных для этого проекта. Однако он прошел через множество итераций, чтобы достичь наиболее подходящего эффекта. Я выбрал более процедурный подход, так как посчитал, что это будет наиболее сложный и интересный путь для создания травы. Для создания шейдера я использовал Shader Graph в Unity версии 2020.1.0f1.

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

  • Primary color + Shadow color (Главный цвет + Цвет тени)

  • Additional color (Дополнительный цвет)

  • Ground color ( Цвет земли)

  • Highlights (Блики)

Primary color и Shadow color разделены на две части. Одна часть это просто цвет травы, а вторая часть отвечает за нанесение поверх другого цвета. Это достигается за счет проекции текстуры на траву из вида сверху в мировых координатах (World Position). Additional color использовался, чтобы достичь большего разнообразия травы, потому что я чувствовал, что двух цветов будет недостаточно, особенно если смотреть сверху. Ground color нужен, чтобы достичь деликатной, почти незаметной имитации окружающего затенения (Ambient Occlusion) снизу травы, чтобы немного разбить элементы. В конечном итоге, я уменьшил его, потому что он создавал слишком сильный контраст между пучками травы и это не подходило для стилизации, по-моему мнению. Именно поэтому, этот эффект окружающего затенения (Ambient Occlusion) от Ground color, очень слабо использован в этой сцене. Последний слой создает блики (Highlights) в верхней части травы, зависящие от скорости перемещения текстуры маски.

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

Деревья

Я создал отдельный шейдер для деревьев и другой растительности, так как он немного отличается от шейдера травы. В этот раз использовался освещаемый шейдер как основу (Lit Master Shader). Благодаря этому я очень легко мог получить информацию от направления света, а также в HDRP освещаемый шейдер (Lit Master Shader) дает возможность управлять полупрозрачностью (Translucency) объекта.

Модель дерева состоит из простых плоскостей (Planes), но с измененными нормалями, чтобы достичь более мягкий вид и получить лучший контроль над распределением света по дереву. Я добился этого в Blender благодаря модификатору передачи данных (Data Transfer Modifier), который позволил мне перенести нормали c другого меша на плоскости (Planes) из которых состоит дерево.

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

Я также реализовал возможность регулировать цвет в зависимости от высоты объекта, по локальному значению Y. Это позволило мне создать симпатичный градиент сверху вниз, что также помогло сделать более мягкий переход от земли к дереву. В итоге, я решил добавить деликатное окружающее затенение (Ambient Occlusion) между листьями, чтобы немного разделить их.

Эффекты воды

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

Водопад на самом деле представляет из себя микс двух составляющих:

  • Вода с шейдером воды

  • Частицы пены и пузырьков

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

Я добавил немного частиц для симуляции пены и пузырьков, используя систему частиц Unity c кастомным мешем в виде сферы. Чтобы добиться финального вида, я поиграл с такими настройками как эмиссия, форма системы частиц и размер частиц в течение их времени жизни (Size Over Lifetime).

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

Ассеты не растительного происхождения

Аcсеты, такие как камни, мост и Монолит я сделал стандартным образом - смоделировал их в Blender, заскульптил высокополигональные модели (High Poly) в ZBrush, и запек их на низкополигональные меши (Low Poly) в Substance Painter.

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

Фокус в том, чтобы использовать Blur Slope фильтр на слое Baked Lighting в Substance Painter, который я кладу поверх всех остальных слоев. Это позволило мне достичь стилизованного неоднородного эффекта.

Очень важно знать, что слой Baked Lighting добавляет тени в текстуру, основываясь на освещении именно от окружения в самом Substance Painter. Из-за этого, я выбрал окружение, которое не такое направленное с точки зрения освещения.

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

Настройка Volume

Последняя часть проекта, о которой я хотел поговорить, это компонент Volume в Unity HDRP, он позволил мне отрегулировать освещение и пост-обработку (Post-Processing). В Volume я добавил несколько Overrides, что помогло мне достичь разных эффектов.

В виде Overrides были добавлены Visual Environment, HDRI Sky и Indirect Lighting Controller, это обеспечило мне немного окружающего освещения (Ambient Light) от неба. При использовании Indirect Lighting Controller я мог регулировать интенсивность окружающего освещения (Ambient Light), что было довольно удобно для меня.

Еще одна полезная опция, которую я действительно люблю использовать внутри Unity HDRP, это туман (Fog) и особенно объемный туман (Volumetric Fog). Я обнаружил, что лучший способ использовать его - это настроить пару компонентов Density Volume в сцене. Density Volume - это компонент, который позволяет вам вручную регулировать область, где будет отображаться туман и на сколько сильным он будет в этой области.

Я также добавил некоторые эффекты пост-обработки (Post-Processing) в Volume. Потому что мне казалось, что сцена была слишком темной и нужно было добавить больше свечения (Bloom), чтобы добиться сказочного вида, к которому я стремился. В итоге, сцена получила больше синих оттенков, особенно в тенях и я остался доволен конечным результатом.

Заключение

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

Если у вас есть какие-то вопросы об этой сцене или о творчестве в целом, то не стесняйтесь писать мне наArtStation.

Спасибо вам за прочтение! Пока!
Maciej Hernik, Level Artist

С оригинальной статьей можно ознакомиться на 80.lvздесь.

Перевод подготовлен при поддержке проектаAlmost There.

Подробнее..

Перевод О наблюдаемости микросервисов в Kubernetes

17.03.2021 16:04:48 | Автор: admin

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

Привет, Хабр. В рамках курса "Microservice Architecture" подготовили для вас перевод материала.

Также приглашаем на открытый вебинар по теме
Распределенные очереди сообщений на примере кафки.


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

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

Наблюдаемость зиждется на трех столпах:

  • Логи (журналы) событий: запись событий, произошедших в системе. События (events) дискретны и содержат метаданные о системе на тот момент, когда они происходили.

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

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

Теперь к насущному вопросу как все это реализовать для наших микросервисов в кластере Kubernetes?

Микросервисы Kubernetes-приложение

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

Давайте рассмотрим микросервисное приложение, которое предоставляет информацию о погоде для конкретного города.

  • Weather-front: компонент, который состоит из фронтенда с интерфейсом для ввода названия города и просмотра информации о погоде. Смотрите скриншот выше.

  • Weather-services: компонент, который в качестве входных данных принимает название города и вызывает внешний погодный API для получения сведений о погоде.

  • Weather-db: это компонент с базой данных Maria, в которой хранятся данные о погоде, которые извлекаются для отслеживаемого города в фоновом режиме.

Указанные выше микросервисы развертываются с помощью объекта развертывания (Deployment object) Kubernetes, а ниже результат выполнения команды kubectl get deploy.

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

Weather-front:

- image: brainupgrade/weather:microservices-front  imagePullPolicy: Always  name: weather-front

Weather-services:

- image: brainupgrade/weather-services:2.0.0  imagePullPolicy: Always  name: weather-services

Weather-db:

- image: mariadb:10.3  name: mariadb  ports:  - containerPort: 3306    name: mariadb

Наблюдаемость Столп первый Логи событий

Чтобы реализовать первый столп наблюдаемости, нам нужно установить стек EFK: Elasticsearch, Fluentd и Kibana. Ниже приведены несложные шаги по установке.

Elasticsearch и Kibana:

helm repo add elastic https://helm.elastic.cohelm repo updatehelm install --name elasticsearch elastic/elasticsearch --set replicas=1 --namespace elasticsearchhelm install --name kibana elastic/kibana

Fluentd:

containers:- name: fluentd  imagePullPolicy: "Always"  image: fluent/fluentd-kubernetes-daemonset:v1.12.0-debian-elasticsearch7-1.0  env:    - name:  FLUENT_ELASTICSEARCH_HOST      value: "elasticsearch-master.elasticsearch.svc.cluster.local"    - name:  FLUENT_ELASTICSEARCH_PORT      value: "9200"

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

При запуске Fluentd вы увидите следующее (Fluentd запускается как Daemonset - скриншот 4):

Вскоре логи начнут помещаться в Elasticsearch. Их можно будет просмотреть в Kibana:

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

Наблюдаемость Столп второй (распределенная) трассировка

Для распределенной трассировки (distributed tracing) у нас есть несколько альтернатив Java-приложения, такие как Zipkin, Jaeger, Elasticsesarch APM и т. д.

Поскольку у нас уже есть стек EFK, давайте воспользуемся APM, предоставляемым Elasticsearch. Во-первых, давайте запустим сервер APM как Kubernetes Deployment.

Код развертывания сервера Elastic APM:

containers:- name: apm-server  image: docker.elastic.co/apm/apm-server:7.5.0  ports:  - containerPort: 8200    name: apm-port

Как только сервер APM запущен, нам следует неинвазивно добавить агента APM в наши микросервисы. Смотрите приведенный ниже фрагмент кода, используемый для микросервиса weather-front. Аналогичный код следует использовать и для компонента weather-services.

Код агента APM для микросервиса weather-front:

initContainers:- name: elastic-java-agent  image: docker.elastic.co/observability/apm-agent-java:1.12.0  volumeMounts:  - mountPath: /elastic/apm/agent    name: elastic-apm-agent  command: ['cp', '-v', '/usr/agent/elastic-apm-agent.jar', '/elastic/apm/agent']   containers:  - image: brainupgrade/weather:microservices-front    imagePullPolicy: Always    name: weather-front    volumeMounts:    - mountPath: /elastic/apm/agent      name: elastic-apm-agent             env:      - name: ELASTIC_APM_SERVER_URL        value: "http://apm-server.elasticsearch.svc.cluster.local:8200"      - name: ELASTIC_APM_SERVICE_NAME        value: "weather-front"      - name: ELASTIC_APM_APPLICATION_PACKAGES        value: "in.brainupgrade"      - name: ELASTIC_APM_ENVIRONMENT        value: prod      - name: ELASTIC_APM_LOG_LEVEL        value: DEBUG      - name: JAVA_TOOL_OPTIONS        value: -javaagent:/elastic/apm/agent/elastic-apm-agent.jar

После повторного развертывания компонентов микросервисов, вы можете перейти в Observability -> APM console в Kibana, чтобы наблюдать, как появляются сервисы (смотрите на скриншот 6).

После того, как вы кликните на сервис weather-front, вы сможете увидеть транзакции:

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

На приведенном выше скриншоте показана распределенная трассировка, на которой четко проиллюстрирована взаимосвязь микросервисов weather-front и weather-services. Кликнув по Trace Sample, вы перейдете к сведениям транзакции (transaction details).

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

Что ж, на данный момент мы рассмотрели два столпа наблюдаемости из трех.

Наблюдаемость Столп третий Метрики

Для реализации третьего столпа, то есть метрик, мы можем использовать дашборд служб APM, где фиксируются задержка (Latency), пропускная способность (Throughput) и процент ошибок (Error rate).

Кроме того, мы можем использовать плагин Spring Boot Prometheus Actuator для сбора данных метрик. Для этого сначала установите Prometheus и Grafana, используя следующие простые команды:

Prometheus и Grafana:

helm repo add prometheus-community  https://prometheus-community.github.io/helm-chartshelm repo add grafana https://grafana.github.io/helm-chartshelm repo updatehelm install --name prometheus prometheus-community/prometheushelm install --name grafana grafana/grafana

После того как Prometheus и Grafana заработают, вам нужно добавить код приведенный ниже в микросервисы и совершить повторное развертывание:

template:   metadata:     labels:       app: weather-services     annotations:       prometheus.io/scrape: "true"       prometheus.io/port: "8888"       prometheus.io/path: /actuator/prometheus     containers:       - image: brainupgrade/weather-services:2.0.0         imagePullPolicy: Always         name: weather-services         volumeMounts:         - mountPath: /elastic/apm/agent           name: elastic-apm-agent                  env:           - name: management.endpoints.web.exposure.include             value: "*"           - name: spring.application.name             value: weather-services           - name: management.server.port             value: "8888"           - name: management.metrics.web.server.request.autotime.enabled             value: "true"           - name: management.metrics.tags.application             value: weather-services

После повторного развертывания микросервисов откройте Grafana, импортируйте дашборд с id 12685 и выберите микросервис, метрики которого вы хотите увидеть. На скриншоте ниже приведен weather-front:

И чтобы увидеть метрики всего кластера, импортируйте дашборд Grafana с id 6417, и вы увидите что-то вроде:


Узнать подробнее о курсе "Microservice Architecture".

Смотреть открытый вебинар по теме Распределенные очереди сообщений на примере кафки.

Подробнее..

Перевод Сеть контейнеров это не сложно

26.05.2021 22:23:37 | Автор: admin

Работа с контейнерами многим кажется волшебством, пришло время разобраться как работает сеть контейнеров. Мы покажем на примерах, что это совсем не сложно. Помните, что контейнеры - всего лишь изолированные процессы Linux.

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

  • Как виртуализировать сетевые ресурсы, чтобы контейнеры думали, что у каждого из них есть выделенный сетевой стек?

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

  • Как настроить сетевой доступ из контейнера во внешний мир (например, в Интернет)?

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

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

  • Network namespaces

  • Virtual Ethernet devices (veth)

  • Virtual network switches (bridge)

  • IP маршрутизация и преобразование сетевых адресов (NAT)

Нам потребуется немного сетевой магии и никакого кода ...

С чего начать?

Все примеры в статье были сделаны на виртуальной машине CentOS 8. Но вы можете выбрать тот дистрибутив, который вам нравится.

Создадим виртуальную машину с помощью Vagrant и подключимся к ней по SSH:

$ vagrant init centos/8$ vagrant up$ vagrant ssh[vagrant@localhost ~]$ uname -aLinux localhost.localdomain 4.18.0-147.3.1.el8_1.x86_64

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

Изоляция контейнеров с помощью Network namespaces

Что составляет сетевой стек Linux? Ну, очевидно, набор сетевых устройств. Что еще? Набор правил маршрутизации. И не забываем про настройку netfilter, создадим необходимые правила iptables.

Напишем небольшой скрипт inspect-net-stack.sh:

#!/usr/bin/env bashecho "> Network devices"ip linkecho -e "\n> Route table"ip routeecho -e "\n> Iptables rules"iptables --list-rules

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

$ sudo iptables -N ROOT_NS

Запускаем скрипт:

$ sudo ./inspect-net-stack.sh> Network devices1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:002: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000    link/ether 52:54:00:e3:27:77 brd ff:ff:ff:ff:ff:ff> Route tabledefault via 10.0.2.2 dev eth0 proto dhcp metric 10010.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100> Iptables rules-P INPUT ACCEPT-P FORWARD ACCEPT-P OUTPUT ACCEPT-N ROOT_NS

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

Мы уже упоминали об одном из Linux namespaces, используемых для изоляции контейнеров, которое называет сетевое пространство имён (Network namespace). Если заглянуть в man ip-netns, то мы прочтём, что Network namespace логически является копией сетевого стека со своими собственными маршрутами, правилами брандмауэра и сетевыми устройствами. Мы не будем затрагивать другие Linux namespaces в этой статье и ограничимся только областью видимости сетевого стека.

Для создания Network namespace нам достаточно утилиты ip, которая входим в популярный пакет iproute2. Создадим новое сетевое пространство имён:

$ sudo ip netns add netns0$ ip netnsnetns0

Новое сетевое пространство имён создано, но как начать его использовать? Воспользуемся командой Linux под названием nsenter. Она осуществляет вход в одно или несколько указанных пространств имен, а затем выполняет в нём указанную программу:

$ sudo nsenter --net=/var/run/netns/netns0 bash# The newly created bash process lives in netns0$ sudo ./inspect-net-stack.sh> Network devices1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00> Route table> Iptables rules-P INPUT ACCEPT-P FORWARD ACCEPT-P OUTPUT ACCEPT

Приведённый выше пример показывает, что процесс bash, работающий внутри пространства имён netns0, видит совершенно другой сетевой стек. Отсутствуют правила маршрутизации, и правила iptables, есть только один loopback interface. Все идет по плану...

Подключаем контейнер к хосту через virtual Ethernet devices (veth)

Выделенный сетевой стек будет бесполезен, если к нему отсутствует доступ. К счастью, Linux предоставляет подходящее средство для этого - virtual Ethernet devices (veth)! Согласно man veth, veth-device - это виртуальные устройства Ethernet. Они работают как туннели между сетевыми пространствами имён для создания моста к физическому сетевому устройству в другом пространстве имён, а также могут использоваться как автономные сетевые устройства.

Виртуальные Ethernet устройства всегда работают парами. Создадим их прямо сейчас:

$ sudo ip link add veth0 type veth peer name ceth0

С помощью этой единственной команды мы только что создали пару взаимосвязанных виртуальных Ethernet устройств. Имена veth0 и ceth0 были выбраны произвольно:

$ ip link1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:002: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000    link/ether 52:54:00:e3:27:77 brd ff:ff:ff:ff:ff:ff5: ceth0@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000    link/ether 66:2d:24:e3:49:3f brd ff:ff:ff:ff:ff:ff6: veth0@ceth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000    link/ether 96:e8:de:1d:22:e0 brd ff:ff:ff:ff:ff:ff

И veth0, и ceth0 после создания находятся в сетевом стеке хоста (также называемом Root Network namespace). Чтобы связать корневое пространство имён с пространством имён netns0, нам нужно сохранить одно из устройств в корневом пространстве имён и переместить другое в netns0:

$ sudo ip link set ceth0 netns netns0# List all the devices to make sure one of them disappeared from the root stack$ ip link1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:002: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000    link/ether 52:54:00:e3:27:77 brd ff:ff:ff:ff:ff:ff6: veth0@if5: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000    link/ether 96:e8:de:1d:22:e0 brd ff:ff:ff:ff:ff:ff link-netns netns0

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

$ sudo ip link set veth0 up$ sudo ip addr add 172.18.0.11/16 dev veth0

Продолжим сnetns0:

$ sudo nsenter --net=/var/run/netns/netns0$ ip link set lo up  # whoops$ ip link set ceth0 up$ ip addr add 172.18.0.10/16 dev ceth0$ ip link1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:005: ceth0@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000    link/ether 66:2d:24:e3:49:3f brd ff:ff:ff:ff:ff:ff link-netnsid 0

Проверяем подключение:

# From netns0, ping root's veth0$ ping -c 2 172.18.0.11PING 172.18.0.11 (172.18.0.11) 56(84) bytes of data.64 bytes from 172.18.0.11: icmp_seq=1 ttl=64 time=0.038 ms64 bytes from 172.18.0.11: icmp_seq=2 ttl=64 time=0.040 ms--- 172.18.0.11 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 58msrtt min/avg/max/mdev = 0.038/0.039/0.040/0.001 ms# Leave netns0$ exit# From root namespace, ping ceth0$ ping -c 2 172.18.0.10PING 172.18.0.10 (172.18.0.10) 56(84) bytes of data.64 bytes from 172.18.0.10: icmp_seq=1 ttl=64 time=0.073 ms64 bytes from 172.18.0.10: icmp_seq=2 ttl=64 time=0.046 ms--- 172.18.0.10 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 3msrtt min/avg/max/mdev = 0.046/0.059/0.073/0.015 ms

Обратите внимание, если мы попытаемся проверить доступность любых других адресов из пространства имен netns0, у нас ничего не получится:

# Inside root namespace$ ip addr show dev eth02: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000    link/ether 52:54:00:e3:27:77 brd ff:ff:ff:ff:ff:ff    inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute eth0       valid_lft 84057sec preferred_lft 84057sec    inet6 fe80::5054:ff:fee3:2777/64 scope link       valid_lft forever preferred_lft forever# Remember this 10.0.2.15$ sudo nsenter --net=/var/run/netns/netns0# Try host's eth0$ ping 10.0.2.15connect: Network is unreachable# Try something from the Internet$ ping 8.8.8.8connect: Network is unreachable

Для таких пакетов в таблице маршрутизации netns0 просто нет маршрута. В настоящий момент существует единственный маршрут до сети 172.18.0.0/16:

# From netns0 namespace:$ ip route172.18.0.0/16 dev ceth0 proto kernel scope link src 172.18.0.10

В Linux есть несколько способов заполнения таблицы маршрутизации. Один из них - извлечение маршрутов из подключенных напрямую сетевых интерфейсов. Помните, что таблица маршрутизации в netns0 была пустой сразу после создания пространства имен. Но затем мы добавили туда устройство ceth0 и присвоили ему IP-адрес 172.18.0.10/16. Поскольку мы использовали не простой IP-адрес, а комбинацию адреса и сетевой маски, сетевому стеку удалось извлечь из него информацию о маршрутизации. Каждый пакет, предназначенный для сети 172.18.0.0/16, будет отправлен через устройство ceth0. Но все остальные пакеты будут отброшены. Точно так же есть новый маршрут в корневом пространстве имен:

# From root namespace:$ ip route# ... omitted lines ...172.18.0.0/16 dev veth0 proto kernel scope link src 172.18.0.11

На этом этапе мы ответили на первый вопрос. Теперь мы знаем, как изолировать, виртуализировать и подключать сетевые стеки Linux.

Объединение контейнеров с помощью virtual network switch (bridge)

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

# From root namespace$ sudo ip netns add netns1$ sudo ip link add veth1 type veth peer name ceth1$ sudo ip link set ceth1 netns netns1$ sudo ip link set veth1 up$ sudo ip addr add 172.18.0.21/16 dev veth1$ sudo nsenter --net=/var/run/netns/netns1$ ip link set lo up$ ip link set ceth1 up$ ip addr add 172.18.0.20/16 dev ceth1

Проверим доступность:

# From netns1 we cannot reach the root namespace!$ ping -c 2 172.18.0.21PING 172.18.0.21 (172.18.0.21) 56(84) bytes of data.From 172.18.0.20 icmp_seq=1 Destination Host UnreachableFrom 172.18.0.20 icmp_seq=2 Destination Host Unreachable--- 172.18.0.21 ping statistics ---2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 55mspipe 2# But there is a route!$ ip route172.18.0.0/16 dev ceth1 proto kernel scope link src 172.18.0.20# Leaving netns1$ exit# From root namespace we cannot reach the netns1$ ping -c 2 172.18.0.20PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.From 172.18.0.11 icmp_seq=1 Destination Host UnreachableFrom 172.18.0.11 icmp_seq=2 Destination Host Unreachable--- 172.18.0.20 ping statistics ---2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 23mspipe 2# From netns0 we CAN reach veth1$ sudo nsenter --net=/var/run/netns/netns0$ ping -c 2 172.18.0.21PING 172.18.0.21 (172.18.0.21) 56(84) bytes of data.64 bytes from 172.18.0.21: icmp_seq=1 ttl=64 time=0.037 ms64 bytes from 172.18.0.21: icmp_seq=2 ttl=64 time=0.046 ms--- 172.18.0.21 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 33msrtt min/avg/max/mdev = 0.037/0.041/0.046/0.007 ms# But we still cannot reach netns1$ ping -c 2 172.18.0.20PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.From 172.18.0.10 icmp_seq=1 Destination Host UnreachableFrom 172.18.0.10 icmp_seq=2 Destination Host Unreachable--- 172.18.0.20 ping statistics ---2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 63mspipe 2

Что-то пошло не так... По какой-то причине мы не можем подключиться из netns1 к root namespace. А из root namespace мы не можем подключиться к netns1. Однако, поскольку оба контейнера находятся в одной IP-сети 172.18.0.0/16, есть доступ к veth1 хоста из контейнера netns0. Интересно...

Возможно, мы столкнулись с конфликтом маршрутов. Давайте проверим таблицу маршрутизации в root namespace:

$ ip route# ... omitted lines ...172.18.0.0/16 dev veth0 proto kernel scope link src 172.18.0.11172.18.0.0/16 dev veth1 proto kernel scope link src 172.18.0.21

После добавления второй пары veth в таблице маршрутизации root namespace появился новый маршрут 172.18.0.0/16 dev veth1 proto kernel scope link src 172.18.0.21, но маршрут до этой подсети уже существовал! Когда второй контейнер пытается проверить связь с устройством veth1, используется первый маршрут и мы видим ошибку подключения. Если бы мы удалили первый маршрут sudo ip route delete 172.18.0.0/16 dev veth0 proto kernel scope link src 172.18.0.11 и перепроверили подключение, то увидели бы обратную ситуацию, то есть подключение netns1 будет восстановлено, но netns0 останется в подвешенном состоянии.

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

Рассмотрим Linux Bridge - еще один виртуализированный сетевой объект! Linux Bridge ведёт себя как коммутатор. Он пересылает пакеты между подключенными к нему интерфейсами. А поскольку это коммутатор, то он работает на уровне L2 (то есть Ethernet).

Чтобы предыдущие этапы нашего эксперимента в дальнейшем не вносили путаницы, удалим существующие сетевые пространства имён:

$ sudo ip netns delete netns0$ sudo ip netns delete netns1# But if you still have some leftovers...$ sudo ip link delete veth0$ sudo ip link delete ceth0$ sudo ip link delete veth1$ sudo ip link delete ceth1

Заново создаём два контейнера. Обратите внимание, мы не назначаем IP-адреса новым устройствам veth0 и veth1:

$ sudo ip netns add netns0$ sudo ip link add veth0 type veth peer name ceth0$ sudo ip link set veth0 up$ sudo ip link set ceth0 netns netns0$ sudo nsenter --net=/var/run/netns/netns0$ ip link set lo up$ ip link set ceth0 up$ ip addr add 172.18.0.10/16 dev ceth0$ exit$ sudo ip netns add netns1$ sudo ip link add veth1 type veth peer name ceth1$ sudo ip link set veth1 up$ sudo ip link set ceth1 netns netns1$ sudo nsenter --net=/var/run/netns/netns1$ ip link set lo up$ ip link set ceth1 up$ ip addr add 172.18.0.20/16 dev ceth1$ exit

Убедимся, что на хосте нет новых маршрутов:

$ ip routedefault via 10.0.2.2 dev eth0 proto dhcp metric 10010.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100

И, наконец, создадим bridge интерфейс:

$ sudo ip link add br0 type bridge$ sudo ip link set br0 up

Теперь подключим к нему veth0 и veth1:

$ sudo ip link set veth0 master br0$ sudo ip link set veth1 master br0

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

$ sudo nsenter --net=/var/run/netns/netns0$ ping -c 2 172.18.0.20PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.64 bytes from 172.18.0.20: icmp_seq=1 ttl=64 time=0.259 ms64 bytes from 172.18.0.20: icmp_seq=2 ttl=64 time=0.051 ms--- 172.18.0.20 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 2msrtt min/avg/max/mdev = 0.051/0.155/0.259/0.104 ms
$ sudo nsenter --net=/var/run/netns/netns1$ ping -c 2 172.18.0.10PING 172.18.0.10 (172.18.0.10) 56(84) bytes of data.64 bytes from 172.18.0.10: icmp_seq=1 ttl=64 time=0.037 ms64 bytes from 172.18.0.10: icmp_seq=2 ttl=64 time=0.089 ms--- 172.18.0.10 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 36msrtt min/avg/max/mdev = 0.037/0.063/0.089/0.026 ms

Прекрасно! Все отлично работает. При этом мы даже не настраивали интерфейсы veth0 и veth1. Мы назначили только два IP-адреса интерфейсам ceth0 и ceth1. Но поскольку они оба находятся в одном сегменте Ethernet (подключены к виртуальному коммутатору), существует возможность подключения на уровне L2:

$ sudo nsenter --net=/var/run/netns/netns0$ ip neigh172.18.0.20 dev ceth0 lladdr 6e:9c:ae:02:60:de STALE$ exit$ sudo nsenter --net=/var/run/netns/netns1$ ip neigh172.18.0.10 dev ceth1 lladdr 66:f3:8c:75:09:29 STALE$ exit

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

Настраиваем сетевой доступ из контейнера во внешний мир (IP routing and masquerading)

Сейчас контейнеры могут подключаться друг к другу. Но будут ли удачны подключения к хосту, то есть к корневому пространству имён?

$ sudo nsenter --net=/var/run/netns/netns0$ ping 10.0.2.15  # eth0 addressconnect: Network is unreachable

Интерфейс eth0 не доступен. Всё очевидно, в netns0 отсутствует маршрут для этого подключения:

$ ip route172.18.0.0/16 dev ceth0 proto kernel scope link src 172.18.0.10

Корневое пространство имён также не может взаимодействовать с контейнерами:

# Use exit to leave netns0 first:$ ping -c 2 172.18.0.10PING 172.18.0.10 (172.18.0.10) 56(84) bytes of data.From 213.51.1.123 icmp_seq=1 Destination Net UnreachableFrom 213.51.1.123 icmp_seq=2 Destination Net Unreachable--- 172.18.0.10 ping statistics ---2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 3ms$ ping -c 2 172.18.0.20PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.From 213.51.1.123 icmp_seq=1 Destination Net UnreachableFrom 213.51.1.123 icmp_seq=2 Destination Net Unreachable--- 172.18.0.20 ping statistics ---2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 3ms

Чтобы установить связь между корневым пространством имён и пространством имён контейнера, нам нужно назначить IP-адрес сетевому интерфейсу моста:

$ sudo ip addr add 172.18.0.1/16 dev br0

Теперь после того, как мы назначили IP-адрес интерфейсу моста, мы получили маршрут в таблице маршрутизации хоста:

$ ip route# ... omitted lines ...172.18.0.0/16 dev br0 proto kernel scope link src 172.18.0.1$ ping -c 2 172.18.0.10PING 172.18.0.10 (172.18.0.10) 56(84) bytes of data.64 bytes from 172.18.0.10: icmp_seq=1 ttl=64 time=0.036 ms64 bytes from 172.18.0.10: icmp_seq=2 ttl=64 time=0.049 ms--- 172.18.0.10 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 11msrtt min/avg/max/mdev = 0.036/0.042/0.049/0.009 ms$ ping -c 2 172.18.0.20PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.64 bytes from 172.18.0.20: icmp_seq=1 ttl=64 time=0.059 ms64 bytes from 172.18.0.20: icmp_seq=2 ttl=64 time=0.056 ms--- 172.18.0.20 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 4msrtt min/avg/max/mdev = 0.056/0.057/0.059/0.007 ms

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

$ sudo nsenter --net=/var/run/netns/netns0$ ip route add default via 172.18.0.1$ ping -c 2 10.0.2.15PING 10.0.2.15 (10.0.2.15) 56(84) bytes of data.64 bytes from 10.0.2.15: icmp_seq=1 ttl=64 time=0.036 ms64 bytes from 10.0.2.15: icmp_seq=2 ttl=64 time=0.053 ms--- 10.0.2.15 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 14msrtt min/avg/max/mdev = 0.036/0.044/0.053/0.010 ms# And repeat the change for netns1

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

Отлично, нам удалось добиться сетевой связности контейнеров с корневым пространством имён. Теперь давайте попробуем подключить их к внешнему миру. По умолчанию переадресация пакетов (ip packet forwarding), то есть функциональность маршрутизатора в Linux отключена. Нам нужно её включить

# In the root namespacesudo bash -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'

Теперь самое интересное - проверка подключения:

$ sudo nsenter --net=/var/run/netns/netns0$ ping 8.8.8.8# hangs indefinitely long for me...

Всё равно не работает. Мы что-то упустили? Если бы контейнер отправлял пакеты во внешний мир, сервер-получатель не смог бы отправлять пакеты обратно в контейнер, потому что IP-адрес контейнера является частным и правила маршрутизации для этого конкретного IP-адреса известны только в локальной сети. К тому же многие контейнеры в мире имеют один и тот же частный IP-адрес 172.18.0.10. Решение этой проблемы называется преобразованием сетевых адресов (NAT). Принцип работы, следующий - перед отправкой во внешнюю сеть пакеты, отправленные контейнерами, заменяют свои исходные IP-адреса (source IP addesses) на адрес внешнего интерфейса хоста. Хост также будет отслеживать все существующие сопоставления (mapping) и по прибытии будет восстанавливать IP-адреса перед пересылкой пакетов обратно в контейнеры. Звучит сложно, но у меня для вас хорошие новости! Нам нужна всего одна команда, чтобы добиться требуемого результата:

$ sudo iptables -t nat -A POSTROUTING -s 172.18.0.0/16 ! -o br0 -j MASQUERADE

Команда довольно проста. Мы добавляем новое правило в таблицу nat цепочки POSTROUTING с просьбой выполнить MASQUERADE всех исходящих пакетов из сети 172.18.0.0/16, но не через интерфейс моста.

Проверьте подключение:

$ sudo nsenter --net=/var/run/netns/netns0$ ping -c 2 8.8.8.8PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.64 bytes from 8.8.8.8: icmp_seq=1 ttl=61 time=43.2 ms64 bytes from 8.8.8.8: icmp_seq=2 ttl=61 time=36.8 ms--- 8.8.8.8 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 2msrtt min/avg/max/mdev = 36.815/40.008/43.202/3.199 ms
$ sudo nsenter --net=/var/run/netns/netns0$ ping -c 2 8.8.8.8PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.64 bytes from 8.8.8.8: icmp_seq=1 ttl=61 time=43.2 ms64 bytes from 8.8.8.8: icmp_seq=2 ttl=61 time=36.8 ms--- 8.8.8.8 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 2msrtt min/avg/max/mdev = 36.815/40.008/43.202/3.199 ms

Помните, что политика iptables по умолчанию - ACCEPT для каждой цепочки, она может быть довольно опасной в реальных условиях:

sudo iptables -S-P INPUT ACCEPT-P FORWARD ACCEPT-P OUTPUT ACCEPT

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

$ sudo iptables -t filter --list-rules-P INPUT ACCEPT-P FORWARD DROP-P OUTPUT ACCEPT-N DOCKER-N DOCKER-ISOLATION-STAGE-1-N DOCKER-ISOLATION-STAGE-2-N DOCKER-USER-A FORWARD -j DOCKER-USER-A FORWARD -j DOCKER-ISOLATION-STAGE-1-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT-A FORWARD -o docker0 -j DOCKER-A FORWARD -i docker0 ! -o docker0 -j ACCEPT-A FORWARD -i docker0 -o docker0 -j ACCEPT-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 5000 -j ACCEPT-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2-A DOCKER-ISOLATION-STAGE-1 -j RETURN-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP-A DOCKER-ISOLATION-STAGE-2 -j RETURN-A DOCKER-USER -j RETURN$ sudo iptables -t nat --list-rules-P PREROUTING ACCEPT-P INPUT ACCEPT-P POSTROUTING ACCEPT-P OUTPUT ACCEPT-N DOCKER-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 5000 -j MASQUERADE-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER-A DOCKER -i docker0 -j RETURN-A DOCKER ! -i docker0 -p tcp -m tcp --dport 5005 -j DNAT --to-destination 172.17.0.2:5000$ sudo iptables -t mangle --list-rules-P PREROUTING ACCEPT-P INPUT ACCEPT-P FORWARD ACCEPT-P OUTPUT ACCEPT-P POSTROUTING ACCEPT$ sudo iptables -t raw --list-rules-P PREROUTING ACCEPT-P OUTPUT ACCEPT

Настроим сетевой доступ из внешнего мира в контейнеры (port publishing)

Публикация портов контейнеров для некоторых (или всех) интерфейсов хоста - популярная практика. Но что на самом деле означает публикация порта?

Представьте, что у нас есть сервис, работающий внутри контейнера:

$ sudo nsenter --net=/var/run/netns/netns0$ python3 -m http.server --bind 172.18.0.10 5000

Если мы попытаемся отправить HTTP-запрос этому сервису с хоста, все будет работать (ну, есть связь между корневым пространством имён и всеми интерфейсами контейнера, почему бы и нет?):

# From root namespace$ curl 172.18.0.10:5000<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"># ... omitted lines ...

Однако, если бы мы получили доступ к этому серверу из внешнего мира, какой IP-адрес мы бы использовали? Единственный IP-адрес, который мы можем знать, - это адрес внешнего интерфейса хоста eth0:

$ curl 10.0.2.15:5000curl: (7) Failed to connect to 10.0.2.15 port 5000: Connection refused

Таким образом, нам нужно найти способ перенаправить все пакеты, поступающие на порт 5000 интерфейса eth0 хоста, на адрес172.18.0.10:5000. Или, другими словами, нам нужно опубликовать порт 5000 контейнера на интерфейсе eth0 хоста.

# External trafficsudo iptables -t nat -A PREROUTING -d 10.0.2.15 -p tcp -m tcp --dport 5000 -j DNAT --to-destination 172.18.0.10:5000# Local traffic (since it doesn't pass the PREROUTING chain)sudo iptables -t nat -A OUTPUT -d 10.0.2.15 -p tcp -m tcp --dport 5000 -j DNAT --to-destination 172.18.0.10:5000

Кроме того, нам нужно включить iptables intercepting traffic over bridged networks (перехватывать трафик bridged networks):

sudo modprobe br_netfilter

Время проверить!

curl 10.0.2.15:5000<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"># ... omitted lines ...

Разбираемся в работе Docker network drivers

Но что же вам сделать теперь со всеми этими бесполезными знаниями? Например, мы могли бы попытаться разобраться в некоторых сетевых режимах Docker!

Начнем с режима --network host. Попробуйте сравнить вывод следующих команд ip link и sudo docker run -it --rm --network host alpine ip link. Сюрприз, они совпадут! Таким образом host mode Docker просто не использует изоляцию сетевого пространства имён и контейнеры работают в корневом сетевом пространстве имён и совместно используют сетевой стек с хост-системой.

Следующий режим, который нужно проверить, - это --network none. Вывод команды sudo docker run -it --rm --network none alpine ip link показывает только один сетевой интерфейс обратной loopback. Это очень похоже на наши наблюдения за только что созданным сетевым пространством имен. То есть до того момента, когда мы добавляли какие-либо veth устройства.

И последнее, но не менее важное: режим --network bridge (по умолчанию), это именно то, что мы пытались воспроизвести в этой статье.

Сети и rootless контейнеры

Одной из приятных особенностей диспетчера контейнеров podman является его ориентация на rootless контейнеры. Однако, как вы, вероятно, заметили, в этой статье мы использовали много эскалаций sudo и без root-прав настроить сеть невозможно. При настройке сетей rootful контейнеров Podman очень близок к Docker. Но когда дело доходит до rootless контейнеров, Podman полагается на проект slirp4netns:

Начиная с Linux 3.8, непривилегированные пользователи могут создавать network_namespaces (7) вместе с user_namespaces (7). Однако непривилегированные сетевые пространства имен оказались не очень полезными, потому что для создания пар veth (4) в пространствах имен хоста и сети по-прежнему требуются привилегии root (иначе доступ в Интернету будет отсутствовать).

slirp4netns позволяет получить доступ из сетевое пространства имен в Интернет непривилегированным пользователям, подключая устройство TAP в сетевом пространстве имен к стеку TCP/IP usermode (slirp).

Сеть rootless контейнера весьма ограничена: технически сам контейнер не имеет IP-адреса, потому что без привилегий root невозможно настроить сетевое устройство. Более того, проверка связи (ping) из rootless контейнера не работает, поскольку в нем отсутствует функция безопасности CAP_NET_RAW, которая необходима для работы команды ping.

Заключение

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

Подробнее..

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

15.06.2021 10:13:02 | Автор: admin

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

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

Prometheus одно из самых популярных решений для мониторинга кластеров Kubernetes. Поэтому каждый шаг в этом руководстве содержит примеры запросов PromQL.

Обнаружение контейнеров без ограничения ресурсов

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

Отсутствие лимитов в контейнере может оказать негативное воздействие на узлы кластера. В лучшем случае узлы начнут выселять pods с учетом QoS. При этом также возникнут проблемы с производительностью из-за троттлинга процессора. Но в худшем случае узел может перестать работать из-за нехватки ресурсов.

Контейнеры без CPU Limit в каждом namespace

sum by (namespace)(count by (namespace,pod,container)(kube_pod_container_info{container!=""}) unless sum by (namespace,pod,container)(kube_pod_container_resource_limits{resource="cpu"}))

Контейнеры без Memory Limit в каждом namespace

sum by (namespace)(count by (namespace,pod,container)(kube_pod_container_info{container!=""}) unless sum by (namespace,pod,container)(kube_pod_container_resource_limits{resource="memory"}))

Допустим, вы нашли несколько контейнеров без ограничений ресурсов. Как определить потенциально опасные? Легко! Нужно найти контейнеры, которые используют больше всего ресурсов и при этом не имеют лимитов.

Топ-10 контейнеров без CPU Limits, потребляющих больше всего ресурсов CPU

topk(10,sum by (namespace,pod,container)(rate(container_cpu_usage_seconds_total{container!=""}[5m])) unless sum by (namespace,pod,container)(kube_pod_container_resource_limits{resource="cpu"}))

Топ-10 контейнеров без Memory Limits, потребляющих больше памяти

topk(10,sum by (namespace,pod,container)(container_memory_usage_bytes{container!=""}) unless sum by (namespace,pod,container)(kube_pod_container_resource_limits{resource="memory"}))

Обнаружение контейнеров со слишком строгими ограничениями

Обнаружение контейнеров со слишком строгими CPU Limits

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

Выполните этот запрос, чтобы найти контейнеры, для которых использование ЦП близко к пределу:

(sum by (namespace,pod,container)(rate(container_cpu_usage_seconds_total{container!=""}[5m])) / sum by (namespace,pod,container)(kube_pod_container_resource_limits{resource="cpu"})) > 0.8

Обнаружение контейнеров со слишком строгими Memory Limits

Если контейнер превысит лимит по памяти, он будет убит.

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

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

(sum by (namespace,pod,container)(container_memory_usage_bytes{container!=""}) / sum by (namespace,pod,container)(kube_pod_container_resource_limits{resource="memory"})) > 0.8

Как выбрать оптимальные значения для лимитов?

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

Консервативная

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

Находим оптимальный CPU Limit с помощью консервативной стратегии:

max by (namespace,owner_name,container)((rate(container_cpu_usage_seconds_total{container!="POD",container!=""}[5m])) * on(namespace,pod) group_left(owner_name) avg by (namespace,pod,owner_name)(kube_pod_owner{owner_kind=~"DaemonSet|StatefulSet|Deployment"}))

Находим оптимальный Memory Limit с помощью консервативной стратегии:

max by (namespace,owner_name,container)((container_memory_usage_bytes{container!="POD",container!=""}) * on(namespace,pod) group_left(owner_name) avg by (namespace,pod,owner_name)(kube_pod_owner{owner_kind=~"DaemonSet|StatefulSet|Deployment"}))

Агрессивная

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

Находим оптимальный CPU Limit с помощью агрессивной стратегии:

quantile by (namespace,owner_name,container)(0.99,(rate(container_cpu_usage_seconds_total{container!="POD",container!=""}[5m])) * on(namespace,pod) group_left(owner_name) avg by (namespace,pod,owner_name)(kube_pod_owner{owner_kind=~"DaemonSet|StatefulSet|Deployment"}))

Находим оптимальный Memory Limit с помощью агрессивной стратегии:

quantile by (namespace,owner_name,container)(0.99,(container_memory_usage_bytes{container!="POD",container!=""}) * on(namespace,pod) group_left(owner_name) avg by (namespace,pod,owner_name)(kube_pod_owner{owner_kind=~"DaemonSet|StatefulSet|Deployment"}))

Достаточно ли ресурсов в вашем кластере?

Узлы кластера гарантируют, что запланированные в них pods будут иметь достаточно ресурсов на основе параметра Requests контейнера каждого pods. К тому же, узлы резервируют за каждым контейнером указанный объем памяти и количество ядер ЦП.

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

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

Как обнаружить превышение доступных ресурсов кластера?

Процент превышения доступных ресурсов по памяти:

100 * sum(kube_pod_container_resource_limits{container!="",resource="memory"} ) / sum(kube_node_status_capacity_memory_bytes)

Процент превышения доступных ресурсов по ЦП:

100 * sum(kube_pod_container_resource_limits{container!="",resource="cpu"} ) / sum(kube_node_status_capacity_cpu_cores)

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

Лучшим решением будет выбрать консервативную стратегию, гарантирующую, что избыточное использование составляет менее 125%, или агрессивную стратегию, которая позволяет лимитам достичь 150% емкости вашего кластера.

Не менее важным будет проверка соответствия лимитов емкости каждого узла. Например, у нас есть контейнер с CPU Requests - 2 и CPU Limit - 8. Этот контейнер можно запланировать на узле с 4 ядрами, но лимиты не дадут нужного эффекта, потому что на узле недостаточно ядер.

Процент превышения доступных ресурсов узла по памяти:

sum by (node)(kube_pod_container_resource_limits{container!=,resource=memory} ) / sum by (node)(kube_node_status_capacity_memory_bytes)

Процент превышения доступных ресурсов узла по ЦП:

sum by (node)(kube_pod_container_resource_limits{container!=,resource=cpu} ) / sum by (node)(kube_node_status_capacity_cpu_cores)

Подведем итоги

В этой статье вы узнали, почему так важно правильно использовать Kubernetes Limits and Requests, как определять наличие неоптимальных параметров в вашем кластере и какие стратегии вы могли бы использовать, чтобы установить правильные ограничения ресурсов Kubernetes.

Если вы хотите узнать больше, рекомендуем прочитать статью рациональное использовании ресурсов в Kubernetes.

Подробнее..

Перевод Использование Google Protocol Buffers (protobuf) в Java

25.02.2021 20:10:38 | Автор: admin

Привет, хабровчане. В рамках курса "Java Developer. Professional" подготовили для вас перевод полезного материала.

Также приглашаем посетить открытый вебинар на тему gRPC для микросервисов или не REST-ом единым.


Недавно вышло третье издание книги "Effective Java" (Java: эффективное программирование), и мне было интересно, что появилось нового в этой классической книге по Java, так как предыдущее издание охватывало только Java 6. Очевидно, что появились совершенно новые разделы, связанные с Java 7, Java 8 и Java 9, такие как глава 7 "Lambdas and Streams" (Лямбда-выражения и потоки), раздел 9 "Prefer try-with-resources to try-finally" (в русском издании 2.9. Предпочитайте try-с-ресурсами использованию try-finally) и раздел 55 "Return optionals judiciously" (в русском издании 8.7. Возвращайте Optional с осторожностью). Но я был слегка удивлен, когда обнаружил новый раздел, не связанный с нововведениями в Java, а обусловленный изменениями в мире разработки программного обеспечения. Именно этот раздел 85 "Prefer alternatives to Java Serialization" (в русском издании 12.1 Предпочитайте альтернативы сериализации Java) и побудил меня написать данную статью об использовании Google Protocol Buffers в Java.

В разделе 85 "Prefer alternatives to Java Serialization" (12.1 Предпочитайте альтернативы сериализации Java) Джошуа Блох (Josh Bloch) выделяет жирным шрифтом следующие два утверждения, связанные с сериализацией в Java:

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

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

После описания в общих чертах проблем с десериализацией в Java и, сделав эти смелые заявления, Блох рекомендует использовать то, что он называет кроссплатформенным представлением структурированных данных (чтобы избежать путаницы, связанной с термином сериализация при обсуждении Java). Блох говорит, что основными решениями здесь являются JSON (JavaScript Object Notation) и Protocol Buffers (protobuf). Мне показалось интересным упоминание о Protocol Buffers, так как в последнее время я немного читал о них и игрался с ними. В интернете есть довольно много материалов по использованию JSON (даже в Java), в то время как осведомленность о Protocol Buffers среди java-разработчиков гораздо меньше. Поэтому я думаю, что статья об использовании Protocol Buffers в Java будет полезной.

На странице проекта Google Protocol Buffers описывается как не зависящий от языка и платформы расширяемый механизм для сериализации структурированных данных. Также там есть пояснение: Как XML, но меньше, быстрее и проще. И хотя одним из преимуществ Protocol Buffers является поддержка различных языков программирования, в этой статье речь пойдет исключительно про использование Protocol Buffers в Java.

Есть несколько полезных онлайн-ресурсов, связанных с Protocol Buffers, включая главную страницу проекта, страницу проекта protobuf на GitHub, proto3 Language Guide (также доступен proto2 Language Guide), туториал Protocol Buffer Basics: Java, руководство Java Generated Code Guide, API-документация Java API (Javadoc) Documentation, страница релизов Protocol Buffers и страница Maven-репозитория. Примеры в этой статье основаны на Protocol Buffers 3.5.1.

Использование Protocol Buffers в Java описано в туториале "Protocol Buffer Basics: Java". В нем рассматривается гораздо больше возможностей и вещей, которые необходимо учитывать в Java, по сравнению с тем, что я расскажу здесь. Первым шагом является определение формата Protocol Buffers, не зависящего от языка программирования. Он описывается в текстовом файле с расширением .proto. Для примера опишем формат протокола в файле album.proto, который показан в следующем листинге кода.

album.proto

syntax = "proto3";option java_outer_classname = "AlbumProtos";option java_package = "dustin.examples.protobuf";message Album {    string title = 1;    repeated string artist = 2;    int32 release_year = 3;    repeated string song_title = 4;}

Несмотря на простоту приведенного выше определения формата протокола, в нем присутствует довольно много информации. В первой строке явно указано, что используется proto3 вместо proto2, используемого по умолчанию, если явно ничего не указано. Две строки, начинающиеся с option, указывают параметры генерации Java-кода (имя генерируемого класса и пакет этого класса) и они нужны только при использовании Java.

Ключевое слово "message" определяет структуру "Album", которую мы хотим представить. В ней есть четыре поля, три из которых строки (string), а одно целое число (int32). Два из них могут присутствовать в сообщении более одного раза, так как для них указано зарезервированное слово repeated. Обратите внимание, что формат сообщения определяется независимо от Java за исключением двух option, которые определяют детали генерации Java-классов по данной спецификации.

Файл album.proto, приведенный выше, теперь необходимо скомпилировать в файл исходного класса Java (AlbumProtos.java в пакете dustin.examples.protobuf), который можно использовать для записи и чтения бинарного формата Protocol Buffers. Генерация файла исходного кода Java выполняется с помощью компилятора protoc, соответствующего вашей операционной системе. Я запускаю этот пример в Windows 10, поэтому я скачал и распаковал файл protoc-3.5.1-win32.zip. На изображении ниже показан мой запущенный protoc для album.proto с помощью команды protoc --proto_path=src --java_out=dist\generated album.proto

Перед запуском вышеуказанной команды я поместил файл album.proto в каталог src, на который указывает --proto_path, и создал пустой каталог build\generated для размещения сгенерированного исходного кода Java, что указано в параметре --java_out.

Сгенерированный Java-класс AlbumProtos.java содержит более 1000 строк, и я не буду приводить его здесь, он доступен на GitHub. Среди нескольких интересных моментов относительно сгенерированного кода я хотел бы отметить отсутствие выражений import (вместо них используются полные имена классов с пакетами). Более подробная информация об исходном коде Java, сгенерированном protoc, доступна в руководстве Java Generated Code. Важно отметить, что данный сгенерированный класс AlbumProtos пока никак не связан с моим Java-приложением, и сгенерирован исключительно из текстового файла album.proto, приведенного ранее.

Теперь исходный Java-код AlbumProtos надо добавить в вашем IDE в перечень исходного кода проекта. Или его можно использовать как библиотеку, скомпилировав в .class или .jar.

Прежде чем двигаться дальше, нам понадобится простой Java-класс для демонстрации Protocol Buffers. Для этого я буду использовать класс Album, который приведен ниже (код на GitHub).

Album.java

package dustin.examples.protobuf;import java.util.ArrayList;import java.util.List;/** * Music album. */public class Album {    private final String title;    private final List < String > artists;    private final int releaseYear;    private final List < String > songsTitles;    private Album(final String newTitle, final List < String > newArtists,        final int newYear, final List < String > newSongsTitles) {        title = newTitle;        artists = newArtists;        releaseYear = newYear;        songsTitles = newSongsTitles;    }    public String getTitle() {        return title;    }    public List < String > getArtists() {        return artists;    }    public int getReleaseYear() {        return releaseYear;    }    public List < String > getSongsTitles() {        return songsTitles;    }    @Override    public String toString() {        return "'" + title + "' (" + releaseYear + ") by " + artists + " features songs " + songsTitles;    }    /**     * Builder class for instantiating an instance of     * enclosing Album class.     */    public static class Builder {        private String title;        private ArrayList < String > artists = new ArrayList < > ();        private int releaseYear;        private ArrayList < String > songsTitles = new ArrayList < > ();        public Builder(final String newTitle, final int newReleaseYear) {            title = newTitle;            releaseYear = newReleaseYear;        }        public Builder songTitle(final String newSongTitle) {            songsTitles.add(newSongTitle);            return this;        }        public Builder songsTitles(final List < String > newSongsTitles) {            songsTitles.addAll(newSongsTitles);            return this;        }        public Builder artist(final String newArtist) {            artists.add(newArtist);            return this;        }        public Builder artists(final List < String > newArtists) {            artists.addAll(newArtists);            return this;        }        public Album build() {            return new Album(title, artists, releaseYear, songsTitles);        }    }}

Теперь у нас есть data-класс Album, Protocol Buffers-класс, представляющий этот Album (AlbumProtos.java) и мы готовы написать Java-приложение для "сериализации" информации об Album без использования Java-сериализации. Код приложения находится в классе AlbumDemo, полный код которого доступен на GitHub.

Создадим экземпляр Album с помощью следующего кода:

/** * Generates instance of Album to be used in demonstration. * * @return Instance of Album to be used in demonstration. */public Album generateAlbum(){   return new Album.Builder("Songs from the Big Chair", 1985)      .artist("Tears For Fears")      .songTitle("Shout")      .songTitle("The Working Hour")      .songTitle("Everybody Wants to Rule the World")      .songTitle("Mothers Talk")      .songTitle("I Believe")      .songTitle("Broken")      .songTitle("Head Over Heels")      .songTitle("Listen")      .build();}

Класс AlbumProtos, сгенерированныйProtocol Buffers, включает в себя вложенный класс AlbumProtos.Album, который используется для бинарной сериализации Album. Следующий листинг демонстрирует, как это делается.

final Album album = instance.generateAlbum();final AlbumProtos.Album albumMessage    = AlbumProtos.Album.newBuilder()        .setTitle(album.getTitle())        .addAllArtist(album.getArtists())        .setReleaseYear(album.getReleaseYear())        .addAllSongTitle(album.getSongsTitles())        .build();

Как видно из предыдущего примера, для заполнения иммутабельного экземпляра класса, сгенерированного Protocol Buffers, используется паттерн Строитель (Builder). Через ссылку экземпляр этого класса теперь можно легко преобразовать объект в бинарный вид Protocol Buffers, используя метод toByteArray(), как показано в следующем листинге:

final byte[] binaryAlbum = albumMessage.toByteArray();

Чтение массива byte[] обратно в экземпляр Album может быть выполнено следующим образом:

/** * Generates an instance of Album based on the provided * bytes array. * * @param binaryAlbum Bytes array that should represent an *    AlbumProtos.Album based on Google Protocol Buffers *    binary format. * @return Instance of Album based on the provided binary form *    of an Album; may be {@code null} if an error is encountered *    while trying to process the provided binary data. */public Album instantiateAlbumFromBinary(final byte[] binaryAlbum) {    Album album = null;    try {        final AlbumProtos.Album copiedAlbumProtos = AlbumProtos.Album.parseFrom(binaryAlbum);        final List <String> copiedArtists = copiedAlbumProtos.getArtistList();        final List <String> copiedSongsTitles = copiedAlbumProtos.getSongTitleList();        album = new Album.Builder(                copiedAlbumProtos.getTitle(), copiedAlbumProtos.getReleaseYear())            .artists(copiedArtists)            .songsTitles(copiedSongsTitles)            .build();    } catch (InvalidProtocolBufferException ipbe) {        out.println("ERROR: Unable to instantiate AlbumProtos.Album instance from provided binary data - " +            ipbe);    }    return album;}

Как вы заметили, при вызове статического метода parseFrom(byte []) может быть брошено проверяемое исключение InvalidProtocolBufferException. Для получения десериализованного экземпляра сгенерированного класса, по сути, нужна только одна строка, а остальной код это создание исходного класса Album из полученных данных.

Демонстрационный класс включает в себя две строки, которые выводят содержимое исходного экземпляра Album и экземпляра, полученного из бинарного представления. В них есть вызов метода System.identityHashCode() на обоих экземплярах, чтобы показать, что это разные объекты даже при совпадении их содержимого. Если этот код выполнить с примером Album, приведенным выше, то результат будет следующим:

BEFORE Album (1323165413): 'Songs from the Big Chair' (1985) by [Tears For Fears] features songs [Shout, The Working Hour, Everybody Wants to Rule the World, Mothers Talk, I Believe, Broken, Head Over Heels, Listen]AFTER Album (1880587981): 'Songs from the Big Chair' (1985) by [Tears For Fears] features songs [Shout, The Working Hour, Everybody Wants to Rule the World, Mothers Talk, I Believe, Broken, Head Over Heels, Listen]

Здесь мы видим, что в обоих экземплярах соответствующие поля одинаковы и эти два экземпляра действительно разные. При использовании Protocol Buffers, действительно, нужно сделать немного больше работы, чем припочти автоматическом механизме сериализации Java, когда надо просто наследоваться от интерфейса Serializable, но есть важные преимущества, которые оправдывают затраты. В третьем издании книги Effective Java (Java: эффективное программирование) Джошуа Блох обсуждает уязвимости безопасности, связанные со стандартной десериализацией в Java, и утверждает, что Нет никаких оснований для использования сериализации Java в любой новой системе, которую вы пишете.


Узнать подробнее о курсе "Java Developer. Professional".

Смотреть открытый вебинар на тему gRPC для микросервисов или не REST-ом единым.

Подробнее..

Стики и работа с Event System в Unity 3D

10.02.2021 22:13:33 | Автор: admin

Учебные материалы для школы программирования. Часть12

Предыдущие уроки можно найти здесь:

Этот материал состоит из двух частей:

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

Познакомимся с использованием Event System в разрезе работы с UI и реализации пользовательской обработки реакции на указатель мыши/тачпада.

Далее, перейдем ко второй, где создадим скрипт, реализующий доступ к другим объектам посредством Event System.

По ходу дела, попробуем свои силы в работе со static-переменными для реализации удобной имплементации модулей в проект, и узнаем о глобальных и локальных координатах RectTransform.

Обе части занятия являют собой продолжение работы над проектом "Жидкий персонаж".

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

Порядок выполнения

Создадим новую панель со следующими параметрами:

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

Внутри панели создадим 2 Image согласно иерархии на скриншотах - Joy и Mushroom Joy тело нашего стика, Mushroom его грибок.

Их параметры:

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

Создадим скрипт. Скрипт необходимо закинуть на панель. Его полный листинг выглядит так (если в таком формате совсем неудобно, пишите в комментах - перенаберу исходный код):

Разберём его подробнее
Для начала подключим пространство имён для обработки событий:

using UnityEngine.EventSystems;

За обработку нажатий отвечают методы OnPointerDown и OnPointerUp. Для их работы необходимы следующие интерфейсы: IpointerDownHandler и IpointerUpHandler.

Чтобы работать с информацией о конкретном нажатии (а в случае мультитача данных нажатий может быть несколько) объявляем поле private PointerEventData eventData;

При нажатии на экран вызывается OnPointerDown и складывает информацию о нажатии в eventData.

В дальнейшем это позволяет нам работать с eventData из метода Update().

Для того, чтобы понимать, актульна ли информация о нажатии, введена булева переменная OnScreen. Если мы нажали на экран, то переменная принимает значение true, объект Joy становится в точку нажатия и объекты Joy и Mushroom становятся видимыми.

Метод OnPointerUp отключает видимость Joy и Mushroom и переводит переменную OnScreenв false.

Остальная обработка возникает в Update().
Там мы выставляем Mushroom по глобальной точке нажатия и меряем её локальные координаты.

Принцип такой: нажали, грибок и джой выставились в току нажатия.
Передвинули палец/указатель и грибок сместился относительно джоя. Это смещение мы и берём из локальных координат. Его и используем как результат.

Теперь, в любом скрипте, который используем методы типа GetAxis строку типа Input.GetAxis("Horizontal")меняем наCustomStick.horizontal

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

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

Хочется напомнить, все материалы рассчитаны на использование в составе проекта с главным героем - желе.

Перейдем ко второй части.

Использование своих типов эвентов через код

Откроем проект Goo (жидкий желеобразный персонаж), созданный ранее. Проект используется как пример, можно использовать любой другой.

Создадим новый скрипт. Его листинг:

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

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

Рассмотрим пару вариантов использования этого скрипта. Вариант первый создание потайной двери-стены, открывающийся ключом. Для этого нам понадобится спрайт стены с обычным коллайдером и спрайт или модель ключа с коллайдером в режиме триггера.
Также можно добавить ещё один пустой объект и закинуть на него звук, создав тем самым AudioSource. Уберём у AudioSource галочку вопроизведения при старте и закинем в него ключ и стены.

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

Это самый простой пример логики. Рассмотрим посложнее.

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

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

Стенку включает триггер, находящийся прямо возле неё. (Тоже изначально выключен)

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

Создадим кнопку. Для этого импортируем приложенное извображение и разрежем на 2 спрайта.

Расположим их в мире в одной точке, зелёный выключим и назовём его "Вкл", Красный назовём "Выкл".

Создадим ещё один пустой объект, закинем на него коллайдер, выставим коллайдеру режим триггера и расположим на кнопке. Настроим следующим образом:

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

Сюда же можно добавить звук нажатия кнопки, закинув его на пустой объект или на сам спрайт зелёной кнопки и оставив галочку Play On Awake.

На этом этапе занятие можно считать завершённым.

Пишите комменты, делитесь полезными ссылками, как можно улучшить проект!

Пожалуйста, поддержите инициативу - нажимайте нравится и поделиться!

Подробнее..

Распознание блоков текста в IOS-приложении с помощью Vision

18.02.2021 14:23:54 | Автор: admin

Работая над приложением, связанным с финансовыми операциями, возникла необходимость распознать и выделить суммы на чеках. Начиная с 13-ой версии в IOS-разработке появился нативный фреймворк Vision, который позволяет распознавать различные объекты на изображениях, без задействования сторонних сервисов.
В данной статье представлен личный опыт разработки приложения, использующего Vision.

Что такое Vision

Из документации Apple: "Vision применяет алгоритмы "компьютерного зрения" для выполнения множества задач с входными изображениями и видео. Фреймворк Vision выполняет распознание лиц, обнаружение текста, распознавание штрих-кодов, регистрацию изображений. Vision также позволяет использовать пользовательские модели CoreML для таких задач, как классификация или обнаружение объектов."
Анализируя документацию Apple, можно предположить, что Vision - это один из этапов подготовки таких продуктов как Apple glasses или шлем смешанной реальности. Забегая вперед, следует подчеркнуть, что данный фреймворк потребляет изрядное количество ресурсов. Обработка статичного изображения может занимать десятки секунд, следовательно, работа с видео в реальном времени будет предельно ресурсоемким процессом, над оптимизацией которого инженерам Apple еще предстоит поработать.
В рамках поставленной задачи, необходимо было решить следующую проблему: распознание блоков текста с помощью Vision.

Разработка

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

//Recognition queuelet textRecognitionWorkQueue = DispatchQueue(label: "TextRecognitionQueue", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem)//Request for text recognitionvar textRecognitionRequest: VNRecognizeTextRequest?
  1. Очередь для задач Vision не вызывает никаких затруднений у разработчиков. Именно в ней будут выполняться все задачи фреймворка.

  2. Объявляется переменная типа VNRecognizeTextRequet. Инициализируется объект из ViewDidLoad (или из init), так как он должен быть активен на протяжении всей жизни ViewController. Этот объект отвечает за работу с Vision, поэтому необходимо разобрать его инициализацию подробнее:

//Set textRecognitionRequest from ViewDidLoadfunc setTextRequest() {    textRecognitionRequest = VNRecognizeTextRequest { request, error in        guard let observations = request.results as? [VNRecognizedTextObservation] else {            return        }        var detectedText = ""        self.textBlocks.removeAll()                    for observation in observations {            guard let topCandidate = observation.topCandidates(1).first else { continue }            detectedText += "\(topCandidate.string)\n"                        //Text block specific for this project            if let recognizedBlock = self.getRecognizedDoubleBlock(topCandidate: topCandidate.string, observationBox: observation.boundingBox) {                self.textBlocks.append(recognizedBlock)            }        }                    DispatchQueue.main.async {            self.textView.text = detectedText            self.removeLoader()            self.drawRecognizedBlocks()        }    }            //Individual recognition request settings    textRecognitionRequest!.minimumTextHeight = 0.011 // Lower = better quality    textRecognitionRequest!.recognitionLevel = .accurate}

Настройки объекта textRecognitionRequest. Описание всех доступных настроек можно найти в документации. Наиболее важным является параметр minimumTextHeight. Именно этот параметр отвечает за сочетание быстродействия и точности распознания текста. Для каждого проекта необходимо найти индивидуальное значение данного параметра, оно зависит от того, какие данные будет обрабатывать приложение.
Так как основной поставленной задачей являлось считывание текста с квитанций, для вычисления значения параметра minimumTextHeight в приложение были добавлены различные типы квитанций в различном состоянии (в том числе и основательно помятые). В результате тестирования было определено значение равное 0.011. В случае распознания текста с квитанций, это значение лучшим образом сочетает в себе быстродействие и точность. Однако нужно отметить, что текст с одного изображения распознается в среднем за пять секунд. Подобной скорости недостаточно для обработки информации в реальном времени и ее следует значительно оптимизировать инженерам Apple.
На основе представленного кода можно сделать вывод, что после операции распознания, объект типа VNRecognizeTextRequet получает блоки текста. Именно с ними и ведется дальнейшая работа, в зависимости от функций приложения. В рассматриваемом примере, каждый распознанный фрагмент текста был внесен в текстовое поле. Так как особенностью задействованного приложения является выделение суммы на квитанции, следовательно, сохранялись только блоки текста, которые можно преобразовать в тип Double. Помимо распознанного текстового значения сохраняются и координаты блока текста на изображении.
Представленный ниже метод отвечает за запуск работы запроса на распознание:

//Call text recognition request handlerfunc recognizeImage(cgImage: CGImage) {    textRecognitionWorkQueue.async {        let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:])        do {            try requestHandler.perform([self.textRecognitionRequest!])        } catch {            DispatchQueue.main.async {                self.removeLoader()                print(error)            }        }    }}

В метод передается объект CGImage, в котором необходимо распознать текст. Вся работа по распознанию ведется в созданной для этого очереди. Создается объект VNImageRequestHandler, в который передается распознаваемый объект CGImage. В блоке do/try/catch запускается работа инициализированного объекта типа VNRecognizeTextRequet.
Описанные выше функции отвечают за распознание текста в приложении. Однако стоит еще остановится на методах, связанных с выделением нужных блоков текста.

func drawRecognizedBlocks() {    guard let image = invoiceImage?.image else  { return }        //transform from documentation    let imageTransform = CGAffineTransform.identity.scaledBy(x: 1, y: -1).translatedBy(x: 0, y: -image.size.height).scaledBy(x: image.size.width, y: image.size.height)            //drawing rects on cgimage    UIGraphicsBeginImageContextWithOptions(image.size, false, 1.0)    let context = UIGraphicsGetCurrentContext()!    image.draw(in: CGRect(origin: .zero, size: image.size))    context.setStrokeColor(CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1))    context.setLineWidth(4)        for index in 0 ..< textBlocks.count {        let optimizedRect = textBlocks[index].recognizedRect.applying(imageTransform)        context.addRect(optimizedRect)        textBlocks[index].imageRect = optimizedRect    }    context.strokePath()            let result = UIGraphicsGetImageFromCurrentImageContext()    UIGraphicsEndImageContext()    invoiceImage?.image = result}

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

struct RecognizedTextBlock {    let doubleValue: Double    let recognizedRect: CGRect    var imageRect: CGRect = .zero}

При распознании блоков текста фреймворк Vision вычисляет ряд важных параметров в объекте VNRecognizedTextObservation. Для нужд рассматриваемого проекта необходимо было получить только значение типа Double и его координаты на изображении, сохраняемые в константе recognizedRect.
Для выделения блока текста на изображении, следует применить трансформацию к координатам из константы recognizedRect. Полученные координаты так же сохраняются в объекте RecognizedTextBlock в переменной imageRect, необходимой для обработки нажатий на выделенные блоки текста.
После сохранения точных координат выделяемых блоков на изображении, обработку нажатий на выделенные области можно осуществить несколькими способами:

  • Добавить необходимое количество невидимых кнопок на изображение, при помощи трансформации сохраненного объекта imageRect;

  • При каждом нажатии на изображение проверять массив блоков текста и искать совпадение координат нажатия с сохраненным объектом imageRect и др.

Чтобы не перегружать ViewController дополнительными элементами, был использован второй способ.

//UIImageView tap listener@objc func onImageViewTap(sender: UITapGestureRecognizer) {    guard let invoiceImage = invoiceImage, let image = invoiceImage.image else {        return    }            //get tap coordinates on image    let tapX = sender.location(in: invoiceImage).x    let tapY = sender.location(in: invoiceImage).y    let xRatio = image.size.width / invoiceImage.bounds.width    let yRatio = image.size.height / invoiceImage.bounds.height    let imageXPoint = tapX * xRatio    let imageYPoint = tapY * yRatio    //detecting if one of text blocks tapped    for block in textBlocks {        if block.imageRect.contains(CGPoint(x: imageXPoint, y: imageYPoint)) {            showTapAlert(doubleValue: block.doubleValue)            break        }    }}

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

Выводы

Представленная статья позволяет разработчикам IOS-приложений ознакомиться с фреймворком Vision, прежде всего с его функцией распознания текста. Тестовое приложение, полученное в результате работы с распознанием текста в Vision, является ключом к пониманию работы с такими возможностями фреймворка как распознание лиц, текста, штрих-кодов и др.
Cкриншоты полученного приложения для распознавания цифровых значений на различных изображениях представлены ниже:

Приложение распознающее блоки текста с помощью VisionПриложение распознающее блоки текста с помощью Vision

Для ознакомления проект можно скачать из репозитория.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru