Ejecución de código personalizado en GO

En realidad, esto se trata de contratos inteligentes.


Pero si no imagina lo que es un contrato inteligente y, en general, está lejos de ser criptográfico, entonces puede imaginarse qué es un procedimiento almacenado en una base de datos. El usuario crea piezas de código que luego funcionan en nuestro servidor. Es conveniente para el usuario escribirlos y publicarlos, y es seguro para nosotros ejecutarlos.

Desafortunadamente, aún no hemos desarrollado la seguridad, por lo que ahora no la describiré, pero daré algunas pistas.

También escribimos en Go, y su tiempo de ejecución impone algunas restricciones muy específicas, la principal de las cuales es, en general, no podemos vincularnos a otro proyecto escrito que no esté en marcha, esto detendrá nuestro tiempo de ejecución cada vez que ejecutemos código de terceros. En general, tenemos la opción de usar algún tipo de intérprete, para lo cual encontramos un Lua completamente sano y un WASM completamente sano, pero de alguna manera no quiero agregar clientes a Lua, pero con WASM ahora hay más problemas que beneficios, está en un estado borrador , que se actualiza cada mes, por lo que esperaremos hasta que se establezca la especificación. Lo usamos como un segundo motor.

Como resultado de largas batallas con su propia conciencia, se decidió escribir contratos inteligentes en GO. El hecho es que si crea la arquitectura para ejecutar el código GO compilado, tendrá que transferir esta ejecución a un proceso separado, como recordará, por seguridad, y transferir a un proceso separado es una pérdida de rendimiento en IPC, aunque en el futuro, cuando entendamos el volumen del ejecutable código, incluso fue de alguna manera agradable que elegimos esta solución. La cuestión es que es escalable, aunque agrega un retraso a cada llamada individual. Podemos aumentar muchos tiempos de ejecución remotos.

Un poco más sobre las decisiones tomadas para que quede claro. Cada contrato inteligente consta de dos partes, una parte es el código de clase y la segunda son los datos del objeto, por lo que en el mismo código podemos, una vez que publiquemos el código, crear muchos contratos que se comportarán básicamente igual, pero con configuraciones diferentes , y con un estado diferente. Si hablamos más, entonces esto ya se trata de blockchain y no del tema de esta historia.

Y entonces, ejecutamos GO


Decidimos usar el mecanismo de complemento, que no solo está listo y es bueno. Él hace lo siguiente, compilamos lo que será un complemento de una manera especial en una biblioteca compartida, y luego lo cargamos, encontramos los símbolos en él y pasamos la ejecución allí. Pero el problema es que GO tiene un tiempo de ejecución, y esto es casi un megabyte de código, y por defecto este tiempo de ejecución también va a esta biblioteca, y tenemos un tiempo de ejecución raznipipenny en todas partes. Pero ahora decidimos hacerlo, asegurándonos de que podemos vencerlo en el futuro.

Todo es simple cuando construye su biblioteca, la construye con la clave - buildmode = plugin y obtiene el archivo .so, que luego abre.

p, err := plugin.Open(path) 

Buscando el personaje que te interesa:

 symbol, err := p.Lookup(Method) 

Y ahora, dependiendo de si la variable es una función o una función, puede llamarla o usarla como variable.

Bajo el capó de este mecanismo hay un simple dlopen (3), cargamos la biblioteca, verificamos que sea un complemento y le proporcionamos el contenedor, al crear el contenedor, todos los caracteres exportados se envuelven en la interfaz {} y se almacenan. Si es una función, debe reducirse al tipo correcto de función y simplemente llamarse, si es la variable, entonces funcionar como una variable.

Lo principal a recordar es que si un símbolo es una variable, entonces es global durante todo el proceso y no se puede usar sin pensar.

Si se ha declarado un tipo en el complemento, entonces tiene sentido colocar este tipo en un paquete separado para que el proceso principal pueda trabajar con él, por ejemplo, pasando como argumentos a las funciones del complemento. Esto es opcional, no puede usar vapor y usar reflejo.

Nuestros contratos son objetos de la "clase" correspondiente, y al principio la instancia de este objeto se almacenó en nuestra variable exportada, por lo que podríamos crear otra misma variable:

 export, err := p.Lookup("EXPORT") obj := reflect.New(reflect.ValueOf(export).Elem().Type()).Interface() 

Y ya dentro de esta variable local del tipo correcto, deserialice el estado del objeto. Después de restaurar el objeto, podemos invocar métodos en él. Después de lo cual el objeto se serializa y se agrega de nuevo a la tienda, saludos, llamamos al método en el contrato.

Si está interesado en cómo, pero es demasiado vago para leer la documentación, entonces:

 method := reflect.ValueOf(obj).MethodByName(Method) res:= method.Call(in) 

En el medio, también debe completar la matriz con interfaces vacías que contengan el tipo correcto de argumento, si está interesado, vea por sí mismo cómo se hizo, las fuentes están abiertas, aunque será difícil encontrar este lugar en la historia .

En general, todo funcionó para nosotros, puede escribir código con algo así como una clase, ponerlo en la cadena de bloques, crear un contrato de esta clase nuevamente en la cadena de bloques, hacer una llamada al método y el nuevo estado del contrato se vuelve a escribir en la cadena de bloques. Genial ¿Cómo crear un nuevo contrato con el código disponible? Muy simple, tenemos funciones de constructor que devuelven un objeto recién creado, que es el nuevo contrato. Hasta ahora, todo funciona a través de la reflexión y el usuario debe escribir:

 var EXPORT ContractType 

Para que sepamos qué símbolo es una representación del contrato, y realmente lo usamos como plantilla.

Realmente no nos gusta. Y golpeamos fuerte.

Analizando


En primer lugar, el usuario no debe escribir nada superfluo, y en segundo lugar, tenemos la idea de que la interacción del contrato con el contrato debe ser simple y probada sin elevar la cadena de bloques, la cadena de bloques es lenta y difícil.

Por lo tanto, decidimos envolver el contrato en un contenedor, que se genera sobre la base del contrato y la plantilla del contenedor, en principio, una solución comprensible. En primer lugar, el contenedor crea un objeto de exportación para nosotros y, en segundo lugar, reemplaza la biblioteca con la que se recopila el contrato cuando el usuario escribe el contrato, la biblioteca de base se usa con los mokas dentro, y cuando se publica el contrato, se reemplaza por uno de combate que funciona con la cadena de bloques en sí .

Para comenzar, debe analizar el código y comprender lo que generalmente tenemos, encontrar la estructura que se hereda de BaseContract para generar un contenedor a su alrededor.

Esto se hace de manera simple, leemos el archivo con el código en [] byte, aunque el analizador en sí mismo puede leer los archivos, es bueno tener el texto en algún lugar al que se refieren todos los elementos AST, se refieren al número de bytes en el archivo y en el futuro queremos recibir el código de estructura tal como es, simplemente tomamos algo como.

 func (pf *ParsedFile) codeOfNode(n ast.Node) string { return string(pf.code[n.Pos()-1 : n.End()-1]) } 

En realidad analizamos el archivo y obtenemos el nodo AST más alto desde el que rastrearemos el archivo.

 fileSet = token.NewFileSet() node, err := parser.ParseFile(fileSet, name, code, parser.ParseComments) 

A continuación, recorremos el código a partir del nodo superior y recopilamos todo lo interesante en una estructura separada.

 for _, decl := range node.Decls { switch d := decl.(type) { case *ast.GenDecl: … case *ast.FuncDecl: … } } 

Decls, ya está analizado en una matriz, una lista de todo lo que está definido en el archivo, pero es una matriz de interfaces Decl que no describe lo que hay dentro, por lo que cada elemento debe convertirse a un tipo específico, aquí los autores del lenguaje se apartaron de su idea de usar interfaces, la interfaz en go / ast es más bien una clase base.

Estamos interesados ​​en nodos de tipo GenDecl y FuncDecl. GenDecl es la definición de una variable o tipo, y debe verificar qué es exactamente el tipo dentro y, una vez más, convertirlo al tipo TypeDecl con el que ya puede trabajar. FuncDecl es más simple: es una función, y si tiene el campo Recv lleno, entonces este es un método de la estructura correspondiente. Recopilamos todo esto en un almacenamiento conveniente, porque luego usamos texto / plantilla, y no tiene mucho poder expresivo.

Lo único que debemos recordar por separado es el nombre del tipo de datos que se hereda de BaseContract, y vamos a bailar alrededor.

Generación de código


Por lo tanto, conocemos todos los tipos y funciones que están en nuestro contrato y necesitamos poder realizar una llamada a un método en un objeto desde el nombre del método entrante y la matriz de argumentos serializados. Pero después de todo, en el momento de la generación del código, conocemos todo el dispositivo del contrato, por lo que colocamos junto a nuestro archivo de contrato junto a otro archivo, con el mismo nombre de paquete, en el que colocamos todas las importaciones necesarias, los tipos ya están definidos en el archivo principal y son innecesarios.

Y aquí está lo principal, envoltorios sobre funciones. El nombre del contenedor se complementa con algún tipo de prefijo y ahora el contenedor es fácil de encontrar.

 symbol, err := p.Lookup("INSMETHOD_" + Method) wrapper, ok := symbol.(func(ph proxyctx.ProxyHelper, object []byte, data []byte) (object []byte, result []byte, err error)) 

Cada contenedor tiene la misma firma, por lo que cuando lo llamamos desde el programa principal, no necesitamos reflexiones adicionales, lo único es que los contenedores de funciones son diferentes de los contenedores de métodos, no reciben y no devuelven el estado del objeto.

¿Qué tenemos dentro del envoltorio?

Creamos una matriz de variables vacías que corresponden a los argumentos de la función, la colocamos en una variable de tipo una matriz de interfaces y deserializamos los argumentos en ella, si somos un método, también debemos serializar el estado del objeto, generalmente algo así:

 {{ range $method := .Methods }} func INSMETHOD_{{ $method.Name }}(ph proxyctx.ProxyHelper, object []byte, data []byte) ([]byte, []byte, error) { self := new({{ $.ContractType }}) err := ph.Deserialize(object, self) if err != nil { return nil, nil, err } {{ $method.ArgumentsZeroList }} err = ph.Deserialize(data, &args) if err != nil { return nil, nil, err } {{ if $method.Results }} {{ $method.Results }} := self.{{ $method.Name }}( {{ $method.Arguments }} ) {{ else }} self.{{ $method.Name }}( {{ $method.Arguments }} ) {{ end }} state := []byte{} err = ph.Serialize(self, &state) if err != nil { return nil, nil, err } {{ range $i := $method.ErrorInterfaceInRes }} ret{{ $i }} = ph.MakeErrorSerializable(ret{{ $i }}) {{ end }} ret := []byte{} err = ph.Serialize([]interface{} { {{ $method.Results }} }, &ret) return state, ret, err } {{ end }} 

Un lector atento estará interesado en lo que es un ayudante proxy. - Este es un objeto combinado que todavía necesitamos, pero por ahora usamos su capacidad de serializar y deserializar.

Bueno, cualquiera que lea preguntará: "Pero estos son sus argumentos, ¿de dónde son?" Aquí también hay una respuesta comprensible, sí texto / plantilla, no hay suficientes estrellas del cielo, es por eso que calculamos estas líneas en el código, y no en la plantilla.

method.ArgumentsZeroList contiene algo como

 var arg0 int = 0 Var arg1 string = “” Var arg2 ackwardType = ackwardType{} Args := []interface{}{&arg0, &arg1, &arg2} 

Y Arguments en consecuencia contiene "arg0, arg1, arg2".

Por lo tanto, podemos llamar a lo que queramos, con cualquier firma.

Pero no podemos serializar ninguna respuesta, el hecho es que los serializadores funcionan con reflexión, y no da acceso a campos de estructuras no exportados, es por eso que tenemos un método auxiliar proxy especial que toma un objeto de interfaz de error y crea un objeto de tipo base a partir de él. Error, que difiere del habitual en que el texto del error está en él en el campo exportado, y podemos serializarlo, aunque con alguna pérdida.

Pero si usamos un esterilizador generador de código, entonces ni siquiera lo necesitamos, estamos compilados en el mismo paquete, tenemos acceso a campos no exportados.

Pero, ¿qué pasa si queremos llamar a un contrato desde un contrato?


No comprende la profundidad del problema si cree que es fácil llamar a un contrato desde un contrato. El hecho es que la validez de otro contrato debe confirmarse por consenso y el hecho de esta llamada debe firmarse en la cadena de bloques, en general, simplemente compilar con otro contrato e invocar su método no funcionará, aunque realmente quiero hacerlo. Pero somos amigos de los programadores, por lo que deberíamos darles la oportunidad de hacer todo directamente y ocultar todos los trucos bajo el capó del sistema. Por lo tanto, el desarrollo del contrato es como si se tratara de llamadas directas, y los contratos se tiran entre sí de manera transparente, pero cuando recopilamos el contrato para su publicación, deslizamos un poder en lugar de otro contrato, que solo conoce su dirección y firmas de llamadas sobre el contrato.

¿Cómo organizar todo esto? - Tendremos que almacenar otros contratos en un directorio especial que nuestro generador podrá reconocer y crear proxies para cada contrato que se importe.

Es decir, si nos encontramos:

 import “ContractsDir/ContractAddress" 

Lo escribimos en la lista de contratos importados.

Por cierto, para esto no necesita saber el código fuente del contrato, solo necesita conocer la descripción que ya hemos recopilado, por lo que si publicamos dicha descripción en algún lugar y todas las llamadas pasan por el sistema principal, entonces no nos importa qué otro contrato está escrito en el idioma, si podemos llamar métodos en él, podemos escribir un talón para él en Go, que se verá como un paquete con un contrato al que se puede llamar directamente. Planes napoleónicos, comencemos.

En principio, ya tenemos un método de ayuda proxy, con esta firma:

 RouteCall(ref Address, method string, args []byte) ([]byte, error) 

Este método puede llamarse directamente desde el contrato, llama al contrato remoto, devuelve una respuesta serializada que necesitamos analizar y volver a nuestro contrato.

Pero es necesario que el usuario tenga todo el aspecto:

 ret := contractPackage.GetObject(Address).Method(arg1,arg2, …) 

Comencemos, en primer lugar, en el proxy, debe enumerar todos los tipos que se utilizan en las firmas de los métodos de contrato, pero como recordamos, para cada nodo AST podemos tomar su representación textual, y ahora ha llegado el momento de este mecanismo.

Luego, necesitamos crear un tipo de contrato, en principio, él ya conoce su clase, solo se necesita una dirección.

 type {{ .ContractType }} struct { Reference Address } 

A continuación, debemos implementar de alguna manera la función GetObject, que en la dirección en la cadena de bloques devolverá una instancia de proxy que sabe cómo trabajar con este contrato, y para el usuario parece una instancia de contrato.

 func GetObject(ref Address) (r *{{ .ContractType }}) { return &{{ .ContractType }}{Reference: ref} } 

Curiosamente, el método GetObject en modo de depuración del usuario es directamente un método de estructura BaseContract, pero no hay nada, nada nos impide, observando el SLA, hacer lo que nos conviene. Ahora podemos crear un contrato de representación, cuyos métodos controlamos. Queda por crear realmente los métodos.

 {{ range $method := .MethodsProxies }} func (r *{{ $.ContractType }}) {{ $method.Name }}( {{ $method.Arguments }} ) ( {{ $method.ResultsTypes }} ) { {{ $method.InitArgs }} var argsSerialized []byte err := proxyctx.Current.Serialize(args, &argsSerialized) if err != nil { panic(err) } res, err := proxyctx.Current.RouteCall(r.Reference, "{{ $method.Name }}", argsSerialized) if err != nil { panic(err) } {{ $method.ResultZeroList }} err = proxyctx.Current.Deserialize(res, &resList) if err != nil { panic(err) } return {{ $method.Results }} } {{ end }} 

Aquí la misma historia con la construcción de la lista de argumentos, ya que somos perezosos y almacenamos exactamente el nodo anterior del método, para los cálculos se requieren muchas conversiones de tipos que las plantillas no conocen, por lo que todo está preparado de antemano. Con las funciones, todo es mucho más complicado, y este es el tema de otro artículo.

Las funciones que tenemos son constructores de objetos y se hace mucho hincapié en cómo se crean realmente los objetos en nuestro sistema, el hecho de la creación se registra en un ejecutor remoto, el objeto se transfiere a otro ejecutor, se verifica y se guarda allí, y hay muchas maneras de guardar, en vano Esta área de conocimiento se llama cripta. Y la idea es básicamente simple, un contenedor dentro del cual solo se almacena la dirección, y métodos que serializan la llamada y extraen nuestro procesador singleton, que hace el resto. No podemos usar el ayudante de proxy transmitido porque el usuario no nos lo pasó, por lo que tuvimos que hacerlo único.

Otro truco: de hecho, todavía usamos el contexto de la llamada, este es un objeto que almacena información sobre quién, cuándo, por qué, por qué se llamó a nuestro contrato inteligente, en función de esta información, el usuario toma la decisión de dar la ejecución, y si es posible entonces como.

Anteriormente, simplemente pasábamos el contexto, era un campo no expresable en el tipo BaseContract con un setter y getter, y el setter permitió establecer el campo solo una vez, por lo que el contexto se estableció antes de que se ejecutara el contrato, y el usuario solo podía leerlo.

Pero aquí está el problema, el usuario solo lee este contexto, si realiza una llamada a algún tipo de función del sistema, por ejemplo, una llamada proxy a otro contrato, entonces esta llamada proxy no recibe ningún contexto, ya que nadie se la pasa. Y luego el almacenamiento local goroutine entra en escena. Decidimos no escribir el nuestro, sino usar github.com/tylerb/gls.

Le permite establecer y tomar contexto para la rutina actual. Por lo tanto, si no se creó una rutina dentro del contrato, solo establecemos el contexto en gls antes de comenzar el contrato, ahora le damos al usuario no un método, sino solo una función.

 func GetContext() *core.LogicCallContext { return gls.Get("ctx").(*core.LogicCallContext) } 

Y felizmente lo usa, pero lo usamos en RouteCall (), por ejemplo, para entender qué contrato está invocando a alguien actualmente.

En principio, el usuario puede crear goroutine, pero si lo hace, entonces el contexto se pierde, por lo que debemos hacer algo con esto, por ejemplo, si el usuario usa la palabra clave go, entonces debemos envolver esas llamadas en nuestro contenedor, que el contexto recordará y creará goroutine y restaurar el contexto en él, pero este es el tema de otro artículo.

Todos juntos


Básicamente, nos gusta cómo funciona la cadena de herramientas del lenguaje GO, de hecho, es un conjunto de comandos diferentes que hacen una cosa, que se ejecutan juntos cuando se va a construir, por ejemplo. Decidimos hacer lo mismo, un equipo coloca un archivo de contrato en un directorio temporal, el segundo coloca un contenedor y llama por tercera vez, lo que crea un proxy para cada contrato importado, el cuarto lo compila todo, el quinto lo publica en la cadena de bloques. Y hay un comando para ejecutarlos todos en el orden correcto.

Hurra, ahora tenemos una cadena de herramientas y tiempo de ejecución para lanzar GO desde GO. Todavía hay muchos problemas, por ejemplo, necesita descargar de alguna manera el código no utilizado, debe determinar de alguna manera que se cuelga y reiniciar el proceso suspendido, pero estas son tareas que tienen claro cómo resolverlo.

Sí, por supuesto, el código que escribimos no pretende ser una biblioteca, no se puede usar directamente, pero leer un ejemplo de generación de código de trabajo siempre es genial, en un momento lo perdí. En consecuencia, parte de la generación de código se puede ver en el compilador , pero cómo comienza en el ejecutor .

Source: https://habr.com/ru/post/es425985/


All Articles