Obfusquer une couche d'accès aux données Dapper sans casser le mappage colonne-propriété
Dapper apparie les noms de colonne du jeu de résultats aux membres publics de votre POCO par nom, sans tenir compte de la casse, au moment de la matérialisation. Renommez ces membres avec un obfuscateur et Dapper cesse silencieusement d'apparier : aucune exception, juste une propriété laissée à null ou 0. Voici le tableau complet : comment le gestionnaire de types par défaut de Dapper lie précisément une colonne à un membre, pourquoi votre texte SQL est en général sûr mais les membres de votre POCO fragiles, les échappatoires par mappage personnalisé, et la keep-list précise de nebula.config.json qui épingle les POCO de résultat tout en renommant le reste de la couche de données.
Un micro-ORM comme Dapper gagne sa vitesse en faisant très peu de magie, mais la seule parcelle de magie qu’il conserve est justement celle qui entre en collision avec l’obfuscation. Lorsque vous écrivez connection.Query<Customer>("SELECT Id, FullName, Email FROM Customers"), Dapper exécute la commande, parcourt le jeu de résultats et, pour chaque ligne, construit un Customer en appariant chaque nom de colonne du lecteur à un membre public de Customer dont le nom correspond, sans tenir compte de la casse. Cet appariement se fait par comparaison de chaînes contre le nom du membre à l’exécution. Renommez Customer.FullName en a avec un obfuscateur et la colonne FullName n’a plus rien à quoi se lier — Dapper laisse donc la propriété à sa valeur par défaut et passe à la suite. Aucune exception. Aucune ligne de journal. Juste un Customer dont le FullName est null.
Ce silence est ce qui rend le sujet digne d’un article soigné. Un fournisseur LINQ cassé lève une exception ; un mappage Dapper cassé renvoie un objet d’apparence plausible mais troué, et vous le découvrez en production. La bonne nouvelle, c’est que la surface fragile est petite et parfaitement identifiable : vous n’avez donc pas besoin de la réaction brutale habituelle qui consiste à exclure toute votre couche de données. Voici l’inventaire de ce que Dapper lit par nom, pourquoi le reste est sûr, et la keep-list exacte de Nebula.NET qui épingle les membres fragiles tout en renommant tout le reste.
Comment Dapper lie une colonne à un membre
Le gestionnaire de types par défaut de Dapper, SqlMapper, construit un matérialiseur pour chaque paire (type, ensemble-de-colonnes) et le met en cache. C’est la logique de construction qui compte ici. Pour un type T doté d’un constructeur sans paramètre, Dapper énumère les colonnes du lecteur et, pour chacune, cherche sur T une cible assignable dont le nom est égal au nom de la colonne selon une comparaison ordinale insensible à la casse. Il privilégie une propriété publique assignable ; à défaut, il liera un champ public. S’il ne trouve aucun membre pour une colonne, cette colonne est simplement ignorée. Si un membre n’a pas de colonne correspondante, il reste à ce que le constructeur a produit : null, 0, false, default.
Trois conséquences en découlent directement, et toutes trois gouvernent la keep-list :
- L’appariement se fait par nom, sans tenir compte de la casse.
FullNamese lie à une colonneFullName,fullnameouFULLNAME. Il ne se lie pas à un membre renomméa. Le pliage de casse ne vous sauve pas d’un renommage ; seul le nom exact (insensible à la casse) survit. - Seuls les membres publics et assignables participent. Dapper écrit via un setter de propriété publique ou un champ public. Un champ de stockage privé est invisible pour le mappeur par défaut, raison pour laquelle les obfuscateurs qui renomment les champs privés sont en général inoffensifs pour Dapper — et pourquoi ce sont spécifiquement les membres publics qu’il faut épingler.
- Une correspondance absente est silencieuse. Il n’y a pas de mode strict sur le chemin par défaut. L’absence de liaison est indiscernable, à l’exécution, d’une colonne qui a légitimement renvoyé
NULL.
Pourquoi le SQL est sûr mais le POCO fragile
Il vaut la peine d’être précis sur ce que l’obfuscateur touche et ne touche pas, car cette asymétrie est la raison même pour laquelle le problème est gérable.
Le SQL de connection.Query<Customer>("SELECT Id, FullName, Email FROM Customers") est un littéral de chaîne. Nebula peut chiffrer ce littéral pour qu’il ne traîne pas en clair dans votre assembly, mais le chiffrement est transparent à l’exécution : la chaîne déchiffrée remise à la base de données est, octet pour octet, ce que vous avez écrit. La base de données voit SELECT Id, FullName, Email et renvoie des colonnes nommées Id, FullName, Email. Les noms de colonne sous forme de littéral de chaîne dans le SQL survivent donc intacts à l’obfuscation : ils n’ont jamais été des identifiants dans votre IL, seulement des données.
Le POCO, c’est l’inverse. Customer.FullName est un vrai membre CLR, un identifiant dans les métadonnées, et renommer les identifiants est précisément ce que fait un obfuscateur. Les deux extrémités de l’appariement de Dapper vieillissent donc différemment sous l’obfuscation : l’extrémité du nom de colonne (issue du SQL) est stable, et l’extrémité du nom de membre (issue du POCO) bouge. L’appariement qui cadrait autrefois ne cadre plus. Épingler les membres du POCO réaligne les deux extrémités.
Voici le POCO et l’appel, écrits tels qu’ils devraient apparaître dans du code livré :
public sealed class Customer
{
public int Id { get; set; }
public string FullName { get; set; } = "";
public string Email { get; set; } = "";
}
public sealed class CustomerRepository
{
private readonly IDbConnection _db;
public CustomerRepository(IDbConnection db) => _db = db;
public Customer? GetById(int id) =>
_db.QuerySingleOrDefault<Customer>(
"SELECT Id, FullName, Email FROM Customers WHERE Id = @Id",
new { Id = id });
}
Deux noms sont ici porteurs à l’exécution, et aucun autre ne l’est. Les membres publics de Customer (Id, FullName, Email) sont appariés aux colonnes du résultat. Les membres de l’objet de paramètres anonyme new { Id = id } sont appariés au marqueur @Id — Dapper lit le nom de propriété Id de l’objet et le lie à @Id, là encore par nom. La classe CustomerRepository, le champ _db, la méthode GetById, le paramètre id : rien de tout cela n’est résolu par nom à l’exécution, de sorte que tout peut être renommé.
La liaison des paramètres a la même dépendance au nom
Le côté paramètres est facile à oublier parce qu’il est moins visible. Lorsque vous passez new { Id = id }, Dapper génère un paramètre nommé Id à partir du nom du membre et l’apparie au jeton @Id du SQL par nom. Renommez ce membre et le marqueur @Id n’a plus de valeur à lier, ce qui lève bel et bien une exception (un paramètre manquant n’est pas silencieux comme l’est une colonne manquante), mais reste une rupture induite par le renommage. Pour les types anonymes, ce n’est en pratique généralement pas un problème, car les noms de propriété des types anonymes du compilateur C# ne sont pas quelque chose que la plupart des obfuscateurs renomment par défaut — mais si vous utilisez une classe de paramètres nommée, ses membres publics sont exactement aussi fragiles qu’un POCO de résultat et doivent eux aussi figurer dans la keep-list.
La keep-list : épinglez les membres mappés, renommez le reste
La phase de renommage de Nebula est pilotée par nebula.config.json. La stratégie consiste à activer le renommage globalement, puis à découper le petit ensemble de membres que Dapper résout par nom. L’unité la plus propre à épingler est le type POCO (et ses membres) ; les dépôts et utilitaires sont laissés au renommage par défaut.
{
"input": "bin/Release/net8.0/Acme.Data.dll",
"rename": {
"enabled": true,
"keep": [
{
"type": "Acme.Data.Models.Customer",
"members": "public",
"reason": "Dapper binds result columns to these public members by name"
},
{
"namespace": "Acme.Data.Models",
"kind": "type",
"members": "public",
"reason": "all query-result POCOs live here; pin their public surface"
}
]
},
"strings": { "encrypt": true },
"controlFlow": { "enabled": true }
}
La portée "members": "public" est le détail important : elle préserve les noms de propriété et de champ publics que lit Dapper, tout en laissant Nebula renommer les champs de stockage privés, auxquels le mappeur par défaut de Dapper ne touche jamais. Si vous regroupez vos POCO dans un espace de noms dédié (Acme.Data.Models ci-dessus), la règle d’espace de noms épingle toute la famille en une seule entrée, de sorte qu’un nouveau résultat de requête ajouté plus tard est couvert sans modifier la configuration.
Si vous utilisez une classe de paramètres nommée plutôt que des types anonymes, épinglez-la de la même manière :
{
"type": "Acme.Data.Parameters.CustomerQuery",
"members": "public",
"reason": "Dapper binds @-parameters to these members by name"
}
Tout ce qui n’est pas nommé dans keep est renommé. CustomerRepository, votre fabrique de connexions, tout utilitaire interne de construction de SQL : leurs identifiants ne portent aucun sens à l’exécution pour Dapper, ils deviennent donc a, b, c, et votre assembly protégé garde sa logique opaque pendant que son mappage de données continue de fonctionner.
L’échappatoire : découpler les noms avec un mappage de types personnalisé
Si vous préférez ne conserver aucun nom — en maximisant la surface de renommage jusque dans vos POCO —, Dapper vous offre un moyen de rendre explicite la liaison colonne-membre, de sorte qu’elle ne dépende plus du nom du membre. SqlMapper.SetTypeMap installe un mappeur par type, et CustomPropertyTypeMap vous laisse résoudre chaque colonne vers un membre via un délégué. Faites piloter ce délégué par un attribut de style [Column] plutôt que par le nom du membre :
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class ColumnAttribute : Attribute
{
public ColumnAttribute(string name) => Name = name;
public string Name { get; }
}
public sealed class Customer
{
[Column("Id")] public int Id { get; set; }
[Column("FullName")] public string FullName { get; set; } = "";
[Column("Email")] public string Email { get; set; } = "";
}
// Register once at startup, before the first query.
SqlMapper.SetTypeMap(typeof(Customer), new CustomPropertyTypeMap(
typeof(Customer),
(type, columnName) => type
.GetProperties()
.FirstOrDefault(p => p
.GetCustomAttributes(typeof(ColumnAttribute), false)
.Cast<ColumnAttribute>()
.Any(a => string.Equals(a.Name, columnName,
StringComparison.OrdinalIgnoreCase)))));
Désormais la liaison passe par la chaîne "FullName" de l’attribut, qui est un littéral que Nebula chiffre mais ne renomme jamais — exactement comme le nom de colonne du SQL. Le membre qu’il décore est libre : Nebula peut renommer FullName en a et le mappage personnalisé résout toujours la colonne FullName vers lui, car la clé de résolution est la valeur de l’attribut, non l’identifiant du membre. Conservez les membres de l’attribut utilisés par la réflexion (ColumnAttribute.Name et le type) dans la keep-list, puisque le délégué les lit, et vous pourrez retirer entièrement les noms de propriété du POCO de keep.
C’est l’arbitrage entre le robuste et le commode. Conserver les noms des membres (la première approche) est plus simple, ne demande aucune modification de code et laisse quelques identifiants publics lisibles. Le mappage personnalisé masque même ces identifiants, mais ajoute un enregistrement à ne pas oublier et un petit coût réflexif à la première matérialisation. Pour la plupart du code livré, la keep-list est le bon choix par défaut ; recourez au mappage personnalisé lorsque les noms de propriété d’un POCO précis sont eux-mêmes sensibles.
Lancer la protection et l’intégrer à la build
La configuration en place, protéger l’assembly tient en une seule commande :
nebula protect --config nebula.config.json --verbose
Nebula lit l’assembly d’entrée, applique le renommage en respectant votre keep-list, chiffre les chaînes, aplatit le flux de contrôle et écrit l’assembly protégé à la place de l’entrée (ou au chemin output si vous le définissez). L’option --verbose affiche le résumé de la table de renommage, qu’il vaut la peine de parcourir une fois pour confirmer que les membres de votre POCO apparaissent comme conservés et les types de votre dépôt comme renommés.
Pour l’automatiser, accrochez-le après la publication avec une cible MSBuild, afin que chaque build publié soit protégé sans étape manuelle :
<Target Name="NebulaProtect" AfterTargets="Publish">
<Exec Command="nebula protect --config $(MSBuildProjectDirectory)/nebula.config.json --input $(PublishDir)Acme.Data.dll"
WorkingDirectory="$(MSBuildProjectDirectory)" />
</Target>
Comme la cible s’exécute avec AfterTargets="Publish", elle opère sur la sortie publiée plutôt que sur la build intermédiaire : vos builds de débogage de la boucle interne restent donc rapides et non obfusqués, tandis que les publications de release livrent des binaires protégés. (Si vous disposez d’une licence Nebula Pro, la protection du flux de contrôle et des chaînes s’exécute sur la sortie publiée selon la configuration ; le niveau gratuit applique la phase de renommage, qui est la partie dont dépend cet article.)
Vérifier avec Glass avant de livrer
La dernière étape consiste à confirmer que le renommage a bien fait ce que la keep-list visait : que les membres fragiles ont survécu et que le reste non. Ouvrez l’assembly protégé dans Glass, le décompilateur .NET gratuit, et regardez deux choses. D’abord, votre POCO : Customer doit toujours montrer Id, FullName, Email comme propriétés publiques avec leurs noms d’origine — c’est ce qui prouve que Dapper liera encore les colonnes. Ensuite, un dépôt : CustomerRepository et ses membres doivent apparaître renommés en identifiants courts et opaques, ce qui prouve que le reste de la couche de données a bien été protégé plutôt que balayé par accident dans la keep-list.
Si vous voyez une propriété du POCO renommée, Dapper renverra silencieusement des valeurs nulles pour elle à l’exécution : cette passe au décompilateur n’est donc pas optionnelle — c’est le substitut le moins coûteux possible à l’exception que Dapper ne lève jamais. Un aller-retour rapide — protéger, décompiler, examiner à l’œil les deux formes, puis exécuter un test d’intégration qui relit une ligne et vérifie que toutes les colonnes sont renseignées — transforme une classe de bogues silencieux de production en quelque chose que vous attrapez sur la build même qui les a produits.
La forme de la règle
Le contrat de Dapper avec votre code est petit et fondé sur les noms : les colonnes du résultat se lient aux membres publics du POCO par nom, et les membres des paramètres anonymes ou nommés se lient aux @marqueurs par nom. Ce sont les seuls noms qu’il résout à l’exécution, et les seuls que vous devez épingler. Tout le reste d’une couche d’accès aux données — les dépôts, la gestion des connexions, la construction des requêtes — n’a pas une telle dépendance et peut être renommé, voir ses chaînes chiffrées et son flux de contrôle aplati comme n’importe quel autre code que vous protégez. Épinglez les membres mappés dans nebula.config.json, ou découplez-les avec un mappage de types personnalisé, vérifiez le résultat dans Glass, et vous livrerez une couche de données opaque pour qui pratique l’ingénierie inverse et transparente pour Dapper.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.