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

Проектирование

HowToCode Адаптация системного подхода к разработке для React и TypeScript

07.03.2021 00:21:34 | Автор: admin

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

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

Кардинальным образом ситуация изменилась после того, как я прошел курс HowToCode[ссылка удалена модератором, т.к. нарушает правила]. В курсе описан системный и, как всё гениальное, простой и красивый подход к разработке, который сводит воедино анализ, проектирование, документацию, тестирование и разработку кода. Весь курс построен на использовании функциональной парадигмы и языка Scheme (диалекта Lisp), тем не менее, рекомендации вполне применимы и для других языков, а для JavaScript и TypeScript, к которым я постарался их адаптировать, так и вообще подходят отлично.

Результаты мне очень понравились:

  • Во-первых, наконец-то мой код стал читаемым, появилась внятная документация и тесты

  • Во-вторых, заработал подход TDD, к которому я делал несколько подходов, но никак не мог начать ему следовать

  • В-третьих, я практически избавился от отладки: того самого процесса, когда код уже написан, но ещё не работает или работает не так как надо, чем дико раздражает

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

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

Но давайте уже перейдём к сути и посмотрим, как этот подход устроен.

Общая идея

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

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

Этапы проектирования

Подход включает 3 этапа:

  1. Анализ задачи и предметной области

  2. Проектирование структур данных

  3. Проектирование функций

Анализ задачи и предметной области

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

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

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

Процедура анализа задачи

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

Алгоритм

В общем виде алгоритм выглядит так:

Шаг 1. Представить задачу в динамике

Шаг 2. Выделить константы, переменные и события.

Шаг 1. Представить задачу в динамике

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

  • в исходном состоянии

  • в различных промежуточных состояниях: возможно, появляется загрузка, или раскрываются какие-то списки или открываются какие-то окна

  • при окончании работы

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

Пример

Допустим, первоначальная постановка задачи звучала как: "Реализовать для web-портала раскрывающийся список, который бы показывал перечень сотрудников, входящих в учебную группу. Для каждого сотрудника должно быть указано ФИО и дата его рождения."

Звучит неплохо, но что делать, пока ещё непонятно. Берем карандаш, бумагу и, если есть возможность, самого заказчика и начинаем рисовать (Ну а я буду рисовать в редакторе).

Начальное состояни

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

Промежуточные состояния

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

Когда список открывается, мы должны показать список сотрудников, входящих в группу, и изменить состояние индикатора. Скорее всего, выглядеть наше приложение станет как-то так:

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

Шаг 2. Выделить константы, переменные и события

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

Константы

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

  • оформление: цвета, шрифты, расстояния, логотипы и иконки, размеры сетки и экрана и т.д.

  • сетевые данные, например, адреса серверов,

  • строковые константы: названия, заголовки и т.п.

Пример выделения констант

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

  • Адрес сервера, с которым работает наше приложение

  • Название приложения, которое отображается в виде заголовка

  • Оформление

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

Переменные

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

  • координаты объектов

  • значения таймера

  • различные переменные, отражающие состояние элементов интерфейса и т.д.

Пример выделения переменных

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

У меня получился следующий список:

  • Состояние загрузки приложения: загружается или данные уже загружены

  • Группа, для описания которой нам, скорее всего, понадобится её идентификатор, название, статус открытия списка группы

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

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

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

События

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

Событиями могут быть:

  • Изменения этапов жизненного цикла приложения или его отдельных компонентов: инициализация, обновление, удаление, и т.п.

  • Пользовательский ввод: события мыши или клавиатуры

  • Таймер

  • Получение данных с сервера и т.д.

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

  • загрузка данных приложения и

  • открытие и закрытие списка сотрудников по клику мыши на шапку группы

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

Теперь мы уже существенно продвинулись вперед:

  1. Во-первых, мы уже покрутили задачу в голове и более или менее её поняли

  2. Во-вторых, мы сделали её куда как более конкретной, свели все возможные варианты реализации списков к одному

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

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

Проектирование структуры данных

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

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

Назначение

Для чего нам это надо?

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

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

  3. И в-третьих, если структуры данных описать полностью и с примерами, как рекомендует алгоритм, то примеры становятся очень неплохим подспорьем для unit-тестов.

Алгоритм

Структуры мы описываем в следующем порядке:

  1. Фиксируем название структуры

  2. Описываем её тип

  3. Пишем интерпретацию структуры - описание того, как преобразовать реальные данные в указанный тип.

  4. Придумываем один - два примера заполненной структуры.

На этом этапе уже начинают играть роль используемые технологии. В нашем случае появляются TypeScript и JSDoc, но для других языков и платформ вполне может использоваться и что-то другое. Ну а раз мы говорим о веб-разработке и библиотеке React JS, то надо сразу отметить, что мы дальше будем использоваться понятия свойств (props) и состояний (state), характерных для React. Ничего страшного, если вы с этими понятиями не знакомы, думаю, что всё будет и так понятно.

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

Пример

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

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

Фиксируем название структуры. Название должно отражать суть структуры. В нашем случае, назовём её AppState - состояние приложения:

export interface AppState {}

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

Описание

Название переменной

Источник

Тип данных

Обязательность

Комментарий

Название приложения

title

Константы

Строка

+

Адрес сервера

backendAddress

Константы

Строка

+

Флаг загрузки данных

isLoading

Переменные

Логическое значение

+

true - данные загружаются

false - данные загружены

Данные об отображаемой группе

group

Переменные

Объект типа Group

-

На момент загрузки данные о группе не определены

Метод загрузки данных

loadData

События

Функция

+

Фиксируем это в коде:

export interface AppState {        title: string;        backendAddress: string;    isLoading: boolean;    group?: Group;    loadData: Function;}

Пишем интерпретацию структуры

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

/** * Общее состояние приложения * @prop title - заголовок приложения * @prop backendAddress - адрес сервера * @prop isLoading - флаг загрузки данных (true - загружаются, false - загружены) * @prop group - данные об отображаемой группе. На момент загрузки данных group не определена * @method loadData - метод загрузки данных */export interface AppState {    title: string;    backendAddress: string;    isLoading: boolean;    group?: Group;    loadData: Function;}

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

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

/** * Общее состояние приложения * @prop title - заголовок приложения * @prop backendAddress - адрес сервера * @prop isLoading - флаг загрузки данных (true - загружаются, false - загружены) * @prop group - данные об отображаемой группе. На момент загрузки данных group не определена * @method loadData - метод загрузки данных */export interface AppState {    title: string;    backendAddress: string;    isLoading: boolean;    group?: Group;    loadData: Function;}/** * Данные об отображаемой группе*///TODOexport interface Group {}

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

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

/** * Общее состояние приложения * @prop title - заголовок приложения * @prop backendAddress - адрес сервера * @prop isLoading - флаг загрузки данных (true - загружаются, false - загружены) * @prop group - данные об отображаемой группе. На момент загрузки данных group не определена * @method loadData - метод загрузки данных */export interface AppState {    title: string;    backendAddress: string;    isLoading: boolean;    group?: Group;    loadData: Function;}//Пример 1const appState1: AppState = {    title: "Заголовок 1",    backendAddress: "/view_doc.html",    isLoading: true,    group: undefined,    loadData: () => {}}//Пример 2const appState2: AppState = {    title: "Заголовок 2",    backendAddress: "/view_doc_2.html",    isLoading: false,    group: group1, //Заглушка для вложенной структуры    loadData: () => {}}/** * Данные об отображаемой группе*///TODOexport interface Group {}//TODOconst group1 = {}

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

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

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

export default abstract class AbstractService {           /**     * Метод загрузки данных о группе с сервера     * @fires get_group_data - имя действия, вызываемого на сервере     * @returns данные о группе     */    abstract getGroupData(): Promise<Group>;}

На клиенте останется только реализовать этот класс, а на сервере - реализовать нужные действия и отдавать данные, которые сервис ожидает, формат их уже для всех ясен.

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

Проектирование функций

Только теперь мы, наконец, переходим к разработке.

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

Алгоритм первоначального проектирования функций

  1. Создаем заглушку. Заглушка - это определение функции, которое:

    • отражает правильное название функции

    • принимает правильное количество и типы параметров

    • возвращает произвольный результат, но корректного типа

  2. Описываем входные, выходные данные и назначение функции

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

  4. Пишем тело функции.

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

    1. сразу описываем функцию и добавляем заглушку

    2. добавляем отметку (TODO), помечающую нашу функцию как объект списка задач

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

Пример проектирования функции

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

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

Шаг 1 - Создаем заглушку

Заглушка функции - это минимальное работоспособное её описание, в котором есть:

  • правильное название функции,

  • правильное количество и тип параметров и

  • возвращаемое значение, произвольное по содержанию, но правильного типа.

Для простой функции на TypeScript будет вполне достаточно написать что-то вроде:

export const getWorkDuration = (worktimeFrom: string, worktimeTo: string): string => {    return "6ч 18мин";}

где:

  • getWorkDuration - правильное название фукнции, то есть то, которое мы и дальше будем использовать

  • worktimeFrom: string, worktimeTo: string - два строковых параметра. Именно столько параметров и такого типа функция должна принимать

  • : string после закрывающей круглой скобки - тип возвращаемого значения

  • return "6ч 18мин" - возврат произвольного значения, но правильного типа из функции

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

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

Для функциональных компонентов:

const componentName = (props: PropsType) => { return <h1>componentName</h1> }

Для компонентов классов:

class componentName extends React.Component<PropsType, StateType>{    state = {        //произвольные значения для всех обязательных свойств состояния    }    render() {                return <h1>componentName</h1>     }}

где:

  • PropsType - это описание типа передаваемых свойств

  • StateType - описание типа состояния компонента

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

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

interface AppProps {}export default class App extends Component<AppProps, AppState> {    state = {        title: "Отображение списка групп",        backendAddress: "",        isLoading: true,        loadData: this.loadData.bind(this)    }    /**     * Метод загрузки данных с сервера     */    //TODO    loadData() {    }    render() {        return <h1>App</h1>    }}

Надо отметить пару особенностей этой реализации:

  1. В компонент App не передаётся никаких свойств "сверху", поэтому интерфейс AppProps пустой

  2. Тип состояния компонента AppState мы импортируем напрямую из описания типов, которое мы создали, когда проектировали структуры

  3. Состояние предусматривает метод loadData, поэтому мы тут же делаем заглушку для этого метода, отмечаем её маркером TODO, чтобы позже к ней вернуться

Шаг 2 - Описываем входные и выходные данные

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

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

/** * Рассчитывает количество часов и минут между указанным начальным и конечным временем * Если время начала работы больше времени конца, то считается, что конечное время - это время следующих суток * @param worktimeFrom - время начала работы в формате ЧЧ:ММ (от 00:00 до 23:59) * @param worktimeTo - время конца работы в формате ЧЧ:ММ (от 00:00 до 23:59) * @return количество часов и минут между переданным временем в формате Хч Y?мин, например 6ч 18мин или 5ч, если количество часов полное *///TODOexport const getWorkDuration = (worktimeFrom: string, worktimeTo: string): string => {    return "6ч 18мин";}

И обязательно отмечаем нашу функцию маркером TODO, чтобы сразу бросалось в глаза, что функция не закончена.

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

Шаг 3. Тестирование

После того, как назначение и входные и выходные данные определены, можно начать продумывать поведение функции. Лучшее средство для этого - unit-тесты. Есть пара рекомендаций о том, как лучше их писать:

  • unit-тесты должны, как минимум, один раз проходить по каждой ветке внутри функции - то есть проверять все ветвления, если они есть

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

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

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

Зависимость содержания функций и тестов от типов данных в функциональном программировании

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

Допустим, у нас есть структура данных, описывающая состояние светофора. Эта структура называется "перечисление" или enum. То есть переменная этого типа может находиться в нескольких дискретных значениях:

type TrafficLights = "красный" | "желтый" | "зеленый";

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

function trafficLightsFunction (trafficLights: TrafficLights) {    switch (trafficLights) {        case "красный":            ...        case "желтый":            ...        case "зеленый":            ...    }}

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

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

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

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

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

Тип данных

Описание

Пример данных

Правила тестирования

1

Атомарные данные

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

Строка

Логическое значение

Число

Для строк допустим 1 сценарий тестирования

для логических значений - 2 (true / false)

Для чисел - 1 сценарий, но может потребоваться дополнительный сценарий для значения 0

2

Перечисления

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

Цвета светофора

Пятибальная шкала оценок

Размеры одежды

и т.д.

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

Поэтому, если вам нужно протестировать 100-бальную шкалу оценок, но при этом вы понимаете, что значения группируются в 4 класса:

1 - 25,

26 - 50,

51 - 75,

76 - 100

То вам понадобится только 4 сценария.

3

Интервалы

Числа в определённом диапазоне

Скорость автомобиля (0 - 300]

Температура воздуха

и т.д.

Необходимо протестировать:

значение внутри диапазона

граничные значения

4

Детализация

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

Подразделение расформировано или нет? Если расформировано, то какова дата расформирования

В данном случае дата расформирования - атрибут, который зависит от признака, расформировано подразделение или нет. Если подразделение активно, то этой даты просто не существует

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

Должно быть, как минимум, столько тест-кейсов, сколько комбинаций подклассов может быть.

Для примера с подразделением должно быть минимум 2 сценария:

подразделение нерасформировано и

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

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

5

Сложный тип (объект)

Данные, состоящие из нескольких независимых частей

Объект "Сотрудник" имеющий поля:

id

ФИО

Дата рождения

Пол

и т.д.

Как минимум 2 сценария. При этом надо изменять значения всех полей

6

Массив

Набор данных, объем которых может изменяться

Список сотрудников

Массив учебных курсов, назначенных сотруднику

Как правило, несколько сценариев:

один, проверяющий работу функции при пустом массиве

один, проверяющий работу функции при массиве с одним элементом

один или несколько, проверяющих работу функции, когда элементов в массиве 2 и больше

Примеры тестов

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

/** * Рассчитывает количество часов и минут между указанным начальным и конечным временем * Если время начала работы больше времени конца, то считается, что конечное время - это время следующих суток * @param worktimeFrom - время начала работы в формате ЧЧ:ММ (от 00:00 до 23:59) * @param worktimeTo - время конца работы в формате ЧЧ:ММ (от 00:00 до 23:59) * @return количество часов и минут между переданным временем в формате Хч Y?мин, например 6ч 18мин или 5ч, если количество часов полное *///TODOexport const getWorkDuration = (worktimeFrom: string, worktimeTo: string): string => {    return "6ч 18мин";}

Итак, функция расчёта рабочего времени. Пусть вас не смущает тип string у параметров, каждый из параметров - это время, представленное в виде строки ЧЧ:ММ, то есть не атомарный тип, а интервал от 00:00 до 23:59. Согласно таблице, для интервалов надо проверить граничные значения и значения внутри диапазона. А так как параметра два, то это справедливо для каждого из них. Для этого нам потребуется 3 тест-кейса:

  1. Первый параметр выходит за граничное значение, а второй нет

  2. Второй параметр выходит за граничное значение, а первый - нет

  3. Оба параметра - в пределах нормальных значений

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

Название

worktimeFrom

worktimeTo

Ожидаемое значение

1

Проверка корректности worktimeFrom

Граничное значение, не входящее в диапазон, например

"24:00"

Нормальное значение, например

"18:00"

Исключение

2

Проверка корректности worktimeFrom

Нормальное значение, например

"18:00"

Граничное значение, не входящее в диапазон, например

"24:00"

Исключение

3

Нормальная работа,

worktimeFrom < worktimeTo

Нормальное значение, меньшее чем worktimeTo, например

"00:00"

Нормальное значение, большее чем worktimeFrom, например,

"23:59"

23ч 59мин

4

Нормальная работа,

worktimeFrom > worktimeTo

Нормальное значение, большее чем worktimeTo, например

"18:49"

Нормальное значение, меньшее чем worktimeFrom, например,

"10:49"

16ч

5

Нормальная работа worktimeFrom = worktimeTo

Нормальное значение, например,

"01:32"

Нормальное значение, например,

"01:32"

Тест-кейсы мы к тому же составили так, чтобы одновременно проверялись выводимые значения: с минутами и без. Остается только написать сами тесты. Я их пишу при помощи Jest и Enzyme - стандартной комбинации для React JS. В самом написании тестов особых премудростей нет, поэтому приведу только один пример:

describe('Тестирование расчёта времени между началом и концом работы', () => {        it('Когда начало и конец смены совпадают, функция возвращает 0ч', ()=>{        const result = getWorkDuration("01:32", "01:32");        expect(result).toBe("0ч");    });        //Подобным образом описываем все сценарии    ...});

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

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

interface AppProps {}export default class App extends Component<AppProps, AppState> {    state = {        title: "Отображение списка групп",        backendAddress: "",        isLoading: true,        loadData: this.loadData.bind(this)    }    /**     * Метод загрузки данных с сервера     */    //TODO    loadData() {    }    render() {        return <h1>App</h1>    }}

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

/** * Общее состояние приложения * @prop title - заголовок приложения * @prop backendAddress - адрес сервера * @prop isLoading - флаг загрузки данных (true - загружаются, false - загружены) * @prop group - данные об отображаемой группе. На момент загрузки данных group не определена * @method loadData - метод загрузки данных */export interface AppState {    title: string;    backendAddress: string;    isLoading: boolean;    group?: Group;    loadData: Function;}

Для начала определимся, к какому типу данных относится эта структура:

  • Во-первых, это сложная структура, состоящая из нескольких полей и членов.

  • Во-вторых, среди полей есть зависимые. Так, наличие поля group зависит от состояния загрузки, пока данные не загружены, данных о группе - нет. Если есть зависимые данные, то можно смело относить эти данные к типу "Детализация".

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

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

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

Таким образом, нам следует написать 4 отдельных теста:

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

  • 2 - изменяя статус загруженности и соответствующие данные, чтобы проверить, правильность поведения компонента в этом случае

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

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

Начнём с простого - с вызова метода loadData при загрузке компонента:

import React from 'react';import Enzyme, { mount, shallow } from 'enzyme';import Adapter from 'enzyme-adapter-react-16';Enzyme.configure({ adapter: new Adapter() });import App from './App';describe('App', () => {    test('Когда компонент App смонтирован, вызывается функция loadData', () => {        //Добавляем слушателя к функции loadData        const loadData = jest.spyOn(App.prototype, 'loadData');                //Монтируем компонент        const wrapper = mount(<App></App>);                //Проверяем количество вызовов функции loadData        expect(loadData.mock.calls.length).toBe(1);    });}

Здесь мы подключили enzyme к нашему файлу с тестами, импортировали сам компонент и написали первый тест. Внутри теста мы:

  1. добавили слушателя к функции loadData внутри компонента,

  2. смонтировали компонент в тестовой среде (то есть сымитировали его появление в приложении) и

  3. проверили, что компонент в ходе монтирования вызвал нашу функцию загрузки данных

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

test('Когда данные загружаются, отображаются только заголовок и спиннер', () => {        //Монтируем компонент        const wrapper = mount(<App></App>);        //Подменяем состояние компонента на нужное для тестирования        wrapper.setState({            title: "Заголовок 1",            backendAddress: "/view_doc.html",            isLoading: true,            group: undefined,            loadData: () => {}        })        //Проверяем правильность отображения компонента        //Проверяем наличие и содержание заголовка        expect(wrapper.find('h1').length).toBe(1);        expect(wrapper.find('h1').text()).toBe("Заголовок 1");        //Заглушка, отображающаяся в процессе загрузки        expect(wrapper.find(Spinner).length).toBe(1);        //Компонент, отображающий группу отображаться не должен        expect(wrapper.find(Group).length).toBe(0);    });

Этот сценарий мы реализуем по следующей схеме:

  1. Монтируем компонент

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

  3. Проверяем правильность отображения компонента

Пока мы писали тест, стало понятно, что для нашего приложения потребуются ещё 2 компонента, которые мы пока не реализовывали:

  • заглушка для отображения в процессе загрузки - спиннер и

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

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

Реализуем третий сценарий по аналогичной схеме:

test('Когда данные загружены, отображается заголовок и группа. Спиннер не отображается', () => {        const wrapper = mount(<App></App>);        wrapper.setState({            title: "Заголовок 2",            backendAddress: "/view_doc_2.html",            isLoading: false,            group: {                id: "1",                name: "Группа 1",                listOfCollaborators: []            },            loadData: () => {}        })        expect(wrapper.find('h1').length).toBe(1);        expect(wrapper.find('h1').text()).toBe("Заголовок 2");        expect(wrapper.find(Spinner).length).toBe(0);        expect(wrapper.find(Group).length).toBe(1);    });

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

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

Шаги 4 и 5. Реализация функций и формирование списка задач

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

  • во-первых, старайтесь писать более высокоуровневый код,

  • во-вторых, избегайте коварной ошибки, которая называется Knowledge Shift (сдвиг области знаний).

Высокоуровневый код

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

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

/** * Рассчитывает количество часов и минут между указанным начальным и конечным временем * Если время начала работы больше времени конца, то считается, что конечное время - это время следующих суток * @param worktimeFrom - время начала работы в формате ЧЧ:ММ (от 00:00 до 23:59) * @param worktimeTo - время конца работы в формате ЧЧ:ММ (от 00:00 до 23:59) * @return количество часов и минут между переданным временем в формате Хч Y?мин, например 6ч 18мин или 5ч, если количество часов полное *///TODOexport const getWorkDuration = (worktimeFrom: string, worktimeTo: string): string => {    return "6ч 18мин";}

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

  1. Привести параметры к числовым значениям, например, минутам, прошедшим с 00:00

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

  3. Привести получившуюся разницу к формату Xч Y?мин, который и вернуть из функции

И тут мы могли бы пойти двумя путями:

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

  • выделить каждый шаг в отдельную функцию.

В результате мы бы получили такую картину:

/** * Рассчитывает количество часов и минут между указанным начальным и конечным временем * Если время начала работы больше времени конца, то считается, что конечное время - это время следующих суток * @param worktimeFrom - время начала работы в формате ЧЧ:ММ (от 00:00 до 23:59) * @param worktimeTo - время конца работы в формате ЧЧ:ММ (от 00:00 до 23:59) * @return количество часов и минут между переданным временем в формате Хч Y?мин, например 6ч 18мин или 5ч, если количество часов полное */export const getWorkDuration = (worktimeFrom: string, worktimeTo: string): string => {    const worktimeFromInMinutes = getWorktimeToMinutes(worktimeFrom);    const worktimeToInMinutes = getWorktimeToMinutes(worktimeTo);    const minutesDiff = calcDiffBetweenWorktime(worktimeFromInMinutes, worktimeToInMinutes);    return convertDiffToString(minutesDiff);}/** * Вычиcляет количество минут, прошедших с начала суток * @param worktimeFrom - время в формате ЧЧ:ММ (от 00:00 до 23:59) * @returns количество минут, прошедших с 00ч 00минут *///TODOexport const getWorktimeToMinutes = (worktime: string): number => {    return 0;}/** * Вычисляет количество минут между началом и концом рабочего дня с учётом суток * @param worktimeFrom - время начала рабочего дня в виде количества минут, прошедших с начала суток * @param worktimeTo - время конца рабочего дня в виде количества минут, прошедших с начала суток * @returns количество минут между началом и концом рабочего дня с учётом суток *///TODOexport const calcDiffBetweenWorktime = (worktimeFrom: number, worktimeTo: number): number => {    return 0;}/** * Преобразовывает количество минут во время в формате Хч Y?мин * @param minutes - количество минут * @returns время в формате Хч Y?мин, например 6ч 18мин или 5ч *///TODOexport const convertDiffToString = (minutes: number): string => {    return "6ч 18мин";}

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

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

Knowledge Shift

Говоря о проектировании функций, конечно же, нельзя обойти стороной такую ошибку как Knowledge Shift.

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

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

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

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

export default class App extends Component<AppProps, AppState> {    state = {        title: "Отображение списка групп",        backendAddress: "",        isLoading: true,        group: undefined,        loadData: this.loadData.bind(this)    }    /**     * Метод загрузки данных с сервера     */    //TODO    loadData() {}    componentDidMount() {        //Вызываем метод loadData при загрузке приложения        this.loadData();    }    render() {        const {isLoading, group, title} = this.state;        return (            <div className="container">                <h1>{title}</h1>                {                    isLoading ?                    <Spinner/>                    //Структуру типа Group передаем на обработку отдельному компоненту                    : <Group group={group}></Group>                }            </div>        );    }}

Мы добавили в компонент реализацию метода жизненного цикла componentDidMount, который вызовет наш метод загрузки данных при отображении компонента. Но основной функционал у нас содержится в методе render. В нем мы выводим заголовок и, в зависимости от статуса загрузки, рисуем либо заглушку - спиннер, либо компонент Group.

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

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

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

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

Подробнее..

Техники повторного использования кода

08.03.2021 12:14:02 | Автор: admin

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

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

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

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

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

Чтобы разделить логику одного сложного объекта на составные части, существуют несколько механизмов:

  • Разделение функционала на классы/объекты и смешивание их полей, методов в одном объекте.

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

  • Вынесение части функционала в отдельные объекты/функции и помещение их в основной объект.

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

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

1) Объединение (смешивание) функционала нескольких объектов в одном
Смешивание и примеси (миксины)
Классическое наследование
Множественное наследование и интерфейсы

2) Композиция/агрегация с использованием списка
Прототипное наследование
Паттерн декоратор и аналоги

3) Композиция/агрегация с использованием одноуровневых структур данных (ссылка, массив ссылок, словарь)
Паттерн стратегия
Entity Component (EC)

4) Композиция/агрегация с вынесением логики вне объекта и его составляющих
Entity Component System (ECS)

5) Композиция/агрегация с использованием графов
Паттерн State machine

6)Композиция/агрегация с использованием деревьев
Паттерн composite и другие древовидные структуры
Behaviour tree

7) Смешанные подходы
React hooks

Объединение (смешивание) функционала нескольких объектов в одном.

Смешивание и примеси (миксины)

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

Существует паттерн примесь (mixin/миксина), в основе которого лежит смешивание.
Примесь это объект, поля и методы которого смешиваются с полями и методами других объектов, расширяя функциональность объекта, но который не используется сам по себе.
Можно добавить несколько миксин к одному объекту/классу. Тогда это схоже с множественным наследованием.

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

Здесь описывается классическое наследование, а не то, как наследование классов устроено в JS.

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

При наследовании происходит копирование членов родительского класса в класс-наследник. При создании экземпляра класса тоже происходит копирования членов класса. Я не исследовал детали этих механизмов, к тому же они явно отличаются в различных языках. Подробнее с этой темой можно ознакомиться в 4-ой главе книги "Вы не знаете JS:thisи Прототипы Объектов".

Когда можно использовать наследование, а когда не стоит?
Наследования не стоит использовать в качестве основной техники для повторного использования кода для сложных объектов. Его можно использовать совместно с композицией для наследования отдельных частей сложного объекта, но не для самого сложного объекта. Например, для React компонентов наследование плохо, а для частей (вроде объектных аналогов custom hooks) из которых мог быть состоять компонент-класс, наследования вполне можно использовать. Но даже так, в первую очередь стоит рассматривать разбиение на большее число составляющих или применения других техник, вместо наследования.

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

Множественное наследование и интерфейсы

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

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

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

Композиция/агрегация с использованием списка

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

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

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

Стоит отметить, что в JavaScript операции записи/удаления работают непосредственно с объектом. Они не используют прототип (если это обычное свойство, а не сеттер). Если в объекте нет свойства для записи, то создается новое. Подробнее об этом: https://learn.javascript.ru/prototype-inheritance#operatsiya-zapisi-ne-ispolzuet-prototip

Цепочка прототипов организована как стек (Last-In-First-Out или LIFO). Какой объект добавлен в цепочку последним (если считать от итогового объекта-контейнера), к тому обращение будет первым.

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

Паттерн Декоратор и аналоги

Декоратор (wrapper/обертка) позволяет динамически добавлять объекту новую функциональность, помещая его в объект-обертку. Обычно объект оборачивается одним декоратором, но иногда используется несколько декораторов и получается своего рода цепочка декораторов.

Цепочка декораторов устроена как стек(LIFO). Какой объект добавлен в цепочку последним (если считать от итогового объекта-контейнера), к тому обращение будет первым.

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

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

HOF (higher order function) и HOC (Higher-Order Component) - паттерны с похожей идей. Они оборачивают функцию/компонент другой функцией/компонентом для расширения функционала.

HOF - функция, принимающая в качестве аргументов другие функции или возвращающая другую функцию в качестве результата. Примером HOF в JS является функция bind, которая, не меняя переданную функцию, возвращает новую функцию с привязанным к ней с помощью замыкания значением. Другим примером HOF является карринг.

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

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

const funcA = сompose(funcB, funcC, funcD);

или же менее читабельный вариант:

const funcA = ()=> {  funcB( funcC( funcD() ) ) ;};

То же самое можно получить такой записью:

function funcA() {  function funcB() {      function funcC() {         function funcD()      }    }}  

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

Итого

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

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

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

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

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

Композиция/агрегация с использованием одноуровневых структур данных (ссылка, массив ссылок, словарь)

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

Паттерн стратегия

Паттерны декоратор и стратегия служат для одной цели с помощью делегирования расширить функциональность объекта. Но делают они это по разному. Хорошо описана эта разница по ссылке - https://refactoring.guru/ru/design-patterns/strategy:
Стратегияменяет поведение объекта изнутри, аДекораторизменяет его снаружи.

Паттерн Cтратегия описывает разные способы произвести одно и то же действие, позволяя динамически заменять эти способы в основном объекте (контексте).

На схеме ниже пара примеров связи стратегий с основным объектом.

К похожим способам (использование ссылки) расширения функционала объекта и повторного использования кода можно отнести события в HTML элементах и директивы в Angular и Vue.

<button onclick="customAction()" /> // html<input v-focus v-my-directive="someValue" /> // vue

Entity Component (EC)

Я не знаю, как называется данный паттерн. В книге Game Programming Patterns он называется просто "Компонент", а по ссылке http://entity-systems.wikidot.com/ его называют системой компонентов/сущностей. В статье же я буду называть его Entity Component (EС), чтобы не путать с подходом, который будет описан в следующей главе.

Сначала пройдемся по определением:

  • Entity (сущность) объект-контейнер, состоящий из компонентов c данными и логикой. В React и Vue аналогом Entity является компонент. В Entity не пишут пользовательскую логику. Для пользовательской логики используются компоненты. Компоненты могут храниться в динамическом массиве или словаре.

  • Component объект со своими данными и логикой, который можно добавлять в любую Entity. В React компонентах похожим аналогом являются custom hooks. И описываемые здесь компоненты и пользовательские хуки в React служат для одной цели расширять функционал объекта, частью которого они являются.

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

Данный паттерн похож на паттерн стратегия. Если в объекте использовать динамический массив со стратегиями, организовать их добавление, удаление и получение определенной стратегии, то это будет похоже на Entity Component. Есть еще одно серьезное отличие - контейнер не реализует интерфейс компонентов или методы для обращения к методам компонентов. Контейнер только предоставляет доступ к компонентам и хранит их. Получается составной объект, который довольно своеобразно делегирует весь свой функционал вложенным объектом, на которые он ссылается. Тем самым EC избавляет от необходимости использования сложных иерархий объектов.

Плюсы EC

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

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

  • можно изменять составной объект (Entity) в процессе выполнения, добавляя или удаляя его составляющие (компоненты)

Минусы

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

В одной из своих следующих статей я опишу применение этого подхода для React компонентов. Тем самым я покажу, как избавиться от первых двух недостатков компонентов на классах, описанных в документации React-а:
https://ru.reactjs.org/docs/hooks-intro.html#its-hard-to-reuse-stateful-logic-between-components
https://ru.reactjs.org/docs/hooks-intro.html#complex-components-become-hard-to-understand

Этот подход используется с самого начала выхода движка Unity3D для расширения функционала элементов (объектов) дерева сцены, включая UI элементы, где вы можете получше ознакомится с данным подходом: https://docs.unity3d.com/ru/2019.4/Manual/UsingComponents.html. Но в таком случае придёться потратить не мало времени на изучение движка.

Итого

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

В случае использования EC может появиться новая проблема при большом количестве компонентов, связанных между собой в одном объекте, становиться сложно разобраться в его работе. Выходом может стать некий компонент, который контролирует взаимодействия между компонентами в одной Entity или в группе вложенных Entities. Такой подход известен как паттерн Посредник (Mediator).

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

Композиция/агрегация с вынесением логики вне объекта и его составляющих

Entity Component System (ECS)

Я не работал с этим подходом, но опишу то, как я его понял.

В ECS объект разбивается на 3 типа составляющих: сущность, компонент (один или несколько), система (общая для произвольного числа объектов). Этот подход похож на EC, но объект разбивается уже на 3 типа составляющих, а компонент содержит только данные.

Определения:

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

  • Component - объект с определенными данными для Entity. Не содержит логики.

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

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

Пример простой ECS: Допустим есть несколько объектов, у которых есть идентификаторы. Несколько из этих объектов ссылаются на компоненты Position, в которых хранятся текущие координаты x, y, и на компонент Speed, который содержит текущую скорость. Есть система Movement, которая перебирает объекты, извлекает из них компоненты Position и Speed, вычисляет новую позицию и сохраняет новые значения x, y в компонент Position.

Как я уже говорил, реализации ECS могут отличаться. Например:

a) entity является контейнером для своих компонентов
http://entity-systems.wikidot.com/artemis-entity-system-framework

b) компоненты содержится в массивах/словарях. Entity является просто идентификатором, по которому определяется компонент, связанный с сущностью.
http://jmonkeyengine.ru/wiki/jme3/contributions/entitysystem/introduction-2
http://entity-systems.wikidot.com/fast-entity-component-system#java
https://www.chris-granger.com/2012/12/11/anatomy-of-a-knockout/

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

Плюсы ECS

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

  • Проще тестировать, т.к. нужно тестировать только системы. Компоненты и сущности тестировать не нужно.

  • Легко выполнять многопоточно.

  • Более эффективное использование памяти, кэша и, следовательно, большая производительность.

  • Легко реализовать сохранение всего приложения, т.к. данные отделены от функционала.

Минусы ECS

  • Высокая сложность, не стандартный подход.

  • для простых проектов является ненужным усложнением.

Так как я занимаюсь фронтенд разработкой, а она по большей части относится к разработки UI, то упомяну, что ECS используется в игре WorldofTanksBlitz для разработки UI:
https://www.youtube.com/watch?v=nu8JJEJtsVE

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

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

Композиция/агрегация с использованием графов

К данному способу повторного использования кода я отнес паттерн машина состояний (State machine/Finite state machine/конечный автомат).

Аналогом машины состояний простой является switch:

switсh (condition) { case stateA: actionA(); case stateB: actionB(); case stateC: actionC();}

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

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

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

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

Преимущества использования машины состояний:
Хорошо описано по ссылке: https://refactoring.guru/ru/design-patterns/state
Добавлю, что становится легче предусмотреть, обработать и протестировать все возможные случаи работы контекста (подсистемы), т.к. видны все его состояния и переходы. Особенно, если состояния являются просто объектами с данными и отделены от остальной логики и отображения.

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

Другие примеры использования в UI:
https://24ways.org/2018/state-machines-in-user-interfaces/
https://xstate.js.org/docs/ (библиотека для JS, которую можно использовать c React, Vue, Svelte)
https://github.com/MicheleBertoli/react-automata (библиотека для React)
http://personeltest.ru/aways/habr.com/ru/company/ruvds/blog/346908/

Подходит ли State machine в качестве основного механизма повторного использования кода и разбиения сложных объектов на составные части?
Иногда он так и используется. Но, он мне кажется сложноватым и не всегда подходящим для использования в качестве основного. Зато он точно хорош в качестве дополнительного, когда нужно организовать взаимодействия между несколькими объектами или частями составного объекта.

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

Композиция/агрегация с использованием деревьев.

Паттерн composite и другие древовидные структуры

Деревья часто встречается в разработке. Например, объекты в JavaScript могут содержать вложенные объекты, а те также могут содержать другие вложенные объекты, тем самым образую дерево. XML, JSON, HTML, DOM-дерево, паттерн Комповщик (Composite) все это примеры древовидной композиции.

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

Behaviour tree

Интересным вариантом композиции является Behaviour tree (дерево поведения). Это организация логики программы (обычно AI) или ее частей в виде дерева.

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

Я уже описывал деревья поведений в прошлом в этой статье.

Более наглядный пример схемы готового дерева из плагина banana-tree

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

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

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

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

Смешанные подходы

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

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

Примером смешивания подходов является анимационная система Mecanim в Unity3D, которая использует иерархическую машину состояний с деревьями смешивания (blend tree) для анимаций. Это относится не совсем к коду, но является хорошим примером комбинации подходов.

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

React hooks

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

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

Как я понял, хуки при вызове добавляют к текущему обрабатываемому компоненту (точнее к fiber-ноде) свое состояние объект, в котором могут быть указаны переданные сallback-и (в случае useEffect, useCallback), массив зависимостей, значения (в случае useState) и прочие данные (в случае useMemo, useRef, ).

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

Стоит отметить, что дерево fiber элементов не совсем соответствует структуре дерева компонентов. У Fiber-ноды только одна дочерняя нода, на которую указывает ссылка child. Вместо ссылки на вторую ноду, первая нода ссылается на вторую (соседнюю) с помощью ссылки sibling. К тому же, все дочерние ноды ссылаются на родительскую ноду с помощью ссылки return.

Также для оптимизации вызова эффектов (обновление DOM, другие сайд-эффекты) в fiber-нодах используются 2 ссылки (firstEffect, nextEffect), указывающие на первую fiber-ноду с эффектом и следующую ноду, у которой есть эффект. Таким образом, получается список нод с эффектами. Ноды без эффектов в нем отсутствуют. Подробнее об этом можно почитать по ссылкам в конце главы.

Вернемся к хукам. Структура сложного функционального компонента с несколькими вложенными custom hooks для разработчика выглядит как дерево функций. Но React хранит в памяти хуки компонента не как дерево, а как очередь. На схеме ниже изображен компонент с вложенными хукам, а под ним fiber-нода с очередью состояний этих же хуков.

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

Чтобы просмотреть содержимое fiber-ноды, достаточно воспользоваться console.log и вставить туда JSX код, который возвращает компонент:

function MyComponent() {  const jsxContent = (<div/>);  console.log(jsxContent);  return jsxContent;}

Корневую fiber-ноду можно просмотреть следующим образом:

const rootElement = document.getElementById('root');ReactDOM.render(<App />, rootElement);console.log(rootElement._reactRootContainer._internalRoot);

Также есть интересная наработка: react-fiber-traverse

Под спойлером приведен код компонента с хуками и отображение его fiber-ноды
import { useState, useContext, useEffect,useMemo, useCallback,         useRef, createContext } from 'react';import ReactDOM from 'react-dom';const ContextExample = createContext('');function ChildComponent() {  useState('childComponentValue');  return <div />;}function useMyHook() {  return useState('valueB');}function ParentComponent() {  const [valueA, setValueA] = useState('valueA');  useEffect(function myEffect() {}, [valueA]);  useMemo(() => 'memoized ' + valueA, [valueA]);  useCallback(function myCallback() {}, [valueA]);  useRef('refValue');  useContext(ContextExample);  useMyHook();  const jsxContent = (    <div>      <ChildComponent />      <button onClick={() => setValueA('valueA new')}>Update valueA</button>    </div>  );  console.log('component under the hood: ', jsxContent);  return jsxContent;}const rootElement = document.getElementById('root');ReactDOM.render(  <ContextExample.Provider value={'contextValue'}>    <ParentComponent />  </ContextExample.Provider>,  rootElement,);

С более подробным описанием работы внутренних механизмов React на русском языке можно ознакомиться по ссылкам:
Как Fiber в React использует связанный список для обхода дерева компонентов
Fiber изнутри: подробный обзор нового алгоритма согласования в React
Как происходит обновление свойств и состояния в React подробное объяснение
За кулисами системы React hooks
Видео: Под капотом React hooks

У подхода с хуками на данный момент есть недостаток - фиксированное дерево функций в компонентах. При стандартном использовании хуков, нельзя изменить логику уже написанного компонента или хуков, состоящих из других хуков. К тому же это мешает тестированию хуков по отдельности. В какой-то степени можно улучшить ситуацию композицией (compose) хуков. Например, существует такое решение: https://github.com/helloitsjoe/react-hooks-compose

Линейность кода и составляющих сложного объекта

Известно, что множество вложенные условий, callback-ов затрудняют читаемость кода: https://refactoring.guru/ru/replace-nested-conditional-with-guard-clauses
http://personeltest.ru/aways/habr.com/ru/company/oleg-bunin/blog/433326/ (в статье упоминается линейный код)
https://www.azoft.ru/blog/clean-code/ (в статье упоминается линейность кода)

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

Подробнее..

Рабочее место на 0,5 м2

30.01.2021 12:05:52 | Автор: admin

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

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

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

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

Когда все закрыто, вообще сливается со стеной.

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

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

Я даже у себя еще смог бы найти 2-3 места, где можно было бы втиснуть такое рабочее пространство в условиях удаленки. Если есть возможность, то глубину можно сделать и больше 30 см., тогда будет больше пространства внутри для хранения. Но в данном случае было лишь 30 см. места. Можно кстати и чуть уже если что.

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

Вот так это вписалось в интерьер и в целом не перекрыло выход на балкон. Рабочее место котика тоже переместилось ближе к жене.

По стоимости мне это обошлось в 5000 руб. с доставкой и плюс специальное сверло для отверстий под провода (сразу не продумал), но там пришлось и соседнюю тумбу просверлить чуть.

Комфортной всем удаленки.

Подробнее..

Recovery mode Рабочее место на 0,5 м2

30.01.2021 14:20:43 | Автор: admin

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

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

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

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

Когда все закрыто, вообще сливается со стеной.

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

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

Я даже у себя еще смог бы найти 2-3 места, где можно было бы втиснуть такое рабочее пространство в условиях удаленки. Если есть возможность, то глубину можно сделать и больше 30 см., тогда будет больше пространства внутри для хранения. Но в данном случае было лишь 30 см. места. Можно кстати и чуть уже если что.

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

Вот так это вписалось в интерьер и в целом не перекрыло выход на балкон. Рабочее место котика тоже переместилось ближе к жене.

По стоимости мне это обошлось в 5000 руб. с доставкой и плюс специальное сверло для отверстий под провода (сразу не продумал), но там пришлось и соседнюю тумбу просверлить чуть.

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

Комфортной всем удаленки!

PS. 1. Забыл написать, что такой не глубокий, надо обязательно крепить к стене. Он конечно сам стоит, но если уже откидывать столешницу, и вес на неё нагружать, то может и опрокинуться. Я прикрепил одним саморезом по центру к стене - вроде намертво. Мебельщики еще с производства звонили и сразу предупредили, что стоять не будет сам. Они вообще несколько раз звонили разные, и с удивлением спрашивали, точно такой делать. Но кстати потом, которые доставляли, как только поставили, сказали, что решение то нормальное и признались что в мастерской посмеивались, а теперь может возьмут модель к производству если спрос будет. Единственное что бы я еще поменял (кроме немного скругленных углов столешницы) - это чтобы столешница при открывании заходил под полку дальним краем, как бы в упор. Тогда будет прочнее. Ну и материал столешницы сделать толще

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

Подробнее..

Как немного облегчить себе жизнь при проектировании электроники?

27.02.2021 00:16:25 | Автор: admin
GALILEO by Intel. Честно взято тут (http://personeltest.ru/aways/www.ema-eda.com/sites/ema/files/Constraint%20Management.zip)GALILEO by Intel. Честно взято тут (http://personeltest.ru/aways/www.ema-eda.com/sites/ema/files/Constraint%20Management.zip)

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

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

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

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

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

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

Посадочные места и трёхмерные модели

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

А что является таковой для печатных плат?

Правильно. Посадочные места ЭРИ!

Прежде всего, забудьте про такую вещь, как чужие посадочные места из непонятной (или понятной) библиотеки из интернета. Только своё, только хардкор! Да, это сложно, особенно, когда проектирование электроники - не ваша основная область деятельности. Но если вы хотите делать действительно качественную электронику - начните делать футпринты самостоятельно. Так в них будут ваши и только ваши ошибки, а не чьи-то чужие. Это не так сложно, особенно если пользоваться адекватными генераторами. Например, с недостатками бесплатной версии PCB Library Expert вполне можно мириться. Зато вы получаете посадочные по IPC из коробки. Вместе с адекватными трёхмерками. При этом, создавая свою библиотеку ЭРИ, вам лучше заранее договориться, как будут называться файлы. Логично, если вы рисуете посадочные по IPC, и называть их так же по методологии IPC. Вы ведь в курсе (да?), что те же конденсаторы, одинаковые по размерам в плоскости, могут иметь совершенно разную высоту, и иногда это может иметь решающее значение.

RESC100X50X40L25NRESC100X50X40L25N

Почему это так важно? Следование рекомендациям IPC позволяет в большинстве случаев получить гарантированно адекватную пайку (для многих стандартных корпусов). Потому что люди старались, вкладывали свой опыт и знания в информацию, которая содержится в IPC-7351B и прочих документах этой замечательной организации (долой почти два с половиной часа времени и мои уши слегка подвяли от акцента Роберта, но оно того стоит, рекомендую!). Сколько моих плат было спаяно что вручную, что на конвейере в печи оплавления - практически не было проблем. При этом посадочные по IPC вполне годятся и для ручной пайки. Может при помощи ЭПСН вы их и не спаяете (на самом деле, сильно зависит от жала), но каким-нибудь клоном серии T12 - запросто!

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

Типичный пример нестандартного трындеца от TIТипичный пример нестандартного трындеца от TI

Для совсем ленивых есть такие сервисы, как SnapEDA, Ultra Librarian и прочие, которые предоставляют вам услуги генерации посадочных мест, УГО и трёхмерок интересующих вас ЭРИ. Поскольку эти сервисы ориентированы на массовость экспорта, как правило, они не учитывают некоторых особенностей конкретных электрических САПР. Например, в SnapEDA невозможно выдать посадочные с площадками с закруглёнными углами. А переделывать сгенерированные из таких сервисов посадочные - то ещё удовольствие. Если берёте оттуда трёхмерки, обязательно проверяйте размеры. То же самое касается и 3D ContentCentral и GrabCAD. Сколько ни пытался оттуда что-то использовать в неизменном виде, всегда были расхождения по размерам.

Паяльная маска (слева) и импортированные из pdf контуры (справа)Паяльная маска (слева) и импортированные из pdf контуры (справа)

Если производитель ЭРИ даёт в документации нормальный векторный чертёж своего изделия (вы же умеете на глаз отличать векторную картинку в pdf от растровой?), бывает чрезвычайно полезно перевести его в, например, dxf, который уже потом, как правило, можно импортировать в вашу любимую САПР, чтобы убедиться, что всё то, что вы понарисовали, соответствует действительности. Это альтернативный безбумажный (а не как советует замечательный Роберт) метод проверки адекватности вашего посадочного места. В комментариях к видео еще предлагают печатать для проверки не на бумаге, а на прозрачной плёнке, но это тоже такое себе. Что делать со статикой? Как быть, если компоненты ещё только в закупке и приедут недель через десять? В общем, спорно. Кстати, импортируя dxf, всегда хорошо проверять, а соответствуют ли расставленные размеры реальным (и дело не только в чудесных словах not to scale на чертеже из документации). Я так и не разобрался, откуда возникают ошибки в импортированных из pdf размерах, но это бывает. Поэтому проверяйте, проверяйте и еще раз проверяйте! На фото ниже дорогущий компас почему-то не работал. Интересно, почему?

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

Зачем вообще заморачиваться с трёхмерками в библиотеке? Всё очень просто: чтобы облегчить себе и коллегам проектирование корпуса и вообще любой сопутствующей механики. Намного проще при создании компонента сразу привязывать к нему модель, чем после трассировки всей платы натужно сопоставлять примененные посадочные места с имеющимися моделями. И опять же, не ленитесь использовать конкретные трёхмерные модели, а не единый условный конденсатор "0603" на всю пассивку 0603. К тому же, многие производители предоставляют трёхмерки своих изделий, по которым в том числе можно проконтролировать адекватность создания посадочного места.

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

Шёлк

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

Догадаетесь, где у этого источника питания резисторы обратной связи?

Не идеальная плата, но работаетНе идеальная плата, но работает

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

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

Вот смотрите:

Шёлковые треугольникиШёлковые треугольники

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

opt3001opt3001

Что делать, когда шёлк нет смысла заказывать, а показать первый вывод хочется? А всё просто. Переносите треугольник в слой меди (или вскрытия маски). Разумеется, если окружающее пространство позволяет. Ещё один удобный приём, когда вы планируете заказывать шелкографию: рисуйте на многоногих разъемах не только первый вывод, но и с определённым шагом номера других выводов. При отладке устройства это очень облегчает тыканье осциллографом, поскольку вам не приходится вручную отсчитывать какой-нибудь семьдесят третий вывод от начала или считать, какой он там будет по порядку с конца, если в разъеме у вас 120 контактов.

Как было спроектировано и как получилось. Напаянное всё уже в работе, но смысл понятенКак было спроектировано и как получилось. Напаянное всё уже в работе, но смысл понятенЭто разъем для UART. Сами разберётесь, почему земля в центре, а не сбоку?Это разъем для UART. Сами разберётесь, почему земля в центре, а не сбоку?

Т.е. вам, практически, не требуется монтажная схема. Достаточно будет только электрической принципиальной с подписанными номерами контактов. Что делать со сквозными разъемами? Меня очень расстраивают печатные платы, на которых первый контакт в разъемах никак не обозначен отличающейся по форме медью. Ведь сразу очевидно, где тут первый контакт. Это бывает очень полезно при отладке в полях, когда плата установлена в корпусе, вы в форме буквы зю изогнулись со щупом осциллографа и еле подлезаете к разъему, а шёлк-то остался с другой стороны и вы, подсвечивая себе зиппой, думаете - а где же тут первый вывод в разъеме? С таким никогда не запутаетесь. Кстати, буду очень признателен, если кто-нибудь в комментариях объяснит мне смысл овально-вытянутых eagle-style площадок для выводных разъемов. Я вот как-то до сих пор этого не понимаю.

Порядок слоёв

Как было и как сделалиКак было и как сделали

Вполне понятно, что если вы делаете двухслойку, то скорее всего герберы (я очень надеюсь, что в производство вы отдаёте герберы, а не проекты) имеют имена для меди вида TOP\BOT или Верх\Низ и тому подобное. А как убедиться, что вашу заметку о том, в каком порядке идёт медь, технолог прочитал? Или что банально слои не перепутали местами? У меня было несколько раз такое. Благодаря описанному ниже способу, завод без особых проблем и проволочек признавал брак и переделывал всё за свой счёт. Приём прост и эффективен. Добавляйте номера слоёв прямо в медь. Вот очередной пример слева. Вверху показано то, как это спроектировано, а далее вид сверху и снизу. Не забывайте делать вскрытие маски в месте размещения такой маркировки. При необходимости для просвечивания пользуйтесь фонариком. Да, разумеется, всё зависит от толщины слоёв диэлектрика, но, как правило, всё достаточно хорошо видно на четырёхслойках. На шестислойках просто с цифрами всё уже гораздо хуже. Что делать в таком случае? Лесенку. Типа вот такой:

Лесенка из меди (http://personeltest.ru/aways/dornerworks.com/blog/pcb-stacking-stripes-could-change-the-way-you-look-at-hardware)Лесенка из меди (http://personeltest.ru/aways/dornerworks.com/blog/pcb-stacking-stripes-could-change-the-way-you-look-at-hardware)

И вот как это выглядит на реальной плате:

Вид торца печатной платы (http://personeltest.ru/aways/resources.altium.com/p/pcb-design-test-test-structures-and-types-tests-part-1)Вид торца печатной платы (http://personeltest.ru/aways/resources.altium.com/p/pcb-design-test-test-structures-and-types-tests-part-1)

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

Инженеры из Gigabyte (http://personeltest.ru/away/www.xtremesystems.org/forums/showthread.php?267340-Sandybridge-for-overclocking-two-solutions-review) одобряют. Такую же маркировку я наблюдал и на некоторых материнках AsusИнженеры из Gigabyte (http://personeltest.ru/away/www.xtremesystems.org/forums/showthread.php?267340-Sandybridge-for-overclocking-two-solutions-review) одобряют. Такую же маркировку я наблюдал и на некоторых материнках Asus

Выравнивание

Хотя сам монтаж - это совсем отдельная тема, стоит упомянуть про вещь, которая может его немного облегчить.

В идеальном мире есть схемотехник, тополог, SI, PI и многие другие инженеры, которые по-хорошему должны работать над проектом. Но, жизнь часто вносит свои коррективы, и иногда паять, трассировать, моделировать и многое другое делать приходится самому (да-да, колхоз как он есть!). А когда в проекте есть ЭРИ, которые из-за размеров, веса или технологии пайки не смогут нормально гарантированно самоцентрироваться из-за поверхностного натяжения припоя, хочется изначально установить их максимально точно. Хорошо, когда есть специальный манипулятор для установки компонентов. А что делать, когда есть только пинцет, да и тот вовсе не вакуумный?

А есть вот такой приём:

Вскрытие маски по углам под корпусом (слева), оно же с периметром корпуса (центр), как это выглядит в ортографической проекции в 3D (справа)Вскрытие маски по углам под корпусом (слева), оно же с периметром корпуса (центр), как это выглядит в ортографической проекции в 3D (справа)

Идея очень проста: в углах компонента делаем вскрытие паяльной маски (если позволяет окружающая топология, очевидно). Ширины линии вскрытия в 0,15мм вполне достаточно. Поскольку точность совмещения при производстве печатной платы слоёв маски с медью существенно лучше точности совмещения меди и шёлка, то можно надеяться, что вскрытые уголки будут на своём месте. С шёлком такое иногда не прокатывает, особенно на многорядных BGA корпусах, когда сместил всё на 0.5мм и уже промахнулся на целый ряд. Очевидно, что надо понимать, для какого т.н. material condition вы делаете свои модели и посадочные места, для nominal или maximum (я всё делаю для nominal) и, соответственно, можно ожидать, что уголки будут работать не всегда идеально.

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

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

И последнее, но далеко не последнее

А то будет как тут https://www.eevblog.com/forum/projects/silk-screen-on-exposed-pcb-padsА то будет как тут https://www.eevblog.com/forum/projects/silk-screen-on-exposed-pcb-pads

Проверяйте герберы, которые отправляете на завод, не ленитесь! Я не буду советовать конкретный софт здесь, поскольку есть много разных просмотрщиков с разной сложностью использования и задачами, которые они могут решать. Лучше, если вы смотрите герберы не в той же программе, которая их генерирует. Ну так, на всякий случай. Наверное, очень здорово, если в таком просмотрщике можно что-то измерить (те же минимальные зазоры и толщины, например, чтобы убедиться, что ваша плата соответствует определенному классу точности). Сверка герберов даже чисто визуально помогает избежать глупых ошибок. Вы инженер, который проектирует печатную плату. Технолог на заводе в общем случае не обязан за вас думать. Его задача - изготовить вам плату максимально близко к тому, что вы прислали. А уж что вы там напроектировали, это не его забота. Может вам так надо.

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

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

Stay tuned, как говорится!

Подробнее..

Собираем ATARI 2600 с нуля

21.03.2021 14:19:44 | Автор: admin

Вместо интро

Atari 2600 или ее клонов у меня никогда не было, да и, в общем-то, не интересовался ею в принципе. Идея собрать свой клон с нуля родилась спонтанно после просмотра видео на Ютубе. Порадовала большая фанбаза западного сообщества, где она долгое время было массовой домашней системой, ведь официально приставка выпускалась целых 14 лет! Еще одной монетой в копилку этого проекта стала относительная простота схемы, ну и спортивный интерес. Все фотографии сборки были сделаны из видео, поэтому сразу извиняюсь за качество. Много картинок!

Изучение и модификация схемы

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

Схема оригинальной версии Atari 2600 (взято с репозитория atariage.com)Схема оригинальной версии Atari 2600 (взято с репозитория atariage.com)Версия Atari 2600 Jr. (взято с репозитория atariage.com)Версия Atari 2600 Jr. (взято с репозитория atariage.com)

Я же решил сделать сборную солянку с четким пониманием что и как должно работать. Схема питания более, чем стандартная для того времени - входное напряжение 8-9в подается на стабилизатор 78L05 и далее расходится по схеме. НО! Для правильной работы видеочипа TIA (Television Interface Adaptor), а точнее, для правильной работы цвета, на чип необходимо дополнительное напряжение 7-8в. И если в оригинальном варианте схемы это напряжение формируется с применением примитивной повышайки на двух диодах и тактового генератора, то в версии Jr. пошли более простым путем - повышенное напряжение забирается до стабилизатора, прямо с блока питания. Меня этот вариант не совсем устраивал, так как я собирался запитывать приставку от обычной USB зарядки, поэтому выбрал участок из оригинальный схемы, но стабилизатор на всякий случай оставил. Подключение джойстиков и переключателей режимов в обеих версиях абсолютно одинаковые, оставляем как есть.

Далее по схеме идут 2 генератора частоты - один основной тактовый генератор, а второй осциллирует частоту цветовой поднесущей.

Схематично второй генератор выглядит очень странно. На деле это не совсем генератор... С видеочипа на него приходит частота 900КГц, которая раскачивает второй кварц на пятой гармонике и уже эта частота усиливается транзистором и подается обратно в видеочип. Забегая вперед, скажу, что запустить эту часть схемы я так и не смог. Кварц никак не хотел раскачиваться. Может сам кварц не подходит для этой схемы, может погрешности номиналов обвязки не вписали в допуски... Не знаю. Вместо этого чудо-решения поставил обычный генератор на одном транзисторе и каких-либо проблем с цветом не возникло. Позже на SECAM-версии схемы увидел точно такое-же решение, только там была продублирована схема основного генератора. Далее по списку идет аудио-видео часть. Оригинальная схема предлагает безальтернативный способ вывода картинки - ВЧ модулятор для подключения к телевизору. То есть нет ни композитного видео, ни раздельного аудио выходов. Решается это довольно просто, достаточно добавить в схему, так называемый, AV мод. Со слотом картриджа тоже случилась засада... 24 пинового разъема с шагом 2,54мм приобрести не удалось - местные магазины оценивают его в неадекватные 500-600руб, а на Ali штучно не продают, только партиями по 5-10 штук. Поэтому было решено использовать разъем с шагом 3.96мм, который достать гораздо проще. По факту, приобретать оригинальные картриджи я не планирую и особой разницы нет для какого шага травить самодельный картридж.

Набросок для дальнейших действийНабросок для дальнейших действийМой вариант схемы в Proteus. Не слишком красиво, но для разводки платы вполне сгодитсяМой вариант схемы в Proteus. Не слишком красиво, но для разводки платы вполне сгодитсяРазводка печатной платы и ее 3D визуализацияРазводка печатной платы и ее 3D визуализация

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

Сборка платы

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

Черный экран отчаянияЧерный экран отчаяния

Сначала запаял тактовый генератор и проверил его работу. Далее второй "генератор-усилитель" и необходимую для первого запуска мелкую обвязку. После установки TIA не обнаружил частоту поднесущей цветности... Но это влияет только на цвет, с этим разберемся позже. После установки всех остальных чипов первая попытка включения. Ну, и кто-бы сомневался, что она не увенчается успехом. На мониторе появился черный экран, это значит, что какой-то сигнал все таки есть и это не могло не радовать. Но исправная приставка при включении без картриджа должна показывать рандомные вертикальные полосы, которых не было. Еще несколько вечеров ушло на полную прозвонку схемы и вдруг.... Причина нашлась после очередного обращения взора на схему. Инверсный выход RESET процессора был посажен на землю! То есть ЦП постоянно находился в режиме сброса. Сверка с другими схемами подтвердила этот косяк, автор либо подключил резистор не туда, либо забыл поставить еще один подтягивающий резистор.

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

Заветные полосы надеждыЗаветные полосы надежды

Но ничего не произошло. Дальше полос и прочих артефактов дело не шло. Пришел к выводу, что у меня неисправен процессор или чип RIOT (RAM/IO/TIMER). Заказал новую пару и ушел в режим ожидания... После замены чипов все сразу заработало! Но цвета не было... После нескольких вечеров безуспешных попыток разобраться, плюнул на это дело и запаял самый примитивный генератор на одном транзисторе. Цвет появился и каких-либо проблем с ним не возникло.

Теперь у нас есть цвет!Теперь у нас есть цвет!

Кстати, по поводу джойстиков. Стандарт подключения периферии у Arati был настолько прост и функционален, что по нему работали геймпады как на SEGA GENESIS/MD, так и на ряде домашних компьютеров, например, на том же Commodore 64. Поэтому вопрос с поиском или изготовлением оригинальных джойстиков не стоял, китайский геймпад от SEGA MD будет отлично работать. Но... после подключения вылезла следующая проблема - они были перепутаны местами.... Как оказалось, это еще один косяк схемы... При этом, она лежит в репозитории самого большого западного Atari-сообщества! Что это такое и почему никто не исправит это недоразумение мне вообще не ясно. К слову, эта схема датирована 2000 годом!

То есть 20 лет все просто клали болт на такие "мелкие недоразумения". Написал письмо в поддержку сайта AtariAge, но ответа не последовало... Ладно, черт с ними, нам надо двигаться дальше.

Создание корпуса

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

Далее нудная процедура шпаклевания и зашкуривания, дабы устранить все артефакты 3D печати.

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

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

Почти всеПочти всеА вот теперь точно все!А вот теперь точно все!

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

В ходе проектирования и сборки было допущено довольно много ошибок, большая часть из них - банальная невнимательность и наивное доверие "народным" схемам. Нужно было сразу брать исходную схему из сервис мануала, который есть на просторах интернета. Но в этот раз, скорее, повезло, большинство ошибок удалось решить без переделывания всей платы. Зачем все это было сделано? Да просто так, из спортивного интереса, а заодно заполнить пробел общения с этой легендарной приставкой, ведь именно она стала по-настоящему первой массовой игровой консолью, именно на ней зарождались все классические жанры игр, многие из которых живы и по сей день. Следующим шагом будет создание картриджа для запуска более емких игр, нежели 2-4кб. Архив с исправленной схемой, файлами корпуса и прочими дополнительными материалами можно забрать тут https://drive.google.com/drive/folders/1EhmmadIfU5a0eB8TdSdzneIpotHtWVXO?usp=sharing Весь процесс создания, а так же исторический путь этой приставки и подробный разбор работы представлен в видео

Подробнее..

Перевод Всё как в жизни законы проектирования космических кораблей

25.01.2021 12:19:59 | Автор: admin

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


  1. Проектирование это работа с цифрами. Исследование без цифр всего лишь мнение.

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

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

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

  5. (Закон Миллера) Кривая определяется тремя точками.

  6. (Закон Мара) Все линейно, если волшебным толстым маркером построить график в двойном логарифмическом масштабе.

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

  8. В природе оптимум почти всегда где-то посередине. Не доверяйте утверждениям, что оптимум находится в крайней точке.

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

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

11. Иногда самый быстрый способ дойти до конца выбросить все и начать сначала.

12. Не существует единственно правильного решения. Но всегда есть несколько неправильных.

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

14. (Закон Эдисона) Лучшее враг хорошего.

15. (Закон Ши) Способность улучшать дизайн проявляется в первую очередь в интерфейсах. Это также лучшее место, чтобы все испортить.

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

17. Факт публикации исследования не делает его верным.

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

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

20. Плохой проект при хорошей подаче в конце концов обречен. Хороший проект при плохой подаче обречен сразу.

21. (Закон Ларраби) Половина из того, что вам рассказывали на уроках в школе чушь. Образование это выяснение того, какая половина чушью не является.

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

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

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

25. (Закон Боудена) После неудачного тестирования всегда можно улучшить расчеты, чтобы показать, что у вас действительно всё это время был отрицательный запас прочности.

26. (Закон Монтемерло) Только без глупостей.

27. (закон Варси) Сроки сдвигаются только в одном направлении.

28. (Закон Рейнджера) Не существует такой вещи, как бесплатный запуск.

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

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

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

32. (Закон демонстраций Аткина) Когда оборудование работает идеально, действительно важные посетители не появляются.

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

34. (Закон Рузвельта о планировании задач) Делайте то, что можете, там, где находитесь, с тем, что имеете.

35. (Закон о проектировании де Сент-Экзюпери) Конструктор знает, что он достиг совершенства не тогда, когда нечего добавить, а тогда, когда нечего убрать.

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

37. (Закон Хеншоу) Одно из ключевых правил для достижения успеха миссии установление четких границ ответственности.

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

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

40. (альтернативная формулировка) Три ключевых правила для обеспечения доступности и своевременности новой космической программы:

  • Никаких новых ракет-носителей

  • Никаких новых ракет-носителей

  • Что бы вы ни делали, не создавайте новых ракет-носителей.

41. (Закон Макбрайна) Вы не сможете сделать лучше, пока не сделаете, чтобы работало.

42. На то, чтобы сделать правильно, времени всегда не хватает, но на то, чтобы потом переделывать, время всегда находится.

43. Нет программы полета нет денег. Есть программа полета нет времени.

44. Вы действительно начинаете что-то понимать, когда замечаете это в третий раз (или когда впервые учите этому).

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


Источники:

  1. Оригинальная статья

  2. Страничка Дэйва Аткина в университете Мэрилэнда


Если вам интересны материалы подобной тематики приглашаю вас в свойтелеграм-канал. Пишу в коротком формате о развитии softskills, brain science и своей работе в ИТ.

Подробнее..

Кому нужен аналоговый дизайн?

12.04.2021 14:15:58 | Автор: admin

Нас окружают цифровые устройства: цифровые камеры, цифровые телевизоры, цифровая связь (сотовые телефоны и Wi-Fi), интернет и так далее. Почему же тогда нас все еще должны интересовать аналоговые схемы? Разве аналоговый дизайн не стар и не вышел из моды? Появятся ли через десять лет даже рабочие места для разработчиков аналоговых устройств? Интересно, что эти вопросы поднимались каждые пять лет на протяжении последних 50 лет, но в основном теми, кто либо не понимал аналогового дизайна, либо не хотел иметь дело с его проблемами. Давайте постараемся разобраться почему аналоговый дизайн по-прежнему важен, актуален и сложен, и останется таковым в ближайшие десятилетия.

Почему аналоговый?

Многие электронные системы выполняют две основные функции: они принимают сигнал, а затем обрабатывают и извлекают из него информацию. Ваш сотовый телефон принимает радиочастотный (РЧ, англ. RF, radio-frequency) сигнал и после его обработки передает голосовую информацию или данные. Точно так же ваша цифровая камера определяет интенсивность света, излучаемого различными частями объекта, и обрабатывает результат для извлечения изображения.

Мы интуитивно знаем, что сложная задача обработки предпочтительно выполняется в цифровой области. Фактически, мы можем задаться вопросом, можем ли мы напрямую оцифровать сигнал и избежать каких либо операций в аналоговой области. На рисунке ниже показан пример, в котором радиочастотный сигнал, принимаемый антенной, оцифровывается аналого-цифровым преобразователем (АЦП, англ. ADC, analog-todigital converter) и полностью обрабатывается в цифровой области. Отправит ли этот сценарий разработчиков аналоговых и радиочастотных
устройств на биржу труда?

Рис. 1.1: Гипотетический радиоприемникРис. 1.1: Гипотетический радиоприемник

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

Рассмотрим еще один интересный пример проблем, который возникает при изучении сигналов мозга. Каждый раз, когда нейрон в вашем мозгу срабатывает, он генерирует электрический импульс высотой в несколько милливольт и длительностью в несколько сотен микросекунд [рис. 1.2(а)]. Для мониторинга активности мозга нейронная записывающая система может использовать десятки зондов (электродов) [рис. 1.2 (b)], каждый из которых воспринимает серию импульсов. Теперь сигнал, производимый каждым датчиком, должен быть усилен, оцифрован и передан по беспроводной сети, чтобы пациент мог свободно перемещаться [рис. 1.2 (c)]. Электроника считывания, обработки и передачи в этой среде должна потреблять небольшое количество энергии по двум причинам: (1) чтобы позволить использовать небольшую батарею в течение нескольких дней или недель, и (2) чтобы минимизировать повышение температуры микросхемы, которая в противном случае может повредить ткани пациента. Среди функциональных блоков, показанных на рис. 1.2 (c), усилители, АЦП и ВЧ-передатчик - все это аналоговые схемы, которые потребляют большую часть энергии.

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

Когда цифровые сигналы становятся аналоговыми?

Использование аналоговых схем не ограничивается аналоговыми сигналами. Если цифровой сигнал настолько мал и/или настолько искажен, что цифровой вентиль не может его правильно интерпретировать, тогда должен вмешаться разработчик аналоговых сигналов. Например, рассмотрим длинный USB-кабель, по которому скорость передачи данных между двумя ноутбуками составляет сотни мегабит в секунду. Как показано на рис. 1.3, портативный компьютер 1 передает данные по кабелю в виде последовательности единиц и нулей.

Рис. 1.3: Эквализация для компенсации затухания высоких частот в кабеле USB.Рис. 1.3: Эквализация для компенсации затухания высоких частот в кабеле USB.

К сожалению, кабель имеет ограниченную полосу пропускания, ослабляя высокие частоты и искажая данные, когда они достигают портативного компьютера 2. Это устройство теперь должно выполнять считывание и обработку, первое из которых требует аналоговой схемы (называемой эквалайзером), которая корректирует искажения. Например, поскольку кабель ослабляет высокие частоты, мы можем спроектировать эквалайзер для усиления таких частот, как концептуально показано на рис. 1.3 в виде графика 1/|H|.

Читатель может задаться вопросом, может ли задача выравнивания на рис. 1.3 быть выполнена в цифровой области. То есть, можем ли мы напрямую оцифровать полученный искаженный сигнал с цифровой коррекцией для кабеля с ограниченной полосой пропускания, а затем выполнять стандартную обработку сигнала USB? Действительно, это возможно, если требуемый здесь АЦП требует меньшей мощности и меньшей сложности, чем аналоговый эквалайзер. После подробного анализа аналоговый дизайнер решает, какой подход использовать, но мы интуитивно знаем, что при очень высоких скоростях передачи данных, например, десятки гигабит в секунду, аналоговый эквалайзер оказывается более эффективным, чем АЦП.

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

Аналоговый дизайн пользуется большим спросом

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

Рис. 1.4: Количество статей по аналоговому дизайну, опубликованных вISSCC за последние годыРис. 1.4: Количество статей по аналоговому дизайну, опубликованных вISSCC за последние годы

На рис. 1.4 показано количество статей по аналоговым технологиям, опубликованных на Международной конференции по твердотельным
схемам (ISSCC) за последние годы, где аналог определяется как статья, требующая знаний в области проектирования аналоговых микросхем. Заметьте, что большинство статей связано с аналоговым дизайном. Это верно даже при том, что аналоговые схемы обычно намного менее сложные, чем цифровые; АЦП содержит несколько тысяч транзисторов, тогда как микропроцессор использует миллиарды.


Проблемы аналогового дизайна

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

Недостатки транзисторов

В результате масштабирования транзисторы продолжают становиться быстрее, но за счет своих аналоговых свойств. Например, максимальное усиление напряжения, которое может обеспечить транзистор, уменьшается с каждым новым поколением КМОП-технологии (КМОП, комплементарная структура металл-оксид-полупроводник; англ. CMOS, complementary metal-oxide-semiconductor). Более того, характеристики транзистора могут зависеть от его окружения, то есть от размера, формы и расстояния до других компонентов на кристалле вокруг него.

Уменьшение напряжения питания

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

Потребляемая мощность

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

Сложность схем

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

Вариации PVT

Многие параметры устройства и схемы зависят от процесса изготовления (process), напряжения питания (supply voltage) и температуры (temperature) окружающей среды. Мы обозначаем эти эффекты как PVT (process-voltage-temperature) и проектируем схемы так, чтобы их производительность была приемлемой для указанного диапазона вариаций PVT. Например, напряжение питания может изменяться от 1 В до 0,95 В, а температура от 0 до 80 градусов. Надежная аналоговая конструкция в КМОП-технологии - сложная задача, поскольку параметры устройств значительно различаются в зависимости от PVT.


Почему интегрированные микросхемы?

Идея размещения нескольких электронных устройств на одной подложке возникла в конце 1950-х годов. За 70 лет технология эволюционировала от производства простых микросхем, содержащих небольшое количество компонентов, до флэш-накопителей с одним триллионом транзисторов, а
также микропроцессоров, содержащих несколько миллиардов устройств. Как предсказывал Гордон Мур (Gordon Moore, один из основателей Intel) в начале 1970-х годов, количество транзисторов на чип продолжало удваиваться примерно каждые полтора года. В то же время минимальный размер транзисторов снизился с примерно 25 мкм в 1960 году до примерно 12 нм в 2015 году, что привело к огромному увеличению быстродействия интегральных схем.

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

Почему КМОП?

Идея полевых транзисторов металл-оксид-кремний (MOSFET) была запатентована Дж. Э. Лилиенфельдом (J. E. Lilienfeld) в начале 1930-х годов, задолго до изобретения биполярного транзистора. Однако из-за производственных ограничений МОП-технологии стали применяться намного позже, в начале 1960-х годов, когда первые несколько поколений производили только транзисторы n-типа. В середине 1960-х годов были представлены комплиментарные МОП-устройства (КМОП) (то есть с транзисторами как n-типа, так и p-типа), что положило начало революции в полупроводниковой промышленности.

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

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

Как технология CMOS стала доминировать на аналоговом рынке?

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

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


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

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

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

P.S. Аналоговый дизайн используется практически везде.

Подробнее..

Connected! Самое главное о дизайне VPN-приложения

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

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

Так получилось, что VPN-клиенты я делал намного чаще, чем пользовался ими по прямому назначению. В 2020 году почти каждый месяц я стабильно получал предложение о создании быстрого, удобного и безопасного. Одни предлагали два сервера, оба из которых платные. Другие давали выбор из 50 стран и возможность подключаться сразу к 4. Несмотря на разный подход, у этих приложений одинаковые паттерны.

Ниже я постарался описать все самое важное, не заостряя внимание на деталях.


Privacy, разрешения

Стандартная процедура, без которой мы не можем предоставить свои услуги. Даем ее сразу после Splash screen. Чем быстрее пользователь разберется с процедурой и забудет о ней, тем раньше он воспользуется приложением. Поэтому мы делаем привычный Agree внизу экрана и нативный Allow, который отпушит в настройки и вернет назад.

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

Кнопка

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

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

Одно слово и противоположный цвет. Все довольны, всем понятно.

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

Список серверов

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

Структурируем список серверов по качеству соединения, количеству доступных мегабайт, алфавиту или другому удобному показателю. Если стран 20+, имеет смысл добавить строку поиска. Можно отобразить качество соединения для пользователя, но судя по наблюдениям, это опционально. А вот показать, хотите ли Вы денег конкретно за этот сервер, очень желательно. Людям не нравится, когда их удивляют неожиданный переходом на экран встроенной покупки.

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

Время подключения.Встречал почти так же редко, как и информацию о скорости, однако в отличие от нее, практического применения таймеру я так и не нашел. Ни личный опыт, ни опросы, ни диалог с коллегами не дали мне ответ на вопрос: Эм, я подключен уже 3 минуты. Что дальше?. Жить это не мешает, но и толка от этого нет. Расскажите, что Вы думаете на этот счет, мне интересно.

Карта

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

Встроенная покупка

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

Другое

Настройки, поддержка, Q&A, восстановление покупок и далее по списку. Все, что сделает жизнь пользователя немного легче. Встречается в 90% VPN-приложений. Остальные 10% вполне обходятся без этого.

Пожалуйста

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

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

В заключение

Это основное, что важно знать про дизайн VPN-приложения. Мы не учитываем красивый фирменный стиль, правильные анимации и приветливый UI. Это тоже важно, и для многих пользователей может стать определяющим в выборе. У нас Starter pack. Реализовав этот список, приложение можно отправлять в App Store и Google Play.

Подробнее..

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

11.12.2020 18:19:05 | Автор: admin
Приветствую, Хабр! Меня зовут Павел Воропаев, я Software Engineer в компании Exness. Ранее успел поработать в разных российских компаниях в качестве фулстек разработчика, разработчика баз данных Oracle, Python-разработчика и тимлида. По случаю завершения моего испытательного срока я решил написать статью, в которой бы хотел поговорить о том, как можно оптимизировать процесс погружения в задачу. Я расскажу о накопленном ранее опыте, и о том как мой опыт выручил меня, когда я пришел в Exness. В примерах буду описывать взаимодействие микросервисов с помощью sequence diagram.

image



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

В чем проблема?





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

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

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

Как это можно исправить?



На предыдущих местах работы я встречал такой алгоритм работы:

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

...

  • еще исправления;
  • долгожданное завершение задачи.


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


Тогда алгоритм работы станет следующим:

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


Почему так важно описать техническое решение до самой реализации:

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


Как я применил этот опыт в Exness?


image

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

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

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

Пример



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

Новому разработчику прилетела задача с формулировкой "Написать новый микросервис для отклонения просроченных заявок. Уведомить клиентов о смене статуса.".

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

  • собрать микросервис, в нем сделать одну POST метод API с названием reject_old_requests;
  • в этом метод API надо получить данные из БД, в запросе указать фильтры по пользователю, статусу и дате;
  • по каждой заявке сменить статус в сервисе, который управляет заявками;
  • затем отправить запрос в сервис нотификации, чтобы уведомить пользователей об изменении статуса заявки.

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



и на какие ошибки можно было бы напороться:

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


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

Как бы выглядела диаграмма после исправлений:



Какая польза от диаграмм?



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

Какие выводы можно сделать?



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

Диаграммы однозначно помогут с:

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

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

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

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

Как извлечь пользу из статической типизации

06.04.2021 20:05:58 | Автор: admin
Живые данные ограниченные типами перетекают из состояние в состояниеЖивые данные ограниченные типами перетекают из состояние в состояние

Эта статья о том, как извлечь максимум пользы из статической системы типов при дизайне вашего кода. Статья пытается быть language agnostic (получается не всегда), примеры на Java и взяты из жизни. Хотя академические языки вроде Idris позволяют делать больше полезных трюков со статической типизацией, а полный вывод типов существенно сокращает размер церемоний, на работе мы пишем на языках другого типа, а хорошие знания хочется уметь применять на практике, так как это сделает нашу жизнь лучше уже сегодня.

Краткий пересказ сюжета статьи

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

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

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

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

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

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

План у нас следующий:

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

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

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

  4. Наконец, мы рассмотрим ряд примеров решения описанных проблем.

  5. Пара слов о том, как абстрагирование уменьшает связанность и где это уместно.

  6. Замечание о важности баланса: как не написать случайно DSL, следуя принципам из статьи.

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

Почему статическую типизацию можно не любить

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

Неполнота

Начнем с того, что какой бы мощной ваша система типов не была, всегда найдутся корректные программы, которые будут ею отвергнуты. С этим хорошо знакомы разработчики на Rust (читайте статьи о non lexical lifetimes: раз, два, три, четыре), но проблема касается любой статической системы типов. Это прямое следствие теоремы Гёделя о неполноте (ещё по теме советую прочитать книгу "ГЭБ: эта бесконечная гирлянда"). Она, грубо говоря, гласит что в любой достаточно сложной формальной системе либо есть теоремы, которые верны, но их верность нельзя доказать в рамках самой системы, либо можно доказать теоремы, которые не верны.

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

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

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

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

Вот маленький пример на Java:

Optional<? extends CharSequence> x = getContent();/*Не компилируется с ошибкой: incompatible types: java.lang.String cannot be converted tocapture#1 of ? extends java.lang.CharSequenc*/CharSequence y = x.orElse("");// А с кастом компилируется и прекрасно работает:// CharSequence y = ( (Optional<CharSequence>) x).orElse("");

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

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

В x лежит Optional реализация монады maybe в Java, а в Rust и Scala оно ещё называется Option. В C# такого нет, поэтому для простоты скажу, что внутри просто лежит nullable ссылка на объект, а сам Optional предоставляет безопасные методы для работы с ним. В частности метод Optional.orElse возвращает либо этот внутренний объект, если он не равен null, либо переданный в аргумент объект.

Синтаксис ? extends CharSequence значит, что внутри лежит объект, реализующий интерфейс CharSequence. В Java "" имеет тип String, который реализует CharSequence.

Очевидно, что какой бы CharSequence не был в x его можно присвоить в y, но конечно же и "" тоже можно присвоить. Поэтому программа корректна и кастовать здесь можно. Однако система типов Java не может этого доказать.

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

Церемонии

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

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

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

Логические ошибки

Типичная логическая ошибкаТипичная логическая ошибка

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

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

Мои субъективные ценности

Хочется всё и сразуХочется всё и сразу

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

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

Полиморфизм

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

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

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

void qsort (    void* base,     size_t num,     size_t size,     int (*comparator)(const void*, const void*));

Но никакой проверки соответствия типов сортируемого массива и компоратора здесь нет. Код же самого компоратора будет кастовать указатели void* к нужному типу.

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

В Java же есть ещё 3 вида полиморфизма. Два универсальных: параметрический (с помощью дженериков) и включений (через наследование), а так же один ad-hoc: через перегрузку функций.

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

Перегрузка функций

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

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

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

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

class Builder {    void addNames(String... names) {addNames(List.of(names))}    void addNames(Iterable<String> names) {/*...*/}}

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

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

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

Вот скажем пример, как не надо делать:

// Метод run примает функцию из String в T и возвращает T для пустой строки.<T> T run(Function<String, T> x) {    return x.apply("");}// Метод run принимает функцию из String в ничего,// вызывает её с пустой строкой и тоже ничего не возвращает.void run(Consumer<String> x) {    run(y-> {        x.accept(y);        return null;        });}    void doWork() {    run(x-> System.out.println(x));             // Не компилируется.    run((String x)-> System.out.println(x));    // А это компилируется кстати.}

Если вы не понимаете этот пример это нормально: я сам до конца его не понимаю. При компиляции Java сообщает, что оба метода run(Function) и run(Consumer) подходят, и она не может выбрать какой вызов сгенерировать, хотя на самом деле это не так: если стереть метод run(Consumer), тогда программа продолжит некомпилироваться, т.к. в переданной лямбде нет возвращаемого значения, и конечно, она не подходит в run(Function). Но самое удивительное, что программа начинает компилироваться, если подсказать ей тип аргумента, хотя уж в нём-то, казалось бы, нет никакого сомнения.

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

Полиморфизм включений

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

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

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

Допустим, есть класс ClassA, у него есть потомок ClassB, а у него потомок ClassC. И есть три метода foo, bar, baz у каждого класса. Причем метод foo вызывает метод bar, а тот вызывает baz. Тогда если ClassB переопределяет foo и baz, а ClassC только baz, то будет очень сложно понять какая цепочка вызовов образуется, если позвать ClassC.foo(). При чтении с вами случится вот что: вы нажмете перейти к декларации у ClassC.foo() попадете в ClassB.foo() там перейдете в ClassA.bar а оттуда в ClassA.baz, а надо было прийти в ClassC.baz. Реальная история, кстати, одного известного опенсорс проекта, все имена заменены.

Даже с картинкой не сразу понятно, правда?Даже с картинкой не сразу понятно, правда?

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

А вот наследование интерфейсов ключевая вещь, без которой остальные трюки не будут работать.

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

Хорошим примером является List.of метод в стандартной библиотеке Java, который создает неизменяемый List. Если ему передать пустой массив, то новых объектов не будет создано и вернется единственный на всех пустой лист. Для одного и двух элементных массивов возвращается класс List12, который может хранить до 2х элементов, что экономит на аллокации массива и его заголовка, и только для бОльших массивов используется реализация, которая хранит склонированный массив. При этом ничто не помешает добавить ещё реализаций, если потребуется.

Параметрический полиморфизм

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

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

Вариантность

Допустим, у нас есть дженерик класс List<T>, и два обычных класса: X и его наследник Y. Мы написали метод, который принимает List<X>, хотим ли мы разрешать передавать в него ещё и List<Y>? С одной стороны, это было бы гибко, но с другой это не всегда безопасно. Например, если у X есть ещё потомок Z, тогда, отправив List<Y>, мы начнем работать с ним как с List<X>: положим туда Z, и тогда пользоваться исходным листом как листом List<Y> будет уже нельзя. Случится то, что в java называется heap pollution. Добиться такого поведения для коллекций без кастов нельзя (а для массивов можно, но мы это здесь опустим)

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

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

Есть всего три вида вариантности:

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

  2. Ковариантность в нашем примере это ситуация, когда передать List<Y> можно. Обычно используется для чтения. В Java записывается как List<? extends X>.

  3. Контрвариантность ситуация обратная, когда принимая List<Y> разрешено за одно принять и List<X>. Обычно используется для записи. В Java записывается как List<? super Y>.

В Rust, например, тип вариантности выбирается автоматически из контекста. В Java и C# их нужно задать руками и принципы там немного разные. Свои минусы и плюсы есть у всех подходов.

Стоит отметить, что сегодня считается, что подход, который выбрала Java не самый лучший. Например, в Kotlin все немного переделали. Проблема в том, что использование вайлдкартов (знаки вопроса) в Java не редко порождает нежизнеспособные объявления.

Здесь я буду вынужден коснуться деталей реализации дженериков в Java. Когда принимаете List<? extends X> языку необходимо как-то запретить вам добавлять элементы в этот лист, чтобы избежать heap pollution. Java поступает очень просто: она запрещает передавать в аргументы методов, где фигурирует дженерик тип, что либо кроме null. Или строже: если ковариантный дженерик тип находится в аргументе метода, то единственное допустимое его значение это null, а если он указан как возвращаемое значение, тогда он равен указанным границам (т.е. для List<? extends X> это X).

Ровно по этой причине ломаются методы в духе orElse(T default) из примера в начале статьи: если T объявлен как ? extends CharSequence передавать в такой метод можно только null, хотя метод T get() вернет объект типа CharSequence. Java не знает что делает метод читает или пишет, но если ни одного объекта нельзя передать, то и сохранить его нельзя. А сохранение null не вызовет heap pollution.

Аналогично и с контрвариантностью: для List<? super Y> вызывать метод add(T) можно только с объектами типа Y, но вызвав T get(int) получится объект типа Object. Контрвариантность используется для записи и сделана, чтобы можно было сохранять объекты типа Y не только в List<Y>, но и в List<X> и List<Object>, поэтому нет никаких ограничений на то, что может вернуть метод get.

Сегодня, когда индустрия много лет черпает вдохновение из ФП, большинство наших классов стали иммутабельными. И хочется прежде всего хорошей поддержки иммутабельных объектов, а они всегда могут быть ковариантными без всяких ограничений, т.к. в них ничего нельзя записать и heap pollution невозможен в принципе.

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

Вложенные дженерики

Во время использования вложенных дженериков всплывает разница между наследником и подтипом. Скажем, List<Y> подтип List<? extends X>, хотя наследования там нет. Поэтому если вы хотите метод, который может принять как Map<K, List<X>>, так и Map<K, List<Y>>, то тип аргумента будет: Map<K, ? extends List<? extends X>>. Тип это вообще больше, чем просто конкретный класс. Если же написать Map<K, List<? extends X>>, то вы не сможете передать туда ни Map<K, List<Y>>, ни даже Map<K, List<X>>, т.к. ожидается конкретно тип List<? extends X> и ничего другого, потому что эта декларация сама является дженерик параметром, а они по умолчанию инвариантны.

Вывод типов

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

Обычно мы не замечаем этого механизма и он просто работает.

Оптимизация виртуальных вызовов через параметрический полиморфизм

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

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

Cадимся на шею системе типов

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

Доказываем производительность

Допустим, мы хотим написать дженерик функцию, которая считает сколько есть объектов типа T в коллекции source, не считая объектов из blacklist. Напишем её так:

<T> int filterCount(Collection<T> source, Set<T> blacklist) {    if (blacklist.isEmpty()) {        return source.size();    }   return (int) source.stream().filter(x->!blacklist.contains(x)).count();}

Обратите внимание на то, что у blacklist тип Set.

В Java, если упрощать, такая иерархия наследования коллекций: сперва идёт Collection, потом от него наследуется Set, List и некоторые другие. И у интерфейса Collection тоже есть метод contains, поэтому ничто нам не мешает использовать его вместо Set.

Однако подразумевается, что операция contains будет быстро работать у Set: за O(1) для HashSet или в крайнем случае за O(log n) для TreeSet. Здесь можно чуть-чуть порассуждать о кастомных Set-ах, но в целом, сознательное использование интерфейса Set ценой очень незначительной потери в гибкости позволяет увернуться от перформенсного бага в будущем. И всё благодаря системе типов.

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

Парси, а не валидируй

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

Допустим, у нас есть таблица в БД и есть два варианта схемы для неё: старая и новая. Пусть схема это просто мапа String->String из имени колонки в её тип, а хотим мы вычислить изменение в схеме, чтобы дальше что-то с ним сделать: распечатать его скажем.

Наивный разработчик сделает так: заведёт класс SchemaDiff и у него будет поле String name и два Nullable поля с типом для первой и второй таблицы соответственно.

final class SchemaDiff {    final String name;    final @Nullable String oldType;    final @Nullable String newType;    SchemaDiff(        String name,         @Nullable String oldType,         @Nullable String newType    ) {        this.name = name;        this.oldType = oldType;        this.newType = newType;    }    @Override    public String toString() {        if (oldType != null && newType != null) {            return String.format(                "Column %s changed the type: %s->%s",                name,                oldType,                 newType            );        }        if (oldType == null) {            return String.format(                "Column %s with type %s has been added",                 name,                 newType            );        }        return String.format(            "Column %s with type %s has been removed",             name,             oldType        );    }}

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

Метод toString показывает сложность работы с таким объектом. Скажем, придется затратить некоторые усилия, чтобы понять почему в последней строчке oldType не может быть равен null.

Правильный же способ, минимизирующий логические ошибки, это создать три класса: RemovedColumn, AddedColumn и TypeChanged. Стоит унаследовать их от общего класса SchemaDiff, чтобы было удобнее обрабатывать их вместе.

abstract class SchemaDiff {    final String name;    protected SchemaDiff(String name) {        this.name = name;    }}final class RemovedColumn extends SchemaDiff {    final String type;    RemovedColumn(String name, String type) {        super(name);        this.type = type;    }    @Override    public String toString() {        return String.format(            "Column %s with type %s has been removed",             name,             type        );    }}final class AddedColumn extends SchemaDiff {    final String type;    AddedColumn(String name, String type) {        super(name);        this.type = type;    }    @Override    public String toString() {        return String.format(            "Column %s with type %s has been added",             name,             type        );    }}final class TypeChanged extends SchemaDiff {    final String oldType;    final String newType;    TypeChanged(String name, String oldType, String newType) {        super(name);        this.oldType = oldType;        this.newType = newType;    }    @Override    public String toString() {        return String.format(            "Column's %s type has been changed: %s->%s",             name,             oldType,             newType        );    }}

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

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

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

Дедубликация кода с помощью параметрического полиморфизма

Любой полиморфизм позволяют схлопывать одинаковые с точностью до типов графы вычислений в один графЛюбой полиморфизм позволяют схлопывать одинаковые с точностью до типов графы вычислений в один граф

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

void process(List<Item> items) {    if (isLegacy) {        List<Legacy> docs = items.stream()            .map(x -> toLegacy(x))            .collect(toList);        legacyTable.store(docs);        logEvent(docs.stream().map(x->x.getId()).collect(toList()));    } else {        List<Modern> docs = items.stream()            .map(x->toModern(x, context))            .collect(toList);        modernTable.store(docs);        logEvent(docs.stream().map(x->x.getId()).collect(toList()));    }}

И проблема в том, что типы Legacy и Modern разные. Методы toLegacy и toModern тоже разные, и у них разное число аргументов. Так же legacyTable и modernTable не только физически разные таблицы, но и разного типа содержат объекты.

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

Дублирование кода не мне вам объяснять это прежде всего источник багов.

И этот код можно дедублицировать, введя такой метод:

<T extends WithId> List<T> store(    List<Item> items,    Function<Item, T> mapper,    Table<T> table) {    result = items.map(mapper).collect(toList());    table.store(result);    return result;}

и переписать основной так:

void process(List<Item> items) {    List<? extends WithId> docs = isLegacy ?        store(items, x -> toLegacy(x), legacyTable) :         store(items, x -> toModern(x, context), modernTable);    logEvent(docs);}

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

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

Вывод типов

Вообще, то, что делает ключевое слово var в Kotlin, C# или в Java, не совсем правильно называть выводом типов, т.к. никакого "вывода" там не происходит: настоящий вывод типов способен определять тип объекта по его использованию.

На Java:

var list = new ArrayList<>(); // К сожалению list будет иметь тип ArrayList<Object>.list.add(1);

На Rust:

let mut vec = Vec::new();   // vec будет иметь тип Vec<i32>vec.push(1);

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

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

HashMap<String, String> map = new HashMap<>();

Писать такой код:

Map<String, String> map = new HashMap<>();

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

На моем старом проекте на Java 11 пересели не так давно, а на новом проекте уже я не так давно, поэтому истории успеха с var у меня нет и здесь будет умозрительный, но правдоподобный пример.

Допустим, у вас был метод какой-то такой:

Long2LongOpenHashMap createMap(long[] keys, long[] values);

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

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

Long2LongMap createMap(long[] keys, long[] values);

Если при этом использование было каким-то таким:

var map = createMap(keys, values);for(long x: xs) {    f(map.get(x));}

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

Вычисления над типами и лифтинг

Итак, реальная задача. У нас есть сервис, который считает ML-ные фичи для потока документов, и мы делаем API доступа к ним. Сервис кладет данные в БД, а пользователи из БД читают. Так что балансировка нагрузки и прочее уже решенная задача. Важно то, что у фичей есть версии при обновлении конфигурации появляется новая версия, она тестируется и потом применяется вместо старой. Есть возможность откатить и прочая бизнес-логика.

Разные виды фичей, допустим, лежат в enum-е:

enum FeatureType {    ELMO,    SLANG,    LIFETIME_7D,}

и каждая фича имеет свою таблицу со своей схемой. Скажем, для ELMO это EmbeddingEntry массив float, для LIFETIME_7D это FloatEntry, один float вероятность, что через 7 дней новость устареет, а для SLANG вообще BlacklistEntry список найденных матных слов в тексте. Все они наследуются от FeatureEntry, в котором ещё лежит id документа, к которому эта фича относится.

И вот мы делаем, допустим, такое простое API:

<TEntry extends FeatureEntry> Collection<TEntry> find(Collection<Id> ids, FeatureType type);

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

Внимание вопрос: как узнать какой именно TEntry соответствует какому FeatureType? Сейчас никак нельзя, чтобы работала сериализация придется сделать так:

enum FeatureType() {    ELMO(EmbeddingEntry.class),    SLANG(BlacklistEntry.class),    LIFETIME_7D(FloatEntry.class),    ;    private final Class<? extends FeatureEntry> entryClass;    public FeatureType(Class<? extends FeatureEntry> entryClass) {        this.entryClass = entryClass;    }    public Class<? extends FeatureEntry> getEntryClass() {        return entryClass;    }}

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

Теперь сериализатор в рантайме получит класс, в который надо сериализовывать. Однако в методе find тип TEntry не может быть выведен на этапе компиляции, и пользователям придется кастить. Причем в реальном проекте фичей не 3, а 56, так что если вы там думали три метода завести, не стоит.

Но можно сделать вот что:

final class FeatureType<TEntry extends FeatureEntry> {    public static final FeatureType<EmbeddingEntry> ELMO =         new FeatureType("elmo", EmbeddingEntry.class);    public static final FeatureType<BlacklistEntry> SLANG =         new FeatureType("slang", BlacklistEntry.class);    public static final FeatureType<FloatEntry> LIFETIME_7D =         new FeatureType("lifetime_7d", FloatEntry.class);    private final Class<TEntry> entryClass;    private final String name;    private FeatureType(String name, Class<TEntry> entryClass) {        this.name = name;        this.entryClass = entryClass;    }    public String getName() {        return name;    }    public Class<TEntry> getEntryClass() {        return entryClass;    }}

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

<TEntry extends FeatureEntry> Collection<TEntry> find(   Collection<Id> ids,    FeatureType<TEntry> type);

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

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

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

Обычно лифтинг превращает функцию над одними объектами в функцию над другими. Например так:

static <TLeft, TRight, TResult> BiFunction<Optional<TLeft>, Optional<TRight>, Optional<TResult>> lift(BiFunction<TLeft, TRight, TResult> function) {    return (left, right) -> left.flatMap(        leftVal -> right.map(rightVal -> function.apply(leftVal, rightVal))    );}

Этот метод превращает любую функцию с двумя аргументами в аналогичную, но работающую с запакованными в Optional значениями.

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

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

Здесь мы воспользуемся перегрузкой:

<TEntry extends FeatureEntry> Collection<TEntry> find(    Collection<Id> ids,     FeatureType<TEntry> type,     Language language);<TEntry extends FeatureEntry> Collection<TEntry> find(    Collection<Id> ids,     FeatureType<TEntry> type,     Country country);

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

Можно это сделать так:

class FeatureType<TEntry extends FeatureEntry> {    public static final ByLanguage<EmbeddingEntry> ELMO =        new ByLanguage<>("elmo", EmbeddingEntry.class);    public static final ByCountry<BlacklistEntry> SLANG =         new ByCountry<>("slang", BlacklistEntry.class);    public static final ByCountry<FloatEntry> LIFETIME_7D =         new ByCountry<>("lifetime_7d", FloatEntry.class);    private final Class<TEntry> entryClass;    private final String name;    private FeatureType(String name, Class<TEntry> entryClass) {        this.name = name;        this.entryClass = entryClass;    }    public String getName() {        return name;    }    static final class ByLanguage<TEntry extends FeatureEntry>        extends FeatureType<TEntry> {...}    static final class ByCountry<TEntry extends FeatureEntry>         extends FeatureType<TEntry> {...}}

И тогда API будет:

<TEntry extends FeatureEntry> Collection<TEntry> find(    Collection<Id> ids,     FeatureType.ByLanguage<TEntry> type,     Language language);<TEntry extends FeatureEntry> Collection<TEntry> find(    Collection<Id> ids,     FeatureType.ByCountry<TEntry> type,     Country country);

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

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

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

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

Развязывание

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

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

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

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

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

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

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

Справа внизу не менеджер, а ШогготСправа внизу не менеджер, а Шоггот

DSL vs composability

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

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

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

Допустим, вы заметили, что у вас часто возникает такой тип: CompletableFuture<Optional<T>> и вы можете написать много полезных методов для работы с ним. Конкретно в Java вы можете поступить только двумя способами:

  1. Сделать обертку OptionalFuture и добавить методы туда.

  2. Добавить статические методы в какой-нибудь FutureUtils.

Первый способ считается более ООП-правильным. Но на практике второй способ намного более composable. Дело в том, что ваш OptionalFuture несовместим ни с какими другими библиотеками и методами для работы как с CompletableFuture, так и с Optional помимо ваших прекрасных методов вам придется проксировать методы CompletableFuture, а их там будь здоров сколько. Причем методы, которые как-то работают с классом CompletableFuture, например, thenCompose (какая ирония), придется дублировать для двух типов и в одну сторону комбинировать будет проще, чем в другую.

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

Это довольно контринтуитивная мысль, но в действительности, по крайней мере в мейнстримных языках, composability находится в некоторой противофазе с теми концепциями, которые я тут описываю. И недавно мне попался потрясающе наглядный пример, который это иллюстрирует в коде Apache Lucene.

Apache Lucene это движок для полнотекстового поиска: им пользуется Twitter, и на его основе написан Elasticsearch. У него очень интересный исходный код, в котором чувствуется дух времени: он написан очень умными людьми, но очень давно сейчас так писать не принято. В частности сайдэффекты там это часть API.

Представьте, что вам надо написать сортировку, но так, чтобы один код работал вообще в любых ситуациях, где что-то как-то сортируется: коллекции и массивы (между ними нет наследования в Java), объекты и примитивы (к сожалению, дженерики в джаве не работают с примитивами, и код принимающий T[] отвергнет int[]) и даже в ситуациях, которые не опишешь в двух словах (необъяснимый пример ждет вас впереди).

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

Однако в Apache Lucene есть класс InPlaceMergeSorter, который это умеет, а работают с ним так:

// Код немного упрощен:private BlendedTermQuery(Term[] terms, float[] boosts, TermStates[] contexts) {    assert terms.length == boosts.length;    assert terms.length == contexts.length;    this.terms = terms;    this.boosts = boosts;    this.contexts = contexts;    // Поля terms, boosts и contexts массивы с одинаковой длиной        // Обратите внимание на пустой конструктор: все нужные нам аргументы мы захватываем.    new InPlaceMergeSorter() {      // Сортируем казалось бы массив terms, по крайней мере сравниваем его.      @Override      protected int compare(int i, int j) {        return terms[i].compareTo(terms[j]);      }     // Но на самом деле мы сортируем все три массива, но по значениям из массива terms     @Override      protected void swap(int i, int j) {        Term tmpTerm = terms[i];        terms[i] = terms[j];        terms[j] = tmpTerm;        TermStates tmpContext = contexts[i];        contexts[i] = contexts[j];        contexts[j] = tmpContext;        float tmpBoost = boosts[i];        boosts[i] = boosts[j];        boosts[j] = tmpBoost;      }    // Нет возвращаемого значения, т.к. результат функции: сайд-эффект.    }.sort(0, terms.length);  }

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

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

Одновременно с этим это API максимально опасное, ведь легко написать такое:

Foo(float[] boosts) {    this.boosts = boosts.clone();    new InPlaceMergeSorter() {      @Override      protected int compare(int i, int j) {        return Float.compare(boosts[i], boosts[j]);      }      @Override      protected void swap(int i, int j) {        float tmpBoost = this.boosts[i];        this.boosts[i] = this.boosts[j];        this.boosts[j] = tmpBoost;      }    }.sort(0, terms.length);  }

Это еле заметно, но сравниваем мы тут один массив, а свопаем другой т.к. boosts указывает на аргумент а this.boosts на поле класса, и тут это разные объекты.

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

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

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

Рассуждение о локальности

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

Если представить, что внимания и памяти разработчика хватает на скоуп только одной функции, тогда без того, чтобы класть побольше информации в тип, не обойтись. В следующей функции вы просто не вспомните проверял ли вы массив на пустоту или нет, null твои аргументы или не null. Передать эту информацию можно только с типом. А если представить, что внимания и памяти разработчика хватает на всю программу целиком, тогда все это наоборот оказывается не нужным, и даже вредным. Зачем мне проверять, пусть даже статически, что я кладу совместимый объект? Я и так держу в голове всю программу и помню что можно класть в какие методы, а что нельзя. Аналогично рассуждают некоторые плюсовики: мне UB не мешает, я точно помню какие мои переменные могут быть null, а какие нет, в уме просчитываю лайфтаймы всех аллоцированных объектов, а код с гонками не пишу в принципе.

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

P.S.

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

Благодарности

Большое спасибо этим людям за ревью статьи до публикации:

  1. Дмитрий Юдаков

  2. Дмитрий Петров

  3. Николай Мишук

  4. Анастасия Павловская

  5. Полина Романченко

  6. Светлана Есенькова

  7. Ян Корнев

Подробнее..

Чистая архитектура. Часть II Парадигмы программирования

26.04.2021 16:20:03 | Автор: admin

Эта серия статей вольный и очень краткий пересказ книги Роберта Мартина (Дяди Боба) Чистая Архитектура, выпущенной в 2018 году. Начало тут.

Парадигмы программирования

Дисциплину, которая в дальнейшем стала называться программированием, зародил Алан Тьюринг в 1938 году. В 1945 он уже писал полноценные программы, которые работали на реальном железе.

Первый компилятор был придуман в 1951 году Грейс Хоппер (бабушка с татуировкой Кобола). Потом начали создаваться языки программирования.

Обзор парадигм

Существует три основных парадигмы: структурное, объектно-ориентированное и функциональное. Интересно, что сначала было открыто функциональное, потом объектно-ориентированное, и только потом структурное программирование, но применяться повсеместно на практике они стали в обратном порядке.

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

Объектно-ориентированное программирование было открыто в 1966 году.

Функциональное программирование открыто в 1936 году, когда Чёрч придумал лямбда-исчисление. Первый функциональный язык LISP был создан в 1958 году Джоном МакКарти.

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

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

Структурное программирование

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

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

С тех пор оператора goto не стало практически ни в одном языке программирования.

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

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

Объектно-ориентированное программирование

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

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

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

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

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

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

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

Любая зависимость всегда может быть инвертирована. В этом и есть мощь ООП.

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

Функциональное программирование

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

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

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

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

Заключение

Таким образом, каждая из трёх парадигм ограничивает нас в чём-то:

  • Структурное отнимает у нас возможность вставить goto где угодно.

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

  • ФП запрещает изменять переменные.

Продолжение следует...

Подробнее..

Чистая архитектура. Часть III Принципы дизайна

27.04.2021 14:21:47 | Автор: admin

Эта серия статей вольный и очень краткий пересказ книги Роберта Мартина (Дяди Боба) Чистая Архитектура, выпущенной в 2018 году.Предыдущая часть здесь.

Принципы дизайна

Принципы SOLID говорят нам, как нам соединять наши функции и данные в классы, и как эти классы должны быть взаимосвязаны. То есть эти принципы относятся к уровню модулей (mid-level). Однако они также применимы и к уровню компонентов (high-level архитектура).

Принципы SOLID зародились в конце 80-х и стабилизировались в начале 2000-х.

SRP: Принцип единственной ответственности

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

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

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

Пример нарушения принципа: класс Employee с тремя методами calculatePay(), reportHours(), save(). Все эти три метода относятся к разным актёрам. calculatePay() это бухгалтерия, reportHours() это отдел кадров, save() это администратор базы данных. Если один из актёров просит внести изменения в свой метод, то это может сломать логику других методов. Также это вносит трудности при слиянии изменений от разных актёров.

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

OCP: Принцип открытости/закрытости

Программный артефакт должен быть открытым для расширения, но закрытым для изменения.

Простыми словами: артефакт должен быть расширяемым без необходимости изменения его кода.

Этот принцип очень важен для архитектурных компонент.

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

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

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

LSP: Принцип подстановки Лисков

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

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

Пример нарушения LSP: классическая проблема квадрата/прямоугольника.

Пример более высокоуровневого нарушения LSP: агрегатор сервисов такси, где в одном из сервисов название одного из параметров REST-запроса отличается от остальных. Тогда добавление этого сервиса приведёт к нарушению работы всего агрегатора. Эту проблему можно решить, например, путём вынесения маппинга параметров во внешнюю БД.

ISP: Принцип разделения интерфейса

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

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

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

DIP: Принцип инверсии зависимостей

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

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

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

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

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

Как создавать инстансы конкретных классов, если мы не можем от них зависеть? Для этого есть несколько решений:

  • Шаблон Абстрактная Фабрика

  • Контейнеры, которые сами внедряют зависимости (XML, аннотации)

  • Сервисы

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

Продолжение следует...

Подробнее..

Принцип подстановки Барбары Лисков (предусловия и постусловия)

28.05.2021 00:20:41 | Автор: admin

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

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

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

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

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

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

<?phpclass Customer{    protected float $account = 0;    public function putMoneyIntoAccount(int|float $sum): void    {        if ($sum < 1) {            throw new Exception('Вы не можете положить на счёт меньше 1$');        }        $this->account += $sum;    }}class  MicroCustomer extends Customer{    public function putMoneyIntoAccount(int|float $sum): void    {        if ($sum < 1) {            throw new Exception('Вы не можете положить на счёт меньше 1$');        }        // Усиление предусловий        if ($sum > 100) {             throw new Exception('Вы не можете положить на больше 100$');        }        $this->account += $sum;    }}

Добавление второго условия как раз является усилением. Так делать не надо!

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

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

Этот пример показывает, как расширение допускается, потому что метод Bar->process() принимает все типы параметров, которые принимает метод в родительском классе.

<?phpclass Foo{    public function process(int|float $value)    {       // some code    }}class Bar extends Foo{    public function process(int|float|string $value)    {        // some code    }}

Пример ниже показывает, как дочерний класс VIPCustomer может принимать в аргумент переопределяемого метода putMoneyIntoAccount более широкий (более абстрактный) объект Money, чем в его родительском методе (принимает Dollars).

<?phpclass Money {}class Dollars extends Money {}class Customer{    protected Money $account;    public function putMoneyIntoAccount(Dollars $sum): void    {        $this->account = $sum;    }}class VIPCustomer extends Customer{    public function putMoneyIntoAccount(Money $sum): void    {        $this->account = $sum;    }}

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

Постусловия не могут быть ослаблены в подклассе

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

<?phpclass Customer{    protected Dollars $account;    public function chargeMoney(Dollars $sum): float    {        $result = $this->account - $sum->getAmount();        if ($result < 0) { // Постусловие            throw new Exception();        }        return $result;    }}class  VIPCustomer extends Customer{    public function chargeMoney(Dollars $sum): float    {        $result = $this->account - $sum->getAmount();        if ($sum < 1000) { // Добавлено новое поведение            $result -= 5;          }               // Пропущено постусловие базового класса              return $result;    }}

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

Сюда-же можно отнести и Ковариантность, которая позволяет объявлять в методе дочернего класса типом возвращаемого значения подтип того типа (ШО?!), который возвращает родительский метод.

На примере будет проще. Здесь в методе render() дочернего класса, JpgImage объявлен типом возвращаемого значения, который в свою очередь является подтипом Image, который возвращает метод родительского класса Renderer.

<?phpclass Image {}class JpgImage extends Image {}class Renderer{    public function render(): Image    {    }}class PhotoRenderer extends Renderer{    public function render(): JpgImage    {    }}

Таким образом в дочернем классе мы сузили возвращаемое значение. Не ослабили. Усилили :)

Инвариантность

Здесь должно быть чуть проще.

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

Инварианты это некоторые условия, которые остаются истинными на протяжении всей жизни объекта. Как правило, инварианты передают внутреннее состояние объекта.

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

<?php class Wallet{    protected float $amount;    // тип данного свойства не должен изменяться в подклассе}

Здесь также стоит упомянуть исторические ограничения (правило истории):

Подкласс не должен создавать новых мутаторов свойств базового класса.

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

<?phpclass Deposit{    protected float $account = 0;    public function __construct(float $sum)    {        if ($sum < 0) {            throw new Exception('Сумма вклада не может быть меньше нуля');        }        $this->account += $sum;    }}class VipDeposit extends Deposit{    public function getMoney(float $sum)    {        $this->account -= $sum;    }}

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

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

Выводы

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

Стоит упомянуть, что нужно страться избавляться от пред/пост условий. В идеале они должны быть определенны как входные/выходные параметры метода (например передачей в сигнатуру готовых value objects и возвращением конкретного валидного объекта на выход).

Надеюсь, было полезно.

Источники

  1. Вики - Принцип подстановки Барбары Лисков

  2. Metanit

  3. PHP.watch

  4. Telegram канал, с короткими заметками

Подробнее..

Перевод Актуальность принципов SOLID

05.06.2021 22:17:23 | Автор: admin

Впервые принципы SOLID были представлены в 2000 году в статье Design Principles and Design Patterns Роберта Мартина, также известного как Дядюшка Боб.

С тех пор прошло два десятилетия. Возникает вопрос - релевантны ли эти принципы до сих пор?

Перед вами перевод статьи Дядюшки Боба, опубликованной в октябре 2020 года, в которой он рассуждает об актуальности принципов SOLID для современной разработки.

Недавно я получил письмо с примерно следующими соображениями:

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

Принцип подстановки Лисков давно устарел, потому что мы уже не уделяем столько внимания наследованию, сколько уделяли 20 лет назад. Думаю, нам стоит рассмотреть позицию Дена Норса о SOLID - Пишите простой код

В ответ я написал следующее письмо.

Принципы SOLID сегодня остаются такими же актуальными, как они были 20 лет назад (и до этого). Потому что программное обеспечение не особо изменилось за все эти годы, а это в свою очередь следствие того, что программное обеспечение не особо изменилось с 1945 года, когда Тьюринг написал первые строки кода для электронного компьютера. Программное обеспечение - это все еще операторы if, циклы while и операции присваивания - Последовательность, Выбор, Итерация.

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

Итак, пройдемся по принципам по порядку.

SRP - Single Responsibility Principle Принцип единственной ответственности.

Объединяйте вещи, изменяющиеся по одним причинам. Разделяйте вещи, изменяющиеся по разным причинам.

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

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

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

OSP - Open-Closed Principle Принцип открытости-закрытости

Модуль должен быть открытым для расширения, но закрытым для изменения.

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

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

И снова слайд Дэна преподносит это совершенно неправильно.

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

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

LSP - Liskov Substitution Principle Принцип подстановки Лисков

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

Люди (включая меня) допустили ошибку, полагая что речь идет о наследовании. Это не так. Речь о подтипах. Все реализации интерфейсов являются подтипами интерфейса, в том числе при утиной типизации. Каждый пользователь базового интерфейса, объявлен этот интерфейс или подразумевается, должен согласиться с его смыслом. Если реализация сбивает с толку пользователя базового типа, то будут множиться операторы if/switch.

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

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

ISP - Interface Segregation Principle Принцип разделения интерфейса

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

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

Проблема особенно остро стоит в статически типизированных языках, таких как Java, C#, C++, GO, Swift и т.д. Динамически типизированные языки страдают гораздо меньше, но тоже не застрахованы от этого - существование Maven и Leiningen тому доказательство.

Слайд Дэна на эту тему ошибочен.

(Примечание. На слайде Ден обесценивает утверждение Клиенты не должны зависеть от методов, которые они не используют фразой Это же и так правда!!)

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

(Примечание. Речь о фразе Если классу нужно много интерфейсов - упрощайте класс!)

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

DIP - Dependency Inversion Principle Принцип инверсии зависимостей

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

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

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

Лучший способ создать путаницу - сказать всем будьте проще и не дать никаких дальнейших инструкций.

Подробнее..

Куда податься Васе чтобы не навредить своему бывшему работодателю?

12.12.2020 04:16:26 | Автор: admin
В очередной раз мы увидели дикий пример, когда компания судится со своими бывшими сотрудниками. На удивление много людей в комментариях защищали работодателя, так у меня родился Вася. Наш Василий ушел от своего работодателя к конкурентам. Василий руководил проектом по созданию ARM процессора на прошлом месте и продолжил это делать у конкурентов. По факту Василий не является незаменимым, но он неплох, примерно, как мы с вами.

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

но что реально Василий о себе знает?

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

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

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

Как бывший это сможет сделать???
Ди никак, каком к верху.

Хорошо, последнее. Представим, что есть две компании, которые настолько конкуренты между собой, что аж до звона сферичности в вакууме, а наш Вася тот самый злодейсткий злодей (по мнению бывшего), что под мышкой умыкнул готовую технологию на новенький ARM процесс. И вы думаете Вася так легко развернется на новом месте и продолжит эту технологию? Да хрен там (простите)! Оборудование всегда разное, оно всегда имеет свою специфику и натянуть чужую технологию на другое оборудование это под час разработческая задача посложней чем все делать с нуля. У новой компании Василия другие поставщики, другая логистика и закупки, другой коллектив (возможно токсичных для Васи людей), иной бюрократический регламент работы. И вот, в то время как бывший рвет и мечет, дрессируя своих юристов натравливая их на Василия, наш Вася грустит в новой компании и думает, чем бы иным заняться.

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

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

Реализация чистой архитектуры в микросервисах

25.05.2021 14:04:29 | Автор: admin
Привет хабр!

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



Авторы статьи: ctimas и Alexey_Salaev



Важность архитектуры микросервиса

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

Начиная проект, было много обсуждений: какой же подход выбрать? как строить нашу новую систему ДБО? Началось все с обсуждений монолит vs микросервисы: обсуждали возможные используемые языки программирования, спорили про фреймворки (использовать ли spring cloud?, какой протокол выбрать для общения между микросервисами?). Данные вопросы, как правило, имеют какое-то ограниченное количество ответов, и мы просто выбираем конкретные подходы и технологии в зависимости от потребностей и возможностей. А ответ на вопрос Как же писать сами микросервисы? был не совсем простым.

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

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

Граница микросервиса

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

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

Рассмотрим пример стандартного бизнес процесса для любого банка создание платежного поручения



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

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



Некоторые микросервисы не подходят под данную концепцию, но количество таких микросервисов в общем процентном соотношении небольшое и составляет около 5%.

Чистая архитектура

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

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

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



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

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

Реализация чистой архитектуры в проекте

Мы перерисовали данную диаграмму, опираясь на наш сценарий.



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

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

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

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



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

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

Встречаются задачи, когда нужно реализовать микросервис без БД или мы можем отказаться от DI, потому что задача слишком проста и ее быстрее решить в лоб. И если всю работу с БД мы будем осуществлять в модуле repository, то где же нам использовать фреймворк, чтобы он приготовил нам весь DI? Вариантов не так и много: либо мы добавляем зависимость в каждый модуль нашего приложения, либо постараемся выделить весь DI в виде отдельного модуля.
Мы выбрали подход с отдельным новым модулем и называем его или infrastructure или application.

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

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



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



Теперь осталось только добавить сам фреймворк DI. Мы у себя в проекте используем Spring, но это не является обязательным, можно взять любой фреймворк, который реализует DI (например micronaut).

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



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

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

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

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

Заключение

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

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

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

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

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

Может поменять способ хранения?

24.05.2021 20:06:11 | Автор: admin

Собрались однажды 2 разработчика. И нужно было им новую HTTP API реализовать для игрового магазина. Дошло дело до выбора БД, которую стоит применить в проекте:

- Слушай, а как мы выберем? Реляционную БД использовать или NoSQL. В частности, может нужна документоориентированная?

- Сперва нужно понять какие данные будут в нашей предметной области!

- Да, вот я уже набросал схемку:

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

- Выглядит так, что у нас уже есть четко описанная структурированная модель, да и опыт использования MySQL есть в компании. Предлагаю использовать его!


И реализовали разработчики успешно свою задумку. Аккуратно нормализовали данные, использовали ORM для работы с БД из приложения. Написали красивый и аккуратный код.

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

Корнем проблемы являлось большое (очень) количество запросов к БД - на каждое отношение между сущностями ORM генерировала дополнительный запрос.

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

А книга и порекомендовала еще раз внимательно взглянуть на свою предметную область... Ведь отношения между сущностями образуют дерево! А дерево можно уместить в одном документе (или представить с помощью одного JSON), что позволит избежать такого количества запросов.

Вооружились идеей разработчики и просто сериализовали сущность в JSON и сложили в 1 столбец MySQL (+ несколько генерируемых столбцов с индексами, для поиска):

95 перцентиль уменьшилась более чем в 3 раза, пропорционально увеличился и выдаваемый rps одного инстанса приложения.

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

Какой вывод можно сделать?

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

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

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

Подробнее..

Tantramantra и магия проектирования

03.04.2021 22:15:26 | Автор: admin
Доброго весеннего дня!
Во время разработки различных механик и прочего интерактива для компьютерных игр, складываются различные схемы-рецепты для реализации требуемого функционала. Большая их часть не привязана к конкретному используемому движку/языку. О некоторых из них я расскажу на примере одного из своих проектов с биомашинками.



Тантрамантра



Для начала о самом проекте. Это тестовый полигон из нескольких карт-миров, где можно покататься на машинке-биотрансформере, которая умеет прыгать, кувыркаться, стрейфиться и создавать некоторые объекты. Также подконтрольное транспортное средство умеет переключаться в различные формы, в том числе и те, которые заточены под полёт (не имеют колёс).
На картах присутствуют телепорты (синие звездообразные объекты), переносящие машинку в другие миры. Всего таких миров четыре стартовый, с водой и скалами, с ровной поверхностью и ещё один водный.
В стартовом мире игрока довольно быстро начинают преследовать два терминатора, одолеть которых можно при помощи специального оружия (после чего кататься уже в более спокойной обстановке). Если же они окажутся ловчее, то происходит game over и игра начинается заново.







Проектирование игровых механизмов



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

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

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

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

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

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

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

Грибы

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

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


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


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


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

Терминаторы

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

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


А вот как эти враги устроены пара сфер и пустышка-прицел, в которую спавнятся выстрелы


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


Оружие

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

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


Точка подбора оружия тоже использует WorldTrigger для определения столкновения


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

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


NodePet объект, изображающий пушку


Код NodePet. В качестве точки привязки, за которой он будет следовать, указана пустышка-прицел, висящая на машинке. А Rotator это другая пустышка, уже в центре самой пушки, которая копирует на себя поворот того прицела, чтобы пушка смотрела в нужную сторону (в качестве бонуса это даёт эффект вращения пушки вокруг своей оси, когда машинка двигается).
Здесь как раз реализован принцип отслеживания координат пушка начинает смещаться, если машинка удалилась от неё на определённое малое расстояние. Поначалу отслеживалось отклонение только по осям X и Y, а потом я дописал и Z для большей точности.




Выстрелы

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

Работают эти выстрелы немного по-разному.


Фрагмент скрипта вражеских выстрелов. Они появляются и начинают лететь в том направлении, куда смотрел терминатор (то есть на игрока). Производить дополнительные действия выстрел начинает не сразу, а после небольшой задержки (reactionTimer) простой способ избежать каких-то нежелательных столкновений в точке его появления.
После задержки выстрел начинает анализировать пройденные отрезки на предмет пересечений, и если там что-то нашлось, то уничтожается, проигрывая эффект.
Если у этого чего-то имелось RigidBody, то выстрел применит туда импульс.
А если это ещё и машинка, то выстрел находит на ней скрипт и вызывает там функцию потери хитпоинтов.
Так как вражеские выстрелы отслеживают пересечения с большей частью геометрии, то они часто могут попасть в какую-то визуальную часть машинки, не достигнув её ядра. Таким образом получается, что часть выстрелов словно не пробивают броню.


Демоверсия



Ниже видеонарезка игрового процесса из свежей версии прототипа 01_02



Архив с демкой весит 714Мб. Запускается она на 64-битной Windows и доступна для скачивания на страничке itch.io (используется Unigine engine, поэтому системные требования не самые малые):

https://thenonsense.itch.io/tantramantra

Подробнее..

Перевод Маленькие задачи, а доверия ещё меньше

12.01.2021 12:17:05 | Автор: admin


Почему делегирование обязанностей лучше, чем распределение задач


Доверие высочайшая форма мотивации. Оно выявляет в людях самое лучшее.

Стивен Р. Кови, Семь навыков высокоэффективных людей

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

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

Наконец-то, у нас появился Винсент, я могу поручить ему заняться A и B; Тед будет делать C, D
и E, Джен займётся F, G и H, а я смогу добраться до I, J, K, L и M.

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

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

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

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

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

Так чьё это видение?


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

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

Слишком много любви к метрикам


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

К сожалению, мелкие задачи в Jira (или в любом из десятков других систем отслеживания ошибок) приносят с собой перспективу целого множества новых вкусных графиков и диаграмм: burn down, burn up, velocity, lead time, cycle time, task age, throughput, failed deployment, flow and control. Всё это манит, как ребёнка манит конфета.

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

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


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

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

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

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

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

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

Оценка всегда требует ресурсов


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

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

Проблема в том, что как только вы начнёте работать над одним из них, то осознаете, что подразумеваемые проектные решения неверны. Теперь вам нужно потратить ГОРАЗДО больше времени, чем прежняя оценка задач, а все остальные задачи, зависящие от этого ошибочного решения, окажутся недействительными. Весь карточный домик развалится. Пришла пора ещё одного ещё дня возни с бэклогом? Какая трата времени!

Вывод


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

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

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

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

Я, Васко, неплохо разбирался в Microsoft Access, поэтому, наверно, буду заниматься базами данных.

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

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



На правах рекламы


VDSina предлагает мощные и недорогие VPS с посуточной оплатой. Интернет-канал для каждого сервера 500 Мегабит, защита от DDoS-атак включена в тариф, возможность установить Windows, Linux или вообще ОС со своего образа, а ещё очень удобная панель управления серверами собственной разработки. Обязательно попробуйте!

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    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