Skip to content

Commit 26a0b21

Browse files
authored
Replace session state encoder with IEncodable generation using source generators (#3673)
* Replace SessionStateEncoder with IEncodeable on session/subscription models - Source-gen [DataType]/[DataTypeField] on: MonitoredItemOptions, MonitoredItemState, SubscriptionOptions, SubscriptionState (auto-generated Encode/Decode/IsEqual/Clone) - Manual IEncodeable on: SessionOptions, SessionState, SessionConfiguration (ConfiguredEndpoint and IUserIdentity require manual encoding) - Delete SessionStateEncoder.cs (488 lines removed) - Remove [DataContract]/[DataMember]/[KnownType]/[CollectionDataContract] attributes - Remove JsonSerializerContext classes - Change init -> set, byte[] -> ByteString, DateTime -> DateTimeUtc - MonitoredItems: ArrayOf<MonitoredItemState> with StructureHandling.Inline - Subscriptions: ArrayOf<SubscriptionState> * Source generator: support partial init properties with backing fields Add IsInitOnly detection to the [DataType] source generator so that partial properties with init-only setters can be used in IEncodeable types. For each init-only partial property, the generator: - Emits a private backing field (__PropertyName) - Emits the partial property implementation (get => __field; init => __field = value) - Uses the backing field in Decode() instead of the property - Skips simple init-only fields in Clone() (record 'with' already copies them) Detection uses both IPropertySymbol.SetMethod.IsInitOnly and syntax-tree fallback (SyntaxKind.InitAccessorDeclaration) for partial definitions. * docs: add partial init properties and session state migration sections - SourceGeneratedDataTypes.md: new 'Partial Init Properties' section documenting backing field generation for init-only partial properties - MigrationGuide.md: new 'Session State Persistence' section covering
1 parent c71f2e7 commit 26a0b21

19 files changed

Lines changed: 831 additions & 831 deletions

File tree

Docs/MigrationGuide.md

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@
4343
- [ExtensionObject array helpers changed](#extensionobject-array-helpers-changed)
4444
- [Complex Types](#complex-types)
4545
- [ComplexTypes moved to Opc.Ua.Client assembly](#complextypes-moved-to-opcuaclient-assembly)
46+
- [Session and Browser State Persistence](#session-and-browser-state-persistence)
47+
- [Property type changes](#property-type-changes)
48+
- [`IUserIdentity` on `SessionOptions` is now computed](#iuseridentity-on-sessionoptions-is-now-computed)
49+
- [Encoding format is not guaranteed backward compatible](#encoding-format-is-not-guaranteed-backward-compatible)
4650
- [OptionSet DataType support](#optionset-datatype-support)
4751
- [Other Breaking Changes](#other-breaking-changes)
4852
- [Migrating from 1.05.377 to 1.05.378](#migrating-from-105377-to-105378)
@@ -628,9 +632,55 @@ Impact on existing code:
628632
- No wire-format changes: encoders/decoders continue to route through `IEncodeableFactory``IEncodeableType.CreateInstance`, which now yields `Opc.Ua.Encoders.OptionSet` for registered sub-types.
629633
- UInteger-backed OptionSet DataTypes remain treated as their underlying unsigned integer in a `Variant` (unchanged).
630634

631-
### Other Breaking Changes
635+
### Session and Browser State Persistence
632636

633-
- **Session/Browser state format**: Persistence switched from `DataContractSerializer` XML to `BinaryEncoder`. Delete old persisted files and re-establish sessions.
637+
**Breaking Change**: Persistence switched from `DataContractSerializer` XML to `IEncoder` and `IDecoder`. `BrowserState`, `SessionState`, `SessionOptions`, `SubscriptionState`, and `MonitoredItemState` are annotated with `[DataType]` and use the standard `Encode`/`Decode` methods generated by the source generator.
638+
639+
To register the state types with the encodeable factory:
640+
641+
```csharp
642+
context.Factory.Builder.AddOpcUaClientDataTypes();
643+
```
644+
645+
#### Property type changes
646+
647+
The following property types have changed to use the new stack value types:
648+
649+
| Class | Property | Old Type | New Type |
650+
|---|---|---|---|
651+
| `SessionState` | `ServerNonce` | `byte[]?` | `ByteString` |
652+
| `SessionState` | `ClientNonce` | `byte[]?` | `ByteString` |
653+
| `SessionState` | `ServerEccEphemeralKey` | `byte[]?` | `ByteString` |
654+
| `SessionState` | `Timestamp` | `DateTime` | `DateTimeUtc` |
655+
| `SessionState` | `Subscriptions` | `SubscriptionStateCollection?` | `ArrayOf<SubscriptionState>` |
656+
| `SubscriptionState` | `MonitoredItems` | `MonitoredItemStateCollection` | `ArrayOf<MonitoredItemState>` |
657+
| `SubscriptionState` | `Timestamp` | `DateTime` | `DateTimeUtc` |
658+
659+
#### `IUserIdentity` on `SessionOptions` is now computed
660+
661+
`SessionOptions.Identity` (`IUserIdentity?`) is no longer a serialized field. It is a computed property backed by `UserIdentityToken? IdentityToken`, which is the actual serialized field:
662+
663+
```csharp
664+
public partial record class SessionOptions
665+
{
666+
// Serialized field
667+
[DataTypeField(Order = 2, StructureHandling = StructureHandling.ExtensionObject)]
668+
public UserIdentityToken? IdentityToken { get; set; }
669+
670+
// Computed — not serialized
671+
public IUserIdentity? Identity
672+
{
673+
get => IdentityToken != null ? new UserIdentity(IdentityToken) : null;
674+
set => IdentityToken = value?.TokenHandler?.Token;
675+
}
676+
}
677+
```
678+
679+
When migrating code that previously set `Identity` directly, no code changes are required — the property still accepts `IUserIdentity`. However, if you were serializing session state manually, note that only `IdentityToken` round-trips through encode/decode.
680+
681+
#### Encoding format is not guaranteed backward compatible
682+
683+
The encoding format for session state has changed. Existing persisted session state files **cannot** be loaded by the new `SessionConfiguration.Create()` method. Handle restore failures and re-persist the new session state.
634684

635685
## Migrating from 1.05.377 to 1.05.378
636686

Docs/SourceGeneratedDataTypes.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,69 @@ public partial class SecuritySettings
477477
> `Exclude` would omit an explicit `false` on encode. `Include` ensures the
478478
> value always round-trips correctly.
479479
480+
## Partial Init Properties
481+
482+
The source generator supports `partial` properties with `init` accessors for
483+
immutable record types. This enables record classes with init-only properties
484+
to participate in OPC UA binary and XML encoding — the generator creates a
485+
private backing field for each such property and assigns to it directly during
486+
`Decode()`, bypassing the init-only constraint.
487+
488+
### Example
489+
490+
```csharp
491+
[DataType(Namespace = "urn:mycompany:myapp")]
492+
public partial record class DeviceConfig
493+
{
494+
[DataTypeField(Order = 0)]
495+
public partial string Name { get; init; } = "Default";
496+
497+
[DataTypeField(Order = 1)]
498+
public partial int Port { get; init; }
499+
}
500+
```
501+
502+
The generator produces:
503+
504+
```csharp
505+
partial record class DeviceConfig : IEncodeable, IJsonEncodeable
506+
{
507+
private string __Name = "Default";
508+
public partial string Name { get => __Name; init => __Name = value; }
509+
510+
private int __Port;
511+
public partial int Port { get => __Port; init => __Port = value; }
512+
513+
public virtual void Decode(IDecoder decoder)
514+
{
515+
// Assigns to backing field, bypassing init constraint
516+
if (decoder.HasField("Name")) __Name = decoder.ReadString("Name");
517+
if (decoder.HasField("Port")) __Port = decoder.ReadInt32("Port");
518+
}
519+
// ...
520+
}
521+
```
522+
523+
### How It Works
524+
525+
1. For each property declared as `partial` with an `init` accessor, the
526+
generator emits a private backing field named `__<PropertyName>`.
527+
2. The partial property implementation delegates `get` and `init` to that
528+
backing field.
529+
3. `Decode()` assigns to the backing field directly, which is legal because
530+
the field is a regular mutable field — only the public `init` accessor is
531+
restricted to object-initializer contexts.
532+
4. `Clone()` for record types uses `this with { }`, which copies init-only
533+
properties automatically via the copy constructor.
534+
535+
### When to Use
536+
537+
- Use `partial` + `init` properties when you want an immutable public API
538+
(callers can only set values at construction time) while still allowing
539+
the decoder to populate the object from a binary or XML stream.
540+
- This is especially useful for configuration and options types modeled as
541+
`record class`.
542+
480543
## Requirements and Constraints
481544

482545
1. **Must be `partial`**: The class must be declared `partial` so the generator

Libraries/Opc.Ua.Client/Session/Session.cs

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -860,15 +860,15 @@ public virtual void Snapshot(out SessionState state)
860860
Snapshot(out SessionConfiguration configuration);
861861

862862
// Snapshot subscription state
863-
var subscriptionStateCollection = new SubscriptionStateCollection(SubscriptionCount);
863+
var subscriptionStates = new List<SubscriptionState>(SubscriptionCount);
864864
foreach (Subscription subscription in Subscriptions)
865865
{
866866
subscription.Snapshot(out SubscriptionState subscriptionState);
867-
subscriptionStateCollection.Add(subscriptionState);
867+
subscriptionStates.Add(subscriptionState);
868868
}
869869
state = new SessionState(configuration)
870870
{
871-
Subscriptions = subscriptionStateCollection
871+
Subscriptions = subscriptionStates
872872
};
873873
}
874874

@@ -878,7 +878,7 @@ public virtual void Restore(SessionState state)
878878
using Activity? activity = m_telemetry.StartActivity();
879879
ThrowIfDisposed();
880880
Restore((SessionConfiguration)state);
881-
if (state.Subscriptions == null)
881+
if (state.Subscriptions.IsEmpty)
882882
{
883883
return;
884884
}
@@ -894,11 +894,11 @@ public virtual void Restore(SessionState state)
894894
/// <inheritdoc/>
895895
public void Snapshot(out SessionConfiguration sessionConfiguration)
896896
{
897-
byte[]? serverNonce = !m_serverNonce.IsEmpty ? m_serverNonce.ToArray() : null;
898-
byte[]? clientNonce = m_clientNonce != null ? [.. m_clientNonce] : null;
899-
byte[]? serverEccEphemeralKey = m_eccServerEphemeralKey?.Data != null
900-
? [.. m_eccServerEphemeralKey.Data]
901-
: null;
897+
ByteString serverNonce = !m_serverNonce.IsEmpty ? m_serverNonce : default;
898+
ByteString clientNonce = m_clientNonce != null ? ByteString.From(m_clientNonce) : default;
899+
ByteString serverEccEphemeralKey = m_eccServerEphemeralKey?.Data != null
900+
? ByteString.From([.. m_eccServerEphemeralKey.Data])
901+
: default;
902902
sessionConfiguration = new SessionConfiguration
903903
{
904904
SessionName = SessionName,
@@ -926,20 +926,20 @@ public void Restore(SessionConfiguration sessionConfiguration)
926926
: null;
927927
m_identity = sessionConfiguration.Identity ?? new UserIdentity();
928928
m_checkDomain = sessionConfiguration.CheckDomain;
929-
m_serverNonce = ByteString.From(sessionConfiguration.ServerNonce);
930-
m_clientNonce = sessionConfiguration.ClientNonce != null
931-
? [.. sessionConfiguration.ClientNonce]
929+
m_serverNonce = sessionConfiguration.ServerNonce;
930+
m_clientNonce = !sessionConfiguration.ClientNonce.IsNull
931+
? sessionConfiguration.ClientNonce.ToArray()
932932
: null;
933933
m_userTokenSecurityPolicyUri = sessionConfiguration.UserIdentityTokenPolicy;
934-
if (sessionConfiguration.ServerEccEphemeralKey?.Length > 0)
934+
if (sessionConfiguration.ServerEccEphemeralKey.Length > 0)
935935
{
936936
string? ephemeralKeyPolicyUri = !string.IsNullOrEmpty(m_userTokenSecurityPolicyUri)
937937
? m_userTokenSecurityPolicyUri
938938
: m_endpoint.Description?.SecurityPolicyUri ?? SecurityPolicies.None;
939939
SecurityPolicyInfo ephemeralKeyPolicy = SecurityPolicies.GetInfo(ephemeralKeyPolicyUri);
940940
m_eccServerEphemeralKey = Nonce.CreateNonce(
941941
ephemeralKeyPolicy,
942-
sessionConfiguration.ServerEccEphemeralKey);
942+
sessionConfiguration.ServerEccEphemeralKey.ToArray());
943943
}
944944
else
945945
{
@@ -980,8 +980,7 @@ public SessionConfiguration SaveSessionConfiguration(Stream? stream = null)
980980
null, context.NamespaceUris.ToArrayOf());
981981
encoder.WriteStringArray(
982982
null, context.ServerUris.ToArrayOf());
983-
SessionStateEncoder.EncodeSessionConfiguration(
984-
encoder, sessionConfiguration);
983+
sessionConfiguration.Encode(encoder);
985984
}
986985
return sessionConfiguration;
987986
}
@@ -994,11 +993,11 @@ public virtual void Save(
994993
{
995994
using Activity? activity = m_telemetry.StartActivity();
996995
// Snapshot subscription state
997-
var subscriptionStateCollection = new SubscriptionStateCollection();
996+
var subscriptionStates = new List<SubscriptionState>();
998997
foreach (Subscription subscription in subscriptions)
999998
{
1000999
subscription.Snapshot(out SubscriptionState state);
1001-
subscriptionStateCollection.Add(state);
1000+
subscriptionStates.Add(state);
10021001
}
10031002

10041003
IServiceMessageContext context = MessageContext
@@ -1009,10 +1008,10 @@ public virtual void Save(
10091008
null, context.NamespaceUris.ToArrayOf());
10101009
encoder.WriteStringArray(
10111010
null, context.ServerUris.ToArrayOf());
1012-
encoder.WriteInt32(null, subscriptionStateCollection.Count);
1013-
foreach (SubscriptionState state in subscriptionStateCollection)
1011+
encoder.WriteInt32(null, subscriptionStates.Count);
1012+
foreach (SubscriptionState state in subscriptionStates)
10141013
{
1015-
SessionStateEncoder.EncodeSubscriptionState(encoder, state);
1014+
state.Encode(encoder);
10161015
}
10171016
}
10181017

@@ -1043,8 +1042,8 @@ public virtual IEnumerable<Subscription> Load(
10431042
var subscriptions = new SubscriptionCollection(count);
10441043
for (int i = 0; i < count; i++)
10451044
{
1046-
SubscriptionState state =
1047-
SessionStateEncoder.DecodeSubscriptionState(decoder);
1045+
var state = new SubscriptionState();
1046+
state.Decode(decoder);
10481047
// Restore subscription from state
10491048
Subscription subscription = CreateSubscription(state);
10501049
subscription.Restore(state);

0 commit comments

Comments
 (0)