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

Best practices

Best practices для клиент-серверного проекта PoC

22.12.2020 12:11:10 | Автор: admin
image
Типичный проект PoC (Proof of Concept) состоит из клиента с GUI, сервера c бизнес логикой и API между ними. Также используется база данных, хранящая оперативную информацию и данные пользователей. Во многих случаях необходима связь с внешними системами со своим API.
Когда у меня возникла необходимость в создании проекта PoC, и я начал разбираться в деталях, то оказалось, что порог вхождения в веб-программирование весьма высок. В крупных проектах для каждого компонента есть выделенные специалисты: front-end, back-end разработчики, UX/UI дизайнеры, архитекторы баз данных, специалисты по API и информационной безопасности, системные администраторы. В небольшом PoC надо самому во всем разобраться, выбрать подходящее техническое решение, реализовать и развернуть. Ситуацию ухудшает тот факт, что из обучающих материалов не всегда понятно, почему предлагается сделать именно так, а не иначе, есть ли альтернативы, является ли решение best practice или это частное мнение автора. Поэтому я разработал заготовку под названием Common Test DB, отвечающую лучшим практикам. Ее можно использовать для начала любого проекта, остается только наполнить функциональным смыслом.
В статье я подробно опишу примененные best practices, расскажу про имеющиеся альтернативы и в конце размещу ссылки на исходники и работающий в сети пример.

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


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

Клиент:
  • Должен осуществлять запросы к серверу, используя REST API
  • Регистрировать, авторизовать, аутентифицировать пользователей
  • Принимать, разбирать и визуализировать пришедшие данные

Сервер
  • Должен принимать HTTP/HTTPS запросы от клиента
  • Осуществлять взаимодействие с внешними системами
  • Взаимодействовать с базой данных (реализовать базовую функциональности CRUD)
  • Обеспечивать безопасность данных, вызовов API и конфигурационных параметров
  • Осуществлять логирование и мониторинг работоспособности

База данных
  • Необходимо реализовать структуру DB
  • Наполнить DB начальными значениями

Архитектура
  • Проект PoC должен быть реализован с возможностью расширения функциональности
  • Архитектура должна предусматривать масштабирование решения

Технологический стек


  • В настоящее время для большинства веб проектов используетсяязык JavaScript.Над JavaScript есть надстройкаTypeScript, которая обеспечивает типизацию переменных и реализацию шаблонов.
  • Для полнофункциональных веб проектов есть несколько стандартных стеков технологий. Один из самых популярных: MEVN (MongoDB + Express.js + Vue.js + Node.js), мне больше нравится PEVN (PostgreSQL + Express.js + Vue.js + Node.js), т.к. RDB базы мне технологически ближе, чем NoSQL.
  • Для GUI существует несколькофреймворков (Vue, React, Angular). Тут выбор, скорее, определяется традициями или личными предпочтениями, поэтому сложно говорить, что лучше, а что хуже. Сначала я выбрал Vue.js, да так на нем и остался. Этот фреймворк хорошо развивается, для него есть готовыевизуальныекомпоненты встилеMaterial Design (Vuetify), которые хорошо смотрятся даже при непрофессиональном применении. Считается, что для React порог вхождения выше, чем для Vue. В React сначала надо осознать специфичную объектную модель, которая отличается от классического веб программирования: компоненты как функции, компоненты как классы, цепочки вызовов.
  • Выбор базы данных уже сложнее, чем личные предпочтения. Но для PoC, зачастую, требуется не функциональность или производительность, а простота. Поэтому в примере будем использовать самую простую в мире базу SQLite. В промышленных PoC в качестве SQL базы я использую PostgreSQL, в качестве NoSQL ее же, т.к. запас прочности уPostgreSQL огромен. Прочность, конечно, не бесконечна и когда-нибудь настанет необходимость перехода на специализированную базу, но это отдельная тема для обсуждения.

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

Сервер


HTTP сервер


В качестве HTTP сервера япробовал два варианта:

Express более зрелый, Fastify, говорят, более быстрый. Есть еще варианты серверов, примеры перечислены в статье The Best 10 Node.js Frameworks. Я использовал самый популярный Express.js.

HTTPS запросы


Говоря про HTTP, я всегда подразумеваю HTTPS, т.к. без него крайне сложно построить безопасное взаимодействие между клиентом и сервером. Для поддержки HTTPS надо:
  • Получить SSL (TLS) сертификат
  • На сервере реализовать поддержку HTTPS протокола
  • На клиенте использовать запросы к клиенту с префиксом HTTPS

Получаем сертификат
Для организации работы HTTPS протокола нужен SSL сертификат. Существуют бесплатные сертификаты Lets Encrypt, но солидные сайты получают платные сертификаты от доверительных центров сертификации CA(Certificate Authority). Для наших тестовых целей на локальном хосте достаточно, так называемого, self-signedcertificate. Он генерируется с помощью утилиты openssl:
openssl req -nodes -new -x509 -keyout server.key -out server.crt

Далее отвечаем на несколько вопросов про страну, город, имя, email и т.п. В результате получаемдва файла:
  • server.key ключ
  • server.crt сертификат

Их нужно будет подложить нашему Express серверу.

HTTPS сервер
С Express сервером все достаточно просто, можно взять стандартный пакет HTTPS из Node.js, и использовать официальную инструкцию: How to create an https server?
После реализации HTTPS сервера используем полученные файлы server.key и server.crt и стандартный для HTTPS порт443.

Взаимодействие с внешними системами


Самая известная библиотека для реализации HTTP/HTTPS запросов: Axios. Она используется как в сервере для вызова API внешних систем, так и в клиенте для вызова API сервера. Есть еще варианты библиотек, которые можно использовать дляспецифичных целей: Обзор пяти HTTP-библиотек для веб-разработки.

Взаимодействие с базой данных


Для работы с базой данных я использовал самую популярную библиотеку для Node.js: Sequelize. В ней мне больше всего нравится то, что переключиться между различными типами баз данных можно всего лишь изменив несколько настроечных параметров. При условии, конечно, что в самом коде не используется специфика определенной базы.Например, чтобы переключиться с SQLight на PostgreSQL, надо в конфигурационном файле сервера заменить:
dialect: 'sqlite'

на
dialect: 'postgres'

И при необходимости изменить имя базы, пользователя и хост.
В PoC я использовал базу данныхSQLite, которая не требует установки.Для администрирования баз данных есть хорошо развитые GUI:

Безопасность доступа к данным


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

  • Получить данные могут все зарегистрированные пользователи.
  • Изменить и удалить данные может только владелец этих данных.

Такая модель доступа к данным называетсяDiscretionary Access Control (DAC), по-русски: избирательное управление доступом. В этой модели владелец данных может делать с ними любые CRUD операции, а права остальных пользователей ограниченны.
Существуют несколько моделей контроля доступа к данным. ПомимоDiscretionary Access Control (DAC)OWASP рекомендует (Access Control Cheat Sheet) использовать следующие:
  • Role-Based Access Control (RBAC)
  • Mandatory Access Control (MAC)
  • Permission Based Access Control

Безопасность вызовов API


В моей предыдущей статье я рассматривал все возможные угрозы: Безопасность REST API от А до ПИ.В PoC реализованы следующие механизмы:
  • Cross-Site Request Forgery (CSRF) (Межсайтовая подмена запросов)

От данной угрозы реализован механизм CSRF токенов когда для каждой сессии пользователя генерируется новый токен (он же SessionId) и сервер проверяет его валидность при любых запросах с клиента. Алгоритм генерации и проверки SessionId подробно описан далее в разделе Авторизация, аутентификация пользователей.
  • Cross-origin resource sharing (CORS) (Кросс-доменное использование ресурсов)

Защита реализована с помощью пакета CORS. В config файле сервера указываются адрес (origin), с которого могут поступать API запросы и список методов (methods), которые может использовать клиент. В дальнейшем сервер будет автоматически ограничивать прием запросов в соответствии с этими настройками.
Для защиты от угроз, перечисленных далее, я использовал пакетhelmet, который позволяет задать значения для определенных HTTP заголовков:
  • Cross-site Scripting (XSS) (Межсайтовое выполнение скриптов)

Для защиты выставляется заголовок, ограничивающий выполнение скриптов:
X-XSS-Protection: 1; mode=block

  • Insecure HTTP Headers

Блокируется отправка сервером заголовка, дающего дополнительную информацию злоумышленнику:
X-Powered-By: Express

  • Insecure HTTP Headers: HTTP Strict Transport Security (HSTS)

Используется Strict-Transport-Security заголовок, который запрещает браузеру обращаться к ресурсам по HTTP протоколу, только HTTPS:
Strict-Transport-Security: max-age=15552000; includeSubDomains

  • Insecure HTTP Headers: X-Frame-Options (защита от Clickjacking)

Данный заголовок позволяет защититься от атаки Clickjacking. Он разрешает использовать фреймы только в нашем домене:
X-Frame-Options: SAMEORIGIN

  • Insecure HTTP Headers: X-Content-Type-Options

Установка заголовка X-Content-Type-Options запрещает браузеру самому интерпретировать тип присланных файлов и принуждает использовать только тот, что был прислан в заголовке Content-Type:
X-Content-Type-Options: nosniff

Защита от DoS


Необходимо защитить сервер и от отказа в обслуживании (DoS-атаки). Например, ограничить число запросов от одного пользователя или по одному ресурсу в течении определенного времени.
Для Node.js существуют средства, позволяющие автоматически реализовывать ограничения на число запросови сразу посылать ответ 429 Too Many Requests, не нагружая бизнес логику сервера.
В примере реализовано простейшее ограничение на число запросов от каждого пользователя в течении определенного времени по таблице Data. В промышленных системах надо защищать все запросы, т.к. даже один запрос, оставшийся без проверки может дать возможность провести атаку.

Безопасность конфигурационных параметров


Настройки для клиента и сервера хранятся в файлах configв JSON формате. Чувствительные настройки сервера нельзя хранить вconfig файлах, т.к. они могут стать доступны в системах версионного контроля. Для такой информации как: логины/пароли для базы данных, ключи доступа к API и т.д. надо использовать специализированные механизмы:
  • Задавать эти параметры в .env файле. Файл .env прописан в gitignore и не сохраняется в системах версионного контроля, поэтомупоэтому туда можно безопасно записывать чувствительную информацию.
  • Использовать механизм переменных окружения (environment variables), которые устанавливаются вручную или прописываютсяинсталляторами.

Логирование


Стандартные требования к логированию:
  • Обеспечить разный уровень логирования (Debug; Info; Warning; Error и т.д.)
  • Логировать HTTP/HTTPS запросы
  • Дублировать консольный вывод в файлы на диске
  • Обеспечить самоочистку файлов логирования по времени иобъёму

Варианты пакетов для логирования, которые я пробовал:

В проекте я использовал log4js. Пакет log4jsработает интуитивно понятно и достаточно развит, чтобы реализовать все требования.

Мониторинг


Мы не будем останавливаться на внешних система мониторинга: Обзор систем мониторинга серверов, т.к. они начинают играть важную роль не на этапе PoC, а в промышленной эксплуатации.
Обратимся к внутренним системам, которые позволяют наглядно посмотреть, что сейчас происходит с сервером, например пакетexpress-status-monitor. Если его установить, топо endpoint
/monitor

можно наглядно мониторить простейшие параметры работы сервера, такие как загрузка CPU, потребление памяти, http нагрузку и т.п. Приведу скриншот из нашего примера. На скриншоте горит красная плашка Failed и может показаться, что что-то не так. Но, на самом деле, все в порядке, т.к. вызов API сознательно делается на несуществующий endpoint:
/wrongRoutePath

image

База данных


Структурабазы данных


В нашем примере реализованы две таблицы с говорящими именами:
  • Users
  • Data

Лучшие практики говорят, что все проверки надо помаксимуму отдавать базе данных. Поэтому в схеме базы данных аккуратно определяем все типы данных, добавляем ограничения (constraints) и внешние ключи.Для пакета sequelize данная функциональность подробно описана в стандартной документации.
В нашем пример сделан один внешний ключ UserId в таблице Data для того, чтобы гарантировать, что у каждой записи в таблицеData будет определенный владелец User. Это позволит при изменении или удалении записи в Data реализовать проверку пользователя, т.к. по нашей логике данные может удалять только их владелец.
Еще одна особенность нашей схемы в том, что один столбец таблицы Data задан в формате JSON. Такой формат удобно использовать, если внешняя система возвращает данные в JSON. В этом случае можно просто записать полученные данные, не тратя усилия на парсинг,а потом делать SQL запросы, используя расширенный синтаксис.Описание работы со столбцами JSON можно найти в официальной документации баз данных. В качестве независимого описания мне понравилась статья на Хабре, в которой описаны все варианты запросов: "JSONB запросы в PostgreSQL.

Формальная схема DB
Cхема таблицыUsers:
  • id INTEGERPRIMARY KEY AUTOINCREMENT уникальный ID
  • uuid UUID NOT NULL UNIQUE уникальный UUID, по нему идет связь с данными этого пользователя в таблице Data
  • email VARCHAR(255) NOT NULL UNIQUE
  • password VARCHAR(64)
  • ddosFirstRequest DATETIME
  • ddosLastRequest DATETIME
  • ddosRequestsNumber DECIMAL
  • lastLogin DATETIME
  • loginState VARCHAR(255) NOT NULL
  • sessionId VARCHAR(1024)
  • commonToken VARCHAR(1024)
  • googleToken VARCHAR(1024)
  • googleAccessToken VARCHAR(1024)
  • googleRefreshToken VARCHAR(1024)
  • createdAt DATETIME NOT NULL
  • updatedAt DATETIME NOT NULL

Схема таблицыData:
  • id INTEGERPRIMARY KEY AUTOINCREMENT
  • uuid UUID NOT NULL UNIQUE
  • ownerUuid UUID NOT NULL
  • data JSON NOT NULL
  • UserId INTEGER REFERENCES Users (id) ON DELETE CASCADE ON UPDATE CASCADE,
  • createdAt DATETIME NOT NULL
  • updatedAt DATETIME NOT NULL


Начальные значения


  • В таблице Users задан один пользовательuser@example.comс паролем password.
  • В таблице Data записано несколько предопределенных значений, которые можно получить из GUI.

Далее перейдем к клиенту.

Клиент


HTTPS запросы


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

RESTful API


REST API лучше реализовывать, используя стандартные средства, чтобы было проще тестировать и документировать, например SwaggerHub
Наше REST API сформировано по правилам Open API и использует стандартные HTTP методы для манипуляции объектами из таблиц Users и Data.В схеме нашего API не все сделано по чистым правилам REST, но главное соблюдена общая концепция.
Хорошей практикой являетсяограничение объёма возвращаемых данных. Для этого используются параметры в запросе: фильтр (filter), ограничение (limit) и смещение (offset). В примере этого нет, но в промышленных системах эта функциональность должна быть реализована.Но даже если реализованы ограничивающие механизмы на клиенте, должна осуществляться дополнительная проверка на сервере на максимальные значения. В нашем примере в конфигурации сервера реализован ограничитель на максимальное число возвращаемых строк из базы,который подставляется вSELECT запросы:limit: 1000

YAML файл с Open API описанием
---swagger: "2.0"info:  description: "This is a common-test-db API. You can find out more about common-test-db\    \ at \nhttps://github.com/AlexeySushkov/common-test-db\n"  version: "2.0.0"  title: "common-test-db"  contact:    email: "alexey.p.sushkov@gmail.com"  license:    name: "MIT License"    url: "https://github.com/AlexeySushkov/common-test-db/blob/main/LICENSE"host: "localhost:443"basePath: "/commontest/v1"tags:- name: "data"  description: "Everything about Data"  externalDocs:    description: "Find out more"    url: "https://github.com/AlexeySushkov/common-test-db/"- name: "users"  description: "Everything about Users"  externalDocs:    description: "Find out more"    url: "https://github.com/AlexeySushkov/common-test-db/"schemes:- "https"paths:  /data:    get:      tags:      - "data"      summary: "Gets all data"      description: "Gets all data"      operationId: "getData"      produces:      - "application/json"      parameters: []      responses:        "200":          description: "successful operation"          schema:            type: "array"            items:              $ref: "#/definitions/Data"        "400":          description: "Invalid status value"      x-swagger-router-controller: "Data"    post:      tags:      - "data"      summary: "Add a new data to the db"      operationId: "addData"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "Data object that needs to be added to the db"        required: true        schema:          $ref: "#/definitions/Data"      responses:        "500":          description: "Create data error"      x-swagger-router-controller: "Data"    put:      tags:      - "data"      summary: "Update an existing data"      operationId: "updateData"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "Data information that needs to be changed"        required: true        schema:          $ref: "#/definitions/Data"      responses:        "400":          description: "Invalid uuid"        "404":          description: "User not found"        "500":          description: "Update data error"      x-swagger-router-controller: "Data"    delete:      tags:      - "data"      summary: "Delete an existing data"      operationId: "deleteData"      consumes:      - "application/x-www-form-urlencoded"      - "application/json"      produces:      - "application/json"      parameters:      - name: "uuid"        in: "formData"        description: "uuid"        required: true        type: "string"      responses:        "400":          description: "Invalid uuid"        "404":          description: "User not found"        "500":          description: "Delete data error"      x-swagger-router-controller: "Data"  /users:    post:      tags:      - "users"      summary: "Register new user"      operationId: "userRegister"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "User that needs to be deleted"        required: true        schema:          $ref: "#/definitions/User"      responses:        "500":          description: "Register user error"      x-swagger-router-controller: "Users"    put:      tags:      - "users"      summary: "Update existing user"      operationId: "userUpdate"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "User information that needs to be changed"        required: true        schema:          $ref: "#/definitions/User"      responses:        "404":          description: "User not found"        "500":          description: "Delete user error"      x-swagger-router-controller: "Users"    delete:      tags:      - "users"      summary: "Delete user"      operationId: "userDelete"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "User that needs to be added to the db"        required: true        schema:          $ref: "#/definitions/User"      responses:        "404":          description: "User not found"        "500":          description: "Delete user error"      x-swagger-router-controller: "Users"  /users/login:    post:      tags:      - "users"      summary: "Login"      operationId: "userLogin"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "User that needs to be added to the db"        required: true        schema:          $ref: "#/definitions/User"      responses:        "404":          description: "User not found"        "500":          description: "Login user error"      x-swagger-router-controller: "Users"definitions:  Data:    type: "object"    required:    - "Counter1"    - "Counter2"    properties:      Counter1:        type: "integer"        format: "int64"      Counter2:        type: "integer"        format: "int64"    example:      Counter1: 10      Counter2: 20  User:    type: "object"    required:    - "email"    - "password"    properties:      email:        type: "string"      password:        type: "string"    example:      password: "password"      email: "email"  ApiResponse:    type: "object"    properties:      status:        type: "string"


Интересная возможность встроить Swagger прямо в сервер, далее подложить ему YAML, реализующий API и по endpoint:
/api-docs

получить стандартную Swagger панель управления и осуществлять тестирование и просмотр документации. Скриншот Swagger, встроенного в наш сервер:

image

GraphQL


Существуют и альтернативы REST API, например GraphQL. Данный стандарт разработан Facebook и позволяет избавиться от недостатков REST:
  • В REST API взаимодействие происходит с использованием многочисленные endpoints. В GraphQL одна.
  • В REST API для получения данных от разных endpoints необходимо осуществить множественные запросы. В GraphQL все необходимые данные можно получить одним запросом.
  • В REST API клиент не может контролировать объём и содержимое данных в ответе. В GraphQL набор возвращаемых данных предсказуем, т.к. задается в запросе. Это сразу избавляет от угрозы: Excessive Data Exposure (Разглашение конфиденциальных данных)
  • В REST API отсутствует обязательность формальной схемы. В GpaphQL создание схемы API с определенными типами данных обязательно, при этом автоматически получается документация и сервер для тестирования (самодокументироваемость и самотестируемость).
  • Клиент всегда может запросить схему API с сервера, тем самым синхронизировать формат своих запросов
  • Из коробки работают подписки, реализованные на технологии WebSockets

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

Регистрация,аутентификация и авторизация пользователей


Если приложение доступно из Интернета, то в форме регистрации имеет смысл использовать функциональность reCaptсha от Google для защиты от ботов и DoS атак. В нашем примере я использую пакет vue-recaptcha.
Для использованияreCaptсha нужно на сайте Google зарегистрировать приложение, получить sitekey и прописать его в настройках клиента. После этого на форме появится, известный всем, вопрос про робота:
image

В примере реализована регистрация пользователей с помощью логина/пароля ис использованием Google Account.
  • При регистрации по логин/пароль пароли в базе данных, разумеется, не хранятся в виде текста, а хранится только хеш. Для его генерации используется библиотекаbcrypt, которая реализует алгоритм хеширванияBlowfish. Сейчас уже есть мнение, что надо использовать более надежные алгоритмы: Password Hashing: Scrypt, Bcrypt and ARGON2.
  • При регистрации с помощью Google Account организация хранения аутентификационных данных проще, т.к. в базу записывается только token, полученный от Google.

Для реализации различных схем аутентификации есть библиотека Passport, которая упрощает работу и скрывает детали. Но чтобы понять как работают алгоритмы, в примере все сделано в соответствии со стандартами, которые я уже описывал в своей статье: Современные стандарты идентификации: OAuth 2.0, OpenID Connect, WebAuthn.
Далее в статье разберем практическую реализацию.

Аутентификация по логину/паролю (Basic Authentication)
Тут все достаточно просто. При получении запроса на логин сервер проверяет, что пользователь с присланнымemail существует и что хеш полученного пароля совпадает с хешом из базы. Далее:
  • Сервер формирует SessionId в формате UUID и Token в формате JWT (JSON Web Token)
  • Токену присваиватся время жизни, которое определяется бизнес задачами. Например, Google устанавливет своим токенам время жизни 1 час.
  • Для формирования токена используется пакетjsonwebtoken
  • В JWT помещается email пользователя, который подписывается ключом сервера. Ключ это текстовая строка, которая хранится, как конфигурационный параметр на сервере.Содержимое токена кодируется Base64
  • SessionId сохраняется в базе и отправляется клиенту в заголовке X-CSRF-Token
  • Token сохраняется в базе и отправляется клиенту в теле HTTP ответа
  • Получив SessionId клиент сохраняетSessionId в sessionStorageбраузера
  • Token записывается в localStorage или Сookiesбраузерадля реализации возможности запомнить меня на этом устройстве
  • В дальнейшем клиент будет присылать SessionId в HTTP заголовкеX-CSRF-Token:

X-CSRF-Token: 69e530cf-b641-445e-9866-b23c492ddbab

  • Token будет присылаться в заголовкеHTTP Authorization с префиксомBearer:

Authorization: BearereyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJpYXQiOjE2MDYxNTY3MTUsImV4cCI6MTYwNjc2MTUxNX0.h3br5wRYUhKIFs3SMN2ZPvMcwBxKn7GMIjJDzCLm_Bw

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

  1. Токен подписан сервером;
  2. Пользователь с email существует;
  3. Время жизни токена не истекло. Если же оно истекло, то при выполнении предыдущих условий автоматически формируется новый токен.

В общем случае излишне генерировать и SessionId и Token. В нашем PoC для примера реализованы оба механизма. Посмотрим внимательней на наш JWT. Пример закодированного JWT токена из заголовка:
Authorization:yJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJpYXQiOjE2MDYxNTY3MTUsImV4cCI6MTYwNjc2MTUxNX0.h3br5wRYUhKIFs3SMN2ZPvMcwBxKn7GMIjJDzCLm_Bw

Раскодируем его открытыми средствами. Например самый простой способ загрузить на сайтhttps://jwt.io

image

Мы видим, что информация, помещенная вJWT не зашифрована, а только подписана ключом сервера и туда нельзя помещать чувствительные к разглашению данные!

Аутентификация с помощью Google Account (Token-Based Authentication)
Тут посложнее, поэтому нарисую диаграмму:

image

1. Пользователь заходит на GUI PoC, выбирает Login with Google".
2. Клиент запрашивает у сервера SessionId и настройки Google. Настройки надо предварительно получить с сайта Google, например, по инструкции из моей статьи: Современные стандарты идентификации: OAuth 2.0, OpenID Connect, WebAuthn
3. Клиент сохраняет SessionId в sessionStorageбраузера
4. Из браузера формируется GET запрос на GoogleAuthorization Server (красная стрелка). Для понимания алгоритма, надо обратить внимание, что ответ на этот запрос браузер получит от Google только в самом конце call flow на шаге 13 и это будет 307 (Redirect).

Формат GET запроса:
https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=918876437901-g312pqdhg5bcju4hpr3efknv.apps.googleusercontent.com&scope=openid+email&redirect_uri=http://localhost:8081/redirect&access_type=offline&state=450c2fd9-0a5e-47d2-8ed5-dd1ff4670b58

В этом запросе:
  • accounts.google.com/o/oauth2/v2/auth endpoint для начала аутентификации. У Google есть, кстати, адрес, по которому можно посмотреть актуальный список всех Google API endpoints:https://accounts.google.com/.well-known/openid-configuration
  • response_type=code параметр говорит, что ожидаем получить в ответAuthorization Code
  • client_id Client ID, выданный при регистрации приложения на Google (в примере указан не настоящий)
  • scope=openid email к каким данным пользователя мы хотим получить доступ
  • redirect_uri = localhost:8081/redirectCallback адрес, заданный при регистрации приложения. В нашем случае это адрес на нашем сервере, который получит запрос от Google
  • state = SessionId, который передается между клиентом, Google и сервером для защиты от вмешательства внешнего злоумышленника

5. Google Authorization Server показывает стандартную Google форму логина.
6. Пользователь вводит свои логин/пароль от Google аккаунта.
7. Google проверяет пользователя и делает GET запрос на адрес Callback с результатом аутентификации, Authorization Code в параметре code и нашем SessionId в параметреstate:
  • state: '450c2fd9-0a5e-47d2-8ed5-dd1ff4670b58',
  • code: '4/0AY0e-g4pg_vSL1PwUWhfDYj3gpiVPUg20qMkTY93JYhmrjttedYwbH376D_BvzZGmjFdmQ',
  • scope: 'email openid www.googleapis.com/auth/userinfo.email',
  • authuser: '1',
  • prompt: 'consent'

8. Сервер, не отправляя пока ответ на GET, формирует POST запрос с Authorization Code, Client ID и Client Secret:
POSThttps://oauth2.googleapis.com/token?code=4/0AY0e-g4pg_vSL1PwUWhfDYj3gpiVPUg20qMkTY93JYhmrjttedYwbH376D_BvzZGmjFdmQ&client_id=918876437901-g312pqdhg5bcju4hpr3efknv.apps.googleusercontent.com&client_secret=SUmydv3-7ZDTIh8аНК85chTOt&grant_type=authorization_code&redirect_uri=http://localhost:8081/redirect

  • oauth2.googleapis.com/token это endpoint для получения token
  • code только что присланный code
  • client_id Client ID, выданный при регистрации приложения на Google (в примере указан не настоящий)
  • client_secret Client Secret, выданный при регистрацииприложения на Google (в примере указан не настоящий)
  • grant_type=authorization_code единственно возможное значение из стандарта

9. Google проверяет присланные данные и формирует access token в формате JWT (JSON Web Token), подписанный своим приватным ключом. В этом же JWT может содержаться и refresh token, c помощью которого возможно продолжение сессии после ее окончания:
  • access_token: 'ya29.a0AfH6SMBH70l6wUe1i_UKfjJ6JCudA_PsIIKXroYvzm_xZjQrCK-7PUPC_U-3sV06g9q7OEWcDWYTFPxoB1StTpqZueraUYVEWisBg46m1kQAtIqhEPodC-USBnKFIztGWxzxXFX47Aag',
  • expires_in: 3599,
  • refresh_token: '1//0cAa_PK6AlemYCgYIARAAGAwSNwF-L9IrdUt1gzglxh5_L4b_PwoseFlQA1XDhqte7VMzDtg',
  • scope: 'openid www.googleapis.com/auth/userinfo.email',
  • token_type: 'Bearer',
  • id_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImRlZGMwMTJkMDdmNTJhZWRmZDVmOTc3ODRlMWJjYmUyM2MxOTcfobDCf2VrxXb6CCxoL_dZq1WnlEjBZx_Sf6Rg_tn3x4gWtusO1oe_bJx_gvlSLxtvSOdO_kPB2uGGQHr3xzF_Evr-S-BiGS8zMuIkslyN6fU7P7BdNVyOYAIYFvHikyIpAoesV2Fd2yBSngBwGmWfrHL7Z2415UrnlCG4H1Nw'

10. Сервер валидирует пришедший Token. Для этого нужен открытый ключ от Google. Актуальные ключи находятся по определенному адресу и периодически меняются:https://www.googleapis.com/oauth2/v1/certs.Поэтому надо или получать их каждый раз по этому адресу, либо хардкодить и следить за изменениями самому.Декодируем пришедший token, для нас самое главное это подтвержденный email пользователя:
decodedIdToken: {iss: 'https://accounts.google.com',azp: '918962537901-gi8oji3qk312pqdhg5bcju4hpr3efknv.apps.googleusercontent.com',aud: '918962537901-gi8oji3qk312pqdhg5bcju4hpr3efknv.apps.googleusercontent.com',sub: '101987547227421522632',email: 'work.test.mail.222@gmail.com',email_verified: true,at_hash: 'wmbkGMnAKOnfAKtGQFpXQw',iat: 1606220748,exp: 1606224348}

11. Проверяем, что email пользователя существует, или создаём нового пользователя c email из токена. При этомSessionId и полученные от Google токены сохраняются в базе.
12. Только теперь отвечаем Google HTTP кодом 307 (Redirect) и заголовком Location c адресом на клиенте:
HTTP Location:http://localhost:8080/googleLogin

13. И только теперь Google отвечает браузеру с тем же кодом307 (Redirect) и заголовком Location с заданным нами адресом
14. Браузер переходит на адрес, указанный вLocation иклиент определяет, что произошла успешная аутентификация пользователя с помощью Google аккаунта
15. Клиент, по сохраненному в sessionStorage SessionId, получает на сервере токен и данные пользователя
16. Клиент сохраняет токен в localStorage браузера

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

Прием, обработка и визуализация данных


Сделаем стандартный вид приложения, как рекомендует Google:
  • Drawer (navigation-drawer) в левой стороне
  • Меню сверху (v-app-bar)
  • Footer внизу (v-footer)
  • Для визуализации полученных данных используем карточки (Data Cards)

image

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

image

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

  1. Для Vue есть библиотекаVuex, реализующаяstate management pattern.
  2. Есть универсальная библиотека Redux основанная на Flux от Facebook. Она реализует концепцию состояний, используя понятия action, state, view.

  • В примере я использовал Vuex.
  • Также необходимRouter для реализации переходов между страницами.

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

Архитектура


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

Заключение


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

Исходники на Github:https://github.com/AlexeySushkov/common-test-db
Пример запущен в облаке Amazon, можно посмотреть, как он выглядит вживую:http://globalid.tech/

Остается только в канун Нового Года пожелать, чтобы сбылись все ваши самые заветные PoC!

It's only the beginning!
Подробнее..

OAuth 2.0 -gt OAuth 2.1. Что дальше?

27.01.2021 22:06:12 | Автор: admin
Архитекторы ничего не выдумывают. Они трансформируют реальность.

Алваро Сиза Виэйра

Много всего уже сказано и написано про фреймворк авторизации OAuth 2.0 с 2012 года. И, казалось бы, все давно его знают, используют, все должно работать надежно и безопасно.
Но, как обычно, на практике все иначе. В работе в реальности приходится сталкиваться с небезопасными реализациями процессов авторизации и аутентификации. Огорчает, что по статистике Россия занимает непочетное первое место по своей уязвимости.
Почему же так получается? Предлагаю вместе со мной и авторами драфта OAuth 2.1 от июля 2020 года сделать небольшую работу над ошибками. Это и будет отражением, на мой взгляд, того, по какому пути развития идет фреймворк OAuth 2.

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

Введение


Стоит ли винить во всем разработчиков, как это принято делать чаще всего? На мой взгляд, не стоит. У разработчика часто стоит задачей реализовать ту или иную функциональность по неким требованиям. Посмотрим же внимательнее на OAuth 2.0.

Фреймворк предлагает воспользоваться правилами по организации потоков авторизации:

А также требования к форматам обмена.

Что в описании стандарта может приводить к небезопасным реализациям на практике?
Я пока оставлю этот вопрос открытым, и предлагаю читателю самостоятельно при дальнейшем изучении OAuth 2.0 и прочтении статьи делать свои выводы. На протяжении этой статьи я же буду приводить свое видение ответа на этот вопрос.

Понятия и термины


Перед рассмотрением отдельно каждого из потоков, дадим определения базовой терминологии, используемой в стандарте (читателю, знакомому с терминологией OAuth 2.0, данный абзац можно пропустить).
Таблица 1. Базовые термины OAuth 2.0
ТерминOAuth 2.0 Authorization Framework Описание изOAuth 2.0 Пример
Resource owner
(Владелец ресурса)
Абстрактная сущность, предоставляющая доступ к защищенном ресурсу.
Если в качестве этой роли выступает человек, то его называют конечным пользователем (end-user).
Например, человек, предоставляющий доступ к своим персональным данным, хранящимся на сервере.
Resource server
(Сервер ресурсов)
Сервер, на котором размещаются защищенные ресурсы, принимающий и отвечающий на защищенные запросы к ресурсам с использованием токена доступа (access token). Например, сервер KeyCloak, предоставляющий доступ к данным пользователя через REST-сервис (UserInfo Endpoint)
Client
(Клиентское приложение)
Приложение, выполняющее запросы ресурсов от имени владельца ресурса и с его разрешения. Термин клиент не подразумевает каких-либо конкретных характеристик реализации в стандарте (например, выполняется ли приложение на сервере, рабочем столе или других устройствах). Web Application,Desktop Native Application,Mobile Native Application,SPA App,Javascript application
Authorization server
(Сервер авторизации)
Сервер, выдающий клиенту токены доступа после успешной аутентификации владельца ресурса и авторизации. Например, сервер KeyCloak (если говорить про открытое решение KeyCloak, то он может выступать как сервером ресурсов, так и сервером авторизации одновременно.

Примечание из OAuth:
Сервер авторизации может быть тем же сервером, что и сервер ресурсов, или отдельным решением. Один сервер авторизации может выдавать токены доступа, принимаемые несколькими серверами ресурсов.
Web application
(веб-приложение)
Веб-приложение это конфиденциальный клиент, запускаемый на веб-сервере. Владельцы ресурсов получают доступ к клиенту через пользовательский интерфейс HTML, отображаемый в Агенте пользователя на устройстве, используемом владельцем ресурса. Учетные данные клиента, а также любой токен доступа, выданный клиенту, хранится на веб-сервере и недоступен владельцу ресурса. Традиционное веб-приложение в клиент-серверной архитектуре.
User-agent-based application Приложение на основе пользовательского агента это публичный клиент, в котором клиентский код загружается с веб-сервера и выполняется в пользовательском агенте (например, веб-браузере) на устройстве, используемом владельцем ресурса. Учетные данные легкодоступны (и часто видны) владельцу ресурса.
  • SPA App,
  • Javascript application with a backend (частные примеры приложений: Angular front-end с .NET backend, или React front-end с Spring Boot backend.)
  • JavaScript Applications without a Backend
Native application Локально устанавливаемое приложение это публичный клиент, установленный и выполняемый на устройстве, используемом владельцем ресурса. Данные протокола и учетные данные доступны владельцу ресурса. Предполагается, что любые учетные данные аутентификации клиента, включенные в приложение, могут быть извлечены. Desktop Native Application,Mobile Native Application
На различных устройствах, включая: настольный компьютер, телефон, планшет и т. д.
Application without an authentification flow Приложение, не участвующее в процессе авторизации/аутентификации пользователей. Выполняет только защищенные запросы к серверам ресурсов. Бэк-часть клиентского приложения, внешняя система-клиент, выполняющая запрос к сервисам системы.

Теперь можем перейти к рассмотрению каждого из потоков детальнее.


Критический взгляд на OAuth 2.0


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

Authorization Code Grant


Рассмотрим первый из наиболее распространенных в реализации потоков с момента создания стандарта: Authorization Code Grant. В самом начале стандарта его разработчики предлагают ознакомиться с общим, объединяющим все потоки документа представлением. А далее уже рассматривается каждый индивидуально и, в частности, Authorization Code Grant (from the OAuth 2.0 Authorization Framework). Но что мы видим:
Лень - двигатель прогресса?


На представлении потока Authorization Code Grant почему-то отсутствует ресурсный сервер. Наверное, у многих потребителей стандарта может возникнуть вопрос, что же делать дальше с полученным токеном?
На практике периодически встречалась с реализацией, когда сам поток реализовывали корректно, а далее все запросы к сервисам просто шли непосредственно из браузера (User-Agent) без какого-либо использования токена для защиты. Либо с использованием токена, который от клиента (Client) пробрасывался в браузер сначала, а из браузера (User-Agent) шли запросы к сервисам. Токен, естественно, можно просто было просмотреть в самом браузере.
В стандарте в самом начале есть описание, как использовать токен в общем описании потока. Но как показала практика, не хватает этой детализации и в описании Authorization Code Grant.
Давайте добавим ресурсный сервер для устранения этой неточности в описание потока (на рисунках я буду приводить только положительные сценарии, чтобы не перегружать диаграммы условиями):
Authorization Code Grant with a Resource Server (Resource Server != Authorization Server)


Также стоит отдельно заметить, что если серверная часть клиент-серверного приложения выполняет запросы к Resource Server внутри одной защищенной сети, то использование Access токена можно опустить. Запросы будут защищены в рамках одной сети, а Authorization Code Grant будет использоваться непосредственно только для аутентификации пользователя.

Implicit Grant


Представление Implicit Grant из OAuth 2.0 я не стану приводить здесь, с ним вы можете самостоятельно ознакомиться по ссылке.
Изучая последовательно поток Implicit Grant, исполнитель задачи, на мой взгляд, может обратить внимание на фразу в описании стандарта:
These clients are typically implemented in a browser using a scripting language such as JavaScript.

Ага! Клиент-серверные приложения уходят в прошлое, значит Authorization Code Grant нам не подходит! Давайте Implicit Grant использовать! наверное, подумал аналитик или разработчик.
Читаем дальше.
Because the access token is encoded into the redirection URI, it may be exposed to the resource owner and other applications residing on the same device.

Еще проще! Никаких сложных и запутанных редиректов! тоже, наверное, может подумать разработчик или аналитик? Ведь стандарт сам разрешает так делать.
Надо читать дальше? Вроде, и так все понятно
А дальше мы увидим вот такую рекомендацию:
See Sections 10.3 and 10.16 for important security considerations when using the implicit grant.

Если мы пройдем по ссылке 10.16, то первое, что мы прочтем, будет это:
" For public clients using implicit flows, this specification does not provide any method for the client to determine what client an access token was issued to."

А затем описание с рекомендациями, как мы могли бы сами о себе позаботиться. Т.е. получаем, что как бы поток в стандарте описан, но его описание ничего общего с безопасностью не имеет. Т.е. сами по себе описываемые последовательности запросов в фреймворке авторизации не гарантируют безопасного взаимодействия. Могут потребоваться дополнительные меры по ее организации.
И OAuth 2.0 это не всегда про безопасность, несмотря на устоявшееся мнение. Но все ли читают рекомендации в сносках?

Resource Owner Password Credentials Grant


Визуальное представление Resource Owner Password Credentials Grant кажется очень простым.
Но в самом параграфе нам сразу написали:
The authorization server should take special care when enabling this grant type and only allow it when other flows are not viable.

Думаю, эта фраза многих уберегла от реализации данного потока на практике. Не будем долго задерживаться на Resource Owner Password Credentials Grant.

Client Credentials Grant


Данный поток подразумевает взаимодействие клиента (Client) с авторизационным сервером (Authorization Server) посредством обмена клиентского идентификатора (Client ID -логина) и секрета (Secret пароля для системы) на токен доступа (access token).
Client Credentials:


Но здесь то уж не могли ничего напутать, правда?
Давайте читать дальше.
В части описания ответа сервера клиенту мы видим такую фразу:
A refresh token SHOULD NOT be included.

Но что значит не следует? Т.е. вроде как, и можно использовать, т.е. какие-то сценарии могут потребовать его наличия? А на практике мы имеем, что системы, которые реализуются по OAuth 2.0, вынуждены реализовывать такую возможность, так как стандартом она не исключается.
И здесь надо вспомнить, для чего, в принципе, были придуманы Refresh token и Access token.
У Access token есть ограниченный срок действия, и когда он истекает, клиент (Client) в обмен на Refresh token запрашивает новый Access token. Refresh token всегда может использоваться один и тот же, либо с каждым новым Access token передаваться и новый Refresh token должен, что безопаснее, но сложнее в реализации.
Если вернуться к описанию потока Authorization Code Grant, то там мы можем увидеть реальную необходимость его использования: в случае, когда по каким-либо причинам злоумышленнику удалось украсть Access token в обмен на код авторизации (это возможно, так как код авторизации передается приложению через обмен с браузером (или другим пользовательским агентом), а Client ID и Secret по каким-то причинам не используются при запросе токена доступа (или их смогли украсть ранее), то из-за ограниченности периода действия Access token, у злоумышленника будет столько времени навредить, сколько действует Access token, и пока в обмен на Refresh token не будет запрошен новый Access token доверенным клиентом.

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

А если мы посмотрим на Client Credentials Flow, то здесь, если злоумышленник украдет Client ID и Secret, то Refresh token нам не навредит, но уже никак и не поможет. Поэтому мы имеем избыточность и излишнюю сложность в реализациях.

OAuth 2.0 -> OAuth 2.1. Что дальше?


Итак, мы с некой долей скептицизма рассмотрели авторизационные потоки, которые нам предлагает фреймворк OAuth 2.0. Давайте же теперь разбираться и отвечать на вопрос, поставленный в заголовке к самой статье. В каком направлении и как развивается OAuth 2.0?
За период, начиная с момента перевода OAuth 2.0 из черновика в статус стандарта, до настоящего времени разработчики стандарта продолжали активно делать работу над ошибками, которая появлялась в свет в виде новых RFC и черновиков в дополнение к OAuth 2.0:

Итогом почти десятилетней работы стало появление драфта OAuth 2.1, период действия которого истекает 31.01.2021.
И здесь интригующий напрашивается вопрос: будет ли продлен драфт или перейдет в статус стандарта?
Драфт консолидировал в себе изменения всех более поздних публикаций. Наиболее значимые из них:

(1) Потоки Implicit grant (response_type=token) и Resource Owner Password Credentials исключаются из документа.
Выдержка из содержания:


(2) Аuthorization code grant расширили кодом PKCE (RFC7636) таким образом, что единственный вариант использования Аuthorization code grant в соответствии с этой спецификацией требует добавления механизма PKCE;
Clients MUST use code_challenge and code_verifier and authorization servers MUST enforce their use except under the conditions described in Section 9.8. In this case, using and enforcing code_challenge and code_verifier as described in the following is still RECOMMENDED.

Clients MUST prevent injection (replay) of authorization codes into the authorization response by attackers. To this end, using code_challenge and code_verifier is REQUIRED for clients and authorization servers MUST enforce their use, unless both of the following criteria are met:
* The client is a confidential or credentialed client.

* In the specific deployment and the specific request, there is reasonable assurance for authorization server that the client implements the OpenID Connect nonce mechanism properly.

(3) Redirect URIs должны будут всегда явно задаваться на сервере.
(4) Refresh токены для публичных клиентов должны ограничиваться, либо использоваться не более 1 раза.
С учетом (1) и (2), наибольшего внимания, на мой взгляд, заслуживает Proof Key for Code Exchange сейчас.
Как мы и говорили выше, все чаше мы уже отходим от реализаций классических проверенных временем клиент-серверных архитектур (хотя я сама призываю всегда не следовать за модой, а выбирать решение в зависимости от поставленной задачи). И разработчики RFC предлагают измененные решения для трансформирующейся реальности.
К сожалению, в OAuth 2.1 представления потоков не изменились, дополнились только описания к ним. Поэтому я не буду уже приводить вид из стандарта (с теми же граблями в виде отсутствующего ресурсного сервера), а сразу сделаю боле детальное описание.
На диаграмме последовательностей вызовов ниже приведен случай для приложения без классического бэкенда (намеренно указывается JSClient). А также видим, что в случае, когда у нас появляется и ресурсный сервер на диаграмме, то мы вынуждены добавлять API шлюз для выполнения более сложных проверок (URI, валидность токена, идентификацию клиента и т.д.) не ресурсным сервером:
Аuthorization code grant с PKCE и API Gateway



Памятка для разработчиков


Имеющийся сейчас у меня опыт работы с системами, реализующими OAuth 2, хотела бы оставить в статье, так как надеюсь, что он может оказаться кому-то полезен и позволит качественнее защищать наши с вами данные в сети.
Позволю себе обозначенные правила к авторизационным потокам и приложениям, которые их реализуют, разложить в виде матрицы принятия решений:
Приложение по OAuth 2.0 OAuth2/OIDCAuthorization codeGrandt OAuth2/OIDCAuthorization codeGrandwith PKCE Client Credentials Grant
Web application with a Confidential Client + + +
User-agent-based application:
JavaScript Applications without a Backendwith a Public Client
- + -
User-agent-based application:
Javascript application with a Backendwith a Confidential Client
+ + +
Application without an authentification flowwith a Confidential Client - - +

Для того или иного приложения и выбираемого подхода существуют свои плюсы и минусы. Вкратце посмотрим на JavaScript application with no backend и JavaScript application with a backend и дадим ключевые рекомендации к реализациям:
JavaScript application with no backend JavaScript application with a backend
Рекомендуется:
  1. В обязательном порядкеограничивать список возможных redirect_url.
  2. Для хранения токенов использоватьjs переменные.
  3. Используется Public Client.
  4. В потоке Authorization Code Grant Flow with PKCE для проверки токенов использовать API Gateway:

Аuthorization code grant с PKCE и API Gateway


Рекомендуется:
  1. ClientID и Secret в обязательном порядке хранить на серверной стороне приложения.
  2. Используется Confidential Client.
  3. Использовать поток Authorization Code Grant Flow (как с PKCE, так допустимо и без):
    Authorization Code Grant with a Resource Server (Resource Server != Authorization Server)


Не стоит:
  1. Реализовывать аутентификацию внутри бизнес-сервисов.
  2. Использовать offline_access.
  3. Передаватьтокены в path- и query- параметрах.
  4. Реализовывать авторизационный и ресурсный сервер в едином решении.

Не стоит:
  1. Передавать ID Token, Access Token, Refresh Token из серверной части агенту пользователя (браузер), т.е. токен хранится только на стороне бэка.


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

Заключение


В заключении, думаю, стоит заметить, что исходя из той динамики, о которой говорилось выше по дополнению OAuth 2.0 RFC и драфтами в течение 9 лет, мы можем ожидать, что со временем, все-таки наши системы станут безопаснее и надежнее. Мы видим, что стандарт становится строже, но в то же время, он и неизбежно трансформируется под влиянием тенденций в разработке. Хотя бдительность я бы предложила не терять и не расслабляться: с новыми требованиями мы можем столкнуться и с новыми нелепыми реализациями из-за двусмысленности добавленных формулировок.
Подробнее..

Перевод Наилучшие практики создания REST API

17.07.2020 10:14:14 | Автор: admin
Всем привет!

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

Приятного чтения!


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

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

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

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

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

Принимаем JSON и выдаем JSON в ответ

REST API должны принимать JSON для полезной нагрузки запроса, а также отправлять отклики в формате JSON. JSON это стандарт передачи данных. К его использованию приспособлена практически любая сетевая технология: в JavaScript есть встроенные методы для кодирования и декодирования JSON либо через Fetch API, либо через другой HTTP-клиент. В серверных технологиях используются библиотеки, позволяющие декодировать JSON практически без вмешательства с вашей стороны.

Существуют и другие способы передачи данных. Язык XML как таковой не очень широко поддерживается во фреймворках; обычно требуется преобразование данных в более удобный формат, а это обычно JSON. На стороне клиента, особенно в браузере, не так легко обращаться с этими данными. Приходится выполнять массу дополнительной работы всего лишь для того, чтобы обеспечить нормальную передачу данных.

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

Чтобы гарантировать, что клиент интерпретирует JSON, полученный с нашего REST API, именно как JSON, следует установить для Content-Type в заголовке отклика значение application/json после того, как будет сделан запрос. Многие серверные фреймворки приложений устанавливают заголовок отклика автоматически. Некоторые HTTP-клиенты смотрят Content-Type в заголовке отклика и разбирают данные в соответствии с указанным там форматом.

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

Также следует убедиться, что в отклике от наших конечных точек нам приходит именно JSON. Во многих серверных фреймворках данная возможность является встроенной.
Рассмотрим в качестве примера API, принимающий полезную нагрузку в формате JSON. В данном примере используется бэкендовый фреймворк Express для Node.js. Можно использовать в качестве промежуточного ПО программу body-parser для разбора тела запроса JSON, а затем вызвать метод res.json с объектом, который мы хотим вернуть в качестве отклика JSON. Это делается так:

const express = require('express');const bodyParser = require('body-parser');const app = express();app.use(bodyParser.json());app.post('/', (req, res) => {  res.json(req.body);});app.listen(3000, () => console.log('server started'));


bodyParser.json() разбирает строку с телом запроса в JSON, преобразуя ее в объект JavaScript, а затем присваивает результат объекту req.body.

Установим для заголовка Content-Type в отклике значение application/json; charset=utf-8 без каких-либо изменений. Метод, показанный выше, применим и в большинстве других бэкендовых фрейморков.

В названиях путей к конечным точкам используем имена, а не глаголы

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

Дело в том, что в названии нашего метода HTTP-запроса уже содержится глагол. Ставить глаголы в названиях путей к конечной точке API нецелесообразно; более того, имя получается излишне длинным и не несет никакой ценной информации. Глаголы, выбираемые разработчиком, могут ставиться просто в зависимости от его прихоти. Например, кому-то больше нравится вариант get, а кому-то retrieve, поэтому лучше ограничиться привычным глаголом HTTP GET, сообщающим, что именно делает конечная точка.

Действие должно быть указано в названии HTTP-метода того запроса, который мы выполняем. В названиях наиболее распространенных методов содержатся глаголы GET, POST, PUT и DELETE.
GET извлекает ресурсы. POST отправляет новые данные на сервер. PUT обновляет имеющиеся данные. DELETE удаляет данные. Каждый из этих глаголов соответствует одной из операций из группы CRUD.

Учитывая два принципа, рассмотренных выше, для получения новых статей мы должны создавать маршруты вида GET /articles/. Аналогично, используем POST /articles/ для обновления новой статьи, PUT /articles/:id для обновления статьи с заданным id. Метод DELETE /articles/:id предназначен для удаления статьи с заданным ID.

/articles это ресурс REST API. Например, можно воспользоваться Express, чтобы выполнять со статьями следующие операции:

const express = require('express');const bodyParser = require('body-parser');const app = express();app.use(bodyParser.json());app.get('/articles', (req, res) => {  const articles = [];  // код для извлечения статьи...  res.json(articles);});app.post('/articles', (req, res) => {  // код для добавления новой статьи...  res.json(req.body);});app.put('/articles/:id', (req, res) => {  const { id } = req.params;  // код для обновления статьи...  res.json(req.body);});app.delete('/articles/:id', (req, res) => {  const { id } = req.params;  // код для удаления статьи...  res.json({ deleted: id });});app.listen(3000, () => console.log('server started'));


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

Конечные точки POST, PUT и DELETE принимают тело запроса в формате JSON и возвращают отклик также в формате JSON, включая в него конечную точку GET.

Коллекции называем существительными во множественном числе

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

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

При работе с конечной точкой /articles мы пользуемся множественным числом при именовании всех конечных точек.

Вложение ресурсов при работе с иерархическими объектами

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

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

Например, это можно сделать при помощи следующего кода в Express:

const express = require('express');const bodyParser = require('body-parser');const app = express();app.use(bodyParser.json());app.get('/articles/:articleId/comments', (req, res) => {  const { articleId } = req.params;  const comments = [];  // код для получения комментариев по articleId  res.json(comments);});app.listen(3000, () => console.log('server started'));


В вышеприведенном коде можно использовать метод GET в пути '/articles/:articleId/comments'. Мы получаем комментарии comments к статье, которой соответствует articleId, а затем возвращаем ее в ответ. Мы добавляем 'comments' после сегмента пути '/articles/:articleId', чтобы указать, что это дочерний ресурс /articles.

Это логично, поскольку comments являются дочерними объектами articles и предполагается, что у каждой статьи свой набор комментариев. В противном случае данная структура может запутать пользователя, поскольку обычно применяется для доступа к дочерним объектам. Тот же принцип действует при работе с конечными точками POST, PUT и DELETE. Все они используют одно и то же вложение структур при составлении имен путей.

Аккуратная обработка ошибок и возврат стандартных кодов ошибок

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

  • 400 Bad Request (Плохой Запрос) означает, что ввод, полученный с клиента, не прошел валидацию.
  • 401 Unauthorized (Не авторизован) означает, что пользователь не представился и поэтому не имеет права доступа к ресурсу. Обычно такой код выдается, когда пользователь не прошел аутентификацию.
  • 403 Forbidden (Запрещено) означает, что пользователь прошел аутентификацию, но не имеет права на доступ к ресурсу.
  • 404 Not Found (Не найдено) означает, что ресурс не найден
  • 500 Internal server error (Внутренняя ошибка сервера) это ошибка сервера, которую, вероятно, не следует выбрасывать явно.
  • 502 Bad Gateway (Ошибочный шлюз) означает недействительное ответное сообщение от вышестоящего сервера.
  • 503 Service Unavailable (Сервис недоступен) означает, что на стороне сервера произошло нечто непредвиденное например, перегрузка сервера, отказ некоторых элементов системы, т.д.


Следует выдавать именно такие коды, которые соответствуют ошибке, помешавшей нашему приложению. Например, если мы хотим отклонить данные, пришедшие в качестве полезной нагрузки запроса, то, в соответствии с правилами Express API, должны вернуть код 400:

const express = require('express');const bodyParser = require('body-parser');const app = express();// существующие пользователиconst users = [  { email: 'abc@foo.com' }]app.use(bodyParser.json());app.post('/users', (req, res) => {  const { email } = req.body;  const userExists = users.find(u => u.email === email);  if (userExists) {    return res.status(400).json({ error: 'User already exists' })  }  res.json(req.body);});app.listen(3000, () => console.log('server started'));


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

Далее, если мы пытаемся передать полезную нагрузку со значением email, уже присутствующим в users, то получаем отклик с кодом 400 и сообщение 'User already exists', означающее, что такой пользователь уже существует. Располагая этой информацией, пользователь может поправиться заменить адрес электронной почты на тот, которого пока нет в списке.

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

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

Разрешать сортировку, фильтрацию и разбивку данных на страницы

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

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

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

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

const express = require('express');const bodyParser = require('body-parser');const app = express();// информация о сотрудниках в базе данныхconst employees = [  { firstName: 'Jane', lastName: 'Smith', age: 20 },  //...  { firstName: 'John', lastName: 'Smith', age: 30 },  { firstName: 'Mary', lastName: 'Green', age: 50 },]app.use(bodyParser.json());app.get('/employees', (req, res) => {  const { firstName, lastName, age } = req.query;  let results = [...employees];  if (firstName) {    results = results.filter(r => r.firstName === firstName);  }  if (lastName) {    results = results.filter(r => r.lastName === lastName);  }  if (age) {    results = results.filter(r => +r.age === +age);  }  res.json(results);});app.listen(3000, () => console.log('server started'));


В вышеприведенном коде у нас есть переменная req.query, позволяющая получить параметры запроса. Затем мы можем извлечь значения свойств путем деструктуризации отдельных параметров запроса в переменные; для этого в JavaScript предусмотрен специальный синтаксис.
Наконец, мы применяем filter с каждым значением параметра запроса, чтобы найти те элементы, которые хотим вернуть.

Справившись с этим, возвращаем results в качестве отклика. Следовательно, при выполнении запроса GET к следующему пути со строкой запроса:

/employees?lastName=Smith&age=30

Получаем:
[    {        "firstName": "John",        "lastName": "Smith",        "age": 30    }]


в качестве возвращенного ответа, поскольку фильтрация производилась по lastName и age.
Аналогично, можно принять параметр запроса page и вернуть группу записей, занимающих позиции от (page - 1) * 20 до page * 20.

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

http://example.com/articles?sort=+author,-datepublished

Где + означает вверх, а вниз. Таким образом, мы сортируем по имени автора в алфавитном порядке и по datepublished от новейшего к наиболее давнему.

Придерживаться проверенных практик обеспечения безопасности

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

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

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

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

Кэшировать данные для улучшения производительности

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

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

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

const express = require('express');const bodyParser = require('body-parser');const apicache = require('apicache');const app = express();let cache = apicache.middleware;app.use(cache('5 minutes'));// информация о сотрудниках в базе данныхconst employees = [  { firstName: 'Jane', lastName: 'Smith', age: 20 },  //...  { firstName: 'John', lastName: 'Smith', age: 30 },  { firstName: 'Mary', lastName: 'Green', age: 50 },]app.use(bodyParser.json());app.get('/employees', (req, res) => {  res.json(employees);});app.listen(3000, () => console.log('server started'));


Вышеприведенный код просто ссылается на apicache при помощи apicache.middleware, в результате имеем:

app.use(cache('5 minutes'))

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

Версионирование API

У нас должны быть различные версии API на тот случай, если мы вносим в них такие изменения, которые могут нарушить работу клиента. Версионирование может производиться по семантическому принципу (например, 2.0.6 означает, что основная версия 2, и это шестой патч). Такой принцип сегодня принят в большинстве приложений.
Таким образом можно постепенно выводить из употребления старые конечные точки, а не вынуждать всех одновременно переходить на новый API. Можно сохранить версию v1 для тех, кто не хочет ничего менять, а версию v2 со всеми ее новоиспеченными возможностями предусмотреть для тех, кто готов обновиться. Это особенно важно в контексте публичных API. Их нужно версионировать, чтобы не сломать сторонние приложения, использующие наши API.
Версионирование обычно делается путем добавления /v1/, /v2/, т.д., добавляемых в начале пути к API.

Например, вот как это можно сделать в Express:

const express = require('express');const bodyParser = require('body-parser');const app = express();app.use(bodyParser.json());app.get('/v1/employees', (req, res) => {  const employees = [];  // код для получения информации о сотрудниках  res.json(employees);});app.get('/v2/employees', (req, res) => {  const employees = [];  // другой код для получения информации о сотрудниках  res.json(employees);});app.listen(3000, () => console.log('server started'));


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

Заключение

Важнейший вывод, связанный с проектированием высококачественных REST API: в них необходимо сохранять единообразие, следуя стандартам и соглашениям, принятым в вебе. JSON, SSL/TLS и коды состояния HTTP обязательная программа в современном вебе.
Не менее важно учитывать производительность. Можно увеличить ее, не возвращая слишком много данных сразу. Кроме того, можно задействовать кэширование, чтобы не запрашивать одни и те же данные снова и снова.

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

Перевод React лучшие практики

08.02.2021 10:22:45 | Автор: admin


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

Введение


Я работаю с React уже 5 лет, однако, когда дело касается структуры приложения или его внешнего вида (дизайна), сложно назвать какие-то универсальные подходы.

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

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

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

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

Компоненты


Функциональные компоненты

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

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

//  Классовые компоненты являются "многословными"class Counter extends React.Component {  state = {    counter: 0,  }  constructor(props) {    super(props)    this.handleClick = this.handleClick.bind(this)  }  handleClick() {    this.setState({ counter: this.state.counter + 1 })  }  render() {    return (      <div>        <p>Значение счетчика: {this.state.counter}</p>        <button onClick={this.handleClick}>Увеличить</button>      </div>    )  }}//  Функциональные компоненты легче читать и поддерживатьfunction Counter() {  const [counter, setCounter] = useState(0)  handleClick = () => setCounter(counter + 1)  return (    <div>      <p>Значение счетчика: {counter}</p>      <button onClick={handleClick}>Увеличить</button>    </div>  )}

Согласованные (последовательные) компоненты

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

У каждого подхода имеются свои преимущества и недостатки.

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

Названия компонентов

Всегда именуйте компоненты. Это помогает анализировать трассировку стека ошибки при использовании инструментов разработчика React.

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

//  Этого следует избегатьexport default () => <form>...</form>//  Именуйте свои функцииexport default function Form() {  return <form>...</form>}

Вспомогательные функции

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

Это уменьшает шум компонента в нем остается только самое необходимое.

//  Этого следует избегатьfunction Component({ date }) {  function parseDate(rawDate) {    ...  }  return <div>Сегодня {parseDate(date)}</div>}//  Размещайте вспомогательные функции перед компонентомfunction parseDate(date) {  ...}function Component({ date }) {  return <div>Сегодня {parseDate(date)}</div>}

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

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

//  Вспомогательные функции не должны "читать" значения из состояния компонентаexport default function Component() {  const [value, setValue] = useState('')  function isValid() {    // ...  }  return (    <>      <input        value={value}        onChange={e => setValue(e.target.value)}        onBlur={validateInput}      />      <button        onClick={() => {          if (isValid) {            // ...          }        }}      >        Отправить      </button>    </>  )}//  Поместите их снаружи и передавайте им только необходимые значенияfunction isValid(value) {  // ...}export default function Component() {  const [value, setValue] = useState('')  return (    <>      <input        value={value}        onChange={e => setValue(e.target.value)}        onBlur={validateInput}      />      <button        onClick={() => {          if (isValid(value)) {            // ...          }        }}      >        Отправить      </button>    </>  )}

Статическая (жесткая) разметка

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

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

//  Статическую разметку сложно поддерживатьfunction Filters({ onFilterClick }) {  return (    <>      <p>Жанры книг</p>      <ul>        <li>          <div onClick={() => onFilterClick('fiction')}>Научная фантастика</div>        </li>        <li>          <div onClick={() => onFilterClick('classics')}>            Классика          </div>        </li>        <li>          <div onClick={() => onFilterClick('fantasy')}>Фэнтези</div>        </li>        <li>          <div onClick={() => onFilterClick('romance')}>Романы</div>        </li>      </ul>    </>  )}//  Используйте циклы и объекты с настройкамиconst GENRES = [  {    identifier: 'fiction',    name: 'Научная фантастика',  },  {    identifier: 'classics',    name: 'Классика',  },  {    identifier: 'fantasy',    name: 'Фэнтези',  },  {    identifier: 'romance',    name: 'Романы',  },]function Filters({ onFilterClick }) {  return (    <>      <p>Жанры книг</p>      <ul>        {GENRES.map(genre => (          <li>            <div onClick={() => onFilterClick(genre.identifier)}>              {genre.name}            </div>          </li>        ))}      </ul>    </>  )}

Размеры компонентов

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

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

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

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

Комментарии в JSX

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

function Component(props) {  return (    <>      {/* Если пользователь оформил подписку, мы не будем показывать ему рекламу */}      {user.subscribed ? null : <SubscriptionPlans />}    </>  )}

Предохранители

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

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

function Component() {  return (    <Layout>      <ErrorBoundary>        <CardWidget />      </ErrorBoundary>      <ErrorBoundary>        <FiltersWidget />      </ErrorBoundary>      <div>        <ErrorBoundary>          <ProductList />        </ErrorBoundary>      </div>    </Layout>  )}

Деструктуризация пропов

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

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

//  Не повторяйте "props" в каждом компонентеfunction Input(props) {  return <input value={props.value} onChange={props.onChange} />}//  Деструктурируйте и используйте значения в явном видеfunction Component({ value, onChange }) {  const [state, setState] = useState('')  return <div>...</div>}

Количество пропов

Ответ на вопрос о количестве пропов является очень субъективным. Количество пропов, передаваемых в компонент, коррелируется с количеством используемых компонентом переменных. Чем больше пропов передается в компонент, тем выше его ответственность (имеется ввиду количество решаемых компонентом задач).

Большое количество пропов может свидетельствовать о том, что компонент делает слишком много.

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

Обратите внимание: чем больше пропов принимает компонент, чем чаще он перерисовывается.

Передача объекта вместо примитивов

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

//  Не передавайте значения по одному<UserProfile  bio={user.bio}  name={user.name}  email={user.email}  subscription={user.subscription}/>//  Вместо этого, используйте объект<UserProfile user={user} />

Условный рендеринг

В некоторых случаях использование коротких вычислений (оператора логическое И &&) для условного рендеринга может привести к отображению 0 в UI. Во избежание этого используйте тернарный оператор. Единственным недостатком такого подхода является чуть большее количество кода.

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

//  Старайтесь избегать коротких вычисленийfunction Component() {  const count = 0  return <div>{count && <h1>Сообщения: {count}</h1>}</div>}//  Вместо этого, используйте тернарный операторfunction Component() {  const count = 0  return <div>{count ? <h1>Сообщения: {count}</h1> : null}</div>}

Вложенные тернарные операторы

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

//  Вложенные тернарники сложно читатьisSubscribed ? (  <ArticleRecommendations />) : isRegistered ? (  <SubscribeCallToAction />) : (  <RegisterCallToAction />)//  Извлеките их в отдельный компонентfunction CallToActionWidget({ subscribed, registered }) {  if (subscribed) {    return <ArticleRecommendations />  }  if (registered) {    return <SubscribeCallToAction />  }  return <RegisterCallToAction />}function Component() {  return (    <CallToActionWidget      subscribed={subscribed}      registered={registered}    />  )}

Списки

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

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

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

//  Не объединяйте циклы с другой разметкойfunction Component({ topic, page, articles, onNextPage }) {  return (    <div>      <h1>{topic}</h1>      {articles.map(article => (        <div>          <h3>{article.title}</h3>          <p>{article.teaser}</p>          <img src={article.image} />        </div>      ))}      <div>Вы находитесь на странице {page}</div>      <button onClick={onNextPage}>Дальше</button>    </div>  )}//  Извлеките список в отдельный компонентfunction Component({ topic, page, articles, onNextPage }) {  return (    <div>      <h1>{topic}</h1>      <ArticlesList articles={articles} />      <div>Вы находитесь на странице {page}</div>      <button onClick={onNextPage}>Дальше</button>    </div>  )}

Пропы по умолчанию

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

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

//  Не определяйте значения пропов по умолчанию за пределами функцииfunction Component({ title, tags, subscribed }) {  return <div>...</div>}Component.defaultProps = {  title: '',  tags: [],  subscribed: false,}//  Поместите их в список аргументовfunction Component({ title = '', tags = [], subscribed = false }) {  return <div>...</div>}

Вложенные функции рендеринга

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

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

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

//  Не вкладывайте одни компоненты в другиеfunction Component() {  function renderHeader() {    return <header>...</header>  }  return <div>{renderHeader()}</div>}//  Извлекайте их в отдельные компонентыimport Header from '@modules/common/components/Header'function Component() {  return (    <div>      <Header />    </div>  )}

Управление состоянием


Редукторы

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

В комбинации с контекстом и TypeScript, useReducer() может быть очень мощным. К сожалению, его используют не очень часто. Люди предпочитают применять специальные библиотеки.

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

//  Не используйте слишком много частей состоянияconst TYPES = {  SMALL: 'small',  MEDIUM: 'medium',  LARGE: 'large'}function Component() {  const [isOpen, setIsOpen] = useState(false)  const [type, setType] = useState(TYPES.LARGE)  const [phone, setPhone] = useState('')  const [email, setEmail] = useState('')  const [error, setError] = useSatte(null)  return (    // ...  )}//  Унифицируйте их с помощью редуктораconst TYPES = {  SMALL: 'small',  MEDIUM: 'medium',  LARGE: 'large'}const initialState = {  isOpen: false,  type: TYPES.LARGE,  phone: '',  email: '',  error: null}const reducer = (state, action) => {  switch (action.type) {    ...    default:      return state  }}function Component() {  const [state, dispatch] = useReducer(reducer, initialState)  return (    // ...  )}

Хуки против HOC и рендер-пропов

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

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

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

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

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

//  Не используйте рендер-пропыfunction Component() {  return (    <>      <Header />        <Form>          {({ values, setValue }) => (            <input              value={values.name}              onChange={e => setValue('name', e.target.value)}            />            <input              value={values.password}              onChange={e => setValue('password', e.target.value)}            />          )}        </Form>      <Footer />    </>  )}//  Используйте хукиfunction Component() {  const [values, setValue] = useForm()  return (    <>      <Header />        <input          value={values.name}          onChange={e => setValue('name', e.target.value)}        />        <input          value={values.password}          onChange={e => setValue('password', e.target.value)}        />      <Footer />    </>  )}

Библиотеки для получения данных

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

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

Работать с внешними данными еще легче, если вы используете GraphQL-клиент наподобие Apollo. Он реализует концепцию состояния клиента из коробки.

Библиотеки для управления состоянием

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

Ментальные модели компонентов


Контейнер и представитель

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

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

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

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

Компоненты с состоянием и без

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

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

Например, компонент <Form/> должен содержать данные формы. Компонент <Input/> должен получать значения и вызывать коллбеки. Компонент <Button/> должен уведомлять форму о желании пользователя отправить данные на обработку и т.д.

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

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

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


Группировка по маршруту/модулю

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

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

//  Не группируйте компоненты по деталям технической реализации containers|    Dashboard.jsx|    Details.jsx components|    Table.jsx|    Form.jsx|    Button.jsx|    Input.jsx|    Sidebar.jsx|    ItemCard.jsx//  Группируйте их по модулю/домену modules|    common|   |    components|   |   |    Button.jsx|   |   |    Input.jsx|    dashboard|   |    components|   |   |    Table.jsx|   |   |    Sidebar.jsx|    details|   |    components|   |   |    Form.jsx|   |   |    ItemCard.jsx

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

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

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

Общие модули

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

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

Абсолютные пути

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

//  Не используйте относительные путиimport Input from '../../../modules/common/components/Input'//  Абсолютный путь никогда не изменитсяimport Input from '@modules/common/components/Input'

Я использую префикс "@" в качестве индикатора внутреннего модуля, но я также видел примеры использования символа "~".

Оборачивание внешних компонентов

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

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

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

//  Не импортируйте зависимости напрямуюimport { Button } from 'semantic-ui-react'import DatePicker from 'react-datepicker'//  Повторно экспортируйте их из внутреннего модуляimport { Button, DatePicker } from '@modules/common/components'

Один компонент одна директория

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

Хорошей практикой является создание файла index.js для повторного экспорта компонента. Это позволяет не изменять пути импорта и избежать дублирования названия компонента import Form from 'components/UserForm/UserForm'. Однако, не следует помещать код компонента в файл index.js, поскольку это сделает невозможным поиск компонента по названию вкладки в редакторе кода.

//  Не размещайте все файлы в одном месте components     Header.jsx     Header.scss     Header.test.jsx     Footer.jsx     Footer.scss     Footer.test.jsx//  Помещайте их в собственные директории components     Header         index.js         Header.jsx         Header.scss         Header.test.jsx     Footer         index.js         Footer.jsx         Footer.scss         Footer.test.jsx

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


Преждевременная оптимизация

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

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

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

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

Размер сборки

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

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

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

Повторный рендеринг коллбеки, массивы и объекты

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

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

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

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

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


Тестирование при помощи снимков

Однажды, я столкнулся с интересной проблемой при проведении snapshot-тестирования: сравнение new Date() без аргумента с текущей датой всегда возвращало false.

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

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

Тестирование корректного рендеринга

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

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

Тестирование состояния и событий

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

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

Тестирование пограничных случаев

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

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

Интеграционное тестирование

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

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

Стилизация


CSS-в-JS

Это очень спорный вопрос. Лично я предпочитаю использовать библиотеки вроде Styled Components или Emotion, позволяющие писать стили в JavaScript. Одним файлом меньше. Не надо думать о таких вещах, как, например, названия классов.

Структурной единицей React является компонент, так что техника CSS-в-JS или, точнее, все-в-JS, на мой взгляд, является наиболее предпочтительной.

Обратите внимание: другие подходы к стилизации (SCSS, CSS-модули, библиотеки со стилями типа Tailwind) не являются неправильными, но я все же рекомендую использовать CSS-в-JS.

Стилизованные компоненты

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

Тем не менее, когда стилизованных компонентов очень много, имеет смысл вынести их в отдельный файл. Я видел использование такого подхода в некоторых открытых проектах вроде Spectrum.

Получение данных


Библиотеки для работы с данными

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

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

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

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

Благодарю за внимание и хорошего начала рабочей недели.
Подробнее..

TypeScript Раскладываем tsconfig по полочкам. Часть 1

13.02.2021 12:07:16 | Автор: admin

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

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

Если открыть официальный референс tsconfig, то там будет полный список всех настроек, разделённых по группам. Однако, это не даёт понимания, с чего начать и что из данного обширного списка опций обязательно, а на что можно не обращать внимания (по крайней мере до поры до времени). Плюс, иногда опции сгруппированы по некому техническому, а не логическому смыслу. Например, некоторые флаги проверок можно найти в группе Strict Checks, некоторые в Linter Checks, а другие в Advanced. Это не всегда удобно для понимания.

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

Структура tsconfig

Рассмотрим структуру и некоторые особенности конфига.

  • tsconfig.json состоит из двух частей. Какие-то опции необходимо указывать в root, а какие-то в compilerOptions

  • tsconfig.json поддерживает комментарии. Такие IDE как WebStorm и Visual Studio Code знают об этом не выделяют комментарии как синтаксическую ошибку

  • tsconfig.json поддерживает наследование. Опции можно разделить по некоторому принципу, описать их в разных файлах и объединить с помощью специальной директивы

Это болванка нашего tsconfig.json:

{  // extends позволяет обогатить опции другими опциями из указанного файла  // файлом tsconfig-checks.json займёмся во второй части статьи  "extends": "./tsconfig-checks.json",  // в корне конфига находятся project-specific опции  "compilerOptions": {    // здесь все настройки, связанные с компилятором  }}

К root опциям относится только следующие: extends, files, include, exclude, references, typeAcquisition. Из них мы будем рассматривать первые 4. Все остальные опции размещаются в compilerOptions.

Иногда в root секции конфига можно встретить такие опции как compileOnSave и ts-node. Эти опции не являются официальными и используются IDE для своих целей.

Секция root

extends

Type: string | false, default: false.

Указывает путь к файлу из которого нужно унаследовать опции. По большей части, служит инструментом упорядочивания. Можно разделить опции по некой логике, чтобы они не смешивались. Например, вынести настройки строгости в отдельный файл, как в примере болванки конфига. Однако, учитывая поддержку комментариев в tsconfig.json это можно сделать проще:

{  "compilerOptions": {    // блок базовых настроек    // блок настроек строгости  }}

Рассмотрим другой use-case, где комментариями отделаться не получится. Если необходимо создать production и development конфиги. Так бы мог выглядеть tsconfig-dev.json версия конфига:

{  "extends": "./tsconfig.json",  "compilerOptions": {    // переопределяем настройки, которые нужны только для dev режима    "sourceMap": true,    "watch": true  }}

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

Если вы решите использовать эту опцию. То увидеть итоговую, объединённую версию конфига поможет команда tsc --showConfig.

files

Type: string[] | false, default: false, связана с include.

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

{  "compilerOptions": {},  "files": [    "core.ts",    "app.ts"  ]}

Данная опция подходит лишь для совсем маленьких проектов из нескольких файлов.

include

Type string[], default: зависит от значения files, связана с exclude.

Если опция files не указана, то TypeScript будет использовать эту директиву для поиска компилируемых файлов. Если include так же не указана, то её значение будет неявно объявлено как ["**/*"]. Это означает, что поиск файлов будет осуществляться во всех папках и их подпапках. Такое поведение не оптимально, поэтому в целях производительности лучше всегда указывать конкретные пути. Можно прописывать как пути к конкретным файлам, так и паттерны путей.

{  "compilerOptions": {},  "include": [    "src/**/*",    "tests/**/*"  ]}

Если паттерны не указывают конкретных расширений, то TypeScript будет искать файлы с расширениями .ts, .tsx, and .d.ts. А если включен флаг allowJs, то ещё .js и .jsx.

Следующие форматы записей делают одно и тоже src, ./src, src/**/*. Я предпочитаю вариант ./src.

Технически, используя опции include и exclude, TypeScript сгенерирует список всех подходящих файлов и поместит их в files. Это можно наблюдать если выполнить команду tsc --showConfig.

exclude

Type: string[], default: ["nodemodules", "bowercomponents", "jspm_packages"].

Директива служит для того, чтобы исключать некоторые лишние пути или файлы, которые включились директивой include. По умолчанию, опция имеет значение путей пакетных менеджеров npm, bower и jspm, так как модули в них уже собраны. Помимо этого, TypeScript будет так же игнорировать папку из опции outDir, если она указана. Это папка, куда помещаются собранные артефакты сборки. Логично, что их нужно исключить. Если добавить свои значения в эту опцию, то необходимо не забыть восстановить умолчания. Так как пользовательские значения не объединяются со значениями по умолчанию. Другими словами, необходимо вручную указать корень модулей своего пакетного менеджера.

{  "compilerOptions": {},  "exclude": [    "node_modules",    "./src/**/*.spec.ts"  ]}

Опция exclude не может исключить файлы, указанные через files.

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

Секция compilerOptions

target

Type: string, default: ES3, влияет на опции lib, module.

Версия стандарта ECMAScript, в которую будет скомпилирован код. Здесь большой выбор: ES3, ES5, ES6 (он же ES2015), ES2016, ES2017, ES2018, ES2019, ES2020, ESNext. Для backend приложений/пакетов подойдёт ES6, если рассчитываете только на современные версии Node.js и ES5, если хотите поддержать более старые версии. На данный момент стандарт ES6, с небольшими оговорками, поддерживается 97.29% браузеров. Так что для frontend приложений ситуация аналогичная.

module

Type: string, default: зависит от target, влияет на опцию moduleResolution.

Модульная система, которую будет использовать ваше собранное приложение. На выбор: None, CommonJS, AMD, System, UMD, ES6, ES2015, ES2020 or ESNext. Для backend приложений/пакетов подойдёт ES6 или CommonJS в зависимости от версий Node.js, которые хотите поддерживать. Для frontend приложений под современные браузеры также подходит ES6. А для поддержки более старых браузеров и для изоморфных приложений, определённо стоит выбрать UMD.

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

moduleResolution

Type: string, default: зависит от module.

Стратегия, которая будет использоваться для импорта модулей. Здесь всего две опции: node и classic. При этом classic в 99% не будет использоваться, так как это legacy. Однако, я специально упомянул этот флаг, так как он меняется в зависимости от предыдущего флага. При изменении значения module режим moduleResolution может переключиться на classic и в консоли начнут появляться сообщения об ошибках на строчках с импортами.

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

lib

Type: string[], default: зависит от target.

В зависимости от того какой target установлен в конфиге, TypeScript подключает тайпинги (*.d.ts-файлы) для поддержки соответствующих спецификаций. Например, если ваш target установлен в ES6, то TypeScript подключит поддержку array.find и прочих вещей, которые есть в стандарте. Но если target стоит ES5, то использовать метод массива find нельзя, так как его не существует в этой версии JavaScript. Можно подключить полифилы. Однако, для того, чтобы TypeScript понял, что теперь данную функциональность можно использовать, необходимо подключить необходимые тайпинги в секции lib. При этом, можно подключить как весь стандарт ES2015, так и его часть ES2015.Core (только методы find, findInex и т.д.).

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

Для --target ES5 подключаются: DOM, ES5, ScriptHostДля --target ES6: DOM, ES6, DOM.Iterable, ScriptHost

Как только вы что-либо добавляете в lib умолчания сбрасываются. Необходимо руками добавить то, что нужно, например DOM:

{  "compilerOptions": {    "target": "ES5",    "lib": [      "DOM",      "ES2015.Core"    ]  }}

outDir

Type: string, default: равняется корневой директории.

Конечная папка, куда будут помещаться собранные артефакты. К ним относятся: .js, .d.ts, и .js.map файлы. Если оставить не указывать значение для данной опции, то все вышеуказанные файлы будут повторять структуру исходных файлов в корне вашего проекта. В таком случае будет сложно удалять предыдущие билды и описывать .gitignore файлы. Да и кодовая база будет похожа на свалку. Советую складывать все артефакты в одну папку, которую легко удалить или заигнорировать системой контроля версий.

Если оставить опцию outDir пустой:

 module    core.js    core.ts index.js index.ts

Если указать outDir:

 dist    module   |    core.js    index.js module    core.ts index.ts

outFile

Type: string, default: none.

Судя по описанию, данная опция позволяет объединить все файлы в один. Кажется, что бандлеры вроде webpack больше не нужны Однако, опция работает только если значение module указано None, System, or AMD. К огромному сожалению, опция не будет работать с модулями CommonJS or ES6. Поэтому скорее всего использовать outFile не придётся. Так как опция выглядит максимально привлекательно, но работает не так как ожидается, я решил предупредить вас об этом гигантском подводном камне.

allowSyntheticDefaultImports

Type: boolean, default: зависит от module или esModuleInterop.

Если какая-либо библиотека не имеет default import, лоадеры вроде ts-loader или babel-loader автоматически создают их. Однако, d.ts-файлы библиотеки об этом не знают. Данный флаг говорит компилятору, что можно писать следующим образом:

// вместо такого импортаimport * as React from 'react';// можно писать такойimport React from 'react';

Опция включена по умолчанию, если включен флаг esModuleInterop или module === "system".

esModuleInterop

Type: boolean, default: false.

За счёт добавления болерплейта в выходной код, позволяет импортировать CommonJS пакеты как ES6.

// библиотека moment экспортируется только как CommonJS// пытаемся импортировать её как ES6import Moment from 'moment';// без флага esModuleInterop результат undefinedconsole.log(Moment);// c флагом результат [object Object]console.log(Moment);

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

alwaysStrict

Type: boolean, default: зависит от strict.

Компилятор будет парсить код в strict mode и добавлять use strict в выходные файлы.

По умолчанию false, но если включен флаг strict, то true.

downlevelIteration

Type: boolean, default: false.

Спецификация ES6 добавила новый синтаксис для итерирования: цикл for / of, array spread, arguments spread. Если код проекта преобразовывается в ES5, то конструкция с циклом for / of будет преобразована в обычный for:

// код es6const str = 'Hello!';for (const s of str) {  console.log(s);}
// код es5 без downlevelIterationvar str = "Hello!";for (var _i = 0, str_1 = str; _i &lt; str_1.length; _i++) {  var s = str_1[_i];  console.log(s);}

Однако, некоторые символы, такие как emoji кодируются с помощью двух символов. Т. е. такое преобразование в некоторых местах будет работать не так, как ожидается. Включенный флаг downlevelIteration генерирует более многословный и более "правильный", но менее производительный код. Код получается действительно очень большим, поэтому не буду занимать место на экране. Если интересно посмотреть пример, то перейдите в playground и выберете в настройках target -> es5, downlevelIteration -> true.

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

forceConsistentCasingInFileNames

Type: boolean, default: false.

Включает режим чувствительности к регистру (case-sensitive) для импорта файлов. Таким образом, даже в case-insensitive файловых системах при попытке сделать импорт import FileManager from './FileManager.ts', если файл в действительности называется fileManager.ts, приведёт к ошибке. Перестраховаться лишний раз не повредит. TypeScript - это про строгость.

Опции секции compilerOptions, которые нужны не в каждом проекте

declaration

Type: boolean, default: false.

С помощью включения данного флага, помимо JavaScript файлов, к ним будут генерироваться файлы-аннотации, известные как d.ts-файлы или тайпинги. Благодаря тайпингам становится возможным определение типов для уже скомпилированных js файлов. Другими словами код попадает в js, а типы в d.ts-файлы. Это полезно в случае, например, если вы публикуете свой пакет в npm. Такой библиотекой смогут пользоваться разработчики, которые пишут как на чистом JavaScript, так и на TypeScript.

declarationDir

Type: string, default: none, связан с declaration.

По умолчанию тайпинги генерируются рядом с js-файлами. Используя данную опцию можно перенаправить все d.ts-файлы в отдельную папку.

emitDeclarationOnly

Type: boolean, default: false, связан с declaration.

Если по какой-то причине вам нужны только d.ts-файлы, то включение данного флага предотвратит генерацию js-файлов.

allowJs

Type: boolean, default: false.

Портировать ваш JavaScript проект на TypeScript поможет данный флаг. Активировав allowJs TypeScript компилятор будет обрабатывать не только ts файлы, но и js. Нет нужды полностью мигрировать проект, прежде чем продолжить его разработку. Можно это делать файл за файлом, просто меня расширение и добавляя типизацию. А новый функционал сразу можно писать на TypeScript.

checkJs

Type: boolean, default: false, связан с allowJs.

TypeScript будет проверять ошибки не только в ts, но и в js-файлах. Помимо встроенных тайпингов для языковых конструкций JavaScript, TS-компилятор так же умеет использовать jsDoc для анализа файлов. Я предпочитаю не использовать этот флаг, а наводить порядок в коде в момент его типизации. Однако, если в вашем проекте хорошее покрытие кода jsDoc, стоит попробовать.

С версии 4.1 при включении checkJs, флаг allowJs включается автоматически.

experimentalDecorators и emitDecoratorMetadata

Type: boolean, default: false.

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

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

Для работы emitDecoratorMetadata необходимо подтянуть в проект библиотеку reflect-metadata.

resolveJsonModule

Type: boolean, default: false.

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

// необходимо указывать расширение .jsonimport config from './config.json'

jsx

Type: string, default: none.

Если проект использует React, необходимо включить поддержку jsx. В подавляющем большинстве случаев будет достаточно опций react или react-native. Так же есть возможность оставить jsx-код нетронутым с помощью опции preserve или использовать кастомные преобразователи react-jsx и react-jsx.

Завершение первой части

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

Подробнее..

Перевод 6 лучших практик React в 2021 году

11.03.2021 16:14:30 | Автор: admin

Для будущих студентов курса "React.js Developer" подготовили перевод материала.

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


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

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

  1. Используйте event.target.name для обработчиков событий.

  2. Как избежать ручной привязки обработчиков событий к this?

  3. Используйте React hooks для обновления состояния.

  4. Кэширование затратных операций с useMemo.

  5. Разделение функций на отдельные элементы для улучшения качества кода.

  6. Как создавать собственные хуки в React?

#1: Используйте имя обработчика события

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

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

Мы можем установить свойство имени на поле ввода и получить доступ к нему из обработчика события. Это значение позволяет нам использовать один обработчик входных данных для событий onChange.

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

export default class App extends React.Component {    constructor(props) {        super(props);        this.state = {            item1: "",            item2: "",            items: "",            errorMsg: ""        };        this.onFirstInputChange = this.onFirstInputChange.bind(this);        this.onSecondInputChange = this.onSecondInputChange.bind(this);    }    onFirstInputChange(event) {        const value = event.target.value;        this.setState({            item1: value        });    }    onSecondInputChange(event) {        const value = event.target.value;        this.setState({            item2: value        });    }    render() {        return (            <div>                <div className="input-section">                    {this.state.errorMsg && (                        <p className="error-msg">{this.state.errorMsg}</p>                    )}                    <input                        type="text"                        name="item1"                        placeholder="Enter text"                        value={this.state.item1}                        onChange={this.onFirstInputChange}                    />                    <input                        type="text"                        name="item2"                        placeholder="Enter more text"                        value={this.state.item2}                        onChange={this.onSecondInputChange}                    />                </div>            </div>      );    }}

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

Давайте рассмотрим эту ситуацию по-другому, используя поле имени. Мы можем получить доступ к этому значению через свойство event.target.name. Теперь создадим одну функцию, которая сможет обрабатывать оба события одновременно. Таким образом, мы можем удалить обе функции onFirstInputChange и onSecondInputChange.

onInputChange = (event) => {  const name = event.target.name;  const value = event.target.value;  this.setState({    [name]: value  });};

Полегче, да? Конечно, вам часто требуется дополнительная проверка данных, которые вы сохраняете в своем состоянии (state). Вы можете использовать утверждение switch для добавления собственных правил валидации для каждого введенного значения.

#2: Избегайте ручной привязки (binding) this

Скорее всего, вы знаете, что React не сохраняет привязку this при прикреплении обработчика к событию onClick или onChange. Поэтому мы должны связать это вручную. Почему мы связываем *this*? Мы хотим связать this обработчика события (event handler) с экземпляром компонента (component instance), чтобы не потерять его контекст, когда мы передадим его в качестве обратного вызова.

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

class Button extends Component {  constructor(props) {    super(props);    this.state = { clicked: false };    this.handleClick = this.handleClick.bind(this);  }    handleClick() {    this.props.setState({ clicked: true });  }    render() {    return <button onClick={this.handleClick}>Click me!</button>;  );}

Однако привязка больше не требуется, так как команда CLI createe-react-app использует @babel/babel-plugin-transform-class-properties плагин версии >=7 и babel/plugin-proposal class-properties плагин версии <7.

Примечание: Вы должны изменить синтаксис обработчика событий (event handler) на синтаксис функции стрелок (arrow function).

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

class Button extends Component {  constructor(props) {    super(props);    this.state = { clicked: false };  }    handleClick = () => this.setState({clicked: true });  render() {    return <button onClick={this.handleClick}>Click me!</button>;  );}

Это так просто! Вам не нужно беспокоиться о привязке функций в вашем конструкторе.

#3: Используйте React hooks чтобы обновить ваше состояние

Начиная с версии 16.8.0, теперь можно использовать методы состояния и жизненного цикла (state and lifecycle methods) внутри функциональных компонентов с помощью React Hooks. Другими словами, мы можем писать более читаемый код, который также намного проще в обслуживании.

Для этого мы будем использовать useState hook. Для тех, кто не знает, что такое hook и зачем его использовать вот краткое определение из React documentation..

Что такое Hook? Hook это специальная функция, которая позволяет подключиться к функциям React. Например, useState это Hook, который позволяет добавлять состояние React к функциональным компонентам.

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

Во-первых, давайте посмотрим, как мы обновляем состояние с помощью setState hook.

this.setState({    errorMsg: "",    items: [item1, item2]});

А теперь давайте воспользуемся useState hook. Нам нужно импортировать этот hook из react библиотеки. Теперь мы можем объявить новые переменные состояния и передать им начальное значение. Мы будем использовать деструктуризацию,чтобы извлечь переменную для получения значения и еще одну для установки значения (это будет функция).Рассмотрим, как это можно сделать в приведенном выше примере.

import React, { useState } from "react";const App = () => {  const [items, setIems] = useState([]);  const [errorMsg, setErrorMsg] = useState("");};export default App;

Теперь вы можете получить прямой доступ как к константам items, так и к errorMsg в вашем компоненте.

Далее, мы можем обновить состояние внутри такой функции:

import React, { useState } from "react";const App = () => {  const [items, setIems] = useState([]);  const [errorMsg, setErrorMsg] = useState("");  return (    <form>        <button onClick={() => setItems(["item A", "item B"])}>          Set items        </button>    </form>  );};export default App;

Вот как ты можешь использовать state hooks (хуки состояния).

#4: Кэширование затратных операций с помощью useMemo

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

Поэтому мы можем использовать hook useMemo для запоминания вывода при передаче тех же параметров в мемоизуемую функцию (memoized function). Hook useMemo принимает функцию и вводимые параметры для запоминания. React ссылается на это как на массив зависимостей. Каждое из значений, упоминаемое внутри функции, также должно появиться в массиве зависимостей.

Вот простой, абстрактный пример. Мы передаем два параметра a и b затратной функции. Так как функция использует оба параметра, то мы должны добавить их в массив зависимостей для нашего useMemo hook.

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

#5: Разделение функций на чистые функции (Pure functions, PF) для улучшения качества кода

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

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

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

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

- freeCodeCamp

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

function ascSort (a, b) {  return a < b ? -1 : (b > a ? 1 : 0);}function descSort (a, b) {  return b < a ? -1 : (a > b ? 1 : 0);}

#6: Создайте собственные React Hooks

Мы научились использовать useState и useMemo React hooks. Тем не менее, React позволяет вам устанавливать свои собственные React hooks, чтобы сформировать необходимую логику и делать компоненты более читабельными.

Мы можем задать пользовательские React hooks, начиная с ключевого слова use, как и все другие React hooks. Это выгодно, когда вы хотите распределить логику между различными функциями. Вместо копирования функции, мы можем определить логику как React hook и повторно использовать ее в других функциях.

Вот пример React-компонента, который обновляет состояние, когда размер экрана становится меньше 600 пикселей. Если это происходит, переменная isScreenSmall устанавливается в true. В противном случае переменная устанавливается в false. Мы используем событие resize из объекта окна для обнаружения изменения размера экрана.

const LayoutComponent = () => {  const [onSmallScreen, setOnSmallScreen] = useState(false);  useEffect(() => {    checkScreenSize();    window.addEventListener("resize", checkScreenSize);  }, []);  let checkScreenSize = () => {    setOnSmallScreen(window.innerWidth < 768);  };  return (    <div className={`${onSmallScreen ? "small" : "large"}`}>      <h1>Hello World!</h1>    </div>  );};

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

import { useState, useEffect } from "react";const useSize = () => {  const [isScreenSmall, setIsScreenSmall] = useState(false);  let checkScreenSize = () => {    setIsScreenSmall(window.innerWidth < 600);  };  useEffect(() => {    checkScreenSize();    window.addEventListener("resize", checkScreenSize);    return () => window.removeEventListener("resize", checkScreenSize);  }, []);  return isScreenSmall;};export default useSize;

Мы можем создать пользовательский React hook, путем использования логики с функцией use.

В этом примере мы назвали пользовательский React hook useSize. Теперь мы можем импортировать useSize hook в любом месте, где нам это необходимо.

React custom hooks в этом нет ничего нового. Вы обертываете логику с функцией и даете ей имя, которое начинается с use. Она действует как обычная функция. Однако, следуя правилам "use", вы говорите всем, кто её импортирует, что это hook (хук). Более того, поскольку это hook, вы должны структурировать его так, чтобы следовать rules of hooks (правилам хуков).

Вот как сейчас выглядит наш компонент. Код становится намного чище!

import React from 'react'import useSize from './useSize.js'const LayoutComponent = () => {  const onSmallScreen = useSize();  return (    <div className={`${onSmallScreen ? "small" : "large"}`}>      <h1>Hello World!</h1>    </div>  );}

Бонусный совет: Мониторинг фронтенда

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

Вот и все! Эта статья научила вас шести методикам улучшения читабельности и качества кода.


Узнать подробнее о курсе "React.js Developer".

Посмотреть открытый вебинар на тему ReactJS: быстрый старт. Сильные и слабые стороны.

Подробнее..

Системный гайд по созданию White Label android-приложений

07.02.2021 14:05:43 | Автор: admin

Как написать код один раз, а продать 20 мобильных приложений? Мы нашли ответ путём проб и факапов и разложили опыт по пунктам: из статьи вы узнаете, как безболезненно реализовать White Label android-проект.

Greetings and salutations! По работе я однажды получил крутую задачу по разработке White Label android-приложения. Изучил достижения коллег в этой области и нашёл только:

  • входные гайды (раз, два, три, etc) о механизмах, но без промышленного дизайна;

  • статьи, в которых освещены узкие аспекты задачи (раз, два, etc).

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

1 Ставим задачу

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

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

Бюджет ограничен... фичи типовые... да здравствует конструктор приложений! Или White Label продукт? Пока отложим термины и опишем задачу: генерировать приложения из единой кодовой базы, каждое с дизайном под бренд клиента и только нужными ему фичами.

Задача: создавать приложения для разных клиентов из единой кодовой базы

1.1 Визуализируем решение

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

У каждого бренда своя программа лояльности, например в SEPHORA накапливаются бонусные баллы и процент скидки, а в Пятёрочке только баллы. В приложениях это выглядит так:

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

Как реализовать такой проект без боли? Прочитайте статью и найдёте ответ.

1.2 Детализируем требования

Разложим видение по полочкам: как в ТЗ, но проще.

Функциональные требования

  1. Реализовать общие модули фичей:

    • новости клиент узнаёт об акциях и жизни сети магазинов;

    • лояльность получает дисконтную карту, узнаёт баланс, пробивает на кассе;

    • ...

  2. Задавать отдельно для каждого приложения:

    • наборы фичей, чтобы выбирать сами модули и настраивать их параметры;

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

Нефункциональные

  • у приложений должен быть общий код;

  • настройка нового приложения меньше четырёх часов разработчика;

  • архитектура должна упрощать расширение модулей и поддержку от 10 до 100 приложений.

1.3 Что пилим то? Конструктор? White Label?

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

  1. Что даёт конструктор/платформа:

    • универсальный сервис сборки приложений из готовых компонентов;

    • например, AppGyver dragndrop вёрстка, программирование на низкоуровневых фичах (открыть экран, сделать фото);

    • творим что угодно от приложений по покупке золота до приёмки грузов.

  2. Что даёт White Label:

    • конструктор для конкретного типа приложений, например для такси;

    • ребрендинг под клиента и настройка высокоуровневых фич (новости, профиль)

Наш фокус на системах лояльности. Значит, делаем White Label. Гуглим white label android development и находим то, что нужно.

2 Проектируем и воплощаем

Строим системную схему White Label приложения

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

и получим четыре жирные проблемы:

  1. Как шарить кодовую базу между приложениями?

  2. Как сделать ребрендинг?

  3. Как задавать конфиги?

  4. Как отключать ненужные модули и настраивать необходимые?

В очередь, проблемы, в очередь!

2.1 Шарим код

Задача одна кодовая база, до 100 приложений. Решение Gradle Product Flavors.

Если вы ещё не знакомы с Gradle Product Flavors, советую почитать документацию или общие статьи. А можно и сразу в контексте White Label: кратко или в формате инструкции

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

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

Главный недостаток. Если вариантов больше 100, то в проекте и конфигах будет тяжело ориентироваться. Но у нас меньше, поэтому ок.

Альтернативы, на мой взгляд, рассматривать нет смысла: решение надёжное, из коробки.

Пример flavors. Допустим, на старте делаем два приложения:

  1. Лояка абстрактная компания;

  2. Ювелирия сеть ювелирных магазинов.

Назовём flavors соответственно loyaka и jewelry. Сразу реализуем best practice конфиг каждого flavor вынесем в отдельный файлик. Зачем? Станет ясно чуть позже.

Пока создадим:

  1. папку project_flavors;

  2. в ней gradle-скрипты flavor_loyaka.gradle, flavor_jewelry.gradle и flavors_common.gradle;

  3. задействуем скрипты в build.gradle уровня app.

Здесь и далее привожу сокращённые примеры из тестового проекта к статье.

flavor_loyaka.gradle

apply from: "$rootDir/project_flavors/flavors_common.gradle"  android {    productFlavors {        loyaka {            dimension APP_DIMENSION            resValue "string", APP_NAME_VAR, 'Лояка'            applicationId BASE_PACKAGE + 'loyaka'        }    }}

flavor_jewelry.gradle

apply from: "$rootDir/project_flavors/flavors_common.gradle"  android {    productFlavors {        jewerly {            dimension APP_DIMENSION            resValue "string", APP_NAME_VAR, 'Ювелирия'            applicationId BASE_PACKAGE + 'jewelry'        }    }}

flavors_common.gradle

android {    ext.DIMENSION_APP = "app"    ext.APP_NAME_VAR = "app_name"    ext.BASE_PACKAGE = "com.livetyping."}

Наконец, задействуем flavors в build.gradle уровня app:

...apply from: "$rootDir/project_flavors/flavor_loyaka.gradle"apply from: "$rootDir/project_flavors/flavor_jewelry.gradle"apply from: "$rootDir/project_flavors/flavors_common.gradle"android {    ...    flavorDimensions APP_DIMENSION}...

2.2 Перекрашиваем

2.2.1 Концепт

У каждого приложения свой бренд, который складывается из:

  • цветовой схемы;

  • шрифтов, картинок, строк;

  • зашитого контента (соглашений, ссылок в соц. сети).

Благодаря flavors тоже решим задачу просто. Загрузим в голову 3 факта:

  1. общие код и ресурсы проекта лежат в папке main;

  2. для gradle main это как дефолтный flavor;

  3. у каждого flavor свои исходники. Например, общие ресурсы лежат в main/res, а специфичные для флэйвора loyaka в loyaka/res;

Что произойдёт, если в main/res и loyaka/res будут картинки с одинаковым именем animal.webp? Возникнет конфликт, и чтобы решить его, Gradle переопределит базовые ресурсы кастомными. Если непонятно, поможет диаграмма:

Слева ресурсы по flavor; справа итоговый APK.Слева ресурсы по flavor; справа итоговый APK.

Задача решена! Уберём дефолтные ресурсы в main, а в конкретных flavor будем переопределять по необходимости.

2.2.2 Best practices

Крайне важно заранее договориться с дизайнерами:

  • ресурсы в приложениях называем одинаково вставляем в проект прямиком из дизайна;

  • тему задаём чётким набором цветов для перекрашивания копируем colors.xml в новый flavor и просто меняем значения.

И, конечно, соблюдаем договорённости, ведь впереди ждут испытания. Например, мы сразу решили, что задаём строгий набор цветов. Однако в очередном приложении цвета не сошлись часть элементов цвета primary в новом дизайне стали accent. Сразу обсудили и изменили дизайн, а ведь могли бы и вставить костыль.

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

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

2.2.3 Пример схемы цветов

Задаём цвета бренда в файле project_styleguide.xml:

<?xml version="1.0" encoding="utf-8"?><resources>    <color name="active">#68b881</color>    <color name="background">#36363f</color>    <color name="disabled">#daede0</color>    <color name="field_dark">#f5f5f5</color>    ...</resources
<?xml version="1.0" encoding="utf-8"?><resources>    <color name="active">#a160d5</color>    <color name="background">#f6ebff</color>    <color name="disabled">#e2c8f6</color>    <color name="field_dark">#f5f5f5</color>    ...</resources>

2.3 Задаём конфиг

2.3.1 Концепт

Фичи настраиваем на двух уровнях:

  1. отключаем ненужные модули;

  2. меняем параметры внутри самих модулей.

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

Упрощённый пример дока в формат модуль-фича-параметры:

  1. Подключаемые модули:

    • лояльность;

    • новости;

  2. Аутентификация:

    • логин пользователя: телефон или email;

    • маска логина.

  3. Карта лояльности:

    • тип штрих-кода: EAN-8, EAN-13, CODE-128.

2.3.2 Пути решения

Как сделать качественный конфиг? Само качество определим так:

  1. удобство работы простота чтения, простота заполнения (в идеале, хотим DSL);

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

Выделим основные пути:

  1. Gradle buildConfigField

    • задаём переменные в gradle скрипте;

    • во время компиляции генерится java класс BuildConfig, переменные доступны как его поля.

  2. JSON

    • json объект в файле;

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

Кратко оценим пути по критериям.

2.3.3 Путь 1. Gradle buildConfigField

Плюсы:

  • удобство создания делаем DSL на минималках: выносим типы и возможные значения параметров в переменные; выявляем синтаксические ошибки на компиляции;

  • простота большинству уже знаком;

  • скорость обращаемся к классу BuildConfig в памяти.

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

Пример переменной на условном DSL:

buildConfigField MAIN_SCREEN_TYPE, MAIN_SCREEN_VAR, MS_SHOPS

2.3.4 Путь 2. JSON

Плюсы:

  • удобство чтения особенно в формате HOCON;

  • удобство создания делаем DSL через JSON Schema, проверяем на ошибки по мере написания;

  • переиспользование шарим между iOS и Android.

Минусы:

  • скорость придётся перед запуском считать из файла или получать с сервера;

  • время на освоение по сравнению с первым вариантом JSON Schema наверняка менее популярна.

2.3.5 Так что же лучше?

Когда делали проект, даже не изучали альтернативы. Сразу сделали через Gradle. На мой взгляд, JSON + Schema его побеждает. Удобство чтения приоритет, при этом удобство создания остаётся на том же уровне, если не лучше. Дополнительная секунда для загрузки файла на общем фоне незначительна.

Сделали конфиг через Gradle, не изучая альтернатив. Но оказалось, что JSON Schema удобнее для чтения и это её главное преимущество.

2.3.6 Best practices для buildConfigField

Если выбрали buildConfigField, то в сыром виде с ним будут проблемы:

  1. чтобы использоватьEnum, придётся указать полный путь к пакету как в типе, так и в значениях;

  2. при изменение имени или типа переменной придётся делать Find & Replace по всем конфигам.

Решение: DSL на минималках. Заводим переменные для названий параметров, а также кастомных типов и вариантов значений. Создаём отдельный gradle-скрипт на каждый модуль. Параметры описываем в формате экран-параметр-переменные. Скрипты кладём в папку business_rules.

Пример: модуль лояльности loyalty_business_rules.gradle:

/*_______________ENTER USER ID________________*//*________User ID________*//*__Variable__*/ext.USER_ID_VAR = "USER_ID"ext.USER_ID_TYPE = "com.example.whitelabelexample.domain.models.UserIdType"/*__Values__*/ext.UI_PHONE = USER_ID_TYPE + ".PHONE"ext.UI_EMAIL = USER_ID_TYPE + ".EMAIL"/*_______________NO CARD________________*//*________Obtain card methods________*//*__Variable__*/ext.OBTAIN_METHODS_VAR = "OBTAIN_CARD_METHODS"ext.OBTAIN_METHODS_ENUM = "com.example.whitelabelexample.domain.models.ObtainCardMethod"ext.OBTAIN_METHODS_TYPE = "java.util.List<" + OBTAIN_METHODS_ENUM + ">"/*__Optional values__*/ext.OM_GENERATE = OBTAIN_METHODS_ENUM + ".GENERATE_VIRTUAL"ext.OM_BIND = OBTAIN_METHODS_ENUM + " .BIND_PHYSICAL"...

UI_PHONE что за UI_? Это сокращение переменной UserId: добавляем префиксы, чтобы избежать коллизий.

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

Пример: flavor_loyaka.gradle:

...loyaka {    ...    /* MAIN SCREEN */    buildConfigField MAIN_SCREEN_TYPE, MAIN_SCREEN_VAR, MS_CARD    /* MODULES */    buildConfigField APP_MODULES_TYPE, APP_MODULES_VAR, list(AM_LOYALTY, AM_SHOWCASE)    /* REGISTRATION */    buildConfigField USER_ID_TYPE, USER_ID_VAR, UI_EMAIL    ...}

flavor_jewelry.gradle:

...jewelry {    ...    /* MAIN SCREEN */    buildConfigField MAIN_SCREEN_TYPE, MAIN_SCREEN_VAR, MS_SHOPS    /* MODULES */    buildConfigField APP_MODULES_TYPE, APP_MODULES_VAR, list(AM_LOYALTY, AM_SHOPS)    /* REGISTRATION */    buildConfigField USER_ID_TYPE, USER_ID_VAR, UI_PHONE    ...}

2.3.7 Получаем доступ к конфигу

Спроектируем решение в контексте Clean Architecture.

Классы конфигов приравниваю к источникам в слое data, ибо они только предоставляют данные. Тогда ui получает параметры посредством domain.

Как сгруппировать параметры по классам? Мы на проекте за группу взяли экран, а зря. С одной стороны, компактные классы, а с другой возникли коллизии, например на экранах ввода номера телефона и подтверждения кода нужна маска номера. Пришлось реализовать считывание из конфига 2 раза.

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

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

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

Пример реализации конфига для второго процесса: фрагмент BuildCardConfig.kt:

class BuildCardConfig : CardConfig {    override fun numberMask(): String = BuildConfig.CARD_NUMBER_MASK    override fun barcodeType(): BarcodeType = BuildConfig.BARCODE_TYPE    override fun obtainmentMethods(): List<ObtainCardMethod> = BuildConfig.OBTAIN_CARD_METHODS    ...}

В итоге получим архитектуру работы с конфигом (диаграмма классов UML; в ui MVVM):

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

2.3.8 Валидируем конфиг

Зачем нужна прослойка в domain? Она же будет пустая! Необязательно. В идеале, хотим защиту от дурака проверку параметров фичей на непротиворечивость. Допустим, дано 2 параметра:

  1. включённые модули;

  2. главный экран.

Если модуль новости и акции выключён, то логично, что главным экраном новости быть не может. Но на уровне Gradle или JSON Schema подобное ограничение сделать нетривиально таким правилам и место в domain.

Например, реализуем описанное условие в GetMainTabUseCase.kt:

class GetMainTabUseCase(    private val mainConfig: MainConfig) {    operator fun invoke(): NavigationTab {        val mainTab = mainConfig.mainTab()        val mainModule = tabsByModules.entries.find { it.value == mainTab }!!.key        val isModuleEnabled = BuildConfig.APP_MODULES.contains(mainModule)        if (isModuleEnabled.not()) {            throw IllegalStateException("Can't use a tab ($mainTab) as main, it's module is disabled   fix config!")        }        return mainTab    }}

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

Альтернатива создавать UseCase только по надобности, но тогда возникает неоднородность: в ui используются одновременно и Config и UseCase. Рискуем использовать параметры, которые требуют валидации, в её обход, и следом за этим растёт вероятность багов.

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

2.4 Настраиваем фичи

2.4.1 Выбираем модули

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

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

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

Рассмотрим главные точки связи модуля с приложением:

  1. Переходы изui боттом навигация, рандомная кнопка, etc;

  2. Реакция на события кастомные (выбран город), платформы (найдена сеть), etc.

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

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

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

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

2.4.2 Настраиваем экраны и бизнес-правила

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

На уровне UseCase берём параметр из нужного класса Config.

GetCardUseCase.kt:

class GetCardUseCase(    private val netRep: CardNetRepository,    private val storageRep: CardStorageRepository,    private val config: CardConfig) {    operator fun invoke(): Card? {        return if (config.isCacheCard()) {            try {                val card = netRep.getCard()                storageRep.save(card)                card            } catch (exception: Exception) {                return storageRep.get()            }        } else {            netRep.getCard()        }    }}

В ui же обращаемся к UseCase на уровне ViewModel или Presenter.

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

Реализация: NoCardViewModel.kt:

class NoCardViewModel(    private val getObtainMethodsUseCase: GetObtainMethodsUseCase,    ...){    private val cardObtainMethods by lazy { getObtainMethodsUseCase() }    val isShowGetVirtualButton by lazy {        cardObtainMethods.contains(ObtainCardMethod.GENERATE_VIRTUAL)    }    val isShowBindPlasticButton by lazy {        cardObtainMethods.contains(ObtainCardMethod.BIND_PHYSICAL)    }    ...}

fragment_nocard.xml:

...<com.google.android.material.button.MaterialButton    android:id="@+id/no_card_bind_plastic_button"    ...    app:isVisible="@{viewmodel.isShowBindPlasticButton}" /><com.google.android.material.button.MaterialButton    android:id="@+id/no_card_get_virtual_button"    ...    app:isVisible="@{viewmodel.isShowGetVirtualButton}" />...

2.4.3 Ещё один трюк

Иногда вариативную вёрстку целесообразнее сделать без конфига.

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

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

3 Подведём итог

Мы успешно спроектировали архитектуру White Label android-проекта, которая соответствует поставленным требованиям, а именно позволяет:

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

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

Горькими уроками поделились, best practices передали. Надеюсь, наш опыт создал цельное представление о создании White Label android-приложений и комфортную отправную точку для вашего проекта.

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

Если возникли вопросы пишите в комменты, буду рад ответить!

4 Куда развить решение?

  • Мы продаём целую систему, а что если продавать модули в другие приложения? Тот же White Label, но на системный уровень ниже. Мы такую задачу решали, если интересно напишите в комментах, расскажем.

  • Когда количество приложений растёт, хочется CI и CD. В этом репозитории есть подробный гайд по настройке Azure Devops.

  • Если не нужна детальная настройка фичей, а писать flavors руками надоело сделайте автогенерацию flavors по json конфигу.

  • Бизнес бьёт ключом, клиентов больше сотни? Пора автоматизировать создание приложений.

Если знаете кейсы, на которые нет ссылок в нашей статье, то обязательно скиньте их в комменты вместе мы точно соберём крутую библиографию!

P.S. Shout-out дорогим коллегам за работу над проектом и помощь в написании статьи - без вас это было бы невозможно :)

Подробнее..

Перевод Культ лучших практик

10.02.2021 18:20:17 | Автор: admin

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

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

Самозваные лучшие практики


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

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

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

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

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

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

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

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

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

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

Трудности перевода


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

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

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

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

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

Эффект авторитета и использование обществом


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

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

  • Хеджирование биржевых ставок. Если мы ошибёмся, то в свою защиту можно сказать, что мы лишь следовали лучшим практикам. Как нас можно обвинять?
  • Подражание лучшему. Если мы видим, как авторитет что-то делает, то для нас вполне естественно изучить и повторить это.
  • Показная добродетельность. Если что-то является лучшим, то мы сразу хотим показать другим, что тоже это делаем.
  • Соответствие ожиданиям. Если все вокруг называют что-то лучшим, то нам будет трудно идти против мнения окружающих.

Культ


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

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

Выход из положения


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

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

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

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

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

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

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

Перевод Антипаттерны деплоя в Kubernetes. Часть 2

04.06.2021 12:23:32 | Автор: admin

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

Список антипаттернов, которые мы рассмотрим:

  1. Использование образов с тегом latest

  2. Сохранение конфигурации внутри образов

  3. Использование приложением компонентов Kubernetes без необходимости

  4. Использование для деплоя приложений инструментов для развёртывания инфраструктуры

  5. Изменение конфигурации вручную

  6. Использование кubectl в качестве инструмента отладки

  7. Непонимание сетевых концепций Kubernetes

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

  9. Смешивание кластеров Production и Non-Production

  10. Развёртывание приложений без Limits

  11. Неправильное использование Health Probes

  12. Не используете Helm

  13. Не собираете метрики приложений, позволяющие оценить их работу

  14. Отсутствие единого подхода к хранению конфиденциальных данных

  15. Попытка перенести все ваши приложения в Kubernetes

6. Использование кubectl в качестве инструмента отладки

Утилита kubectl незаменима при работе с кластерами Kubernetes. Но не стоит использовать её как единственный отладочный инструмент.

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

kubectl get nskubectl get pods -n saleskubectl describe pod prod-app-1233445 -n saleskubectl get svc - n saleskubectl describe...

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

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

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

Например, обратите внимание на Kubevious, это универсальная панель управления Kubernetes, которая позволяет вам искать и отображать ресурсы Kubernetes в соответствии с настраиваемыми правилами.

7. Непонимание сетевых концепций Kubernetes

Прошли те времена, когда единственный балансировщик нагрузки был всем, что необходимо для настройки подключения к вашему приложению. Kubernetes представляет свою собственную сетевую модель, и вам нужно разобраться с её основными концепциями. По крайней мере, вы должны быть знакомы с типами сервисов - Load Balancer, Cluster IP, Node Port и понимать, чем сервисы отличаются от Ingress.

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

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

Будет полезно разобраться, что такое Service Mesh и какие проблемы она решает. Необязательно разворачивать Service Mesh в каждом кластере Kubernetes. Но желательно понимать, как она работает и зачем вам это нужно.

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

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

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

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

Один из наиболее распространенных подходов наличие как минимум трех сред (QA / Staging / Production), в зависимости от размера компании их может быть больше. Наиболее важной из них является интеграционная, которая содержит результат слияния всех обновлений с основной веткой.

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

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

  1. Обновление A содержит ошибки, B и C в порядке

  2. Обновление B содержит ошибки, A и C в порядке

  3. Обновление C содержит ошибки, B и A в порядке

  4. Каждое обновление в отдельности работает, но при работе вместе с обновлениями A и B возникает ошибка

  5. Каждое обновление в отдельности работает, но при работе вместе с обновлениями A и C возникает ошибка

  6. Каждое обновление в отдельности работает, но при работе вместе с обновлениями B и C возникает ошибка

  7. Каждое обновление в отдельности работает, но все 3 обновления вместе не работают

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

Способы избежать подобных проблем:

  1. "Резервирование" промежуточного (stage) окружения разработчиками, чтобы они имели возможность тестировать свои обновления изолированно.

  2. Компания создает несколько staging сред, которые разработчики используют для тестирования своих обновлений (поскольку единственное staging окружение может быстро стать узким местом).

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

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

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

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

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

git checkout mastergit checkout -b feature-a-b-togethergit merge feature-agit merge feature-bgit push origin feature-a-b-together

Как по волшебству, будет создана динамическая среда:feature-a-b-together.staging.company.comилиstaging.company.com/feature-a-b-together.

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

Обратите внимание, что это нормально, если у вашей компании есть постоянные staging среды для специализированных нужд, таких как нагрузочное тестирование, тестирование на проникновения, развертывание A / B и т. д. Но для базового сценария Я разработчик и хочу, чтобы моё обновление работало изолированно и запускались интеграционные тесты динамические среды лучшее решение.

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

9. Смешивание кластеров Production и Non-Production

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

Прежде всего, смешивание Production и Non-Production кластеров может привести к нехватке ресурсов.

Так как приложение, работающее некорректно, может утилизировать слишком много ресурсов.

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

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

Если вы объедините этот антипаттерн со вторым (сохранение конфигурации внутри образов), должно стать очевидно, что может произойти множество негативных сценариев, например:

  1. Разработчик создает новое пространство имен, используемое для тестирования и production.

  2. Далее развертывается приложение и запускаются интеграционные тесты.

  3. Интеграционные тесты записывают фиктивные данные или очищают БД.

  4. К сожалению, в контейнерах были рабочие URL-адреса и конфигурация внутри них, и, таким образом, все интеграционные тесты фактически повлияли на работу production!

Чтобы не попасть в эту ловушку, гораздо проще просто создать Production и Non-Production кластера. К сожалению, многие руководства описывают сценарий при котором, пространства имен можно использовать для разделения сред, и даже в официальной документации Kubernetes есть примеры с пространствами имен prod / dev.

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

  1. Production

  2. Pre Production (Shadow) клон production, но с меньшими ресурсами

  3. Development кластер, который мы уже обсуждали выше

  4. Специализированный кластер для нагрузочного тестирования / тестирования безопасности

  5. И даже отдельный кластер для служебных инструментов

10. Развёртывание приложений без Limits

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

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

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

Одно из преимуществ Kubernetes эластичность ресурсов. Если кластер убивает / перезапускает ваше приложение, когда оно начинает обрабатывать значительную нагрузку (например, ваш интернет-магазин испытывает всплеск трафика), вы не используете все преимущества Kubenetes. Вы можете решить эту проблему с использованием vertical pod auto-scaler (VPA).

Подробнее..

Перевод Отладка Makefile часть 2

21.12.2020 22:14:07 | Автор: admin

Методы отладки


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


Отладка Makefile /часть 1/


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


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


debug:       $(for v,$(V), \         $(warning $v = $($v)))

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


$ make V="USERNAME SHELL" debugmakefile:2: USERNAME = Ownermakefile:2: SHELL = /bin/sh.exemake: debug is up to date.

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


debug:       $(for v,$(V) $(MAKECMDGOALS), \         $(if $(filter debug,$v),,$(warning $v = $($v))))

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


$ make debug PATH SHELLmakefile:2: USERNAME = Ownermakefile:2: SHELL = /bin/sh.exemake: debug is up to date.make: *** No rule to make target USERNAME. Stop.

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


DATE := $(shell date +%F)OUTPUT_DIR = out-$(DATE)make-directories := $(shell [ -d $(OUTPUT_DIR) ] || mkdir -p $(OUTPUT_DIR))all: ;

Если это запустить с опцией отладки sh, мы увидим:


$ make SHELL="sh -x"+ date +%F+ '[' -d out-2004-05-11 ']'+ mkdir -p out-2004-05-11

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


Часто встречаются сильно вложенные выражения, например, для оперирования с именами файлов:


FIND_TOOL = $(firstword $(wildcard $(addsuffix /$(1).exe,$(TOOLPATH))))

Ничего хорошего в отладке таких выражений нет. Один из разумных подходов будет их разворот и печать каждого подвыражения:


$(warning $(TOOLPATH))$(warning $(addsuffix /$(1).exe,$(TOOLPATH)))$(warning $(wildcard $(addsuffix /$(1).exe,$(TOOLPATH))))

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


Общие сообщения об ошибках


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


Сообщение make об ошибке имеет стандартный формат:


    makefile:n: *** message. Stop

или:


    make:n: *** message. Stop.

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


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


Синтаксические ошибки


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


Одна из наиболее частых ошибок для новых пользователей make это опускание скобок вокруг имен переменных:


foo:     for f in $SOURCES; \     do                 \                       \     done

Скорее всего, make развернёт переменную $S в ничего, и оболочка выполнит цикл только раз со значением OURCES в f. В зависимости от того, что ты собрался делать с f, можно получить забавные сообщения оболочки:


    OURCES: No such file or directory

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


missing separator


Сообщение:


    makefile:2:missing separator. Stop.

или (в GNU make пер.):


    makefile:2:missing separator (did you mean TAB instead of 8 spaces?). Stop.

обычно означает make искал разделитель, такой как :, =, или табуляцию и не нашел ни одного. Вместо этого, он нашел что-то что он не понял.


commands commence before first target


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


unterminated variable reference


Это простая, но распространённая ошибка. Она означает, что ты забыл закрыть имя переменной или вызов функции правильным количеством скобок. С сильно вложенными вызовами функций и именами переменных make файлы становятся похожими на Lisp! Избежать этого поможет хороший редактор, который умеет сопоставлять скобки, такой как Emacs.


Ошибки в командных сценариях


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


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


Классическое сообщение:


    bash: foo: command not found

выводится, когда оболочка не смогла найти команду foo. Так, оболочка поискала в каждой папке из переменной PATH исполняемый файл и не нашла совпадений. Чтобы исправить такую ошибку, нужно обновить PATH переменную, обычно в .profile (Bourne shell), .bashrc (bash) или .cshrc (C shell). Конечно, можно также установить PATH в самом makefile, и экспортировать PATH из make.


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


$ maketouch /foo/bartouch: creating /foo/bar: No such file or directorymake: *** [all] Error 1

Здесь touch команда не сработала, что напечатало своё собственное сообщение объясняющее сбой. Следующая строка это итоговая ошибка make. Упавшая цель в makefile указана в квадратных скобках, а затем статус выхода упавшей программы. Если программа вышла по сигналу, а не с ненулевым статусом выхода, то make напечатает более подробное сообщение.


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


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


No Rule to Make Target


Это сообщение имеет две формы:


    make: *** No rule to make target XXX. Stop.

и:


    make: *** No rule to make target XXX, needed by YYY. Stop.

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


Есть три причины для этой ошибки:


  • В твоем makefile отсутствует необходимое правило для обновления файла. В этом случае тебе необходимо добавить правило с описанием как построить цель.
  • В makefile опечатка. Или make ищет неверный файл или в правиле построения этого файла указан неверный файл. Если в makefile используются переменные, то опечатки становится еще труднее отыскать. Иногда единственный путь быть точно уверенным в значении сложного имени файла это напечатать его или печатая переменную напрямую или исследуя внутреннюю базу данных make.
  • Файл должен быть, но make не находит его или из-за того, что его нет, или make не знает где его искать. Конечно, иногда make абсолютно прав. Файла нет похоже мы забыли его скачать из VCS. Еще чаще, make не может найти файл из-за того, что исходник расположен где-то еще. Иногда исходник в другом дереве исходников, или может файл генерируется другой программой и создался в папке артефактов сборки.


    Overriding Commands for Target


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


    makefile:5: warning: overriding commands for target foo
    

    Также он может вывести сообщение:


    makefile:2: warning: ignoring old commands for target foo
    

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



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


Например, мы могли бы определить общную цель во включаемом файле:


# Create a jar file.$(jar_file):        $(JAR) $(JARFLAGS) -f $@ $^

и позволим нескольким отдельным makefile добавить свои собственные требования. Мы могли бы записать в makefile:


# Set the target for creating the jar and add prerequisitesjar_file = parser.jar$(jar_file): $(class_files)

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

Подробнее..

6 вещей, которые не стоит делать в ASP.NET контроллерах

26.04.2021 02:09:13 | Автор: admin

ASP.NET контроллеры должны быть тонкими

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

Почему они должны быть тонкими? Какой в этом плюс? Как сделать их тонкими, если они сейчас не такие? Как сохранить их тонкими?

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

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

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

1. Маппинг объектов передачи данных (DTO)

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

Вы понимаете, это что-то вроде:

public IActionResult CheckOutBook([FromBody]BookRequest bookRequest){    var book = new Book();    book.Title = bookRequest.Title;    book.Rating = bookRequest.Rating.ToString();    book.AuthorID = bookRequest.AuthorID;    //...}

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

2. Валидация

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

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

Вот такому коду нет оправданий!

public IActionResult Register([FromBody]AutomobileRegistrationRequest request){    // Проверяем, что VIN номер был заполнен...    if (string.IsNullOrEmpty(request.VIN))    {        return BadRequest();    }        //...}

3. Бизнес-логика

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

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

4. Авторизация

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

Аналогично в случае с валидацией, ASP.NET предлагает множество путей для выноса авторизации (ПО промежуточного слоя и фильтры, например).

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

5. Обработка ошибок

Это больно, это БОЛЬНО!

public IActionResult GetBookById(int id){    try    {      // Важный код, который должен выполнять шеф-повар...    }    catch (DoesNotExistException)    {      // Код, который должен выполнять ассистент...    }    catch (Exception e)    {      // Пожалуйста, только не это...    }}

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

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

6. Сохранение/получение данных

Часто в целях экономии времени в контроллерах появляется код, получающий или сохраняющий объекты, используя Репозитории. Если контроллер предоставляет только CRUD операции, то к чёрту, пускай.

У меня даже есть статья, показывающая использование контроллеров таким образом.

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

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

public IActionResult CheckOutBook(BookRequest request){    var book = _bookRepository.GetBookByTitleAndAuthor(request.Title, request.Author);    // Если у вас уже есть логика получения книги, то вы скорее    // всего захотите добавить сюда и логику выдачи этой книги  // ...return Ok(book);}

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

Именно здесь мне нравится использовать некий сервис (спрятанный за интерфейсом) для обработки запроса или делегирования какому-нибудь CQRS объекту.

На этом всё!


Статья закончилась, а у вас есть ещё примеры, которые не были освещены? Не согласны с каким-то из пунктов? Или просто хотите задать вопрос? Добро пожаловать в комментарии!

Перевод статьи подготовлен в преддверии старта курса "C# ASP.NET Core разработчик".

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

Ссылка для записи на вебинар

Подробнее..

Управление тестами в TestOps храните информацию, а не выводы

24.05.2021 12:21:25 | Автор: admin

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

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

Единое дерево

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

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

Что с ним не так?

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

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

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

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

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

В итоге папочки станут снова управляемыми, уровни вложенности структурируются, но снова получится только один срез по фичам. Если попытаться создать структуру с двумя срезами, по фичам и компонентам сразу, ее сложность и запутанность создаст больше проблем, чем пользы. А если у вас 3000 тест-кейсов? А если 10000?

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

Еще один подводный камень хранения тестов в папках ждет тех, кто планирует хранить в них автоматизированные тесты, написанные на разных языках программирования. Тут все просто: в Java тесты хранятся по полному имени пакет вроде io.qameta.allure, а в JavaScript или Python просто по имени пакета. Это значит, что при автоматической генерации тест-кейсов из автотестов, каждый фреймворк будет городить свои подструктуры в дереве и вся нормализация и базовая структура будет нарушена. Конечно, можно написать свою интеграцию для каждого фреймворка, но мы же стараемся упростить себе жизнь, верно?

"Из коробки" роботы не всё делают одинаково. "Из коробки" роботы не всё делают одинаково.

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

Как эту задачу решали Ops'ы?

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

Давайте рассмотрим его повнимательнее и попробуем применить к тестированию. Админы и все те, кто работают в Ops к метрикам и данным относятся с большой щепетильностью и любовью. Просто потому, что о падении сервиса, сети или недоступности какой-то кластера вы захотите узнать раньше пользователя, так? Изначально весь мониторинг строился по классической иерархической структуре: дата-центр кластер машинка метрики (CPU, RAM, storage и прочее). Удобная и понятная система, которая работает, если не приходится в реальном времени отслеживать десяток метрик, которые не привязаны к физическим машинам. А метрики для Ops'ов очень важны.

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

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

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

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

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

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

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

Да это же просто автоматизация папочек! скажете вы.

И это прекрасно это классическое решение проблемы масштабирования. Конечно, у системы с метками есть несколько минусы:

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

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

Тест-кейсы: дерево против меток

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

Ответ прост: мир разработки, к сожалению или к счастью, уже давно ушел от жесткой структуры релизы ускоряются, сборки автоматизируются. На фоне этих изменений, мир тестирования в большинстве компаний выглядит немного архаично: на новые фичи готовится по пачке новых тест-кейсов для ручных тестов и автоматизации, они раскладываются по папкам, запускается в тестирование на неопределенный срок (от 1-2 дней до недели), собирается результат тестирования и отгружается обратно в разработку после полного завершения. Ничего не напоминает? Это же старая добрая каскадная разработка (waterfall, то бишь)! В 2021 году. Если послушать Баруха Садогурского в одном из подкастов, где он довольно убедительно рассказывает, что любой процесс это по сути agile, в котором "мы пытаемся отложить принятие решения максимально далеко, чтобы перед этим собрать побольше данных", станет понятно, почему весь мир разработки гонится за короткими итерациями и быстрыми релизами. Именно поэтому разработчики уже давно пишут софт итеративно, внедряя по одной фиче или паре фиксов на релиз, опсы отгружают эти релизы как можно скорее, которые перед этим тестируются. А как они тестируются?

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

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

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

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

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

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

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

Согласитесь, звучит заманчиво: иметь гибкость JIRA-тикетов в управлении тест-кейсами. Останется только автоматизировать запуски на слияние веток и вот тестирование уже отвечает требованиям DevOps!

Подробнее..

Перевод Модульное тестирование архитектуры Spring Boot проектас помощью ArchUnit

12.11.2020 18:15:59 | Автор: admin

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

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

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

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

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

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

  • Проверки зависимостей пакетов

  • Проверки зависимостей класса

  • Проверки содержания классов и пакетов

  • Проверки наследования

  • Проверка аннотаций

  • Проверка уровней

  • Проверки цикла

Примечание переводчика. Эта заметка дополняет предыдущую на тему ArchUnit.

Начнем

Для поддержки ArchUnit JUnit 5, просто добавьте следующую зависимость из Maven Central:

pom.xml

XML

<dependency>    <groupId>com.tngtech.archunit</groupId>    <artifactId>archunit-junit5</artifactId>    <version>0.14.1</version>    <scope>test</scope></dependency>

build.gradle

Groovy

dependencies {   testImplementation 'com.tngtech.archunit:archunit-junit5:0.14.1' } } 

Проверки зависимостей пакетов

Java

class ArchunitApplicationTests {  private JavaClasses importedClasses;  @BeforeEach  public void setup() {        importedClasses = new ClassFileImporter()                .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)                .importPackages("com.springboot.testing.archunit");    }  @Test  void servicesAndRepositoriesShouldNotDependOnWebLayer() {      noClasses()                .that().resideInAnyPackage("com.springboot.testing.archunit.service..")                .or().resideInAnyPackage("com.springboot.testing.archunit.repository..")                .should()                .dependOnClassesThat()                .resideInAnyPackage("com.springboot.testing.archunit.controller..")                .because("Services and repositories should not depend on web layer")                .check(importedClasses);    }}

Сервисы и репозитории не должны взаимодействовать с веб-уровнем.

Проверки зависимостей класса

class ArchunitApplicationTests {  private JavaClasses importedClasses;  @BeforeEach    public void setup() {        importedClasses = new ClassFileImporter()                .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)                .importPackages("com.springboot.testing.archunit");    }    @Test    void serviceClassesShouldOnlyBeAccessedByController() {        classes()                .that().resideInAPackage("..service..")                .should().onlyBeAccessed().byAnyPackage("..service..", "..controller..")                .check(importedClasses);    }}

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

Две точки представляют любое количество пакетов (сравните AspectJ Pointcuts).

Соглашение об именовании

Java

class ArchunitApplicationTests {    private JavaClasses importedClasses;  @BeforeEach  public void setup() {    importedClasses = new ClassFileImporter()        importedClasses = new ClassFileImporter()                .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)                .importPackages("com.springboot.testing.archunit");  }    @Test    void serviceClassesShouldBeNamedXServiceOrXComponentOrXServiceImpl() {        classes()                .that().resideInAPackage("..service..")                .should().haveSimpleNameEndingWith("Service")                .orShould().haveSimpleNameEndingWith("ServiceImpl")                .orShould().haveSimpleNameEndingWith("Component")                .check(importedClasses);    }    @Test    void repositoryClassesShouldBeNamedXRepository() {        classes()                .that().resideInAPackage("..repository..")                .should().haveSimpleNameEndingWith("Repository")                .check(importedClasses);    }    @Test    void controllerClassesShouldBeNamedXController() {        classes()                .that().resideInAPackage("..controller..")                .should().haveSimpleNameEndingWith("Controller")                .check(importedClasses);    }}

Общее правило это соглашение об именах.Например, все имена сервис классов должны заканчиваться на Service, Component и т. д.

Проверка аннотаций

Java

class ArchunitApplicationTests {  private JavaClasses importedClasses;  @BeforeEach  public void setup() {      importedClasses = new ClassFileImporter()              .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)              .importPackages("com.springboot.testing.archunit");  }  @Test  void fieldInjectionNotUseAutowiredAnnotation() {      noFields()              .should().beAnnotatedWith(Autowired.class)              .check(importedClasses);  }  @Test  void repositoryClassesShouldHaveSpringRepositoryAnnotation() {      classes()              .that().resideInAPackage("..repository..")              .should().beAnnotatedWith(Repository.class)              .check(importedClasses);  }  @Test  void serviceClassesShouldHaveSpringServiceAnnotation() {      classes()              .that().resideInAPackage("..service..")              .should().beAnnotatedWith(Service.class)              .check(importedClasses);  }}

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

Проверки уровней

class ArchunitApplicationTests {private JavaClasses importedClasses;@BeforeEach  public void setup() {        importedClasses = new ClassFileImporter()                .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)                .importPackages("com.springboot.testing.archunit");    }    @Test    void layeredArchitectureShouldBeRespected() {layeredArchitecture()                .layer("Controller").definedBy("..controller..")                .layer("Service").definedBy("..service..")                .layer("Repository").definedBy("..repository..")                .whereLayer("Controller").mayNotBeAccessedByAnyLayer()                .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")                .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")                .check(importedClasses);    }}

В приложении Spring Boot уровень сервиса зависит от уровня репозитория, уровень контроллера зависит от уровня сервиса.

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

Полный исходный код примеров можно найти в моемрепозитории GitHub.

Подробнее..
Категории: Java , Testing , Best practices

Перевод Обеспечение границ компонент чистой архитектуры с помощью Spring Boot и ArchUnit

15.11.2020 20:17:49 | Автор: admin

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

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

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

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

Это тем более важно, если мы работаем над монолитной кодовой базой, охватывающей множество различных областей бизнеса или ограниченных контекстов, если использовать жаргон Domain-Driven Design.

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

Пример кода

Эта статья сопровождается примером рабочего кодана GitHub.

Видимость Package-Private

Что помогает с соблюдением границ компонентов?Уменьшение видимости.

Если мы используем Package-Private видимость для внутренних классов, доступ будут иметь только классы в одном пакете.Это затрудняет добавление нежелательных зависимостей извне пакета.

Итак, просто поместите все классы компонента в один и тот же пакет и сделайте общедоступными только те классы, которые нам нужны вне компонента.Задача решена?

Нет, на мой взгляд.

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

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

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

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

Модульный подход к ограниченным контекстам

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

Пример использования

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

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

Чтобы использовать язык Domain-Driven Design (DDD): компонент биллинга реализует ограниченный контекст, который предоставляет варианты использования биллинга.Мы хотим, чтобы этот контекст был как можно более независимым от других ограниченных контекстов.В остальной части статьи мы будем использовать термины компонент и ограниченный контекст как синонимы.

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

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

Классы API и внутренние классы

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

billing api internal     batchjob    |    internal     database         api         internal

У каждого компонента и субкомпонента естьinternalпакет, содержащий, ну, внутренние классы, и дополнительныйapiпакет, содержащий, как вы угадали, классы API, которые предназначены для использования другими компонентами.

Такое разделение пакетов междуinternalиapiдает нам несколько преимуществ:

  • Мы можем легко вкладывать компоненты друг в друга.

  • Легко догадаться, что классы внутриinternalпакета нельзя использовать извне.

  • Легко догадаться, что классы внутриinternalпакета можно использовать из его подпакетов.

  • Пакеты apiиinternalдают нам инструмент для обеспечения соблюдения правил зависимостей с ArchUnit (подробнее об этомпозже).

  • Мы можем использовать столько классов или подпакетов впакетеapiилиinternal, сколько захотим, при этом границы наших компонентов по-прежнему четко определены.

Если возможно, классы внутриinternalпакета должны быть package-private.Но даже если они являются public (и они должны быть public, если мы используем подпакеты), структура пакета определяет четкие и легко отслеживаемые границы.

Вместо того, чтобы полагаться на недостаточную поддержку Java для package-private видимости, мы создали архитектурно выразительную структуру пакета, которая может быть легко реализована с помощью инструментов.

Теперь давайте посмотрим на эти пакеты.

Инверсия зависимостей для предоставления доступа к Package-Private функциям

Начнем сdatabaseподкомпонента:

database api|    + LineItem|    + ReadLineItems|    + WriteLineItems internal     o BillingDatabase

+означает, что класс является public,oозначает, что он является package-private.

databaseКомпонент выставляет API с двумя интерфейсамиReadLineItemsиWriteLineItems, которые позволяют читать и записвысать строку заказа клиента из и в базу данных, соответственно.ТипLineItemдомена также является частью API.

Внутриdatabaseподкомпонент имеет класс,BillingDatabaseреализующий два интерфейса:

@Componentclass BillingDatabase implements WriteLineItems, ReadLineItems {  ...}

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

Обратите внимание, что это применение принципа инверсии зависимостей.

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

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

Давайте также заглянем вbatchjobподкомпонент:

Подкомпонент batchjob не предоставляет API доступа к другим компонентам.У него просто есть классLoadInvoiceDataBatchJob(и, возможно, несколько вспомогательных классов), который ежедневно загружают данные из внешнего источника, преобразуют их и передают их в базу данных биллингового компонента черезWriteLineItemsинтерфейс:

@Component@RequiredArgsConstructorclass LoadInvoiceDataBatchJob {  private final WriteLineItems writeLineItems;  @Scheduled(fixedRate = 5000)  void loadDataFromBillingSystem() {    ...    writeLineItems.saveLineItems(items);  }}

Обратите внимание, что мы используем@ScheduledаннотациюSpring,чтобы регулярно проверять наличие новых элементов в биллинговой системе.

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

billing api|    + Invoice|    + InvoiceCalculator internal     batchjob     database     o BillingService

Компонент billingпредоставляет доступ к интерфейсу InvoiceCalculatorи доменному типуInvoice.Опять же, интерфейс InvoiceCalculatorреализован внутренним классом, который вызываетсяBillingServiceв примере.BillingServiceобращается к базе данных черезReadLineItemsAPI базы данных для создания счета-фактуры клиента из нескольких позиций:

@Component@RequiredArgsConstructorclass BillingService implements InvoiceCalculator {  private final ReadLineItems readLineItems;  @Override  public Invoice calculateInvoice(        Long userId,         LocalDate fromDate,         LocalDate toDate) {        List<LineItem> items = readLineItems.getLineItemsForUser(      userId,       fromDate,       toDate);    ...   }}

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

Соединяем все вместе с помощью Spring Boot

Чтобы связать все вместе с приложением, мы используем функцию Spring Java Config и добавляемConfigurationкласс вinternalпакеткаждого модуля:

billing internal     batchjob    |    internal    |        o BillingBatchJobConfiguration     database    |    internal    |        o BillingDatabaseConfiguration     o BillingConfiguration

Эти конфигурации говорят Spring внести набор компонентов Spring в контекст приложения.

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

@Configuration@EnableJpaRepositories@ComponentScanclass BillingDatabaseConfiguration {}

С помощью аннотации @Configurationмы сообщаем Spring, что это класс конфигурации, который вносит компоненты Spring в контекст приложения.

Аннотация @ComponentScanговорит Spring, что нужно включить все классы ,которые находятся в том же пакете, что икласс конфигурации (или подпакет) и аннотированные с@Componentкак бины в контекст приложения.Это загрузит нашBillingDatabaseкласс, приведенный выше.

Вместо @ComponentScanмы могли бы также использовать@Beanаннотированные фабричные методы внутри@Configurationкласса.

Под капотом для подключения к базе данныхdatabaseмодуль использует репозитории Spring Data JPA.Мы включаем их с помощью аннотации @EnableJpaRepositories.

Конфигурация batchjobвыглядит также:

@Configuration@EnableScheduling@ComponentScanclass BillingBatchJobConfiguration {}

Только аннотация @EnableSchedulingдругая.Нам это нужно, чтобы включить аннотацию @Scheduledв нашем bean-компонентеLoadInvoiceDataBatchJob.

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

@Configuration@ComponentScanclass BillingConfiguration {}

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

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

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

  • Загрузить только один (под) компонент в контекст приложения в рамкахинтеграционного теста SpringBootTest.

  • Включить или отключить определенные (под) компоненты, добавиваннотацию @Conditional...к конфигурации этого подкомпонента.

  • Заменить компоненты, внесенные в контекст приложения, на (под) компонент, не затрагивая другие (под) компоненты.

Однако у нас все еще есть проблема: классы вbilling.internal.database.apiпакете являются public, то есть к ним можно получить доступ извнеbillingкомпонента, что нам не нужно.

Давайте решим эту проблему, добавив в игру ArchUnit.

Обеспечение границ с помощью ArchUnit

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

В нашем случае мы хотим определить правило, согласно которому все классы вinternalпакете не используются извне этого пакета.Это правило гарантирует, что классы внутриbilling.internal.*.apiпакетов недоступны извнеbilling.internalпакета.

Маркировка внутренних пакетов

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

Мы могли бы сделать это по имени (то есть рассматривать все пакеты с именем internal как внутренние пакеты), но мы также можем отметить пакеты другим именем, для чего создадим аннотацию@InternalPackage:

@Target(ElementType.PACKAGE)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface InternalPackage {}

Затем во все наши внутренние пакеты мы добавляемpackage-info.javaфайл с этой аннотацией:

@InternalPackagepackage io.reflectoring.boundaries.billing.internal.database.internal;import io.reflectoring.boundaries.InternalPackage;

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

Проверка отсутствия доступа к внутренним пакетам извне

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

class InternalPackageTests {  private static final String BASE_PACKAGE = "io.reflectoring";  private final JavaClasses analyzedClasses =       new ClassFileImporter().importPackages(BASE_PACKAGE);  @Test  void internalPackagesAreNotAccessedFromOutside() throws IOException {    List<String> internalPackages = internalPackages(BASE_PACKAGE);    for (String internalPackage : internalPackages) {      assertPackageIsNotAccessedFromOutside(internalPackage);    }  }  private List<String> internalPackages(String basePackage) {    Reflections reflections = new Reflections(basePackage);    return reflections.getTypesAnnotatedWith(InternalPackage.class).stream()        .map(c -> c.getPackage().getName())        .collect(Collectors.toList());  }  void assertPackageIsNotAccessedFromOutside(String internalPackage) {    noClasses()        .that()        .resideOutsideOfPackage(packageMatcher(internalPackage))        .should()        .dependOnClassesThat()        .resideInAPackage(packageMatcher(internalPackage))        .check(analyzedClasses);  }  private String packageMatcher(String fullyQualifiedPackage) {    return fullyQualifiedPackage + "..";  }}

ВinternalPackages(), мы используем reflection библиотеку для сбора всех пакетов, аннотированных нашей@InternalPackageаннотацией.

Затем для каждого из этих пакетов мы вызываемassertPackageIsNotAccessedFromOutside().Этот метод использует API-интерфейс ArchUnit, подобный DSL, чтобы гарантировать, что классы, которые находятся вне пакета, не должны зависеть от классов, которые находятся внутри пакета.

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

Но у нас все еще есть одна проблема: что, если мы переименуем базовый пакет (io.reflectoringв данном случае) в процессе рефакторинга?

Тогда тест все равно пройдет, потому что он не найдет никаких пакетов в (теперь несуществующем)io.reflectoringпакете.Если у него нет пакетов для проверки, он не может потерпеть неудачу.

Итак, нам нужен способ сделать этот тест безопасным при рефакторинге.

Обеспечение безопасного рефакторинга правил архитектуры

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

class InternalPackageTests {  private static final String BASE_PACKAGE = "io.reflectoring";  @Test  void internalPackagesAreNotAccessedFromOutside() throws IOException {    // make it refactoring-safe in case we're renaming the base package    assertPackageExists(BASE_PACKAGE);    List<String> internalPackages = internalPackages(BASE_PACKAGE);    for (String internalPackage : internalPackages) {      // make it refactoring-safe in case we're renaming the internal package      assertPackageIsNotAccessedFromOutside(internalPackage);    }  }  void assertPackageExists(String packageName) {    assertThat(analyzedClasses.containPackage(packageName))        .as("package %s exists", packageName)        .isTrue();  }  private List<String> internalPackages(String basePackage) {    ...  }  void assertPackageIsNotAccessedFromOutside(String internalPackage) {    ...  }}

Новый методassertPackageExists()использует ArchUnit, чтобы убедиться, что рассматриваемый пакет содержится в классах, которые мы анализируем.

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

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

Вывод

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

Это позволяет нам разрабатывать компоненты с четкими API и четкими границами, избегая большого шара грязи.

Дайте мне знать свои мысли в комментариях!

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

Если вас интересуют другие способы работы с границами компонентов с помощью Spring Boot, вам может бытьинтересен проект moduliths.

Подробнее..
Категории: Java , Testing , Best practices , Spring boot 2

Профессионализм разработчика на шаг ближе к счастью

05.01.2021 16:09:52 | Автор: admin

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

Счастье и удволетворенность работой

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

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

В чем проблема?

Проблема в том, что бизнес не всегда знает, что для него лучше. Представители бизнеса менеджеры, специалисты по продукту, аналитики и многие другие профессионалы намного лучше разработчика умеют отвечать на вопрос что делать? (произведение Н. Чернышевского не имеет к этому никакого отношения). Но никто не обладает большей экспертизой в разработке ПО чем разработчики (а также архитекторы, технические менеджеры и другие специалисты обладающие знаниями и hard skills в области разработки). Их задача предоставить бизнесу свою экспертизу, валидировать его идеи и отвечать на вопрос как делать?. Идея такого взаимодействия лежит в основе методологии Agile (https://agilemanifesto.org/).

Внимательный читатель заметит, как я прибегаю к приему *argumentum ad auctoritatem (аргумент к авторитету).* Но в мире мнений и субъективных суждений, в котором мы живем это хороший прием, и я советую прибегать в том числе и к нему для донесения профессионалом своей идеи.

Мнение профессионала о том, что полезно для бизнеса субъективно. Это мнение, которое, возможно, не является истиной в последней инстанции и, как любое мнение, может быть ошибочно errare humanum est. Тем не менее, я считаю что разработчику стоит бороться за те идеи, в которые он верит как профессионал.

Что можно попробовать сделать?

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

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

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

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

Кого и как убеждать?

Если с идеей разработчика согласны все, кто влияет на ее принятие, тогда она принимается и результатом становится однозначный успех. Но эта ситуация выглядит идеализированной, и, скорее всего, возникнут спорные ситуации, которые потребуют разрешения. Я считаю, что в случае разработки ПО есть две стороны, которые разработчик может убедить в правоте своих суждений. Первая горизонтальная это другие разработчики, так как невозможно внедрить практики, например TDD (разработка через тестирование) или code review, если это делает один человек в команде. Вторая вертикальная представители бизнеса, так как применение best practices, особенно когда их внедрение только начинаются, требует времени разработчиков.

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

А использование административного ресурса это как раз возможная необходимость разрешения вертикальных споров. Разработчику необходимо убедить представителей бизнеса в том, что его идея принесет пользу компании. Бизнесу нужна причина тратить ресурсы, поэтому нужно ясно объяснить, что внедрение best practices разработки это инвестиция. Здесь также инструментами убеждения являются все возможные приемы риторики, а также, возможно, работа с данными, поиск примеров и апелляция к авторитету.

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

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

Заключение

Эта статья глубоко субъективное мнение о том, как разработчику получать больше удовольствия от своей работы за счет проявления профессионализма и донесения до бизнеса субъективных идей, которые разработчик, как профессионал, считает верными. Статья во многом вдохновлена идеями Agile, книгой Clean Architecture Роберта Мартина, а также собственным опытом, переживаниями и опытом знакомых и коллег.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    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