Skip to content
Go back

在 EF Core 与 PostgreSQL 中使用存储过程和函数

Published:  at  12:00 AM

在使用 Entity Framework Core 开发 .NET 应用时,大多数场景下 LINQ 查询都能满足需求。但当面对复杂的聚合查询、数据库特有功能或需要原子操作时,直接使用数据库的存储过程和函数往往是更明智的选择。本文将详细讲解如何在 EF Core 与 PostgreSQL 环境中有效使用这些数据库对象,并探讨其适用场景与最佳实践。

何时应该使用原生 SQL

虽然 LINQ 提供了类型安全和重构支持,但在某些场景下,直接编写 SQL 会是更好的选择:

性能优化需求:当面对包含多表连接、窗口函数或复杂聚合的报表查询时,手写 SQL 往往能获得更好的性能表现。你可以在数据库工具中直接测试和优化查询语句,然后再集成到代码中。

数据库特定功能:PostgreSQL 提供了许多强大的特性,如全文搜索、JSON 操作符和公共表表达式(CTE),这些功能在 LINQ 中并不总有对等的表达方式。此时直接使用 SQL 是最直接的路径。

遗留系统集成:如果数据库中已存在存储过程和函数(可能来自遗留系统),直接调用它们比用 C# 重写整个逻辑更加合理。

原子操作与锁控制:需要使用 FOR UPDATE 锁来协调多个更新操作时,在存储过程中实现比在应用层管理更简单也更安全。

减少网络往返:一个函数调用能从五个表中聚合数据,这比执行五次独立的 LINQ 查询要高效得多。

PostgreSQL 函数与存储过程的区别

PostgreSQL 对函数(Function)和存储过程(Procedure)有明确的区分,理解这种区别对于合理设计数据库逻辑至关重要。

函数设计用于返回值。它们可以返回标量值、表或复杂的 JSON 对象。你使用 SELECT 语句调用函数,并且可以像使用其他表达式一样在查询中使用它们。函数在事务内运行,可以用在 WHERE 子句、连接和其他查询上下文中。

存储过程设计用于产生副作用。它们不直接返回值,但可以通过 OUT 参数修改数据。你使用 CALL 语句调用存储过程,它们特别适合需要显式管理事务或执行多个相关更新的复杂操作。

简单来说:需要查询数据时使用函数,需要修改数据或执行复杂逻辑时使用存储过程。

这种区分会影响你如何设计数据库逻辑以及如何在 C# 中调用这些数据库对象。

实战示例:标量函数

让我们从一个简单的标量函数开始。下面的函数返回指定票种的剩余数量:

CREATE OR REPLACE FUNCTION ticketing.tickets_left(p_ticket_type_id uuid)
RETURNS numeric
LANGUAGE sql
AS $$
  SELECT tt.available_quantity
  FROM ticketing.ticket_types tt
  WHERE tt.id = p_ticket_type_id
$$;

这个函数非常简单,只是将一个查询封装在函数中。

在 EF Core 中调用这个函数同样直接:

app.MapGet("ticket-types/{ticketTypeId}/available-quantity",
async (Guid ticketTypeId, EventManagementContext dbContext) =>
{
    var result = await dbContext.Database.SqlQuery<int>(
            $"""
             SELECT ticketing.tickets_left({ticketTypeId}) AS "Value"
             """)
        .FirstAsync();

    return Results.Ok(result);
});

注意这里的 AS "Value" 别名。当 EF Core 映射到基元类型时,它期望有一个名为 Value 的属性。引号保留了精确的大小写(PostgreSQL 默认会将未加引号的标识符转换为小写)。

插值字符串语法($"{ticketTypeId}")看起来可能有安全风险,但实际上 EF Core 会自动将其转换为参数化查询。你并不是在拼接 SQL 字符串,而是使用 C# 插值作为参数的便捷语法。

实战示例:表值函数

函数可以返回完整的结果集,这正是它们真正强大的地方。下面的函数返回客户的订单汇总信息:

CREATE OR REPLACE FUNCTION ticketing.customer_order_summary(p_customer_id uuid)
RETURNS TABLE (
    order_id uuid,
    created_at_utc timestamptz,
    total_price numeric,
    currency text,
    item_count numeric
)
LANGUAGE sql
AS $$
SELECT
    o.id,
    o.created_at_utc,
    o.total_price,
    o.currency,
    COALESCE(SUM(oi.quantity), 0) AS item_count
FROM ticketing.orders o
LEFT JOIN ticketing.order_items oi ON oi.order_id = o.id
WHERE o.customer_id = p_customer_id
GROUP BY o.id, o.created_at_utc, o.total_price, o.currency
ORDER BY o.created_at_utc DESC
$$;

这个函数连接订单和订单项,聚合数量,并返回多行结果。虽然可以用 LINQ 编写,但 SQL 版本更清晰,而且可以直接在数据库工具中测试。

要在 C# 中使用它,首先创建一个与函数输出匹配的 DTO:

public class OrderSummaryDto
{
    public Guid OrderId { get; set; }
    public DateTime CreatedAtUtc { get; set; }
    public decimal TotalPrice { get; set; }
    public string Currency { get; set; }
    public int ItemCount { get; set; }
}

然后像查询普通表一样查询该函数:

app.MapGet("customers/{customerId}/order-summary",
async (Guid customerId, EventManagementContext dbContext) =>
{
    var orders = await dbContext.Database
        .SqlQuery<OrderSummaryDto>(
            $"""
             SELECT
                order_id AS OrderId,
                created_at_utc AS CreatedAtUtc,
                total_price AS TotalPrice,
                currency AS Currency,
                item_count AS ItemCount
             FROM ticketing.customer_order_summary({customerId})
             """)
        .ToListAsync();

    return Results.Ok(orders);
});

关键在于使用别名将列名映射到 DTO 属性。EF Core 会自动处理其余部分。

这是一个简单的无连接情况,但你也可以在更复杂的查询中使用这种模式。不过,你需要手动映射到 DTO,因为 EF Core 无法将原生 SQL 中的连接转换为实体图。通常情况下,函数会返回扁平结构,如有需要可以在 C# 中映射到更丰富的模型。

实战示例:带验证的存储过程

存储过程在处理复杂业务逻辑时真正展现其价值。假设需要调整票务库存,同时要防止竞态条件并验证操作:

CREATE OR REPLACE PROCEDURE ticketing.adjust_available_quantity(
    p_ticket_type_id uuid,
    p_delta numeric,
    p_reason text DEFAULT 'manual-adjust'
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_qty numeric;
    v_avail numeric;
    v_new_avail numeric;
BEGIN
    SELECT quantity, available_quantity
    INTO v_qty, v_avail
    FROM ticketing.ticket_types
    WHERE id = p_ticket_type_id
    FOR UPDATE;

    IF NOT FOUND THEN
        RAISE EXCEPTION 'ticket_type % not found', p_ticket_type_id;
    END IF;

    v_new_avail := v_avail + p_delta;

    IF v_new_avail < 0 THEN
        RAISE EXCEPTION 'Cannot reduce below zero';
    END IF;

    IF v_new_avail > v_qty THEN
        RAISE EXCEPTION 'Cannot exceed quantity';
    END IF;

    UPDATE ticketing.ticket_types
    SET available_quantity = v_new_avail
    WHERE id = p_ticket_type_id;
END;
$$;

这个存储过程完成了几项重要工作:

虽然可以在 C# 中通过手动管理事务和显式锁定来实现这些功能,但这样会更复杂且容易出错。让数据库处理它擅长的事情。

在 EF Core 中调用存储过程的方式如下:

app.MapPut("ticket-types/{ticketTypeId}/available-quantity", async (
    Guid ticketTypeId,
    int quantity,
    EventManagementContext dbContext) =>
{
    try
    {
        await dbContext.Database.ExecuteSqlAsync(
            $"""
             CALL ticketing.adjust_available_quantity({ticketTypeId},{quantity})
             """);

        return Results.Ok();
    }
    catch (Exception e)
    {
        return Results.BadRequest(e.Message);
    }
});

存储过程本身不返回值,但如果它抛出异常(使用 RAISE EXCEPTION),PostgreSQL 会将异常传播到 C# 代码。你可以捕获异常并返回适当的错误响应。

SQL 注入问题(无需恐慌)

看到这些插值字符串,你可能会想:“这不是在等待 SQL 注入攻击吗?”

实际上不是。

当你编写以下代码时:

$"SELECT * FROM users WHERE id = {userId}"

EF Core 并不会拼接字符串。它会将其转换为:

SELECT * FROM users WHERE id = @p0

实际值作为参数单独发送,完全独立于 SQL 文本。这适用于本文中的所有示例。

插值语法只是编写参数化查询的便捷方式。

原因在于我们传递给 SqlQuery 方法的不是 string 类型,而是 FormattableString 类型。这是一个特殊类型,它分别捕获格式和参数,允许 EF Core 处理参数化。

本文中的所有内容都适用于 SQL Server、MySQL、SQLite 和其他 EF Core 支持的数据库。主要区别在于语法差异。

数据库视图简介

数据库视图类似于不带参数的函数。它们是保存的查询,可以通过名称引用。

你可以像查询函数一样使用 SqlQuery<T> 查询视图:

var results = await dbContext.Database
    .SqlQuery<ActiveCustomerDto>(
        $"SELECT * FROM ticketing.active_customers")
    .ToListAsync();

或者,你可以在 DbContext 中将视图映射到实体类型,以获得完整的 LINQ 支持。

视图适合频繁使用且不需要参数的查询。函数则提供了参数化的灵活性。

最佳实践总结

我们介绍了如何在 EF Core 中使用 PostgreSQL 函数和存储过程,从简单的标量函数到带验证和锁定的复杂存储过程。

你了解了何时使用函数(需要返回数据时)与何时使用存储过程(需要修改数据时)。你看到了 EF Core 的 SqlQuery<T>ExecuteSqlAsync 如何在提供类型安全的同时让你编写所需的 SQL。你还学到了何时使用原生 SQL 更合理:复杂聚合、数据库特定功能、带锁定的原子操作以及减少网络往返。

EF Core 不会强迫你在 LINQ 和原生 SQL 之间做出选择。你可以同时使用两者。

当需要返回数据时使用函数,当需要用复杂逻辑修改数据时使用存储过程,当 LINQ 无法高效满足需求时使用原生 SQL 查询。结合 EF Core 的便利性和数据库的强大功能,你可以灵活地为每个场景选择合适的工具。

参考资料



Previous Post
开发者与 AI 代码审查:如何有效审查 AI 生成的 .NET 代码
Next Post
.NET 中抽象的真实代价:何时使用,何时避免