golang

AOP в Golang: как рефлексировать, и почему вам не стоит этого делать

  • пятница, 7 июня 2024 г. в 00:00:04
https://habr.com/ru/articles/819789/

Привет, Хабр! 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