Client de stockage
La fonctionnalité StorageClient est l’interface côté exécution pour tout magasin d’objets blob/objets. Un StorageClientPlugin récupère les octets par clé, place les octets sur une clé, supprime une clé, répertorie les clés sous un préfixe et crée des URL présignées. C'est la forme entière - tout ce qui ressemble à "un blob nommé dans un espace de clé plat" s'insère derrière, et le code d'application appelant cette fonctionnalité ne sait jamais si le backend est S3, R2, MinIO, un système de fichiers local ou un double de test en mémoire.
La forme est volontairement étroite. La gestion des versions au-delà d'un seul etag, les niveaux de cohérence, le téléchargement en plusieurs parties et la réplication sont tous des problèmes spécifiques au backend qui restent dans le plugin, et non dans le trait partagé.
Le trait#
pub trait StorageClientPlugin: Send + Sync {
fn name(&self) -> &str;
fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
fn put(&self, key: &str, body: Vec<u8>, opts: PutOpts) -> Result<(), StorageError>;
fn delete(&self, key: &str) -> Result<(), StorageError>;
fn list_page(
&self,
prefix: &str,
continuation_token: Option<&str>,
) -> Result<(Vec<Object>, Option<String>), StorageError>;
fn presigned_url(
&self,
key: &str,
op: PresignOp,
ttl_secs: u64,
) -> Result<String, StorageError>;
fn is_healthy(&self) -> bool { true }
}
name(): un court identifiant affiché dans les journaux, les métriques et la TUI.get/put/delete— opérations au niveau clé. Les charges utiles sont délibérémentVec<u8>. Voir la note sur les charges utiles ci-dessous.list_page— liste paginée. Voir la note sur la pagination des listes ci-dessous.presigned_url— renvoie une URL signée sous la formeString. L'appelant choisit comment l'utiliser.is_healthy—truepar défaut ; les backends avec un client de longue durée doivent remplacer le service par ping.
Les charges utiles sont Vec<u8>#
Les premières ébauches utilisaient bytes::Bytes pour get et put. Nous sommes revenus à Vec<u8> pour trois raisons :
- Hygiène des caisses à feuilles.
bext-plugin-apidépend uniquement deserdeetserde_json. MettreBytesdans le trait entraîneraitbytesdans chaque caisse invité WASM liée à l'API, pour une victoire ergonomique qui ne dépasse pas le coût de la dépendance. - Plat WASM ABI. La limite invité/hôte transmet de toute façon des tampons d'octets simples, il n'y a donc aucun avantage de clone bon marché sur le fil.
- Les appelants peuvent terminer. Quiconque souhaite
Bytesà l'extérieur peut créerBytes::from(vec)en une seule ligne et payer le coût de conversion uniquement là où cela l'intéresse.
Les backends natifs qui contiennent un client aws-sdk / object_store / local-fs en interne peuvent bien sûr utiliser Bytes à l'intérieur ; la surface du trait leur demande simplement de distribuer des vecteurs simples à la frontière.
La liste est paginée et non diffusée#
Chaque magasin d'objets expose la liste en tant que point de terminaison paginé avec un jeton de continuation : S3, R2, GCS, Azure Blob, ils se ressemblent tous de l'extérieur. Nous exposons cette forme directement :
fn list_page(
&self,
prefix: &str,
continuation_token: Option<&str>,
) -> Result<(Vec<Object>, Option<String>), StorageError>;
Le tuple renvoyé est (objects_on_this_page, next_token). Si next_token est Some, retransmettez-le lors du prochain appel. None signifie « plus de pages ». Une première page vide est une réponse valide (sans erreur) — NotFound est réservé aux clés qui n'existent pas, pas aux préfixes qui n'ont pas encore de correspondance.
Nous avons choisi cette forme plutôt qu'un BoxStream<'_, Object> car :
- Il conserve le trait synchrone, correspondant à toutes les autres capacités E1/E2 (
Session,Auth,Mailer,Tracer,Scheduled,Webhook,FeatureFlag,I18n,Locking). - Cela évite de glisser
futuresdans une caisse à feuilles. Sinon,bext-plugin-apiaurait besoin defutures-corejuste pour nommerStream. - Cela correspond au fonctionnement des vrais backends. L'API de liste native de chaque magasin d'objets est paginée. Un wrapper de flux était l’exception et non la règle.
- Les appelants qui souhaitent un flux peuvent en créer un sur deux lignes. Un
std::iter::from_fn(sync) ou unasync_stream::stream!(async) surlist_pageest trivial.
Les backends qui contiennent une forme de flux native peuvent toujours l'utiliser en interne et la découper en pages à la limite du trait.
Classement des erreurs#
Les erreurs sont une énumération plate avec quatre variantes porteuses :
| Variante | HTTP (lorsqu'il est apparu en amont) | Quand le backend le renvoie |
|---|---|---|
NotFound |
404 | La clé n'existe pas. Renvoyé par get et presigned_url pour les clés manquantes. |
AccessDenied |
403 | Informations d'identification manquantes, erronées ou autorisées mais pas pour cette clé. |
QuotaExceeded |
413 | La taille de l'objet, le plafond par compartiment ou le quota de taux de requêtes ont été dépassés. |
Backend(String) |
500 | Tout le reste : réseau, disque, fournisseur 5xx, réponse mal formée, erreur SDK. Le message concerne les journaux. |
Les appelants se dirigent vers la variante pour décider quoi faire. Un gestionnaire de téléchargement typique ressemble à :
match storage.put(key, bytes, opts) {
Ok(()) => Ok(204),
Err(StorageError::QuotaExceeded) => Err((413, "too large")),
Err(StorageError::AccessDenied) => Err((403, "forbidden")),
Err(StorageError::NotFound) => Err((404, "gone")),
Err(StorageError::Backend(msg)) => {
tracing::error!("storage backend error: {msg}");
Err((500, "internal"))
}
}
Les plugins sont censés classer – et non paraphraser – les erreurs de leur backend. @bext/storage-s3 mappe NoSuchKey → NotFound, HTTP 403 → AccessDenied et EntityTooLarge → QuotaExceeded, tout le reste tombant sur Backend(msg).
URL présignées#
presigned_url(key, op, ttl_secs) crée une URL signée de courte durée pour la clé donnée. PresignOp est une énumération étroite :
| Variante | Signification |
|---|---|
Get |
URL de téléchargement signée. Le titulaire peut récupérer l'objet pour ttl_secs. |
Put |
URL de téléchargement signée. Le titulaire peut télécharger des octets sur la clé pour ttl_secs. |
Seulement ces deux-là, délibérément. Delete et Head peuvent être ajoutés ultérieurement si un appelant concret en a besoin ; les expédier plus tôt verrouillerait une forme que nous n’avons pas testée. Les backends qui ne peuvent pas créer d'URL présignées (un plugin de système de fichiers pur, par exemple) devraient renvoyer Backend("presigned URLs not supported") plutôt que de paniquer.
Le type de retour est String, et non url::Url, pour la même raison de caisse de feuilles : la caisse de traits n'extrait pas url. Les appelants s'analysent s'ils le souhaitent.
Backends compatibles S3 via @bext/storage-s3#
La première implémentation de référence, @bext/storage-s3, est un wrapper léger autour du client AWS SDK v1 aws-sdk-s3. Il fonctionne avec le vrai AWS S3 et avec tout fournisseur parlant l'API S3 : le backend est sélectionné uniquement en remplaçant l'URL du point de terminaison au moment de la construction :
- AWS S3 — pas de remplacement, région par défaut résolue à partir de l'environnement.
- Cloudflare R2 —
https://<account>.r2.cloudflarestorage.com, régionauto. - MinIO —
http://minio.local:9000(ou partout où se trouve votre MinIO). - Backblaze B2 —
https://s3.<region>.backblazeb2.com.
Le plugin active automatiquement l'adressage de type chemin lorsqu'un remplacement de point de terminaison est défini, car chaque fournisseur non AWS de cette liste l'exige et AWS le tolère. Les opérateurs n'ont pas besoin d'apprendre un autre drapeau.
À l’intérieur du plugin, chaque méthode de trait est synchrone ; le client asynchrone AWS SDK est piloté soit par un handle tokio injecté (lorsque l'hôte exécute déjà tokio), soit par un environnement d'exécution à thread unique détenu. Même modèle que @bext/mailer-ses.
Exemple : bext.config.toml#
# dev: local MinIO, single endpoint override
[storage]
provider = "s3"
bucket = "dev-uploads"
endpoint = "http://minio.local:9000"
region = "us-east-1"
# prod: Cloudflare R2, production bucket
[storage]
provider = "s3"
bucket = "prod-uploads"
endpoint = "https://abc123.r2.cloudflarestorage.com"
region = "auto"
# credentials come from env (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)
Changer de backend est un changement de configuration. Le code d'application appelant storage.put(...) reste exactement le même ; le plugin derrière la capacité storage est celui configuré par l'opérateur.
Si vous devez servir un fichier volumineux directement au client sans le mettre en mémoire tampon dans votre gestionnaire, utilisez une URL Get présignée ( PresignOp::Get ) et redirigez-y le client — get matérialise l'objet entier dans un Vec<u8> dans le processus de plug-in, ce qui ne convient pas aux objets de plusieurs gigaoctets.
Lorsque StorageClient n'a pas la bonne forme#
StorageClient est destiné au travail** en forme de blob **: octets opaques derrière une clé, indexés par préfixe. Ce n'est pas pour :
- Requêtes structurées. Si vous avez besoin de
WHERE tag = 'foo' ORDER BY created_at, il s'agit d'une base de données ou d'un index de recherche, pas d'un magasin d'objets blob. - Mutations transactionnelles entre clés. Les magasins d'objets sont des gagnants en dernière écriture ; Les écritures atomiques multi-clés n'existent pas sous cette forme. Utilisez une vraie base de données.
- Flux de téléchargement/téléchargement multipart avec reprise. Ceux-ci auront éventuellement une extension dédiée sur le trait (probablement un sous-module
multipartavec ses propres types), car la forme est trop différente d'unputà un seul coup pour être intégrée dans une seule méthode sans compromis. - Lectures en continu d'objets très volumineux.
getmatérialise le corps entier en unVec<u8>. Les appelants qui ont besoin de diffuser directement une exportation de 10 Go vers le client doivent utiliser l'API du plugin spécifique au backend jusqu'à ce qu'une extension de streaming arrive.
Si l'élément que vous stockez est « un fichier de taille raisonnable téléchargé par l'utilisateur » ou « un instantané sérialisé pour un travail par lots », StorageClient est la bonne forme. S'il s'agit d'un « pipeline d'objets volumineux avec des lectures partielles et des écritures pouvant être reprises », attendez l'extension en plusieurs parties.
Où aller ensuite#
- Aperçu des capacités couvre l’échelle de promotion et les autres capacités fondamentales E1/E2.
- Le plugin de référence
@bext/storage-s3se trouve danscrates/bext-impls/bext-storage-s3et constitue le point de départ recommandé pour toute personne portant une base de code S3/R2/MinIO/B2 existante sur la forme de capacité.