Skip to the content.

TeamsApp升级之路 之 EF Core SQL 篇

我们继续 LuckyDraw 的升级之路,上篇讲了在infra层面的数据库配置,这篇文章主要介绍如何在 LuckyDraw 的 bot api service 里使用 EF core 来处理抽奖,并且同时保证向前兼容,也就是说如果已经有个抽奖是使用 Azure storage table 的,那就继续使用 storage table,虽然这种做法增加了大量的代码工作量,但是可以保证用户体验,不会升级之后,用户突然觉的之前的抽奖怎么打不开了或者不能参与了。

整个升级过程中,我深深体会到 test 代码的作用,几年前花了大量的时间写的测试代码,现在来看非常值得。如果没有这些代码,我根本就不敢修改当前的代码,因为无法预知改了之后会不会影响之前的逻辑。

好,我们来看看升级中的几个要点:

1. 使用 Managed Identify 来访问数据库

传统的数据库访问用户名和密码,这样的问题是密码会出现在 connection string 里,或者出现在某个配置里,这样带来了一些安全隐患,azure 为我们提供了一个更加安全的方式,Managed Identity,具体的配置方法可以参考微软的官方文档:https://learn.microsoft.com/en-us/azure/azure-functions/functions-identity-access-azure-sql-with-managed-identity

所以在代码层面,我们可以这么做:

public static void UseManagedIdentity(this DbContext dbContext)
{
    if (dbContext.Database.ProviderName != "Microsoft.EntityFrameworkCore.SqlServer")
    {
        return;
    }
    var tokenProvider = new AzureServiceTokenProvider();
    var conn = (SqlConnection)dbContext.Database.GetDbConnection();
    conn.AccessToken = tokenProvider.GetAccessTokenAsync("https://database.windows.net/").Result;
}

public class LuckyDrawDbContext : DbContext
{
    public LuckyDrawDbContext(DbContextOptions<LuckyDrawDbContext> options) : base(options)
    {
        this.UseManagedIdentity();
        ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
    }
}

上面的代码实际上是先使用 AzureServiceTokenProvider 来向 azure 获取一个 access token,然后数据库的访问就用这个 token 来访问。

值得一提的是我这里使用了 NoTracking 模式,来防止 EF Core 默认的 tracking 行为。提高执行效率。

        ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

2. 日期类型

Teams SDK 很多地方(几乎所有地方)都是使用 DateTimeOffset 来传递日期,这个类型的好处是里面带了 timezone (offset) 信息。SQL server 实际上也支持这种类型。但是我在 LuckyDraw 这次数据库设计中还是使用了传统的 DateTime datetime2(7) 类型,原因是我有另外一个数据库字段来记录 timezone 信息,在DateTime里保存全部都是 UTC,这样不会混乱。我相信很多人不认同这种做法,但是我自己多年的经验告诉我,time一旦涉及到 timezone 非常的复杂,很多问题很难调试,所以我尽量的简化,一旦接收到 date time 信息,我第一时间转换成 UTC。

在需要 DateTimeOffset 类型的地方,我可以来回转换:

  • DateTime 转 DateTimeOffset: new DateTimeOffset(dateTime, TimeSpan.Zero)
  • DateTimeOffset 转 DateTime: dateTimeOffset.UtcDateTime

3. 数据库版本管理

数据库版本管理是一个比较复杂的话题,并没有太好的解决方法,我目前使用的是 DbUp。我给我的数据库脚本编好号码,然后 embed 到我的project里:

  <ItemGroup>
    <EmbeddedResource Include="Database/DbScripts/*.sql" LogicalName="DatabaseScripts.%(Filename)%(Extension)" />
  </ItemGroup>

然后启动的时候,使用 DbUp 升级数据库,确保数据库里的schema是最新的额版本:

var upgradeEngine = DeployChanges.To.SqlDatabase(connectionString)
    .WithScriptsEmbeddedInAssembly(currentAssembly, path => path.Contains("DatabaseScripts."))
    .WithTransactionPerScript()
    .LogScriptOutput()
    .LogToConsole()
    .Build();

if (!upgradeEngine.IsUpgradeRequired())
{
    LogInformation("Database upgrade is not required.");
    return;
}

var result = upgradeEngine.PerformUpgrade();
if (!result.Successful)
{
    throw new InvalidOperationException("DbUp failed to perform upgrade.", result.Error);
}

4. 使用 in-memory database 进行自动化测试

有一些朋友喜欢使用真是的 SQL server 进行测试,因为这样能够cover所有的真实的场景,我个人比较喜欢使用 EF Core的 in-memory database 来进行测试,因为它的执行速度非常快,当我们的项目有几百个测试用例的时候,如果使用真实的 SQL server 测试,跑一遍用例起码要 5-10 分钟,但是如果是 in-memory database,通常可以在一分钟内完成,这个对开发效率来说有很大提高,所以我还是喜欢 in-memory db。

当然 in-memory db 有一些问题,比如发现不了 FK 冲突,无法做性能测试。等等。

在测试代码里,我替换掉 DbContext 的option。替换成 in-memory db 的option。

services.ReplaceScoped(GetInMemoryOptions<LuckyDrawDbContext>(mainDbName));

public static IServiceCollection ReplaceScoped<TService>(this IServiceCollection services, Func<IServiceProvider, TService> implementationFactory) where TService : class
{
    var service = services.First(s => s.ServiceType == typeof(TService));
    services.Remove(service);
    return services.AddScoped(implementationFactory);
}

public static Func<IServiceProvider, DbContextOptions<T>> GetInMemoryOptions<T>(string dbName) where T : DbContext
{
    return (_) =>
    {
        var builder = new DbContextOptionsBuilder<T>();
        builder.UseInMemoryDatabase(databaseName: dbName);
        return builder.Options;
    };
}

希望这些点能够给大家升级 teams app 的时候有一些参考和启发。

Written on December 18, 2022