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

Mongodb

Инструменты Node.js разработчика. Какие ODM нам нужны

18.10.2020 22:23:47 | Автор: admin

ODM - Object Document Mapper - используется преимущественно для доступа к документоориенриирвоанным базам данных, к которым относятся MongoDB, CouchDB, ArangoDB, OrientDB (последние две базы данных гибридные) и некоторые другие.

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

Таблица

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

Пакет (npm)

Количество скачиваний в неделю

База данных

pg

1 660 369

PostgreSQL

mysql

713 548

MySQL

mongodb (из них 1 034 051 вместе с mongoose)

1 974 992

MongoDB

mongoose

1 034 051

MongoDB

nano

60 793

CouchDB

PouchDB

25 707

CouchDB

arangojs

7 625

ArangoDB

orientjs

598

OrientDB

Из таблицы можно сделать очень интересные выводы:

1) Использование MongoDB в проектах на nodejs почти сравнялось с использованием реляционных баз данных, и превысило использование одной отдельно взятой реляционной базы данных PostgreSQL или MySQL;

2) В половине проектов для доступа к MongoDB используется библиотека mongoose (ODM);

3) В половине проектов для доступа к MongoDB не используется библиотека mongoose. А это означает, что не используется сторонняя ODM, так как другой популярной библиотеки ODM для MongoDB нет.

4) Такие базы данных как ArangoDB, OrtintDB существенно менее популярны, чем MongoDB, и даже CoucbDB, и не имеют общепризнанных ODM.

Сначала остановлюсь на мотивации использования документоориентированных баз данных в проектах на Node.js. Отставим в сторону маркетинговую составляющую, хотя ее доля возможно более 50% из общего количества в приведенной статистике. Будем исходить что это все же кому-то действительно нужно, по-настоящему.

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

1) Отсутствие жесткой схемы. Сейчас это уже не аргумент. Так как MySQL и PostgrSQL обзавелись типом данных json, который может то же самое и даже больше (например остается возможность запросов с JOIN). Но даже не это главное. Как показывает статистика, половина проектов на MongoDB использует mongoose, в которой задается жесткая схема документа в коллекции.

2) Встроенные документы. Сейчас тоже не аргумент. MySQL и PostgrSQL имеют тип данных json, которые может то же самое. Надо отдать должное, что внедрение этого типа данных было ускорено развитием NoSQL.

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

От чего придется отказаться, отказавшись от использования реляционных баз данных: как известно, ACID и JOIN. (Хотя тут есть варианты)

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

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

CouchDB, также как и реляционные базы данных, испытала влияние MongoDB, и в одной из новых версий получила более удобный язык запросов mango, который похож на язык запросов MongoDB.

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

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

В плане репликации и шардирования ArangoDB напоминает CouchDB. И даже гарантирует ACID в Еnterprise версии продукта.

JOIN в ArangoDB также не является проблемой. Вот так будет выглядеть классический запрос MANY-TO-MANY:

 FOR a IN author      FOR ba IN bookauthor      FILTER a._id == ba.author        FOR b IN book        FILTER b._id == ba.book        SORT a.name, b.title    RETURN { author: a, book: b }

Это не выполняемый код на JavaScript с полным перебором всех записей (как может показаться на первый взгляд). Это такой очень оригинальный язык запросов, с синтаксисом JavaScript который является аналогом SQL и называется AQL. Подробности можно узнать в статье на Хабре.

ArangoDB также позволяет публиковать REST сервисы прямо в базе данных (Foxx) и делать поисковые запросы, включая нечеткий поиск (fuzzy search), что позволяет строить поиск без Elasticsearch (Elasticsearch конечно более мощный инструмент, но проблему составляет синхронизация данных в основной базе данных и в Elasticsearch).

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

Самое существенное, что есть в решении - это понимание того функционала, который должна решать ODM. В этом смысле я хочу сослаться на проект universal-router (см. сообщение на Хабре), который из всего многообразия функций, к которым мы уже успели привыкнуть в роутерах React.js или Vue.js, выделили главный функционал на котором построили свое решение.

Если говорить об ODM, таким функционалом, без которого не обойтись, является по моему мнению ровно три функции:
1) Преобразование JSON (полученного из базы данных) в типизированную модель или коллекцию;

2) Преобразование типизированной модели или коллекции в JSON для сохранения в базе данных;

3) Преобразование типизированной модели или коллекции в JSON для отправки клиенту.

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

При этом есть смысл максимально использовать возможности Typescript (например декораторы, рефлексию, метаданные).

export class Reposytory {  private db: Database;  constructor() {    this.db = db;  }  @collection(Author)  async authorFindAll() {    const row = await this.db.query({      query: 'for row in authors return row',      bindVars: {},    });    return row.all();  }  @model(Author)  async authorCreate(author: Author): Promise<Author> {    const doc = await this.db.query({      query: 'insert @author into authors let doc = NEW return doc',      bindVars: { 'author': author }    });    return doc.next();  }}

Код декораторов будет довольно лаконичен:

export function collection(Constructor: new(...args: any[]) => void) {  return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor): void => {      const originalValue = descriptor.value;      descriptor.value = async function(...args: any[]) {        const plainData = await originalValue.apply(this, args)        const data = new Array();        plainData.forEach((item: any) => data.push(new Constructor(item)));        return data;      }    }}export function model(Constructor: new(...args: any[]) => void) {  return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor): void => {      const originalValue = descriptor.value;      descriptor.value = async function(...args: any[]) {        const plainData = await originalValue.apply(this, args)        return new Constructor(plainData);      }    }}

Что касается модели документа, я построил схему модели на таких декораторах:

attr(Type?) - атрибут - то есть то что сохраняется в базе данных и не является вычислимым параметром;

optional(Type?) - не обязательное значение;

array(Type?) - типизированная коллекция;

translatable(Type?) - значение, сохраняющееся в базе данных виде объекта и возвращающееся клиенту в виде строки с выбранной локалью;

group(...args: string[]) - список групп клиентов, которым доступно значение.

На таких декораторах можно построить описание объекта:

import {Model, ModelType} from '../src';import {optional, attr, array, getter, group, _type, translatable} from '../src';import {Translatable, TranslatableType} from '../src';interface AddressType extends ModelType {    city: string,    street: Translatable,    house: string,    appartment?: number,}interface AuthorType extends ModelType{    name: Translatable,    address: AddressType,}export class Address extends Model<AddressType> implements AddressType {    @attr()    @group('admin', 'user')    @translatable(Translatable)    public city!: string;    @attr()    @group('admin')    @translatable(Translatable)    public street!: Translatable;    @attr()    @group('admin')    public house!: string;    @attr()    @optional()    @group('admin')    public appartment?: number;}export class Author extends Model<AuthorType> implements AuthorType  {    @attr(Translatable)    @translatable(Translatable)    @group('admin', 'user')    public name!: string;    @attr(Address)    @group('admin', 'user')    public address!: Address}

Результаты исследования представлены в репозитарии.

Желающих обсудить приглашаю в чат https://t.me/arangodb_odm

Подробнее..
Категории: Javascript , Node.js , Nosql , Nodejs , Mongodb , Odm , Arangodb , Couchdb , Orientdb

Полный стек на примере списка задач (React, Vue, TypeScript, Express, Mongoose)

24.12.2020 12:19:48 | Автор: admin


Доброго времени суток, друзья!

В данном туториале я покажу вам, как создать фуллстек-тудушку.

Наше приложение будет иметь стандартный функционал:

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

Выглядеть наше приложение будет так:


Для более широкого охвата аудитории клиентская часть приложения будет реализована на чистом JavaScript, серверная на Node.js. В качестве абстракции для ноды будет использован Express.js, в качестве базы данных сначала локальное хранилище (Local Storage), затем индексированная база данных (IndexedDB) и, наконец, облачная MongoDB.

При разработке клиентской части будут использованы лучшие практики, предлагаемые такими фреймворками, как React и Vue: разделение кода на автономные переиспользуемые компоненты, повторный рендеринг только тех частей приложения, которые подверглись изменениям и т.д. При этом, необходимый функционал будет реализован настолько просто, насколько это возможно. Мы также воздержимся от смешивания HTML, CSS и JavaScript.

В статье будут приведены примеры реализации клиентской части на React и Vue, а также фуллстек-тудушки на React + TypeScript + Express + Mongoose.

Исходный код всех рассматриваемых в статье проектов находится здесь.

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

Демо нашего приложения:


Итак, поехали.

Клиент


Начнем с клиентской части.

Создаем рабочую директорию, например, javascript-express-mongoose:

mkdir javascript-express-mongoosecd !$code .

Создаем директорию client. В этой директории будет храниться весь клиентский код приложения, за исключением index.html. Создаем следующие папки и файлы:

client  components    Buttons.js    Form.js    Item.js    List.js  src    helpers.js    idb.js    router.js    storage.js  script.js  style.css

В корне проекта создаем index.html следующего содержания:

<!DOCTYPE html><html lang="en">  <head>    <meta charset="UTF-8" />    <meta name="viewport" content="width=device-width, initial-scale=1.0" />    <title>JS Todos App</title>    <!-- Подключаем стили -->    <link rel="stylesheet" href="client/style.css" />  </head>  <body>    <div id="root"></div>    <!-- Подключаем скрипт -->    <script src="client/script.js" type="module"></script>  </body></html>

Стили (client/style.css):
@import url('https://fonts.googleapis.com/css2?family=Stylish&display=swap');* {  margin: 0;  padding: 0;  box-sizing: border-box;  font-family: stylish;  font-size: 1rem;  color: #222;}#root {  max-width: 512px;  margin: auto;  text-align: center;}#title {  font-size: 2.25rem;  margin: 0.75rem;}#counter {  font-size: 1.5rem;  margin-bottom: 0.5rem;}#form {  display: flex;  margin-bottom: 0.25rem;}#input {  flex-grow: 1;  border: none;  border-radius: 4px;  box-shadow: 0 0 1px inset #222;  text-align: center;  font-size: 1.15rem;  margin: 0.5rem 0.25rem;}#input:focus {  outline-color: #5bc0de;}.btn {  border: none;  outline: none;  background: #337ab7;  padding: 0.5rem 1rem;  border-radius: 4px;  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.5);  color: #eee;  margin: 0.5rem 0.25rem;  cursor: pointer;  user-select: none;  width: 102px;  text-shadow: 0 0 1px rgba(0, 0, 0, 0.5);}.btn:active {  box-shadow: 0 0 1px rgba(0, 0, 0, 0.5) inset;}.btn.info {  background: #5bc0de;}.btn.success {  background: #5cb85c;}.btn.warning {  background: #f0ad4e;}.btn.danger {  background: #d9534f;}.btn.filter {  background: none;  color: #222;  text-shadow: none;  border: 1px dashed #222;  box-shadow: none;}.btn.filter.checked {  border: 1px solid #222;}#list {  list-style: none;}.item {  display: flex;  flex-wrap: wrap;  justify-content: space-between;  align-items: center;}.item + .item {  border-top: 1px dashed rgba(0, 0, 0, 0.5);}.text {  flex: 1;  font-size: 1.15rem;  margin: 0.5rem;  padding: 0.5rem;  background: #eee;  border-radius: 4px;}.completed .text {  text-decoration: line-through;  color: #888;}.disabled {  opacity: 0.8;  position: relative;  z-index: -1;}#modal {  position: absolute;  top: 10px;  left: 10px;  padding: 0.5em 1em;  background: rgba(0, 0, 0, 0.5);  border-radius: 4px;  font-size: 1.2em;  color: #eee;}


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


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

Дополнительные элементы: 1) заголовок; 2) счетчик количества невыполненных задач.

Приступаем к созданию компонентов (сверху вниз). Компоненты Form и Buttons являются статическими, а List и Item динамическими. В целях дифференциации статические компоненты экспортируются/импортируются по умолчанию, а в отношении динамических компонентов применяется именованный экспорт/импорт.

client/Form.js:

export default /*html*/ `<div id="form">  <input      type="text"      autocomplete="off"      autofocus      id="input"  >  <button    class="btn"    data-btn="add"  >    Add  </button></div>`

/*html*/ обеспечивает подсветку синтаксиса, предоставляемую расширением для VSCode es6-string-html. Атрибут data-btn позволит идентифицировать кнопку в скрипте.

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

client/Buttons.js:

export default /*html*/ `<div id="buttons">  <button    class="btn filter checked"    data-btn="all"  >    All  </button>  <button    class="btn filter"    data-btn="active"  >    Active  </button>  <button    class="btn filter"    data-btn="completed"  >    Completed  </button></div>`

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

client/Item.js (самый сложный компонент с точки зрения структуры):

/** * функция принимает на вход задачу, * которая представляет собой объект, * включающий идентификатор, текст и индикатор выполнения * * индикатор выполнения управляет дополнительными классами * и текстом кнопки завершения задачи * * текст завершенной задачи должен быть перечеркнут, * а кнопка для изменения (обновления) текста такой задачи - отключена * * завершенную задачу можно сделать активной*/export const Item = ({ id, text, done }) => /*html*/ `<li  class="item ${done ? 'completed' : ''}"  data-id="${id}">  <button    class="btn ${done ? 'warning' : 'success'}"    data-btn="complete"  >    ${done ? 'Cancel' : 'Complete'}  </button>  <span class="text">    ${text}  </span>  <button    class="btn info ${done ? 'disabled' : ''}"    data-btn="update"  >    Update  </button>  <button    class="btn danger"    data-btn="delete"  >    Delete  </button></li>`

client/List.js:

/** * для формирования списка используется компонент Item * * функция принимает на вход список задач * * если вам не очень понятен принцип работы reduce * https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce*/import { Item } from "./Item.js"export const List = (todos) => /*html*/ `  <ul id="list">    ${todos.reduce(      (html, todo) =>        (html += `            ${Item(todo)}        `),      ''    )}  </ul>`

С компонентами закончили.

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

src/helpers.js:

/** * данная функция будет использоваться * для визуализации нажатия одной из кнопок * для фильтрации задач * * она принимает элемент - нажатую кнопку и класс - в нашем случае checked * * основной контейнер имеет идентификатор root, * поэтому мы можем обращаться к нему напрямую * из любой части кода, в том числе, из модулей*/export const toggleClass = (element, className) => {  root.querySelector(`.${className}`).classList.remove(className)  element.classList.add(className)}// примерные задачиexport const todosExample = [  {    id: '1',    text: 'Learn HTML',    done: true  },  {    id: '2',    text: 'Learn CSS',    done: true  },  {    id: '3',    text: 'Learn JavaScript',    done: false  },  {    id: '4',    text: 'Stay Alive',    done: false  }]

Создадим базу данных (пока в форме локального хранилища).

src/storage.js:

/** * база данных имеет два метода * get - для получения тудушек * set - для записи (сохранения) тудушек*/export default (() => ({  get: () => JSON.parse(localStorage.getItem('todos')),  set: (todos) => { localStorage.setItem('todos', JSON.stringify(todos)) }}))()

Побаловались и хватит. Приступаем к делу.

src/script.js:

// импортируем компоненты, вспомогательную функцию, примерные задачи и хранилищеimport Form from './components/Form.js'import Buttons from './components/Buttons.js'import { List } from './components/List.js'import { Item } from './components/Item.js'import { toggleClass, todosExample } from './src/helpers.js'import storage from './src/storage.js'// функция принимает контейнер и список задачconst App = (root, todos) => {  // формируем разметку с помощью компонентов и дополнительных элементов  root.innerHTML = `    <h1 id="title">      JS Todos App    </h1>    ${Form}    <h3 id="counter"></h3>    ${Buttons}    ${List(todos)}  `  // обновляем счетчик  updateCounter()  // получаем кнопку добавления задачи в список  const $addBtn = root.querySelector('[data-btn="add"]')  // основной функционал приложения  // функция добавления задачи в список  function addTodo() {    if (!input.value.trim()) return    const todo = {      // такой способ генерации идентификатора гарантирует его уникальность и соответствие спецификации      id: Date.now().toString(16).slice(-4).padStart(5, 'x'),      text: input.value,      done: false    }    list.insertAdjacentHTML('beforeend', Item(todo))    todos.push(todo)    // очищаем поле и устанавливаем фокус    clearInput()    updateCounter()  }  // функция завершения задачи  // принимает DOM-элемент списка  function completeTodo(item) {    const todo = findTodo(item)    todo.done = !todo.done    // рендерим только изменившийся элемент    renderItem(item, todo)    updateCounter()  }  // функция обновления задачи  function updateTodo(item) {    item.classList.add('disabled')    const todo = findTodo(item)    const oldValue = todo.text    input.value = oldValue    // тонкий момент: мы используем одну и ту же кнопку    // для добавления задачи в список и обновления текста задачи    $addBtn.textContent = 'Update'    // добавляем разовый обработчик    $addBtn.addEventListener(      'click',      (e) => {        // останавливаем распространение события для того,        // чтобы нажатие кнопки не вызвало функцию добавления задачи в список        e.stopPropagation()        const newValue = input.value.trim()        if (newValue && newValue !== oldValue) {          todo.text = newValue        }        renderItem(item, todo)        clearInput()        $addBtn.textContent = 'Add'      },      { once: true }    )  }  // функция удаления задачи  function deleteTodo(item) {    const todo = findTodo(item)    item.remove()    todos.splice(todos.indexOf(todo), 1)    updateCounter()  }  // функция поиска задачи  function findTodo(item) {    const { id } = item.dataset    const todo = todos.find((todo) => todo.id === id)    return todo  }  // дополнительный функционал  // функция фильтрации задач  // принимает значение кнопки  function filterTodos(value) {    const $items = [...root.querySelectorAll('.item')]    switch (value) {      // отобразить все задачи      case 'all':        $items.forEach((todo) => (todo.style.display = ''))        break      // активные задачи      case 'active':        // отобразить все и отключить завершенные        filterTodos('all')        $items          .filter((todo) => todo.classList.contains('completed'))          .forEach((todo) => (todo.style.display = 'none'))        break      // завершенные задачи      case 'completed':        // отобразить все и отключить активные        filterTodos('all')        $items          .filter((todo) => !todo.classList.contains('completed'))          .forEach((todo) => (todo.style.display = 'none'))        break    }  }  // функция обновления счетчика  function updateCounter() {    // считаем количество невыполненных задач    const count = todos.filter((todo) => !todo.done).length    counter.textContent = `      ${count > 0 ? `${count} todo(s) left` : 'All todos completed'}    `    if (!todos.length) {      counter.textContent = 'There are no todos'      buttons.style.display = 'none'    } else {      buttons.style.display = ''    }  }  // функция повторного рендеринга изменившегося элемента  function renderItem(item, todo) {    item.outerHTML = Item(todo)  }  // функция очистки инпута  function clearInput() {    input.value = ''    input.focus()  }  // делегируем обработку событий корневому узлу  root.onclick = ({ target }) => {    if (target.tagName !== 'BUTTON') return    const { btn } = target.dataset    if (target.classList.contains('filter')) {      filterTodos(btn)      toggleClass(target, 'checked')    }    const item = target.parentElement    switch (btn) {      case 'add':        addTodo()        break      case 'complete':        completeTodo(item)        break      case 'update':        updateTodo(item)        break      case 'delete':        deleteTodo(item)        break    }  }  // обрабатываем нажатие Enter  document.onkeypress = ({ key }) => {    if (key === 'Enter') addTodo()  }  // оптимизация работы с хранилищем  window.onbeforeunload = () => {    storage.set(todos)  }}// инициализируем приложения;(() => {  // получаем задачи из хранилища  let todos = storage.get('todos')  // если в хранилище пусто  if (!todos || !todos.length) todos = todosExample  App(root, todos)})()

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

Однако, с использованием локального хранилища в качестве базы данных сопряжено несколько проблем: 1) ограниченный размер около 5 Мб, зависит от браузера; 2) потенциальная возможность потери данных при очистке хранилищ браузера, например, при очистке истории просмотра страниц, нажатии кнопки Clear site data вкладки Application Chrome DevTools и т.д.; 3) привязка к браузеру невозможность использовать приложение на нескольких устройствах.

Первую проблему (ограниченность размера хранилища) можно решить с помощью IndexedDB.

Индексированная база данных имеет довольно сложный интерфейс, поэтому воспользуемся абстракцией Jake Archibald idb-keyval. Копируем этот код и записываем его в файл src/idb.js.

Вносим в src/script.js следующие изменения:

// import storage from './src/storage.js'import { get, set } from './src/idb.js'window.onbeforeunload = () => {  // storage.set(todos)  set('todos', todos)}// обратите внимание, что функция инициализации приложения стала асинхронной;(async () => {  // let todos = storage.get('todos')  let todos = await get('todos')  if (!todos || !todos.length) todos = todosExample  App(root, todos)})()

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

React, Vue

Ниже приводятся примеры реализации клиентской части тудушки на React и Vue.

React:


Vue:


База данных


Перед тем, как создавать сервер, имеет смысл настроить базу данных. Тем более, что в этом нет ничего сложного. Алгоритм действий следующий:

  1. Создаем аккаунт в MongoDB Atlas
  2. Во вкладке Projects нажимаем на кнопку New Project
  3. Вводим название проекта, например, todos-db, и нажимаем Next
  4. Нажимаем Create Project
  5. Нажимаем Build a Cluster
  6. Нажимаем Create a cluster (FREE)
  7. Выбираем провайдера и регион, например, Azure и Hong Kong, и нажимаем Create Cluster
  8. Ждем завершения создания кластера и нажимаем connect
  9. В разделе Add a connection IP address выбираем либо Add Your Current IP Address, если у вас статический IP, либо Allow Access from Anywhere, если у вас, как в моем случае, динамический IP (если сомневаетесь, выбирайте второй вариант)
  10. Вводим имя пользователя и пароль, нажимаем Create Database User, затем нажимаем Choose a connection method
  11. Выбираем Connect your application
  12. Копируем строку из раздела Add your connection string into your application code
  13. Нажимаем Close












В корневой директории создаем файл .env и вставляем в него скопированную строку (меняем <username>, <password> и <dbname> на свои данные):

MONGO_URI=mongodb+srv://<username>:<password>@cluster0.hfvcf.mongodb.net/<dbname>?retryWrites=true&w=majority

Сервер


Находясь в корневой директории, инициализируем проект:

npm init -y// илиyarn init -yp

Устанавливаем основные зависимости:

yarn add cors dotenv express express-validator mongoose

  • cors отключает политику общего происхождения (одного источника)
  • dotenv предоставляет доступ к переменным среды в файле .env
  • express облегчает создание сервера на Node.js
  • express-validator служит для проверки (валидации) данных
  • mongoose облегчает работу с MongoDB

Устанавливаем зависимости для разработки:

yarn add -D nodemon open-cli morgan

  • nodemon запускает сервер и автоматически перезагружает его при внесении изменений в файл
  • open-cli открывает вкладку браузера по адресу, на котором запущен сервер
  • morgan логгер HTTP-запросов

Далее добавляем в package.json скрипты для запуска сервера (dev для запуска сервера для разработки и start для продакшн-сервера):

"scripts": {  "start": "node index.js",  "dev": "open-cli http://localhost:1234 && nodemon index.js"},

Отлично. Создаем файл index.js следующего содержания:

// подключаем библиотекиconst express = require('express')const mongoose = require('mongoose')const cors = require('cors')const morgan = require('morgan')require('dotenv/config')// инициализируем приложение и получаем роутерconst app = express()const router = require('./server/router')// подключаем промежуточное ПОapp.use(express.json())app.use(express.urlencoded({ extended: false }))app.use(cors())app.use(morgan('dev'))// указываем, где хранятся статические файлыapp.use(express.static(__dirname))// подлючаемся к БДmongoose.connect(  process.env.MONGO_URI,  {    useNewUrlParser: true,    useUnifiedTopology: true,    useFindAndModify: false,    useCreateIndex: true  },  () => console.log('Connected to database'))// возвращаем index.html в ответ на запрос к корневому узлуapp.get('/', (_, res) => {  res.sendFile(__dirname + '/index.html')})// при запросе к api передаем управление роутеруapp.use('/api', router)// определяем порт и запускаем серверconst PORT = process.env.PORT || 1234app.listen(PORT, () => console.log(`Server is running`))

Тестируем сервер:

yarn dev// илиnpm run dev



Прекрасно, сервер работает. Теперь займемся маршрутизацией. Но перед этим определим схему данных, которые мы будем получать от клиента. Создаем директорию server для хранения серверных файлов. В этой директории создаем файлы Todo.js и router.js.

Структура проекта на данном этапе:

client  components    Buttons.js    Form.js    Item.js    List.js  src    helpers.js    idb.js    storage.js  script.js  style.cssserver  Todo.js  router.js.envindex.htmlindex.jspackage.jsonyarn.lock (либо package-lock.json)

Определяем схему в src/Todo.js:

const { Schema, model } = require('mongoose')const todoSchema = new Schema({  id: {    type: String,    required: true,    unique: true  },  text: {    type: String,    required: true  },  done: {    type: Boolean,    required: true  }})// экспорт модели данныхmodule.exports = model('Todo', todoSchema)

Настраиваем маршрутизацию в src/router.js:

// инициализируем роутерconst router = require('express').Router()// модель данныхconst Todo = require('./Todo')// средства валидацииconst { body, validationResult } = require('express-validator')/** * наш интерфейс (http://personeltest.ru/away/localhost:1234/api) * будет принимать и обрабатывать 4 запроса * GET-запрос /get - получение всех задач из БД * POST /add - добавление в БД новой задачи * DELETE /delete/:id - удаление задачи с указанным идентификатором * PUT /update - обновление текста или индикатора выполнения задачи * * для работы с БД используется модель Todo и методы * find() - для получения всех задач * save() - для добавления задачи * deleteOne() - для удаления задачи * updateOne() - для обновления задачи * * ответ на запрос - объект, в свойстве message которого * содержится сообщение либо об успехе операции, либо об ошибке*/// получение всех задачrouter.get('/get', async (_, res) => {  const todos = (await Todo.find()) || []  return res.json(todos)})// добавление задачиrouter.post(  '/add',  // пример валидации  [    body('id').exists(),    body('text').notEmpty().trim().escape(),    body('done').toBoolean()  ],  async (req, res) => {    // ошибки - это результат валидации    const errors = validationResult(req)    if (!errors.isEmpty()) {      return res.status(400).json({ message: errors.array()[0].msg })    }    const { id, text, done } = req.body    const todo = new Todo({      id,      text,      done    })    try {      await todo.save()      return res.status(201).json({ message: 'Todo created' })    } catch (error) {      return res.status(500).json({ message: `Error: ${error}` })    }  })// удаление задачиrouter.delete('/delete/:id', async (req, res) => {  try {    await Todo.deleteOne({      id: req.params.id    })    res.status(201).json({ message: 'Todo deleted' })  } catch (error) {    return res.status(500).json({ message: `Error: ${error}` })  }})// обновление задачиrouter.put(  '/update',  [    body('text').notEmpty().trim().escape(),    body('done').toBoolean()  ],  async (req, res) => {    const errors = validationResult(req)    if (!errors.isEmpty()) {      return res.status(400).json({ message: errors.array()[0].msg })    }    const { id, text, done } = req.body    try {      await Todo.updateOne(        {          id        },        {          text,          done        }      )      return res.status(201).json({ message: 'Todo updated' })    } catch (error) {      return res.status(500).json({ message: `Error: ${error}` })    }})// экспорт роутераmodule.exports = router

Интеграция


Возвращаемся к клиентской части. Для того, чтобы абстрагировать отправляемые клиентом запросы мы также прибегнем к помощи роутера. Создаем файл client/src/router.js:

/** * наш роутер - это обычная функция, * принимающая адрес конечной точки в качестве параметра (url) * * функция возвращает объект с методами: * get() - для получения всех задач из БД * set() - для добавления в БД новой задачи * update() - для обновления текста или индикатора выполнения задачи * delete() - для удаления задачи с указанным идентификатором * * все методы, кроме get(), принимают на вход задачу * * методы возвращают ответ от сервера в формате json * (объект со свойством message)*/export const Router = (url) => ({  // получение всех задач  get: async () => {    const response = await fetch(`${url}/get`)    return response.json()  },  // добавление задачи  set: async (todo) => {    const response = await fetch(`${url}/add`, {      method: 'POST',      headers: {        'Content-Type': 'application/json'      },      body: JSON.stringify(todo)    })    return response.json()  },  // обновление задачи  update: async (todo) => {    const response = await fetch(`${url}/update`, {      method: 'PUT',      headers: {        'Content-Type': 'application/json'      },      body: JSON.stringify(todo)    })    return response.json()  },  // удаление задачи  delete: async ({ id }) => {    const response = await fetch(`${url}/delete/${id}`, {      method: 'DELETE'    })    return response.json()  }})

Для того, чтобы сообщать пользователю о результате выполнения CRUD-операции (create, read, update, delete создание, чтение, обновление, удаление), добавим в src/helpers.js еще одну вспомогательную функцию:

// функция создает модальное окно с сообщением о результате операции// и удаляет его через две секундыexport const createModal = ({ message }) => {  root.innerHTML += `<div data-id="modal">${message}</div>`  const timer = setTimeout(() => {    root.querySelector('[data-id="modal"]').remove()    clearTimeout(timer)  }, 2000)}

Вот как выглядит итоговый вариант client/script.js:

import Form from './components/Form.js'import Buttons from './components/Buttons.js'import { List } from './components/List.js'import { Item } from './components/Item.js'import { toggleClass, createModal, todosExample } from './src/helpers.js'// импортируем роутер и передаем ему адрес конечной точкиimport { Router } from './src/router.js'const router = Router('http://localhost:1234/api')const App = (root, todos) => {  root.innerHTML = `    <h1 id="title">      JS Todos App    </h1>    ${Form}    <h3 id="counter"></h3>    ${Buttons}    ${List(todos)}  `  updateCounter()  const $addBtn = root.querySelector('[data-btn="add"]')  // основной функционал  async function addTodo() {    if (!input.value.trim()) return    const todo = {      id: Date.now().toString(16).slice(-4).padStart(5, 'x'),      text: input.value,      done: false    }    list.insertAdjacentHTML('beforeend', Item(todo))    todos.push(todo)    // добавляем в БД новую задачу и сообщаем о результате операции пользователю    createModal(await router.set(todo))    clearInput()    updateCounter()  }  async function completeTodo(item) {    const todo = findTodo(item)    todo.done = !todo.done    renderItem(item, todo)    // обновляем индикатор выполнения задачи    createModal(await router.update(todo))    updateCounter()  }  function updateTodo(item) {    item.classList.add('disabled')    const todo = findTodo(item)    const oldValue = todo.text    input.value = oldValue    $addBtn.textContent = 'Update'    $addBtn.addEventListener(      'click',      async (e) => {        e.stopPropagation()        const newValue = input.value.trim()        if (newValue && newValue !== oldValue) {          todo.text = newValue        }        renderItem(item, todo)        // обновляем текст задачи        createModal(await router.update(todo))        clearInput()        $addBtn.textContent = 'Add'      },      { once: true }    )  }  async function deleteTodo(item) {    const todo = findTodo(item)    item.remove()    todos.splice(todos.indexOf(todo), 1)    // удаляем задачу    createModal(await router.delete(todo))    updateCounter()  }  function findTodo(item) {    const { id } = item.dataset    const todo = todos.find((todo) => todo.id === id)    return todo  }  // дальше все тоже самое  // за исключением window.onbeforeunload  function filterTodos(value) {    const $items = [...root.querySelectorAll('.item')]    switch (value) {      case 'all':        $items.forEach((todo) => (todo.style.display = ''))        break      case 'active':        filterTodos('all')        $items          .filter((todo) => todo.classList.contains('completed'))          .forEach((todo) => (todo.style.display = 'none'))        break      case 'completed':        filterTodos('all')        $items          .filter((todo) => !todo.classList.contains('completed'))          .forEach((todo) => (todo.style.display = 'none'))        break    }  }  function updateCounter() {    const count = todos.filter((todo) => !todo.done).length    counter.textContent = `      ${count > 0 ? `${count} todo(s) left` : 'All todos completed'}    `    if (!todos.length) {      counter.textContent = 'There are no todos'      buttons.style.display = 'none'    } else {      buttons.style.display = ''    }  }  function renderItem(item, todo) {    item.outerHTML = Item(todo)  }  function clearInput() {    input.value = ''    input.focus()  }  root.onclick = ({ target }) => {    if (target.tagName !== 'BUTTON') return    const { btn } = target.dataset    if (target.classList.contains('filter')) {      filterTodos(btn)      toggleClass(target, 'checked')    }    const item = target.parentElement    switch (btn) {      case 'add':        addTodo()        break      case 'complete':        completeTodo(item)        break      case 'update':        updateTodo(item)        break      case 'delete':        deleteTodo(item)        break    }  }  document.onkeypress = ({ key }) => {    if (key === 'Enter') addTodo()  }};(async () => {  // получаем задачи из БД  let todos = await router.get()  if (!todos || !todos.length) todos = todosExample  App(root, todos)})()

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

TypeScript

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



Заключение


Подведем краткие итоги.

Мы с вами реализовали полноценное клиент-серверное приложение для добавления, редактирования и удаления задач из списка, интегрированное с настоящей базой данных. На клиенте мы использовали самый современный (чистый) JavaScript, на сервере Node.js сквозь призму Express.js, для взаимодействия с БД Mongoose. Мы рассмотрели парочку вариантов хранения данных на стороне клиента (local storage, indexeddb idb-keyval). Также мы увидели примеры реализации клиентской части на React (+TypeScript) и Vue. По-моему, очень неплохо для одной статьи.

Буду рад любой форме обратной связи. Благодарю за внимание и хорошего дня.
Подробнее..

Практическое знакомство с 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

Использование Shopker для IOT

27.12.2020 20:07:49 | Автор: admin

Недавно я столкнулся с задачей визуализации данных от датчиков (температура, влажность, частицы PM2.5). Для решения подобных задач существует несколько бесплатных инструментов, например, Grafana + InfluxDB. Найденные мной решения показались слишком сложными и требовательными к ресурсам сервера, поэтому я решил "изобрести свой велосипед", а точнее создать шаблон для Shopker.

Shopker - бесплатный движок для создания сайта, который использует PHP фреймворк Symfony (5+), JS фреймворк Angular (11+) и базу данных MongoDB (4+). Сайт проекта: https://shopker.org/.

Сначала я расскажу как быстро развернуть сайт и настроить приём данных от датчиков, а во второй части остановлюсь немного подробнее на исходном коде шаблона. Результат можно посмотреть здесь: http://iot.shopker.org/.

1. Установка Shopker

Этот пункт я не буду расписывать подробно, т.к. всё есть в документации. Рекомендую использовать Bash скрипт для установки на VDS сервере: https://shopker.org/documentation/bash-script-for-vds. В разделе "Уроки" есть видео.

2. Установка шаблона

Скачиваем shopker-templates.zip здесь https://github.com/andchir/shopker-templates/releases/. Или можно на своем сервере клонировать репозиторий и воспользоваться командой npm install

Распаковываем архив и по FTP загружаем папку "iot" в папку "/templates/", а папку "/assets/iot/" в "/public/", чтобы получилось "/public/assets/iot/".

Открываем админку, переходим в раздел "Настройки". В пункте "Тема шаблонов" вводим название новой темы - iot. Сохраняем.

3. Создание типа контента и коллекции в БД

Переходим в раздел "Каталог" -> "Типы контента", нажимаем кнопку "Добавить".

В поле "Заголовок" вводим "Датчики", системное имя - "sensors", коллекция - "sensors". Активируем чекбокс "Разрешено создание пользователями". Это нужно, чтобы иметь возможность сохранять новые данные через API. Далее создаем первое поле - "Температура, С". Системное имя - "temperature", тип ввода и тип вывода - "Число", группа - "Основное", чекбоксы - "Показывать в таблице", "Показывать на странице" и "Показывать в списке".

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

4. Создание категории

Переходим в раздел "Каталог", в выпадающем меню нажимаем кнопку "Добавить новую категорию". Заголовок - "Датчики", системное имя - "sensors", тип контента - "Датчики".

Далее создаем дочернюю категорию "Устройство #1" (название Вашего устройства). В эту категорию будем сохранять данные от датчиков. Запомните ID этой категории, оно пригодится.

5. Отправка данных и сохранение

Для использования API нам нужен токен. Его можно создать через запрос, используя свой логин и пароль, а можно в разделе "Пользователи".

Вот пример использования API для Bash:

curl -X POST \"http://your-domain.com/api/ru/user_content/19" \-H "Content-Type: application/json" \-H "Accept: application/json" \-H "X-AUTH-TOKEN: xxxxx" \-d '{"temperature":23,"humidity":35}'

Если используете ESP2866 или подобный модуль wi-fi, то функция отправки данных будет примерно такая:

// Shopker settingschar shopkerApiUrl[45] = "http://your-domain.com/api/ru/user_content/19";// 19 - ID категорииchar shopkerApiKey[121] = "c5f3a652f67c5d31a5cd39ec287bd3ba36e1b7737dc9e26ae697c4d467c8638a1f1bd62906b00f4a1f83f1214fad20fe3fbe49d135f263c10d64b137";// API токен...void sendDataToShopker(float temp, float hum, int pm2, int pm10) {  Serial.println("API URL: " + String(shopkerApiUrl));    String jsonString = "{";  jsonString += "\"temperature\":" + String(temp) + ", ";  jsonString += "\"humidity\":" + String(hum) + ", ";  jsonString += "\"pm25\":" + String(pm2) + ", ";  jsonString += "\"pm10\":" + String(pm10);  jsonString += "}";  Serial.println(jsonString);  HTTPClient http;  http.begin(shopkerApiUrl);  http.addHeader("Content-Type", "application/json");  http.addHeader("Accept", "application/json");  http.addHeader("Content-Length", String(jsonString.length()));  http.addHeader("X-AUTH-TOKEN", String(shopkerApiKey));  int httpCode = http.POST(jsonString);  String response = http.getString();  Serial.println("Response code: " + String(httpCode));  Serial.println("Response: " + response);  http.end();}

6. Вывод значений на графиках

Чтобы по умолчанию выводились данные с конца (последняя страница), нужно в настройках указать отрицательное число в параметре "Число элементов на странице по умолчанию" (например "-50"), а сортировку указать - "id_asc" (по порядку по ID).

Технические подробности

Для вывода графиков на главной станице используется Twig-функция "contentList":

{{ contentList('sensor_data', 'sensors', {"isActive": true, parentId: 19}, {"_id": "asc"}, -50) }}

Подробнее о ней в документации: https://shopker.org/documentation/content-display

"sensor_data" - это название шаблона, который находится в папке "/templates/iot/catalog" - "sensor_data.html.twig". В этот шаблон передается тип контента, из которого можно взять массив полей, которые отмечены для показа:

{% set fieldsOnPage = contentType.getFieldsByFlag('showOnPage') %}

Для вывода графиков используется JS-библиотека Plotly.

При желании можно выводить данные только для зарегистрированных пользователей:

{% if is_granted('ROLE_USER') %}    Тут контент для зарегистрированных и авторизованных.{% endif %}

Подробнее о шаблонизаторе: https://twig.symfony.com/doc/3.x/

Подробнее..

Делаем поиск в веб-приложении с нуля

05.11.2020 18:21:17 | Автор: admin
В статье Делаем современное веб-приложение с нуля я рассказал в общих чертах, как выглядит архитектура современных высоконагруженных веб-приложений, и собрал для демонстрации простейшую реализацию такой архитектуры на стеке из нескольких предельно популярных и простых технологий и фреймворков. Мы построили single page application с server side rendering, поддерживающее просмотр неких карточек, набранных в Markdown, и навигацию между ними.

В этой статье я затрону чуть более сложную и интересную (как минимум мне, разработчику команды поиска) тему: полнотекстовый поиск. Мы добавим в наш контейнерный рай ноду Elasticsearch, научимся строить индекс и делать поиск по контенту, взяв в качестве тестовых данных описания пяти тысяч фильмов из TMDB 5000 Movie Dataset. Также мы научимся делать поисковые фильтры и копнём совсем немножко в сторону ранжирования.




Инфраструктура: Elasticsearch


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

Давайте добавим одну ноду Elasticsearch в наш docker-compose.yml:

services:  ...  elasticsearch:    image: "elasticsearch:7.5.1"    environment:      - discovery.type=single-node    ports:      - "9200:9200"  ...


Переменная окружения discovery.type=single-node подсказывает Elasticsearch, что надо готовиться к работе в одиночку, а не искать другие ноды и объединяться с ними в кластер (таково поведение по умолчанию).

Обратите внимание, что мы публикуем 9200 порт наружу, хотя наше приложение ходит в него внутри сети, создаваемой docker-compose. Это исключительно для отладки: так мы сможем обращаться в Elasticsearch напрямую из терминала (до тех пор, пока не придумаем более умный способ об этом ниже).

Добавить клиент Elasticsearch в наш вайринг не составит труда благо, Elastic предоставляет минималистичный Python-клиент.

Индексация


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

Теперь же перед нами стоит обратная задача по содержимому (или его фрагментам) получить идентификаторы карточек. Стало быть, нам нужен обратный индекс. Для него-то нам и пригодится Elasticsearch!

Общая схема построения индекса обычно выглядит как-то так.
  1. Создаём новый пустой индекс с уникальным именем, конфигурируем его как нам нужно.
  2. Обходим все наши сущности в базе и кладём их в новый индекс.
  3. Переключаем продакшн, чтобы все запросы начали ходить в новый индекс.
  4. Удаляем старый индекс. Тут по желанию вы вполне можете захотеть хранить несколько последних индексов, чтобы, например, удобнее было отлаживать какие-то проблемы.


Давайте создадим скелет индексатора и потом разберёмся подробнее с каждым шагом.

import datetimefrom elasticsearch import Elasticsearch, NotFoundErrorfrom backend.storage.card import Card, CardDAOclass Indexer(object):    def __init__(self, elasticsearch_client: Elasticsearch, card_dao: CardDAO, cards_index_alias: str):        self.elasticsearch_client = elasticsearch_client        self.card_dao = card_dao        self.cards_index_alias = cards_index_alias    def build_new_cards_index(self) -> str:        # Построение нового индекса.        # Сначала придумываем для индекса оригинальное название.        index_name = "cards-" + datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")        # Создаём пустой индекс.         # Здесь мы укажем настройки и опишем схему данных.        self.create_empty_cards_index(index_name)        # Кладём в индекс все наши карточки одну за другой.        # В настоящем проекте вы очень скоро захотите         # переписать это на работу в пакетном режиме.        for card in self.card_dao.get_all():            self.put_card_into_index(card, index_name)        return index_name    def create_empty_cards_index(self, index_name):        ...     def put_card_into_index(self, card: Card, index_name: str):        ...    def switch_current_cards_index(self, new_index_name: str):        ... 


Индексация: создаём индекс


Индекс в Elasticsearch создаётся простым PUT-запросом в /имя-индекса или, в случае использования Python-клиента (нашем случае), вызовом

elasticsearch_client.indices.create(index_name, {    ...})


Тело запроса может содержать три поля.

  • Описание алиасов ("aliases": ...). Система алиасов позволяет держать знание о том, какой индекс сейчас актуальный, на стороне Elasticsearch; мы поговорим про неё ниже.
  • Настройки ("settings": ...). Когда мы будем большими дядями с настоящим продакшном, мы сможем сконфигурировать здесь репликацию, шардирование и другие радости SRE.
  • Схема данных ("mappings": ...). Здесь мы можем указать, какого типа какие поля в документах, которые мы будем индексировать, для каких из этих полей нужны обратные индексы, по каким должны быть поддержаны агрегации и так далее.


Сейчас нас интересует только схема, и у нас она очень простая:

{    "mappings": {        "properties": {            "name": {                "type": "text",                "analyzer": "english"            },            "text": {                "type": "text",                "analyzer": "english"            },            "tags": {                "type": "keyword",                "fields": {                    "text": {                        "type": "text",                        "analyzer": "english"                    }                }            }        }    }}


Мы пометили поля name и text как текстовые на английском языке. Анализатор это сущность в Elasticsearch, которая обрабатывает текст перед сохранением в индекс. В случае english анализатора текст будет разбит на токены по границам слов (подробности), после чего отдельные токены будут лемматизированы по правилам английского языка (например, слово trees упростится до tree), слишком общие леммы (вроде the) будут удалены и оставшиеся леммы будут положены в обратный индекс.

С полем tags чуть-чуть сложнее. Тип keyword предполагает, что значения этого поля некие строковые константы, которые не надо обрабатывать анализатором; обратный индекс будет построен по их сырым значениям без токенизации и лемматизации. Зато Elasticsearch создаст специальные структуры данных, чтобы по значениям этого поля можно было считать агрегации (например, чтобы одновременно с поиском можно было узнать, какие теги встречались в документах, удовлетворяющих поисковому запросу, и в каком количестве). Это очень удобно для полей, которые по сути enum; мы воспользуемся этой фичей, чтобы сделать клёвые поисковые фильтры.

Но чтобы по тексту тегов можно было искать и текстовым поиском тоже, мы добавляем к нему подполе "text", настроенное по аналогии с name и text выше по существу это означает, что Elasticsearch во всех приходящих ему документах будет создавать ещё одно виртуальное поле под названием tags.text, в которое будет копировать содержимое tags, но индексировать его по другим правилам.

Индексация: наполняем индекс


Для индексации документа достаточно сделать PUT-запрос в /имя-индекса/_create/id-документа или, при использовании Python-клиента, просто вызвать нужный метод. Наша реализация будет выглядеть так:

    def put_card_into_index(self, card: Card, index_name: str):        self.elasticsearch_client.create(index_name, card.id, {            "name": card.name,            "text": card.markdown,            "tags": card.tags,        })


Обратите внимание на поле tags. Хотя мы описали его как содержащее keyword, мы отправляем не одну строку, а список строк. Elasticsearch поддерживает такое; наш документ будет находиться по любому из значений.

Индексация: переключаем индекс


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

Алиас это указатель на ноль или более индексов. API Elasticsearch позволяет использовать имя алиаса вместо имени индекса при поиске (POST /имя-алиаса/_search вместо POST /имя-индекса/_search); в таком случае Elasticsearch будет искать по всем индексам, на которые указывает алиас.

Мы заведём алиас под названием cards, который всегда будет указывать на актуальный индекс. Соответственно, переключение на актуальный индекс после завершения построения будет выглядеть так:

    def switch_current_cards_index(self, new_index_name: str):        try:            # Нужно удалить ссылку на старый индекс, если она есть.            remove_actions = [                {                    "remove": {                        "index": index_name,                         "alias": self.cards_index_alias,                    }                }                for index_name in self.elasticsearch_client.indices.get_alias(name=self.cards_index_alias)            ]        except NotFoundError:            # Ого, старого индекса-то и не существует вовсе.            # Наверное, мы впервые запустили индексацию.            remove_actions = []        # Одним махом удаляем ссылку на старый индекс         # и добавляем ссылку на новый.        self.elasticsearch_client.indices.update_aliases({            "actions": remove_actions + [{                "add": {                    "index": new_index_name,                     "alias": self.cards_index_alias,                }            }]        })


Я не стану подробнее останавливаться на alias API; все подробности можно посмотреть в документации.

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

Весь код, реализующий индексацию, можно посмотреть в этом коммите.

Индексация: добавляем контент


Для демонстрации в этой статье я использую данные из TMDB 5000 Movie Dataset. Чтобы избежать проблем с авторскими правами, я лишь привожу код утилиты, импортирующей их из CSV-файла, который предлагаю вам скачать самостоятельно с сайта Kaggle. После загрузки достаточно выполнить команду

docker-compose exec -T backend python -m tools.add_movies < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv


, чтобы создать пять тысяч карточек, посвящённых кино, и команду

docker-compose exec backend python -m tools.build_index


, чтобы построить индекс. Обратите внимание, что последняя команда на самом деле не строит индекс, а только ставит задачу в очередь задач, после чего она выполнится на воркере подробнее об этом подходе я рассказывал в прошлой статье. docker-compose logs worker покажут вам, как воркер старался!

Прежде, чем мы приступим к, собственно, поиску, нам хочется своими глазами увидеть, записалось ли что-нибудь в Elasticsearch, и если да, то как оно выглядит!

Наиболее прямой и быстрый способ это сделать воспользоваться HTTP API Elasticsearch. Сперва проверим, куда указывает алиас:

$ curl -s localhost:9200/_cat/aliasescards                cards-2020-09-20-16-14-18 - - - -


Отлично, индекс существует! Посмотрим на него пристально:

$ curl -s localhost:9200/cards-2020-09-20-16-14-18 | jq{  "cards-2020-09-20-16-14-18": {    "aliases": {      "cards": {}    },    "mappings": {      ...    },    "settings": {      "index": {        "creation_date": "1600618458522",        "number_of_shards": "1",        "number_of_replicas": "1",        "uuid": "iLX7A8WZQuCkRSOd7mjgMg",        "version": {          "created": "7050199"        },        "provided_name": "cards-2020-09-20-16-14-18"      }    }  }}


Ну и, наконец, посмотрим на его содержимое:

$ curl -s localhost:9200/cards-2020-09-20-16-14-18/_search | jq{  "took": 2,  "timed_out": false,  "_shards": {    "total": 1,    "successful": 1,    "skipped": 0,    "failed": 0  },  "hits": {    "total": {      "value": 4704,      "relation": "eq"    },    "max_score": 1,    "hits": [      ...    ]  }}


Итого в нашем индексе 4704 документа, а в поле hits (которое я пропустил, потому что оно слишком большое) можно даже увидеть содержимое некоторых из них. Успех!

Более удобным способом просмотра содержимого индекса и вообще всевозможного баловства с Elasticsearch будет воспользоваться Kibana. Добавим контейнер в docker-compose.yml:

services:  ...  kibana:    image: "kibana:7.5.1"    ports:      - "5601:5601"    depends_on:      - elasticsearch  ...


После повторного docker-compose up мы сможем зайти в Kibana по адресу localhost:5601 (внимание, сервер может стартовать небыстро) и, после короткой настройки, просмотреть содержимое наших индексов в симпатичном веб-интерфейсе.



Очень советую вкладку Dev Tools при разработке вам часто нужно будет делать те или иные запросы в Elasticsearch, и в интерактивном режиме с автодополнением и автоформатированием это гораздо удобнее.

Поиск


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

Разделим эту нетривиальную задачу на три этапа и обсудим каждый в отдельности.

  1. Добавляем в бэкенд компонент Searcher, отвечающий за логику поиска. Он будет формировать запрос к Elasticsearch и конвертировать результаты в более удобоваримые для нашего бэкенда.
  2. Добавляем в API эндпоинт (ручку/роут/как у вас в компании это называют?) /cards/search, осуществляющий поиск. Он будет вызывать метод компонента Searcher, обрабатывать полученные результаты и возвращать клиенту.
  3. Реализуем интерфейс поиска на фронтенде. Он будет обращаться в /cards/search, когда пользователь определился, что он хочет искать, и отображать результаты (и, возможно, какие-то дополнительные контролы).


Поиск: реализуем


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

# backend/backend/search/searcher.pyimport abcfrom dataclasses import dataclassfrom typing import Iterable, Optional@dataclassclass CardSearchResult:    total_count: int    card_ids: Iterable[str]    next_card_offset: Optional[int]class Searcher(metaclass=abc.ABCMeta):    @abc.abstractmethod    def search_cards(self, query: str = "",                      count: int = 20, offset: int = 0) -> CardSearchResult:        pass


Какие-то вещи очевидны. Например, пагинация. Мы амбициозный молодой убийца IMDB стартап, и результаты поиска никогда не будут вмещаться на одну страницу!

Какие-то менее очевидны. Например, список ID, а не карточек в качестве результата. Elasticsearch по умолчанию хранит наши документы целиком и возвращает их в результатах поиска. Это поведение можно отключить, чтобы сэкономить на размере поискового индекса, но для нас это явно преждевременная оптимизация. Так почему бы не возвращать сразу карточки? Ответ: это нарушит single-responsibility principle. Возможно, когда-нибудь мы накрутим в менеджере карточек сложную логику, переводящую карточки на другие языки в зависимости от настроек пользователя. Ровно в этот момент данные на странице карточки и данные в результатах поиска разъедутся, потому что добавить ту же самую логику в поисковый менеджер мы забудем. И так далее и тому подобное.

Реализация этого интерфейса настолько проста, что мне было лень писать этот раздел :-(

# backend/backend/search/searcher_impl.pyfrom typing import Anyfrom elasticsearch import Elasticsearchfrom backend.search.searcher import CardSearchResult, SearcherElasticsearchQuery = Any  # для аннотаций типовclass ElasticsearchSearcher(Searcher):    def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):        self.elasticsearch_client = elasticsearch_client        self.cards_index_name = cards_index_name    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:        result = self.elasticsearch_client.search(index=self.cards_index_name, body={            "size": count,            "from": offset,            "query": self._make_text_query(query) if query else self._match_all_query        })        total_count = result["hits"]["total"]["value"]        return CardSearchResult(            total_count=total_count,            card_ids=[hit["_id"] for hit in result["hits"]["hits"]],            next_card_offset=offset + count if offset + count < total_count else None,        )    def _make_text_query(self, query: str) -> ElasticsearchQuery:        return {            # Multi-match query делает текстовый поиск по             # совокупности полей документов (в отличие от match            # query, которая ищет по одному полю).            "multi_match": {                "query": query,                # Число после ^  приоритет. Найти фрагмент текста                # в названии карточки лучше, чем в описании и тегах.                "fields": ["name^3", "tags.text", "text"],            }        }    _match_all_query: ElasticsearchQuery = {"match_all": {}}


По сути мы просто ходим в API Elasticsearch и аккуратно достаём ID найденных карточек из результата.

Реализация эндпоинта тоже довольно тривиальна:

# backend/backend/server.py...    def search_cards(self):        request = flask.request.json        search_result = self.wiring.searcher.search_cards(**request)        cards = self.wiring.card_dao.get_by_ids(search_result.card_ids)        return flask.jsonify({            "totalCount": search_result.total_count,            "cards": [                {                    "id": card.id,                    "slug": card.slug,                    "name": card.name,                    # Здесь не нужны все поля, иначе данных на одной                    # странице поиска будет слишком много, и она будет                    # долго грузиться.                } for card in cards            ],            "nextCardOffset": search_result.next_card_offset,        })...


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



So far so good, идём дальше.

Поиск: добавляем фильтры


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

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



Чтобы реализовать фильтры, нам нужно решить две проблемы.
  • Научиться по запросу понимать, какой набор фильтров доступен. Мы не хотим показывать все возможные значения фильтра на каждом экране, потому что их очень много и при этом большинство будет приводить к пустому результату; нужно понять, какие теги есть у документов, найденных по запросу, и в идеале оставить N самых популярных.
  • Научиться, собственно, применять фильтр оставить в выдаче только документы с тегами, фильтр по которым выбрал пользователь.


Второе в Elasticsearch элементарно реализуется через API запросов (см. terms query), первое через чуть менее тривиальный механизм агрегаций.

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

# backend/backend/search/searcher.pyimport abcfrom dataclasses import dataclassfrom typing import Iterable, Optional@dataclassclass TagStats:    tag: str    cards_count: int@dataclassclass CardSearchResult:    total_count: int    card_ids: Iterable[str]    next_card_offset: Optional[int]    tag_stats: Iterable[TagStats]class Searcher(metaclass=abc.ABCMeta):    @abc.abstractmethod    def search_cards(self, query: str = "",                      count: int = 20, offset: int = 0,                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:        pass


Теперь перейдём к реализации. Первое, что нам нужно сделать завести агрегацию по полю tags:

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -10,6 +10,8 @@ ElasticsearchQuery = Any  class ElasticsearchSearcher(Searcher): +    TAGS_AGGREGATION_NAME = "tags_aggregation"+     def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):         self.elasticsearch_client = elasticsearch_client         self.cards_index_name = cards_index_name@@ -18,7 +20,12 @@ class ElasticsearchSearcher(Searcher):         result = self.elasticsearch_client.search(index=self.cards_index_name, body={             "size": count,             "from": offset,             "query": self._make_text_query(query) if query else self._match_all_query,+            "aggregations": {+                self.TAGS_AGGREGATION_NAME: {+                    "terms": {"field": "tags"}+                }+            }         })


Теперь в поисковом результате от Elasticsearch будет приходить поле aggregations, из которого по ключу TAGS_AGGREGATION_NAME мы сможем достать бакеты, содержащие информацию о том, какие значения лежат в поле tags у найденных документов и как часто они встречаются. Давайте извлечём эти данные и вернём в удобоваримом виде (as designed above):

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -28,10 +28,15 @@ class ElasticsearchSearcher(Searcher):         total_count = result["hits"]["total"]["value"]+        tag_stats = [+            TagStats(tag=bucket["key"], cards_count=bucket["doc_count"])+            for bucket in result["aggregations"][self.TAGS_AGGREGATION_NAME]["buckets"]+        ]         return CardSearchResult(             total_count=total_count,             card_ids=[hit["_id"] for hit in result["hits"]["hits"]],             next_card_offset=offset + count if offset + count < total_count else None,+            tag_stats=tag_stats,         )


Добавить применение фильтра самая лёгкая часть:

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -16,11 +16,17 @@ class ElasticsearchSearcher(Searcher):         self.elasticsearch_client = elasticsearch_client         self.cards_index_name = cards_index_name -    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:+    def search_cards(self, query: str = "", count: int = 20, offset: int = 0,+                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:         result = self.elasticsearch_client.search(index=self.cards_index_name, body={             "size": count,             "from": offset,-            "query": self._make_text_query(query) if query else self._match_all_query,+            "query": {+                "bool": {+                    "must": self._make_text_queries(query),+                    "filter": self._make_filter_queries(tags),+                }+            },             "aggregations": {


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

Осталось реализовать _make_filter_queries():

    def _make_filter_queries(self, tags: Optional[Iterable[str]] = None) -> List[ElasticsearchQuery]:        return [] if tags is None else [{            "term": {                "tags": {                    "value": tag                }            }        } for tag in tags]


На фронтенд-части опять-таки не стану останавливаться; весь код в этом коммите.

Ранжирование


Итак, наш поиск ищет карточки, фильтрует их по заданному списку тегов и выводит в каком-то порядке. Но в каком? Порядок очень важен для практичного поиска, но всё, что мы сделали за время наших разбирательств в плане порядка это намекнули Elasticsearch, что находить слова в заголовке карточки выгоднее, чем в описании или тегах, указав приоритет ^3 в multi-match query.

Несмотря на то, что по умолчанию Elasticsearch ранжирует документы довольно хитрой формулой на основе TF-IDF, для нашего воображаемого амбициозного стартапа этого вряд ли хватит. Если наши документы это товары, нам надо уметь учитывать их продажи; если это user-generated контент уметь учитывать его свежесть, и так далее. Но и просто отсортировать по числу продаж/дате добавления мы не можем, потому что тогда мы никак не учтём релевантность поисковому запросу.

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

Задача ранжирования очень сложна, так что неудивительно, что один из основных современных методов её решения машинное обучение. Приложение технологий машинного обучения к ранжированию собирательно называется learning to rank.

Типичный процесс выглядит так.

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

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

Извлекаем признаки. Мы придумываем для наших сущностей какое-то множество признаков, которые могли бы помочь нам оценить релевантность сущностей поисковым запросам. Помимо того же TF-IDF, который уже умеет для нас вычислять Elasticsearch, типичный пример CTR (click-through rate): мы берём логи нашего сервиса за всё время, для каждой пары сущность+поисковый запрос считаем, сколько раз сущность появлялась в выдаче по этому запросу и сколько раз её кликали, делим одно на другое, et voil простейшая оценка условной вероятности клика готова. Мы также можем придумать признаки для пользователя и парные признаки пользователь-сущность, чтобы сделать ранжирование персонализированным. Придумав признаки, мы пишем код, который их вычисляет, кладёт в какое-то хранилище и умеет отдавать в real time для заданного поискового запроса, пользователя и набора сущностей.

Собираем обучающий датасет. Тут много вариантов, но все они, как правило, формируются из логов хороших (например, клик и потом покупка) и плохих (например, клик и возврат на выдачу) событий в нашем сервисе. Когда мы собрали датасет, будь то список утверждений оценка релевантности товара X запросу Q примерно равна P, список пар товар X релевантнее товара Y запросу Q или набор списков для запроса Q товары P1, P2, правильно отранжировать так-то, мы ко всем фигурирующим в нём строкам подтягиваем соответствующие признаки.

Обучаем модель. Тут вся классика ML: train/test, гиперпараметры, переобучение, перфовидеокарты и так далее. Моделей, подходящих (и повсеместно использующихся) для ранжирования, много; упомяну как минимум XGBoost и CatBoost.

Встраиваем модель. Нам остаётся так или иначе прикрутить вычисление модели на лету для всего топа, чтобы до пользователя долетали уже отранжированные результаты. Тут много вариантов; в иллюстративных целях я (опять-таки) остановлюсь на простом Elasticsearch-плагине Learning to Rank.

Ранжирование: плагин Elasticsearch Learning to Rank


Elasticsearch Learning to Rank это плагин, добавляющий в Elasticsearch возможность вычислить ML-модель на выдаче и тут же отранжировать результаты согласно посчитанным ею скорам. Он также поможет нам получить признаки, идентичные используемым в real time, переиспользовав при этом способности Elasticsearch (TF-IDF и тому подобное).

Для начала нам нужно подключить плагин в нашем контейнере с Elasticsearch. Нам потребуется простенький Dockerfile

# elasticsearch/DockerfileFROM elasticsearch:7.5.1RUN ./bin/elasticsearch-plugin install --batch http://es-learn-to-rank.labs.o19s.com/ltr-1.1.2-es7.5.1.zip


и сопутствующие изменения в docker-compose.yml:

--- a/docker-compose.yml+++ b/docker-compose.yml@@ -5,7 +5,8 @@ services:   elasticsearch:-    image: "elasticsearch:7.5.1"+    build:+      context: elasticsearch     environment:       - discovery.type=single-node


Также нам потребуется поддержка плагина в Python-клиенте. С изумлением я обнаружил, что поддержка для Python не идёт в комплекте с плагином, так что специально для этой статьи я её запилил. Добавим elasticsearch_ltr в requirements.txt и проапгрейдим клиент в вайринге:

--- a/backend/backend/wiring.py+++ b/backend/backend/wiring.py@@ -1,5 +1,6 @@ import os +from elasticsearch_ltr import LTRClient from celery import Celery from elasticsearch import Elasticsearch from pymongo import MongoClient@@ -39,5 +40,6 @@ class Wiring(object):         self.task_manager = TaskManager(self.celery_app)          self.elasticsearch_client = Elasticsearch(hosts=self.settings.ELASTICSEARCH_HOSTS)+        LTRClient.infect_client(self.elasticsearch_client)         self.indexer = Indexer(self.elasticsearch_client, self.card_dao, self.settings.CARDS_INDEX_ALIAS)         self.searcher: Searcher = ElasticsearchSearcher(self.elasticsearch_client, self.settings.CARDS_INDEX_ALIAS)


Ранжирование: пилим признаки


Каждый запрос в Elasticsearch возвращает не только список ID документов, которые нашлись, но и некоторые их скоры (как вы бы перевели на русский язык слово score?). Так, если это match или multi-match query, которую мы используем, то скор это результат вычисления той самой хитрой формулы с участием TF-IDF; если bool query комбинация скоров вложенных запросов; если function score query результат вычисления заданной функции (например, значение какого-то числового поля в документе) и так далее. Плагин ELTR предоставляет нам возможность использовать скор любого запроса как признак, позволяя легко скомбинировать данные о том, насколько хорошо документ соответствует запросу (через multi-match query) и какие-то предрассчитанные статистики, которые мы заранее кладём в документ (через function score query).

Поскольку на руках у нас база TMDB 5000, в которой лежат описания фильмов и, помимо прочего, их рейтинги, давайте возьмём рейтинг в качестве образцово-показательного предрассчитанного признака.

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

  • Признаки мы будем хранить в отдельной коллекции и доставать отдельным менеджером. Сваливать все данные в одну сущность порочная практика.
  • В этот менеджер мы будем обращаться на этапе индексации и класть все имеющиеся признаки в индексируемые документы.
  • Чтобы знать схему индекса, нам надо перед началом построения индекса знать список всех существующих признаков. Этот список мы пока что захардкодим.
  • Поскольку мы не собираемся фильтровать документы по значениям признаков, а собираемся только извлекать их из уже найденных документов для обсчёта модели, мы выключим построение по новым полям обратных индексов опцией index: false в схеме и сэкономим за счёт этого немного места.


Ранжирование: собираем датасет


Поскольку, во-первых, у нас нет продакшна, а во-вторых, поля этой статьи слишком малы для рассказа про телеметрию, Kafka, NiFi, Hadoop, Spark и построение ETL-процессов, я просто сгенерирую случайные просмотры и клики для наших карточек и каких-то поисковых запросов. После этого нужно будет рассчитать признаки для получившихся пар карточка-запрос.

Пришла пора закопаться поглубже в API плагина ELTR. Чтобы рассчитать признаки, нам нужно будет создать сущность feature store (насколько я понимаю, фактически это просто индекс в Elasticsearch, в котором плагин хранит все свои данные), потом создать feature set список признаков с описанием, как вычислять каждый из них. После этого нам достаточно будет сходить в Elasticsearch с запросом специального вида, чтобы получить вектор значений признаков для каждой найденной сущности в результате.

Начнём с создания feature set:

# backend/backend/search/ranking.pyfrom typing import Iterable, List, Mappingfrom elasticsearch import Elasticsearchfrom elasticsearch_ltr import LTRClientfrom backend.search.features import CardFeaturesManagerclass SearchRankingManager:    DEFAULT_FEATURE_SET_NAME = "card_features"    def __init__(self, elasticsearch_client: Elasticsearch,                  card_features_manager: CardFeaturesManager,                 cards_index_name: str):        self.elasticsearch_client = elasticsearch_client        self.card_features_manager = card_features_manager        self.cards_index_name = cards_index_name    def initialize_ranking(self, feature_set_name=DEFAULT_FEATURE_SET_NAME):        ltr: LTRClient = self.elasticsearch_client.ltr        try:            # Создать feature store обязательно для работы,            # но при этом его нельзя создавать дважды \_()_/            ltr.create_feature_store()        except Exception as exc:            if "resource_already_exists_exception" not in str(exc):                raise        # Создаём feature set с невероятными ТРЕМЯ признаками!        ltr.create_feature_set(feature_set_name, {            "featureset": {                "features": [                    # Совпадение поискового запроса с названием                    # карточки может быть более сильным признаком,                     # чем совпадение со всем содержимым, поэтому                     # сделаем отдельный признак про это.                    self._make_feature("name_tf_idf", ["query"], {                        "match": {                            # ELTR позволяет параметризовать                            # запросы, вычисляющие признаки. В данном                            # случае нам, очевидно, нужен текст                             # запроса, чтобы правильно посчитать                             # скор match query.                            "name": "{{query}}"                        }                    }),                    # Скор запроса, которым мы ищем сейчас.                    self._make_feature("combined_tf_idf", ["query"], {                        "multi_match": {                            "query": "{{query}}",                            "fields": ["name^3", "tags.text", "text"]                        }                    }),                    *(                        # Добавляем все имеющиеся предрассчитанные                        # признаки через механизм function score.                        # Если по какой-то причине в документе                         # отсутствует искомое поле, берём 0.                        # (В настоящем проекте вам стоит                        # предусмотреть умолчания получше!)                        self._make_feature(feature_name, [], {                            "function_score": {                                "field_value_factor": {                                    "field": feature_name,                                    "missing": 0                                }                            }                        })                        for feature_name in sorted(self.card_features_manager.get_all_feature_names_set())                    )                ]            }        })    @staticmethod    def _make_feature(name, params, query):        return {            "name": name,            "params": params,            "template_language": "mustache",            "template": query,        }


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

    def compute_cards_features(self, query: str, card_ids: Iterable[str],                                feature_set_name=DEFAULT_FEATURE_SET_NAME) -> Mapping[str, List[float]]:        card_ids = list(card_ids)        result = self.elasticsearch_client.search({            "query": {                "bool": {                    # Нам не нужно проверять, находятся ли карточки                    # на самом деле по такому запросу  если нет,                     # соответствующие признаки просто будут нулевыми.                    # Поэтому оставляем только фильтр по ID.                    "filter": [                        {                            "terms": {                                "_id": card_ids                            }                        },                        # Это  специальный новый тип запроса,                        # вводимый плагином SLTR. Он заставит                        # плагин посчитать все факторы из указанного                        # feature set.                        # (Несмотря на то, что мы всё ещё в разделе                        # filter, этот запрос ничего не фильтрует.)                        {                            "sltr": {                                "_name": "logged_featureset",                                "featureset": feature_set_name,                                "params": {                                    # Та самая параметризация.                                     # Строка, переданная сюда,                                    # подставится в запросах                                    # вместо {{query}}.                                    "query": query                                }                            }                        }                    ]                }            },            # Следующая конструкция заставит плагин запомнить все            # рассчитанные признаки и добавить их в результат поиска.            "ext": {                "ltr_log": {                    "log_specs": {                        "name": "log_entry1",                        "named_query": "logged_featureset"                    }                }            },            "size": len(card_ids),        })        # Осталось достать значения признаков из (несколько        # замысловатого) результата поиска.        # (Чтобы понять, где в недрах результатов нужные мне         # значения, я просто делаю пробные запросы в Kibana.)        return {            hit["_id"]: [feature.get("value", float("nan")) for feature in hit["fields"]["_ltrlog"][0]["log_entry1"]]            for hit in result["hits"]["hits"]        }


Простенький скрипт, принимающий на вход CSV с запросами и ID карточек и выдающий CSV с признаками:

# backend/tools/compute_movie_features.pyimport csvimport itertoolsimport sysimport tqdmfrom backend.wiring import Wiringif __name__ == "__main__":    wiring = Wiring()    reader = iter(csv.reader(sys.stdin))    header = next(reader)    feature_names = wiring.search_ranking_manager.get_feature_names()    writer = csv.writer(sys.stdout)    writer.writerow(["query", "card_id"] + feature_names)    query_index = header.index("query")    card_id_index = header.index("card_id")    chunks = itertools.groupby(reader, lambda row: row[query_index])    for query, rows in tqdm.tqdm(chunks):        card_ids = [row[card_id_index] for row in rows]        features = wiring.search_ranking_manager.compute_cards_features(query, card_ids)        for card_id in card_ids:            writer.writerow((query, card_id, *features[card_id]))


Наконец можно это всё запустить!

# Создаём feature setdocker-compose exec backend python -m tools.initialize_search_ranking# Генерируем событияdocker-compose exec -T backend \    python -m tools.generate_movie_events \    < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv \    > ~/Downloads/habr-app-demo-dataset-events.csv# Считаем признакиdocker-compose exec -T backend \    python -m tools.compute_features \    < ~/Downloads/habr-app-demo-dataset-events.csv \    > ~/Downloads/habr-app-demo-dataset-features.csv


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

Ранжирование: обучаем и внедряем модель


Опустим подробности загрузки датасетов (скрипт полностью можно посмотреть в этом коммите) и перейдём сразу к делу.

# backend/tools/train_model.py... if __name__ == "__main__":    args = parser.parse_args()    feature_names, features = read_features(args.features)    events = read_events(args.events)    # Разделим запросы на train и test в соотношении 4 к 1.    all_queries = set(events.keys())    train_queries = random.sample(all_queries, int(0.8 * len(all_queries)))    test_queries = all_queries - set(train_queries)    # DMatrix  это тип данных, используемый xgboost.    # Фактически это массив значений признаков с названиями     # и лейблами. В качестве лейбла мы берём 1, если был клик,     # и 0, если не было (детали см. в коммите).    train_dmatrix = make_dmatrix(train_queries, events, feature_names, features)    test_dmatrix = make_dmatrix(test_queries, events, feature_names, features)    # Учим модель!    # Поля этой статьи всё ещё крайне малы для долгого разговора     # про ML, так что я возьму минимально модифицированный пример     # из официального туториала к XGBoost.    param = {        "max_depth": 2,        "eta": 0.3,        "objective": "binary:logistic",        "eval_metric": "auc",    }    num_round = 10    booster = xgboost.train(param, train_dmatrix, num_round, evals=((train_dmatrix, "train"), (test_dmatrix, "test")))    # Сохраняем обученную модель в файл.     booster.dump_model(args.output, dump_format="json")     # Санитарный минимум проверки того, как прошло обучение: давайте    # посмотрим на топ признаков по значимости и на ROC-кривую.    xgboost.plot_importance(booster)    plt.figure()    build_roc(test_dmatrix.get_label(), booster.predict(test_dmatrix))    plt.show()


Запускаем

python backend/tools/train_search_ranking_model.py \    --events ~/Downloads/habr-app-demo-dataset-events.csv \    --features ~/Downloads/habr-app-demo-dataset-features.csv \     -o ~/Downloads/habr-app-demo-model.xgb


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

Если всё сделано правильно, модель успешно обучится, и мы увидим две красивых картинки. Первая график значимости признаков:



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

Второй график ROC-кривая:



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

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

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -27,6 +30,19 @@ class ElasticsearchSearcher(Searcher):                     "filter": list(self._make_filter_queries(tags, ids)),                 }             },+            "rescore": {+                "window_size": 1000,+                "query": {+                    "rescore_query": {+                        "sltr": {+                            "params": {+                                "query": query+                            },+                            "model": self.ranking_manager.get_current_model_name()+                        }+                    }+                }+            },             "aggregations": {                 self.TAGS_AGGREGATION_NAME: {                     "terms": {"field": "tags"}


Теперь после того, как Elasticsearch произведёт нужный нам поиск и отранжирует результаты своим (довольно быстрым) алгоритмом, мы возьмём топ-1000 результатов и переранжируем, применив нашу (относительно медленную) машинно-обученную формулу. Успех!

Заключение


Мы взяли наше минималистичное веб-приложение и прошли путь от отсутствия фичи поиска как таковой до масштабируемого решения со множеством продвинутых возможностей. Сделать это было не так уж просто. Но и не так уж сложно! Итоговое приложение лежит в репозитории на Github в ветке со скромным названием feature/search и требует для запуска Docker и Python 3 с библиотеками для машинного обучения.

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

И, конечно, это решение не претендует на законченность и готовность к продакшну, а является исключительно иллюстрацией того, как всё может быть сделано. Улучшать его можно практически бесконечно!
  • Инкрементальная индексация. При модификации наших карточек через CardManager хорошо бы сразу обновлять их в индексе. Чтобы CardManager не знал, что у нас в сервисе есть ещё и поиск, и обошлось без циклических зависимостей, придётся прикрутить dependency inversion в том или ином виде.
  • Для индексации в конкретно нашем случае связки MongoDB с Elasticsearch можно использовать готовые решения вроде mongo-connector.
  • Пока пользователь вводит запрос, мы можем предлагать ему подсказки для этого в Elasticsearch есть специальная функциональность.
  • Когда запрос введён, стоит попытаться исправить в нём опечатки, и это тоже целое дело.
  • Для улучшения ранжирования нужно организовать логирование всех пользовательских событий, связанных с поиском, их агрегацию и расчёт признаков на основе счётчиков. Признаки сущность-запрос, сущность-пользователь, сущность-положение Меркурия тысячи их!
  • Особенно весело пилить агрегации событий не офлайновые (раз в день, раз в неделю), а реалтаймовые (задержка от события до учёта в признаках в пределах пяти минут). Вдвойне весело, когда событий сотни миллионов.
  • Предстоит разобраться с прогревом, нагрузочным тестированием, мониторингами.
  • Оркестрировать кластер нод с шардированием и репликацией это целое отдельное наслаждение.

Но чтобы статья осталась читабельного размера, я остановлюсь на этом и оставлю вас наедине с этими челленджами. Спасибо за внимание!
Подробнее..

Перевод Spring Cloud и Spring Boot. Часть 2 использование Zipkin Server для распределенной трассировки

02.02.2021 02:11:38 | Автор: admin

Из прошлой статьи (перевод на хабре, оригинал на англ.) вы узнали, как использовать Eureka Server для обнаружения и регистрации сервисов. В этой статье мы познакомимся с распределенной трассировкой (Distributed Tracing).

Что такое распределенная трассировка

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

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

В этой статье мы рассмотрим использование Zipkin Server для распределенной трассировки. Для этого нам понадобятся следующие зависимости в pom.xml.

<dependency>  <groupId>io.zipkin.java</groupId>  <artifactId>zipkin-server</artifactId>  <version>2.11.7</version></dependency><dependency>  <groupId>io.zipkin.java</groupId>  <artifactId>zipkin-autoconfigure-ui</artifactId>  <version>2.11.7</version></dependency>

Запуск Zipkin Server

Создайте maven-приложение и дайте ему какое-то имя (например, zipkin-server). Для создания Spring-проекта можно воспользоваться https://start.spring.io.

Ваш pom.xml должен выглядеть следующим образом:

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://personeltest.ru/away/maven.apache.org/POM/4.0.0" xmlns:xsi="http://personeltest.ru/away/www.w3.org/2001/XMLSchema-instance"      xsi:schemaLocation="http://personeltest.ru/away/maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">  <modelVersion>4.0.0</modelVersion>  <groupId>com.example.zikin.server</groupId>  <artifactId>zipkin-server</artifactId>  <version>0.0.1-SNAPSHOT</version>  <packaging>jar</packaging>  <name>zipkin-server</name>  <description>Demo project for Spring Boot</description>  <parent>     <groupId>org.springframework.boot</groupId>     <artifactId>spring-boot-starter-parent</artifactId>     <version>2.0.7.RELEASE</version>     <relativePath/> <!-- lookup parent from repository -->  </parent>  <properties>     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>     <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>     <java.version>1.8</java.version>  </properties>  <dependencies>     <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-web</artifactId>     </dependency>     <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-devtools</artifactId>        <scope>runtime</scope>     </dependency>     <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-test</artifactId>        <scope>test</scope>     </dependency>     <dependency>        <groupId>io.zipkin.java</groupId>        <artifactId>zipkin-server</artifactId>        <version>2.11.7</version>     </dependency>     <dependency>        <groupId>io.zipkin.java</groupId>        <artifactId>zipkin-autoconfigure-ui</artifactId>        <version>2.11.7</version>     </dependency>  </dependencies>  <build>     <plugins>        <plugin>           <groupId>org.springframework.boot</groupId>           <artifactId>spring-boot-maven-plugin</artifactId>        </plugin>     </plugins>  </build></project>

Теперь откройте ZipkinServerApplication.java и добавьте аннотацию @EnableZipkinServer, как показано ниже.

package com.example.zikin.server;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import zipkin2.server.internal.EnableZipkinServer;@SpringBootApplication@EnableZipkinServerpublic class ZipkinServerApplication {  public static void main(String[] args) {     SpringApplication.run(ZipkinServerApplication.class, args);  }}

В файл application.properties, расположенный в src/main/resources, добавьте следующие параметры.

spring.application.name=zipkin-serverserver.port=9411

Запустите проект как Java-приложение и перейдите по адресу http://localhost:9411/zipkin.

Здесь пока нет никаких трейсов, так как клиентские приложения еще не зарегистрированы.

Регистрация клиентского приложения в Zipkin Server

Регистрация Eureka Server в Zipkin Server

В предыдущей статье (перевод на хабре, оригинал на англ.) мы посмотрели, как запустить Eureka Server. Теперь его можно зарегистрировать в Zipkin Server, добавив одно свойство в файл application.properties.

spring.zipkin.base-url=http://localhost:9411/

Параметр spring.zipkin.base-url определяет адрес, где находится Zipkin Server.

Вам также необходимо добавить еще одну зависимость в pom.xml приложения Eureka Server.

<dependency>   <groupId>org.springframework.cloud</groupId>   <artifactId>spring-cloud-starter-zipkin</artifactId></dependency>

После того как Eureka Server запустится, в Zipkin Server вы увидите трейсы, а в Zipkin Server UI в поле Service Name появится eureka-server.

Регистрация клиентского Spring Boot-приложения в Zipkin Server

Для регистрации любого клиентского приложения, основанного на Spring Boot, нужно добавить одно свойство в файл application.properties.

spring.zipkin.base-url=http://localhost:9411/

И одну зависимость в pom.xml вашего клиентского приложения.

<dependency>   <groupId>org.springframework.cloud</groupId>   <artifactId>spring-cloud-starter-zipkin</artifactId></dependency>

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

Просмотр деталей трассировки в Zipkin Server

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

Теперь вы знаете, как использовать Zipkin Server для распределенной трассировки.


Перевод статьи подготовлен в преддверии старта курса Разработчик на Spring Framework. Приглашаем всех желающих зарегистрироваться на бесплатный демо-урок по теме Введение в облака, создание кластера в Mongo DB Atlas.

На уроке участники вместе с экспертом поговорят о видах облаков и настроят бесплатный Mongo DB кластер для своих проектов.

Подробнее..

Эксплуатация MongoDB в Kubernetes решения, их плюсы и минусы

26.03.2021 10:08:08 | Автор: admin

MongoDB одна из самых популярных NoSQL/документоориентированных баз данных в мире веб-разработки, поэтому многие наши клиенты используют её в своих продуктах, в том числе и в production. Значительная их часть функционирует в Kubernetes, так что хотелось бы поделиться накопленным опытом: какие варианты для запуска Mongo в K8s существуют? В чем их особенности? Как мы сами подошли к этому вопросу?

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

Главные вызовы

В частности, при размещении Mongo в кластере важно учитывать:

  1. Хранилище. Для гибкой работы в Kubernetes для Mongo лучше всего подойдут удаленные хранилища, которые можно переключать между узлами, если понадобится переместить Mongo при обновлении узлов кластера или их удалении. Однако удаленные диски обычно предоставляются с более низким показателем iops (в сравнении с локальными). Если база является высоконагруженной и требуются хорошие показания по latency, то на это стоит обратить внимание в первую очередь.

  2. Правильные requests и limits на podах с репликами Mongo (и соседствующих с ними podами на узле). Если не настроить их правильно, то поскольку Kubernetes более приветлив к stateless-приложениям можно получить нежелательное поведение, когда при внезапно возросшей нагрузке на узле Kubernetes начнет убивать podы с репликами Mongo и переносить их на соседние, менее загруженные. Это вдвойне неприятно по той причине, что перед тем, как pod с Mongo поднимется на другом узле, может пройти значительное время. Всё становится совсем плохо, если упавшая реплика была primary, т.к. это приведет к перевыборам: вся запись встанет, а приложение должно быть к этому готово и/или будет простаивать.

  3. В дополнение к предыдущему пункту: даже если случился пик нагрузки, в Kubernetes есть возможность быстро отмасштабировать узлы и перенести Mongo на узлы с большими ресурсами. Потому не стоит забывать про podDisruptionBudget, что не позволит удалять или переносить podы разом, старательно поддерживая указанное количество реплик в рабочем состоянии.

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

К счастью, на данный момент практически любой провайдер может предоставить любой тип хранилища на ваш выбор: от сетевых дисков до локальных с внушительным запасом по iops. Для динамического расширения кластера MongoDB подойдут только сетевые диски, но мы должны учитывать, что они всё же проигрывают в производительности локальным. Пример из Google Cloud:

А также они могут зависеть от дополнительных факторов:

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

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

Каким образом можно поднять MongoDB в Kubernetes?

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

1. Helm-чарт от Bitnami

И первое, что привлекает внимание, это Helm-чарт от Bitnami. Он довольно популярен, создан и поддерживается значительно долгое время.

Чарт позволяет запускать MongoDB несколькими способами:

  1. standalone;

  2. Replica Set (здесь и далее по умолчанию подразумевается терминология MongoDB; если речь пойдет про ReplicaSet в Kubernetes, на это будет явное указание);

  3. Replica Set + Arbiter.

Используется свой (т.е. неофициальный) образ.

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

Минимальная конфигурация, которая понадобится для поднятия, это:

1. Указать архитектуру (Values.yaml#L58-L60). По умолчанию это standalone, но нас интересует replicaset:

...architecture: replicaset...

2. Указать тип и размер хранилища (Values.yaml#L442-L463):

...persistence:  enabled: true  storageClass: "gp2" # у нас это general purpose 2 из AWS  accessModes:    - ReadWriteOnce  size: 120Gi...

После этого через helm install мы получаем готовый кластер MongoDB с инструкцией, как к нему подключиться из Kubernetes:

NAME: mongobitnamiLAST DEPLOYED: Fri Feb 26 09:00:04 2021NAMESPACE: mongodbSTATUS: deployedREVISION: 1TEST SUITE: NoneNOTES:** Please be patient while the chart is being deployed **MongoDB(R) can be accessed on the following DNS name(s) and ports from within your cluster:    mongobitnami-mongodb-0.mongobitnami-mongodb-headless.mongodb.svc.cluster.local:27017    mongobitnami-mongodb-1.mongobitnami-mongodb-headless.mongodb.svc.cluster.local:27017    mongobitnami-mongodb-2.mongobitnami-mongodb-headless.mongodb.svc.cluster.local:27017To get the root password run:    export MONGODB_ROOT_PASSWORD=$(kubectl get secret --namespace mongodb mongobitnami-mongodb -o jsonpath="{.data.mongodb-root-password}" | base64 --decode)To connect to your database, create a MongoDB(R) client container:    kubectl run --namespace mongodb mongobitnami-mongodb-client --rm --tty -i --restart='Never' --env="MONGODB_ROOT_PASSWORD=$MONGODB_ROOT_PASSWORD" --image docker.io/bitnami/mongodb:4.4.4-debian-10-r0 --command -- bashThen, run the following command:    mongo admin --host "mongobitnami-mongodb-0.mongobitnami-mongodb-headless.mongodb.svc.cluster.local:27017,mongobitnami-mongodb-1.mongobitnami-mongodb-headless.mongodb.svc.cluster.local:27017,mongobitnami-mongodb-2.mongobitnami-mongodb-headless.mongodb.svc.cluster.local:27017" --authenticationDatabase admin -u root -p $MONGODB_ROOT_PASSWORD

В пространстве имен увидим готовый кластер с арбитром (он enabled в чарте по умолчанию):

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

1. Установить PDB (по умолчанию он выключен). Мы не хотим терять кластер в случае drainа узлов можем позволить себе недоступность максимум 1 узла (Values.yaml#L430-L437):

...pdb:  create: true  maxUnavailable: 1...

2. Установить requests и limits (Values.yaml#L350-L360):

...resources:  limits:    memory: 8Gi  requests:     cpu: 4    memory: 4Gi...

В дополнение к этому можно повысить приоритет у podов с базой относительно других podов (Values.yaml#L326).

3. По умолчанию чарт создает нежесткое anti-affinity для podов кластера. Это означает, что scheduler будет стараться не назначать podы на одни и те же узлы, но если выбора не будет, то начнет размещать туда, где есть место.

Если у нас достаточно узлов и ресурсов, стоит сделать так, чтобы ни в коем случае не выносить две реплики кластера на один и тот же узел (Values.yaml#L270):

...podAntiAffinityPreset: hard...

Сам же запуск кластера в чарте происходит по следующему алгоритму:

  1. Запускаем StatefulSet с нужным числом реплик и двумя init-контейнерами: volume-permissions и auto-discovery.

  2. Volume-permissions создает директорию для данных и выставляет права на неё.

  3. Auto-discovery ждёт, пока появятся все сервисы, и пишет их адреса в shared_file, который является точкой передачи конфигурации между init-контейнером и основным контейнером.

  4. Запускается основной контейнер с подменой command, определяются переменные для entrypointа и run.sh.

  5. Запускается entrypoint.sh, который вызывает каскад из вложенных друг в друга Bash-скриптов с вызовом описанных в них функций.

  6. В конечном итоге инициализируется MongoDB через такую функцию:

      mongodb_initialize() {        local persisted=false        info "Initializing MongoDB..."        rm -f "$MONGODB_PID_FILE"        mongodb_copy_mounted_config        mongodb_set_net_conf        mongodb_set_log_conf        mongodb_set_storage_conf        if is_dir_empty "$MONGODB_DATA_DIR/db"; then                info "Deploying MongoDB from scratch..."                ensure_dir_exists "$MONGODB_DATA_DIR/db"                am_i_root && chown -R "$MONGODB_DAEMON_USER" "$MONGODB_DATA_DIR/db"                mongodb_start_bg                mongodb_create_users                if [[ -n "$MONGODB_REPLICA_SET_MODE" ]]; then                if [[ -n "$MONGODB_REPLICA_SET_KEY" ]]; then                        mongodb_create_keyfile "$MONGODB_REPLICA_SET_KEY"                        mongodb_set_keyfile_conf                fi                mongodb_set_replicasetmode_conf                mongodb_set_listen_all_conf                mongodb_configure_replica_set                fi                mongodb_stop        else                persisted=true                mongodb_set_auth_conf                info "Deploying MongoDB with persisted data..."                if [[ -n "$MONGODB_REPLICA_SET_MODE" ]]; then                if [[ -n "$MONGODB_REPLICA_SET_KEY" ]]; then                        mongodb_create_keyfile "$MONGODB_REPLICA_SET_KEY"                        mongodb_set_keyfile_conf                fi                if [[ "$MONGODB_REPLICA_SET_MODE" = "dynamic" ]]; then                        mongodb_ensure_dynamic_mode_consistency                fi                mongodb_set_replicasetmode_conf                fi        fi        mongodb_set_auth_conf        }

2. Устаревший чарт

Если поискать чуть глубже, можно обнаружить еще и старый чарт в главном репозитории Helm. Ныне он deprecated (в связи с выходом Helm 3 подробности см. здесь), но продолжает поддерживаться и использоваться различными организациями независимо друг от друга в своих репозиториях например, здесь им занимается норвежский университет UiB.

Этот чарт не умеет запускать Replica Set + Arbiter и использует маленький сторонний образ в init-контейнерах, но в остальном достаточно прост и отлично выполняет задачу деплоя небольшого кластера.

Мы стали использовать его в своих проектах задолго до того, как он стал deprecated (а это произошло не так давно 10 сентября 2020 года). За минувшее время чарт сильно изменился, однако в то же время сохранил основную логику работы. Для своих задач мы сильно урезали чарт, сделав его максимально лаконичным и убрав всё лишнее: шаблонизацию и функции, которые неактуальны для наших задач.

Минимальная конфигурация сильно схожа с предыдущим чартом, поэтому подробно останавливаться на ней не буду только отмечу, что affinity придется задавать вручную (Values.yaml#L108):

      affinity:        podAntiAffinity:          requiredDuringSchedulingIgnoredDuringExecution:          - labelSelector:              matchLabels:               app: mongodb-replicaset

Алгоритм его работы схож с чартом от Bitnami, но менее нагружен (нет такого нагромождения маленьких скриптов с функциями):

1. Init-контейнер copyconfig копирует конфиг из configdb-readonly (ConfigMap) и ключ из секрета в директорию для конфигов (emptyDir, который будет смонтирован в основной контейнер).

2. Секретный образ unguiculus/mongodb-install копирует исполнительный файл peer-finder в work-dir.

3. Init-контейнер bootstrap запускает peer-finder с параметром /init/on-start.sh этот скрипт занимается поиском поднятых узлов кластера MongoDB и добавлением их в конфигурационный файл Mongo.

4. Скрипт /init/on-start.sh отрабатывает в зависимости от конфигурации, передаваемой ему через переменные окружения (аутентификация, добавление дополнительных пользователей, генерация SSL-сертификатов), плюс может исполнять дополнительные кастомные скрипты, которые мы хотим запускать перед стартом базы.

5. Список пиров получают как:

          args:            - -on-start=/init/on-start.sh            - "-service=mongodb"log "Reading standard input..."while read -ra line; do    if [[ "${line}" == *"${my_hostname}"* ]]; then        service_name="$line"    fi    peers=("${peers[@]}" "$line")done

6. Выполняется проверка по списку пиров: кто из них primary, а кто master.

  • Если не primary, то пир добавляется к primary в кластер.

  • Если это самый первый пир, он инициализирует себя и объявляется мастером.

7. Конфигурируются пользователи с правами администратора.

8. Запускается сам процесс MongoDB.

3. Официальный оператор

В 2020 году вышел в свет официальный Kubernetes-оператор community-версии MongoDB. Он позволяет легко разворачивать, обновлять и масштабировать кластер MongoDB. Кроме того, оператор гораздо проще чартов в первичной настройке.

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

Архитектура оператора:

В отличие от обычной установки через Helm в данном случае понадобится установить сам оператор и CRD (CustomResourceDefinition), что будет использоваться для создания объектов в Kubernetes.

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

  1. Оператор создает StatefulSet, содержащий podы с контейнерами MongoDB. Каждый из них член ReplicaSetа в Kubernetes.

  2. Создается и обновляется конфиг для sidecar-контейнера агента, который будет конфигурировать MongoDB в каждом podе. Конфиг хранится в Kubernetes-секрете.

  3. Создается pod с одним init-контейнером и двумя основными.

    1. Init-контейнер копирует бинарный файл хука, проверяющего версию MongoDB, в общий empty-dir volume (для его передачи в основной контейнер).

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

  4. Далее контейнер с агентом на основе конфигурации, указанной в Custom Resource для кластера, генерирует конфиг для самой MongoDB.

Вся установка кластера укладывается в:

---apiVersion: mongodb.com/v1kind: MongoDBCommunitymetadata:  name: example-mongodbspec:  members: 3  type: ReplicaSet  version: "4.2.6"  security:    authentication:      modes: ["SCRAM"]  users:    - name: my-user      db: admin      passwordSecretRef: # ссылка на секрет ниже для генерации пароля юзера        name: my-user-password      roles:        - name: clusterAdmin          db: admin        - name: userAdminAnyDatabase          db: admin      scramCredentialsSecretName: my-scram# учетная запись пользователя генерируется из этого секрета# после того, как она будет создана, секрет больше не потребуется---apiVersion: v1kind: Secretmetadata:  name: my-user-passwordtype: OpaquestringData:  password: 58LObjiMpxcjP1sMDW

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

Но в то же время он уступает предыдущим вариантам тем, что у него нет встроенной возможности отдачи метрик в Prometheus, а вариант запуска только один Replica Set (нельзя создать арбитра). Кроме того, данный способ развертывания не получится сильно кастомизировать, т.к. практически все параметры регулируются через кастомную сущность для поднятия кластера, а сама она ограничена.

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

Как уже упоминалось, существует и enterprise-версия оператора, которая предоставляет большие возможности в том числе, установку не только Replica Setов, но и shared-кластеров с настройками шардирования, конфигурации для доступа извне кластера (с указанием имен, по которым он будет доступен извне), дополнительные способы аутентификации т.д. И, конечно же, документация к нему описана гораздо лучше.

Заключение

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

У разных вариантов запуска MongoDB есть разные плюсы. Чарты легко модифицировать под ваши нужды, но вы столкнетесь с проблемами при обновлении MongoDB или при добавлении узлов, т.к. всё равно потребуются ручные операции с кластером. Способ с оператором в этом смысле лучше, но ограничен по другим параметрам (по крайней мере, в своей community-редакции). Также мы не нашли ни в одном из описанных вариантов возможность из коробки запускать скрытые реплики.

Наконец, не стоит забывать, что есть и managed-решения для Mongo, однако мы в своей практике стараемся не привязываться к определенным провайдерам и предпочитаем варианты для чистого Kubernetes. Мы также не рассматривали Percona Kubernetes Operator for PSMDB, потому что он ориентирован на вариацию MongoDB от одноимённой компании (Percona Server for MongoDB).

P.S.

Читайте также в нашем блоге:

Подробнее..

Фоновые задачи на Faust, Часть I Введение

20.09.2020 16:05:00 | Автор: admin

http://personeltest.ru/aways/habrastorage.org/webt/wo/6b/ui/wo6buieqgfwzr4y5tczce4js0rc.png


Как я дошёл до жизни такой?


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


Скажу так, проект очень интересный и вполне успешно работает в других приложениях нашей команды, да и сам автор говорит о том, что смог выкатить в прод, заюзав асинхронный пул. Но, к сожалению, мне это не очень подошло, так как обнаружилась проблема с групповым запуском задач (см. group). На момент написания статьи issue уже закрыта, однако, работа велась на протяжении месяца. В любом случае, автору удачи и всех благ, так как рабочие штуки на либе уже есть в общем, дело во мне и для меня оказался инструмент сыроват. Вдобавок, в некоторых задачах было по 2-3 http-запроса к разным сервисам, таким образом даже при оптимизации задач мы создаём 4 тысячи tcp соединений, примерно каждые 2 часа не очень Хотелось бы создавать сессию на один тип задач при запуске воркеров. Чуть подробнее о большом кол-ве запросов через aiohttp тут.


В связи с этим, я начал искать альтернативы и нашёл! Создателями celery, а конкретно, как я понял Ask Solem, была создана Faust, изначально для проекта robinhood. Faust написана под впечатлением от Kafka Streams и работает с Kafka в качестве брокера, также для хранения результатов от работы агентов используется rocksdb, а самое главное это то, что библиотека асинхронна.


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


Что будем делать?


Итак, в небольшой серии статей я покажу, как собирать данные в фоновых задачах с помощью Faust. Источником для нашего пример-проекта будет, как следует из названия, alphavantage.co. Я продемонстрирую, как писать агентов (sink, топики, партиции), как делать регулярное (cron) выполнение, удобнейшие cli-комманды faust (обёртка над click), простой кластеринг, а в конце прикрутим datadog (работающий из коробки) и попытаемся, что-нибудь увидеть. Для хранения собранных данных будем использовать mongodb и motor для подключения.


P.S. Судя по уверенности, с которой написан пункт про мониторинг, думаю, что читатель в конце последней статьи всё-таки будет выглядеть, как-то так:


http://personeltest.ru/aways/habrastorage.org/webt/e5/v1/pl/e5v1plkcyvxyoawde4motgq7vpm.png


Требования к проекту


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


  1. Выгружать ценные бумаги и overview по ним (в т.ч. прибыли и убытки, баланс, cash flow за последний год) регулярно
  2. Выгружать исторические данные (для каждого торгового года находить экстремумы цены закрытия торгов) регулярно
  3. Выгружать последние торговые данные регулярно
  4. Выгружать настроенный список индикаторов для каждой ценной бумаги регулярно

Как полагается, выбираем имя проекту с потолка: horton


Готовим инфраструктуру


Заголовок конечно сильный, однако, всё что нужно сделать это написать небольшой конфиг для docker-compose с kafka (и zookeeper в одном контейнере), kafdrop (если нам захочется посмотреть сообщения в топиках), mongodb. Получаем [docker-compose.yml](https://github.com/Egnod/horton/blob/562fa5ec14df952cd74760acf76e141707d2ef58/docker-compose.yml) следующего вида:


version: '3'services:  db:    container_name: horton-mongodb-local    image: mongo:4.2-bionic    command: mongod --port 20017    restart: always    ports:      - 20017:20017    environment:      - MONGO_INITDB_DATABASE=horton      - MONGO_INITDB_ROOT_USERNAME=admin      - MONGO_INITDB_ROOT_PASSWORD=admin_password  kafka-service:    container_name: horton-kafka-local    image: obsidiandynamics/kafka    restart: always    ports:      - "2181:2181"      - "9092:9092"    environment:      KAFKA_LISTENERS: "INTERNAL://:29092,EXTERNAL://:9092"      KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka-service:29092,EXTERNAL://localhost:9092"      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT"      KAFKA_INTER_BROKER_LISTENER_NAME: "INTERNAL"      KAFKA_ZOOKEEPER_SESSION_TIMEOUT: "6000"      KAFKA_RESTART_ATTEMPTS: "10"      KAFKA_RESTART_DELAY: "5"      ZOOKEEPER_AUTOPURGE_PURGE_INTERVAL: "0"  kafdrop:    container_name: horton-kafdrop-local    image: 'obsidiandynamics/kafdrop:latest'    restart: always    ports:      - '9000:9000'    environment:      KAFKA_BROKERCONNECT: kafka-service:29092    depends_on:      - kafka-service

Тут вообще ничего сложного. Для kafka объявили два listener'а: одного (internal) для использования внутри композной сети, а второго (external) для запросов из вне, поэтому пробросили его наружу. 2181 порт zookeeper'а. По остальному, я думаю, ясно.


Готовим скелет проекта


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


horton docker-compose.yml horton     agents.py *     alphavantage.py *     app.py *     config.py     database      connect.py      cruds       base.py       __init__.py       security.py *      __init__.py     __init__.py     records.py *     tasks.py *

*Всё что я отметил мы пока не трогаем, а просто создаём пустые файлы.**


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


Начнём с зависимостей и мета о проекте pyproject.toml


Далее, запускаем установку зависимостей и создание virtualenv (либо, можете сами создать папку venv и активировать окружение):


pip3 install poetry (если ещё не установлено)poetry install

Теперь создадим config.yml креды и куда стучаться. Сразу туда можно разместить и данные для alphavantage. Ну и переходим к config.py извлекаем данные для приложения из нашего конфига. Да, каюсь, заюзал свою либу sitri.


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


Что будет дальше?


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


Итак, а в этой самой следующей части мы:


  1. Напишем небольшой клиентик для alphavantage на aiohttp с запросами на нужные нам эндпоинты.
  2. Сделаем агента, который будет собирать данные о ценных бумагах и исторические цены по ним.

Код проекта


Код этой части

Подробнее..

Фоновые задачи на Faust, Часть II Агенты и Команды

23.09.2020 04:06:02 | Автор: admin

Оглавление

  1. Часть I: Введение

  2. Часть II: Агенты и Команды

Что мы тут делаем?

Итак-итак, вторая часть. Как и писалось ранее, в ней мы сделаем следующее:

  1. Напишем небольшой клиентик для alphavantage на aiohttp с запросами на нужные нам эндпоинты.

  2. Сделаем агента, который будет собирать данные о ценных бумагах и мета информацию по ним.

Но, это то, что мы сделаем для самого проекта, а в плане исследования faust мы узнаем, как писать агентов, обрабатывающих стрим событий из kafka, а так же как написать команды (обёртка на click), в нашем случаи - для ручного пуша сообщения в топик, за которым следит агент.

Подготовка

Клиент AlphaVantage

Для начала, напишем небольшой aiohttp клиентик для запросов на alphavantage.

alphavantage.py

Spoiler
import urllib.parse as urlparsefrom io import StringIOfrom typing import Any, Dict, List, Unionimport aiohttpimport pandas as pdimport stringcasefrom loguru import loggerfrom horton.config import API_ENDPOINTclass AlphaVantageClient:    def __init__(        self,        session: aiohttp.ClientSession,        api_key: str,        api_endpoint: str = API_ENDPOINT,    ):        self._query_params = {"datatype": "json", "apikey": api_key}        self._api_endpoint = api_endpoint        self._session = session    @logger.catch    def _format_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:        formatted_data = {}        for field, item in data.items():            formatted_data[stringcase.snakecase(field)] = item        return formatted_data    @logger.catch    async def _construct_query(        self, function: str, to_json: bool = True, **kwargs    ) -> Union[Dict[str, Any], str]:        path = "query/"        async with self._session.get(            urlparse.urljoin(self._api_endpoint, path),            params={"function": function, **kwargs, **self._query_params},        ) as response:            data = (await response.json()) if to_json else (await response.text())            if to_json:                data = self._format_fields(data)        return data    @logger.catch    async def get_securities(self, state: str = "active") -> List[Dict[str, str]]:        data = await self._construct_query("LISTING_STATUS", state=state, to_json=False)        data = pd.read_csv(StringIO(data))        securities = data.to_dict("records")        for index, security in enumerate(securities):            security = self._format_fields(security)            security["_type"] = "physical"            securities[index] = security        return securities    @logger.catch    async def get_security_overview(self, symbol: str) -> Dict[str, str]:        return await self._construct_query("OVERVIEW", symbol=symbol)    @logger.catch    async def get_historical_data(self, symbol: str) -> Dict[str, Any]:        return await self._construct_query(            "TIME_SERIES_DAILY_ADJUSTED", symbol=symbol, outputsize="full"        )    @logger.catch    async def get_last_price_data(self, symbol: str) -> Dict[str, Any]:        return await self._construct_query("GLOBAL_QUOTE", symbol=symbol)    @logger.catch    async def get_indicator_data(        self, symbol: str, indicator: str, **indicator_options    ) -> Dict[str, Any]:        return await self._construct_query(            indicator, symbol=symbol, **indicator_options        )

Собственно по нему всё ясно:

  1. API AlphaVantage достаточно просто и красиво спроектирована, поэтому все запросы я решил проводить через метод construct_query где в свою очередь идёт http вызов.

  2. Все поля я привожу к snake_case для удобства.

  3. Ну и декорация logger.catch для красивого и информативного вывода трейсбека.

P.S. Незабываем локально добавить токен alphavantage в config.yml, либо экспортировать переменную среды HORTON_SERVICE_APIKEY. Получаем токен тут.

CRUD-класс

У нас будет коллекция securities для хранения мета информации о ценных бумагах.

database/security.py

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

get_app()

Добавим функцию создания объекта приложения в app.py

Spoiler
import faustfrom horton.config import KAFKA_BROKERSdef get_app():    return faust.App("horton", broker=KAFKA_BROKERS)

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

Основная часть

Агент сбора и сохранения списка ценных бумаг

app = get_app()collect_securities_topic = app.topic("collect_securities", internal=True)@app.agent(collect_securities_topic)async def collect_securities(stream: StreamT[None]) -> AsyncIterable[bool]:pass

Так, сначала получаем объект faust-приложения - это достаточно просто. Далее, мы явно объявляем топик для нашего агента... Тут стоит упомянуть, что это такое, что за параметр internal и как это можно устроить по-другому.

  1. Топики в kafka, если мы хотим узнать точное определение, то лучше прочитать офф. доку, либо можно прочитать конспект на хабре на русском, где так же всё достаточно точно отражено :)

  2. Параметр internal, достаточно хорошо описанный в доке faust, позволяет нам настраивать топик прямо в коде, естественно, имеются ввиду параметры, предусмотренные разработчиками faust, например: retention, retention policy (по-умолчанию delete, но можно установить и compact), кол-во партиций на топик (partitions, чтобы сделать, например, меньшее чем глобальное значение приложения faust).

  3. Вообще, агент может создавать сам управляемый топик с глобальными значениями, однако, я люблю объявлять всё явно. К тому же, некоторые параметры (например, кол-во партиций или retention policy) топика в объявлении агента настроить нельзя.

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

app = get_app()@app.agent()async def collect_securities(stream: StreamT[None]) -> AsyncIterable[bool]:pass

Ну а теперь, опишем, что будет делать наш агент :)

app = get_app()collect_securities_topic = app.topic("collect_securities", internal=True)@app.agent(collect_securities_topic)async def collect_securities(stream: StreamT[None]) -> AsyncIterable[bool]:    async with aiohttp.ClientSession() as session:        async for _ in stream:            logger.info("Start collect securities")            client = AlphaVantageClient(session, API_KEY)            securities = await client.get_securities()            for security in securities:                await SecurityCRUD.update_one(                    {"symbol": security["symbol"], "exchange": security["exchange"]}, security, upsert=True                )            yield True

Итак, в начале агента мы открываем aiohttp сессию для запросов через наш клиент. Таким образом, при запуске воркера, когда будет запущен наш агент, сразу же будет открыта сессия - одна, на всё время работы воркера (или несколько, если изменить параметр concurrency у агента с дефолтной единички).

Далее, мы идём по стриму (сообщение мы помещаем в _, так как нам, в данном агенте, безразлично содержание) сообщений из нашего топика, если они есть при текущем сдвиге (offset), иначе, наш цикл будет ожидать их поступления. Ну а внутри нашего цикла, мы логируем поступление сообщения, получаем список активных (get_securities возвращает по-умолчания только active, см. код клиента) ценных бумаг и сохраняем его в базу, проверяя при этом, есть ли бумага с таким тикером и биржей в БД, если есть, то она (бумага) просто обновится.

Запустим наше творение!

> docker-compose up -d... Запуск контейнеров ...> faust -A horton.agents worker --without-web -l info

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

В нашей команде запуска мы указали faust'у, где искать объект приложения и что делать с ним (запустить воркер) с уровнем вывода логов info. Получаем следующий вывод:

Spoiler
aS v1.10.4 id           horton                                             transport    [URL('kafka://localhost:9092')]                    store        memory:                                            log          -stderr- (info)                                    pid          1271262                                            hostname     host-name                                          platform     CPython 3.8.2 (Linux x86_64)                       drivers                                                           transport  aiokafka=1.1.6                                       web        aiohttp=3.6.2                                      datadir      /path/to/project/horton-data                       appdir       /path/to/project/horton-data/v1                   ... логи, логи, логи ...Topic Partition Set topic                       partitions  collect_securities          {0-7}       horton-__assignor-__leader  {0}         

Оно живое!!!

Посмотрим на partition set. Как мы видим, был создан топик с именем, которое мы обозначили в коде, кол-во партиций дефолтное (8, взятое из topic_partitions - параметра объекта приложения), так как у нашего топика мы индивидуальное значение (через partitions) не указывали. Запущенному агенту в воркере отведены все 8 партициций, так как он единственный, но об этом будет подробнее в части про кластеринг.

Что же, теперь можем зайти в другое окно терминала и отправить пустое сообщение в наш топик:

> faust -A horton.agents send @collect_securities{"topic": "collect_securities", "partition": 6, "topic_partition": ["collect_securities", 6], "offset": 0, "timestamp": ..., "timestamp_type": 0}

P.S. с помощью @ мы показываем, что посылаем сообщение в топик с именем "collect_securities".

В данном случае, сообщение ушло в 6 партицию - это можно проверить, зайдя в kafdrop на localhost:9000

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

2020-09-23 00:26:37.304 | INFO     | horton.agents:collect_securities:40 - Start collect securities

Так же, можем заглянуть в mongo (с помощью Robo3T или Studio3T) и увидеть, что ценные бумаги в базе:

Я не миллиардер, а потому, довольствуемся первым вариантом просмотра.

Счастье и радость - первый агент готов :)

Агент готов, да здравствует новый агент!

Да, господа, нами пройдена только 1/3 пути, уготованного этой статьёй, но не унывайте, так как сейчас будет уже легче.

Итак, теперь нам нужен агент, который собирает мета информацию и складывает её в документ коллекции:

collect_security_overview_topic = app.topic("collect_security_overview", internal=True)@app.agent(collect_security_overview_topic)async def collect_security_overview(    stream: StreamT[?],) -> AsyncIterable[bool]:    async with aiohttp.ClientSession() as session:        async for event in stream:            ...

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

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

import faustclass CollectSecurityOverview(faust.Record):    symbol: str    exchange: str

Как вы уже могли догадаться, faust для описания схемы сообщения использует аннотацию типов в python, поэтому и минимальная версия, поддерживаемая библиотекой - 3.6.

Вернёмся к агенту, установим типы и допишем его:

collect_security_overview_topic = app.topic(    "collect_security_overview", internal=True, value_type=CollectSecurityOverview)@app.agent(collect_security_overview_topic)async def collect_security_overview(    stream: StreamT[CollectSecurityOverview],) -> AsyncIterable[bool]:    async with aiohttp.ClientSession() as session:        async for event in stream:            logger.info(                "Start collect security [{symbol}] overview", symbol=event.symbol            )            client = AlphaVantageClient(session, API_KEY)            security_overview = await client.get_security_overview(event.symbol)            await SecurityCRUD.update_one({"symbol": event.symbol, "exchange": event.exchange}, security_overview)            yield True

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

Ну что же, последний штрих - добавим в collect_securitites вызов агента сбора мета информации:

....for security in securities:    await SecurityCRUD.update_one({            "symbol": security["symbol"],            "exchange": security["exchange"]        },        security,        upsert = True,    )    await collect_security_overview.cast(        CollectSecurityOverview(symbol = security["symbol"], exchange = security["exchange"])    )....

Используем ранее объявлению схему для сообщения. В данном случае, я использовал метод .cast, так как нам не нужно ожидать результат от агента, но стоит упомянуть, что способов послать сообщение в топик:

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

  2. send - не блокирует, так как не ожидает результата. Можно указать агента в топик которого уйдёт результат.

  3. ask - ожидает результата. Можно указать агента в топик которого уйдёт результат.

Итак, на этом с агентами на сегодня всё!

Команда мечты

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

После объявленных агентов в agents.py добавим функцию с декоратором app.command, вызывающую метод cast у collect_securitites:

@app.command()async def start_collect_securities():    """Collect securities and overview."""    await collect_securities.cast()

Таким образом, если мы вызовем список команд, в нём будет и наша новая команда:

> faust -A horton.agents --help....Commands:  agents                    List agents.  clean-versions            Delete old version directories.  completion                Output shell completion to be evaluated by the...  livecheck                 Manage LiveCheck instances.  model                     Show model detail.  models                    List all available models as a tabulated list.  reset                     Delete local table state.  send                      Send message to agent/topic.  start-collect-securities  Collect securities and overview.  tables                    List available tables.  worker                    Start worker instance for given app.

Ею мы можем воспользоваться, как любой другой, поэтому перезапустим faust воркер и начнём полноценный сбор ценных бумаг:

> faust -A horton.agents start-collect-securities

Что будет дальше?

В следующей части мы, на примере оставшихся агентов, рассмотрим, механизм sink для поиска экстремум в ценах закрытия торгов за год и cron-запуск агентов.

На сегодня всё! Спасибо за прочтение :)

Код этой части

P.S. Под прошлой частью меня спросили про faust и confluent kafka (какие есть у confluent фичи). Кажется, что confluent во многом функциональнее, но дело в том, что faust не имеет полноценной поддержки клиента для confluent - это следует из описания ограничений клиентов в доке.

Подробнее..

Аспекты учета и поиска геоинформационных объектов с задействованием MongoDB

02.04.2021 04:18:46 | Автор: admin

Онлайн-курс:"OTUS.NoSQL".

Проект: https://github.com/BorisPlus/mongodb_geo

Введение

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

Академический подход написания статей подразумевал "сведения, отражающие свойства ... объектов материального мира". Однако на практике имелся факт осуществления энтузиастом накладки поверх Гугл-карт через штатное API рисунков с топографией Средиземья и построение маршрутов героев Дж. Толкина, что не совсем "материально". Другим стыком с нематериальным может служить пример наборов данных по типуGeoIP,E.164,ABC.

Результат исследования представляет собой инструмент отображения хранящихся в MongoDB сведений о геообъектах на карте посредством web-доступа. Клиентская часть реализована с использованиемLeaflet(JavaScript-библиотека с открытым исходным кодом для мобильных интерактивных карт) и набора соответствующих процедур асинхронного получения сведений от серверной части. Сервис разработан на базе созданного ранее на курсе"OTUS.Web-python"конструктора программного обеспечения"Dummy WSGI Framework"(репозиторий) на языке программирования Python3 с задействованием WSGI.

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

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

Гео-объекты MongoDB

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

Более сложные структуры, такие как:набор точек,набор линий,набор полигоновиколлекция геообъектовне рассматриваются.

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

<field>: { type: <GeoJSON type> , coordinates: <coordinates> }

В рамках получения сведений клиентской стороной от серверной геообъекты искусственно поделены на два вида - статичные и динамичные.

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

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

Предполагается, что инфраструктура MongoDB развернута.
mongo  192.168.102.99  --port 49326 ---> use otus switched to db otus > db.dropDatabase() { "dropped" : "otus", "ok" : 1 } > use otus switched to db otus > db otus > show collections

Точки (статичные)

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

> db.meteorites.createIndex( { "ident": 1 }, { unique: true } )> db.meteorites.createIndex( { "location" : "2dsphere" } ) 

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

location: { type: 'Point' , coordinates: [ LON, LAT ] }

Загрузка исходных данных о метеоритах:

mongoimport --host 192.168.102.99  --port 49326 \--db otus --collection meteorites --jsonArray \--file ./foreign/meteorites/data.json2021-03-28T10:28:09.443+0300    connected to: mongodb://192.168.102.99:49326/2021-03-28T10:28:12.443+0300    [<span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);"><span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);">#</span>##.....................] otus.meteorites      1.62MB/10.1MB (16.0%)</span>2021-03-28T10:28:15.443+0300    [<span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);"><span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);">#</span>########...............] otus.meteorites      3.97MB/10.1MB (39.4%)</span>2021-03-28T10:28:18.443+0300    [<span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);"><span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);">#</span>###########............] otus.meteorites      5.39MB/10.1MB (53.4%)</span>2021-03-28T10:28:21.443+0300    [<span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);"><span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);">#</span>################.......] otus.meteorites      7.23MB/10.1MB (71.6%)</span>2021-03-28T10:28:24.443+0300    [<span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);"><span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);">#</span>####################...] otus.meteorites      8.83MB/10.1MB (87.5%)</span>2021-03-28T10:28:27.443+0300    [<span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);"><span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);">#</span>######################.] otus.meteorites      9.71MB/10.1MB (96.3%)</span>2021-03-28T10:28:28.453+0300    [<span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);"><span class="pl-c" style="box-sizing: border-box; color: var(--color-prettylights-syntax-comment);">#</span>#######################] otus.meteorites      10.1MB/10.1MB (100.0%)</span>2021-03-28T10:28:28.454+0300    45716 document(s) imported successfully. 0 document(s) failed to import.

Исходя из уже имеющегося опыта, из набора 45716 объектов необходимо удалить метеорит, который не относится к земной поверхности (марсианский метеоритMeridiani Planum), так как его координаты не соответствуют стандарту земного геопозиционирования и не могут быть помещены в индекс (индексирование приводит к ошибкеCan't extract geo keys: ... longitude/latitude is out of bounds, ..., равно как и вставка таких данных в индекс).

db.meteorites.remove({"ident" : "32789"});

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

db.meteorites.updateMany(     {"geolocation":{$exists:true}},    [{        $set: {            "location" : {                "type": "Point",                "coordinates" : [                     { $toDouble: "$geolocation.longitude" } ,                     { $toDouble: "$geolocation.latitude" }                 ]            }        }    }]);

В результате в MongoDB в коллекцииmeteoritesв атрибутахlocationсодержится информация о местоположении 38400 метеоритах из их общего числа 45716.

Важное замечание:согласнодокументацииданный порядок следования координат{ долгота, широта }является единственно верным с точки зрения MongoDB (If specifying latitude and longitude coordinates, list the longitude first and then latitude). Необходимо заострить внимание на этом обстоятельстве, так как в последующем при отображении информации на карте Leaflet нужен будет другойпорядокабсолютно для всех координат любых геообъектов -{ широта, долгота }. Указанное приводит к тому, что после получения сведений из MongoDB требуется произвести перестановку в парах координат или представить их в виде "словаря"{ lon: долгота, lat: широта }. Если для точки это выражается в одной перестановке, то для полигона это происходит в рамках итерации по точкам границы. Было бы хорошо, если бы MongoDB поддерживала хранение и в формате{ широта, долгота }.

Полигоны (статичные)

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

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

Необходимые коллекция, поля и индексы:

db.geo_wikimapia_polygons.createIndex( { "ident": 1 }, { unique: true } )db.geo_wikimapia_polygons.createIndex( { "area" : "2dsphere" } ) 

Сбордемонстрационных данных реализован на языке программирования Python3 с использованием библиотекиpymongo.

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

Изначально сведения о местоположении полигона разнесены по полям записи, то есть фактически отсутствует атрибут необходимой для MongoDB структуры для геообъекта типа "полигон". Поэтому в его качестве выступает дополнительное полеarea, куда перенесены сведения в необходимом формате (происходит сразу привставкесведений в рамках их онлайн-получения). Структура геообъекта типаполигонимеет вид:

area: { type: 'Polygon' , coordinates: [[ [LON_1, LAT_1], [LON_2, LAT_2], ..., [LON_1, LAT_1] ]] }

В результате запросов к WikiMapia:

python3 ./foreign/onetime_static_load_polygons_wikimapia.py Page 1 has docs count 50Page 2 has docs count 50...Page 37 has docs count 35Max page 37 with some data

в MongoDB накоплена информация в отношении:

> db.geo_wikimapia_polygons.count()

1832 зданий и сооружений.

Важное замечание: в MongoDB сведения о полигоне должны удовлетворять спецификации (пункт 3.1.6 RFC 7946 "GeoJSON" August 2016). В частности, полигон, имеющий пересечение граней, не может быть добавлен (иначе в MongoDB возникает ошибкаEdges <number K> and <number M> cross. Edge locations in degrees: [Kx1, Ky1]-[Kx2, Ky2] and [Mx1, My1]-[Mx2,My2]). Кроме того, важно, чтобы полигон был "замкнут", то есть крайняя точка должна совпадать и первоначальной (иначе в MongoDB возникает ошибкаLoop is not closed). WikiMapia же иным образом подходит к требованиям достоверности координат. Поэтому из 1835 полигонов, полученных в описанном ранее абзаце (36 страниц * 50 полигонов + 35 полигонов = 1835 полигонов), сохранены 1832 объекта.

Линии (динамичные)

В качестве демонстрационных динамичных геообъектов выбранылиниии точки, отражающие крайние части маршрутов и фактическое пребывание автомобилей таксопарка компании "Яндекс" соответственно.

Важное замечание: сведения о линии должны удовлетворять спецификации (пункт 3.1.4 RFC 7946 "GeoJSON" August 2016), то есть линия должна содержать две разные точки (при этом она может также содержать и одинаковые).

Сбор демонстрационных данныхреализованна языке программирования Python3 с использованием библиотекrequests,pymongoи задействованием пакета многопроцессорной обработкиmultiprocessing. Необходимость крайнего обусловлена требованиями увеличения скорости получения актуальных сведений о местоположении и пути следования автомобилей с целью повышения эффективности частоты прорисовки маршрутов на карте (максимизации интерактивности). Сведения получаются в отношении заранее определенных точек района г. Санкт-Петербурга. Точки сбора данной информации располагаются на определенном коротком расстоянии друг от друга и образуют заранее рассчитанную в проекте "ячеистую" структуру. Данный подход отличается оталгоритма "заливки", применявшегося иным разработчиком, исследовавшим подобную информацию ранее.

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

Изначально сведения о маршруте не имеют необходимой MongoDB структуры для геообъекта типа "линия". Поэтому в качестве данного атрибута выступает дополнительное полеpath, куда перенесены сведения в необходимом формате. Структура геообъекта типалинияимеет вид:

path: { type: 'LineString' , coordinates: [ [LON_1, LAT_1], [LON_2, LAT_2], ..., [LON_N, LAT_N] ] }

Операции с геообъектами

В рамках работы рассматриваются такиеоперациикак:$geoIntersectsи$nearSphere.

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

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

Использование иной операции$nearSphereпродемонстрировано на примере выборки из MongoDb полигонов и метеоритов с целью отображения их на карте только при условии присутствия их в "круговой" окрестности наблюдения (для полигона - пересечение с окружностью).

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

Операции$nearи$nearSphereшире по возможности, чем просто "нахождение в круговой окрестности", так как описывают не только максимальное удаление ($maxDistance) от точки наблюдения, но и минимальное ($minDistance). Данное обстоятельство может быть использовано при работе с секторами, учитывающими "примерное" удаление объектов от исходной точки наблюдения: секторное поле зрения на панораме "Яндекс.Карты", "классические" области действия сотовых вышек, углы обзора видеокамер городского наружного наблюдения и иное.

Сервис демонстрации геообъектов

Сервис реализован на базе авторского конструктора программного Web-обеспечения"Dummy WSGI Framework"(репозиторий), является легковесным (около 45 килобайт кода).

pip3 install -r ./service/requirements.txtuwsgi --http 127.0.0.1:8080 --wsgi-file ./service/application.py

Обстановка в области карты

По адресуhttp://127.0.0.1:8080вниманию представлен район г. Санкт-Петербурга, где отображены полигоны зданий. По мере перемещения по карте или изменении масштаба карты в асинхронном режиме подгружаются иные полигоны.

Разработчикам на заметку: для записи GIF-анимации использовалось программное обеспечениеPeek.

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

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

Очередным открытием в данных NASA явилось наличие 64 метеоритов, местоположения падения которых имеют абсолютно одинаковые координаты. Указанное обнаружено в результате визуального изучения метеоритов на карте (выделяющаяся темная тень).

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

> db.meteorites.find({"location.coordinates": [13.43333,58.58333] }).count()    64> db.meteorites.find({"location.coordinates": [13.43333,58.58333] }, {name: 1, _id: 0})    { "name" : "Osterplana" }    { "name" : "sterplana 002" }    { "name" : "sterplana 003" }    ...    { "name" : "sterplana 064" }

Данные "необычные" сведения соответствуют метеориту "sterplana", имеющему удивительнуюисторию(рус.).

Для интерактивной демонстрации динамичных геообъектов необходима следующая коллекция:

db.geo_yandex_taxi.deleteMany({})db.geo_yandex_taxi.createIndex( { "ident": 1 }, { unique: true } )db.geo_yandex_taxi.createIndex( { "last_point" : "2dsphere" } )db.geo_yandex_taxi.createIndex( { "path" : "2dsphere" } )

и фоновый сбор актуальных данных:

python3 ./foreign/upsert_yandex_taxi_loop.py 9       2.61409401893615729       2.4818162918090829       2.5282382965087899       2.3746058940887459       2.53371548652648939       2.72976160049438489       2.605773925781259       2.5869448184967049       2.5660433769226074

Исходные сведения о перемещении таксопарка "Яндекс" обезличены и содержат крайние точки пройденных маршрутов:

{'id': 'bcc095db8e3b56e057caebdb97af5694', 'display_tariff': 'business', 'free': True, 'static_icon': False, 'positions': [{'lon': 30.326291, 'lat': 59.974395, 'direction': 50.0, 'timestamp': '2021-03-24T23:49:01.000000+0000'}, {'lon': 30.326291, 'lat': 59.974395, 'direction': 50.0, 'timestamp': '2021-03-24T23:48:52.000000+0000'}, {'lon': 30.326291, 'lat': 59.974395, 'direction': 50.0, 'timestamp': '2021-03-24T23:48:43.000000+0000'}, {'lon': 30.326291, 'lat': 59.974395, 'direction': 50.0, 'timestamp': '2021-03-24T23:48:34.000000+0000'}]}

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

На их основании сформированы линия перемещения и точка крайнего местоположения.

Помимо мониторинга на предмет появления нового динамичного объекта в текущей области карты системой осуществляется проверка покидания объектами ее границ. В ином бы случае движущиеся объекты останавливались и скапливались по краям карты (в случае работы подобного сервиса с "подконтрольными" сведениями необходимость в данной особенности полностью отсутствует).

Обстановка в области круговой окрестности

По адресуhttp://localhost:8080/circle/продемонстрирована выборка только тех геообъектов, которые попадают в круговую окрестность, располагаемую по центру карты.

Важное замечание: с целью использованияnearиnearSphereтребуется создать индекс2dили2dsphere, иначе их вызов приведет к ошибке исполнения:

error processing query: ns=otus.geo_wikimapia_polygonsTree: GEONEAR  field=area maxdist=500 isNearSphere=0Sort: {}Proj: { _id: 0 } planner returned error :: caused by :: unable to find index for $geoNear query, full error: {'ok': 0.0, 'errmsg': 'error processing query: ns=otus.geo_wikimapia_polygonsTree: GEONEAR  field=area maxdist=500 isNearSphere=0\nSort: {}\nProj: { _id: 0 }\n planner returned error :: caused by :: unable to find index for $geoNear query', 'code': 291, 'codeName': 'NoQueryExecutionPlans'}

Технические особенности реализации

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

Конфигурационный файл

Параметр конфигурации сервисаbase_config.pyсодержит отсылку на вид ("статичный", "динамичный"), название коллекции базы MongoDB ("meteorites", "geo_wikimapia_polygons", "geo_yandex_taxi") и атрибуты ("location", "area", "last_point", "path"), содержащие сведения о геообъектах с указанием их GeoJSON-типа ("Point", "LineString", "Polygon"), а именно:

...MONGODB_DB_COLLECTIONS = dict(    static={        "meteorites": {            "location": POINT_OBJECT,        },        "geo_wikimapia_polygons": {            "area": POLYGON_OBJECT,        },    },    dynamic={        "geo_yandex_taxi": {            "last_point": POINT_OBJECT,            "path": LINE_STRING_OBJECT,        },    },)...

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

При отсутствии в конфигурации динамичной коллекции, генерируемая сервисом HTML-страница не содержит инструкцию на JavaScript, осуществляющую периодический запрос сведений при неизменной локации. Указанное устраняет излишнюю нагрузку на серверную часть сервиса.

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

"Ненагружающий" запоздалый AJAX запрос

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

 function get_data(...){    ...    if (xhr && !(xhr.readyState === 4)) {        xhr.abort();        console.log('Previous AJAX #' + xhr.__dt + ' was aborted');    }    clearTimeout(timer);    xhr = new XMLHttpRequest();    xhr.responseType = 'json';    xhr.__dt = Date.now();    console.log('Start AJAX #' + xhr.__dt);    timer = setTimeout(function() {        // find objects in area.    }}

Демонстрация работы по старту и прерыванию AJAX-запросов доступна в консоли web-браузера при включении в нем режима "отладки" (F12).

Направления работы

Соответствие координат

MongoDB использует систему координат WGS84 (MongoDB geospatial queries on GeoJSON objects calculate on a sphere; MongoDB uses the WGS84 reference system for geospatial queries on GeoJSON objects) (поиск вглоссариислова "wgs84").

При этом Leaflet по-умолчанию использует систему координат EPSG 3857.

Исходя из описания,EPSG 3857допустима для координат между85.06Sи85.06N.

То есть в рамках рабочей эксплуатации необходимо в Leaflet установить параметр CRS равным"L.CRS.EPSG4326", посколькуонне имеет таких ограничений и целиком соответствует системе геокодирования MongoDB.

Запредельные координаты

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

pymongo.errors.OperationFailure: longitude/latitude is out of bounds, lng: 561.213 lat: 89.9823 ... Valid longitude values are between -180 and 180, both inclusive.Valid latitude values are between -90 and 90, both inclusive.

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

-тестирование

В рамках работы не рассматривались вопросы шардирования сведений о геообъектах (операции ихподдерживают в MongoDBу версий выше 4.0) и не исследовалась отдача при нагрузке.

Расширение числа источников

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

Выводы

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

Вместо заключения

Лучше один раз увидеть, чем сто раз услышать.

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

Спасибо курсам"OTUS" ("OTUS.Web-python" 2018 и "OTUS.NoSQL" 2020)за приобретенный опыт Fullstack-разработки (в частности интеграции Python, Javascript и MongoDB).

Подробнее..
Категории: Python , Otus , Mongodb , Maps api , Leaflet

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

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

Всем привет! Меня всё ещё зовут Антон. В предыдущейстатьея провел небольшой обзор APIM Gravitee и в целом систем типа API Management. В этой статье я расскажу,как поднять ознакомительный стенд APIM Gravitee (https://www.gravitee.io), рассмотрим архитектуру системы, содержимое docker compose file, добавим некоторые параметры, запустим APIM Gravitee и сделаем первую API. Статья немного погружает в технические аспекты и может быть полезна администраторам и инженерам, чтобы начать разбираться в системе.

Архитектура

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

Все в докере, в том числе и MongoDB и Elasticsearch. Чтобы сделать полноценную среду для тестирования крайне желательно компоненты MongoDB и Elasticsearch вынести за пределы Docker. Также для простоты манипулирования настройками можно вынести конфигурационные файлы Gateway и Management API: logback.xml и gravitee.yml.

docker-compose.yml

Среду для начальных шагов будем поднимать, используяdocker-compose file, предоставленный разработчиками наgithub. Правда,внесемнесколькокорректив.

docker-compose.yml# Copyright (C) 2015 The Gravitee team (<http://gravitee.io>)## Licensed under the Apache License, Version 2.0 (the "License");# you may not use this file except in compliance with the License.# You may obtain a copy of the License at##<http://www.apache.org/licenses/LICENSE-2.0>## Unless required by applicable law or agreed to in writing, software# distributed under the License is distributed on an "AS IS" BASIS,# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.# See the License for the specific language governing permissions and# limitations under the License.#version: '3.5'networks:frontend:name: frontendstorage:name: storagevolumes:data-elasticsearch:data-mongo:services:mongodb:image: mongo:${MONGODB_VERSION:-3.6}container_name: gio_apim_mongodbrestart: alwaysvolumes:- data-mongo:/data/db- ./logs/apim-mongodb:/var/log/mongodbnetworks:- storageelasticsearch:image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-7.7.0}container_name: gio_apim_elasticsearchrestart: alwaysvolumes:- data-elasticsearch:/usr/share/elasticsearch/dataenvironment:- http.host=0.0.0.0- transport.host=0.0.0.0- xpack.security.enabled=false- xpack.monitoring.enabled=false- cluster.name=elasticsearch- bootstrap.memory_lock=true- discovery.type=single-node- "ES_JAVA_OPTS=-Xms512m -Xmx512m"ulimits:memlock:soft: -1hard: -1nofile: 65536networks:- storagegateway:image: graviteeio/apim-gateway:${APIM_VERSION:-3}container_name: gio_apim_gatewayrestart: alwaysports:- "8082:8082"depends_on:- mongodb- elasticsearchvolumes:- ./logs/apim-gateway:/opt/graviteeio-gateway/logsenvironment:- gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000- gravitee_ratelimit_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000- gravitee_reporters_elasticsearch_endpoints_0=http://elasticsearch:9200networks:- storage- frontendmanagement_api:image: graviteeio/apim-management-api:${APIM_VERSION:-3}container_name: gio_apim_management_apirestart: alwaysports:- "8083:8083"links:- mongodb- elasticsearchdepends_on:- mongodb- elasticsearchvolumes:- ./logs/apim-management-api:/opt/graviteeio-management-api/logsenvironment:- gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000- gravitee_analytics_elasticsearch_endpoints_0=http://elasticsearch:9200networks:- storage- frontendmanagement_ui:image: graviteeio/apim-management-ui:${APIM_VERSION:-3}container_name: gio_apim_management_uirestart: alwaysports:- "8084:8080"depends_on:- management_apienvironment:- MGMT_API_URL=http://localhost:8083/management/organizations/DEFAULT/environments/DEFAULT/volumes:- ./logs/apim-management-ui:/var/log/nginxnetworks:- frontendportal_ui:image: graviteeio/apim-portal-ui:${APIM_VERSION:-3}container_name: gio_apim_portal_uirestart: alwaysports:- "8085:8080"depends_on:- management_apienvironment:- PORTAL_API_URL=http://localhost:8083/portal/environments/DEFAULTvolumes:- ./logs/apim-portal-ui:/var/log/nginxnetworks:- frontend

Первичные системы, на основе которых строится весь остальной сервис:<o:p>

  1. MongoDB - хранение настроек системы, API, Application, групп, пользователей и журнала аудита.

  2. Elasticsearch(Open Distro for Elasticsearch) - хранение логов, метрик, данных мониторинга.

MongoDB

docker-compose.yml:mongodb

mongodb:image: mongo:${MONGODB_VERSION:-3.6}<o:p>container_name: gio_apim_mongodb<o:p>restart: always<o:p>volumes:<o:p>- data-mongo:/data/db<o:p>- ./logs/apim-mongodb:/var/log/mongodb<o:p>networks:<o:p>- storage<o:p>

С MongoDB всё просто поднимается единственный экземпляр версии 3.6, если не указано иное, с volume для логов самой MongoDB и для данных в MongoDB.<o:p>

Elasticsearch

docker-compose.yml:elasticsearch

elasticsearch:image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-7.7.0}<o:p>container_name: gio_apim_elasticsearchrestart: alwaysvolumes:- data-elasticsearch:/usr/share/elasticsearch/dataenvironment:- http.host=0.0.0.0- transport.host=0.0.0.0<o:p>- xpack.security.enabled=false- xpack.monitoring.enabled=false- cluster.name=elasticsearch- bootstrap.memory_lock=true- discovery.type=single-node- "ES_JAVA_OPTS=-Xms512m -Xmx512m"ulimitsmemlock:soft: -1hard: -1nofile: 65536networks:- storage

elasticsearch:

elasticsearch:image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-7.7.0}container_name: gio_apim_elasticsearchrestart: alwaysvolumes:- data-elasticsearch:/usr/share/elasticsearch/data<o:p>environment:- http.host=0.0.0.0- transport.host=0.0.0.0- xpack.security.enabled=false- xpack.monitoring.enabled=false- cluster.name=elasticsearch- bootstrap.memory_lock=true- discovery.type=single-node- "ES_JAVA_OPTS=-Xms512m -Xmx512m"ulimits:memlock:soft: -1hard: -1nofile: 65536networks:- storage

С Elasticsearch также всё просто поднимается единственный экземпляр версии 7.7.0, если не указано иное, с volume для данных в Elasticsearch. Сразу стоит убрать строки xpack.security.enabled=false и xpack.monitoring.enabled=false, так как хоть они и указаны как false, Elasticsearch пытается найти XPack и падает. Исправили ли этот баг в новых версиях не понятно, так что просто убираем их, или комментируем. Также стоит обратить внимание на секцию ulimits, так как она требуется для нормальной работы Elasticsearch в docker.<o:p>

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

  1. Gateway

  2. Management API

  3. Management UI

  4. Portal UI

Gateway/APIM Gateway

docker-compose.yml:gatewaygateway:image: graviteeio/apim-gateway:${APIM_VERSION:-3}container_name: gio_apim_gatewayrestart: alwaysports:- "8082:8082"depends_on:- mongodb- elasticsearchvolumes:- ./logs/apim-gateway:/opt/graviteeio-gateway/logs      environment:    - gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000      - gravitee_ratelimit_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000- gravitee_reporters_elasticsearch_endpoints_0=http://elasticsearch:9200networks:- storage- frontend<o:p>

С Gateway всё несколько сложнее поднимается единственный экземпляр версии 3, если не указано иное. Если мы посмотрим, что лежит наhub.docker.com, то увидим, что у версий 3 и latest совпадают хеши. Дальше мы видим, что запуск данного сервиса, зависит от того, как будут работать сервисы MongoDB иElasticsearch. Самое интересное, что если Gateway запустился и забрал данные по настроенным API из mongodb, то дальше связь с mongodb и elasticsearch не обязательна. Только в логи будут ошибки сыпаться, но сам сервис будет работать и соединения обрабатывать согласно той версии настроек, которую последний раз закачал в себя Gateway. В секции environment можно указать параметры, которые будут использоваться в работе самого Gateway, для переписывания данных из главного файла настроек: gravitee.yml. Как вариант можно поставить теги, тенанты для разграничения пространств, если используется Open Distro for Elasticsearch вместо ванильного Elasticsearch. Например, так мы можем добавить теги, тенанты и включить подсистему вывода данных о работе шлюза.

environment:- gravitee_tags=service-tag #включаемтег: service-tag- gravitee_tenant=service-space #включаемтенант: service-space- gravitee_services_core_http_enabled=true # включаем сервис выдачи данных по работе Gateway  - gravitee_services_core_http_port=18082 # порт сервиса- gravitee_services_core_http_host=0.0.0.0 # адрес сервиса- gravitee_services_core_http_authentication_type=basic # аутентификация либо нет, либо basic - логин + пароль  - gravitee_services_core_http_authentication_type_users_admin=password #логин: admin,пароль: password  Чтобы к подсистеме мониторинга был доступ из вне, надо ещё открыть порт 18082.ports:- "18082:18082"Management API/APIM APIdocker-compose.yml:management_apimanagement_api:image: graviteeio/apim-management-api:${APIM_VERSION:-3}container_name: gio_apim_management_apirestart: alwaysports:- "8083:8083"links:- mongodb- elasticsearchdepends_on:- mongodb- elasticsearchvolumes:- ./logs/apim-management-api:/opt/graviteeio-management-api/logsenvironment:- gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000  - gravitee_analytics_elasticsearch_endpoints_0=http://elasticsearch:9200 networks:    - storage- frontend

ManagementAPI это ядро всей системы и предоставляет службы для управления и настройкиAPI, логи, аналитики и веб-интерфейсовManagementUIиPortalUI. Зависит от MongoDB и Elasticsearch. Также можно через секцию environment указать параметры, которые будут использоваться в работе самого ядра системы. Дополним наши настройки:

environment:- gravitee_email_enable=true # включаем возможность отправлять письма- gravitee_email_host=smtp.domain.example # указываем сервер через который будем отправлять письма- gravitee_email_port=25 # указываем порт для сервера- gravitee_email_username=domain.example/gravitee #логиндлясервера- gravitee_email_password=password #парольдлялогинаотсервера  - gravitee_email_from=noreply@domain.example # указываем от чьего имени будут письма- gravitee_email_subject="[Gravitee.io] %s" #указываемтемуписьма

Management UI/APIM Console

docker-compose.yml:apim_consolemanagement_ui:image: graviteeio/apim-management-ui:${APIM_VERSION:-3}container_name: gio_apim_management_uirestart: alwaysports:- "8084:8080"depends_on:- management_apienvironment:- MGMT_API_URL=http://localhost:8083/management/organizations/DEFAULT/environments/DEFAULT/      volumes:    - ./logs/apim-management-ui:/var/log/nginxnetworks:- frontend

Management UI предоставляет интерфейс для работы администраторам и разработчикам. Все основные функции можно осуществлять и выполняя запросы непосредственно к REST API. По опыту могу сказать, что в переменной MGMT_API_URL вместоlocalhostнадо указать IP адрес или название сервера, где вы это поднимаете, иначе контейнер не найдет Management API.

Portal UI/APIM Portaldocker-compose.yml:apim_portalportal_ui:image: graviteeio/apim-portal-ui:${APIM_VERSION:-3}container_name: gio_apim_portal_uirestart: alwaysports:- "8085:8080"depends_on:- management_apienvironment:- PORTAL_API_URL=http://localhost:8083/portal/environments/DEFAULTvolumes:- ./logs/apim-portal-ui:/var/log/nginxnetworks:- frontend

Portal UI это портал для менеджеров. Предоставляет доступ к логам, метрикам и документации по опубликованным API. По опыту могу сказать, что в переменной PORTAL_API_URL вместоlocalhostнадо указать IP-адрес или название сервера, где вы это поднимаете, иначе контейнер не найдет Management API.<o:p>

Теперь соберем весь файл вместе.

docker-compose.yml

# Copyright (C) 2015 The Gravitee team (<http://gravitee.io>)## Licensed under the Apache License, Version 2.0 (the "License");# you may not use this file except in compliance with the License.# You may obtain a copy of the License at##         <http://www.apache.org/licenses/LICENSE-2.0>## Unless required by applicable law or agreed to in writing, software# distributed under the License is distributed on an "AS IS" BASIS,# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.# See the License for the specific language governing permissions and# limitations under the License.#version: '3.5'networks:  frontend:    name: frontend  storage:    name: storagevolumes:  data-elasticsearch:  data-mongo:services:  mongodb:    image: mongo:${MONGODB_VERSION:-3.6}    container_name: gio_apim_mongodb    restart: always    volumes:      - data-mongo:/data/db      - ./logs/apim-mongodb:/var/log/mongodb    networks:      - storage  elasticsearch:    image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-7.7.0}    container_name: gio_apim_elasticsearch    restart: always    volumes:      - data-elasticsearch:/usr/share/elasticsearch/data    environment:      - http.host=0.0.0.0      - transport.host=0.0.0.0      - cluster.name=elasticsearch      - bootstrap.memory_lock=true      - discovery.type=single-node      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"    ulimits:      memlock:        soft: -1        hard: -1      nofile: 65536    networks:      - storage  gateway:    image: graviteeio/apim-gateway:${APIM_VERSION:-3}    container_name: gio_apim_gateway    restart: always    ports:      - "8082:8082"      - "18082:18082"    depends_on:      - mongodb      - elasticsearch    volumes:      - ./logs/apim-gateway:/opt/graviteeio-gateway/logs    environment:      - gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000      - gravitee_ratelimit_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000      - gravitee_reporters_elasticsearch_endpoints_0=http://elasticsearch:9200         - gravitee_tags=service-tag # включаем тег: service-tag         - gravitee_tenant=service-space # включаем тенант: service-space         - gravitee_services_core_http_enabled=true # включаем сервис выдачи данных по работе Gateway         - gravitee_services_core_http_port=18082 # порт сервиса         - gravitee_services_core_http_host=0.0.0.0 # адрес сервиса          - gravitee_services_core_http_authentication_type=basic # аутентификация либо нет, либо basic - логин + пароль         - gravitee_services_core_http_authentication_type_users_admin=password # логин: admin, пароль: password    networks:      - storage      - frontend  management_api:    image: graviteeio/apim-management-api:${APIM_VERSION:-3}    container_name: gio_apim_management_api    restart: always    ports:      - "8083:8083"    links:      - mongodb      - elasticsearch    depends_on:      - mongodb      - elasticsearch    volumes:      - ./logs/apim-management-api:/opt/graviteeio-management-api/logs    environment:      - gravitee_management_mongodb_uri=mongodb://mongodb:27017/gravitee?serverSelectionTimeoutMS=5000&connectTimeoutMS=5000&socketTimeoutMS=5000      - gravitee_analytics_elasticsearch_endpoints_0=http://elasticsearch:9200         - gravitee_email_enable=true # включаем возможность отправлять письма         - gravitee_email_host=smtp.domain.example # указываем сервер через который будем отправлять письма         - gravitee_email_port=25 # указываем порт для сервера         - gravitee_email_username=domain.example/gravitee # логин для сервера         - gravitee_email_password=password # пароль для логина от сервера         - gravitee_email_from=noreply@domain.example # указываем от чьего имени будут письма          - gravitee_email_subject="[Gravitee.io] %s" # указываем тему письма    networks:      - storage      - frontend  management_ui:    image: graviteeio/apim-management-ui:${APIM_VERSION:-3}    container_name: gio_apim_management_ui    restart: always    ports:      - "8084:8080"    depends_on:      - management_api    environment:      - MGMT_API_URL=http://localhost:8083/management/organizations/DEFAULT/environments/DEFAULT/    volumes:      - ./logs/apim-management-ui:/var/log/nginx    networks:      - frontend  portal_ui:    image: graviteeio/apim-portal-ui:${APIM_VERSION:-3}    container_name: gio_apim_portal_ui    restart: always    ports:      - "8085:8080"    depends_on:      - management_api    environment:      - PORTAL_API_URL=http://localhost:8083/portal/environments/DEFAULT    volumes:      - ./logs/apim-portal-ui:/var/log/nginx    networks:      - frontend

Запускаем

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

vCPU: 4

RAM: 4 GB

HDD: 50-100 GB

Для работы Elasticsearch, MongoDB и Gravitee Gateway нужно примерно по 0.5 vCPU, больше только лучше. Примерно тоже самое и с оперативной памятью - RAM. Остальные сервисы по остаточному принципу. Для хранения настроек много места не требуется, но учитывайте, что в MongoDB еще хранятся логи аудита системы. На начальном этапе это будет в пределах 100 MB. Остальное место потребуется для хранения логов в Elasticsearch.

docker-compose up -d # если не хотите видеть логиdocker-compose up # если хотите видеть логи и как это все работает

Как только в логах увидите строки:

gio_apim_management_api_dev | 19:57:12.615 [graviteeio-node] INFO  i.g.r.a.s.node.GraviteeApisNode - Gravitee.io - Rest APIs id[5728f320-ba2b-4a39-a8f3-20ba2bda39ac] version[3.5.3] pid[1] build[23#2f1cec123ad1fae2ef96f1242dddc0846592d222] jvm[AdoptOpenJDK/OpenJDK 64-Bit Server VM/11.0.10+9] started in 31512 ms.

Можно переходить по адресу: http://ваш_адрес:8084/.

Нужно учесть, что Elasticsearch может подниматься несколько дольше, поэтому не пугайтесь если увидите такое "приглашение":

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

Вводим стандартный логин и пароль: admin/admin и мы в системе!

Первичная настройка

Настройки самой системы

Переходим в менюSettings PORTAL Settings

Здесь можно настроить некоторый параметры системы. Например: доступные методы аутентификации наших клиентов: Keyless, API_KEY, Oauth2 или JWT. Подробно их мы рассмотрим скорее всего в третьей статье, а пока оставим как есть. Можно подключить Google Analytics. Время обновления по задачам и нотификациям. Где лежит документация и много ещё чего по мелочи.

Добавление tags и tenant

ПереходимвменюSettings GATEWAY Shardings Tags

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

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

ПереходимвменюSettings GATEWAY Tenants

То же самое и с настройкой тенантов. Только тут нет кнопки "+", но есть серенькая надпись "New tenant", которую надо нажать для добавления нового тенанта. Естественно, данный тенант должен быть создан в Open Distro for Elasticsearch, и к нему должны быть выданы права.

Добавление пользователей

Переходим вSettings USER MANAGEMENT Users

Здесь можно добавлять пользователей, вот только работать это будет, если у нас настроена рассылка по email. Иначе новым пользователям не придёт рассылка от системы со ссылкой на сброс пароля. Есть ещё один способ добавить пользователя, но это прям хардкод-хардкод!

В файле настроек Management API: gravitee.yml есть такой кусочек настроек:

security:  providers:  # authentication providers    - type: memory      # password encoding/hashing algorithm. One of:      # - bcrypt : passwords are hashed with bcrypt (supports only $2a$ algorithm)      # - none : passwords are not hashed/encrypted      # default value is bcrypt      password-encoding-algo: bcrypt      users:        - user:          username: admin          password: $2a$10$Ihk05VSds5rUSgMdsMVi9OKMIx2yUvMz7y9VP3rJmQeizZLrhLMyq          roles: ORGANIZATION:ADMIN,ENVIRONMENT:ADMIN

Здесьперечисленытипыхранилищдляпользователей: memory, graviteeиldap.Данные из хранилищаmemoryберутся из файла настроек:gravitee.yml. Данные из хранилищаgraviteeхранятся вMongoDB. Для хранения пользовательских паролей, по умолчанию используется тип хеширования BCrypt с алгоритмом $2a$. В представленных настройках мы видим пользователя: admin с хешированным паролем: admin и его роли. Если мы будем добавлять пользователей через UI, то пользователи будут храниться в MongoDB и тип их будет уже gravitee.

Создание групп пользователей

Переходим вSettings USER MANAGEMENT Groups

При нажатии на "+" получаем возможность добавить группу и пользователей в эту группу.

Проверка доступных шлюзов

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

Здесь мы видим настройки шлюза. В частности, Sharding tags и Tenant. Их мы добавили чуть раньше.

Естественно, есть возможность мониторинга состояния шлюза. Данные по мониторингу хранятся в Elasticsearch в отдельном индексе.

Публикация первого API

Для публикации первого API нам сначала потребуется сделать какой-нибудь backend с API.

BackEnd с API, балеринами и Swagger.

Возьмём FastAPI и сделаем простейшее backend с API.

#!/bin/env python3import uvicornfrom fastapi import FastAPIapp = FastAPI()@app.get('/')@app.get('/{name}')def read_root(name: str = None):    """    Hello world    :return: str = Hello world    """    if name:        return {"Hello": name}    return {"Hello": "World"}@app.get("/items/{item_id}")@app.post("/items/{item_id}")@app.put("/items/{item_id}")def gpp_item(item_id: str):    """    Get items    :param item_id: id    :return: dict    """    return {"item_id": item_id}if __name__ == "__main__":    uvicorn.run(app, host="0.0.0.0", port=8000)

Это иAPIможно назвать с трудом, но для примера работы вполне хватит.

Теперь запустим его! Можно даже на том же сервере.

python3 main.py

Теперь, если мы зайдем на этот серверhttp://backend_server:8000/,мы увидим приветствие миру! Если подставим своё имя, типа так:http://backend_server:8000/Anton, то приветствовать уже будут вас! Если же хотите познать всю мощьFastAPI, то сразу заходите на адрес:http://backend_server:8000/docsилиhttp://backend_server:8000/redoc. На этих страницах можно найти все методы, с которым работает данное API и также ссылку на swagger файл. Копируем URL до swagger файла.

В прошлый раз мы создавали наш план вручную. Было несколько утомительно. Сегодня мы сделаем все быстрее, через импорт swagger файла!

На главном экране Gravitee нажимаем большой "+", выбираем "IMPORT FROM LINK", вставляем URL и нажимаем "IMPORT".

Получается как-то так

Нажимаем "IMPORT"!

Почти полностью сформированный API! Теперь немного доработаем напильником...

Для начала нажимаем "START THE API" чтобы API запустить.

Переходим в "Plans" и нажимаем "+".

Создаем тестовый план.

Тип аутентификации выбираем Keyless (public) и нажимаем "NEXT".

Ограничения по количеству запросов и путям пропускаем. Нажимаем "NEXT".

Политики нам тоже пока не нужны. Нажимаем "SAVE".

План создан, но пока находиться в стадии "Staging"

Нажимаем на кнопку публикации плана - синее облачко со стрелочкой вверх! Подтверждаем кнопкой "PUBLISH"

И получаем опубликованный план и какую-то желтую полоску с призывом синхронизировать новую версию API.

Нажимаем "deploy your API" и подтверждаем наше желание "OK"

Переходим вAPIs Proxy Entrypoints

Здесь можно указать точки входа для нашего API и URL пути. У нас указан только путь "/fastapi". Можно переключиться в режим "virtual-hosts" и тогда нам будет доступен вариант с указанием конкретных серверов и IP. Это может быть актуально для серверов с несколькими сетевыми интерфейсами.

ВAPIs Proxy GENERAL CORSможнопроизвестинастройкиCross-origin resource sharing.

ВAPIs Proxy GENERAL Deploymentsнадоуказатьвсеsharding tags,которыебудутиспользоватьсяэтимAPI.

ВAPIs Proxy BACKEND SERVICES Endpointsможно указать дополнительные точки API и настроить параметры работы с ними.

Сейчас нас интересуют настройки конкретной Endpoint, поэтому нажимаем на нижнюю шестеренку.

Исправляем "Target" на http://backend_server:8000/, устанавливаем tenant, сохраняем и деплоим!

ВAPIs Proxy Deploymentsнадо указать те sharding tags, которые могут использовать данное API. После этого необходимо вернуться в созданный план и в списке Sharding tags выбрать тег "service-tag".

ВAPIs Designможно указать политики, которые будут отрабатывать при обработке запросов.

ВAPIs Analytics Overviewможно смотреть статистику по работе данного конкретного API.

ВAPIs Analytics Logsможно настроить логи и потом их смотреть.

ВAPIs Auditможно посмотреть, как изменялись настройки API.

Остальное пока рассматривать не будем, на работу оно не влияет.

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

Переходим наhttp://gravitee_host:8082/fastapi/ , и вам покажется приветствие миру:

Также сразу можно заглянуть вAPIs Analytics Overview/Logsдля просмотра статистики обработки запросов.

Заключение

Итак, поздравляю всех, кто дочитал до сюда и остался в живых! Теперь вы знаете, как поднять ознакомительный стенд APIM Gravitee, как его настроить, создать новое API из swagger файла и проверить, что всё работает. Вариантов настройки шлюзов, точек входа и выхода, сертификатов, балансировок нагрузки и записи логов много. В одной статье всего и не расскажешь. Так что в следующей статье я расскажу о более продвинутых настройках системы APIM Gravitee. В Телеграмме есть канал по данной системе:https://t.me/gravitee_ru, вопросы по нюансам настройки можно задавать туда.

Подробнее..

DataGrip 2020.3 SQL для Монги, новые форматы экспорта, интроспекция прав доступа и другое

25.11.2020 18:12:55 | Автор: admin
Привет! Очередной длинный пост о том, что мы сделали за последние четыре месяца. Как всегда, мы говорим DataGrip, а подразумеваем все остальные наши IDE. В том числе и WebStorm, SQL-плагин к которому теперь можно докупить.




Самое важное:


SQL для MongoDB
Поддержка Couchbase
Аутентификация через Azure AD
Улучшения в редакторе больших значений
Открытие таблиц в транспонированном виде
Новые форматы экспорта
Интроспекция прав доступа
Форматирование диалекта Generic
Улучшения в конфигурациях запуска
Перетаскивание вкладок

SQL для MongoDB


Теперь можно писать SQL в MongoDB. Мы написали транслятор SQL в JavaScript.



Работают только запросы SELECT и предложения JOIN, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, OFFSET. Полный пост обо всём, что мы поддержали, читайте здесь.

В контекстном меню есть две опции: Copy JS script to clipboard и Show JS Script. Последняя откроет окно с JS-запросом, который мы отправим на сервер. Здесь же можно его отредактировать и запустить.



Соединение


Поддержка Couchbase


Наша семья растёт: мы поддержали Couchbase! Важно отметить, что DataGrip работает с Couchbase Query, а не с Couchbase Analytics.



Аутентификация через Azure AD


Об этом нас давно просили: к базе данных Azure SQL теперь можно подсоединиться через Active Directory.



Рабочая папка


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



Редактор данных


Редактор больших значений


Мы сделали этот редактор в предыдущей версии, а сейчас улучшили несколько вещей.

Форматированное значение


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



Расположение снизу


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



Картинки


Ещё в этом же редакторе отображаются картинки.



Открытие таблиц в транспонированном виде


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



Выделение в редакторе


В редакторе всегда работало умное выделение: нажимаете Ctrl+W на Windows/Linux или Opt+Up на macOS и сначала выделяется текущая строка, потом текущий столбец, потом вся таблица. Теперь можно двигаться и в обратном порядке: при помощи Ctrl+Shift+W на Windows/Linux и Opt+Down на macOS.



Импорт/экспорт


Новые форматы


Сделали два новых формата: One-row и SQL-Insert-Multirow.



One-Row копирует выделенные значения в одну строку через запятую. Это удобно, когда значения столбца нужно вставить в оператор IN.



SQL-Insert-Multirow сгенерирует один INSERT для нескольких строк. В некоторых базах, например MySQL и PostgreSQL, это работает.



Не ставить кавычки


Новая опция в CSV форматах: never quote values.



Интроспекция


Сбор диагностической информации


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

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

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

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



Права доступа


DataGrip теперь знает о правах доступа и показывает их в сгенерированном DDL для объекта.


Изменённые объекты в проводнике базы данных


Если вы изменили DDL объектов, но ещё не успели отправить свои изменения в базу, эти объекты будут подсвечены в дереве. Раньше изменённые объекты показывались только в окне Database Changes.



[PostgreSQL] Больше свойств таблиц


DataGrip генерирует DDL таблицы, включая TABLESPACE и INDEX ACCESS METHOD.



[Greenplum] Новые объекты


В дереве базы данных Greenplum стало больше отображаемых объектов: мы добавили коллации, материализованные представления, внешние таблицы, обёртки сторонних данных, сторонние сервера, сторонние таблицы и сопоставления пользователей.



[Oracle] Корректное отображение перегрузок


Перегрузки внутри одного пакета раньше отображались как один объект. Теперь как несколько. Число в квадратных скобках это индекс процедуры в базе данных.



Помощь в написании кода


Форматирование диалекта Generic


Теперь вы можете форматировать запросы, работая с базами, которые мы не поддерживаем.



Новый интерфейс переименования


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



Исправление опечаток


Alt+Enter на Windows/Linux и Opt+Enter на macOSтеперь сразу предлагают список исправлений, без вложенного меню.



[Oracle] Конфликт вызовов


Если возникает конфликт вызова перегруженной процедуры, DataGrip предупредит об этом.



Новые настройки капитализации в форматировании


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



Запуск запросов


Конфигурации запуска


Запуск скриптов из файлов стал удобнее:

Смена контекста


DataGrip предупредит, если внутри скрипта меняется контекст, то есть используется USE или SET search_path.



Отображение контекста


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



История запуска


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



Вывод консоли в отдельной вкладке


Если для вас важна ширина экрана, вывод консоли можно открывать в новой вкладке окна Services по умолчанию. Таким образом, вся ширина экрана будет задействована для результата запроса или текстового вывода. Включается это в Settings/Preferences | Database | General | Open new services tab for sessions.



[SQL Server] Имена вкладок


В прошлом году мы сделали так, что комментарии перед запросом становятся именами вкладок. Это не работало в SQL Server, если запускать сразу несколько запросов, потому что SQL Server обрабатывает несколько запросов как один. Теперь работает как надо.



[SQL Server] Подсказки для создания индекса в планировщике


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



Редактирование DDL



Предупреждение о неактуальной структуре таблицы


Если вы открываете DDL таблицы, но кэшированная версия DataGrip успела устареть, вас об этом предупредят. Раньше это работало только для объектов с исходным кодом: процедур, функций, представлений.



Предупреждение, если объект удален


Если объект был удален, пока вы его редактировали, появится сообщение об этом. Например, вы вносили изменения в процедуру, а в это время её кто-то дропнул!



DataGrip предложит три варианта:

Revert local changes: выбирайте этот вариант, если вас устраивает, что процедура удалена. Редактор закроется.

Keep local changes: в этом варианте вы будете продолжать вносить изменения в процедуру, а при нажатии Submit она будет создана заново.

Restore in the database: то же самое, но DataGrip сразу же восстановит вариант процедуры, который был актуален на тот момент, когда вы ее открыли. Это нужно для того, чтобы правильно подсветить ваши текущие изменения при редактировании. После нажатия Submit результаты этого действия и Keep local changes будут одинаковы.

Действия по работе с исходниками


Действия Submit, Rollback и Show Changes теперь можно запускать для объекта из любого контекста, в том числе проводника. Например, у вас висят изменения для некоторого количества процедур. Можно выделить несколько из них в дереве и только их исходники отправить в базу. А остальные, например, откатить. Раньше эти операции делались только из окна Database Changes.



Общее


Перетаскивание вкладок


Теперь можно переносить вкладки в разные области экрана. Получается, есть и полностью независимый сплит-режим: если выбрать режим In-Editor Results, у каждой вкладки будет свой результат запроса.



Синхронизация темы с системными настройками


Если выбрать Sync with OS в настройках Settings/Preferences | Appearance & Behavior | Appearance | Theme, то IDE будет автоматически синхронизировать цветовую тему с настройками операционной системы.



Сочетания клавиш macOS словами


Некоторым людям сложно читать условные обозначения клавиш на macOS. Теперь можно дёрнуть ключ ide.macos.disable.native.shortcut.symbols в реестре, и вместо значков будут слова. Сработает для всех меню внутри IDE, кроме главного, в котором поменять что-то мы бессильны.



Вкладка предпросмотра


В настройках панели Files выберите Enable Preview Tab. Теперь по клику на файл его содержимое видно во вкладке предпросмотра. Если вы начинаете редактировать файл, эта вкладка превращается в обычную.



Простой калькулятор


Вычисляйте несложные штуки в окне Search Everywhere.



Ассоциации с файлами


С этой версии в настройках DataGrip можно указать, какие файлы IDE должна открывать по умолчанию. Настройка находится тут: Settings/Preferences | Settings | Editor | File Types | Associate file types with DataGrip.



Кстати, это могут быть не только файлы, связанные с базами данных. DataGrip вполне подходящий редактор для JSON, HTML или MarkDown*!

*с плагином

Развернуть всё


Раньше в панелях Database и Files была только кнопка Collapse All, которая закрывает все узлы. Мы добавили кнопку Expand All, которая открывает все возможные узлы до последнего уровня. Может пригодится при быстром поиске, когда вы просто печатаете имя объекта, находясь в дереве. Такой поиск ищет только по открытым узлам.



Такой вышел релиз!

Скачать триал на месяц

Фидбек принимаем в комментариях к посту и здесь:

Трекер (это если точно нашли проблему)
Телеграм-канал
Твиттер
Почта

Команда DataGrip
Подробнее..

Базы данных. Тенденции общемировые и в России

19.12.2020 00:19:44 | Автор: admin

Эта статья не является ответом на множество вопросов по базам данных (БД) и системам управлениям базами данных (СУБД). Я как автор выражаю своё собственное мнение о трендах, стараясь опираться на беспристрастные показатели, статистики и т.д., но для примера приводя собственный опыт. Я не являюсь ангажированным представителем какой-либо компании и выражаю точку зрения опираясь на опыт более 25 лет работы с разными СУБД, в том числе, которую создавал своими руками. Не так много даже опытных программистов и архитекторов, которые знают все термины, технологии, какие подводные камни и куда идёт движение. Тема поистине огромная, поэтому в рамках одной статьи не раскрыть даже верхний уровень информации. Если кто-то не встретит свою любимую СУБД или её невероятный плюс, который стоит упомянуть, то прошу в комментариях указать и этим дополнить общую картину, что поможет другим разобраться и понять лучше предметную область. Поехали!

Open Source DBMS vs Commercial DBMS

Для начала приведу график с сайта, db-engines.com, по моим ощущениям, неплохо отслеживающим тренды БД. Именно этот график добавил желания написать статью о текущем положении дел. Когда мы говорим фразу база данных, то на самом деле чаще имеем в виду конкретную систему управления базами данных (СУБД), поэтому если в тексте встретится БД вместо СУБД, то это в силу такой привычки.

Open Source DBMS системы управления баз данных с открытым исходным кодом догнали коммерческие СУБД с закрытым исходным кодом. Open Source 49.98% против 50.02% у Commercial. Итогом 2020 года становится момент, когда можно будет сказать, что open source не менее популярны. Как вы видите, эта ситуация возникла не внезапно. Подсчёт на графике не в численном соотношении, а в очках, которые набирают те или иные системы.

Для интереса можете посмотреть на сайте где находится ваша любимая СУБД в рейтинге. Итогом последнего года стал вылет Microsoft Access из десятки, он вместе с языком программирования COBOL напоминает, что жизненный цикл технологий может быть очень длинным. Полагаю, что в следующем году IBM DB2 будет опускаться сильнее всего в топе. Топ 10 СУБД это 75% всех набранных баллов. За год топ 10 в очках почти не поменялся.

Open Source вырос качественно за последние годы и всё больше ИТ специалистов, принимающих решение задаются вопросом, а стоят ли лицензии Oracle, MS SQL, IBM DB2 и прочих коммерческих продуктов, чтобы в них вкладываться. Не в малой степени этому способствуют аппетиты одноимённых компаний. В последние годы стало модно в коммерческих продуктах продавать enterprise licenses (лицензии уровня предприятий без искусственных ограничений функционала) за ядра процессоров. Можете по ссылке посчитать, что выходит для сервера с 4 процессорами по 16 ядер - всего 64 ядра если вам не подходят лицензии за пользователей.

MS SQL - 439 936$

Oracle - 1 368 000$

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

Прошлый 2019 год и текущий 2020 проходили с растущим влиянием компании AMD и её центральных процессоров, включая серверные EPYC, которые кардинально меняют стоимость физических ядер, а это значит, что ядер будут покупать всё больше. Многоядерность AMD развилась с внедрением чиплетов и теперь 64 ядра в одном процессоре - это реальность. Готовы ли будут компании покупать платные лицензии платных СУБД на ядра в разы больше? Не уверен. Физический сервер будет получаться по стоимости на порядок ниже лицензий. Серверный рынок довольно инертный и на AMD не перейдут и за несколько лет, но Oracle, Microsoft и прочие начнут терять долю ещё быстрее если не изменят политику. Microsoft выпускал версию SQL Server для Linux, но опять же за деньги и не нашлось большого числа желающих её купить. IBM DB2 теряет долю рынка уже давно.

Переход многих компаний на микросервисы (наверняка слышали на российских конференциях и форумах мы пилим монолит на микросервисы) зачастую приводит к отделению данных физически на разные сервера, для коммерческих СУБД это платные лицензии, для Open Source нет. Чтобы не выйти из бюджета выбирают бесплатные решения.

Уместно будет сказать, что бизнес коммерческих СУБД выгоден и ими владеют богатейшие люди планеты. В топе самых богатых людей оказываются владельцы компаний, владеющих коммерческими СУБД: Билл Гейтс (Microsoft), Ларри Эллисон (Oracle), которые занимают значительную долю в топе СУБД. Ещё из топ 10 списка Forbes богатейших людей, у которых есть огромные или облачные БД присутствуют Джефф Безос (Amazon RDS), Ларри Пейдж и Сергей Брин (Google с их Bigtable).

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

Тот же MySQL несмотря на название open source был уже продан за 1 млрд.$, Sun Microsystem, а потом поглощён Oracle. Основатель MySQL Майкл Видениус переоткрыл проект назвав его MariaDB. Я был на его выступлении в Москве где он призывал переходить на их продолжение оригинальной СУБД и надо сказать, что немалая часть разработчиков так и поступило. MariaDB уже на 12 месте.

Коммерческие СУБД дороги, но есть и масса плюсов, они стабильны, удобны, легко интегрируемы в ИТ инфраструктуру, есть система подготовки специалистов, сторонние компании расширяют функционал. Плюсом open source помимо бесплатности и растущей части возможностей платных СУБД можно считать и то, что вы можете взять код БД и поменять под свои нужды, как делал Facebook c MySQL. Для одной СУБД open source может быть несколько движков или несколько веток развития и вы можете выбирать любой вариант, который вам подходит. Влетевшая в топ 10 SQLite - мультиплатформенный продукт. Это персональная БД не использующая парадигму Сервер-Клиент, когда вам нужно с минимальными затратами просто локально хранить данные для приложения и пользоваться всеми плюсами СУБД.

Что в России: Нужно понимать, что когда в 70-80 годах году появились первые коммерческие продукты, например Oracle, у нас ещё был Брежнев, Олимпиада в Москве, перестройка, отставание в электронной промышленности и т.д. Мы пока догоняем или развиваем существующие системы.

Первые версии СУБД в мире были коммерческие, распространялись точечно, требовали присутствия специалистов. Покупать программное обеспечение (ПО) за валюту в нашей стране исторически не всегда принято (опустим обсуждение политических и экономических мотивов), поэтому альтернатива в виде бесплатных СУБД и пиратских версий коммерческих продуктов присутствуют. В институтах сейчас часто учат на бесплатных движках БД, открытое ПО это стильно, модно, молодёжно. Поэтому рынок очень быстро наполняется специалистами в области open source. В России есть разработки СУБД в виде ClickHouse, Tarantool, ветки PostgreSQL и т.д., коммерческих экспортируемых БД нет. Есть реестр российских программ где можно поинтересоваться текущим положением дел по отечественным СУБД. Состав правда вызывает сомнение, например, наряду с названиями, которые вы могли слышать встречаются и с названием типа Паспортный стол общежитий ВУЗа.

Переход на open source в России ускорился и в связи с санкциями. Помнится, выходившая новость об Oracle о возможном запрете продавать технологии для нефтегазовой отрасли России поменяло вектор видения будущего в умах и бюджетах некоторых наших компаний с хорошими бюджетами на ИТ. Как выше сказал фраза мы пилим монолит на микросервисы зачастую приводит в Open Source. В России, да как и во всём мире, растут PostgreSQL, MySQL, SQLite, MongoDB проекты.

Многим могло показаться, что open source давно обогнал commercial, но это мнение может сложиться если вы относитесь к миру online проектов, сайтов, приложений для смартфонов и т.д. Из этого следует следующее сравнение online vs. offline.

БД online проектов против offline

Есть 2 класса проектов, которые в чём-то похожи, а в чём-то нет. Это проекты с основным направлением онлайн продаж или оффлайн бизнес.

Если вы берёте классический бизнес оффлайн, связанный с добычей природных ресурсов, банковским делом, дистрибьюцией (оптовые продажи) или ритейлом (розничные продажи), то развивался он чаще всего из стека технологий на базе Windows решений. И переходя в онлайн он может использовать стек технологий WISA (Windows, IIS, MS SQL, ASP.Net). Есть и онлайн проекты изначально на WISA, для примера, всем известный StackOverflow. На текущий момент, в чисто онлайн проектах доминирует стек технологий типа LAMP (Linux Apache MySQL PHP). Новые молодые команды, приходящие в существующий бизнес, часто не работали с Windows стэком технологий и предлагают переписывать существующие системы. В России эта тенденция очень хорошо ощущается в последние годы.

Деньги любят счёт, и чтобы сравнить долю онлайн или оффлайн проектов давайте посмотрим рейтинг по выручке в мире. В топе ритейл, добыча ресурсов, автомобилестроение и т.д. В крупнейших компаниях (не только России) обычно зоопарк из технологий, тут будут ERP, CRM системы в виде SAP, Microsoft Dynamics NAV или AX, 1C в России и много чего ещё. Компании с большим оборотом используют разные системы управления предприятием, которые в свою очередь часто используют коммерческие БД, Oracle, MS SQL, IBM DB2.

Но капитализация компаний в мире за последние 10 лет изменилась и мы видим, что в топе теперь ИТ гиганты и всего 2 компании из прошлого топа Microsoft и Alphabet (Google). В динамике изменения тут. Это значит смещение денежного потока в онлайн. Мы все уже привыкли и платить онлайн и переводить деньги, покупать товары с доставкой и т.д. А текущий 2020 год принёс немало прибыли именно онлайн компаниям.

Рейтинг компаний России по выручке. На первых местах добыча ресурсов, банки, ритейл, а не ИТ корпорации. Яндекс на 113 месте. Правда по капитализации Яндекс входит в топ 20. Тенденции по внедрению большего числа решений с open source есть, причины описаны выше. Исторически многие крупнейшие онлайн-ритейлеры (магазины) в России на платных БД, но задумываются над переходом тестируя части функционала на open source. Банки начали миграцию в онлайн, пример Тинькофф банка подтверждает, что нужно развивать онлайн банкинг.

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

Реляционные СУБД против остальных

Обилие статей в том числе на Хабре про разные виды БД вводят в заблуждение, что весь мир уже перестаёт использовать классические реляционные СУБД. На самом деле RDBMS это подавляющее большинство всех СУБД в мире. На другие виды приходится около части и нужно понимать, что это чаще всего специфический функционал.

Для примера скажу, что в данный момент изо дня в день в работе типичной крупной компании может широко использоваться одновременно разные СУБД как платные MS SQL(Oracle) и ещё набор из MongoDB, Redis, MySQL, ClickHouse, ElasticSearch и т.д.

Рассмотрим очень кратко основные типы:

Relational: Главный тип, который ассоциируется с БД. Данные хранятся в виде 2-мерных таблиц с определёнными столбцами и строками, в которых хранятся значения. Индексы используются для ускорения поиска по указанному в создании индекса полю или полям. Связь между 2 таблицами идёт по одинаковым полям в них ключам (Key). Для добавления, изменения, удаления данных используется язык SQL (Structured Query Language), о нём ниже. Описание структуры данных хранится в самой же БД в данных системных реляционных таблиц. Эта простая идея с 2-мерными таблицами выдержала проверку временем и продолжает быть самой распространённой.

Document stores:

Отличие от реляционных БД в том, что данные хранятся в виде документов с любой структурой. То есть колонки таблицы жёстко не определены. Но тем не менее можно создавать индексы, которые вставляют ссылку на строку если находится указанный аттрибут документа. Типичный представитель MongoDB хранение документов используя синтаксис JSON (Java Script Object Notation). На самом деле BSON (Binary JSON), который компактнее, как и любые бинарные типы чем строковые.

Вот так выглядят строки в коллекции (это аналог таблиц)

Точно также таблицы могут ссылаться друг на друга.

Key-Value : появился этот тип NoSQL решения из-за необходимости быстро записывать, менять и получать значения по какому-то параметру. Не редко это используют для некритичных, быстроменяющихся значений, которые нет смысла записывать и хранить. Типичный представитель Redis. На старом ноутбуке вы можете получить десятки тысяч операций в секунду по записи, изменению данных. Достигается это тем что данные хранятся в памяти, с которой операции быстрее. Отсюда же и минус, что если памяти недостаточно, то скорость будет деградировать. Например, вы хотите измерять число запросов с 1 IP за минуту. Заводится строка где ключ это IP, любое обращение добавляет счётчику +1. Если запросов много, то троттлинг (ограничение). Ключ может иметь TTL и обнуляться раз в X минут.

Search Engines: Поиск это важная функция в любой системе. Если c поиском по точному совпадению в виде ID (кода, артикула, партномера и т.д.) реляционные БД справляются очень качественно и быстро, то поиск внутри документа по фразе, включая использование разных форм слова, множественного числа и прочих составляющих живого языка уже не выходит так быстро. Нужно сканировать данные от начала до конца и выискивать походящие документы. Поэтому поступают так как делают крупные поисковики индексируя страницы, если представить по простому, то они проходят по документам предварительно, составляют список слов, которые встречаются в документе и когда нужен поиск, то ищут по преподготовленным спискам слов с ссылками на документы, чем больше слов совпало тем вероятнее, что этот документ и нужен. Типичный представитель ElasticSearch его большое число инсталляций обусловлено ещё и тем, что существует типичный стек ELK (англ. лось) ElasticSearch+Logstash+Kibana для мониторинга событий, например, логов веб серверов или сервисов.

Wide column stores: Лучше всего представлять как среднее между реляционной БД и Key-Value БД. Есть таблицы, строки и колонки. Но колонки не имеют жёсткой структуры и могут иметь в разных строках разные названия и значения.

Представители этого типа БД Cassandra, HBase.

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

Также удобно представлять графами связи между людьми (вершины), что он знает кого-то (ребро) или их возраст и интересы. Формула химического соединения можно представить, что вершины графаатомы молекулы, а рёбра это связи между молекулам. Теория графов обширна и развивается с 18 века, так что математическая база накоплена большая. Типичный представитель графовой СУБД - Neo4j.

Columnstore

Хотя в рейтинге db-engines этот вид не идёт отдельно, а относится к реляционным, но стоит его упомянуть. Коммерческие реляционные СУБД включают в себя и этот вид как отдельную особенность, но и существуют специализированные отдельные решения. Основное отличие колоночных БД, что данные хранятся не в строках, а в столбцах. Если у вас в столбце одни и те же значения, то они очень сильно компрессируются и меньше места занимают на диске и в памяти. Представители этого типа ClickHouse, Vertica. Эту картинку с анимацией лучше смотреть на сайте ClickHouse.

В последнее время стала появляться в диаграммах СУБД ClickHouse от Яндекса. Цифры разные, но то что её стали замечать и включать уже хорошо для её развития.

Multi-model databases

Во многих СУБД, помимо основного исторического типа хранения добавлялись новые с течением времени. Мир не стоит на месте, поэтому если создатели СУБД видели необходимость поддержать другие типы хранения данных, то они добавлялись. Поэтому у большинства современных СУБД из топа в описании может присутствовать multi-model. Перевод крупных реляционных СУБД в разряд multi-model ограничила рост многих NoSQL решений в последние годы. Зачем использовать что-то ещё, если нужный тип включен в основную СУБД как вторичный.

SQL vs NoSQL

Сам термин NoSQL возник чуть более 10 лет назад примерно в 2009 году и как говорят сейчас пошёл хайп. Многие новые программные продукты, которые были призваны решить некую проблему присущую связке Реляционная БД + SQL гордо начали именоваться NoSQL, чтобы показать, что они новые и продвигают невиданные доселе технологии способные решить много проблем. А проблемы действительно были. Нужна была возможность легко горизонтально масштабировать решения в связи с ростом данных, которые могли прибывать в больших количествах, объёмы данных стали резко увеличиваться. Причём стали сохраняться данные, которые не были структурированы, например, с сайта информация на что кликал, куда переходил пользователь, что искал, какие элементы всплывали, баннер показывался и т.д. Всё это сваливается и хранится. Сейчас вы и не удивляетесь, что вам упорно показывают рекламу по теме, которая вас однажды заинтересовала, вас уверенно ведут к воронке принятия решения, о покупке, подписке и т.д.

График роста данных, он немного не свежий, но показывающий рост данных более 10 лет назад.

По своему опыту скажу, в 0-вых годах оффлайн компании генерили больше долларов выручки на 1 Gb данных в БД, чем сейчас онлайн компания. И соотношение примерно раз в 100-200 меньше долларов на Гб у онлайн.

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

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

Часто можно встретить такого рода картинки классификаций NoSQL БД по типам и с примерами СУБД. Выше мы их рассмотрели.

A что же SQL Structured Query Language структурированный язык запросов? Он существует с начала 1970-х годов, был стандартизирован и благодаря этим стандартам, которые поддерживают все создатели реляционных БД минимизирует разницу в работе с разными реляционными СУБД. Да, производители вставляют свои собственные фичи (features - особенности), которые могут выходить за пределы стандартов SQL, так как предлагают некую новые конкурентные технологии, если они будут поддержаны массово, то эти новые технологии и их описание будут включены со временем в стандарт SQL. Если вы пишете SQL запросы в одной СУБД, то вам не составит большого труда перейти на другую и продолжить работать с ней. Мало того, часто в клиентах NoSQL СУБД, есть фишка в виде запросов на SQL. Например, для MongoDB я часто использую Studio3T, где вы можете писать обычный SQL и он переводится в специализированные запросы MongoDB, для самого MongoDB есть SQL адаптер. ClickHouse и Tarantool (российские разработки) поддерживают SQL запросы. Также во многих NoSQL СУБД появились особенности присущие SQL, например, join-ы, схемы данных, логика NULL для значений и т.д.

Cloud DBs vs DBs

Облачные технологии постепенно растут. Рост этот составляет десятки и сотни процентов в год на разные облачные технологии. Среди них есть и облачные БД. Если в любой области экономики есть рост с такими цифрами значит всё больше усилий будут прилагать компании, чтобы занять этот рынок и получить свой кусок пирога, а дальше заинтересовать побольше клиентов и включить их в этот пирог растущий дальше. Да, мы с вами это часть их пирога. На Хабре много статей про облака, плюсы и минусы, чтобы не повторяться можно почитать например, тут или в другой статье по соответствующим тэгам.

По оценкам Gartner объём всего облачного рынка за 5 лет вырастет в 2 раза.

Вот такие распределения по компаниям если брать BPaaS и IaaS. По моим ощущениям очень похоже на правду. AWS лидер, Microsoft понемногу догоняет в последние годы, Alibaba растёт и дешевле всего на рынке Китая, который уже нельзя игнорировать глобальным компаниям.

Рынок БД в облаке (DBaaS) выглядит в цифрах гораздо скромнее по сравнению с цифрами всех облачных трат, при том, что каждая компания имеет свои БД и не малые.

Объясняется это такими факторами:

  1. Зачастую компании не используют специфичные облачные БД провайдера, потому что нужно адаптировать приложения, есть особенности, которые не позволяют это делать. Чаще сейчас используется облачная инфраструктура, то есть вы получаете свои виртуальные хосты с CPU, RAM, SSD(HDD) и используете её, чтобы там установить экземпляры стандартных СУБД.

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

  3. Кто столкнулся с облаками, тот знает, что траты могут возникнуть откуда вы не ждёте и соответственно не просчитали стоимость. Приведу пример, положить данные на хранение в холодное облачное хранилище стоит не дорого, а вот скачать обратно уже в десятки или сотни раз дороже. Поэтому если вы делаете бэкапы и храните их не трогая, то это дешевле своих дисков, но вот когда захотите обратно скачать, то сначала вы подождёте много часов до извлечения, а потом заплатите значительную сумму за каждый Mb. И такая ситуация и для AWS и для Azure и остальных. Вот недавняя история про NASA, когда им аудиторы сказали, что будут платить гораздо больше. Случаи перерасходов бюджетов от роста функционала сплошь и рядом.

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

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

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

  7. В России к этому добавляется ситуация, что не так давно проводилась масштабная блокировка зарубежных ресурсов, блокировали многое включая IP адреса облачных БД. Это реальность и она может быть во многих странах.

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

В России есть Яндекс.Облако, SberCloud, честно не пользовался ими в плане БД. Был опыт использования других сервисов Яндекса, которые потом перевели в облако, поменяли протоколы и сделали платными. Пока не заинтересовали платить деньги, так как есть другие поставщики как Microsoft, Google, которые имеют бесплатные квоты для небольших объёмов и есть ещё ряд преимуществ.

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

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

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

OLTP vs OLAP

Данные в БД могут использоваться для проведения текущих операций бизнеса Online Transaction Processing (OLTP): найти клиента, выписать счёт, оплатить ресурс, списать остаток со склада при заказе товара и т.д. Почти все эти операции должны проводиться в режиме реального времени. Если пользователь на сайте или во внутрикорпоративной системе ожидает по несколько секунд простые операции и это проблема с БД, то значит есть что оптимизировать. OLTP изначально проектируются для ведения бизнеса в реальном времени. Если компания имеет базы данных, то там есть OLTP.

Есть данные, которые используются для анализа работы компании Online Analytical Processing (OLAP). То есть для OLAP собираются большие массивы данных и чтобы их быстро просчитывать в любом разрезе нужна простая магия по предрасчитыванию всего, что с наибольшей вероятность может понадобится бизнесу. То есть если вы хотите знать количества кликов на вашем глобальном сайте по странам или страницам, то их нужно заранее просчитать да ещё делая эту группировку по времени, чтобы потом смотреть динамику во времени, сравнивать с историческими трендами. И OLAP хранилища могут быть не реляционными да и вообще не структурированными, использовать специализированные языки управления большими массивами данных, или языки для статистической обработки данных. В последнее время стало модно называть обычных специалистов по аналитике в бизнесе Data Scientist. Это не совсем верно, но термин уже прижился. Обычно это смесь из следующих ингредиентов SQL, Python, R, фреймворков для работы с нейронными сетями, математическими моделями разного вида и т.д.

Количество OLAP БД обычно меньше в количественном отношении чем OLTP, но размеры их больше. Для OLAP БД важна поддержка многопоточности, когда запрос распараллеливается между ядрами и каждое ядро делает свою часть работы. Если ваша OLAP СУБД умеет шардироваться на много серверов, хорошо работает с многопоточностью, поддерживает все последние SIMD (single instruction, multiple data) инструкции процессоров, когда за 1 операцию обрабатываются большие пакеты данных, то скорость обработки данных увеличивается кратно на все эти множители.

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

SSD vs HDD vs Storage vs Tape vs Other

Эта часть о том на каких хранителях хранить данные для БД.

В 2020 году не остаётся сомнений в том, что SSD побеждают в борьбе с HDD. В серверных системах с БД это понимание пришло гораздо раньше, чем где либо. Всё дело в том, что в большинстве типов БД, важно не последовательное чтение, а чтение в память из разных мест с диска. И такая же случайная запись для данных. С этим нет проблем у SSD, тогда как скорость доступа до случайного места на диске у HDD достигается скоростью вращения шпинделя и скоростью перемещения считывающего механизма между дорожками. Попробуйте одновременно копировать несколько десятков файлов на HDD из разных мест, скорость быстро деградирует до неприемлемых значений. Так и запросы данных от 1000 пользователей, которые лежат в разных местах диска быстро сведут на нет скорость любого HDD. Поэтому для операционных OLTP систем нет большого смысла использовать HDD. На картинке ниже обычные SSD c 6000 IOPS (операций считывания и записи на диск в секунду), в серверных решениях особенно с NVME есть гораздо больше, но стоит отделять маркетинговые цифры на коротких замерах, попадающих в кэш от реальной работы диска в таком режиме круглыми сутками.

HDD есть смысл использовать в OLAP системах, когда данные лежат последовательно и их нужно читать и записывать только так или есть смысл использовать для бэкапов данных, это крупная последовательная запись и чтение. Также в больших архивных БД и везде где стоимость хранения 1 Гб это решающая единица. HDD дешевле SSD по стоимости за 1 Гб.

По отказоустойчивости SSD лучше HDD если их рассматривать как отдельные устройства. Это личный опыт на тысячах экземплярах. Выходы из строя SSD гораздо реже HDD, но нужно понимать, что это статистика по серверным моделям, многие из которых производились по нормам SLC и MLC, стоящие дороже, позволяющие перезаписывать данные гораздо больше раз чем продвигаемые сейчас TLC и QLC, которые не рекомендуются для БД. Для серверных систем где хранятся БД используют диски и комплектующие с повышенной отказоустойчивостью. SSD диск в 1Tb и стоимостью 1000$ - это нормальная ситуация для БД. В них заложены возможности работать месяцами на пределе, не только много читая, но и много записывая, не перегреваясь или резко сбрасывая скорость. Не нашлось картинки по сравнению отказоустойчивости серверных SSD и HDD, но есть про обычные. SSD выходят из строя реже.

Форм-фактор SSD это 2.5 дюйма устройства для горячей замены, PCI-X карты, U.2 серверный аналог M.2, который в настольных компьютерах. Современный протокол SSD NVME.

Storage Система Хранения Данных (СХД) - это внешние хранилища данных, которые подключаются к серверам по оптоволокну или сетевому интерфейсу. Хранилища ставятся в те же серверные стойки, что и сервера и соединяются с ними. СХД это ещё один огромный пласт информации, которого хватит на 10 статей. Специализированное оборудование для хранения данных. Их основное предназначение это высокая отказоустойчивость, повышенная скорость обработки данных. Стоимости хранилищ данных начинаются от десятков тысяч долларов за продвинутые версии и это с минимальным набором дисков. Верхняя планка не ограничена, она может достигать и миллионов долларов и больше. Современные СХД могут иметь в названии слова типа AllFlash что подразумевает отказ в них от HDD и внутренние алгоритмы и код оптимизированы только под SSD.

После поглощения EMC компания DELL упрочила своё положение на рынке хранилищ уровня предприятий. Huawei растёт на глазах и становится заметным игроком несмотря на санкции США. В России нет своих хранилищ данных мирового уровня, все значимые игроки рынка просто перемаркируют готовые изделия своей торговой маркой или собирают из частей известных производителей или вендоров свой вариант.

Intel Optane (3D Xpoint) специфичный вид энергонезависимой памяти, самый быстрый на данный момент на случайное чтение, но на случайную запись нет такого явного преимущества, а в последовательном чтении и записи проигрывает топовым SSD. Не развился из-за высокой цены на накопители и отсутствия накопителей большого объёма. Так SSD+NVME обеспечивают лучшие показатели цена/качество. За цену Optane можно купить несколько SSD, которые в RAID будут давать большую скорость.

RAID Нет смысла повторяться для чего нужны объединения дисков в массивы, для скорости и для отказоустойчивости. Прочитать можно здесь. Смотря какую задачу вы решаете, тот RAID и используется. Для OLTP БД чаще всего встречается RAID10.

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

Возможности СУБД

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

Горизонтальное масштабирование из коробки: Horizontal scaling vs Vertical Scaling.

Это то, что в немалой степени определило появление множества видов БД и СУБД в последние 10-15 лет. Если в начале 2000-x Oracle, Microsoft, IBM вели обратную агитацию и призывали объединять разрозненные данные из множества филиалов компаний в единый центр где стоит мощный сервер с данными и все работают удалённо с этими данными, включая появившиеся корпоративные сайты, Web API, мобильных клиентов, то уже в конце 2000-x при взрывном росте данных стало понятно, что вертикально масштабировать (покупать всё больший сервер) стоит уже слишком дорого или уже невозможно. Упирались в число CPU, дисков, сеть, соединений и т.д. для центральных узлов инфраструктуры. Поэтому появились решения, позволяющие распределять данные на множество серверов БД.

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

Не стоит думать, что горизонтальное масштабирование решит все ваши проблемы. Наоборот оно привнесёт усложнение всего и вся: кода, инфраструктуры, поиска ошибок и много чего ещё. А это тоже всё деньги. Но вам уже не понадобятся очень мощные и дорогие сервера, ваш проект сможет пережить большие нагрузки. Отдельные узлы хранящие данные часто называют шардами, а процесс распределения данных и запросов между узлами шардированием. Если правильно выбирать ключ шардирования, то запросы за данными идут только к той шарде где они есть. Брать на себя распределение запросов может как само приложение так и движок СУБД. Ниже на картинке пример, когда используется hash функция для шардирования, чтобы определить какой сервер использовать. И ещё у каждой шарды могут быть копии (реплики).

В данный момент, не так просто купить новые 8 процессорные сервера для пиковой производительности, их число очень ограничено они не нужны рынку, вытеснены 4 процессорными, которые дешевле и не в 2 раза, а больше. И если брать реальный пример, то 2 процессорный современный сервер по мощности вычислений сопоставим или превосходит 8 процессорный сервер 10 летней давности. Помимо процессоров ускорились все компоненты серверов: память, шина и т.д. Тот же самый запрос будет работать в 2-3 раза быстрее, если все данные в памяти. СУБД очень хорошо умеют использовать ядра и параллелить выполнение запросов.

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

Итог: для реально больших проектов вам понадобится горизонтальное масштабирование, если вы не доросли до такого, то лучше не усложняйте сервисы и БД разнесением на множество узлов, если только в СУБД это не поддерживается совершенно прозрачно для приложений. Проще проводить действия в оперативной памяти одного сервера чем тащить разрозненные куски данных с разных узлов и объединять их, чтобы получать результат.

Отказоустойчивость из коробки - High Availability. Master-master, master-slave.

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

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

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

Выглядит примерно так. Есть одна БД с данными, они расходятся на остальные сервера, возможно в удалённом дата-центре. Slave-копии могут использоваться для чтения, запросы за данными могут направляться на копии. Называется Master-Slave.

Но рано или поздно начнёт возникать ситуация, что ваша БД не успевает записывать. Хочется, чтобы данные могли записываться в другой копии, она была бы не только для чтения. Эта схема посложнее, так как нужно ещё разрешать конфликты если изменения одинаковых данных происходит на разных узлах в одно время, а если у вас master копии далеко, то вероятность конфликтов увеличивается. Это называется Master-Master. Плюс у master могут быть slave копии.

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

Хорошо, когда ваша СУБД поддерживает такое распространение данных, на вас не ложится груз проблем как зафиксировав изменение данных в одном месте добиваться, чтобы данные появились в другом. А если сеть моргнула, а если копия не отвечает, и ещё много если на себя берёт движок. Если master становится недоступным, то происходит автоматическое перераспределение ролей, вчерашний slave становится master, а все остальные копии принимают от него данные. Приложение не замечает переключение, потому что работает через роутер, который также переключается. У вас сломался сервер с БД, а простоя нет, всё продолжает работать как ни в чём не бывало. Также можно проводить работы с экземпляром БД выключая его из работы, установить обновление СУБД и потом обратно ввести в работу.

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

Online maintenance - online alter

24/7/365 означает, что ваш проект работает всегда 24 часа, 7 дней в неделю и все 365 дней. У вас нет окна для работ по обслуживанию БД (maintenance). Значит все операции по созданию архивных копий, перестройки индексов, созданию таблиц, удалению колонок и много чего ещё, что должно проходить онлайн без заметной деградации производительности. То есть пока вы перестраиваете таблицу, например, удаляя колонку данных в реляционной БД, то таблица будет доступна, а будет создаваться копия, которая будет содержать все изменения пока идёт процесс перестройки. Не всегда есть возможность иметь много копий серверов с БД, для платных СУБД это ещё и деньги, чтобы проводить работы по очереди, поэтому возможность изменений структур без прерывания работы очень важно.

Мониторинг

Каждая интенсивно используемая и изменяемая БД рано или поздно сталкивается с вопросами производительности. Но как понять, что проблемы начались заранее не доводя до потери денег компаниями? Нужно мониторить состояние вашей СУБД. Если вы можете делать замеры текущего состояния, а ещё лучше смотреть статистику, которая собирает сама же СУБД, то вы из коробки можете решить множество проблем. В платных СУБД есть невероятно огромное количество метрик и статистик, есть сторонние компании, создающие инструменты мониторинга. Рынок очень конкурентный и в каждом инструменте есть свои особенности. Бесплатные СУБД в последние годы сильно улучшились в плане мониторинга и к ним тоже стали создавать мониторинги, только часто они уже не бесплатные.

Инструменты управления СУБД

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

Или для некоторых СУБД есть прекрасная возможность, востребованная у работающих с крупными данными, когда виден % исполнения запроса. Вы видите когда закончится запрос и выдаст результат.

Ещё из интересного: скриптование данных это создание инструкций SQL, которые создадут копию данных на другом сервере, миграция данных, сравнение структур данных, сравнение данных, экспорт в другие форматы, системы контроля версий и обновления product-среды и т.д.

Есть инструменты, которые используются для управления разными типами БД, например, DataGrip от JetBrains (те самые которые причастны к Kotlin, ReSharper, GoLand и т.д.) очень мощный и настраиваемый. Картинка СУБД, с которыми он работает.

Расширение функционала СУБД на другом языке программирования

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

Логирование изменений

Важным вопросом бывает вопрос, а что было с данными или структурами БД на некий момент назад. Логирование изменений пригодится, когда вы поменяете структуры или данные, а понадобится вернуть обратно сами данные, либо структуры таблиц, индексы, либо код SQL в запросах. Вы будете знать, что на такой-то момент было так. Также это предохраняет от уничтожения данных, чаще всего непреднамеренного. В каждой СУБД название технологий разное, для примера Flashback Data Archive, Temporal history, Change Tracking, Data Audit и т.д.

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

Бизнес-логика в БД или нет

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

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

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

Не все БД позволяют хранить код запросов в БД, например, в ClickHouse нет даже полноценного языка скриптов. Как организовывать хранение бизнес-логики компания решает самостоятельно, здесь нет универсальных правил.

Поддержка JSON

Самый скачиваемый NuGet-ом пакет в Micrsoft Visual Studio для языка C# - это библиотека для работы с JSON (JavaScript Object Notation). Этот пример показывает, что если нечто востребовано, то оно будет пробиваться везде где сможет даже у Microsoft, который исторически развивал XML. Хотя хранение в JSON противоречит правилам реляционных БД, но реальность такова, что слишком много данных в JSON в ИТ инфраструктуре и поддержку этого формата вставляют в СУБД разного типа.

In Memory

Оперативная память RAM быстрее любых жёстких дисков. Поэтому для максимального ускорения операций, в том числе с БД нужно по максимуму использовать память. Скажу сразу, что во всех СУБД память стараются использовать по полной и БД любого типа работает быстрее если много памяти на сервере. Часто запись на диск данных откладывается и не происходит непосредственно во время проведения операции записи. Есть много технологий и в разных СУБД они разные или могут называться по-разному, чтобы снизить число обращений к дискам.

Какие-то СУБД поддерживают возможность In-Memory как вариант работы, а некоторые объявляют эту возможность как главную, например, Tarantool.

Сжатие данных

Немаловажный параметр, который позволит экономить на размерах дисков и памяти серверов. Кроме этого сжатие ускоряет в разы операции чтения данных, сохранения копий данных и т.д. Например для OLAP хранилищ это значит более быстрое получение результатов запросов по огромной массе данных. Нужно понимать, что сжатие не бесплатно для ресурсов, но плюсов больше чем минусов, алгоритмы сжатия используются быстрые, которые не сильно нагружают CPU. Обычно сжатие задаётся на уровне СУБД и в работе программного обеспечения никак не сказывается, то есть ничего не нужно менять в коде.

Временные (temporary) объекты

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

MapReduce

Под этим устоявшимся термином от Google будем обозначать класс задач по распределённым вычислениям. Название идёт от двух шагов Map распределяющего входные данные между распределёнными узлами и Reduce получение результатов от распределённых узлов и формирование итогового результата. Представители Apache Hadoop и Spark это целый набор библиотек, файловой распределённой системы HDFS и много чего ещё. Примером СУБД для работы с такими фреймворками является Hive, реляционная СУБД с поддержкой SQL. Тренды.

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

Работа с пространственными данными

Если необходимо находить объекты в реальном мире и чаще всего это задача нахождения ближайших объектов по отношению к некой точке в пространстве. Но как искать такие данные в реляционных данных? В принципе ничего не мешает делать свои собственные способы как искать в любом виде БД ближайшие точки, как подготавливать данные, чтобы поиск был быстрым. Разработчики СУБД тоже увидев спрос на такие поиски добавили технологии для пространственных индексов (spatial index) в виде сеток или часто можно встретить реализацию индексов с помощью R-tree дерева.

Graph data

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

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

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

В самих БД есть ряд технологий, которые позволяют снизить риск доступа к данным. Это шифрование бэкапов, когда даже украв диск с копиями БД без пароля с ним ничего нельзя сделать. Файлы работающей БД вы скорее всего не сможете просто прочитать, СУБД получает к ним эксклюзивный доступ. В каждой СУБД есть разграничение прав пользователей, группы в которые включаются пользователи, права на разные операции на сервере СУБД включая чтение данных, запись, изменение структур, настроек и т.д. Огромное число видов доступа под любые задачи из реальной жизни. Доступы можно раздавать, отнимать, явно запрещать доступ к неким ресурсам. Вопросы безопасности - это огромный и очень интересный пласт информации не для этой статьи. Не забывайте ставить обновления, так как уязвимости находятся и закрываются производителями СУБД. Проводите сторонний аудит безопасности ИТ инфраструктуры проверенными компаниями. Вы будете неприятно удивлены когда до ваших данных доберутся аудиторы безопасности (пентестеры), правда чаще это будет не из-за проблем безопасности самой СУБД или вашей БД.

Использование GPU, NPU (Neural Processing Unit), Google TPU (Tensor Processing Unit)

На современном этапе развития БД каких-то массовых использований графических и специализированных процессоров в движках СУБД не наблюдается. Да, GPU и NPU используются для математических расчётов, обучения нейронных сетей, но размер оперативной памяти GPU и NPU меньше чем у обычных серверов, а задача выборки или обновления данных (наиболее частые в БД) не требуют огромной вычислительной мощности. Данные из БД можно подавать на вход специализированных фреймворков работающих с нейронными сетями для дальнейших итераций. DPU (Data Processing Unit) это класс процессоров не имеющего стандартов, обычно интегрированных в сетевые карты. Их будущее ещё под вопросом.

Community

Большое сообщество единомышленников, использующих то же ПО обеспечит вам ответы на вопросы, которые могут быть не так тривиальны. Первое, что стоит смотреть при непонятной ошибке это аналогичные вопросы с тем же текстом ошибки или описанием ситуации. Для разных СУБД есть множество сайтов с авторитетными авторами. Тем не менее приведу статистику с StackOverflow.com сколько вопросов есть по топовым БД, на каждый может быть несколько ответов. Наверняка Ваш вопрос будет уже с решением если сообщество большое. Такая вот накопленная KnowledgeBase.

Tag

Count

MySQL

598,350

SQL Server

285,092

Mongodb

129,907

Oracle

122,385

Postgresql

117,427

sqlite

82,596

ms-access

46,177

elasticsearch

44,482

redis

18,290

db2

10,485

clickhouse

530

tarantool

103

Для общей картины, изображение связей БД, фреймворков, языков программирования, платформ взятых . Не смотрим % использования - это всегда причина для холиваров (holy war - священная война). Здесь больше интересны связи, что с чем чаще входит в связь. Красным отмечены СУБД. Куда делись Oracle и IBM DB2 это загадка на совести составителей диаграммы.

Подведём итоги: Все СУБД из топа завоевали это место в процессе естественного отбора и имеют свой кусок рынка. OpenSource побеждает Commercial СУБД, в России процесс ускорился в этом направлении. СУБД Online проектов растут в количестве отнимая долю в выручке у оффлайн бизнеса. Реляционные БД в ближайшее время не сдадут позиции и будут преобладать. Для управления БД будет доминировать язык SQL. Для хранения данных операционных БД используется флеш-память. Чем больше возможностей и особенностей у СУБД и большее число людей использует, то тем проще её интегрировать в инфраструктуру и поддерживать. Переход в облака БД идёт фрагментарно, в ближайшие годы большая часть данных будет храниться локально. Российские технологии по БД в мировом масштабе не особо заметны, но есть такие и пожелаем им успеха.

Подробнее..

DataGrip 2021.1 Редактирование прав, контекстные шаблоны, предсказуемая навигация и не только

01.04.2021 20:04:02 | Автор: admin

Привет!

Сегодня мы выпустили DataGrip 2021.1: наш самый мощный релиз за последние годы. И это не шутка!

Самое важное:

  • Интерфейс для работы с правами доступа

  • Контекстные шаблоны Live Templates

  • Упрощенная навигация

  • Легкое копирование источников данных

  • Улучшенная сортировка

  • Редактирование данных в MongoDB

  • Поддержка Azure MFA

Редактирование прав

В окне редактирования объекта теперь можно изменять права на объект.

Также права можно добавлять и изменять в окне редактирования пользователя или роли. Напомним, что вызываются окна редактирования нажатием Cmd/Ctrl+F6.

Это работает в PostgreSQL, Redshift, Greenplum, MySQL, MariaDB, DB2, SQL Server и Sybase.

Контекстные шаблоны

Нас давно просили сделать как у других: чтобы для таблицы в проводнике можно было быстро сгенерировать простой запрос, например SELECT TOP 100 FROM %tableName%.

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

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

В итоге мы сделали новый вид шаблонов кода контекстные шаблоны. Работают они так:

Посмотрим шаблон Select first N rows from a table. Найдите его в настройках:

Он выглядит, как обычный шаблон, и его можно использовать в таком качестве. Но у переменной $table$ есть специальное выражение dbObjectName(). Вы увидите это, нажав на Edit Variables. Так вот, именно это выражение делает шаблон контекстным, то есть значение в эту переменную можно подставить автоматически кликнув на любой объект в проводнике базы данных.

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

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

Редактор данных

Редактирование данных в MongoDB

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

<img src="http://personeltest.ru/aways/habrastorage.org/webt/1t/ux/8y/1tux8yvfyrs2eiha3byoj0ss2tu.png" />

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

Сортировка

Сортировка стала более удобной:

  • Мы добавили поле ORDER BY, а <Filter> переименовали в WHERE. Впишите в ORDER BY условия сортировки, чтобы получился работающий запрос.

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

Если хотите отсортировать данные на стороне DataGrip, отключите Sort via ORDER BY. Конечно, в этом случае, сортируются данные только на текущей странице.

Теперь вы можете по умолчанию открывать таблицы отсортированными по числовому первичному ключу.

Панель инструментов

Мы немного обновили панель инструментов в редакторе данных: добавили кнопки Revert Changes и Find. Кнопки Rollback и Commit в режиме автоматического подтверждения транзакций скрываются.

Транспонирование однострочных результатов

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

Навигация

Предсказуемые действия

Мы удалили эти настройки:

Если вы никогда не меняли их значения по умолчанию, то главное изменение в новой версии для вас такое: действие Go to declaration (Ctrl/Cmd+B) теперь открывает DDL этого объекта. Раньше оно подсвечивало объект в проводнике базы данных.

Для перемещения к объекту в проводнике мы представили новое сочетание клавиш: Alt+Shift+B для Windows/Linux и Opt+Shift+B для macOS.

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

  • Ctrl/Cmd+B открывает DDL.

  • F4 открывает данные.

  • Alt/Opt+Shift+B подсвечивает объект в проводнике.

Удаление настроек всегда ломает привычки некоторому количеству людей. Мы постарались учесть это. Вот наши советы таким пользователям:

  • Все сочетания клавиш можно менять. Если, например, вы не хотите отвыкать от того, что Ctrl/Cmd+B подсвечивает объект в проводнике, назначьте это сочетание клавиш действию Select in database tree.

  • В то же время, если вам нравится, что внутри скрипта Ctrl/Cmd+B и Ctrl/Cmd+Click открывает определение CREATE, не убирайте эти сочетания с действия Go to declaration, если вы последовали предыдущему совету.

  • Если вам нравилось, что при отключенной настройке Preview data editor over DDL editor двойной клик по таблице открывал DDL, это можно вернуть через ключ в реестре. Он называется database.legacy.navigate.to.code.from.tree. Но мы не советуем менять значения в реестре и надеемся, что те полпроцента людей, у которых эта галочка была снята, быстро привыкнут к новому поведению :)

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

Вкладка Database

Тут мы ничего особо не сделали просто переименовали вкладку Tables в Database. Этим мы напоминаем, что по сочетанию клавиш Cmd+O/Ctrl+N можно искать не только таблицы, но и процедуры, функции, схемы.

Соединение

Поддержка Azure MFA

Мы поддержали интерактивную аутентификацию через Azure Active Directory. Если она включена, при соединении у вас автоматически откроется браузер, где вы сможете завершить аутентификацию.

Версия 2.x драйвера для Redshift

Этот драйвер можно скачать в DataGrip, начиная с версии 2021.1. Главное изменение состоит в том, что теперь запросы можно останавливать.

Полная поддержка Google Big Query

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

Поддержка диалекта CockroachDB

Теперь DataGrip правильно подсвечивает запросы и показывает ошибки в скриптах для CockroachDB. Соответствующую интроспекцию мы сделаем в одном из следующих релизов.

Улучшения в окне соединения

Сделали это окно чуть более дружелюбным:

  • Источники данных и драйверы разделены на две вкладки.

  • На странице каждого драйвера появилась кнопка Create data source.

  • Кнопка Test Connection переехала вниз теперь ее видно из всех вкладок, а не только из General и SSH/SSL.

  • Для источников данных на основе файлов (так называемых DDL Data Sources) теперь можно явно задать диалект.

  • Поле JDBC URL расширяется, потому что адреса для подключения бывают очень длинными.

Проводник базы данных

Легкое копирование источников данных

Возможность копировать и вставлять источники данных мы сделали давно. Но с этого релиза вы можете использовать самые знаменитые сочетания клавиш в мире Ctrl/Cmd+C/V/X.

  • Напоминаем, что когда вы копируете источник данных, в буфер обмена сохраняется XML. Его можно послать коллеге в мессенджере, а он вставит его в свою IDE все сработает.

  • Если копирование и вставка происходит в одном проекте, вам не понадобится заново вводить пароль.

  • Источник данных можно не только копировать, но и вырезать. Вырезание отменяется при помощи Ctrl/Cmd+Z.

Новый интерфейс

Объекты второстепенной важности (роли, пространства имен, внешние источники и др.) мы поместили в папки Server Objects и Database Objects.

Если хотите чтобы было, как раньше, включите настройку Group Database and Schemas.

[Oracle] Скрытие сгенерированных объектов

Если отключить Show generated objects, то из проводника пропадут:

  • Таблицы материализованных представлений

  • Логи материализованных представлений

  • Вторичные таблицы

[SQLite] Новые типы объектов

В SQLite теперь отображаются функции, модули и виртуальные столбцы.

Улучшения для неподдерживаемых баз

Шаблоны для источников данных

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

Еще раз напомним, что поддержка для таких источников данных базовая. Скрипты подсвечиваются на основе стандарта SQL:2016, а информация об объектах берется из драйвера.

Написание запросов

Инспекция про избыточные имена в CTE

Если запрос не запустится из-за избыточных имен в общем табличном выражении, DataGrip сообщит об этом.

[SQL Server] Системные функции можно использовать без имени схемы

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

Поддержка JSON Lines

Формат JSON Lines используется для хранения данных и логов. И новая версия правильно подсвечивает файлы этого формата.

Толщина шрифта

Теперь вы можете настраивать толщину шрифта.

Импорт / Экспорт

Незагруженные данные

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

Настройка, которая определяет, какое количество данных DataGrip загружает по умолчанию, находится здесь: Settings/Preferences | Database | Data Views | Maximum number of bytes loaded per value.

Запрос в файле Excel

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

First row is header в контекстном меню импорта

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

Интерфейс

Прикрепление папки при помощи drag-n-drop

Прикрепить папку, то есть открыть ее в панели Files, теперь можно, перетащив её.

Открытие вкладок в режиме разделенного редактора

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

Длинные названия вкладок

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

На этом все!

Скачать триал на месяц

Фидбек принимаем в комментариях к посту и здесь:

Трекер (это если точно нашли проблему)

Телеграм-канал

Твиттер

Почта

Команда DataGrip

Подробнее..

Перевод 14 вещей, которые я хотел бы знать перед началом работы с MongoDB

23.09.2020 20:15:33 | Автор: admin
Перевод статьи подготовлен в преддверии старта курса Нереляционные базы данных.



Основные моменты:

  • Крайне важно разработать схему несмотря на то, что в MongoDB она необязательна.
  • Аналогично, индексы должны соответствовать вашей схеме и шаблонами доступа.
  • Избегайте использования больших объектов и больших массивов.
  • Будьте осторожны с настройками MongoDB, особенно если речь идет о безопасности и надежности.
  • В MongoDB нет оптимизатора запросов, поэтому вы должны быть осторожны при выполнении операций запроса.


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

Создание сервера MongoDB без аутентификации


К сожалению, MongoDB по умолчанию ставится без аутентификации. Для рабочей станции, доступ к которой устанавливается локально, такая практика нормальна. Но поскольку MongoDB это многопользовательская система, которая любит использовать большие объемы памяти, будет лучше, если вы поставите ее на сервер с максимально возможным в ваших условиях количеством оперативной памяти, даже если собираетесь использовать ее только для разработки. Установка на сервер через порт по умолчанию может оказаться проблемной, особенно, если в запросе можно выполнить любой код на javascript (например, $where в качестве идеи для инъекции).

Есть несколько методов аутентификации, но проще всего установить для пользователя ID/пароль. Воспользуйтесь этой идеей, пока будете думать над причудливой аутентификацией на основе LDAP. Если говорить о безопасности, то MongoDB должна постоянно обновляться, а логи всегда следует проверять на наличие несанкционированного доступа. Мне, например, нравится выбирать другой порт в качестве порта по умолчанию.

Не забудьте привязать поверхность атаки к MongoDB


Чек-лист обеспечения безопасности MongoDB содержит хорошие советы для снижения риска проникновения в сеть и утечки данных. Легко отмахнуться и сказать, что сервер для разработки не нуждается в высоком уровне безопасности. Однако все не так просто и это относится ко всем серверам MongoDB. В частности, если нет веской причины использовать mapReduce, group или $where, нужно отключить использование произвольного кода на JavaScript, написав в файле конфигурации javascriptEnabled:false. Поскольку в стандартной MongoDB файлы данных не зашифрованы, разумно запускать MongoDB с Dedicated User, у которого есть полный доступ к файлам, с ограниченным доступом только для него и возможностью использовать собственные средства управления доступом к файлам операционной системы.

Ошибка при разработке схемы


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

Классическая статья 6 эмпирических правил для проектирования схем MongoDB стоит того, чтобы ее прочитать, а такие функции, как Schema Explorer в стороннем инструменте Studio 3T, стоит использовать для регулярных проверок схем.

Не забудьте о порядке сортировки


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

Создание коллекций с большими документами


MongoDB рада разместить большие документы размером до 16 МБ в коллекциях, а GridFS предназначена для больших документов размером больше 16 МБ. Но только потому, что большие документы там можно разместить, хранить их там не лучшая идея. Лучше всего MongoDB будет работать, если вы будете сохранять отдельные документы размером в несколько килобайт, рассматривая их больше, как строки в широкой SQL-таблице. Большие документы будут источником проблем с производительностью.

Создание документов с большими массивами


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

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

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


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

В MongoDB вы инструктируете повара. Например, нужно убедиться, что данные проходят через reduce как можно раньше в пайплайне с помощью $match и $project, а сортировка происходит только после reduce, и что поиск происходит ровно в том порядке, в котором вам нужно. Наличие оптимизатора запросов, который избавляет от лишней работы, оптимально упорядочивает этапы и выбирает тип соединения, может вас избаловать. В MongoDB у вас появляется больше контроля ценой удобства.

Такие инструменты как Studio 3T упростят построение запросов агрегации в MongoDB. Функция Aggregation Editor позволит вам применять операторы пайплайна по одному этапу за раз, а также проверять входные и выходные данные на каждом этапе для упрощения дебага.

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


Никогда не устанавливайте в MongoDB параметры записи с высокой скоростью, но низкой надежностью. Этот режим file-and-forget кажется быстрым, поскольку команда возвращается до того, как осуществляется запись. Если система упадет до того, как данные будут записаны на диск, они потеряются и окажутся в несогласованном состоянии. К счастью, в 64-битном MongoDB включено журналирование.

Движки для хранения MMAPv1 и WiredTiger используют логирование для предотвращения этого, хотя WiredTiger может восстановиться до последней согласованной контрольной точки, если журналирование отключено.

Журналирование гарантирует, что база данных находится в согласованном состоянии после восстановления и хранит все данные до момента записи в журнале. Периодичность записей настраивается с помощью параметра commitIntervalMs.

Чтобы быть уверенным в записях, убедитесь, что в файле конфигурации журналирование включено (storage.journal.enabled), а периодичность записей соответствует тому объему информации, который вы можете позволить себе потерять.

Сортировка без индекса


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

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

Поиск без поддержки индексов


Поисковые запросы выполняют функцию аналогичную операции JOIN в SQL. Для лучшей работы им нужен индекс значения ключа, используемого в качестве внешнего ключа. Это неочевидно, поскольку использование не отражено в explain(). Такие индексы являются дополнением к индексу, записанному в explain(), который в свою очередь используется операторами пайплайна $match и $sort, когда те встречаются в начале пайплайна. Индексы теперь могут охватывать любую стадию пайплайна агрегации.

Отказ от использования мультиобновлений


Метод db.collection.update() используется для изменения части существующего документа или целого документа, вплоть до полной замены в зависимости от заданного вами параметра update. Не так очевидно, что он не обработает все документы в коллекции, пока вы не установите параметр multi для обновления всех документов, отвечающих критериям запроса.

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


В JSON объект состоит из неупорядоченной коллекции размером ноль или более пар имя/значение, где имя это строка, а значение это строка, число, логическое значение, ноль, объект или массив.

К сожалению, BSON придает большое значение порядку при поиске. В MongoDB порядок ключей внутри встроенных объектов имеет значение, т.е. { firstname: "Phil", surname: "factor" } это не то же самое, что { { surname: "factor", firstname: "Phil" }. То есть вы должны хранить в документах порядок пар имя/значение, если хотите быть уверены в том, что найдете их.

Не путайте null и undefined


Значение undefined никогда не было допустимым в JSON, согласно официальному стандарту JSON (ECMA-404, Раздел 5), несмотря на то, что оно используется в JavaScript. Более того, для BSON оно устарело и преобразовывается в $null, что не всегда является хорошим решением. Избегайте использования undefined в MongoDB.

Использование $limit() без $sort()


Очень часто, когда вы ведете разработку в MongoDB, полезно просто увидеть образец результата, который вернется из запроса или агрегации. Для этой задачи вам пригодится $limit(), но его никогда не должно быть в финальной версии кода, если только перед ним вы не используете $sort. Эта механика нужна, поскольку иначе вы не можете гарантировать порядок результата, и не сможете надежно просматривать данные. В верхней части результата вы будете получать разные записи в зависимости от сортировки. Для надежной работы запросы и агрегации должны быть детерминированными, то есть выдавать одинаковые результаты при каждом выполнении. Код, в котором есть $limit(), но нет $sort, не будет являться детерминированным и впоследствии может вызвать ошибки, которые будет трудно отследить.

Заключение


Единственный способ разочароваться в MongoDB это сравнивать ее непосредственно с другим типом баз данных, таким как СУБД, или прийти к ее использованию, исходя из каких-то определенных ожиданий. Это все равно что сравнивать апельсин с вилкой. Системы баз данных преследуют определенные цели. Лучше всего просто понять и оценить для себя эти различия. Было бы стыдно давить на разработчиков MongoDB из-за пути, который вынудил их идти по пути СУБД. Мне хочется видеть новые и интересные способы решения старых проблем, таких как обеспечение целостности данных и создание систем данных, устойчивых к сбоям и атакам злоумышленников.

Внедрение в MongoDB в версии 4.0 ACID transactionality хороший пример внедрения важных улучшений инновационным путем. Мультидокументальные и мультиоператорные транзакции теперь атомарные. Также появилась возможность регулировать время, необходимое для получения блокировок, и заканчивать зависшие транзакции, а также изменять уровень изоляции.


Читать ещё:


Подробнее..

Виды репликации в MongoDB

30.09.2020 14:10:05 | Автор: admin
Привет, хабровчане! Расшифровали для вас часть урока по MongoDB от Евгения Аристова, разработчика с 20-летним стажем и автора онлайн-курса Нереляционные базы данных. Материал, как и сам курс, будет полезен специалистам, сталкивающимся в работе с NoSQL, желающим научиться оптимизировать свои базы данных и работу с ними.

Зачем нужна репликация?


1. Высокая доступность. Бэкап это хорошо, но нужно время на его развертывание.
2. Горизонтальное масштабирование. В случае, когда закончились физические ядра и память у сервера.
3. Бэкап лучше делать с реплики, а не с мастера.
4. Геораспределение нагрузки.

В MongoDB видов репликации из коробки немного: самый актуальный на данный момент Replicaset, а второй Master-slave, который ограничен версией 3.6 и подробно рассматриваться в этой статье не будет.

1. Запись и чтение с основного сервера


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



2. Чтение с реплики


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



3 способа сделать реплику доступной для чтения:


  • Указать db.slaveOk()
  • Указать в строке подключения драйвера нужные параметры
  • Указать все, а потом более точечно прописать в самом запросе, например, читай из Secondary в Южном регионе: db.collection.find({}).readPref( secondary, [ { region: South} ] )


Проблемы чтения с реплики


  1. Так как запись асинхронная, она может быть уже сделана на Primary, но не доехать до Secondary, поэтому будут прочитаны старые данные с Secondary.
  2. Записав данные на основной, нельзя быть уверенным, когда остальные получат эти данные.
    Чтобы было все синхронно, каждая нода должна подтвердить получение данных. Сейчас в MongoDB есть общие настройки, а есть на каждый запрос, где можно указать, от скольки нод ожидается подтверждение запроса.
  3. Когда падает основной сервер, запускается процесс выборов (кворум) а это уже особое отдельное веселье.


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


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



Б) Одна Secondary-нода меняется на Arbiter. Это очень легковесное приложение, запускается как Mongo, практически не ест ресурсов и отвечает за то, что определяет, какую ноду в момент голосования признать главной. И это в целом рекомендуемая конфигурация.



Основные особенности этой конфигурации:


  • Репликация асинхронная
  • Арбитр не содержит данных, и поэтому очень легковесный
  • Primary может стать Secondary и наоборот. Арбитр не может стать ни Primary, ни Secondary
  • Максимальное количество реплик 50 и только 7 из них имеют право голосовать
  • Arbiter можно установить и на Primary или Secondary, но делать это не рекомендуется, т.к. если этот сервер упадет, Arbiter тоже не сможет выполнить свою функцию.


Если вам интересно узнать больше о кластерных возможностях MongoDB, посмотреть запись всего демо-урока можно тут. На занятии Евгений Аристов демонстрирует отличия Replicaset от Master-slave, объясняет процесс кворума, масштабирование, шардирование и правильный подбор ключа к шардированию.

Изучение возможностей MongoDB входит в программу онлайн-курса Нереляционные базы данных. Курс предназначен для разработчиков, администраторов и других специалистов, которые сталкиваются в работе с NoSQL. На занятиях студенты на практике осваивают наиболее актуальные сегодня инструменты: Cassandra, MongoDB, Redis, ClickHouse, Tarantool, Kafka, Neo4j, RabbitMQ.

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

Шардинг, от которого невозможно отказаться

22.04.2021 10:12:30 | Автор: admin
image

А не пора ли нам шардить коллекции?
Не-е-е:


  • у нас нет времени, мы пилим фичи!
  • CPU занят всего на 80% на 64 ядерной виртуалке!
  • данных всего 2Tb!
  • наш ежедневный бекап идет как раз 24 часа!

В принципе, для большинства проектов вcё оправдано. Это может быть еще прототип или круг пользователей ограничен Да и не факт, что проект вообще выстрелит.
Откладывать можно сколько угодно, но если проект не просто жив, а еще и растет, то до шардинга он доберется. Одна беда, обычно, бизнес логика не готова к таким "внезапным" вызовам.
А вы закладывали возможность шардинга при проектировании коллекций?


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


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


Всем привет, от команды разработки Smartcat и наших счастливых админов!


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


Зачем нам шардинг


Шардинг штатная возможность горизонтального масштабирования в MongoDB. Но, чтобы стоимость нашего шардинга была линейной, нам надо чтобы балансировщик MongoDB мог:


  • выровнять размер занимаемых данных на шард. Это сумма размера индекса и сжатых данных на диске.
  • выровнять нагрузку по CPU. Его расходуют: поиск по индексу, чтение, запись и агрегации.
  • выровнять размер по update-трафику. Его объем определяет скорость ротации oplog. Это время за которое мы можем: поднять упавший сервер, подключить новую реплику, снять дамп данных.

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


Особенности шардинга в MongoDB


Как мы знаем балансировщик MongoDB при отсутствии ограничений (превышение размера данных на шард, нарушение границ зон) выравнивает только количество чанков на шард. Это простой, надежный и сходящийся алгоритм.
Есть документация про лимиты количества чанков и про порядок выбора размещения чанка.
Этот алгоритм и определяет главные ограничения на балансируемые данные:


  1. Ключ шардирования должен быть высокоселективный. В противном случае мы не получим достаточного числа интервалов данных для балансировки.
  2. Данные должны поступать с равномерным распределением на весь интервал значений ключа. Тривиальный пример неудачного ключа это возрастающий int или ObjectId. Все операции по вставке данных будут маршрутизироваться на последний шард (maxKey в качестве верхней границы).

Самое значимое ограничение гранулярность ключа шардирования.
Или, если сформулировать отталкиваясь от данных, на одно значение ключа должно приходиться мало данных. Где "мало" это предельный размер чанка (от 1Mb до 1Gb) и количество документов не превышает вот эту вот величину.


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


Бизнес логика требует слонов


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


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

Пример модели:


{    _id: ObjectId("507c7f79bcf86cd7994f6c0e"),    projectId: UUID("3b241101-e2bb-4255-8caf-4136c566a962"),    name: "job name 1",    creation: ISODate("2016-05-18T16:00:00Z"),    payload: "any additional info"}

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


Теперь, надо выбрать второе поле ключа или оставить только первое.
Например, у нас 20% запросов используют только поле name, еще 20% только поле creation, а остальные опираются на другие поля.
Если в ключ шардирования включить второе поле, то крупные проекты, те у которых объем работ не помещается в одном чанке, будут разделены на несколько чанков. В процессе разделения, высока вероятность, что новый чанк будет отправлен на другой шард и для сбора результатов запроса нам придётся обращаться к нескольким серверам. Если мы выберем name, то до 80% запросов будут выполняться на нескольких шардах, тоже самое с полем creation. Если до шардирования запрос выполнялся на одном сервере, а после шардирования на нескольких, то нам придется дополнительную читающую нагрузку компенсировать дополнительными репликами, что увеличивает стоимость масштабирования.


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


  • у нас могут появляться не перемещаемые jumbo-чанки. При большом объеме работ в одном проекте мы обязательно превысим предельный размер чанка. Эти чанки не будут разделены балансировщиком.
  • у нас будут появляться пустые чанки. При увеличении проекта, балансировщик будет уменьшать диапазон данных чанка для этого проекта. В пределе, там останется только один идентификатор. Когда большой проект будет удален, то на этот узкий чанк данные больше не попадут.

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


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


Проблемы с масштабированием


Итак, мы, вопреки ожиданиям, набрали достаточно неперемещаемых чанков, чтобы это стало заметно. То есть буквально, случай из практики. Вы заказываете админам новый шард за X$ в месяц. По логам видим равномерное распределение чанков, но занимаемое место на диске не превышает половины. С одной стороны весьма странное расходование средств было бы, а с другой стороны возникает вопрос: мы что же, не можем прогнозировать совершенно рутинную операцию по добавлению шарда? Нам совсем не нужно участие разработчика или DBA в этот момент.


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


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


Надеюсь, тут уже все уже запуганы и потеряли надежду. ;)


Решение


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


1-й воспользоваться командой moveChunk прямое указание балансировщику о перемещении конкретного чанка.


2-й воспользоваться командой addTagRange привязка диапазона значений ключа шардирования к некоторому шарду и их группе.


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


Предварительное прототипирование 1-го варианта выявило дополнительные особенности.


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


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


Массовые сканирования dataSize ухудшают отзывчивость сервера на боевых запросах.


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


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


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


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


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


Группировка данных


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


Итак, коллекция уже шардирована.


  • Читаем все ее чанки из коллекции config.chunks с сортировкой по возрастанию ключа {min: 1}
  • Распределяем чанки по шардам, так чтобы их было примерно одинаковое количество. Но при этом все чанки на одном шарде должны объединяться в один интервал.

Например:


У нас есть три шарда sh0, sh1, sh2 с одноименными тегами.
Мы вычитали поток из 100 чанков по возрастанию в массив


var chunks = db.chunks.find({ ns: "demo.coll"}).sort({ min: 1}).toArray();

Первые 34 чанка будем размещать на sh0
Следующие 33 чанка разместим на sh1
Последние 33 чанка разместим на sh2
У каждого чанка есть поля min и max. По этим полям мы выставим границы.


sh.addTagRange( "demo.coll", {shField: chunks[0].min}, {shField: chunks[33].max}, "sh0");sh.addTagRange( "demo.coll", {shField: chunks[34].min}, {shField: chunks[66].max}, "sh1");sh.addTagRange( "demo.coll", {shField: chunks[67].min}, {shField: chunks[99].max}, "sh2");

Обратите внимание, что поле max совпадает с полем min следующего чанка. А граничные значения, т.е. chunks[0].min и chunks[99].max, всегда будут равны MinKey и MaxKey соответственно.
Т.е. мы покрываем этими зонами все значения ключа шардирования.


Балансировщик начнёт перемещать чанки в указанные диапазоны.
А мы просто ждем окончания работы балансировщика. Т.е. когда все чанки займут свое место назначения. Ну за исключением jumbo-чанков конечно.


Коррекция размера


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


sh.addTagRange( "demo.coll", {shField: MinKey}, {shField: 1025}, "sh0");sh.addTagRange( "demo.coll", {shField: 1025}, {shField: 5089}, "sh1");sh.addTagRange( "demo.coll", {shField: 5089}, {shField: MaxKey}, "sh2");

Вот так будет выглядеть размещение чанков:


Командой db.demo.coll.stats() можно получить объем данных, которые хранятся на каждом шарде. По всем шардам можно вычислить среднее значение, к которому мы хотели бы привести каждый шард.


Если шард надо увеличить, то его границы надо перемещать наружу, если уменьшить, то внутрь.
Так как уже явно заданы диапазоны ключей, то балансировщик не будет их перемещать с шарда на шард. Следовательно, мы можем пользоваться командой dataSize, мы точно знаем данные какого шарда мы сканируем.
Например, нам надо увеличить sh0 за счет h1. Границу с ключем будем двигать в бОльшую сторону. Сколько именно данных мы сместим перемещением конкретного 34-го чанка, мы можем узнать командой dataSize с границами этого чанка.


db.runCommand({ dataSize: "demo.coll", keyPattern: { shField: 1 }, min: { shField: 1025 }, max: { shField: 1508 } })

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


Вот так будет выглядеть смещение границы на один чанк



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


sh.removeTagRange( "demo.coll", {shField: MinKey}, {shField: 1025}, "sh0");sh.removeTagRange( "demo.coll", {shField: 1025}, {shField: 5089}, "sh1");sh.addTagRange( "demo.coll", {shField: MinKey}, {shField: 1508}, "sh0");sh.addTagRange( "demo.coll", {shField: 1508}, {shField: 5089}, "sh1");

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


Выгодные особенности этого подхода:


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

Дополнительные возможности


Вообще, на практике требуется выравнивание используемого объема диска на шардах, а не только части шардированных коллекции. Частенько, нет времени или возможности проектировать шардирование вообще всех БД и коллекций. Эти данных лежат на своих primary-shard. Если их объем мал, то его легко учесть при коррекции размера и просто часть данных оттащить на другие шарды.


С течением времени, особенно после частых удалений, будут образовываться пустые чанки. Не то чтобы они теперь сильно мешают, но их может быть сильно больше чанков с данными. Чтобы они не заставляли страдать роутер, было бы хорошо их прибрать. Раньше (до дефрагментации) их надо было найти, переместить к другому чанку-соседу на один шард, потом запустить команду mergeChunks. Перемещение нужно, т.к. команда объединяет только соседние чанки на одном шарде.
Есть даже скрипты для автоматизации, но они долго работают, и есть один тикет с печально низким приоритетом.


Теперь все значительно проще. Соседние чанки и так на одном шарде. Можно объединять хоть весь интервал целиком. Если он конечно без jumbo-чанков, их надо исключить. Есть на интервале пустые чанки или нет, это уже не важно. Балансировщик заново сделает разбиение по мере добавления или изменения данных.


Почти итог


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


Наши плюсы:


  • точная балансировка занимаемого размера
  • лучшее распределение update-трафика
  • минимальное сканирование при коррекциях
  • бесплатная уборка пустых чанков
  • минимальные допуски на запасное/неиспользуемое дисковое пространство на каждом шарде
  • мы всегда можем удалить привязки диапазонов ключей и вернуться к исходному состоянию

Минусы:


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

Но это было бы слишком скучно Время победить слонов и не вернуться!


Победа над слонами!


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


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


Вспомним пример модели:


{    _id: ObjectId("507c7f79bcf86cd7994f6c0e"),    projectId: UUID("3b241101-e2bb-4255-8caf-4136c566a962"),    name: "job name 1",    creation: ISODate("2016-05-18T16:00:00Z"),    payload: "any additional info"}

Ранее мы выбрали ключ шардирования {projectId: 1}
Но теперь при проектировании можно выбрать любые уточняющие поля для ключа шардирования:


  • {projectId: 1, name: 1}
  • {projectId: 1, creation: 1}
  • {projectId: 1, _id: 1}

А если вы пользуетесь MongoDB версии 4.4 и выше, то там есть удобная функция уточнение ключа шардирования на существующих данных.


Данные дефрагментированы, а это дает нам гарантии того, что основной объем документов работ с одинаковым projectId будет находиться на одном шарде. Как это выглядит на практике?


Вот иллюстрация примеров размещения работ по чанкам. В случае, если мы выбрали ключ шардирования {projectId: 1, _id: 1}



Здесь, для упрощения примера, идентификаторы представлены целыми числами.
А термином "проект" я буду называть группу работ с одинаковым projectId.


Некоторые проекты будут полностью умещаться в один чанк. Например, проекты 1 и 2 размещены в 1м чанке, а 7-й проект во 2-м.
Некоторые проекты будут размещены в нескольких чанках, но это будут чанки с соседними границами. Например, проект 10 размещен в 3, 4 и 5 чанках, а проект 18 в 6 и 7 чанках.
Если мы будем искать работу по ее полю projectId, но без _id, то как будет выглядеть роутинг запросов?


Планировщик запросов MongoDB отлично справляется с исключением из плана запроса тех шардов, на которых точно нет нужных данных.
Например, поиск по условию {projectId: 10, name: "job1"} будет только на шарде sh0


А если проект разбит границей шарда? Вот как 18-й проект например. Его 6-й чанк находится на шарде sh0, а 7-й чанк находится на шарде sh1.
В этом случае поиск по условию {projectId: 18, name: "job1"} будет только на 2х шардах sh0 и sh1. Если известно, что размер проектов у нас меньше размера шарда, то поиск будет ограничен только этими 2-мя шардами.


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


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


  • группа располагается на одном, максимум двух шардах.
  • число групп которые имели несчастье разместиться на 2х шардах ограничено числом границ. Для N шардов будет N-1 граница.

Больше одно шардовых запросов, меньше лишней нагрузки на чтение, меньше дополнительных реплик!
Мало нам было бы радости, но с таких сильно селективным ключом мы и от jumbo-чанков избавляемся.


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


Точно итог


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


Теперь уже можно оценить достижения и потери.
Достижения:


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

Потери:


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

Осталось спроектировать весь процесс дефрагментации, расчета поправок и коррекции границ Ждите!

Подробнее..

MongoDB базовые возможности

13.10.2020 00:06:12 | Автор: admin
Цель:
освоить базовые возможности mongodb

Необходимо:
  • установить MongoDB одним из способов: ВМ, докер;
  • заполнить данными;
  • написать несколько запросов на выборку и обновление данных
  • создать индексы и сравнить производительность.


Решение

Установка хранилища

MongoDB было развернуто на машине в локальной сети с использованием Docker-контейнеризации и оболочки www.portainer.io.

Заполнение хранилища данными

Я в поисках данных, достаточных для изучения базовых возможностей MongoDB, остановился на наборе данных NASA Earth Meteorite Landings (1000 строк со сведениями об упавших на Землю метеоритах, из репозитория https://github.com/jdorfman/awesome-json-datasets.

Замечание: нашел более полные (45.7K) сведения https://data.nasa.gov/Space-Science/Meteorite-Landings/gh4g-9sfh, но экспорт в JSON через их API дает только 1000 записей (непонятно), надо перелопатить полные данные экспортируемого CSV файла https://data.nasa.gov/api/views/gh4g-9sfh/rows.csv?accessType=DOWNLOAD?

Замечание: упс, можно получить полные данные в JSON, но это хак. Искренне надеюсь, что в этом нет SQL-инъекции

https://data.nasa.gov/api/id/gh4g-9sfh.json?$select=`name`,`id`,`nametype`,`recclass`,`mass`,`fall`,`year`,`reclat`,`reclong`,`geolocation`&$order=`:id`+ASC&$limit=46000&$offset=0

wc ./gh4g-9sfh.json     45716   128491 10441343 ./gh4g-9sfh.json


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

wget -qO - https://www.mongodb.org/static/pgp/server-4.4.asc | sudo apt-key add -echo "deb http://repo.mongodb.org/apt/debian buster/mongodb-org/4.4 main" | sudo tee /etc/apt/sources.list.d/mongodb-org-4.4.listsudo apt-get updatesudo apt-get install -y mongodb-org-shellsudo apt-get install -y mongodb-org-tools


Проверка соединения:

mongo nosql-2020.otus --port 32789    MongoDB shell version v4.4.1    connecting to: mongodb://nosql-2020.otus:32789/test?compressors=disabled&gssapiServiceName=mongodb    Implicit session: session { "id" : UUID("5ff24788-0710-4a1a-821f-7acb2eddfb4f") }    MongoDB server version: 4.4.1    Welcome to the MongoDB shell.    For interactive help, type "help".    For more comprehensive documentation, see            https://docs.mongodb.com/    Questions? Try the MongoDB Developer Community Forums            https://community.mongodb.com


Импорт данных с локальной машины на удаленную:

mongoimport --host nosql-2020.otus  --db "otus_003" --port 32789 --collection eml45k --jsonArray --file ./003_MONGODB.files/gh4g-9sfh.json        2020-10-12T01:01:13.826+0100    connected to: mongodb://nosql-2020.otus:32789/    2020-10-12T01:01:16.827+0100    [#######.................] otus_003.eml45k      2.99MB/9.96MB (30.0%)    2020-10-12T01:01:19.827+0100    [###############.........] otus_003.eml45k      6.44MB/9.96MB (64.6%)    2020-10-12T01:01:22.827+0100    [#######################.] otus_003.eml45k      9.81MB/9.96MB (98.5%)    2020-10-12T01:01:23.035+0100    [########################] otus_003.eml45k      9.96MB/9.96MB (100.0%)    2020-10-12T01:01:23.035+0100    45716 document(s) imported successfully. 0 document(s) failed to import.


Замечание: 10 секунд на все про все, забавненько

Выборка данных

> show databasesadmin     0.000GBconfig    0.000GBlocal     0.000GBotus_003  0.000GBtest      0.000GB> use otus_003switched to db otus_003> show collectionseml


Ищем метеорит по заведомо известному имени:

> db.eml45k.find({name:"Bjelaja Zerkov"})    { "_id" : ObjectId("5f8380a91c0ab84b54bfe394"), "name" : "Bjelaja Zerkov", "id" : "5063", "nametype" : "Valid", "recclass" : "H6", "mass" : "1850", "fall" : "Fell", "year" : "1796-01-01T00:00:00.000", "reclat" : "49.783330", "reclong" : "30.166670", "geolocation" : { "latitude" : "49.78333", "longitude" : "30.16667" } }


Ищем метеорит по заведомо известным координатам:

> db.eml45k.find({ "geolocation" : { "latitude" : "44.83333" , "longitude" : "95.16667" } }){ "_id" : ObjectId("5f8380a91c0ab84b54bfe322"), "name" : "Adzhi-Bogdo (stone)", "id" : "390", "nametype" : "Valid", "recclass" : "LL3-6", "mass" : "910", "fall" : "Fell", "year" : "1949-01-01T00:00:00.000", "reclat" : "44.833330", "reclong" : "95.166670", "geolocation" : { "latitude" : "44.83333", "longitude" : "95.16667" } }


Выборка списка упавших метеоритов с сортировкой по году падения (интересно, почему у NASA нет конкретного времени падения по Гринвичу) и c ограничением списка выбираемых полей:
> db.eml45k.find( { }, {year: 1, id: 1, name: 1, _id: 0 }).sort( { year: -1 } ){ "name" : "Northwest Africa 7701", "id" : "57150", "year" : "2101-01-01T00:00:00.000" }{ "name" : "Chelyabinsk", "id" : "57165", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7755", "id" : "57166", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7812", "id" : "57258", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7822", "id" : "57268", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7856", "id" : "57421", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7855", "id" : "57420", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7857", "id" : "57422", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7858", "id" : "57423", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7861", "id" : "57425", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7862", "id" : "57426", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Northwest Africa 7863", "id" : "57427", "year" : "2013-01-01T00:00:00.000" }{ "name" : "Battle Mountain", "id" : "56133", "year" : "2012-01-01T00:00:00.000" }{ "name" : "Sutter's Mill", "id" : "55529", "year" : "2012-01-01T00:00:00.000" }{ "name" : "Antelope", "id" : "57455", "year" : "2012-01-01T00:00:00.000" }{ "name" : "Catalina 009", "id" : "57173", "year" : "2012-01-01T00:00:00.000" }{ "name" : "Jiddat al Harasis 799", "id" : "57428", "year" : "2012-01-01T00:00:00.000" }{ "name" : "Johannesburg", "id" : "55765", "year" : "2012-01-01T00:00:00.000" }{ "name" : "Ksar Ghilane 011", "id" : "55606", "year" : "2012-01-01T00:00:00.000" }{ "name" : "Ksar Ghilane 010", "id" : "55605", "year" : "2012-01-01T00:00:00.000" }Type "it" for more>


Что-то я не нашел? как объединить два поля непосредственно при выборке:

"geolocation" : { "latitude" : "49.78333", "longitude" : "30.16667" } 


в точку (необходимо заметить изменение порядка следования произведено в соответствии с документацией (https://docs.mongodb.com/manual/geospatial-queries/#geospatial-legacy, то есть

< field >: [< longitude >, < latitude >]):


"geolocation" : { "type" : "Point", "coordinates" : [  30.16667 , 49.78333 ] } }


непосредственно при запросе, чтобы сделать (кто знает?) что-то по типу этого:

> db.eml45k.find({     [         {$toDouble: "$geolocation.longitude"} ,        {$toDouble: "$geolocation.latitude"}     ] : {        $geoWithin: {            $geometry: {                type : "Polygon" ,                coordinates: [[                    ... ,                ]]            }        }    }}) 


Поэтому создал искусственное поле в коллекции:

db.eml45k.updateMany(     {},    [{        $set: {            "pointed_geolocation.type" : "Point",            "pointed_geolocation.coordinates" : [                 { $toDouble : "$geolocation.longitude" } ,                 { $toDouble: "$geolocation.latitude" }             ]        }    }]);{ "acknowledged" : true, "matchedCount" : 45716, "modifiedCount" : 45716 }


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

> db.eml45k.find({     "pointed_geolocation.coordinates" : {        $geoWithin: {            $geometry: {                type : "Polygon" ,                coordinates: [[                    [ 47.0 , 33.0  ],                     [ 47.0 , 65.0 ],                     [ 169.0 , 65.0 ],                    [ 169.0 ,  33.0 ],                    [ 47.0 , 33.0 ]                ]]            }        }    },    'fall': 'Fell'},{     year: {$year: { "$toDate": "$year"}},     "pointed_geolocation.coordinates": 1,     name: 1,     _id: 0 }).sort( { year: -1 } )


Выборка
{ name: Chelyabinsk, pointed_geolocation: { coordinates: [ 61.11667, 54.81667 ] }, year: 2013 }
{ name: Dashoguz, pointed_geolocation: { coordinates: [ 59.685, 41.98444 ] }, year: 1998 }
{ name: Kunya-Urgench, pointed_geolocation: { coordinates: [ 59.2, 42.25 ] }, year: 1998 }
{ name: Sterlitamak, pointed_geolocation: { coordinates: [ 55.98333, 53.66667 ] }, year: 1990 }
{ name: Undulung, pointed_geolocation: { coordinates: [ 124.76667, 66.13889 ] }, year: 1986 }
{ name: Omolon, pointed_geolocation: { coordinates: [ 161.80833, 64.02 ] }, year: 1981 }
{ name: Yardymly, pointed_geolocation: { coordinates: [ 48.25, 38.93333 ] }, year: 1959 }
{ name: Vengerovo, pointed_geolocation: { coordinates: [ 77.26667, 56.13333 ] }, year: 1950 }
{ name: Kunashak, pointed_geolocation: { coordinates: [ 61.36667, 55.78333 ] }, year: 1949 }
{ name: Krasnyi Klyuch, pointed_geolocation: { coordinates: [ 56.08333, 54.33333 ] }, year: 1946 }
{ name: Lavrentievka, pointed_geolocation: { coordinates: [ 51.56667, 52.45 ] }, year: 1938 }
{ name: Pavlodar (stone), pointed_geolocation: { coordinates: [ 77.03333, 52.3 ] }, year: 1938 }
{ name: Kainsaz, pointed_geolocation: { coordinates: [ 53.25, 55.43333 ] }, year: 1937 }
{ name: Ichkala, pointed_geolocation: { coordinates: [ 82.93333, 58.2 ] }, year: 1936 }
{ name: Nikolaevka, pointed_geolocation: { coordinates: [ 78.63333, 52.45 ] }, year: 1935 }
{ name: Brient, pointed_geolocation: { coordinates: [ 59.31667, 52.13333 ] }, year: 1933 }
{ name: Pesyanoe, pointed_geolocation: { coordinates: [ 66.08333, 55.5 ] }, year: 1933 }
{ name: Kuznetzovo, pointed_geolocation: { coordinates: [ 75.33333, 55.2 ] }, year: 1932 }
{ name: Boriskino, pointed_geolocation: { coordinates: [ 52.48333, 54.23333 ] }, year: 1930 }
{ name: Khmelevka, pointed_geolocation: { coordinates: [ 75.33333, 56.75 ] }, year: 1929 }
Type it for more
> it
{ name: Mamra Springs, pointed_geolocation: { coordinates: [ 62.08333, 45.21667 ] }, year: 1927 }
{ name: Demina, pointed_geolocation: { coordinates: [ 84.76667, 51.46667 ] }, year: 1911 }
{ name: Krutikha, pointed_geolocation: { coordinates: [ 77, 56.8 ] }, year: 1906 }
{ name: Barnaul, pointed_geolocation: { coordinates: [ 84.08333, 52.73333 ] }, year: 1904 }
{ name: Tyumen, pointed_geolocation: { coordinates: [ 65.53333, 57.16667 ] }, year: 1903 }
{ name: Ochansk, pointed_geolocation: { coordinates: [ 55.26667, 57.78333 ] }, year: 1887 }



Странно, я не знал, что Челябинский не значится в категории найден.

Агрегируем и найдем сколько найдено и сколько нет:

db.eml45k.aggregate([{ $match: {     "pointed_geolocation.coordinates" : {        $geoWithin: {            $geometry: {                type : "Polygon" ,                coordinates: [[                    [ 47.0 , 33.0  ],                     [ 47.0 , 65.0 ],                     [ 169.0 , 65.0 ],                    [ 169.0 ,  33.0 ],                    [ 47.0 , 33.0 ]                ]]            }        }    }} },    {"$group" : {_id: "$fall", count: { $sum: 1 }}}]){ "_id" : "Fell", "count" : 26 }{ "_id" : "Found", "count" : 63 }


Итого найдено 63 из 89, а __26__ __не__ нашли, так что есть шанс :)

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

Удалим все индексы в коллекции от прошлых экспериментов:

db.eml45k.dropIndexes(){        "nIndexesWas" : 1,        "msg" : "non-_id indexes dropped for collection",        "ok" : 1}


Попробуем посмотреть оценочное время исполнения запроса:

db.eml45k.find({     "pointed_geolocation.coordinates" : {        $geoWithin: {            $geometry: {                type : "Polygon" ,                coordinates: [[                    [ 47.0 , 33.0  ],                     [ 47.0 , 65.0 ],                     [ 169.0 , 65.0 ],                    [ 169.0 ,  33.0 ],                    [ 47.0 , 33.0 ]                ]]            }        }    }}).explain("executionStats").executionStats.executionTimeMillis...110...110...109


Итог примерно в среднем 110 секунд.

Проиндексируем:
db.eml45k.createIndex( { "pointed_geolocation" : "2dsphere" } )    {        "ok" : 0,        "errmsg" : "Index build failed: 98b9ead2-c156-4312-81af-1adf5896e3c9: Collection otus_003.eml45k ( 6db2d178-61b5-4627-8512-fcf919fe596f ) :: caused by :: Can't extract geo keys: { _id: ObjectId('5f838e30fb89bd9d553ae27f'), name: \"Bulls Run\", id: \"5163\", nametype: \"Valid\", recclass: \"Iron?\", mass: \"2250\", fall: \"Fell\", year: \"1964-01-01T00:00:00.000\", pointed_geolocation: { type: \"Point\", coordinates: [ null, null ] } }  Point must only contain numeric elements",        "code" : 16755,        "codeName" : "Location16755"    }


Ошибка из-за NULL-значений, я что-то не нашел сходу как (кто знает?) ее при индексировании исключить из индекса, поэтому удалю ключи эти:

db.eml45k.updateMany(    { "pointed_geolocation.coordinates" : [ null , null ] },         [{                 $set: { "pointed_geolocation": null }    }] );


Пробуем опять индекс

db.eml45k.createIndex( { "pointed_geolocation" : "2dsphere" } )        {        "ok" : 0,        "errmsg" : "Index build failed: d33b31d4-4778-4537-a087-58b7bd1968f3: Collection otus_003.eml45k ( 6db2d178-61b5-4627-8512-fcf919fe596f ) :: caused by :: Can't extract geo keys: { _id: ObjectId('5f838e35fb89bd9d553b3b8f'), name: \"Meridiani Planum\", id: \"32789\", nametype: \"Valid\", recclass: \"Iron, IAB complex\", fall: \"Found\", year: \"2005-01-01T00:00:00.000\", reclat: \"-1.946170\", reclong: \"354.473330\", geolocation: { latitude: \"-1.94617\", longitude: \"354.47333\" }, pointed_geolocation: { type: \"Point\", coordinates: [ 354.47333, -1.94617 ] } }  longitude/latitude is out of bounds, lng: 354.473 lat: -1.94617",        "code" : 16755,        "codeName" : "Location16755"    }


Ошибка __longitude/latitude is out of bounds, lng: 354.473 lat: -1.94617__ и в документации https://docs.mongodb.com/manual/geospatial-queries/#geospatial-legacy

    Valid longitude values are between -180 and 180, both inclusive.    Valid latitude values are between -90 and 90, both inclusive.


и 354.47333 не входит в диапазон от -180 до 180.

Очень странно, я сначала думал там нужно поправку везде на минус 180 сделать

(`$subtract: [{ $toDouble : "$geolocation.longitude" }, 180.0]`)


, но в итоге не все так просто.

Какие долготы не в диапазоне:

db.eml45k.find({"pointed_geolocation.coordinates.0": {$lt: -180}} ) # нет таких, так и должно бытьdb.eml45k.find({"pointed_geolocation.coordinates.0": {$lt: 0}} ) #  такие есть, так и должно бытьdb.eml45k.find({"pointed_geolocation.coordinates.0": {$gt: 180}} ) # всего один, и так не должно быть    { "_id" : ObjectId("5f838e35fb89bd9d553b3b8f"), "name" : "Meridiani Planum", "id" : "32789", "nametype" : "Valid", "recclass" : "Iron, IAB complex", "fall" : "Found", "year" : "2005-01-01T00:00:00.000", "reclat" : "-1.946170", "reclong" : "354.473330", "geolocation" : { "latitude" : "-1.94617", "longitude" : "354.47333" }, "pointed_geolocation" : { "type" : "Point", "coordinates" : [ 354.47333, -1.94617 ] } }


В итоге только один метеорит имеет странные координаты. Поискав, выяснил, что этот метеорит Meridiani Planum был случайно найден марсоходом Opportunity в 2005 году
(http://old.mirf.ru/Articles/art2427_2.htm). Это (ВНИМАНИЕ) марсианский метеорит, найден (ВНИМАНИЕ) на Марсе. Вот NASA шутники.

Удалим его из коллекции.

db.eml45k.remove({"id" : "32789"})WriteResult({ "nRemoved" : 1 })


Индексируем
> db.eml45k.createIndex( { "pointed_geolocation" : "2dsphere" } ){        "createdCollectionAutomatically" : false,        "numIndexesBefore" : 1,        "numIndexesAfter" : 2,        "ok" : 1}


Замеряем

db.eml45k.find({     "pointed_geolocation.coordinates" : {        $geoWithin: {            $geometry: {                type : "Polygon" ,                coordinates: [[                    [ 47.0 , 33.0  ],                     [ 47.0 , 65.0 ],                     [ 169.0 , 65.0 ],                    [ 169.0 ,  33.0 ],                    [ 47.0 , 33.0 ]                ]]            }        }    }}).explain("executionStats").executionStats.executionTimeMillis


В итоге при перепроверках 104 107 106

Как-то странно, не очень-то и шустрее.

Удалил индекс, проверил.

Без индекса и с индексом одинаково.

Пробую отдельно для Челябинского:

db.eml45k.find(    {"pointed_geolocation.coordinates" : [ 61.11667, 54.81667 ]}).explain("executionStats").executionStats.executionTimeMillis


без индекса и с индексом одинаково.

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

В итоге, запрос

db.eml45k.find({     "pointed_geolocation" : {        $geoWithin: {            $geometry: {                type : "Polygon" ,                coordinates: [[                    [ 47.0 , 33.0  ],                     [ 47.0 , 65.0 ],                     [ 169.0 , 65.0 ],                    [ 169.0 ,  33.0 ],                    [ 47.0 , 33.0 ]                ]]            }        }    }}).explain("executionStats").executionStats.executionTimeMillis


без индекса 125, 123, 119, 123 миллисекунды, а с индексом 7, 4, 4, 5.

Всё получилось.
Подробнее..
Категории: Nosql , Otus , Mongodb

Перевод Atlas как сервис

12.05.2021 20:07:31 | Автор: admin

Перевод материала подготовлен в рамках курса "NoSQL".

Приглашаем также всех желающих на двухдневный интенсив MongoDB Map-Reduce Framework.
Темы 1 дня: CRUD-операции; фильтрация по полям; sort, skip, limit; запросы по поддокументам.
Темы 2 дня: концепция map-reduce; концепция pipeline; структура и синтаксис агрегации; стадия $match; стадия $group; стадия $lookup.


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

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

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

Архитектура

Хотя API-интерфейсы Atlas можно вызывать непосредственно из клиентского интерфейса, мы решили использовать трехуровневую архитектуру. Ее преимущества заключаются в следующем:

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

  • возможность упростить API-интерфейсы, доступные разработчикам клиентских частей приложений;

  • возможность тонкой настройки защиты конечных точек API.

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

Конечно же, для размещения среднего уровня мы выбрали Realm.

Реализация

Серверная часть

API Atlas

API-интерфейсы Atlas обернуты в набор функций Realm.

По большей части все они вызывают API Atlas следующим образом (здесь мы взяли для примера getOneCluster):

/** Gets information about the requested cluster. If clusterName is empty, all clusters will be fetched.* See https://docs.atlas.mongodb.com/reference/api/clusters-get-one**/exports = async function(username, password, projectID, clusterName) {const arg = {scheme: 'https',host: 'cloud.mongodb.com',path: 'api/atlas/v1.0/groups/' + projectID +'/clusters/' + clusterName,username: username,password: password,headers: {'Content-Type': ['application/json'], 'Accept-Encoding': ['bzip, deflate']},digestAuth:true};// The response body is a BSON.Binary object. Parse it and return.response = await context.http.get(arg);return EJSON.parse(response.body.text());};

Исходный код каждой функции размещен на GitHub.

API MiniAtlas

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

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

API

Тип метода

Конечная точка

Получение списка кластеров

GET

/getClusters

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

POST

/getClusters

Вывод состояния кластера

GET

/getClusterState?clusterName:cn

Изменение кластера

PATCH

/modifyCluster

Приостановка или возобновление работы кластера

POST

/pauseCluster

Удаление кластера

DELETE

/deleteCluster?clusterName:cn

Далее представлен исходный код конечной точки getClusters (примечание: имя пользователя и пароль извлекаются из констант Value и Secret):

/** GET getClusters** Query Parameters** None** Response - Currently all values documented at https://docs.atlas.mongodb.com/reference/api/clusters-get-all/**/exports = async function(payload, response) {var results = [];const username = context.values.get("username");const password = context.values.get("apiKey");projectID = context.values.get("projectID");// Sending an empty clusterName will return all clusters.var clusterName = '';response = await context.functions.execute("getOneCluster", username, password, projectID, clusterName);results = response.results;return results;};

Исходный код каждого веб-хука размещен на GitHub.

При сохранении веб-хука генерируется URL-адрес, который служит конечной точкой для API:

Защита конечных точек API

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

exports = function(payload) {const headers = context.request.requestHeadersconst { Authorization } = headersconst user_id = Authorization.toString().replace(/^Bearer/, '')return user_id};

MongoDB Realm имеет несколько встроенных провайдеров аутентификации, включая доступ для анонимных пользователей, доступ по комбинации электронная почта/ пароль, доступ по ключам API, а также аутентификацию OAuth2.0 через Facebook, Google и AppleID.

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

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

Клиентская часть

Клиентская часть реализована на JQuery и размещена в Realm.

Аутентификация

С помощью MongoDB Stitch Browser SDK клиент предлагает пользователю войти в учетную запись Google (если вход еще не выполнен) и передает учетные данные пользователя Google в StitchAppClient.

let credential = new stitch.GoogleRedirectCredential();client.auth.loginWithRedirect(credential);

Идентификатор пользователя, который необходимо использовать при отправке запроса API в серверную часть, можно получить из StitchAppClient следующим образом:

let userId = client.auth.authInfo.userId;

Затем его можно включить в заголовок при вызове API. Вот пример вызова API createCluster:

export const createCluster = (uid, data) => {let url = `${baseURL}/createCluster`const params = {method: "post",headers: {"Content-Type": "application/json;charset=utf-8",...(uid && { Authorization: uid })},...(data && { body: JSON.stringify(data) })}return fetch(url, params).then(handleErrors).then(response => response.json()).catch(error => console.log(error) );};

Все вызовы API можно посмотреть в файле webhooks.js.

Полезный совет

Нам принесли большую пользу командные рабочие пространства в Postman. Этот инструмент позволяет совместно разрабатывать серверные API и проверять их работоспособность.

Заключение

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


Узнать подробнее о курсе "NoSQL"

Участвовать в двухдневном интенсиве MongoDB Map-Reduce Framework

Подробнее..

Перевод Реализация мультиарендности с использованием Spring Boot, MongoDB и Redis

28.02.2021 18:05:07 | Автор: admin

В этом руководстве мы рассмотрим, как реализовать мультиарендность в Spring Boot приложении с использованием MongoDB и Redis.

Используются:

  • Spring Boot 2.4

  • Maven 3.6. +

  • JAVA 8+

  • Монго 4.4

  • Redis 5

Что такое мультиарендность?

Мультиарендность (англ. multitenancy множественная аренда) это программная архитектура, в которой один экземпляр программного приложения обслуживает нескольких клиентов.Все должно быть общим, за исключением данных разных клиентов, которые должны быть должным образом разделены.Несмотря на то, что они совместно используют ресурсы, арендаторы не знают друг друга, и их данные хранятся совершенно отдельно.Каждый покупатель называется арендатором.

Предложение программное обеспечение как услуга (SaaS) является примером мультиарендной архитектуры.Более подробно.

Модели с несколькими арендаторами

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

  1. База данных для каждого арендатора: каждый арендатор имеет свою собственную базу данных и изолирован от других арендаторов.

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

  3. Общая база данных, отдельная схема: все арендаторы совместно используют базу данных, но имеют свои собственные схемы и таблицы базы данных.

Начнем

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

Мы начнем с создания простого проекта Spring Boot наstart.spring.ioсо следующими зависимостями:

<dependencies>    <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-data-mongodb</artifactId>    </dependency>    <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-web</artifactId>    </dependency>    <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-data-redis</artifactId>    </dependency>    <dependency>        <groupId>redis.clients</groupId>        <artifactId>jedis</artifactId>    </dependency>    <dependency>        <groupId>org.projectlombok</groupId>        <artifactId>lombok</artifactId>        <optional>true</optional>    </dependency></dependencies>

Определение текущего идентификатора клиента

Идентификатор клиента необходимо определить для каждого клиентского запроса.Для этого мы включим поле идентификатора клиента в заголовок HTTP-запроса.

Давайте добавим перехватчик, который получает идентификатор клиента из http заголовкаX-Tenant.

@Slf4j@Componentpublic class TenantInterceptor implements WebRequestInterceptor {    private static final String TENANT_HEADER = "X-Tenant";    @Override    public void preHandle(WebRequest request) {        String tenantId = request.getHeader(TENANT_HEADER);        if (tenantId != null && !tenantId.isEmpty()) {            TenantContext.setTenantId(tenantId);            log.info("Tenant header get: {}", tenantId);        } else {            log.error("Tenant header not found.");            throw new TenantAliasNotFoundException("Tenant header not found.");        }    }    @Override    public void postHandle(WebRequest webRequest, ModelMap modelMap) {        TenantContext.clear();    }    @Override    public void afterCompletion(WebRequest webRequest, Exception e) {    }}

TenantContextэто хранилище, содержащее переменную ThreadLocal.ThreadLocal можно рассматривать как область доступа (scope of access), такую как область запроса (request scope) или область сеанса (session scope).

Сохраняя tenantId в ThreadLocal, мы можем быть уверены, что каждый поток имеет свою собственную копию этой переменной и что текущий поток не имеет доступа к другому tenantId:

@Slf4jpublic class TenantContext {    private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();    public static void setTenantId(String tenantId) {        log.debug("Setting tenantId to " + tenantId);        CONTEXT.set(tenantId);    }    public static String getTenantId() {        return CONTEXT.get();    }    public static void clear() {        CONTEXT.remove();    }}

Настройка источников данных клиента (Tenant Datasources)

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

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

@Servicepublic class RedisDatasourceService {    private final RedisTemplate redisTemplate;    private final ApplicationProperties applicationProperties;    private final DataSourceProperties dataSourceProperties;public RedisDatasourceService(RedisTemplate redisTemplate, ApplicationProperties applicationProperties, DataSourceProperties dataSourceProperties) {        this.redisTemplate = redisTemplate;        this.applicationProperties = applicationProperties;        this.dataSourceProperties = dataSourceProperties;    }        /**     * Save tenant datasource infos     *     * @param tenantDatasource data of datasource     * @return status if true save successfully , false error     */        public boolean save(TenantDatasource tenantDatasource) {        try {            Map ruleHash = new ObjectMapper().convertValue(tenantDatasource, Map.class);            redisTemplate.opsForHash().put(applicationProperties.getServiceKey(), String.format("%s_%s", applicationProperties.getTenantKey(), tenantDatasource.getAlias()), ruleHash);            return true;        } catch (Exception e) {            return false;        }    }        /**     * Get all of keys     *     * @return list of datasource     */         public List findAll() {        return redisTemplate.opsForHash().values(applicationProperties.getServiceKey());    }        /**     * Get datasource     *     * @return map key and datasource infos     */         public Map<String, TenantDatasource> loadServiceDatasources() {        List<Map<String, Object>> datasourceConfigList = findAll();        // Save datasource credentials first time        // In production mode, this part can be skip        if (datasourceConfigList.isEmpty()) {            List<DataSourceProperties.Tenant> tenants = dataSourceProperties.getDatasources();            tenants.forEach(d -> {                TenantDatasource tenant = TenantDatasource.builder()                        .alias(d.getAlias())                        .database(d.getDatabase())                        .host(d.getHost())                        .port(d.getPort())                        .username(d.getUsername())                        .password(d.getPassword())                        .build();                save(tenant);            });        }        return getDataSourceHashMap();    }        /**     * Get all tenant alias     *     * @return list of alias     */         public List<String> getTenantsAlias() {        // get list all datasource for this microservice        List<Map<String, Object>> datasourceConfigList = findAll();        return datasourceConfigList.stream().map(data -> (String) data.get("alias")).collect(Collectors.toList());    }        /**     * Fill the data sources list.     *     * @return Map<String, TenantDatasource>     */         private Map<String, TenantDatasource> getDataSourceHashMap() {        Map<String, TenantDatasource> datasourceMap = new HashMap<>();        // get list all datasource for this microservice        List<Map<String, Object>> datasourceConfigList = findAll();        datasourceConfigList.forEach(data -> datasourceMap.put(String.format("%s_%s", applicationProperties.getTenantKey(), (String) data.get("alias")), new TenantDatasource((String) data.get("alias"), (String) data.get("host"), (int) data.get("port"), (String) data.get("database"), (String) data.get("username"), (String) data.get("password"))));        return datasourceMap;    }}

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

Чтобы иметь возможность динамически переключаться на подключение к базе данных mongo, мы создаемкласс MultiTenantMongoDBFactory, расширяющий класс SimpleMongoClientDatabaseFactory из org.springframework.data.mongodb.core.Он вернетэкземплярMongoDatabase, связанный с текущим арендатором.

@Configurationpublic class MultiTenantMongoDBFactory extends SimpleMongoClientDatabaseFactory {@Autowired    MongoDataSources mongoDataSources;public MultiTenantMongoDBFactory(@Qualifier("getMongoClient") MongoClient mongoClient, String databaseName) {        super(mongoClient, databaseName);    }    @Override    protected MongoDatabase doGetMongoDatabase(String dbName) {        return mongoDataSources.mongoDatabaseCurrentTenantResolver();    }}

Нам нужно инициализироватьконструктор MongoDBFactoryMultiTenantс параметрами по умолчанию (MongoClientиdatabaseName).

Это реализует прозрачный механизм для получения текущего клиента.

@Component@Slf4jpublic class MongoDataSources {    /**     * Key: String tenant alias     * Value: TenantDatasource     */    private Map<String, TenantDatasource> tenantClients;    private final ApplicationProperties applicationProperties;    private final RedisDatasourceService redisDatasourceService;    public MongoDataSources(ApplicationProperties applicationProperties, RedisDatasourceService redisDatasourceService) {        this.applicationProperties = applicationProperties;        this.redisDatasourceService = redisDatasourceService;    }    /**     * Initialize all mongo datasource     */    @PostConstruct    @Lazy    public void initTenant() {        tenantClients = new HashMap<>();        tenantClients = redisDatasourceService.loadServiceDatasources();    }    /**     * Default Database name for spring initialization. It is used to be injected into the constructor of MultiTenantMongoDBFactory.     *     * @return String of default database.     */    @Bean    public String databaseName() {        return applicationProperties.getDatasourceDefault().getDatabase();    }    /**     * Default Mongo Connection for spring initialization.     * It is used to be injected into the constructor of MultiTenantMongoDBFactory.     */    @Bean    public MongoClient getMongoClient() {        MongoCredential credential = MongoCredential.createCredential(applicationProperties.getDatasourceDefault().getUsername(), applicationProperties.getDatasourceDefault().getDatabase(), applicationProperties.getDatasourceDefault().getPassword().toCharArray());        return MongoClients.create(MongoClientSettings.builder()                .applyToClusterSettings(builder ->                        builder.hosts(Collections.singletonList(new ServerAddress(applicationProperties.getDatasourceDefault().getHost(), Integer.parseInt(applicationProperties.getDatasourceDefault().getPort())))))                .credential(credential)                .build());    }    /**     * This will get called for each DB operations     *     * @return MongoDatabase     */    public MongoDatabase mongoDatabaseCurrentTenantResolver() {        try {            final String tenantId = TenantContext.getTenantId();            // Compose tenant alias. (tenantAlias = key + tenantId)            String tenantAlias = String.format("%s_%s", applicationProperties.getTenantKey(), tenantId);            return tenantClients.get(tenantAlias).getClient().                    getDatabase(tenantClients.get(tenantAlias).getDatabase());        } catch (NullPointerException exception) {            throw new TenantAliasNotFoundException("Tenant Datasource alias not found.");        }    }73}

Тест

Давайте создадим CRUD пример с документом Employee.

@Builder@Data@AllArgsConstructor@NoArgsConstructor@Accessors(chain = true)@Document(collection = "employee")public class Employee  {    @Id    private String id;    private String firstName;    private String lastName;    private String email;}

Также нам нужно создать классы EmployeeRepository, EmployeeServiceиEmployeeController.Для тестирования при запуске приложения мы загружаем фиктивные данные в каждую базу данных клиента.

@Overridepublic void run(String... args) throws Exception {    List<String> aliasList = redisDatasourceService.getTenantsAlias();    if (!aliasList.isEmpty()) {        //perform actions for each tenant        aliasList.forEach(alias -> {            TenantContext.setTenantId(alias);            employeeRepository.deleteAll();            Employee employee = Employee.builder()                    .firstName(alias)                    .lastName(alias)                    .email(String.format("%s%s", alias, "@localhost.com" ))                    .build();            employeeRepository.save(employee);            TenantContext.clear();        });    }}

Теперь мы можем запустить наше приложение и протестировать его.

Итак, мы все сделали. Надеюсь, это руководство поможет вам понять, что такое мультиарендность и как она может быть реализована в Spring Boot проекте с использованием MongoDB и Redis.

Полный исходный код примера можно найти наGitHub.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    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