Guides pratiques : clés, valeurs et Layers
Chaque section ici décrit une tâche avec l'API key/value. Elles supposent que vous avez déjà une
base de données ouverte (un IFdbDatabaseProvider), telle que configurée dans
Prise en main. Pour comprendre pourquoi les clés sont des tuples, ce
qu'est un subspace, et pourquoi un Layer ne détient aucun état par transaction, voyez
l'explication ; pour l'encodage des tuples en détail, voyez la référence.
Construire une clé
Construisez les clés avec subspace.Key(...), qui encode ses arguments en tuple derrière le préfixe
du subspace. Construisez les valeurs avec les factories FdbValue.*. Passez l'objet clé ou valeur
directement à la transaction ; ne le pré-sérialisez pas avec .ToSlice().
// construit les clés (typées, lazy)
var k = subspace.Key("user", 123); // préfixe + ("user", 123)
// rendue en octets ici, dans des buffers poolés
Slice value = await tr.GetAsync(k);
tr.Set(subspace.Key("user", 123), FdbValue.FromTuple(("Alice", 30)));
tr.Clear(subspace.Key("user", 123));
.ToSlice() existe, mais seulement quand vous avez besoin des octets en tant que données
(logging, tests, ou stockage d'une clé à l'intérieur d'une valeur). Pour comprendre pourquoi la
concaténation manuelle casse l'ordre et l'échappement, voyez l'explication.
Construire une clé dont la fin est dynamique
Pour un index générique dont la valeur indexée a un type arbitraire, chaînez un tuple construit à l'exécution sur un préfixe typé :
IVarTuple value = /* construit à l'exécution */;
// préfixe typé (1, idx) + suffixe dynamique
var indexKey = subspace.Key(INDEXES, indexId).Tuple(value);
C'est le remplacement moderne de l'ancien style dynamique subspace.Pack(...).
Créer des ids ordonnés et sans collision
Pour les files d'attente, les journaux d'événements et les change feeds (tout ce qui a besoin d'ids ordonnés globalement et sans collision), laissez la base de données assigner un VersionStamp au moment du commit :
// un stamp incomplet, complété au commit
var stamp = tr.CreateVersionStamp(userVersion);
// FDB écrit le vrai stamp monotone au commit
tr.SetVersionStampedKey(log.Key(stamp), payload);
Un simple range scan renvoie alors les entrées dans l'ordre du commit, sans compteur partagé source de contention. Voyez Layers avancés pour le pattern complet de change feed.
Lire un range
La plupart des layers lisent des ranges, pas des clés individuelles. Construisez les ranges à partir de clés et de subspaces ; n'incrémentez jamais les octets à la main.
tr.GetRange(subspace.ToRange()); // tout ce qui est sous le subspace
tr.GetRange(subspace.Key("user", 123).ToRange()); // tout ce qui est sous un préfixe
FdbKeyRange.Between(subspace.Key(100), subspace.Key(200)); // [100, 200)
Dérivations utiles (méthodes d'extension sur n'importe quelle clé) : key.Successor() (la clé
suivante, une borne inférieure exclusive), key.NextSibling() (la première clé qui n'a pas key
comme préfixe, une borne supérieure exclusive sur ses enfants), subspace.First() /
subspace.Last(), et les KeySelector FirstGreaterOrEqual() / LastLessOrEqual().
Décoder des clés issues d'un range
Lisez un range, récupérez les octets bruts des clés, et décodez-les avec le même subspace qui les a produits :
foreach (var kv in chunk)
{
var (name, id) = subspace.Decode<string, int>(kv.Key); // STuple<string?, int?>
int idOnly = subspace.DecodeLast<int>(kv.Key);
IVarTuple all = subspace.Unpack(kv.Key);
}
Utilisez Decode/DecodeLast/Unpack ; ne découpez jamais les octets à la main.
Pour décoder des clés dans une hot loop, évitez l'allocation de Range[] par appel :
dimensionnez un Span<Range> avec TuPack.CountItems, puis subspace.Unpack(kv.Key.Span, buffer)
renvoie un SpanTuple qui utilise votre buffer (TuPack.Unpack(ReadOnlySpan<byte>, Span<Range>)
est la forme brute). Gardez le buffer en vie tant que le tuple est utilisé.
Résoudre un subspace via le Directory layer
Vous ne codez jamais un préfixe en dur. Déclarez un chemin logique, et résolvez-le en subspace via le Directory layer à l'intérieur de la transaction :
ISubspaceLocation location = db.Root["Tenants"]["ACME"]["Documents"]["Books"];
await db.WriteAsync(async tr =>
{
// interroge le Directory layer
IKeySubspace subspace = await location.Resolve(tr);
tr.Set(subspace.Key("BOOK_123"), FdbValue.FromTuple(("Title", "ISBN")));
}, ct);
Trois règles, et l'explication couvre pourquoi le préfixe est dynamique en premier lieu :
- Résolvez à chaque transaction. Le préfixe est stable en pratique mais pas garanti pour toujours ; le mettre en cache vous-même contourne le Directory layer et risque une corruption.
- Resolve ouvre ; il ne crée pas.
Resolvethrow si le directory n'existe pas encore. Créez-le la première fois aveclocation.CreateOrOpenAsync(tr)dans une transaction en lecture-écriture, ce que fait un layer à l'initialisation. - L'indexeur
db.Root[...]descend d'un segment à la fois.db.Root["a", "b"]n'est pas deux segments : la surcharge à deux arguments est(name, layerId). Chaînez l'indexeur (db.Root["a"]["b"]) ou passez unFdbPath.
Choisir un encodage de valeur
Les valeurs sont produites par les factories FdbValue.*. Choisissez la factory qui correspond
au pattern d'accès :
| Besoin | Utiliser |
|---|---|
| Octets bruts / blob | FdbValue.ToBytes(slice) |
| Valeur vide (entrées d'index) | FdbValue.Empty |
| Texte | FdbValue.ToTextUtf8(s) / ToTextUtf16(s) |
| Un compteur que vous muterez atomiquement | FdbValue.ToFixed64LittleEndian(n) (le little-endian de taille fixe est requis pour AtomicAdd64) |
| Un tuple | FdbValue.FromTuple(("a", 1)) |
| Document JSON | FdbValue.ToJson(obj), voir CrystalJson |
Pour relire : slice.ToInt64(), slice.ToStringUtf8(), CrystalJson.Deserialize<T>(slice) (qui
associe une clé manquante ou vide à null), etc.
Pour une valeur JSON, FdbValue.ToJson(obj) sérialise un objet via CrystalJson, la stack JSON du
SDK, et CrystalJson.Deserialize<T>(slice) la relit :
tr.Set(subspace.Key("D", book.Id), FdbValue.ToJson(book));
Book? loaded = CrystalJson.Deserialize<Book>(await tr.GetAsync(subspace.Key("D", book.Id)));
CrystalJson est une stack JSON généraliste avec son propre guide : le DOM, le source generator et les settings sont dans CrystalJson.
Écrire un Layer
Encapsulez l'accès à la base de données dans un Layer : un fin wrapper au-dessus d'un
ISubspaceLocation qui ne détient aucun état par transaction. Pour comprendre pourquoi le pattern a
cette forme, voyez l'explication. Chaque layer suit la même forme :
- La classe du layer est un fin wrapper réutilisable au-dessus d'un
ISubspaceLocation(plus des codecs/options). Elle ne détient aucun état par transaction. - Elle implémente
IFdbLayer<TState>.Resolve(tr)résout la location et renvoie unStatequi contient l'IKeySubspacerésolu. Mémoïsez-le danstr.Contextpour que les appels répétés àResolve(tr)dans une transaction soient peu coûteux. - Tout le travail réel se fait dans des méthodes qui prennent une transaction et utilisent le
subspace du
Statepour construire les clés. - Le
Statene doit jamais échapper à la transaction : ne le stockez pas dans un champ et ne le réutilisez pas entre les retries. (les données locales detr.Contextsont par transaction, donc y mémoïser est sûr ; un champ de layer ne l'est pas.)
Un store de documents avec un index secondaire
public sealed partial class BookStore : IFdbLayer<BookStore.State>
{
// Distingue les sous-parties du subspace avec de petites constantes entières, pas des strings :
// 0 s'encode en 1 octet (0x14), 1 en 2 octets (0x15 0x01), alors que "D" fait 3 octets (0x02 'D' 0x00) sur chaque clé.
// (0, <id>) -> document json
private const int SUBSPACE_DOCUMENTS = 0;
// (1, <author>, <id>) -> vide (entrée d'index)
private const int SUBSPACE_INDEX_AUTHOR = 1;
public BookStore(ISubspaceLocation location) => this.Location = location;
public ISubspaceLocation Location { get; }
public string Name => nameof(BookStore);
private const string LocalDataKey = nameof(BookStore);
public ValueTask<State> Resolve(IFdbReadOnlyTransaction tr)
{
if (tr.Context.TryGetLocalData(LocalDataKey, out State? s)) return new(s);
return ResolveSlow(this, tr);
static async ValueTask<State> ResolveSlow(BookStore self, IFdbReadOnlyTransaction tr)
{
var subspace = await self.Location.Resolve(tr);
return tr.Context.GetOrCreateLocalData(LocalDataKey, new State(self, subspace));
}
}
public sealed partial class State
{
public IKeySubspace Subspace { get; }
internal State(BookStore layer, IKeySubspace subspace) => this.Subspace = subspace;
public void Insert(IFdbTransaction tr, Book book)
{
tr.Set(this.Subspace.Key(SUBSPACE_DOCUMENTS, book.Id), FdbValue.ToJson(book));
tr.Set(this.Subspace.Key(SUBSPACE_INDEX_AUTHOR, book.Author, book.Id), FdbValue.Empty);
}
public async Task<Book?> GetAsync(IFdbReadOnlyTransaction tr, string id)
=> CrystalJson.Deserialize<Book>(await tr.GetAsync(this.Subspace.Key(SUBSPACE_DOCUMENTS, id)));
public IAsyncQuery<string> FindIdsByAuthor(IFdbReadOnlyTransaction tr, string author)
=> tr.GetRange(this.Subspace.Key(SUBSPACE_INDEX_AUTHOR, author).ToRange())
.Select(kv => this.Subspace.DecodeLast<string>(kv.Key)!);
}
}
Utilisé via les helpers de retry loop, qui résolvent le state pour vous :
var store = new BookStore(db.Root["Documents"]["Books"]);
await store.WriteAsync(db, (tr, st) => st.Insert(tr, book), ct);
Book? b = await store.ReadAsync(db, (tr, st) => st.GetAsync(tr, "B1"), ct);
Composer plusieurs layers dans une transaction
Comme les méthodes d'un layer prennent une transaction au lieu d'ouvrir la leur, une seule
retry loop peut piloter plusieurs layers de façon atomique. Insérez un document, mettez en file
d'attente un job d'arrière-plan, et publiez un événement dans le même WriteAsync, et soit ils
committent tous, soit aucun :
await db.WriteAsync(async tr =>
{
await books.InsertAsync(tr, book);
await workers.QueueAsync(tr, new GenerateThumbnails(book.Id));
await feed.PublishAsync(tr, new BookCreated(book.Id));
}, ct);
Si la transaction échoue à committer, c'est comme si la requête n'avait jamais eu lieu : pas de document, pas de job, pas d'événement.
Maintenir un index secondaire
Les entrées d'index sont des données dérivées : c'est votre code, pas la base de données, qui les garde synchronisées. C'est là que les layers se trompent le plus souvent :
- Pour changer l'index, vous devez connaître l'ancienne valeur indexée, et vous ne pouvez
l'apprendre que depuis le document stocké, jamais depuis un objet que l'appelant vous passe (il
peut être périmé, laissant une entrée d'index orpheline).
Update/Patch/Deletelisent donc le document actuel et en dérivent l'ancienne clé d'index à partir de celui-là. - Mutez l'index dans la même transaction que le document, pour qu'il ne puisse jamais se désynchroniser lors d'un échec partiel.
- Ne réécrivez l'index que lorsque la valeur indexée a réellement changé. Pour des documents mis à jour fréquemment dont le champ indexé est stable, cela évite des écritures inutiles (et les conflits qu'elles provoquent).
Concrètement, changer l'auteur d'un livre réécrit le document sur place et déplace son entrée d'index, les deux dans une seule transaction. Le document garde sa clé, donc seule sa valeur change ; la clé d'index est réellement différente, donc l'ancienne entrée est supprimée et une nouvelle est insérée :
L'exemple propose trois variantes de mise à jour, qui échangent une lecture contre des obligations pour l'appelant :
| Méthode | Lit l'ancien doc ? | À utiliser quand |
|---|---|---|
UpdateAsync(tr, book) |
oui | l'appelant a construit un Book neuf et ne détient pas l'original |
UpdateAsync(tr, updated, original) |
non | l'appelant a déjà lu original dans la même transaction (sa lecture fournit le conflit qui garde l'index cohérent ; passer un original périmé corrompt l'index) |
PatchAsync(tr, id, patch) |
oui | mises à jour de champs peu coûteuses ; un patch sans effet (updated == current, par égalité de valeur du record) n'écrit rien |
Rendre les clés d'un layer lisibles
Les clés brutes sont des octets opaques. Les outils (le shell FQL, FdbShell, les dumps, le
logger de transactions) peuvent les afficher comme des tuples lisibles si le layer publie un
schéma. Implémentez IFdbLayerSchemaMapper (souvent comme classe imbriquée) et renvoyez une
FqlTemplateExpression par famille de clés :
public sealed class SchemaMapper : IFdbLayerSchemaMapper
{
public string LayerId => "docstore.Books";
public IEnumerable<FqlTemplateExpression> GetRules()
{
yield return new("document",
FqlTupleExpression.Create().Integer(SUBSPACE_DOCUMENTS, "D").VarString("id"),
FdbValueTypeHint.Json);
yield return new("index.author",
FqlTupleExpression.Create().Integer(SUBSPACE_INDEX_AUTHOR, "I").VarString("author").VarString("id"),
FdbValueTypeHint.None);
}
}
Une fois ce schéma publié, une clé brute cesse d'être des octets opaques et se lit comme un tuple
lisible. Les deux familles s'affichent ainsi (D/I sont les noms d'affichage des subspaces
0/1, et ... représente le préfixe Directory résolu) :
Le hint de valeur peut aussi être une fonction de la clé décodée
((SpanTuple t) => t.Get<string>(0) switch { … }) quand le type de la valeur dépend de la clé.
Migrer depuis l'ancienne API de clés dynamiques
L'ancien code utilisait une API de subspace dynamique (IDynamicKeySubspace,
subspace.Encode(...) / .Pack(...)), remplacée par la famille typée subspace.Key(...). Traduisez
mécaniquement :
| Ancien (dynamique) | Nouveau (typé) |
|---|---|
subspace.Encode(a, b, c) |
subspace.Key(a, b, c) |
subspace.Pack(STuple.Create(a, b).Concat(value)) |
subspace.Key(a, b).Tuple(value) |
subspace.EncodeRange(a, b) |
subspace.Key(a, b).ToRange() |
global.Partition.ByKey(p) |
global.Key(p).ToSubspace() |
type de champ/retour IDynamicKeySubspace |
IKeySubspace |
Des layers de référence à imiter
En cas de doute, lisez les vraies implémentations dans FoundationDB.Layers.Common/ : FdbMap
(clé→valeur), FdbIndex (clés composites (value, id)), FdbVector (clés d'index entières),
FdbHighContentionCounter (évitement de la contention en écriture), FdbBlob (découpage des grandes
valeurs), FdbStringIntern (maps bidirectionnelles).
Ensuite : Transactions pour la sémantique de retry loop dans laquelle ces layers s'exécutent.