Привет, Хабр! Nikolaich << in

Я java-программист по профессии и алкоголик go-developer по зову души. И вот в один прекрасный день я подумал о том, что раз уж в Go есть пакет reflect, то должны быть и способы АОП, прямо как в java. Если вкратце, хочется генерировать обертки для функций в рантайме, позволяя красиво оборачивать логи, мониторинги, трейсинги, и прочие довольно однотипные штуки, по аналогии с тем, как я проделывал это в java.

Вот понятный джава-программистам пример кода такой обертки.

Java Spring AspectJ code
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LoggingAspect {

    @Around("execution(* ru.example.service.EmployeeService.*(..))")
    public Object aroundAdvice(
      ProceedingJoinPoint joinPoint) throws Throwable {

        // Custom logic before the method execution
        logger.info("Before method execution");

        // Proceed with the actual method execution
        Object result = joinPoint.proceed();

        // Custom logic after the method execution
        logger.info("After method execution");

        return result;
    }
}

Для заинтересовавшихся оставляю ссылку для первичного погружения в Spring AOP. А для тех, кто ничего из этого не понял (потому что пошла она, эта ваша java) оставляю ссылку на вики, что за зверь такой АОП.

Итак, начнем уже кодить на Go! Для начала определимся с сигнатурой. На вход мы хотим принимать обертываемую функцию funcIn, а на выходе получать новую функцию funcOut, но с точно такими же входными и выходными параметрами, как funcIn. В этом нам помогут дженерики. Фича появилась в языке с версии 1.18, и мне кажется очень полезной.

func wrapFunc[FuncType any](funcIn FuncType) (funcOut FuncType)

В квадратных скобках указываем, что используем дженерик [FuncType any]. То есть по сути мы готовы принять any - что угодно. Что не очень хорошо, так как кто-то может подсунуть на вход не функцию, а какую-нибудь структуру, срез или другие неподходящие вещи, но специфичного определения, указывающего компилятору, чтобы FuncType был исключительно функцией, я не нашел... Но что хорошо - это то что получив на вход функцию funcIn с некоторым набором входных и выходных параметров, мы можем быть уверены, что на выходе будет функция funcOut с точно таким же набором входных и выходных параметров, и мы сможем практически бесшовно подкладывать на место старой функции - новую. Так как и funcIn имеет тип FuncType, и funcOut имеет точно такой же тип FuncType! (Хоть мы пока и не знаем, какой именно)

А еще нам хотелось бы принимать во wrapFunc обертку wrapper для нашей функции funcIn. wrapper - это тоже функция. На вход она должна будет принять funcIn, а на выходе - сгенерировать рефлективное представление абсолютно любой функции в go, которое мы затем передадим в reflect.MakeFunc, чтобы создать настоящую функцию. Сразу запишем синоним для этого рефлективного представления функции:

type WrappedFunc = func(args []reflect.Value) []reflect.Value

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

func SimpleWrapper(funcIn any) WrappedFunc {
	return func(args []reflect.Value) []reflect.Value {
      // тут может быть код ДО продолжения исполнения funcIn
      results := aop.Proceed(funcIn, args) // вызываем саму funcIn
      // тут может быть код ПОСЛЕ выполнения funcIn
      return results // возвращаем результат исполнения funcIn
	}
}

// Функция вызывает абсолютно любую функцию fptr с полученными аргументами args
// и возвращает результат.
// Очень удобно, когда ты не знаешь, что за функция 
// и какие входные/выходные параметры. Рефлексия все разберет сама
func Proceed(fptr any, args []reflect.Value) []reflect.Value {
	return reflect.ValueOf(fptr).Call(args)
}

И вот такую обертку wrapper мы хотим подсунуть во wrapFunc вторым аргументом и применить к funcIn, чтобы на выходе получить заветную funcOut.

func wrapFunc[FuncType any](
  funcIn FuncType,
  wrapper func(any) WrappedFunc,
) (funcOut FuncType) {
	fn := reflect.ValueOf(&funcOut).Elem() // берем рефлексию на funcOut
    // генерируем WrappedFunc на основе нашей funcIn, 
    // и создаем из нее новую рефлексию некой функции
	rf2 := reflect.MakeFunc(fn.Type(), wrapper(funcIn)) 
	fn.Set(rf2) // подкладываем сгенерированную рефлексию в funcOut
	return // ура! у нас получилась совершенно новая функция заместо старой
}

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

go get github.com/alnpokrovsky/go-aop

aop.go
package aop

import (
	"fmt"
	"reflect"
	"runtime"
)

type WrappedFunc = func(args []reflect.Value) []reflect.Value

// WrapFunc returns new function with same signature as funcIn func
// funcIn will be argument of every wrapper.
// so you should manually call Proceed(fptr, args)
// to get result of inner function inside wrapper
func WrapFunc[FuncType any](
	funcIn FuncType,
	wrappers ...func(any) WrappedFunc,
) (funcOut FuncType) {
	funcOut = funcIn
	for _, wrapper := range wrappers {
		funcOut = wrapFunc(funcOut, wrapper)
	}
	return
}

func wrapFunc[FuncType any](
	funcIn FuncType,
	wrapper func(any) WrappedFunc,
) (funcOut FuncType) {
	fn := reflect.ValueOf(&funcOut).Elem()
	rf2 := reflect.MakeFunc(fn.Type(), wrapper(funcIn))
	fn.Set(rf2)
	return
}

// Proceed is usabale for wrapper, when you just want
// to call wrapped func with provided arguments
func Proceed(fptr any, args []reflect.Value) []reflect.Value {
	return reflect.ValueOf(fptr).Call(args)
}

// IsImplements checks if reflectValue rv implements interface T
func IsImplements[T any](rv reflect.Value) bool {
	return rv.Type().Implements(reflect.TypeOf((*T)(nil)).Elem())
}

// As casts reflectValue rv to your interface T
func As[T any](rv reflect.Value) T {
	return rv.Interface().(T)
}

// FuncName returns function name with it's argument and return types
func FuncName(fn any) string {
	fnName := runtime.FuncForPC(reflect.ValueOf(fn).Pointer()).Name()
	fnType := reflect.TypeOf(fn).String()
	return fmt.Sprintf("%s %s", fnName, fnType)
}

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

var callsCounter = 0

func CleanWrapperCountAfter(fptr any) aop.WrappedFunc {
	return func(args []reflect.Value) []reflect.Value {
        log.Println(aop.FuncName(fptr)) // выводим имя функции перед выполнением
        results := aop.Proceed(fptr, args) // вызываем исходную функцию
		callsCounter++ // увеличиваем счетчик вызовов функции
		return results
	}
}

func main() {
    wrappedFunc := aop.WrapFunc(func(a int, b int) int {
		return a + b
	},
		CleanWrapperCountAfter,
	) // получили новую функцию
    c := wrappedFunc(1,2) // применили
    log.Println(c) // просто чтобы не ругался на неиспользуемые переменные
    log.Println(callsCounter) // о чудо, счетчик равен 1!
}

Казалось бы, здорово! Я изобрел АОП в golang. Завершаем повествование и расходимся копипастить и улучшать читабельность кода, помещая всякие трейсы функций в обертки.


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

aop_test.go
package aop

import (
	"reflect"
	"testing"

	"github.com/stretchr/testify/assert"
)

var callsCounter = 0

func cleanWrapperCountAfter(fptr any) WrappedFunc {
	return func(args []reflect.Value) []reflect.Value {
		results := Proceed(fptr, args) // вызываем исходную функцию
		callsCounter++                 // увеличиваем счетчик вызовов функции
		return results
	}
}

// just to make sure it is working
func TestSimpleWrapper(t *testing.T) {
	wrappedFunc := WrapFunc(func(a int, b int) int {
		return a + b
	},
		cleanWrapperCountAfter,
	) // получили новую функцию
	result := wrappedFunc(1, 2) // применили

	assert.Equal(t, 3, result)
	assert.Equal(t, callsCounter, 1)

	result = wrappedFunc(10, 20)

	assert.Equal(t, 30, result)
	assert.Equal(t, callsCounter, 2)
}

var simpleFunc = func(a int, b int) int {
	return a + b
}

func BenchmarkNoReflection(b *testing.B) {
	for i := 0; i < b.N; i++ {
		simpleFunc(1, 2)
	}
}

var wrappedFunc = WrapFunc(simpleFunc, cleanWrapperCountAfter)

func BenchmarkWrappedFunc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		wrappedFunc(1, 2)
	}
}

var twiceWrappedFunc = WrapFunc(simpleFunc, cleanWrapperCountAfter, cleanWrapperCountAfter)

func BenchmarkTwiceWrappedFunc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		twiceWrappedFunc(1, 2)
	}
}

Теперь запускаем go test -bench=. и ужасаемся полученным цифрам

Ужасные цифры
Ужасные цифры

Мы ухудшили производительность простейшей функции в 300 раз (с 0.9323ns/op до 273.7ns/op) всего одной оберткой! А если мы хотим применить две, то уже в 600 раз. Не самые лучшие показатели для программы на языке, который борется за звание дома высокой культуры быта топ-10 по скорости выполнения. Пожалуй, мы и пользуемся Go как раз из-за скорости работы программ, на нем написанных.

P.S. Некоторые дополнительные исследования показали, что использование одной обертки ухудшает производительность исходной функции не в 1000 раз, как могло сразу показаться, а лишь на фиксированные 250-400ns(зависит от машины), что по прежнему довольно много, но уже не кажется таким кошмаром, когда обертка располагается над прожорливой функцией, которая и так работает несколько миллисекунд. А сделают ли лишние пол миллисекунды погоду - оставляю на усмотрение авторов программ.

P.P.S. Если кого-то заинтересовало, какие именно я проводил дополнительные исследования, и как пытался уменьшить время, сжираемое обертками, - возможно, я мог бы посвятить этому отдельную статью. Пишите в комментариях "Хочу продолжение!".

Nikolaich >> out

Комментарии (9)


  1. csl
    05.06.2024 18:23
    +1

    Хочу продолжение!


  1. NeoCode
    05.06.2024 18:23
    +1

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


    1. Kealon
      05.06.2024 18:23

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


  1. nalgeon
    05.06.2024 18:23
    +10

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

    var nCalls = 0
    
    func count(fn func()) {
        fn()
        nCalls++
    }
    
    func main() {
        sum := func(a int, b int) int {
            return a + b
        }
    
        total := 0
        count(func() { total += sum(1, 2) })
        count(func() { total += sum(3, 4) })
        count(func() { total += sum(5, 6) })
    
        fmt.Println(total)
        // 21
        fmt.Println(nCalls)
        // 3
    }
    

    песочница


    1. alnpokrovsky Автор
      05.06.2024 18:23

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

      Но, как я и отмечал в конце статьи, использование рефлексии сильно замедляет код. И да, не очень-то типично для Go, ратующего за прозрачность. Отсюда и название "... и почему вам не стоит этого делать" )))


  1. 9241304
    05.06.2024 18:23
    +4

    Откуда эта страсть к переусложнению на ровном месте?


  1. sdramare
    05.06.2024 18:23
    +6

    Если уж делать AOP в го, то логичней использовать кодогенерацию через AST пакет. Но лучше AOP не делать вообще и дело вовсе не в производительности(для большинства приложений лишние 200 наносекунд на вызове функции абсолютно незаметны). Дело в том, что в го, как в языке, есть концепция - код должен быть простым, однозначным и очевидным. Должен быть только один путь сделать что-то. Именно по этому в го нет такого количества синтаксического сахара, как в других языках и сами программы выглядят более многословными, но хорошо читабельными. Да, вы можете пытаться использовать в го AOP, DI, ORM и т.д., эмулировать ООП, делать политики создания абстрактных фабрик, но делать это будет неудобно и выглядеть оно будет неогранично. Мое субъективное мнение, что го отлично подходит для рапид-девелопмента, когда вам надо быстро сделать какой-то тул или создать еще один микросервис, которые будет небольшим и сосредоточеным на одной конкретной задаче. В этом случае вам никакие AOP просто не нужны, вы всю нужную логику напишите быстрее, чем будете гуглить документацию и писать обязательный бойлерплейт. А если вам надо делать огромный монореп, с миллионами строк кода, кучей объектов предметной области в одном приложении-сервисе, с множеством правил их взаимодействия, исключений из правил, самой разной бизнес логики, над которым одновременно работают десятки программистов и все это не возможно поддерживать без множества слоев абстраций, то лучше просто не использовать го, а посмотреть в сторону других языков - той же джавы, котлина, сишарпа.


  1. serjeant
    05.06.2024 18:23
    +2

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


    1. alnpokrovsky Автор
      05.06.2024 18:23

      Возможно, вы невнимательно читали, и не заметили, что я никого не заставляю так делать. Более того, даже не рекомендую =D