Skip to content

Compose the Go XML serialization - #689

Merged
mristin merged 1 commit into
mainfrom
mristin/Compose-Go-XML-serialization
Sep 13, 2026
Merged

Compose the Go XML serialization#689
mristin merged 1 commit into
mainfrom
mristin/Compose-Go-XML-serialization

Conversation

@mristin

@mristin mristin commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Write-side dual of #688, as #687 was of #686 in C#.

Measured on the AAS meta-model fixture, the serialization region of xmlization.go goes 11,080 -> 4,727 lines (-57%), and the file as a whole 16,692 -> 10,330. writeExtensionAsSequence and its six properties go 180 -> 69 lines.

Everything is written through one shape,
func(encoder, value T) error. A stringification, a class's own write<X>AsSequence and the generated item, list and tuple writers all have it, so writeElement -- which frames an XML element around a value written that way -- is the only writer a property needs, whatever kind of value it holds. What is left per property is that one call and a decision on presence:

err = finishProperty(
    "SemanticID()",
    writeOptionalInstance(
        encoder,
        "semanticId",
        that.SemanticID(),
        writeReferenceAsSequence,
    ),
)
if err != nil {
    return
}

finishProperty takes the result of the write rather than the write itself -- the same trick as the read side's readOptional, since Go evaluates the argument first -- so one function concludes every property, prepending the getter to the path of the error.

Mind that the getter is spelled out as it is in Go, SemanticID() and not semanticId, since a serialization error renders its path through aasreporting.ToGolangPath, while the de-serialization prepends the XML name, its errors being XPaths. Go has no nameof.

The writeOptional* family has one member per way Go spells an optional, each named after that spelling, as the check for the presence is the only thing which differs between them: a pointer to the value (writeOptionalPointer: a scalar, an enumeration, a tuple), a value which is nil on its own (writeOptionalInstance: an instance, or a named union, which is a pointer to a struct) and a slice (writeOptionalSlice: a list, the bytes). They can not be collapsed into one, since Go decides nil-ness by the representation: a type parameter can not be compared against nil, any(that) == nil is false for a nil pointer boxed in an any -- hence the comparison against the zero value in writeOptionalInstance, which in turn panics on a slice, which is not comparable. Each of them writes nothing at all when the value is absent, which is what keeps the absence out of the generated code.

No closure is allocated on the write path any more: every writer passed on is a top-level function or an instantiated generic, hence a static funcval, exactly as on the read side. A list and a tuple get one generated content writer as well, per item type resp. per tuple type -- writeListOfIReference and writeTupleOfStringLong and their kin -- since writeList and writeTupleN take a writer per item, and Go gives no partial application to bind those in without allocating.

Finally, the flush. write<X>AsSequence used to flush the encoder after every property, and write<X> again at its end element, which defeated the buffering of xml.Encoder on every write; only the flush after the last token is needed, as EncodeToken deliberately leaves it to the caller. Marshal is therefore split into writeClass, which dispatches on the model type, and Marshal itself, which calls it and flushes exactly once; writeInstance and writeUnion go to writeClass, so a nested instance does not flush either. Serializing an instance holding 1,000 instances (42 KB of XML) from the list_of_classes fixture, the writes which actually reach the underlying writer go 2,003 -> 11, one per 4 KB of buffer instead of one per property and per instance; to an os.File that is 5.29 -> 0.76 ms per serialization, the median of five.

Write-side dual of #688, as #687 was of #686 in C#.

Measured on the AAS meta-model fixture, the serialization region of
``xmlization.go`` goes 11,080 -> 4,727 lines (-57%), and the file as
a whole 16,692 -> 10,330. ``writeExtensionAsSequence`` and its six
properties go 180 -> 69 lines.

Everything is written through one shape,
``func(encoder, value T) error``. A stringification, a class's own
``write<X>AsSequence`` and the generated item, list and tuple writers
all have it, so ``writeElement`` -- which frames an XML element around
a value written that way -- is the only writer a property needs,
whatever kind of value it holds. What is left per property is that one
call and a decision on presence:

    err = finishProperty(
        "SemanticID()",
        writeOptionalInstance(
            encoder,
            "semanticId",
            that.SemanticID(),
            writeReferenceAsSequence,
        ),
    )
    if err != nil {
        return
    }

``finishProperty`` takes the *result* of the write rather than the write
itself -- the same trick as the read side's ``readOptional``, since Go
evaluates the argument first -- so one function concludes every
property, prepending the getter to the path of the error.

Mind that the getter is spelled out as it is in Go, ``SemanticID()`` and
not ``semanticId``, since a serialization error renders its path through
``aasreporting.ToGolangPath``, while the de-serialization prepends
the XML name, its errors being XPaths. Go has no ``nameof``.

The ``writeOptional*`` family has one member per way Go spells an
optional, each named after that spelling, as the check for the presence
is the only thing which differs between them: a pointer to the value
(``writeOptionalPointer``: a scalar, an enumeration, a tuple), a value
which is nil on its own (``writeOptionalInstance``: an instance, or
a named union, which is a pointer to a struct) and a slice
(``writeOptionalSlice``: a list, the bytes). They can not be collapsed
into one, since Go decides nil-ness by the representation: a type
parameter can not be compared against ``nil``, ``any(that) == nil`` is
false for a nil *pointer* boxed in an ``any`` -- hence the comparison
against the zero value in ``writeOptionalInstance``, which in turn
panics on a slice, which is not comparable. Each of them writes nothing
at all when the value is absent, which is what keeps the absence out of
the generated code.

No closure is allocated on the write path any more: every writer passed
on is a top-level function or an instantiated generic, hence a static
funcval, exactly as on the read side. A list and a tuple get one
generated content writer as well, per item type resp. per tuple
type -- ``writeListOfIReference`` and ``writeTupleOfStringLong`` and
their kin -- since ``writeList`` and ``writeTupleN`` take a writer per
item, and Go gives no partial application to bind those in without
allocating.

Finally, the flush. ``write<X>AsSequence`` used to flush the encoder
after every property, and ``write<X>`` again at its end element, which
defeated the buffering of ``xml.Encoder`` on every write; only the flush
after the last token is needed, as ``EncodeToken`` deliberately leaves
it to the caller. ``Marshal`` is therefore split into ``writeClass``,
which dispatches on the model type, and ``Marshal`` itself, which calls
it and flushes exactly once; ``writeInstance`` and ``writeUnion`` go to
``writeClass``, so a nested instance does not flush either. Serializing
an instance holding 1,000 instances (42 KB of XML) from
the ``list_of_classes`` fixture, the writes which actually reach
the underlying writer go 2,003 -> 11, one per 4 KB of buffer instead of
one per property and per instance; to an ``os.File`` that is 5.29 ->
0.76 ms per serialization, the median of five.
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 34778173263

Coverage increased (+0.01%) to 85.286%

Details

  • Coverage increased (+0.01%) from the base build.
  • Patch coverage: 6 uncovered changes across 1 file (232 of 238 lines covered, 97.48%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
aas_core_codegen/golang/lib/_generate_xmlization.py 238 232 97.48%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 39839
Covered Lines: 33977
Line Coverage: 85.29%
Coverage Strength: 2.56 hits per line

💛 - Coveralls

@mristin
mristin merged commit 5e4bd0b into main Sep 13, 2026
5 checks passed
@mristin
mristin deleted the mristin/Compose-Go-XML-serialization branch September 13, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants