Feeds architecture
This document gives information about the architecture and the implementation of feeds. To get information about the usage off the feeds framework, you should look to this doc.
Dev general guidelines
- All instances of feeds should be cached using the
AttachedProperty
helper. - Consequently, all feed implementation must be state less (except the special case of
State<T>
). - When invoking a user asynchronous method, the
SourceContext
should be set as ambient (cf.FeedHelper.InvokeAsync
). - Untyped interfaces (
IMessage
,IMessageEntry
, etc.) exists only for binding consideration and should not be re-implemented nor used in source code.
Caching
In order to allow a light creation syntax in a property getter (public Feed<int> MyFeed => _anotherFeed.Select(_ => 42)
, which is re-evaluated each time the property is get), but without rebuilding and re-querying the feed each time, we have 2 levels of caching.
Instance caching
First is the instance of the feed itself. This is done using the AttachedProperty
helper class. Each feed if attached to a owner
and identified by a key
. It makes sure that for a given owner
we have only one instance of the declared feed, i.e. running _anotherFeed.Select(_ => 42)
will always return the same instance.
The owner
is usually (by order of preference):
- The parent feed if any (
_anotherFeed
in example above); - The
Target
of thekey
delegate, so the instance of the class that is declaring the feed; - The
key
delegate itself if it’s a static delegate instance (e.g., in the example above_ => 42
theTarget
is going to benull
as we don’t have any capture)
The key
is usually the delegate that is provided by the user. It’s really important here to note that a helper method like below would break the instance caching:
public static Feed<string> ToString(this IFeed<T> feed, Func<T, string> toString)
=> feed.Select(t => toString(t));
As the delegate t => toString(t)
is declared in the method itself, it will be re-instantiated each time. Valid implementations would have been:
public static Feed<string> ToString(this IFeed<T> feed, Func<T, string> toString)
=> feed.Select(toString); // We are directly forwarding the user delegate
public static Feed<string> ToString(this IFeed<T> feed, Func<T, string> toString)
=> AttachedProperty.GetOrCreate(owner: feed, key: toString, factory: (theFeed, theToString) => theFeed.Select(t => theToString(t))); // We are explicitly caching the instance.
Subscription caching
The second level of caching is for the “subscription” on a feed. This is needed to make sure that in a given context, enumerating / awaiting multiple times to a same feed won’t re-build the value (noticeably, won’t re-request a value coming from a web API call).
This caching is achieved by the SourceContext
.
Those context are weakly attached to a owner (typically a ViewModel
) and each call to context.GetOrCreateSource(feed)
will return a state full subscription to the feed
which will replay the last received message.
When implementing an
IFeed
, thecontext
provided in theGetSource
is only intended to be used to restore it as current in some circumstances, like invoking a user’s async method. Your feed must remain state less, so you should not usecontext.GetOrCreateSource(parent)
.On the other side, each helper that allow user to “subscribe” to a feed should do something like
SourceContext.Current.GetOrCreateSource(feed)
(and notfeed.GetSource(SourceContext.Current)
)
Issuing messages
When implementing an IFeed
you will have to create some messages.
If you don't have any parent feed the easiest way is to start from Message<T>.Initial
(do not send it as first message), then update it:
var current = Message<int>.Initial;
for (var i = 0; i++; i < 42)
{
yield return i;
await Task.Delay(100, ct);
}
If you do have a parent feed, you should use the MessageManager<TParent, TResult>
, eg.:
var manager = new MessageManager<TParent, TResult>();
var msgIndex = 0;
await foreach(var parentMsg in _parent.GetSource(context))
{
if (manager.Update(localMsg => localMsg.With(parentMsg).Data(msgIndex++)))
{
yield return manager.Current;
}
}
Make sure that your feed always produces at least on message. If there isn’t any relevant, send the
Message.Initial
before completing theIAsyncEnumerable
source.If you have a parent feed make sure to always forward the parent message, even if the parent message does not change any local value:
manager.Update(localMsg => localMsg.With(parentMsg))
.Be aware that enumeration of an
IAsyncEnumerable
is sequential (i.e. one value at once). TheMessageManager
also has a constructor that allows to asynchronously send messages to anAsyncEnumerableSubject
.
Axes
An axe is referring to an “informational axe” related to a given data, a.k.a. a metadata. Currently the feed framework is managing (i.e., actively generating value for) only 2 metadata: error and progress, but as Messages are designed to encapsulate a data and all its metadata, a MessageEntry
can have more than those 2 well-known axes.
┌────┐1 *┌────────┐ 2┌────────────┐1 *┌────────────────┐
│Feed├────►│Message │ ┌─►│MessageEntry├────►│MessageAxis │
└────┘ ├────────┤ │ ├────────────┤ └─────┬──────────┘
│Previous├──┤ │Data │1 │1
│Current ├──┘ │Error ├──┐ │
│Changes │ │Progress │ │ │1
└────────┘ └────────────┘ │ *┌─────┴──────────┐
└─►│MessageAxisValue│
└────────────────┘
MessageEntry
are basically dictionaries ofMessageAxis
andMessageAxisValue
, except they are returningMessageAxisValue.Unset
instead of throwing error for unset axes.The
DataAxis
is a special axis that must be set on all entries. It exists only to unify/ease implementation. You should useOption.Undefined
to “unset” the data.If you are about to add an axe, you should make sure to provided extensions methods over
IMessageBuilder
andIMessageEntry
to read/write it directly to/from the effective metadata type. The genericGet
andSet
are there for that and should not be used directly in user’s code.
Request
The subscriber of a feed can send some request to the source feed to enhance its behavior.
The most common request is the RefreshRequest
.
When implementing an
IFeed
you have access to those requests using theRequests<TRequest>()
method on theSourceContext
you get in theGetSource
.When consuming a feed, you can send a request to that feed by creating a "child" context (
SourceContext.CreateChild()
) giving you ownIRequestSource
.
Token and TokenSet
In a response to a request a feed might issue a token that is then added to its messages so the subscriber that sent the request is able to track the completion of the request. This is the case for the Refresh and the Pagination which are forwarding those tokens through the refresh and the pagintation axes.
As when a subscriber sends a request, it might be handled by more than one feed. For instance, when combining two AsyncFeed
instances, the refresh request will cause those two feeds to refresh their data.
Even if it's not yet the implemented, we can also imagine that an operator feed (such as the SelectAsyncFeed
) might also trigger a refresh of its own projection.
Refresh and Pagination axes are working with TokenSet
. It is a collection of IToken
that only keep the latest tokens for a given source in relation to the subscription context.
Subscriber Combine Async A Async B
│ │ │ │
┌─ Control channel (Requests on SourceContext) ─┐
│ │ │ │ │ │
│ │ Request 1 │ │ │ │
┌──┼──◄─┼──────────────►│ Request 1 │ │ │
│ │ │ ├──────────►│ │ │
▼ │ │ │ Token A │ │ │
│ │ │ │◄──────────┘ │ │
│ │ │ │ Request 1 │ │
▼ │ │ ├──────────────────────►│ │
│ │ │ │ Token B │ │
│ │ │ │◄──────────┬───────────┤ │
▼ │ │ TokenSet[A,B] │ │ │ │
│ │ │◄──────────────┤ │ │ │
│ │ │ │ │ │ │
▼ └────┼───────────────┼───────────┼───────────┼──┘
IsExecuting │ │ │ │
│ ┌─ Message channel ──┼───────────┼───────────┼──┐
│ │ │ │ │ │ │
▼ │ │ ┤ Msg with │ │ │
│ │ │ │ token A │ │ │
│ │ │ │◄──────────┤ Msg with │ │
▼ │ │ Msg with │ │ token A │ │
│ │ │ TokenSet[A,B] │◄──────────┼───────────┤ │
└─►├───►│◄──────────────┤ │ │ │
│ │ │ │ │ │
└────┼───────────────┼───────────┼───────────┼──┘
│ │ │ │
Note
As a subscriber, you can use the TokenSetAwaiter
to wait for a set that has been produced by a request.
Note
When implementing an IFeed
you can use the CoercingRequestManager
or the SequentialRequestManager
to easily implement such request / token logic.
When you answer to a request with a token, you must then issue a new message with that token.
View
While developing in the feed framework, the most interesting view-related part is the presentation layer. Feeds are transferring messages containing the data and its metadata, but those are not binding friendly. The presentation layer has then 2 responsibilities:
- Read, i.e. from the ViewModel to the View, it will expose and maintain the state of the last message through standard binding-friendly properties and
INotifyPropertyChanged
; - Write, i.e. from the View to the ViewModel, it will convert back standard properties into so called
IInput
, which inherits from theIFeed
interface, and which allows the ViewModel to manage the view just like any other data source.
Those binding friendly properties are generated by the FeedsGenerator
.
To trigger the generation, the class needs to have at least on constructor which has an
IInput
, or anICommandBuilder
parameter, or flag the type with[ReactiveBindable(true)]
attribute.When we generate a binding friendly class for a type
MyType
we are generating aBindableMyType
class nested into theMyType
itself. This class inherits from theBindableViewModelBase
.
Denormalizing
In order to make a record editable field per field (e.g. a user profile where you want to edit first name and last name independently), we can generate a BindableRecord
object that we re-expose each property of the Record
independently.
When a record is denormalized, we are generating / maintaining only one
State<Record>
. Setting a value of any of the denormalized properties, will update that root state.When a record is denormalized, we can no longer directly set a record instance on the
VM.Record
(the property is of typeBindableRecord
). To work around this, if the record does not have aValue
property which would conflict, we are generating a get/set propertyValue
which can be used to set the instance (e.g.VM.Record.Value
).When a property is updated, we also ensure to raise the property changes, including for the
Value
property (if defined) and theGetValue
method.
Dispatching and threading considerations
The user’s ViewModel
are expected to run on background thread for performance considerations. Commands and state updates from generated ViewModel classes are always ran on a background thread.
To avoid strong dependency on a dispatcher (neither by injection nor by the need to create VM on UI thread), all generated classes are auto resolving the dispatcher when an event handler is registered or when a public method is used, using the internal LazyDispatcherResolver
and EventManager
. Those classes are then completing their initialization once the first dispatcher is resolved.
The EventManager
will capture the thread used to register an event handler and will then make sure to invoke that handler on that thread if it was a UI thread. This allows BindableVM
to be data-bound to multiple window/UI threads.
┌───────────────┐ ┌───────────────┐
│EventManager │ │IInvocationList│
├───────────────┤1 *├───────────────┤
│Add(handler) ├────►│Add(handler) │
│Remove(handler)│ │Remove(handler)│
│Raise(args) │ │Raise(args) │
└───────────────┘ └───────▲───────┘
│
┌───────────────┴──────────────┐
│ │
┌───────────┴────────────┐ ┌────────────┴────────────┐
│DispatcherInvocationList│ │MultiThreadInvocationList│
└───────────▲────────────┘ └─────────────────────────┘
│
┌─────┴───────┐
│ │
┌─────┴─────┐ ┌───┴────┐
│Coaslescing│ │Queueing│
└───────────┘ └────────┘
We have one
IInvocationList
per UI thread and one for all background threads (theMultiThreadInvocationList
).As a bonus, when a dispatcher bound handler is about to be invoked from another thread, it can be coalesced to be raised only once (cf.
CoalescingDispatcherInvocationList
).