Примеры организации интерфейсов
Данная статья будет продолжением предыдущей, в которой я рассказывал о значимости интерфейсов, их преимуществах и проблемах. В этой статье мы рассмотрим интерфейсы на практических примерах, чтобы понять, как они организованы с точки зрения дизайна языка, их преимущества и недостатки. Также я расскажу, как можно внедрить интерфейсы в те языки программирования, где их нет (как ключевого слова).
Go
Действительно, в языке программирования Go поддержка интерфейсов встроена изначально, поскольку его создатели стремились сделать язык, который позволяет программистам снова наслаждаться процессом программирования. Для создания интерфейса в Go необходимо определить его:
|
С Go 1.18 можно использовать Generic’и:
|
Действительно, использование интерфейсов в программировании позволяет разделить код на более независимые компоненты и снизить степень связанности между ними (decoupling), что облегчает тестирование и улучшает общую структуру приложения.
Для тестирования кода, использующего интерфейсы, часто применяют механизм создания mock-объектов - заменителей реальных объектов, которые имитируют их поведение в рамках тестов. Существуют специальные библиотеки для создания mock-объектов в Go, такие как mockery
или go-mock
. Они позволяют создавать заменители интерфейсов, которые могут быть настроены на возвращение определенных значений или вызов определенных методов в ответ на заданные параметры. Это помогает проводить более полное и точное тестирование кода и улучшать его качество.
|
Как видно, это позволяет декларативно определить что вернёт функция в ответ на какой запрос, отвязывая нас при тестировании от необходимости реализации функции.
Вот список некоторых наиболее распространенных интерфейсов по умолчанию в Go:
fmt.Stringer
- Этот интерфейс определяет единственный методString() string
, который возвращает строковое представление объекта. Любой тип, у которого есть методString() string
, автоматически реализует интерфейсfmt.Stringer
;error
- Этот интерфейс определяет единственный методError() string
, который возвращает строку, описывающую ошибку. Любой тип, у которого есть методError() string
, автоматически реализует интерфейсerror
;io.Reader
- Этот интерфейс определяет единственный методRead(p []byte) (n int, err error)
, который читает до len(p) байт в p и возвращает количество прочитанных байтов и ошибку, если есть;io.Writer
- Этот интерфейс определяет единственный методWrite(p []byte) (n int, err error)
, который записывает len(p) байтов из p в базовый поток данных;io.Closer
- Этот интерфейс определяет единственный методClose() error
, который закрывает базовый поток данных и возвращает ошибку, если есть;sort.Interface
- Этот интерфейс определяет три методаLen() int
,Less(i, j int) bool
иSwap(i, j int)
, которые используются для реализации алгоритмов сортировки;context.Context
- Этот интерфейс определяет множество методов, которые используются для управления контекстом запроса или операции, включая методы для управления сроками, отмены и хранения и извлечения значений.
Если говорить об интерфейсах в Go, то они обычно создаются максимально компактными для обеспечения их гибкости и удобства использования.
Хочу отметить интерфейс context.Context
, который широко используется в Go для передачи контекста выполнения между горутинами (goroutines) и для отмены операций. Его определение следующее:
|
context.Context
содержит четыре метода: Deadline()
, Done()
, Err()
и Value()
, которые позволяют определять время выполнения операции, отслеживать состояние выполнения операции, получать ошибки и передавать значения между функциями, связанными с одним контекстом.
В большинстве функций в Go используется context.Context
, который позволяет определить, когда нужно прекратить выполнение и какие данные еще могут быть доступны в контексте. Использование context.Context
является важной частью разработки в Go, поскольку позволяет эффективно управлять ресурсами и предотвращать утечки горутин, обеспечивая более стабильную и безопасную работу приложений.
То есть контекст одновременно является средством синхронизации и описания произвольного контекста. И если первое понятно, то ко второму есть некоторые вопросы. Зачем в строго типизированном Go со всеми его возможностями такая сущность для переноса нетипизированных данных?
Контекст в Go предназначен для передачи значений и метаданных между различными компонентами системы, включая горутины. Аналогией к этому можно привести протокол HTTP. Если вы создадите простое приложение, которое возвращает ответ на запрос, и поставите перед ним NGINX, балансировщик и другие сервисы, то вы увидите, что запрос и ответ содержат заголовки (Headers). В заголовках могут содержаться пользовательские данные (например, данные для аутентификации), а также служебные данные, сгенерированные промежуточными компонентами (например, идентификаторы трейсов и имена серверов). Точно так же, контекст в Go может содержать пользовательские значения и служебную информацию, которая необходима для выполнения задачи.
Контекст в Go позволяет передавать значения между функциями вверх и вниз по стеку вызовов, включая функцию, описанную в интерфейсе, и функции выше и ниже неё. Для доступа к этим значениям необходимо правильно проверить наличие значения с определенным ключом в контексте. Контекст также может содержать интерфейсы для доступа к базе данных, логированию или телеметрии в зависимости от условий.
Ещё он позволяет избежать использования синглтонов, которые в последнее время признаны антипаттерном. Однако за этим стоит динамическая проверка типов и необходимость явно указывать, какую информацию ожидает функция в контексте.
Важный вопрос ещё о том, где происходить извлечения из контекста той информации, которая точно нужна в функции. К примеру, если контекст несёт себе информацию о логировании (к примеру файл для вывода логов), то где мы должны её извлечь? В функции, которая вызывает логирование или в функции, которая производит запись в лог?
То есть, если:
|
То:
|
Или:
|
При решении такого вопроса, нам надо просто постараться обеспечить прозрачность. То есть будет лучше написать две функции: одна будет явно извлекать из контекста, а вторая писать в лог.
|
Здесь можно логирование убрать в отдельный пакет, тогда вызов его сведётся к чему-то вроде:
|
Или реализовать функцию, которая производит логирование через информацию в контектсе:
|
Что сократит количество бойлерплейта.
Итого:
- Интерфейсы строго типизированные, не предполагают наличия какой-либо реализации или полей;
- Стремятся к тому, чтобы быть очень маленькими;
- Часто несут
context
, который используется как средство передачи информации о синхронизации и другой служебной информации; - Надо стараться как можно более явно описать тот факт что будет в интерфейсе, так как это сократит количество телодвижений при отладке.
Python
В прошлом разделе мы смотрели на типизированный язык. Но что насчёт если у нас нет строгих типов?
Поскольку Python предоставляет большие возможности по мета-программированию, то в нём есть абстрактные классы, которые могут быть использованы для расширения возможностей языка.
|
Разумеется, это можно переписать с использованием аннотаций типов и тем самым получить проверки перед началом исполнения программы:
|
При этом даже использование библиотеки abc
не является обязательным:
|
Но для проверки типов потребуется всё же наследование от некоторой сущности более высокого уровня. В случае использования библиотеки typing
это будет Protocol
.
|
Библиотека abc
также позволяет комбинировать определение абстрактного метода с property
и classmethod
:
|
Инверсия зависимостей на Python выглядит следующим образом:
|
Поскольку Python даёт много возможностей для мета-программирования, то количество способов, которыми можно выстрелить себе в ногу при использовании абстракций значительно больше чем в Go.
К примеру:
|
При синтаксической корректности, мы определили метод в абстрактном классе, что приводит к появлению химеры, которая не только объявление, но и частично определяет абстракцию.
Разумеется, чтобы абстракции работали в полную силу нам нужно иметь проверку типов, к примеру оператор *
и умножает и дублирует строку, поэтому следующий код верен, если у нас нет проверки типов:
|
Итого:
- Интерфейсы (абстрактные классы) можно делать различными методами, с использованием разных библиотек (и без них);
- Становятся по-настоящему мощными только при наличии аннотаций типов;
- Есть много способов как ошибиться при их использовании.
Helm
Далее рассмотрим декларативные языки и то, как мы можем использовать их для создания интерфейсов для деплоя приложений в k8s. Хотя для создания сущностей в k8s мы можем написать программу, используя доступные API и библиотеки на разных языках, наиболее распространенным способом является написание конфигурационных файлов на языке YAML, которые описывают запросы к API k8s, выполняемые при помощи программы kubectl
.
Для создания интерфейсов для фронт-енда также используются языки шаблонизаторы, такие как jinja
или text/template
. Они позволяют подставлять заданные переменные и создавать динамические страницы. Нам будет достаточно подобного, но для конфигураций в YAML.
Наряду с Helm, существует инструмент Kustomize, который также позволяет создавать интерфейсы для деплоя приложений в k8s. Kustomize рассматривает YAML файлы как структуры и применяет к ним патчи, которые переопределяют их содержимое. Важно упомянуть, что примеры таких патчей описаны в RFC 6902 относительно JSON (https://datatracker.ietf.org/doc/html/rfc6902), который также поддерживается в Kustomize. Это позволяет более гибко настраивать конфигурации для разных сред и облегчает их поддержку.
Далее рассмотрим использование именно Helm. Предположим, у нас есть несколько сервисов, которые мы хотим деплоить в k8s, и мы хотим унифицировать процесс их деплоя. Для этого мы можем создать следующий шаблон:
|
Для использования этого шаблона нам потребуется собрать Helm-пакет. Для этого определим файл Chart.yaml
:
|
Собрать его:
|
Что создаст файл my-chart-0.1.0.tgz
. После чего мы можем определить для него следующий файл переменных:
|
И установить в k8s:
|
А если нам потребуется обновить на новую версию чарта, то мы сделаем следующее:
|
Однако, стоит задуматься о том, где провести границу между тем, что должно решать система, и тем, что может решить пользователь, который будет использовать эту систему. Рассмотрим содержимое файла values.yaml
. Некоторые из полей кажутся избыточными:
containerPort
– можно предположить, что все сервисы будут использовать порт 80, и не указывать этот параметр явно;servicePort
– аналогично, можно считать, что все сервисы будут доступны по порту 80;serviceType
– данный параметр может сильно варьировать доступность сервиса на разных уровнях, поэтому его можно исключить;- Настройки ингресса тоже можно считать стандартизированными и не указывать явно в
values.yaml
.
Таким образом, убрав эти избыточные параметры, мы можем упростить настройку системы для пользователя:
|
|
Также, необходимо учесть версионирование. У нас есть несколько версий:
- Версия чарта, который определяет то, что мы деплоим;
- Версия сервиса (tag контейнера);
- Версия
values.yaml
, которая определяет конфигурацию сервиса.
Как можно решить этот вопрос? values.yaml
должен быть доступен для разработчиков, поскольку они определяют параметры, которые будут использоваться при развёртывании сервиса. Поэтому, версия values.yaml
может совпадать с версией сервиса.
В связи с этим, можно вспомнить о семантическом версионировании: https://semver.org/.
Наконец, кажется, что наш интерфейс получился слишком сложным. Чтобы разрешить эту проблему, можно разделить интерфейс на базовые чарты, которые описывают каждый блок чарта, указанного выше.
|
Тогда мы сможем собрать индивидуальный чарт для сервиса:
|
Какие преимущества мы получим, используя версионирование и разделение интерфейсов в наших сервисах, а также принципы SOLID?
Благодаря версионированию, мы сможем гибко настраивать наши сервисы, учитывать зависимости и контролировать изменения. Мы будем иметь доступ к следующим версиям:
- Базовых чартов;
- Чартов сервисов;
- Файлов
values.yaml
, которые соответствуют версии docker-образов.
Стоит отметить, что с помощью версионирования мы эффективно разграничиваем ответственности, вводим четкие интерфейсы и управляем зависимостями - все это основные принципы SOLID.
Итак, в результате мы получаем:
- Для декларативных языков соблюдаются принципы SOLID;
- Стандартизация на уровне компании/продукта позволяет сократить размеры интерфейсов;
- Версионирование в декларативных языках происходит более явно;
- В Helm имеются типы, которые наследованы от Go (ссылка на документацию: https://helm.sh/docs/chart_template_guide/data_types/).
Terraform (HCL)
Как мы можем описывать интерфейсы для инфраструктуры в целом, используя Terraform, также известный как Hashicorp Config Language? Давайте рассмотрим пример и опишем деплой lambda-функции в AWS при помощи Terraform.
Мы можем использовать Terraform для создания инфраструктуры как кода и определения ее состояния. В данном случае, мы можем использовать его для создания и управления lambda-функцией в AWS.
|
Предполагаем, что функция у нас лежит в zip-архиве, функция может быть простым скриптом.
Развернём её:
|
Что будем делать дальше? Правильно! Сделаем интерфейс, минимальный и удобный. За это в HCL отвечают модули:
|
Как можно заметить у нас осталась только одна функция, чего будет достаточно для деплоя. Теперь поместим файлы в отдельные папку и используем как модуль.
А если нам надо создать много функций, то мы можем использовать следующий синтаксис:
|
Итого:
- Мы можем описывать инфраструктуру тоже используя принципы SOLID;
- Создавай некоторый уровень абстракции мы должны следить за его размером и сайд-эффектами.
Остальное
CI/CD
CI/CD это тоже интерфейс! Чтобы успешно его внедрить, нам надо сформировать некоторые правила:
- Пусть сервис хранится в git-репозитории;
- Пусть конфигурация сервиса задаётся helm-чартом из примера выше;
- Пусть сервис хранит все настройки в values.yaml;
- Пусть сервис собирается командой
make build
в корневом каталоге; - Пусть образ сервиса формируется командой
make image
в корневом каталоге; - Сервис загружается в Container registry по своему имени;
- В корневом каталоге лежит файл с описанием сервиса, содержащий:
|
Данной информации достаточно для проведения деплоя сервиса в облако. Здесь мы можем комбинировать различные сущности и интерфейсы, используя их для последовательного процесса разворачивания сервиса.
Однако пока не существует единого языка для описания этого процесса, за исключением естественного языка. Требуется стандарт, написанный на естественном языке, чтобы определить требования к сервисам для проведения деплоя в рамках конкретного пайплайна.
API
API может задаваться при помощи proto или OpenAPI. Что позволяет ещё и генерировать код разной сложности. Тут уже достаточно сложно добиться независимости от конкретных реализаций сторонних сервисов, но если у вас есть идеи как это сделать, то буду рад их услышать.
Конфигурация
Конфигурация — это тоже интерфейс. При этом есть много разных мест и её способов хранения, но выработка универсального решения это тоже решение будущего.
Выводы
Таким образом, использование интерфейсов - это мощный инструмент для разработки ПО, который может помочь обеспечить стандартизацию, повысить эффективность и упростить процесс создания и управления кодом и инфраструктурой. При этом:
- Принципы SOLID могут применяться к интерфейсам практически везде, в зависимости от контекста;
- Стандартизация является ключевым результатом использования интерфейсов;
- Использование типов может сделать использование интерфейсов более эффективным;
- Для интерфейсов необходимо версионирование, которое может быть более или менее явным в зависимости от уровня;
- Интерфейсы могут использоваться для генерации кода, инфраструктуры и других целей;
- Интерфейсы могут нести неявный контекст, но пользоваться им надо осторожно.