Узнайте о рефлексиях Go и общем дизайне на практическом примере

1656660761 uznajte o refleksiyah go i obshhem dizajne na prakticheskom primere

Дэвид Ригер

2RvmmY5xQUDSIInEnSRmPDFr1gxRReHRgOmh

Рефлексии позволяют нам проверять и изменять структуру программы при ее выполнении. В этой статье мы рассмотрим некоторые части го reflect API пакета и применить их к реальному случаю использования путем создания a общий механизм настройки программы.

Что имеем и чего хотим

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

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

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

Мы также хотим разрешить аналитический процесс запускать — с фиксированной конфигурацией — автономно и периодически на планировщике задач (например, Jenkins, Rundeck или cron) и сообщать нам о интересующем нас результате без необходимости взаимодействовать с ним перед каждым запуском.

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

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

  • Постоянная и абстрактная конфигурация
  • Интерфейс, с помощью которого как пользователи, так и другое программное обеспечение (планировщик задач) могут запускать программу
  • Интуитивно понятное расширение

Подход 1: самодовольство

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

o6Z4F4-B9Y6OK3Zms-PlTzyq7HfwcOXsgO-H
Автономное приложение. Клиент и бизнес-логика — одно и то же программное обеспечение.
package main

func main() {
    rawData := Parse("books_db.flatfile")
    
// Run algorithms of our choice against the data
    bpDChart := amountOfBooksPerDecade(rawData)
    bpAList := booksPerAuthor(rawData)
    
createReport(bpDChart, bpAList)
}

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

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

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

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

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

Подход 2: Удобный для клиента

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

t4G9m0DUCL8cWZg9GE4y0vnbABPhqE5kZiCO
Чистая архитектура с клиентом, общающимся с опубликованным API на основе бизнес-логики. Часто отличный подход, но он не дается бесплатно.

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

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

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

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

Подход 3: общий

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

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

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

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

ACm908OU9Ky1E-IVL5Rb6SI6KdqCHMcscwMI
Рисунок Gopher от Рене Френч (reneefrench.blogspot.com)

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

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

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

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

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

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

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

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

То, как это реализуется, отличается от языка к языку. Мы рассмотрим (один из способов) решение проблемы в Go.

Поиск функций по их названиям

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

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

Однако можно найти a метод типа своим названием.

Метод Go — это функция, которая имеет приемника, тогда как получатель может быть любым типом, определенным в том же пакете, что и метод.

type Library struct {
    books []Book
}

func (l *Library) GetMostSoldBooks(startYear, endYear int) SoldStat {
    ...
}

Здесь мы определяем тип Library который базируется на типе struct. Теперь возьмем определенную выше функцию GetMostSoldBooksизвлеките параметр библиотеки и превратите его в тип приемника, который превращает функцию в метод *Library типа. С другой стороны, *Library описывает указатель на Library.

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

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

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

import "reflect"

m := reflect.ValueOf(&Library{}).MethodByName("GetMostSoldBooks")

Сначала нам нужно взять экземпляр типа приемника (тип приемника есть *Library) и превратить его в a reflect.Value передав его reflect.ValueOf(). Затем мы можем вызвать возвращенное значение MethodByName() с названием метода, который мы хотим получить.

То, что мы получаем взамен, — это завернутая вызываемая функция. reflect.Value который будет принимать именно те параметры, которые мы определили в определении метода. Обратите внимание, что после вызова этой функции экземпляр of *Library мы перешли к reflect.ValueOf() будет использован как тип приемника. Это значит, что важно, чтобы вы уже передали правильный экземпляр в reflect.ValueOf()функция.

Чтобы сделать возвратное значение m в приведенном выше примере фактически вызывается, нам нужно будет привести его с reflect.Value к фактическому типу функции с правильными подписями. Это будет выглядеть следующим образом:

mCallable := m.Interface().(func(int, int) SoldStat)

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

Создание методов общими

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

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

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

func (l *Library) GetMostSoldBooksWrap(p GenericParams) Reportable {
    return l.GetMostSoldBooks(p.(*MostSoldBooksParams))
}

Вот у нас есть метод обертки GetMostSoldBooksWrap для конкретного способа GetMostSoldBooks. Как и конкретный метод, обертка является методом типа *Library. Разница заключается в его подписи. Он принимает общий прометр GenericParams и возвращает экземпляр типа Reportable. В своем теле он вызывает конкретный аналитический метод, обрабатывающий библиотечные данные. Также новый тип MostSoldBooksParams который оборачивает параметры конкретного метода.

Теперь давайте посмотрим, откуда берутся новые типы.

Для того чтобы иметь возможность пройти GenericParams параметр к бетону GetMostSoldBooks() метод, конкретный метод должен принимать только один параметр, к которому мы можем привести общий параметр. Мы делаем это, изменяя сигнатуру метода конкретной функции принятия a *MostSoldBooksParams параметр.

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

type MostSoldBooksParams struct { 
    startYear int
    endYear int
}

func (l *Library) GetMostSoldBooks(p *MostSoldBooksParams) SoldStat {
    ...
}

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

Вернемся к методу обертки.

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

В этом решении мы называем оболочки из <concrete method name&gt;Завернуть. В файле конфигурации мы можем просто указать имя того же конкретного метода, и логика отображения будет append Перед поиском метода перейдите к строке.

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

type GenericParams interface { 
   IsValid() (bool, string)
}

The GenericParam Тип параметра – это интерфейс. Объявляем один метод IsValid() (bool, string) для этого интерфейса, что означает, что каждая структура, определяющая этот метод, автоматически реализует GenericParams интерфейс.

Это актуально, поскольку в нашем методе оболочки мы транслируем интерфейс GenericParams к конкретному типу структуры MostSoldBooksParams . Это работает только если MostSoldBooksParams реализует GenericParams интерфейс.
Поэтому сейчас мы предоставляем a IsValid() метод для конкретного типа параметра.

func (p *MostSoldBooksParams) IsValid() (bool, string) {
 …
 return true, “”
}

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

func (l *Library) GetMostSoldBooks(p *MostSoldBooksParams) SoldStat
{
    if isValid, reason := p.IsValid(); !isValid {
        log.Fatalf(“\nParams invalid:: %s”, reason)
    }
    ...
}

Наконец, мы имеем Reportable тип, являющийся нашим общим возвращенным значением.

type Reportable interface { 
    Report() HTMLStatisticReport 
}

Как и общий тип параметра, Reportable это интерфейс. Он объявляет один метод Report() вернет статистический отчет в формате HTML.

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

Мы делаем это еще раз, написав реализацию метода, объявленного интерфейсом.

func (p SoldStat) Report() HTMLStatisticReport {
    ...create report...
}

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

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

m := reflect.ValueOf(library).MethodByName("GetMostSoldBooksWrap")
mCallable = m.Interface().(func(GenericParams) Reportable)

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

Передача параметров

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

statistics: 
    — statsMethodName: GetMostSoldBooks 
      startYear: 1984
      endYear: 2018

Выше показан пример файла конфигурации в формате YAML. У нас есть корневой элемент statistics который отображается в списке. Каждый элемент списка является аналитическим алгоритмом, который мы хотим запустить и включить его результат в отчет. Элементы состоят из ключа statsMethodName, с названием аналитического метода в качестве значения и одним ключом для каждого параметра с соответствующими значениями. Имена параметров должны совпадать с именами полей в структуре параметров, объявленной для связанного метода. В этом случае структура параметра является той, что мы объявили раньше, а именно MostSoldBooksParamsс полями startYear и endYearоба из которых имеют тип integer.

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

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

methodName := "GetMostSoldBooks" // taken from configuration file
conreteMethod := reflect.ValueOf(library).MethodByName(methodName)

wrapperName := fmt.Sprintf("%sWrap", methodName)
wrapperMethod := reflect.ValueOf(library).MethodByName(wrapperName)

Далее нам необходимо получить доступ к типу параметра, переданного конкретному методу.

concreteMethodParamsType := conreteMethod.Type().In(0).Elem()

concreteMethodParamsType Теперь будет сохранять тип структуры параметра метода. Для случая с GetMostSoldBooks это есть MostSoldBooksParams.

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

concreteMethodParamsPtr := reflect.New(concreteMethodParamsType)
concreteMethodParams := concreteMethodParamsPtr.Elem()

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

parameterField := concreteMethodParams.FieldByName(configParam)

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

if configValueInt, isInt := configValue.(int); isInt {
    parameterField.SetInt(int64(configValueInt)
)

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

Наконец, так же, как мы поступали с методом обертки, мы приведем concreteMethodParams struct to a GenericParams типа. Обратите внимание, что здесь нужно использовать тип указателя.

wrapperParams := concreteMethodParamsPtr.Interface().(GenericParams)

Собрав все вместе

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

wrapperMethod(wrapperParams)

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

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

Теперь вопрос, который вы должны задать: Это хороший код?

Мой ответ: Не знаю.

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

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *