Pull to refresh

Soft Mocks для Go! (переопределение функций и методов в runtime)

Reading time 4 min
Views 5K

Soft Mocks для Go!


Основная идея Soft Mocks для PHP — это переписывание кода «на лету» перед include(), чтобы можно было поменять реализацию любых методов, функций и констант во время исполнения. Поскольку go — компилируемый язык, то логично делать то же самое на этапе компиляции. В этой статье я расскажу по свой проект Soft Mocks for Go.

Функциональность


Возможности Soft Mocks for Go весьма ограничены — вы можете временно переопределить нужные вам функции и методы, а потом откатить свои правки. Также можно вызвать оригинальную функцию.

При использовании soft mocks, следующий код:

func main() {
    closeFunc := (*os.File).Close
    soft.Mock(closeFunc, func(f *os.File) error {
        fmt.Printf("File is going to be closed: %s\n", f.Name())
        res, _ := soft.CallOriginal(closeFunc, f)[0].(error)
        return res 
    })  
    fp, _ := os.Open("/dev/null")
    fmt.Printf("Hello, world: %v!\n", fp.Close())
}

Напечатает вот что:

File is going to be closed: /dev/null
Hello, world: <nil>!

Скачать библиотеку можно тут.

Аналоги


Для go уже есть библиотека для monkey patching: github.com/bouk/monkey. Эта библиотека точно также позволяет подменять реализацию функций и методов структур, но работает она по другому принципу и пытается «пропатчить» код функции прямо во время исполнения, переписывая память приложения. Этот способ тоже имеет право на существование, но мне кажется, что подход Soft Mocks лучше в долгосрочной перспективе.

Как это работает


Я начал с простого proof-of-concept, сделав следующую правку в файле file_unix.go стандартной библиотеки:

@@ -9,6 +9,8 @@
 import (
 	"runtime"
 	"syscall"
+
+	"github.com/YuriyNasretdinov/golang-soft-mocks"
 )
 
 // fixLongPath is a noop on non-Windows platforms.
@@ -126,6 +128,11 @@
 // Close closes the File, rendering it unusable for I/O.
 // It returns an error, if any.
 func (f *File) Close() error {
+	if closeFuncIntercepted {
+		println("Intercepted!")
+		return nil
+	}
+
 	if f == nil {
 		return ErrInvalid
 	}
@@ -293,3 +300,9 @@
 	}
 	return nil
 }
+
+var closeFuncIntercepted bool
+
+func init() {
+	soft.RegisterFunc((*File).Close, &closeFuncIntercepted)
+}

Однако оказалось, что стандартная библиотека не разрешает импорты извне (кто бы мог подумать?), поэтому пришлось сделать симлинк /usr/local/go/src/soft, который ведет на $GOPATH/src/github.com/YuriyNasretdinov/golang-soft-mocks. После этого код заработал и у меня получилось достичь того, чтобы можно было включать и отменять перехват по желанию.

Адрес функции


Немного странно, но в go нельзя сделать вот такой map:

map[func()]bool

Дело в том, что функции не поддерживают оператор сравнения и поэтому не поддерживаются в качестве ключей для map'ов: golang.org/ref/spec#Map_types. Но это ограничение можно обойти, если использовать reflect.ValueOf(f).Pointer() для получения указателя на начало кода функции. Причина же, почему функции не сравниваются между собой, заключается в том, что указатель на функцию в go на самом деле является двойным указателем и может содержать в себе дополнительные поля, такие как, например, receiver. Об этом более подробно рассказано вот здесь.

Concurrency


Поскольку в go есть горутины (pun intended), то простой булевый флаг будет вызывать race condition при вызове перехватываемой функции из нескольких горутин. В библиотеке github.com/bouk/monkey явно говорится о том, что метод monkey.Patch() не является потокобезопасным, поскольку патчит память напрямую.

В нашем же случае можно вместо простого bool сделать int32 (для экономии памяти это не int64), который мы будем изменять с помощью atomic.LoadInt32 и atomic.StoreInt32. В архитектуре x86 атомарные операции представляют из себя обычные LOAD и STORE, поэтому атомарное чтение и запись не будут слишком сильно влиять на производительность полученного кода.

Зависимости пакета reflect


Как можно видеть, мы подключаем в каждом файле пакет soft, который является алиасом для нашего пакета github.com/YuriyNasretdinov/golang-soft-mocks. Этот пакет использует пакет reflect, поэтому мы не можем переписывать пакеты reflect, atomic и их зависимости, иначе мы получим циклические импорты. А зависимостей у пакета reflect на удивление много:



Поэтому Soft Mocks для Go не поддерживает подмену функций и методов из приведенных выше пакетов.

Неожиданные грабли


Также, помимо всего прочего, оказалось, что в go можно писать, например, вот так:

func (TestDeps) StartCPUProfile(w io.Writer) error {
    return pprof.StartCPUProfile(w)
}

Обратите внимание на то, что у ресивера (TestDeps) нет имени! Точно также можно не писать имена аргументов, если вы их (аргументы) не используете.

В стандартной библиотеке иногда встречается type shadowing (имя переменной и имя типа совпадают):

func (file *file) close() error {
    if file == nil || file.fd == badFd {
        return syscall.EINVAL
    }   
    var err error
    if e := syscall.Close(file.fd); e != nil {
        err = &PathError{"close", file.name, e}
    }   
    file.fd = -1 // so it can't be closed again

    // no need for a finalizer anymore
    runtime.SetFinalizer(file, nil)
    return err 
}

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

Заключение


Я сделал Soft Mocks for Go буквально за несколько вечеров, в отличие от Soft Mocks для PHP, который разрабатывался порядка 2 недель. Это отчасти объясняется тем, что для Go есть хорошие встроенные инструменты для работы с AST файлов, а также с простотой синтаксиса — в Go намного меньше возможностей и меньше подводных камней, поэтому разработка такой утилиты была достаточно простой.

Скачать утилиту (вместе с инструкцией по использованию) можно по адресу github.com/YuriyNasretdinov/golang-soft-mocks. Я буду рад услышать критику и пожелания.
Tags:
Hubs:
+17
Comments 2
Comments Comments 2

Articles