Skip to content
← Todas las publicaciones
· Delta1 Labs ObfuscationNebula

Ofuscar una capa de acceso a datos con Dapper sin romper el mapeo columna-propiedad

Dapper empareja los nombres de columna del conjunto de resultados con los miembros públicos de tu POCO por nombre, sin distinguir mayúsculas, en el momento de la materialización. Renombra esos miembros con un ofuscador y Dapper deja de emparejar en silencio: sin excepción, solo una propiedad que queda en null o 0. Aquí tienes el panorama completo: cómo enlaza exactamente una columna a un miembro el controlador de tipos por defecto de Dapper, por qué tu texto SQL suele ser seguro pero los miembros de tu POCO son frágiles, las vías de escape con mapas personalizados y la keep-list precisa de nebula.config.json que fija los POCO de resultado mientras renombra el resto de la capa de datos.

Un micro-ORM como Dapper gana su velocidad haciendo muy poca magia, pero la única pieza de magia que conserva es justo la que choca con la ofuscación. Cuando escribes connection.Query<Customer>("SELECT Id, FullName, Email FROM Customers"), Dapper ejecuta el comando, recorre el conjunto de resultados y, por cada fila, construye un Customer emparejando cada nombre de columna del lector con un miembro público de Customer cuyo nombre coincida, sin distinguir mayúsculas. Ese emparejamiento se hace por comparación de cadenas contra el nombre del miembro en tiempo de ejecución. Renombra Customer.FullName a a con un ofuscador y la columna FullName no tiene nada a lo que enlazarse, así que Dapper deja la propiedad en su valor por defecto y sigue. Sin excepción. Sin línea de registro. Solo un Customer cuyo FullName es null.

Ese silencio es lo que hace que esto merezca un artículo cuidadoso. Un proveedor LINQ roto lanza una excepción; un mapeo de Dapper roto devuelve un objeto de aspecto plausible con huecos, y te enteras en producción. La buena noticia es que la superficie frágil es pequeña y perfectamente identificable, así que no necesitas la reacción tosca de siempre de excluir toda tu capa de datos. Este es el inventario de lo que Dapper lee por nombre, por qué el resto es seguro y la keep-list exacta de Nebula.NET que fija los miembros frágiles mientras renombra todo lo demás.

Cómo enlaza Dapper una columna a un miembro

El controlador de tipos por defecto de Dapper, SqlMapper, construye un materializador para cada par (tipo, conjunto-de-columnas) y lo guarda en caché. La lógica de construcción es la parte que importa aquí. Para un tipo T con un constructor sin parámetros, Dapper enumera las columnas del lector y, por cada una, busca un destino asignable en T cuyo nombre sea igual al nombre de la columna bajo una comparación ordinal sin distinguir mayúsculas. Prefiere una propiedad pública asignable; en su defecto, enlazará un campo público. Si no encuentra ningún miembro para una columna, esa columna simplemente se omite. Si un miembro no tiene columna coincidente, se queda con lo que produjera el constructor: null, 0, false, default.

De esto se siguen directamente tres consecuencias, y las tres rigen la keep-list:

  1. El emparejamiento es por nombre, sin distinguir mayúsculas. FullName enlaza con una columna FullName, fullname o FULLNAME. No enlaza con un miembro renombrado a a. El plegado de mayúsculas no te salva frente a un renombrado; solo sobrevive el nombre exacto (sin distinguir mayúsculas).
  2. Solo participan los miembros públicos y asignables. Dapper escribe a través de un setter de propiedad pública o de un campo público. Un campo de respaldo privado es invisible para el mapeador por defecto, por lo que los ofuscadores que renombran campos privados suelen ser inofensivos para Dapper, y por eso es específicamente a los miembros públicos a los que debes fijar.
  3. Una coincidencia ausente es silenciosa. No hay modo estricto en la ruta por defecto. La ausencia de un enlace es indistinguible, en tiempo de ejecución, de una columna que legítimamente devolvió NULL.
Fijado: el nombre de columna coincide con el de la propiedad públicacolumn "FullName"column "Email"coincidepublic string FullNamepublic string Emailenlazado ✓enlazado ✓Propiedades renombradas: ningún miembro coincidecolumn "FullName"column "Email"sin coincidenciapublic string apublic string bqueda null ✗queda 0 / null ✗Interioridades del repositorio: sin dependencia de nombreCustomerRepository, SqlBuilder, ConnectionFactory → renombrados sin afectar al mapeo

Por qué el SQL es seguro pero el POCO es frágil

Conviene ser precisos sobre qué toca y qué no toca el ofuscador, porque la asimetría es la razón misma de que esto sea manejable.

El SQL de connection.Query<Customer>("SELECT Id, FullName, Email FROM Customers") es un literal de cadena. Nebula puede cifrar ese literal para que no quede en texto plano en tu ensamblado, pero el cifrado es transparente en tiempo de ejecución: la cadena descifrada que se entrega a la base de datos es, byte a byte, lo que escribiste. La base de datos ve SELECT Id, FullName, Email y devuelve columnas llamadas Id, FullName, Email. Los nombres de columna como literal de cadena en el SQL, por tanto, sobreviven intactos a la ofuscación: nunca fueron identificadores en tu IL, solo datos.

El POCO es lo contrario. Customer.FullName es un miembro CLR real, un identificador en los metadatos, y renombrar identificadores es justo lo que hace un ofuscador. Así que los dos extremos del emparejamiento de Dapper envejecen de forma distinta bajo la ofuscación: el extremo del nombre de columna (del SQL) es estable, y el extremo del nombre del miembro (del POCO) se mueve. El emparejamiento que antes cuadraba ya no lo hace. Fijar los miembros del POCO vuelve a alinear ambos extremos.

Aquí tienes el POCO y la llamada, escritos como deberían verse en código de producción:

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 });
}

Dos nombres aquí son portantes en tiempo de ejecución, y ningún otro lo es. Los miembros públicos de Customer (Id, FullName, Email) se emparejan con las columnas del resultado. Los miembros del objeto de parámetros anónimo new { Id = id } se emparejan con el marcador @Id: Dapper lee el nombre de propiedad Id del objeto y lo enlaza a @Id, de nuevo por nombre. La clase CustomerRepository, el campo _db, el método GetById, el parámetro id: nada de eso se resuelve por nombre en tiempo de ejecución, así que todo ello puede renombrarse.

El enlace de parámetros tiene la misma dependencia de nombre

El lado de los parámetros es fácil de olvidar porque es menos visible. Cuando pasas new { Id = id }, Dapper genera un parámetro llamado Id a partir del nombre del miembro y lo empareja con el token @Id del SQL por nombre. Renombra ese miembro y el marcador @Id no tiene valor que enlazar, lo cual sí lanza una excepción (un parámetro ausente no es silencioso como lo es una columna ausente), pero sigue siendo una rotura inducida por el renombrado. Para los tipos anónimos esto no suele ser un problema en la práctica, porque los nombres de propiedad de los tipos anónimos del compilador de C# no son algo que la mayoría de los ofuscadores renombre por defecto; pero si usas una clase de parámetros con nombre, sus miembros públicos son exactamente tan frágiles como un POCO de resultado y también pertenecen a la keep-list.

La keep-list: fija los miembros mapeados, renombra el resto

La fase de renombrado de Nebula se dirige mediante nebula.config.json. La estrategia es activar el renombrado globalmente y luego recortar el pequeño conjunto de miembros que Dapper resuelve por nombre. La unidad más limpia para fijar es el tipo POCO (y sus miembros); los repositorios y ayudantes se dejan al renombrado por defecto.

{
  "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 }
}

El ámbito "members": "public" es el detalle importante: preserva los nombres de propiedad y de campo públicos que lee Dapper, al tiempo que permite a Nebula renombrar los campos de respaldo privados, que el mapeador por defecto de Dapper nunca toca. Si guardas tus POCO en un espacio de nombres dedicado (Acme.Data.Models arriba), la regla de espacio de nombres fija toda la familia en una sola entrada, de modo que un nuevo resultado de consulta añadido más tarde queda cubierto sin cambiar la configuración.

Si usas una clase de parámetros con nombre en lugar de tipos anónimos, fíjala del mismo modo:

{
  "type": "Acme.Data.Parameters.CustomerQuery",
  "members": "public",
  "reason": "Dapper binds @-parameters to these members by name"
}

Todo lo que no se nombre en keep se renombra. CustomerRepository, tu fábrica de conexiones, cualquier ayudante interno de construcción de SQL: sus identificadores no portan ningún significado en tiempo de ejecución para Dapper, así que se convierten en a, b, c y tu ensamblado protegido mantiene su lógica opaca mientras su mapeo de datos sigue funcionando.

La vía de escape: desacopla los nombres con un mapa de tipos personalizado

Si prefieres no conservar ningún nombre —maximizando la superficie de renombrado incluso en tus POCO—, Dapper te da una forma de hacer explícito el enlace columna-miembro, de modo que deje de depender del nombre del miembro. SqlMapper.SetTypeMap instala un mapeador por tipo, y CustomPropertyTypeMap te deja resolver cada columna a un miembro mediante un delegado. Haz que ese delegado se guíe por un atributo al estilo [Column] en lugar del nombre del miembro:

[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)))));

Ahora el enlace pasa por la cadena "FullName" del atributo, que es un literal que Nebula cifra pero nunca renombra, exactamente como el nombre de columna del SQL. El miembro que decora queda libre: Nebula puede renombrar FullName a a y el mapa personalizado sigue resolviendo la columna FullName hacia él, porque la clave de resolución es el valor del atributo, no el identificador del miembro. Conserva los miembros del atributo que usa la reflexión (ColumnAttribute.Name y el tipo) en la keep-list, ya que el delegado los lee, y podrás quitar por completo los nombres de propiedad del POCO de keep.

Esta es la disyuntiva entre lo robusto y lo cómodo. Conservar los nombres de los miembros (el primer enfoque) es más sencillo, no requiere cambios de código y deja legibles unos pocos identificadores públicos. El mapa personalizado oculta incluso esos identificadores, pero añade un registro que no debes olvidar y un pequeño coste reflexivo en la primera materialización. Para la mayoría del código de producción la keep-list es el valor por defecto correcto; recurre al mapa personalizado cuando los nombres de propiedad de un POCO concreto sean en sí mismos sensibles.

Ejecutar la protección e integrarla en la compilación

Con la configuración lista, proteger el ensamblado es un solo comando:

nebula protect --config nebula.config.json --verbose

Nebula lee el ensamblado de entrada, aplica el renombrado respetando tu keep-list, cifra las cadenas, aplana el flujo de control y escribe el ensamblado protegido en lugar de la entrada (o en la ruta output si la configuras). La opción --verbose imprime el resumen del mapa de renombrado, que vale la pena revisar una vez para confirmar que los miembros de tu POCO aparecen como conservados y los tipos de tu repositorio aparecen como renombrados.

Para automatizarlo, engánchalo tras la publicación con un objetivo de MSBuild, de modo que cada compilación publicada quede protegida sin un paso manual:

<Target Name="NebulaProtect" AfterTargets="Publish">
  <Exec Command="nebula protect --config $(MSBuildProjectDirectory)/nebula.config.json --input $(PublishDir)Acme.Data.dll"
        WorkingDirectory="$(MSBuildProjectDirectory)" />
</Target>

Como el objetivo se ejecuta con AfterTargets="Publish", opera sobre la salida publicada en lugar de sobre la compilación intermedia, de modo que tus compilaciones de depuración del bucle interno siguen siendo rápidas y sin ofuscar, mientras que las publicaciones de release entregan binarios protegidos. (Si tienes una licencia Nebula Pro, la protección de flujo de control y de cadenas se ejecuta sobre la salida publicada según la configuración; el nivel gratuito aplica la fase de renombrado, que es la parte de la que depende este artículo.)

Verifica con Glass antes de entregar

El último paso es confirmar que el renombrado hizo de verdad lo que pretendía la keep-list: que los miembros frágiles sobrevivieron y el resto no. Abre el ensamblado protegido en Glass, el decompilador .NET gratuito, y fíjate en dos cosas. Primero, tu POCO: Customer debería seguir mostrando Id, FullName, Email como propiedades públicas con sus nombres originales; eso es lo que demuestra que Dapper seguirá enlazando las columnas. Segundo, un repositorio: CustomerRepository y sus miembros deberían aparecer renombrados a identificadores cortos y opacos, lo que demuestra que el resto de la capa de datos se protegió de verdad en lugar de haber sido arrastrado por accidente a la keep-list.

Si ves una propiedad del POCO renombrada, Dapper devolverá nulos para ella en silencio en tiempo de ejecución, así que esta pasada con el decompilador no es opcional: es el sustituto más barato posible de la excepción que Dapper nunca lanza. Un ciclo rápido —proteger, decompilar, revisar a ojo las dos formas y luego ejecutar una prueba de integración que lea una fila de vuelta y verifique que todas las columnas están pobladas— convierte una clase de errores silenciosos de producción en algo que atrapas en la misma compilación que los produjo.

La forma de la regla

El contrato de Dapper con tu código es uno pequeño y basado en nombres: las columnas del resultado se enlazan a los miembros públicos del POCO por nombre, y los miembros de los parámetros anónimos o con nombre se enlazan a los @marcadores por nombre. Esos son los únicos nombres que resuelve en tiempo de ejecución, y son los únicos que debes fijar. Todo lo demás en una capa de acceso a datos —los repositorios, la gestión de conexiones, la construcción de consultas— no tiene tal dependencia y es libre de ser renombrado, cifrado en sus cadenas y aplanado en su flujo de control como cualquier otro código que estés protegiendo. Fija los miembros mapeados en nebula.config.json, o desacóplalos con un mapa de tipos personalizado, verifica el resultado en Glass y entregarás una capa de datos opaca para quien haga ingeniería inversa y transparente para Dapper.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.