Доступ до даних у. NET: порівняно ORM і стратегія створення карт (частина 2 - Dapper, Raw SQL і Hybrid Goptions) (Українська (Ukrainian))

Доступ до даних у. NET: порівняно ORM і стратегія створення карт (частина 2 - Dapper, Raw SQL і Hybrid Goptions)

Wednesday, 03 December 2025

//

20 minute read

Ласкаво просимо до другої частини нашої всебічної напрямної для доступу до даних у.NET! In Частина 1Ми глибоко дослідили основу функціонування сутності, включаючи створення SQL, спільні пастки, і цю критичну пересторогу щодо проксі та кешування.

У цій статті ми розглянемо легші альтернативи і як комбінувати декілька підходів для оптимальної продуктивності:

  • Dapper: солодке місце для мікро- ORM
  • Raw ADO. NET /Npgsql: Максимальна швидкодія і контроль
  • Бібліотеки, що розташовують об' єкти: Mapster всunit description in lists AutoMapper
  • Підходи гібриду: Об' єднання EF Core і Dapper (взір CQRS)
  • Швидкодія- Бенхмарки: Порівняння у реальному світі
  • Матриця визначення: Виберіть правильний інструмент для вашого сценарію

Зміст

Dapper: The Micro-ORM

Dapper Це легкий, високоефективний мікро- комп' ютер, створений за допомогою StackOverflow. Він забезпечує тонкий шар над ADO. NET, що виконує стомливий процес створення результатів запиту до об' єктів і надає вам повний контроль SQL.

Чому " даппер " існує

Dapper був народжений з потреб у високоефективному доступі до даних. Команда виявила, що повна ORM на зразок Framey сутностей (Pre- Core) додала забагато над головою для їх високоекранних сценаріїв. Dapper надає вам 95% зручності з лише 5- 15% над сирою АДО. NET.

Можливості ключів

  • Raw SQL: Ви самі написали SQL - повний контроль
  • Висока швидкодія: Мінімальна над головою ADO.NET (5-15%)
  • Мульти- сонComment: прив' язати складні запити до декількох пов' язаних типів
  • Обробка параметрів: Автоматичне параметризування запобігає впорскування SQL
  • Підтримка операцій: Повне керування операціями
  • Простота: Без конфігурації, без стеження за змінами, без магії
  • Async/Await: Повна асинхронна підтримка сучасного .NET

Основні приклади даппер

using Npgsql;
using Dapper;

public class DapperBlogRepository
{
    private readonly string _connectionString;

    public DapperBlogRepository(string connectionString)
    {
        _connectionString = connectionString;

        // Configure Dapper to work with PostgreSQL naming conventions
        DefaultTypeMap.MatchNamesWithUnderscores = true;
    }

    // Simple query
    public async Task<IEnumerable<BlogPost>> GetRecentPostsAsync(int count)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT id, title, content, tags, published_date, category_id
            FROM blog_posts
            ORDER BY published_date DESC
            LIMIT @Count";

        return await connection.QueryAsync<BlogPost>(sql, new { Count = count });
    }

    // Query with WHERE clause
    public async Task<BlogPost> GetPostByIdAsync(int id)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT id, title, content, published_date
            FROM blog_posts
            WHERE id = @Id";

        return await connection.QueryFirstOrDefaultAsync<BlogPost>(sql, new { Id = id });
    }

    // Insert with returning ID
    public async Task<int> CreatePostAsync(BlogPost post)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            INSERT INTO blog_posts (title, content, published_date, category_id)
            VALUES (@Title, @Content, @PublishedDate, @CategoryId)
            RETURNING id";

        return await connection.ExecuteScalarAsync<int>(sql, post);
    }

    // Update
    public async Task UpdatePostAsync(BlogPost post)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            UPDATE blog_posts
            SET title = @Title,
                content = @Content,
                published_date = @PublishedDate
            WHERE id = @Id";

        await connection.ExecuteAsync(sql, post);
    }

    // Delete
    public async Task DeletePostAsync(int id)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = "DELETE FROM blog_posts WHERE id = @Id";

        await connection.ExecuteAsync(sql, new { Id = id });
    }
}

Multi- Mapping: Обробка приєднання

Однією з найпотужніших можливостей Dapper є багатоспоживання - ефективного керування об' єднанням і відображенням до декількох пов' язаних об' єктів:

public async Task<IEnumerable<BlogPost>> GetPostsWithCategoryAsync()
{
    using var connection = new NpgsqlConnection(_connectionString);

    const string sql = @"
        SELECT
            p.id, p.title, p.content, p.published_date,
            c.id, c.name, c.description
        FROM blog_posts p
        INNER JOIN categories c ON p.category_id = c.id
        ORDER BY p.published_date DESC";

    return await connection.QueryAsync<BlogPost, Category, BlogPost>(
        sql,
        (post, category) =>
        {
            post.Category = category;
            return post;
        },
        splitOn: "id"  // Split at the second "id" column
    );
}

// More complex: Posts with comments
public async Task<IEnumerable<BlogPost>> GetPostsWithCommentsAsync()
{
    using var connection = new NpgsqlConnection(_connectionString);

    const string sql = @"
        SELECT
            p.id, p.title, p.content,
            c.id, c.author, c.content, c.created_at
        FROM blog_posts p
        LEFT JOIN comments c ON p.id = c.blog_post_id
        ORDER BY p.published_date DESC, c.created_at";

    var postDict = new Dictionary<int, BlogPost>();

    await connection.QueryAsync<BlogPost, Comment, BlogPost>(
        sql,
        (post, comment) =>
        {
            if (!postDict.TryGetValue(post.Id, out var existingPost))
            {
                existingPost = post;
                existingPost.Comments = new List<Comment>();
                postDict.Add(post.Id, existingPost);
            }

            if (comment != null)
            {
                existingPost.Comments.Add(comment);
            }

            return existingPost;
        },
        splitOn: "id"
    );

    return postDict.Values;
}

Додаткові технології Dapper

Динамічні параметри для складних питань:

public async Task<IEnumerable<BlogPost>> SearchWithDynamicFiltersAsync(SearchCriteria criteria)
{
    using var connection = new NpgsqlConnection(_connectionString);

    var parameters = new DynamicParameters();
    var conditions = new List<string>();

    var sql = new StringBuilder("SELECT * FROM blog_posts");

    if (!string.IsNullOrEmpty(criteria.SearchTerm))
    {
        conditions.Add("search_vector @@ to_tsquery('english', @SearchTerm)");
        parameters.Add("SearchTerm", criteria.SearchTerm);
    }

    if (criteria.CategoryIds?.Any() == true)
    {
        conditions.Add("category_id = ANY(@CategoryIds)");
        parameters.Add("CategoryIds", criteria.CategoryIds);
    }

    if (criteria.FromDate.HasValue)
    {
        conditions.Add("published_date >= @FromDate");
        parameters.Add("FromDate", criteria.FromDate.Value);
    }

    if (criteria.Tags?.Any() == true)
    {
        conditions.Add("tags && @Tags");  // PostgreSQL array overlap
        parameters.Add("Tags", criteria.Tags);
    }

    if (conditions.Any())
    {
        sql.Append(" WHERE ");
        sql.Append(string.Join(" AND ", conditions));
    }

    sql.Append(" ORDER BY published_date DESC LIMIT @Limit");
    parameters.Add("Limit", criteria.Limit);

    return await connection.QueryAsync<BlogPost>(sql.ToString(), parameters);
}

Нетипові обробники типів для типів PostgreSQL:

// Handle PostgreSQL arrays
public class PostgresArrayTypeHandler : SqlMapper.TypeHandler<string[]>
{
    public override void SetValue(IDbDataParameter parameter, string[] value)
    {
        parameter.Value = value;
        ((NpgsqlParameter)parameter).NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text;
    }

    public override string[] Parse(object value)
    {
        return (string[])value;
    }
}

// Handle PostgreSQL JSONB
public class JsonTypeHandler<T> : SqlMapper.TypeHandler<T>
{
    public override void SetValue(IDbDataParameter parameter, T value)
    {
        parameter.Value = JsonSerializer.Serialize(value);
        ((NpgsqlParameter)parameter).NpgsqlDbType = NpgsqlDbType.Jsonb;
    }

    public override T Parse(object value)
    {
        return JsonSerializer.Deserialize<T>(value.ToString());
    }
}

// Register handlers (in startup)
SqlMapper.AddTypeHandler(new PostgresArrayTypeHandler());
SqlMapper.AddTypeHandler(new JsonTypeHandler<Dictionary<string, object>>());

Місткенькі операції за допомогою COPY PostgreSQL:

public async Task BulkInsertPostsAsync(IEnumerable<BlogPost> posts)
{
    using var connection = new NpgsqlConnection(_connectionString);
    await connection.OpenAsync();

    using var writer = await connection.BeginBinaryImportAsync(
        "COPY blog_posts (title, content, tags, published_date) FROM STDIN (FORMAT BINARY)"
    );

    foreach (var post in posts)
    {
        await writer.StartRowAsync();
        await writer.WriteAsync(post.Title);
        await writer.WriteAsync(post.Content);
        await writer.WriteAsync(post.Tags, NpgsqlDbType.Array | NpgsqlDbType.Text);
        await writer.WriteAsync(post.PublishedDate);
    }

    await writer.CompleteAsync();
}

Підтримка операцій:

public async Task TransferPostToCategoryAsync(int postId, int newCategoryId)
{
    using var connection = new NpgsqlConnection(_connectionString);
    await connection.OpenAsync();

    using var transaction = await connection.BeginTransactionAsync();

    try
    {
        // Update the post
        await connection.ExecuteAsync(
            "UPDATE blog_posts SET category_id = @CategoryId WHERE id = @PostId",
            new { CategoryId = newCategoryId, PostId = postId },
            transaction
        );

        // Log the change
        await connection.ExecuteAsync(
            @"INSERT INTO category_history (post_id, category_id, changed_at)
              VALUES (@PostId, @CategoryId, @ChangedAt)",
            new { PostId = postId, CategoryId = newCategoryId, ChangedAt = DateTime.UtcNow },
            transaction
        );

        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

Коли користуватися даппером

⇩ Використовувати rapper When:

  • Швидкодія є критичною, але вам не потрібна абсолютна максимальна швидкість
  • Вам зручно писати SQL
  • У вас є складні запити, які погано відносяться до LINQ
  • Вам потрібен чудовий контроль над створенням SQL
  • Ви працюєте з існуючою схемою бази даних
  • Вам потрібні мінімальні надкладні та залежності
  • Використання пам' яті є турботою
  • Вам потрібно широко використовувати специфічні для баз даних можливості

▸ Уникайте переливки, коли:

  • Ваша команда незадоволена SQL
  • Вам потрібне автоматичне стеження за змінами
  • Ви хочете, щоб схема мігрувала як код
  • У вас схема швидкого розвитку
  • Вам потрібна портивність між базами даних
  • Ви надаєте перевагу LINQ над синтаксисом SQL

Символи швидкодії

  • Швидкодія запиту: 5-15% над сирим ADO.NET
  • Використання пам' яті: Мінімальний - без стеження за змінами або проксі
  • Місткенькі дії: Чудово з COPY PostgreSQL
  • Перший запит: Швидкий - без кроку збирання
  • Продуктність розробника: Потрібні знання SQL, але дуже передбачувані

Raw ADO. NET з Npgsql

Для абсолютної максимальної швидкодії і керування ви можете скористатися Npgsql безпосередньо без шару ORM.

When Raw ADO. NET

Raw ADO. NET підходить, якщо:

  • Вам потрібні кожну мілісекунду швидкодії
  • Ви створюєте високопрозорі обробники даних
  • Ви працюєте з особливостями PostgreSQL, які не підтримуються ORM
  • Вам потрібен точний контроль над розподілом пам'яті
  • Ви виконуєте операції з масками або обтікання великих наборів даних

Приклад: Чистий Npgsql

public class NpgsqlBlogRepository
{
    private readonly string _connectionString;

    public async Task<List<BlogPost>> GetRecentPostsAsync(int count)
    {
        var posts = new List<BlogPost>();

        using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        using var command = new NpgsqlCommand(
            "SELECT id, title, content, tags, published_date FROM blog_posts ORDER BY published_date DESC LIMIT @count",
            connection
        );

        command.Parameters.AddWithValue("count", count);

        using var reader = await command.ExecuteReaderAsync();

        while (await reader.ReadAsync())
        {
            posts.Add(new BlogPost
            {
                Id = reader.GetInt32(0),
                Title = reader.GetString(1),
                Content = reader.GetString(2),
                Tags = reader.GetFieldValue<string[]>(3),
                PublishedDate = reader.GetDateTime(4)
            });
        }

        return posts;
    }

    // Using prepared statements for repeated queries
    public async Task<BlogPost> GetPostByIdAsync(int id)
    {
        using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        using var command = new NpgsqlCommand(
            "SELECT id, title, content FROM blog_posts WHERE id = $1",
            connection
        );

        command.Parameters.AddWithValue(id);
        await command.PrepareAsync(); // Prepared statement for performance

        using var reader = await command.ExecuteReaderAsync();

        if (await reader.ReadAsync())
        {
            return new BlogPost
            {
                Id = reader.GetInt32(0),
                Title = reader.GetString(1),
                Content = reader.GetString(2)
            };
        }

        return null;
    }

    // Working with PostgreSQL JSONB
    public async Task<Dictionary<string, object>> GetPostMetadataAsync(int id)
    {
        using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        using var command = new NpgsqlCommand(
            "SELECT metadata FROM blog_posts WHERE id = $1",
            connection
        );

        command.Parameters.AddWithValue(id);

        var json = await command.ExecuteScalarAsync() as string;
        return JsonSerializer.Deserialize<Dictionary<string, object>>(json);
    }

    // Streaming large result sets
    public async IAsyncEnumerable<BlogPost> StreamAllPostsAsync()
    {
        using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        using var command = new NpgsqlCommand(
            "SELECT id, title, content FROM blog_posts ORDER BY id",
            connection
        );

        using var reader = await command.ExecuteReaderAsync();

        while (await reader.ReadAsync())
        {
            yield return new BlogPost
            {
                Id = reader.GetInt32(0),
                Title = reader.GetString(1),
                Content = reader.GetString(2)
            };
        }
    }
}

Символи швидкодії

  • Швидкодія запиту: Базова лінія - найшвидша можлива
  • Використання пам' яті: Найнижчий - повний контроль над розподілами
  • Місткенькі дії: Чудово з протоколом COPY
  • Продуктність розробника: Вимагає більше ручної роботи

Бібліотеки, що розташовують об' єкти

Під час роботи з Dapper або raw ADO. NET, вам часто потрібно створити карту між різними представленнями об' єктів (DTO, елементами, моделями перегляду). Це можна зробити за допомогою декількох бібліотек.

Карта: карти високої відповідності

Mapster Це швидкий, заснований на конвенціях об'єктний картограф, який використовує створення джерел для оптимальної швидкодії.

// Install: Mapster and Mapster.Tool
using Mapster;

public class BlogPostDto
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Summary { get; set; }
    public List<string> CategoryNames { get; set; }
}

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public List<Category> Categories { get; set; }
}

// Configuration
public class MappingConfig : IRegister
{
    public void Register(TypeAdapterConfig config)
    {
        config.NewConfig<BlogPost, BlogPostDto>()
            .Map(dest => dest.Summary, src => src.Content.Substring(0, Math.Min(200, src.Content.Length)))
            .Map(dest => dest.CategoryNames, src => src.Categories.Select(c => c.Name).ToList());

        // Reverse map with ignore
        config.NewConfig<BlogPostDto, BlogPost>()
            .Ignore(dest => dest.Content);
    }
}

// Registration in Program.cs
TypeAdapterConfig.GlobalSettings.Scan(Assembly.GetExecutingAssembly());

// Usage with Dapper
public class BlogService
{
    private readonly string _connectionString;

    public async Task<List<BlogPostDto>> GetPostsAsync()
    {
        using var connection = new NpgsqlConnection(_connectionString);

        var posts = await connection.QueryAsync<BlogPost>(@"
            SELECT p.id, p.title, p.content
            FROM blog_posts p
        ");

        // Map to DTOs - very fast with Mapster
        return posts.Adapt<List<BlogPostDto>>();
    }

    // Projection mapping (compile-time)
    public async Task<List<BlogPostDto>> GetPostsDtosDirectlyAsync()
    {
        using var connection = new NpgsqlConnection(_connectionString);

        // Query directly to DTO shape
        return (await connection.QueryAsync<BlogPostDto>(@"
            SELECT
                id,
                title,
                SUBSTRING(content, 1, 200) as summary
            FROM blog_posts
        ")).ToList();
    }
}

AutoMapper: Зріла базована на конвенціях карта

AutoMapper Це найпопулярніша бібліотека карт, хоча і повільніша за Mapster.

// Install: AutoMapper and AutoMapper.Extensions.Microsoft.DependencyInjection
using AutoMapper;

public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<BlogPost, BlogPostDto>()
            .ForMember(d => d.Summary, opt => opt.MapFrom(s =>
                s.Content.Length > 200 ? s.Content.Substring(0, 200) : s.Content))
            .ForMember(d => d.CategoryNames, opt => opt.MapFrom(s =>
                s.Categories.Select(c => c.Name)));

        // Reverse map
        CreateMap<BlogPostDto, BlogPost>()
            .ForMember(d => d.Content, opt => opt.Ignore());
    }
}

// Registration in Program.cs
services.AddAutoMapper(typeof(MappingProfile));

// Usage
public class BlogService
{
    private readonly IMapper _mapper;
    private readonly string _connectionString;

    public BlogService(IMapper mapper, IConfiguration configuration)
    {
        _mapper = mapper;
        _connectionString = configuration.GetConnectionString("DefaultConnection");
    }

    public async Task<List<BlogPostDto>> GetPostsAsync()
    {
        using var connection = new NpgsqlConnection(_connectionString);

        var posts = await connection.QueryAsync<BlogPost>(@"
            SELECT id, title, content FROM blog_posts
        ");

        return _mapper.Map<List<BlogPostDto>>(posts.ToList());
    }
}

Ручне відображення: повне керування

Іноді найкращим підходом є чітке відображення вручну:

public static class BlogPostMapper
{
    public static BlogPostDto ToDto(this BlogPost post)
    {
        return new BlogPostDto
        {
            Id = post.Id,
            Title = post.Title,
            Summary = post.Content.Length > 200
                ? post.Content.Substring(0, 200) + "..."
                : post.Content,
            CategoryNames = post.Categories?.Select(c => c.Name).ToList() ?? new List<string>()
        };
    }

    public static List<BlogPostDto> ToDtoList(this IEnumerable<BlogPost> posts)
    {
        return posts.Select(p => p.ToDto()).ToList();
    }

    // Inline mapping for simple cases
    public static BlogPostDto MapToDto(BlogPost post) => new()
    {
        Id = post.Id,
        Title = post.Title,
        Summary = post.Content[..Math.Min(200, post.Content.Length)]
    };
}

// Usage
var posts = await _repository.GetAllPostsAsync();
var dtos = posts.ToDtoList();

Порівняння швидкодії відображення

BenchmarkDotNet Results (mapping 1000 objects):

Method              | Mean      | Allocated
--------------------|-----------|----------
Manual Mapping      | 45.2 μs   | 78 KB
Mapster             | 52.1 μs   | 79 KB
AutoMapper          | 184.3 μs  | 156 KB

Захоплення ключів:

  • Приписування вручну найшвидше, але потребує більше коду
  • Mapster майже такий же швидкий з меншим кодом
  • AutoMapper є зручним, але має вищий над головою над головою

Гибридський підхід: найліпший з обох світів

У реальних програмах ви часто бажаєте використовувати різні підходи до різних сценаріїв у одній програмі. Ось приклад: рекомендований підхід для більшості виробничих систем.

Шаблон CQRS: розділювачі читання і запис

Шаблон CQRS (Command Processed Segregation) є природним підходом для доступу до гібридних даних. Для глибшого занурення у CQRS і події за допомогою МартенCity in Germany, див. мою статтю Сучасний CQRS і розвиток подій.

Як Мартен розказує цю дискусію:

Marten - це база даних документів і сховища подій, побудованих на PostgreSQL, за допомогою яких можна отримати гібридний доступ до даних іншого рівня. Програма комбінує:

  • Адміністрація події для запису (змінні потоки подій)
  • Проекції для читання (матеріалізовані перегляди, оптимізовані для запитів)
  • PostgreSQL's JSONB для зберігання документів
  • Вбудовані шаблони CQRS

Хоча у цій статті йдеться про традиційний реляційний доступ до даних (EF Core, Dapper), Мартен показує, як можна використати додаткові можливості PostgreSQL (JSONB, потоки даних подій) для реалізації складних архітектур. Ці принципи однакові:

  • Відокремлювати моделі запису (оптимізовані для операцій і послідовності)
  • Відокремлювати моделі читання (оптимізовані для запитів і швидкодії)
  • Використовувати правильний інструмент для кожного завдання
graph TB
    Client[Client Application]

    subgraph "Write Side - Commands"
        WriteAPI[Write API / Commands]
        EFCore[EF Core Context]
        WriteDB[(PostgreSQL<br/>Write Operations)]
    end

    subgraph "Read Side - Queries"
        ReadAPI[Read API / Queries]
        Dapper[Dapper Repository]
        ReadDB[(PostgreSQL<br/>Read Operations)]
    end

    Client -->|Create/Update/Delete| WriteAPI
    WriteAPI --> EFCore
    EFCore -->|Change Tracking<br/>Validation<br/>Business Logic| WriteDB

    Client -->|Query/Search| ReadAPI
    ReadAPI --> Dapper
    Dapper -->|Optimized SQL<br/>DTOs<br/>No Tracking| ReadDB

    WriteDB -.->|Same Database| ReadDB

    style Client stroke:#6366f1,stroke-width:2px
    style WriteAPI stroke:#2563eb,stroke-width:2px
    style EFCore stroke:#2563eb,stroke-width:2px
    style WriteDB stroke:#2563eb,stroke-width:2px
    style ReadAPI stroke:#059669,stroke-width:2px
    style Dapper stroke:#059669,stroke-width:2px
    style ReadDB stroke:#059669,stroke-width:2px

За допомогою цього шаблона можна використовувати:

  • Корінь EF для запису: Зміна стеження, перевірки, ділові правила
  • Dapper для читання: Максимальна швидкодія запитів і гнучкість
  • Та сама база даних, різні шаблони доступу, оптимізовані для кожного з випадків використання

Реалізація: CQRS з EF Core і Dapper

// Commands: Use EF Core for change tracking and validation
public class BlogCommandService
{
    private readonly BlogDbContext _context;
    private readonly ILogger<BlogCommandService> _logger;

    public BlogCommandService(BlogDbContext context, ILogger<BlogCommandService> logger)
    {
        _context = context;
        _logger = logger;
    }

    public async Task<int> CreatePostAsync(CreatePostCommand command)
    {
        // Business logic and validation
        var post = new BlogPost
        {
            Title = command.Title,
            Content = command.Content,
            CategoryId = command.CategoryId,
            PublishedDate = DateTime.UtcNow
        };

        _context.BlogPosts.Add(post);
        await _context.SaveChangesAsync();

        _logger.LogInformation("Created blog post {PostId}", post.Id);

        return post.Id;
    }

    public async Task UpdatePostAsync(UpdatePostCommand command)
    {
        var post = await _context.BlogPosts.FindAsync(command.Id);

        if (post == null)
            throw new InvalidOperationException($"Post {command.Id} not found");

        post.Title = command.Title;
        post.Content = command.Content;
        post.UpdatedAt = DateTime.UtcNow;

        await _context.SaveChangesAsync();

        _logger.LogInformation("Updated blog post {PostId}", post.Id);
    }

    public async Task DeletePostAsync(int id)
    {
        var post = await _context.BlogPosts.FindAsync(id);

        if (post != null)
        {
            _context.BlogPosts.Remove(post);
            await _context.SaveChangesAsync();

            _logger.LogInformation("Deleted blog post {PostId}", id);
        }
    }
}

// Queries: Use Dapper for read performance
public class BlogQueryService
{
    private readonly string _connectionString;
    private readonly ILogger<BlogQueryService> _logger;

    public BlogQueryService(IConfiguration configuration, ILogger<BlogQueryService> logger)
    {
        _connectionString = configuration.GetConnectionString("DefaultConnection");
        _logger = logger;
    }

    public async Task<BlogPostDto> GetPostBySlugAsync(string slug)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT
                p.id,
                p.title,
                p.slug,
                p.content,
                p.published_date,
                c.id as category_id,
                c.name as category_name,
                (SELECT COUNT(*) FROM comments WHERE blog_post_id = p.id) as comment_count
            FROM blog_posts p
            INNER JOIN categories c ON p.category_id = c.id
            WHERE p.slug = @Slug";

        var post = await connection.QueryFirstOrDefaultAsync<BlogPostDto>(sql, new { Slug = slug });

        if (post != null)
        {
            _logger.LogInformation("Retrieved blog post by slug {Slug}", slug);
        }

        return post;
    }

    public async Task<PagedResult<BlogPostSummaryDto>> GetRecentPostsAsync(int page, int pageSize)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT
                p.id,
                p.title,
                p.slug,
                LEFT(p.content, 200) as summary,
                p.published_date,
                c.name as category_name
            FROM blog_posts p
            INNER JOIN categories c ON p.category_id = c.id
            ORDER BY p.published_date DESC
            LIMIT @PageSize OFFSET @Offset";

        const string countSql = "SELECT COUNT(*) FROM blog_posts";

        var posts = await connection.QueryAsync<BlogPostSummaryDto>(
            sql,
            new { PageSize = pageSize, Offset = (page - 1) * pageSize }
        );

        var totalCount = await connection.ExecuteScalarAsync<int>(countSql);

        return new PagedResult<BlogPostSummaryDto>
        {
            Items = posts.ToList(),
            TotalCount = totalCount,
            Page = page,
            PageSize = pageSize
        };
    }

    public async Task<List<BlogPostDto>> SearchPostsAsync(string searchTerm)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT
                p.id,
                p.title,
                p.slug,
                p.content,
                p.published_date,
                c.name as category_name,
                ts_rank(p.search_vector, query) as relevance_score
            FROM blog_posts p
            INNER JOIN categories c ON p.category_id = c.id,
                 to_tsquery('english', @SearchTerm) query
            WHERE p.search_vector @@ query
            ORDER BY relevance_score DESC
            LIMIT 50";

        var posts = await connection.QueryAsync<BlogPostDto>(sql, new { SearchTerm = searchTerm });

        _logger.LogInformation(
            "Searched posts with term {SearchTerm}, found {Count} results",
            searchTerm,
            posts.Count()
        );

        return posts.ToList();
    }
}

// Service layer orchestrating commands and queries
public class BlogService
{
    private readonly BlogCommandService _commands;
    private readonly BlogQueryService _queries;

    public BlogService(BlogCommandService commands, BlogQueryService queries)
    {
        _commands = commands;
        _queries = queries;
    }

    // Write operations delegate to command service
    public Task<int> CreatePostAsync(CreatePostCommand command) => _commands.CreatePostAsync(command);
    public Task UpdatePostAsync(UpdatePostCommand command) => _commands.UpdatePostAsync(command);
    public Task DeletePostAsync(int id) => _commands.DeletePostAsync(id);

    // Read operations delegate to query service
    public Task<BlogPostDto> GetPostBySlugAsync(string slug) => _queries.GetPostBySlugAsync(slug);
    public Task<PagedResult<BlogPostSummaryDto>> GetRecentPostsAsync(int page, int pageSize)
        => _queries.GetRecentPostsAsync(page, pageSize);
    public Task<List<BlogPostDto>> SearchPostsAsync(string searchTerm)
        => _queries.SearchPostsAsync(searchTerm);
}

Корінь EF з періодичним сиром SQL

Для програм, які є головним чином ECF Core, але потребують оптимізації швидкодії час від часу:

public class BlogService
{
    private readonly BlogDbContext _context;

    // 95% of queries: Use EF Core LINQ
    public async Task<List<BlogPost>> GetPostsByCategoryAsync(int categoryId)
    {
        return await _context.BlogPosts
            .Where(p => p.CategoryId == categoryId)
            .Include(p => p.Comments)
            .ToListAsync();
    }

    // 5% of queries: Use raw SQL for complex analytics
    public async Task<List<PostAnalytics>> GetPostAnalyticsAsync()
    {
        using var connection = _context.Database.GetDbConnection();
        await _context.Database.OpenConnectionAsync();

        using var command = connection.CreateCommand();
        command.CommandText = @"
            WITH post_metrics AS (
                SELECT
                    p.id,
                    p.title,
                    COUNT(DISTINCT c.id) as comment_count,
                    COUNT(DISTINCT v.id) as view_count,
                    AVG(c.sentiment_score) as avg_sentiment
                FROM blog_posts p
                LEFT JOIN comments c ON p.id = c.post_id
                LEFT JOIN post_views v ON p.id = v.post_id
                WHERE p.published_date >= NOW() - INTERVAL '30 days'
                GROUP BY p.id, p.title
            )
            SELECT * FROM post_metrics
            ORDER BY view_count DESC";

        var analytics = new List<PostAnalytics>();
        using var reader = await command.ExecuteReaderAsync();

        while (await reader.ReadAsync())
        {
            analytics.Add(new PostAnalytics
            {
                PostId = reader.GetInt32(0),
                Title = reader.GetString(1),
                CommentCount = reader.GetInt64(2),
                ViewCount = reader.GetInt64(3),
                AverageSentiment = reader.IsDBNull(4) ? 0 : reader.GetDouble(4)
            });
        }

        return analytics;
    }
}

Порівняння швидкодії

Давайте поглянемо на місця виконання дій у реальному світі для спільних операцій з PostgreSQL:

Benchmark: читання 1000 Records

BenchmarkDotNet Results (Lower is Better):

Method                    | Mean       | Allocated
------------------------- |----------- |-----------
EF Core (No Tracking)    | 12.34 ms   | 2.4 MB
EF Core (With Tracking)  | 15.67 ms   | 4.8 MB
Dapper                   | 8.21 ms    | 1.8 MB
Raw Npgsql               | 7.45 ms    | 1.2 MB

Benchmark: додавання 1000 записів

Method                    | Mean       | Allocated
------------------------- |----------- |-----------
EF Core (SaveChanges)    | 245.3 ms   | 15.2 MB
EF Core (BulkInsert)     | 42.1 ms    | 8.4 MB
Dapper (Loop)            | 189.7 ms   | 2.1 MB
Npgsql COPY              | 18.3 ms    | 0.8 MB

Benchmark: Складне Приєднатися до запиту

Method                    | Mean       | Allocated
------------------------- |----------- |-----------
EF Core (Include)        | 28.5 ms    | 5.2 MB
EF Core (Split Query)    | 24.1 ms    | 4.8 MB
Dapper (Multi-Map)       | 16.8 ms    | 3.1 MB
Raw Npgsql               | 15.2 ms    | 2.4 MB

Захоплення ключів

  1. Raw Npgsql найшвидший але потребує найбільше коду
  2. Dapper пропонує чудову виставу з мінімальною вартістю абстракції (60- 70% швидшою за EF- ядро)
  3. ECF Core без запитів на стеження є прийнятним для більшості сценаріїв
  4. Місткіші операціїNoun, name of the user action показує найбільшу різницю між швидкодією (10- 13x)!
  5. Розподіл пам' яті слідувати подібному шаблону до часу виконання

Матриця рішень: те, що слід використовувати

Використовувати ядро EF, якщо:

  • ▸ Залучити нову програму з вимогами еволюціонування
  • біса- driven дизайн з багатою моделями сутностей
  • ♫ Вам потрібні міграції і керування схемами
  • ♫ Команда зручніша з C # ніж SQL.
  • Пропорція читання/ запису збалансована
  • ♫ Випромінювати в межах 20-50% оптимального є прийнятним
  • ⇩ Ви бажаєте змінити слідкування і одиницю робочого шаблона

Див. Частина 1 для повноцінного ECF Core.

Використовувати Dapper, якщо:

  • ⇩ Важливі, але не критичні
  • ♫ Ви маєте складні запити, які не дуже добре відносяться до LINQ
  • ♫ Вам зручно писати SQL
  • ▸ Вам потрібен чудовий контроль над створенням SQL
  • ⇩ Йти з вже існуючими схемами баз даних
  • біса Читати-важливі роботи з простими записами
  • ▸ Ви хочете мінімальну абстракцію над головою

Використовувати Raw Npgsql, якщо:

  • ♪ Максимальна швидкодія є критичною
  • Обробники даних з високою температурою
  • ведьми з специфічними для PostgreSQL особливостями
  • ⇩ Операції та імпорт мас
  • щосекунди і мегабайти
  • ▸ Вам потрібен абсолютний контроль

Гібридний підхід Коли:

  • ведь частини застосування мають різні потреби.
  • Шаблон CQRS (EF core для запису, Dapper для читання)
  • ♪Master використовується EF cile, але декілька потребують сирого SQL
  • ⇩ Ви бажаєте поступово перелітати між підходами
  • ведь, комплексні програми з вимогами иво

Резюме найліпших вправ

Загальні напрямні

  1. Почати з ядра EF для нових проектів, хіба що у вас специфічні вимоги до швидкодії
  2. Профіль перед оптимізацією - Не думайте, що вам потрібен Dapper/raw SQL
  3. Використовувати гібридні підходи - комбінувати сильні сторони різних інструментів
  4. Зберігати доступ до даних ізольованим - Шаблон сховища допомагає змінювати реалізацію
  5. Використовувати набір з' єднань - правильно налаштувати для навантаження на роботу
  6. Можливості PostgreSQL - не абстрагувати потужні можливості баз даних

Специфічні Dapper

  1. Використовувати параметризовані запити щоб запобігти ін'єкції SQL
  2. Розглянемо кеш результатів запиту для дорогих, повторних запитів
  3. Використовувати мультимедіа для приєднання замість декількох раундів
  4. Повторно використати з' єднання з набору з' єднання
  5. Візьмімо для прикладу Dapper.Contrib для простих операцій CRUD

Поради щодо швидкодії

  1. Індексувати ваші запити - Проаналізуйте повільні запити за допомогою EXLAIN ПРОЧИЩЕННЯ
  2. Використовувати підготовлені інструкції для повторних запитів
  3. Пакетні операціїSuccessful message after an user action якщо можливо
  4. Використовувати COPY для вставки оптових даних до PostgreSQL
  5. Монітор з' єднанняComment - налаштувати розмір набору min/ max
  6. Розгляньте копії для важкої роботи з читанням

Висновки

Вибір належного підходу до доступу до даних для вашої програми. NET з PostgreSQL не означає, що слід знайти " найкращий " інструмент, він про відповідність потрібному інструменту з вашими потребами:

  • Корінь EF (Дивіться Частина 1) У порівнянні з швидкими розробками, моделюванням доменів і програмами, за допомогою яких продуктивність розробки надає змогу працювати без обробки
  • Dapper надає чудовий середній рівень з майже нетиповою швидкодією і розсудливою абстракцією
  • Raw Npgsql забезпечує максимальну швидкодію і керування для операцій з наповненням даних
  • Створення карт бібліотек Наприклад, Mapster і AutoMapper зменшує кількість котел під час роботи з доступом до даних нижнього рівня

На практиці найуспішніші програми часто використовують гібридний підхід, застосовуючи сильні сторони кожного інструмента, якщо потрібно:

  • Користування Корінь EF для вашої доменної логіки і запису
  • Користування Dapper для складних запитів на читання і звітів
  • Користування raw Npgsql для аналітичних операцій та аналітичних операцій

Ключ:

  1. Зрозуміти ваші вимоги - швидкодія, швидкість розробки, навички команди
  2. Профіль вашої програми - ідентифікувати реальні вузькі місця, не враховані
  3. Виберіть прагматичний - Використовується найпростіший інструмент, який задовольняє ваші потреби
  4. Залишайтеся гнучкими - ви можете змішувати підходи в межах однієї програми

Пам'ятайте, що передчасна оптимізація є коренем всього зла, але так само є створенням системи, яка не може змінити масштабу, якщо потрібно. Почніть з простого, вимірюйте швидкодію і оптимізуйте те, що має значення.

Посилання і подальше читання

Частина 1 цієї серії:

Офіційна документація:

Супутні статті про цей блог:


Це підсумовує нашу подвійну серію даних про доступ до даних у.NET! Ми розглянули все: від потужних абстракцій EF Core до максимальної швидкодії SQL з практичним керівництвом щодо комбінованих підходів для оптимальних результатів.

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.