Go многими воспринимается как средство для написания микросервисов, тем не менее, сам он является языком общего назначения и позволяет писать приложения любого рода, в том числе, поддерживающие модули расширения. Примерно год назад передо мной встала задача добавить поддержку плагинов в приложение на Go. Задача стояла не особо остро, и руки до решения дошли только сейчас. Ответ, однако, оказался не столь очевидным, сколь хотелось бы. Идеального решения, как по мне, нет, поэтому попытаюсь максимально объективно рассмотреть все три имеющихся. Публикация вышла довольно объемная, поэтому для тех, кому интересны только общее описание, сравнения и выводы, прилагается содержание. Стандартный пакет plugin  Итого RPC при помощи hashicorp/go-plugin  Итого Динамическая подгрузка библиотек C  Темная сторона CGO  Вызов C-функций из Go Преобразование типов Передача указателей Контракт C Реализация плагина на Go Реализация основного приложения Итого Выводы Думаю, что всем, кто пытался добавить поддержку системы плагинов для своего приложения на Go, первым в поиске попадался стандартный пакет plugin. К сожалению, он применим только в двух случаях: Вы не всегда хотите включать все модули приложения в поставку Вы хотите динамически подгружать нужные модули по мере необходимости Собственно, все. Описывать работу с пакетом не вижу смысла - официальная документация снабдит отличными примерами и даст исчерпывающие ответы на все возможные вопросы. Как по мне, путаницу вносит само название пакета, ведь плагин, в привычном понимании, при помощи него создать невозможно. На нас действует серьезное ограничение: плагин должен быть собран тем же окружением, что и основное приложение. Имеются различия в версии компилятора - до свидания, версия плагина отличается от ожидаемой версии приложения - счастливого пути. Фактически, плагин может собрать только сам разработчик основного приложения, что убивает на корню все затею. Также очевидно, что плагин не может быть написан на другом языке. Тем не менее, есть и плюсы: пакет стандартный, поддерживается рантаймом самого языка. Приложение с использованием стандарного пакета plugin, аналогичное по функционалу тому, что приведено в последней части этой статьи, можно посмотреть здесь. Плюсы: Входит в стандартную поставку Поддерживается внутренними средствами языка Позволяет передавать указатели Используется единый рантайм Плагин и приложение работают в едином адресном пространстве, не создается дополнительных процессов Минусы: Использует CGO, а потому потребует наличия кросс-компилятора C для сборки под другую платформу/архитектуру Работает только на Linux, FreeBSD, macOS Не дает возможности собирать плагины в другом окружении и другими разработчиками Не дает возможности подключать плагины, собранные для другой версии приложения Не дает возможности писать плагины на другом языке Не позволяет динамически отключать подключенные плагины Как оказалось, вопросом подключения сторонних модулей к Go-приложению задались в HashiCorp более четырех лет назад. Выход из ситуации они нашли весьма очевидный и странный одновременно: если Go хорош для микросервисов - пусть плагины будут микросервисами! Суть идеи довольно проста: Компилируем отдельно основное приложение и отдельно сервисы плагинов с единым контрактом net/rpc или gRPC Основное приложение запускает процессы с плагинами через exec.Command и устанавливает с ними связь по TCP через сокет Взаимодействие происходит через удаленный вызов процедур Любое приложение с плагином состоит, как минимум, из трех-четырех пакетов, поэтому размещать здесь листинги считаю излишним. За официальными примерами кода можно проследовать сюда. За неофициальными - сюда и сюда. Плюсы: Не использует CGO, а значит, не имеет проблем с кроссплатформой Позволяет писать и собирать плагины отдельно от основого приложения Позволяет писать плагины на других языках (правда, нормальной документации на эту тему нет, так что придется поковырять исходники) Позволяет динамически отключать подключенные плагины Минусы: Передача данных поверх TCP имеет существенные накладные расходы, а gRPC в моих тестах сработал даже медленнее net/rpc Плагин запускается в виде отдельного процесса и взаимодействует с приложением через сокет Компилятор Go умеет собирать не только исполняемые файлы и плагины, но и библиотеки C, а раз он умеет их собирать, то должен уметь и подгружать. Вырисовывается решение: используем динамически подключаемые библиотеки для взаимодействия с плагинами, получаем возможность собирать и поставлять плагины отдельно от основного приложения и вообще писать их на других языках. Звучит здорово, не правда ли? Не совсем... О том, как написать плагин в виде подключаемой библиотеки, не выстрелив себе в ногу, и порассуждаю ниже. Как человек, исповедующий инженерный подход, я честно сперва пытался найти готовый пакет, позволяющий загружать динамические библиотеки, но ничего подходящего мне не попалось (если вам есть что посоветовать - жду в комментариях). Да, решения есть, но те, что удалось обнаружить, либо очень грубо реализуют обертки над функциями C, либо просто являются примерами с преамбулой на C (комментарий с C-кодом, расположенный перед импортом псевдо-пакета C). И все они используют libdl, то есть работают только в POSIX-совместимых системах. Мне же хотелось получить что-то похожее на работу стандартного пакета plugin, что-то, что будет инкапсулировать в себе хотя бы процессы загрузки и выгрузки библиотек. В итоге, на основе кодовой базы стандартного пакета plugin, был реализован собственный пакет dlplugin. Этот пакет имеет достаточно простой интерфейс и реализует лишь несколько основных функций: Подгружает динамические C-библиотеки Предоставляет функцию поиска символов (symbol) в библиотеке для инициализации интерфейса плагина Выгружает динамические C-библиотеки Также есть наброски реализации для MS Windows, но попытка запустить собранную при помощи кросс-компиляции пару (приложение и библиотека) не увенчалась успехом. Так как сам я под Windows не пишу, и разворачивать окружение на целевой платформе для отладки одного пакета было лень, то на данный момент реализация для Windows остается под вопросом, поэтому, если у кого-то возникнет желание доделать начатое - буду рад принять pull request на github. Будет справедливо заметить, что библиотека должна не только загружаться и выгружаться, но еще и выполнять какую-то полезную работу. Понятное дело, универсальный пакет не может ничего знать об интерфейсе конкретного плагина, а, следовательно, не может его самостоятельно инициализировать. (На самом деле, мог бы - задача решается кодогенерацией, но это - тема для отдельной статьи.) Разработчику приложения в любом случае придется иметь дело с CGO. Итак, добро пожаловать на темную сторону. Я постараюсь продемонстрировать процесс написания приложения и библиотеки с плагином на синтетическом примере, с которым можно ознакомиться по ссылке. Предположим, что наше приложение работает с некими устройствами (Device), эти устройства хранят в себе значения типа int32 имеют методы для изменения, чтения и печати значения, также есть методы, позволяющие получить сериализованное состояние устройства в бинарном виде или в JSON: Кроме того, нам понадобятся функции, которые будут создавать экземпляр устройства и освобождать его, когда оно нам более не потребуется: Реализации могут быть разными и должны подключаться динамически в виде библиотек, а это значит, что нам нужно спроектировать C-интерфейс библиотеки. Для начала представим, как подобный интерфейс мог бы выглядеть в Go: Так как внутреннее представление типа Device может отличаться от библиотеки к библиотеке, а тип interface{} невозможно передать в C, будем использовать вместо него числовой идентификатор, для этих целей отлично подойдет тип uintptr. Дело в том, что передавать в C можно только те Go-указатели, которые не хранят в себе других Go-указателей. Любой интерфейс всегда содержит указатель на исходное значение, а значит не может быть передан в C, многомерные массивы ([][]int) и структуры, содержащие указатели (type struct { v *int }), также не могут быть переданы. (Если я в чем-то ошибся при штудировании документации к CGO - прошу более сведущих в вопросе поправить меня.) Также учтем, что может быть передан некорректный идентификатор в любой из методов интерфейса плагина, а значит, может быть возвращена ошибка. Учтем все сказанное выше: Такой интерфейс допустим для Go, но не для C. C не может возвращать несколько значений (можно было бы завернуть их в структуру, но лучше поступить более традиционным способом - передать указатель на значение, которое необходимо заполнить). Именно здесь мы ступаем на шаткую дорожку, держа в руке заряженный пистолет, направленный в собственную ногу. Начнем с того, что встроенный тип Go int и тип C.int - разные типы, потому, если функция C ожидает типа int *, то из Go нужно передавать *C.int, привести int к C.int и наоборот нет проблем, а вот приведение типов указателей уже так просто не провернуть, но мы вполне можем провернуть следующий трюк при вызове C-функции: (*C.int)(unsafe.Pointer(&intVal)). В C unsafe.Pointer эквивалентен типу void *, то есть - нетипизированный указатель, поэтому можно привести любой указатель к unsafe.Pointer, а unsafe.Pointer привести к любому указателю. Здесь, пожалуй, стоит остановиться и кратко рассмотреть работу с CGO. Я настоятельно рекоммендую самостоятельно тщательно ознакомиться с официальной документацией к CGO, здесь же лишь попытаюсь продемонстрировать несколько примеров и указать на узкие моменты. Тем же, кто хорошо знаком с нюансами работы с CGO предлагаю перейти сразу к следующей части. Весь C-код, используемый в пакете, должен быть размещен в виде С-преамбулы (понятие из официальной документации) - комментария перед импортом псевдо-пакета C, который не может быть сгруппирован с другими импортами. Это, конечно, не означает того, что можно работать только с кодом из комментария, никто не запрещает подключать любые заголовки и библиотеки, причем, если разместить C-код отдельно и включить его при помощи директивы #include в преамбуле Go-пакета, то C-код из этой же директории должен быть автоматически скомпилирован при пересборке пакета. Для обращения к функциям и типам C нужно использовать имя псевдо-пакета: C.. Рассмотрим небольшой пример: Альтернативный вариант, код C вынесен в отдельные файлы: ПРИМЕЧАНИЕ: Для запуска или сборки второго примера нужно указывать не main.go, а путь к директории или .: go run ., в противном случае C-код не будет скопилирован, и компилятор выдаст ошибку на этапе линковки. При вызове C-функций из Go действуют два главных ограничения: Нельзя вызывать функции, принимающие переменное число аргументов (varargs) Нельзя вызывать указатели на функции, то есть переменная, хранящая указатель на функцию не может быть вызвана напрямую из Go-кода как функция Стоит отметить, что все типы C являются приватными, а значит не могут быть использованы за пределами пакета, в котором происходит взаимодействие с C. Так тип C.int в пакете a и тип C.int в пакете b - разные типы данных. Рассматривать приложения, состоящие из одного пакета не будем, а значит, необходимо преобразовывать типы для экспортируемых значений. Вообще, таким образом, создатели Go намекают нам, что нужно четко обозначить водораздел - пакет, в котором происходит интеграция с C-кодом. Можно считать это, в некотором смысле, транспортным уровнем архитектуры. C-структуру нельзя присвоить Go-структуре, придется делать это вручную: Несколько иначе дело обстоит с массивами и строками. Для работы со строками и массивами байт вообще имеется несколько специальных функций: ПРИМЕЧАНИЕ: Под C-строкой подразумевается массив байт, оканчивающийся нулевым символом ('\0'). C.CString() и C.CBytes() выделяют память в куче при помощи malloc, поэтому необходимо вызывать C.free для особождения памяти (при этом, в преамбуле C должен быть подключен заголовок stdlib.h). Для массивов таких специальных функций не предусмотрено, зато для преобразования массива или среза в C-массив достаточно привести указатель на первый элемент массива к unsafe.Pointer, а затем - к нужному типу C-указателя, например: ПРИМЕЧАНИЕ: На самом деле типы Go могут быть переданы в C, для них будет генерироваться отдельное определение типа, например, int станет GoInt. Однако с точки зрения построения приложения с плагинами, которые могут быть реализованы на других языках, протекание Go-типов в C-код - не лучшая идея. Вот мы и подошли вплотную к самой главной сложности взаимодействия между Go и C. Здесь нужно понимать главную разницу между C-указателями и Go-указателями: первые контролируются разработчиком и, если память выделена на стеке, она автоматически будет освобождена при выходе из соответствующей функции, если же память выделена в куче при помощи *alloc-функции, то особождение памяти должно производиться вручную, вызовом free; за выделение и особождение памяти в Go отвечает сборщик мусора, который работает независимо от основного кода приложения. Таким образом, возможна следующая ситуация: Дело в том, что приведение типа к unsafe.Pointer разрывает связь с исходным массивом, на который ссылается срез, и, если срез более нигде в функции не используется, то память может быть очищена сборщиком мусора в любой момент. Таким образом, наличие переменной cArr не гарантирует того, что память не будет особождена. Однако, здесь мы можем использовать следующий трюк: если произвести приведение типа непосредственно при вызове функции, то сборщик мусора не осободит память тех переменных, что переданы в качестве аргументов, до завершения исполнения вызываемой функции. (Опять же, если есть мнение, что я неправильно понял данный нюанс - просьба оповестить в комментариях.) Безопасный вариант передачи будет выглядеть так: Также не стоит забывать о том, что массивы C, в отличие от массивов и срезов Go, не хранят в себе длину, ее нужно передавать отдельно: В тех случаях, когда переменная используется и после вызова C-функции, таких проблем не возникает. Также имеется специальная функция пакета runtime: KeepAlive. Смысл в том, чтобы вызвать runtime.KeepAlive и передать ей в качестве аргумента переменную, которая с этого момента может быть освобождена: Кроме того, если требуется передать указатель надолго, и гарантировать, что память, на которую он указывает, не будет особождена, можно самостоятельно гарантировать сохранность объекта в памяти, скажем, помещая его в глобальную переменную/срез/карту, либо, используя cgo.NewHadle(), но работу с этой возможностью рассмотрим ниже при организации работы функции обратного вызова (callback). Все сказанное выше касается и любых типов указателей, не только указателей на массивы и срезы. Вернемся к нашему интерфейсу плагина. Так как интерфейс должен быть представлен не в Go, а в C, то оформим его для себя визуально в виде соответствующего заголовочного файла. Встроенного типа ошибок в C нет, поэтому, для простоты будем возвращать целочисленный код ошибки. Также, несмотря на наличие типа bool в стандартной библиотеке C, мне не удалось использовать его, подключив соответствующий заголовок (stddef.h), поэтому в качестве булевого параметра будем использовать тип char: Выглядит неплохо, если не считать одного момента: передавая указатель на буфер мы не можем знать - какой именно объем памяти необходимо выделить. Можно было бы решить эту проблему, передавая в качестве дополнительного аргумента функцию-аллокатор, либо предванительно запрашивая необходимый размер буфера. В данном случае поступим иначе - пускай плагин сам выделяет память под буфер, а со стороны приложения передадим коллбек. Впомним, что нужно не просто вызвать какой-то коллбек, но и привязать его к конкретному экземпляру устройства. К сожалению, указатель на функцию Go нельзя напрямую передать в C, а C не поддерживает лямбда-функции, поэтому передадим, кроме того, ссылку-идентификатор на нужную лямбда-функцию Go в качесте дополнительного агумента (ниже рассмотрим способ создания и работы с такими идентификаторами). Итоговый вариант нашего контракта будет следующим: Начнем с наиболее простой части - реализации плагина на Go. Для начала опишем пакет device, который будет содержать реализацию устройства: Теперь приступим к написанию основного пакета плагина (main). Для того, чтобы Go-функции были доступны в качестве C-функций библиотеки, достаточно предварить их заголовок комментарием //export <func_name>, список функций и их имен уже определен выше в виде C-заголовка, поэтому приступим к реализации. Первая задача, которую необходимо решить - создание и удаление экземпляров устройств. Мы могли бы завести глобальную карту map[uintptr]*Device и хранить экземпляры там, но воспользуемся более простым методом - используем cgo.Handle. При каждом вызове cgo.NewHandle с передачей переменной в качестве параметра создается и возвращается идентификатор на ресурс, хранящийся в переменной, а сам ресурс становится достижим, а, следовательно, не будет удален сборщиком мусора, до момента удаления всех идентификаторов посредством метода h.Delete() и прямых ссылок на этот ресурс. ПРИМЕЧАНИЕ: Множество вызовов cgo.NewHandle может возвращать разные значения. Реализуем функции плагина для создания и удаления экзепляра устройства: Если сами функции create_device и free_device довольно просты и не требует пояснений, то реализацию функции getDeviceHandle стоит рассмотреть подробней: Для того, чтобы наш плагин не вызывал панику при передаче неверного идентификатора экземпляра устройства при вызове какого-либо метода, обернем вызов h.Value() в функцию с возможностью восстановить работу после паники. В остальных функциях плагина сам идентификатор нам не требуется, поэтому добавим еще одну функцию, упрощающую получение указателя на экземпляр устройства по числовому идентификатору: Реализация всех функций нашего контракта тривиальна, за исключением get_device, потому ее мы детально рассмотрим сейчас, а код остальных функций можно будет посмотреть в итоговом листинге основного пакета плагина. Итак, функция get_device, согласно контракту, должна иметь следующую сигнатуру: Go не может вызвать C-функцию по указателю, а вот сам C может, поэтому напишем функцию-обертку, принимающую указатель на коллбек и список его параметров и вызывающую его внутри. Модификатор static говорит о том, что функция не будет видна за пределами самого объектного файла. Напишем преамбулу C, а затем включим ее в итоговый листинг: Теперь напишем саму функцию get_device. Идея проста: получаем устройство по числовому идентификатору, сериализуем его в бинарный вид или JSON-строку в зависимости от значения аргумента useJSON, вызываем обертку call_back, в которая, в свою очередь, вызовет реальный коллбек: C-преамбула вынесена здесь отдельно для отображения с подсветкой синтаксиса. Для сборки Go-плагина достаточно выполнить следующую команду: На этом процедура создания плагина законечена. Библиотека с плагином готова, осталось ее к чему-нибудь подключить. Определим задачи приложения: оно должно динамически подгружать плагин, создавать устройство и проверять работу его методов. В более сложных приложениях можно было бы наплодить дополнительных уровней абстрации: создать интерфейс (GenericDevice) и структуру-обертку, которая вызывала бы непосредственные функции интерфейса плагина, реализуя этот интерфейс. Мы же, для простоты и наглядности примера, поступим проще, и реализуем лишь интерфейс самого плагина в виде структуры и ее методов. Для этого создадим пакет papi (Plugin API). Можно было бы определить отдельно интерфейс плагина, который выглядит следующим образом (но, в данном случае, он нам не понадобится и приведен здесь для наглядности): Поэтому сразу определим его реализацию в виде структуры DevicePlugin и ее методов: Поля структуры содержат функции-обертки, вызывающие непосредственно C-функции плагина, а методы просто вызывают их (тела методов будут продемонстрированы в полном листинге). Но прежде чем использовать подобную структуру, ее необходимо инициализровать. Для этого необходимо реализовать интерфейс dlplugin.PluginInitializer: Поэтому добавим метод Init к нашей структуре с соответствующей сигнатурой. Смысл в том, чтобы получить символ из библиотеки (в нашем случае, указатель на функцию) и обернуть его вызов. Рассмотрим функцию free_device. Мы можем получить указатель на нее из библиотеки при помощи функции lookup, которая будет передана пакетом dlplugin в метод Init нашей структуры, но это указатель на C-функцию, а, как уже известно, указатель на C-функцию нельзя вызвать напрямую из Go. Следовательно, добавим C-преамбулу в наш пакет papi и напишем обертку для вызова free_device по указателю: Для краткости уберем опреление типа: Теперь инициализируем соответствующую функцию в структуре: Остальные функции реализуются по аналогиии, за исключением get_device, которую рассмотрим подробней. Эта функция использует коллбек и передает в него буфер с данными, но, как мы знаем, нет никаких гарантий того, что буфер будет жив после окончания вызова коллбека, а это значит, что придется копировать данные из переданного буфера в локальный, контролируемый местным сборщиком мусора. Кроме того, мы знаем, что нельзя передать указатель на Go-функцию в C, поэтому применим cgo.Handle для обхода данного ограничения. Идея работы с коллбеками состоит в том, чтобы отправлять на другую сторону (в плагин или из него) указатель на C-функцию, который не может измениться во время работы приложения, и числовой идентификатор, по которому можно будет получить доступ к Go-функции-коллбеку, но только на вызывающей стороне. Таким образом, мы не передаем реальный указатель на Go-функцию, но имеем возможность достать его из хранилища (cgo.Handle) во время вызова коллбека с другой стороны. Начнем с определения типа для функции-коллбека: Теперь определим обертку, которая будет вызывать наш коллбек. Из C нельзя вызывать функции Go напрямую, но ничего не мешает нам экспортировать Go-функции в C, как мы это делали с функциями плагина, и вызывать уже экспортированную функцию: Здесь также стоило бы добавить обертку для предотвращения паники, но оставим все как есть для простоты примера. Таким образом, функция GetDeviceCallback подразумевает, что до ее вызова уже был сгенерирован идентификатор, хранящий ссылку на указатель функции-коллбека. Со стороны C нам понадобится определить функцию-обертку, которая будет вызывать библиотечную функцию по указателю и передавать ей указатель на экспортированную Go-функцию в качестве аргумента-коллбека. Запишем это в преамбулу: Теперь добавим код в функцию инициализации, позволяющий прозрачно вызывать функцию плагина get_device: C-преамбула вынесена здесь отдельно для отображения с подсветкой синтаксиса. Основной файл проекта (main.go) не требует каких-либо детальных пояснений, его листинг с подробными комментариями приведен ниже. Никаких дополнительных флагов при сборке указывать не требуется, при вызове указываем значения для флагов plug и val: Более простые примеры доступны в директории examples пакета dlplugin. Плюсы: Позволяет передавать указатели в/из плагина для обработки (с нюансами, подробно рассмотренными выше) Позволяет писать и собирать плагины отдельно от основого приложения Позволяет использовать плагины, написанные на других языках Позволяет динамически отключать подключенные плагины Плагин и приложение работают в едином адресном пространстве, не создается дополнительных процессов Передача данных и вызовы на порядок быстрее, чем реализация поверх TCP, соответственно, проще переварить интенсивное взаимодействие Минусы: Использует CGO, а потому потребует наличия кросс-компилятора C для сборки под другую платформу/архитектуру Работает только на Linux, FreeBSD, macOS (пока что) Существенно медленнее происходит вызов и передача данных, нежели при использовании стандартного пакета plugin Требуется базовое знание C, нюансов взаимодействия C и Go, повышенная внимательность при реализации вызовов функций плагина Не может использовать единый рантайм даже если и плагин, и основное приложение написаны на Go Как и говорилось в самом начале: идеального решения нет, у каждого из трех рассмотренных есть свои плюсы и минусы. При правильно подобранной архитектуре скорость передачи данных между плагином и основным приложением перестает играть решающую роль, ведь, зачастую, задача динамической библиотеки состоит либо в том, чтобы получить какие-то входные данные и выполнить работу и вернуть результат, либо в том, чтобы работать в качестве автономного модуля, с которым взаимодействие происходит через некий интерфейс. Если ваши задачи не укладываются в одно из этих двух вариантов, то стоит задуматься: Правильно ли выбрана вами архитектура приложения? Подходит ли Go для решения вашей задачи? Выше рассматривается именно динамическое подключение библиотеки, что является весьма специфичной задачей. На самом деле, чаще можно обойтись более простыми способами, о которых доступна масса информации, в том числе и здесь - на хабре. Надеюсь, что те, кто осилил данный лонгрид смогли почерпнуть для себя что-то полезное. Конструктивная критика в комментариях приветствуется. Полные примеры с подключением нескольких плагинов одновременно, реализацией плагина на C++ (работает быстрее Go-плагина и весит меньше), реализациями посредством пакетов plugin и hashicorp/go-plugin, а также простыми бенчмарками для всех вариантов, доступны здесь. Спасибо за внимание.