Obfusquer une application qui s'enregistre par convention : la DI par scan d'assembly se lie aux noms de types, donc le renommage désenregistre vos services
Un grand nombre d'applications .NET n'écrivent jamais leurs enregistrements DI. Elles scannent un assembly au démarrage — avec Scrutor, ou une boucle de réflexion maison — et enregistrent chaque type dont le nom se termine par « Service », « Handler » ou « Repository ». Cette convention est une comparaison de chaîne sur le nom du type, et l'obfuscation par renommage change chacun de ces noms, si bien que le prédicat t.Name.EndsWith("Handler") ne correspond à rien, les services ne sont silencieusement jamais enregistrés, et l'application lève « Unable to resolve service for type ... » à la première requête — un échec à l'exécution avec un build vert, loin de la ligne qui l'a causé. Cet article sépare les deux façons dont un scan peut trouver un type : par son nom, qui ne survit pas au renommage, et par sa forme — assignable à une interface marqueur, décoré d'un attribut, dérivé d'une base — qui y survit, parce que ce sont des identités de jetons de métadonnées que le renommage réécrit de façon cohérente des deux côtés. Il montre les enregistrements Scrutor qui cassent et leurs équivalents qui survivent, explique pourquoi AsImplementedInterfaces ne pose pas de problème alors que le prédicat sur le nom en pose un, trace la ligne que trace Nebula, donne à la fois le refactoring recommandé et la règle d'exclusion pour quand vous ne pouvez pas refactorer, et se termine par un smoke test de démarrage qui fait échouer le build obfusqué rapidement plutôt qu'à la première requête.
Un grand nombre d’applications .NET n’écrivent jamais réellement leurs enregistrements de services. Elles scannent. Au démarrage, une application balaie un assembly, trouve chaque type qui ressemble à un service — par convention de nommage, généralement — et l’enregistre. Scrutor en a fait un one-liner, et les boucles de réflexion maison le faisaient bien avant. C’est propre, c’est de la convention plutôt que de la configuration, et cela a un mode d’échec spécifique et silencieux sous obfuscation : la convention est presque toujours une comparaison de chaîne sur le nom du type, et le renommage change chaque nom.
Le résultat est la pire forme qu’un bug puisse prendre. Le build est vert — le scan est du code valide. L’application démarre — le scan s’exécute sans erreur. Et puis la première requête demande au conteneur IOrderService, le conteneur n’en a jamais entendu parler, et vous obtenez Unable to resolve service for type 'IOrderService' pointant vers le contrôleur, nulle part près du scan qui n’a silencieusement rien enregistré. Cet article explique pourquoi cela arrive avec Nebula, et le changement d’une ligne qui y met fin.
L’enregistrement qui casse
Voici la forme la plus souvent écrite — scanner l’assembly, prendre tout ce dont le nom se termine par un mot de convention, l’enregistrer sous ses interfaces :
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(c => c.Where(t => t.Name.EndsWith("Service"))) // <-- string match on the name
.AsImplementedInterfaces()
.WithScopedLifetime());
Lisez ce que fait réellement ce prédicat : au démarrage, il énumère les types de l’assembly et garde ceux dont le Name se termine par les sept caractères S-e-r-v-i-c-e. Avant obfuscation, ce sont OrderService, PricingService, InventoryService. Après que Nebula les a renommés en a, b, c, le prédicat demande à chacun d’eux « ton nom se termine-t-il par ‘Service’ ? » et chacun répond non. Le scan ne garde aucun type, AsImplementedInterfaces n’a rien à lier, et IOrderService n’est jamais enregistré.
Rien là-dedans n’est un bug que le compilateur ou l’obfuscateur pourrait attraper. Le prédicat est valide. Le renommage a fait exactement ce qu’on lui a dit. Les deux faits sont simplement incompatibles, et l’incompatibilité ne devient visible que lorsque quelque chose tente de résoudre un service que le scan vide n’a jamais ajouté.
La distinction qui compte : nom contre forme
Tous les scans ne cassent pas. La différence est de savoir si le scan trouve un type par son nom ou par sa forme, et cette ligne est tout l’enjeu. Un nom est une chaîne que le renommage réécrit — et le littéral de chaîne dans votre prédicat n’est pas réécrit avec lui, si bien que la comparaison devient obsolète. Une forme — implémente cette interface, porte cet attribut, dérive de cette base — est une relation dans le graphe de métadonnées, et le renommage est une réécriture cohérente de ce graphe : il renomme l’interface et le type implémenteur ensemble, de sorte que « implémente » tient toujours.
// SURVIVES — matches on the implements-relationship, a metadata token identity
.AddClasses(c => c.AssignableTo<ICommandHandler>())
// SURVIVES — matches on an attribute type token; renaming keeps attribute and target in sync
.AddClasses(c => c.WithAttribute<ServiceAttribute>())
// BREAKS — matches on a frozen string the rename cannot reach
.AddClasses(c => c.Where(t => t.Name.EndsWith("Handler")))
// BREAKS WORSE — resolves a type from a name string that no longer names anything
.AddSingleton(Type.GetType("Acme.Orders.OrderService")!)
Le correctif tient en une ligne, et il vous rend les noms
Donnez un marqueur à la convention et scannez sur celui-ci plutôt que sur l’orthographe. Une interface marqueur est la solution la plus propre, parce qu’elle ne coûte rien à l’exécution et dit exactement ce que la convention voulait dire :
public interface IAppService { } // marker; renaming keeps it resolvable by token
public sealed class OrderService : IAppService, IOrderService { /* ... */ }
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(c => c.AssignableTo<IAppService>()) // <-- shape, not name
.AsImplementedInterfaces()
.WithScopedLifetime());
Désormais, le scan demande à chaque type « implémentes-tu IAppService ? », ce à quoi les métadonnées répondent correctement avant et après obfuscation. Nebula est libre de renommer OrderService, IOrderService et tous les autres types de la couche, parce que le scan ne lit plus un seul nom — la seule chose dont il dépend est la relation implémente, et le renommage la préserve par construction. Vous obtenez l’obfuscation que vous vouliez et l’enregistrement par convention que vous vouliez, sans chevauchement entre les deux.
Un attribut fonctionne de manière identique si vous préférez ne pas ajouter d’interface à la hiérarchie du type :
[Service(ServiceLifetime.Scoped)]
public sealed class PricingService : IPricingService { /* ... */ }
.AddClasses(c => c.WithAttribute<ServiceAttribute>())
L’attribut est reconnu par son jeton de type, que le renommage garde cohérent sur la classe d’attribut et sur chaque type qui en est décoré.
Quand vous ne pouvez pas changer le scan
Parfois le scan est dans une bibliothèque qui ne vous appartient pas, et il filtre sur un nom que vous ne pouvez pas influencer. L’échappatoire consiste à conserver les noms dont le scan dépend — mais à les conserver étroitement, et à en comprendre le coût. Une configuration Nebula exclut du renommage uniquement les types nommés selon la convention :
{
"inputs": [ "bin/Release/net8.0/Acme.Orders.dll" ],
"outputDirectory": "protected",
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "*Service", "action": "keep" },
{ "pattern": "*Handler", "action": "keep" },
{ "pattern": "*Repository", "action": "keep" },
{ "pattern": "Acme.Orders.*", "action": "rename" }
]
}
}
Soyez lucide sur le compromis : chaque règle keep ici laisse un nom de classe dans les métadonnées, et l’ensemble des noms que vous exposez est précisément votre couche de services — la carte de ce que fait l’application. Plus la règle est haut placée, plus elle l’emporte sur le rename général, si bien qu’un keep *Service fige le nom de chaque service. C’est pourquoi le refactoring est la meilleure réponse dès que le scan est le vôtre : faire passer un seul prédicat de Name.EndsWith à AssignableTo retransforme tout le bloc keep en rename et rend les noms à l’obfuscateur. N’utilisez l’exclusion que pour le scan tiers que vous ne pouvez réellement pas atteindre.
Échouer vite plutôt qu’à la première requête
Ce qui rend ce bug coûteux, c’est la distance — le scan vide et la résolution échouée sont loin l’un de l’autre. Réduisez la distance avec un smoke test de démarrage qui vérifie que les services que vous attendez sont réellement enregistrés, et exécutez-le contre le build obfusqué, parce que c’est le seul build où le scan peut revenir vide :
[Fact]
public void Expected_services_are_registered_after_scanning()
{
using var provider = BuildProductionServiceProvider(); // same Scan(...) as the app
// Resolve the way the app will — if the scan registered nothing, this throws here,
// in a test, instead of on the first production request.
Assert.NotNull(provider.GetService<IOrderService>());
Assert.NotNull(provider.GetService<IPricingService>());
// Or assert the count the convention should have produced:
var handlers = provider.GetServices<ICommandHandler>().ToList();
Assert.Equal(expected: 7, handlers.Count);
}
BuildServiceProvider(validateOnBuild: true) n’attrapera pas cela de lui-même — il valide que les enregistrements présents peuvent être construits, et un enregistrement manquant est simplement absent, pas invalide. Vous devez vérifier la présence de ce que le scan aurait dû trouver. Exécutez ce test sur l’assembly protégé en CI et un scan que le renommage a vidé passe au rouge dans le build, ce qui est exactement là où vous le voulez, plutôt que dans une stack trace de production qui accuse le contrôleur.
À retenir
L’enregistrement DI par convention est une comparaison de chaîne sur les noms de types bien plus souvent qu’il n’y paraît, et l’obfuscation par renommage change chacun de ces noms tout en laissant figé le littéral de chaîne de votre prédicat — si bien que le scan n’enregistre silencieusement rien et que l’application échoue à la première résolution, à un build vert de distance de la cause. Le correctif consiste à faire correspondre le scan sur la forme plutôt que sur l’orthographe : AssignableTo une interface marqueur ou WithAttribute un attribut marqueur, que le renommage réécrit tous deux de façon cohérente à travers le graphe de métadonnées, de sorte que le scan continue de trouver vos types quel que soit leur nom. Ce seul changement permet à Nebula de renommer toute votre couche de services — les noms que vous avez obfusqués pour les cacher — pendant que l’enregistrement continue de fonctionner. Quand le scan n’est pas le vôtre à changer, figez uniquement les types nommés selon la convention avec des règles keep étroites et acceptez de laisser ces noms visibles. Dans les deux cas, prouvez-le avec un test de démarrage qui résout les services attendus contre le build obfusqué, pour qu’un scan vidé échoue en CI et jamais en production.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.