Internals

Internals #

This article describes only the code generation mode (code, client commands) of go-asyncapi. Other modes (infra, diagram, etc.) are much simpler – in fact, they run only “rendering” and “writing” stages.

This article briefly describes the code generation internals of the go-asyncapi.

The figure below shows the go-asyncapi execution steps.

Execution steps

1. Compilation #

Compilation step contains the logic to parse AsyncAPI entities and convert them to internal objects – artifacts. The next steps work only with these objects. For example, it’s on this step a jsonschema object is turned into the Go struct (in object form).

Every artifact satisfies the common.Artifact interface. Some artifacts represent the complex entities (e.g. a channel) and produce the complex code. Others are simpler and represent a simple Go type (e.g. a jsonschema object), they additionally satisfy the common.GolangType interface.

The result of the compilation step is a list of artifacts, gathered and compiled from a passed AsyncAPI document and all referenced documents.

Locator #

If an AsyncAPI document contains $refs to another documents, go-asyncapi uses the Locator. This is a part of go-asyncapi, that locates and reads a document by its URL using either the built-in logic or user-provided command.

Late binding #

Artifacts reflect the relationships between AsyncAPI entities that they are compiled from. For example, just like a channel contains messages it passes through, a render.Channel artifact bounds with list of appropriate render.Message objects.

Entity may be defined in document in-place as an object or as a reference to an object in the same document or external document. Documents may contain the long chains of such references, some of them may be recursive or cross-document references. The challenge here is how to find compiled artifacts and bind them.

To manage this, go-asyncapi uses the “late binding” technique, when references are not resolved immediately during compilation. Instead, we use a special artifact lang.Ref that acts as “placeholder” (blue squares on the figure above) and keeps the reference to a target artifact. All these placeholders remain unresolved until all referenced documents are compiled, and on the linking step go-asyncapi takes every placeholder, finds the compiled artifact it refers to and assigns it.

The most obvious way could be to resolve a reference on demand, but this could require some kind of partial compilation mechanism, that should handle recursive calls. In other words, we could get the dependency hell. The late binding approach simplifies the go-asyncapi design, resolving references without long recursive calls.

The drawback of this approach is that we need another execution step – linking.

2. Linking #

On the linking step, go-asyncapi does the late binding process described above. Specifically, it walks through all lang.Ref and lang.Promise and fills them with the pointers to artifacts they refer to. If the artifact is not found (e.g. due to incorrect $ref), it raises an error.

The result of the linking step is the same list of artifacts, but with all references resolved.

3. Rendering #

On the rendering stage, go-asyncapi loops over all artifacts and invokes the root template, passing every artifact in template context. The rendered code is merged into the output file(s) according to code layout.

After the process is finished, every resulting file is additionally processed by the preamble template, that is used to add the package declaration, import statements, “copyright” notice, etc.

4. Formatting #

This post-processing step is optional and is used to format the result. For Go code, the gofmt tool is used to format the code.

5. Writing #

The final step writes the results to files.

Tool design principles #

The requirements for the code generated by go-asyncapi are not significantly different from the requirements for the code generated by other tools:

  • The code should be easy to read, maintainable, flexible, modular, and easy to integrate into existing projects
  • The generation process must be idempotent – result must not change on repeated runs with the same input
  • It should follow the Go conventions and idioms, be compatible with stdlib Go interfaces and types
  • Minimal or zero external dependencies
  • Performance optimizations are preferable
  • The codegen process should be easy to run (including inside CI/CD pipelines) and to be configurable on different levels (document, configuration file, command line, etc.)

Other than that, there are some specific requirements for go-asyncapi.

The first one is that because the complex environments may be described in dozens of AsyncAPI documents referenced to each other, we should track these dependencies and reflect them in the resulting code.

Finally, the generated code should be functional without additional user’s effort (except that he should provide the connection settings). This speeds up the prototyping, testing and gives a good start to write an application for simple use cases. However, this behavior should be optional.