舊 WCF 項目成功遷移到 asp.net core web api


背景

接上一篇,放棄了 asp.net core + gRPC 的方案后,我靈光一閃,為什么不用 web api 呢?不也是 asp.net core 的嗎?雖然 RESTful 不是強約束,客戶端寫起來也麻煩,但還是可以滿足基本需求,避免大幅修改舊有的業務邏輯代碼

在網上找到相當多的文章,比較 gRPC 和 RESTful 的優缺點,結論都是 gRPC 推薦用作內部系統間調用RESTful 推薦用作對外開放接口
選擇 RESTful 另一個最重要的原因是,gRPC 的底層框架需要HTTP2,而 win7 不支持HTTP2,有相當一部分用戶在 win7 上。上篇有人推薦 grpc web ,由於項目是 WPF 桌面客戶端,這種 web 方式可能就更不適合了。

Entity Framework Core

基礎安裝和配置

這部分基本與上一篇的前半部分內容一致,為了保證單篇文章的獨立性。把這部分內容完全 copy 過來🙄🙄🙄。

舊的WCF項目,數據庫訪問使用的是 Entity Framework + Linq + MySql。需要安裝的 Nuget 包:

  • MySql.Data.EntityFrameworkCore Mysql的EF核心庫;
  • Microsoft.EntityFrameworkCore.Proxies 《Lazy loading 》 懶加載的插件;
  • Microsoft.EntityFrameworkCore.DesignMicrosoft.EntityFrameworkCore.Tools 這兩個插件,用於生成代碼;

另外,還需要下載安裝 mysql-connector-net-8.0.21.msi 來訪問數據庫。其中有一個 Scaffold-DbContextbug 99419 TINYINT(1) 轉化為 byte,而不是預期的 bool。這個問題將會在 8.0.22 版本中修復,目前只能手動修改。
EF當然是 Database First 了,生成EF代碼需要在Package Manager Console用到 Scaffold-DbContext 命令,有三點需要注意:

  • Start up 啟始項目一定要是引用它的項目,並且編譯成功的;
  • Default project 生成后,代碼存放的項目;
  • 如果生成失敗,提示:“Your startup project 'XXXX' doesn't reference Microsoft.EntityFrameworkCore.Design. This package is required for the Entity Framework Core Tools to work. Ensure your startup project is correct, install the package, and try again.”。編輯項目文件 csproj 移除 <PrivateAssets>All</PrivateAssets> 從 "Microsoft.EntityFrameworkCore.Design"和"Microsoft.EntityFrameworkCore.Tools"中;

EF remove PrivateAssets

我的命令: Scaffold-DbContext -Connection "server=10.50.40.50;port=3306;user=myuser;password=123456;database=dbname" -Provider MySql.Data.EntityFrameworkCore -OutputDir "EFModel" -ContextDir "Context" -Project "DataAccess" -Context "BaseEntities" -UseDatabaseNames -Force

其他建議:

  • Library類庫最好是 Net Standard 方便移植;
  • 新建一個類來繼承BaseEntities,覆蓋 OnConfiguring 方法,可配置的數據庫連接字符串;
public class Entities : BaseEntities
{
    private static string _lstDBString;

    public static void SetDefaultDBString(string _dbString)
    {
        if (string.IsNullOrEmpty(_lstDBString))
        {
            _lstDBString = _dbString;
        }
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            optionsBuilder.UseLazyLoadingProxies().UseMySQL(_lstDBString);
        }
    }
}
  • 最好采用 asp.net core 的框架注入;鑒於項目的原因,假如強行采用的話,改動比較大,只好放棄;
public void ConfigureServices(IServiceCollection services)
{
    string _dbString = Configuration.GetConnectionString("MyDatabase");
    services.AddDbContext<DataAccess.Context.Entities>(
        options => options.UseLazyLoadingProxies().UseMySQL(_dbString));
    services.AddGrpc();
}
{
    "ConnectionStrings": {
        "MyDatabase": "server=127.0.0.1;port=3306;user=myuser;password=123456;database=dbname"
    },
    "log4net": "log4net.config",
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    },
    "AllowedHosts": "*"
}

EF6 to EF Core 3.1

從 Entity Framework 6 遷移到 Entity Framework Core 3.1 的調整:

  1. 提示錯誤:“Set operations over different store types are currently unsupported”。Union 的使用改變,需要添加 AsEnumerable(),在 EF5.0 會修復這個問題 issuesAsEnumerable() 操作的是 將 Linq to Sql 切換為 Linq to Objects 可以支持 L2O 的所有操作;

    var query = (from a in context.km_folder
                select new FolderFileModel()
                {
                    Id = a.id,
                    Name = a.folder_name
                }).AsEnumerable().
                Union(from a in context.km_fileinfo
                    select new FolderFileModel()
                    {
                        Id = a.id,
                        Name = a.file_name
                    }).AsEnumerable();
    
    // Linq to Sql 切換為 Linq to Objects , 可以執行 外部方法
    var query2 = context.Observations.Select(o => o.Id).AsEnumerable().Select(x => MySuperSmartMethod(x));
    
  2. 直接執行 sql 語句的方式發生改變, sql查詢結果 與 model 里面的字段要一一對應,如果不需要的字段可以添加 [NotMapped]

    // EF 6
    context.Database.SqlQuery<FolderFileModel>(sql, techArgs);
    
    // EF core
    
    /// <summary>
    /// 為了兼容執行sql的方法,參考寫法
    /// https://stackoverflow.com/a/50452479/6667125
    /// </summary>
    public partial class BaseEntities : DbContext
    {
        public virtual DbSet<FolderFileModel> FolderFileModels { get; set; }
    
        partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<FolderFileModel>().HasNoKey();
        }
    }
    
    public class FolderFileModel
    {
        public long? Id { get; set; }
        public string Name { get; set; }
    
        [NotMapped]
        public bool IsSelected { get; set; }
    }
    
    // 執行方法
    context.FolderFileModels.FromSqlRaw("SQL SCRIPT", techArgs).ToList();
    

其他問題

出現問題:指定的架構無效。錯誤: CLR 類型到 EDM 類型的映射不明確,因為多個 CLR 類型與 EDM 類型“user”匹配。

DbContext 的 MetadataWorkSpace 一旦生成會緩存起來。也就是說,在同一個應用程序域里面,一旦用dbcontext操作過數據庫,它會自動讀取類所在 assembly 里面的所有類,並嘗試匹配數據庫模型,然后將匹配結果保存起來(保存到上面的MetaOCSpace中)。當下次操作數據庫時,返回數據對應類類所在其它assembly里面的類與當前已匹配數據庫模型發生沖突時,便會報錯。(這段話是網上抄的)

{{< notice note "在ASP.NET MVC5高級編程(第5版)中第70頁作者寫到" >}}
實體框架的另一種(默認的)策略是延遲加載策略。使用延遲建在策略,EF在LINQ查詢中只加載主要對象(專輯)的數據,而不填充Genre和Artist屬性。
var albums=db.Albums;
延遲加載根據需要來加載相關數據,也就是說,只有當Album的Genre或Artist需要屬性時,EF才會公國向數據庫發送一個個額外的查詢來加載這些數據。延遲加載策略會強制框架未列表中每一個專輯向數據庫發送一個額外的查詢。
{{< /notice >}}

我的理解是,懶加載通過反射來查找實體對象,而該程序集中,存在多個同名的類型,即使改類型在不同命名空間下也會報錯。只要將相同的類型名稱,改為不一樣即可。

服務端 asp.net core web api

這部分可是水還是有點深了。由於最近幾年主要以 WPF 桌面軟件開發為主,很少了解 asp.net core 。這次算是惡補了一下,下面是個人總結,一切以官方文檔為准

啟動類 StartUp

啟動類 StartUp.cs ,在這里面主要是注冊服務(Swagger、mvc等),注冊中間件(身份認證、全局異常捕獲等),以及不同環境的切換(Development、Production)。下面是我的 StartUp 類,有幾點經驗總結:

  • 初始化讀取全局配置參數,比如 log4net.config連接字符串等;
  • web api 只需要添加 services.AddControllers();,而不是 AddMvc();
  • Swagger 只在開發環境下啟用,而生產環境無效《在 ASP.NET Core 中使用多個環境》 多環境開發測試,真的太好用了,強烈推薦使用
  • 在根路徑下增加返回內容Hello Asp.Net Core WebApi 3.1!,為了方便測試是否運行成功。
public void ConfigureServices(IServiceCollection services)
{
    InitConfig();
    services.AddControllers();
    services.AddSwaggerDocument(SwaggerDocumentConfig); // Register the Swagger services
}

private void InitConfig()
{
    Entities.SetDefaultDBString(Configuration.GetConnectionString("MyDatabase"));
    Common.LogMaker.InitLog4NetConfig(Configuration.GetSection("log4net").Value);
    Common.WebApiLogger.Singleton.LogMaker.LogInfo("Start WebApi!");
}

private void SwaggerDocumentConfig(NSwag.Generation.AspNetCore.AspNetCoreOpenApiDocumentGeneratorSettings config)
{
    config.PostProcess = document =>
    {
        document.Info.Version = typeof(Startup).Assembly.GetName().Version.ToString();
        document.Info.Title = "Test Web Api";
        document.Info.Description = "僅供測試和發開使用。";
        document.Info.Contact = new NSwag.OpenApiContact
        {
            Name = "long",
            Email = "long@test.com"
        };
    };
}


public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();

        // Register the Swagger generator and the Swagger UI middlewares
        app.UseOpenApi();
        app.UseSwaggerUi3();
    }

    app.UseCustomExceptionMiddleware(); // 全局異常中間件
    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello Asp.Net Core WebApi 3.1!");
        });

        endpoints.MapControllers();
    });
}

路由 和 Controller

真心的覺得 asp.net core 的路由設計,真的是太棒了!而我只用到了其中很小的一部分《REST Api 的屬性路由》。其中有個注意點,全局路由與屬性路由會有沖突,需要特別注意。

為了方便管理路由,靈活使用,以及后期版本的維護,創建一個路由模板Controller基類,所有 Controller 都繼承自 MyControllerBase

public class MyV1ApiRouteAttribute : Attribute, IRouteTemplateProvider
{
    public string Template => "api/v1/[controller]/[action]";
    public int? Order => 0;
    public string Name { get; set; }
}

[ApiController]
[MyV1ApiRoute]
[Produces(MediaTypeNames.Application.Json)]
public class MyControllerBase : ControllerBase
{
}

Nswag

NswagSwashbuckle 是微軟官方推薦的 Swagger 工具(官方 swagger 在線試用😆)。我選擇 Nswag 的主要原因是,它提供的工具,根據 API 生成 C# 客戶端代碼,其實到最后我也沒有使用這個功能。 《NSwag 和 ASP.NET Core 入門》

Nswag 使用起來也非常簡單,參考我的 啟動類 StartUp 中的寫法。如果想要把代碼中的注釋也體現在 Swagger 文檔中,需要執行一些額外的操作。
在 csproj 文件中增加 <GenerateDocumentationFile>true</GenerateDocumentationFile>,另外,最好在 /Project/PropertyGroup/NoWarn 中增加 1591,否則你會得到一大堆的 warning : # CS1591: Missing XML comment for publicly visible type or member. 原因是項目中存在沒有注釋的方法,屬性或類

vs-swagger

注:用 swaggerUI 來測試的話,可能會出現兩個問題:

  1. 如果用 nginx 轉發的話,會需要 跨域 CORS 的支持;
  2. 實際使用本地網絡發送過去的,比如,在 10.40.50.237 上面搭建的 webapi,而 swaggerUI 發送地址 http://localhost:5000/api/xxx

上傳文件

這里的方法只適合小文件,如果需要大文件上傳,需要用到 steam ,多文件上傳參數改為 List<IFormFile> 即可。 DisableRequestSizeLimit 取消上傳限制。

[HttpPost, DisableRequestSizeLimit]
public bool SendStream(IFormFile _file)
{
    var _newFileName = Path.Combine(UploadFileManagementBLL.Singleton.StoragePath, _file.FileName);
    using (FileStream fs = System.IO.File.Create(_newFileName))
    {
        _file.CopyTo(fs);
        fs.Flush();
    }

    return true;
}

全局異常捕獲

用到自定義中間件,而 asp.net core 提供的 UseExceptionHandler 《Handle errors in ASP.NET Core》 我覺得不太好用。
日志唯一ID: TraceIdentifier,方便日后跟蹤日志; UseCustomExceptionMiddleware 在 startup.cs 中一定要放在第一位,中間件的順序是有非常重要的影響的 《中間件順序》

官方的圖片

public class CustomExceptionMiddleware
{
    private readonly RequestDelegate _next;

    public CustomExceptionMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(context, ex);
        }
    }

    private Task HandleExceptionAsync(HttpContext context, Exception ex)
    {
        Common.WebApiLogger.Singleton.LogMaker.LogInfo($"Unhandled exception UID:[{context.TraceIdentifier}] Message:[{ex}]");

        string _retString = $"Error! Please contact the administrator.ErrorId:[{context.TraceIdentifier}] Exception:[{ex.Message}].";
        context.Response.StatusCode = (int)System.Net.HttpStatusCode.InternalServerError;
        return context.Response.WriteAsync(_retString);
    }
}

public static class CustomExceptionMiddlewareExtensions
{
    public static IApplicationBuilder UseCustomExceptionMiddleware(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<CustomExceptionMiddleware>();
    }
}

客戶端 WebApiClient

在網上尋找有沒有現成的 RESTful 的 C# 工具,發現了WebApiClient,看了一下樣例,確實非常簡單,非常省事兒,只需要寫個簡單的 Interface 接口類,就可以了,關鍵是它還支持各種奇奇怪怪的 HTTP 接口
PS: 最開始讀 README.md 時候,總是一臉懵逼,一直把它當成 server 端的工具😓。直到開始寫客戶端的時候,才真正看懂了他的文檔。

WebApiClient.Tool

Swagger 是一個與語言無關的規范,用於描述 REST API。既然 Swagger 是一種規范,那么極有可能存在,根據 Swagger.json 生成代碼的工具。想着 WebApiClient 開發者是不是也已經提供了工具,果然不出所料,WebApiClient.Tools

只需運行一行命令,就可以根據 Swagger.json 直接生成客戶端的實體類,接口,甚至包括注釋,簡直爽的不要不要的,完美的避開了手寫代碼的過程
我的命令: WebApiClient.Tools.Swagger.exe --swagger=http://10.50.40.237:5000/swagger/v1/swagger.json --namespace=MyWebApiProxy

WebApiClient.JIT

由於還有一大部分的 win7 桌面軟件用戶,而他們大概率不會安裝 net core ,所以只能選擇 net framework 的版本 WebApiClient.JIT。使用起來也相當方便,只需要在啟動的時候初始化一下webapi地址,然后在需要的時候調用即可。
WebApiClient 提供的是一個異步的接口,由於舊項目升級,避免大幅改動,就沒有使用異步的功能。

public static void InitialWebApiConfig(string _baseUrl)
{
    HttpApi.Register<IUserManagementApi>().ConfigureHttpApiConfig(c =>
    {
        c.HttpHost = new Uri(_baseUrl);
        c.FormatOptions.DateTimeFormat = DateTimeFormats.ISO8601_WithoutMillisecond;
    });
}

public void Todo()
{
    using (var client = HttpApi.Resolve<IUserManagementApi>())
    {
        var _req = new LoginRequestV2();
        _response = client.UserLoginExAsync(_req).InvokeAsync().Result;
    }
}

部署 Ubuntu + Nginx

項目的服務端,對操作系統沒有特別要求,所以直接選擇最新的 Ubuntu 20.04.1 LTS 。吸取了 gRPC 部署的一些經驗,這次只部署 http 服務,額外增加了 nginx 反向代理,只是因為在官網上看到了《使用 Nginx 在 Linux 上托管 ASP.NET Core》😜。

Kestrel 是 ASP.NET Core 項目模板指定的默認 Web 服務器,所以一般情況下,ASP.NET Core是不需要額外的容器的。《ASP.NET Core 中的 Web 服務器實現》。下面是我的具體實現操作:

  1. 根據文檔《在 Linux 上安裝 .NET Core》《安裝 Nginx》 指引,安裝 aspnetcore-runtime-3.1nginx

  2. 配置 Nginx ,這部分我用的比較簡單,只用到轉發功能,未來可能在這一層增加 SSL ;另外 try_files $uri $uri/ =404 這一句需要注釋掉才行

    > sudo nano /etc/nginx/sites-available/default
    
    server {
        listen 80 default_server;
        listen [::]:80 default_server;
    
        root /var/www/html;
        index index.html index.htm index.nginx-debian.html;
        server_name _;
        location / {
                # First attempt to serve request as file, then
                # as directory, then fall back to displaying a 404.
                # try_files $uri $uri/ =404;
                proxy_pass http://localhost:5000;
        }
    
    }
    
    > sudo nginx -t    # 驗證配置文件的語法
    > sudo nginx -s reload
    
  3. 創建 Linux 的 web api 的服務文件,並啟動。我的示例,--urls這個是非常實用的參數,可多端口;重點注意 ASPNETCORE_ENVIRONMENT 在配置是生產環境,還是開發環境

    > sudo nano /etc/systemd/system/kestrel-mywebapi.service
    
    [Unit]
    Description=mywebapi App running on Ubuntu
    
    [Service]
    WorkingDirectory=/home/user/publish
    ExecStart=/usr/bin/dotnet /home/user/publish/MyWebApi.dll --urls http://localhost:5000
    Restart=always
    # Restart service after 10 seconds if the dotnet service crashes:
    RestartSec=10
    KillSignal=SIGINT
    SyslogIdentifier=dotnet-example
    User=user
    # Production Development
    Environment=ASPNETCORE_ENVIRONMENT=Development
    Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
    
    [Install]
    WantedBy=multi-user.target
    
    > sudo systemctl enable kestrel-mywebapi.service
    > sudo systemctl restart kestrel-mywebapi.service
    > sudo systemctl status kestrel-mywebapi.service
    
  4. 開啟防火牆端口,Ubuntu 是默認關閉22端口。安全起見,避免被頻繁掃描,建議把 ssh 默認端口 22 改為其他不常見的端口號。

sudo netstat -aptn
sudo apt-get install ufw
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp

sudo ufw enable
sudo ufw status

瀏覽器測試 http://10.50.40.237,返回預期的Hello Asp.Net Core WebApi 3.1!,完美😁。還有一個小坑就是 https 還沒有配置。

自動化腳本

在開發階段,需要經常得編譯,打包,上傳。雖然 VS2019 具有直接發布到 FTP 的功能,不過我沒有使用。一方面,該功能從來沒用過,另一方面,還是想自己寫個更加靈活的腳本。
目前只實現了 編譯,打包,上傳的功能,后續再增加 ssh 登錄,解壓,重啟 asp.net 。

echo only win10

cd D:\Projects\lst\01-MyWebApi\MyWebApi
rem 已注釋:dotnet publish --output bin/publish/ --configuration Release --runtime linux-x64 --framework netcoreapp3.1 --self-contained false
dotnet publish -p:PublishProfileFullPath=/Properties/PublishProfiles/FolderProfile.pubxml --output bin/publish/

cd bin
tar.exe -a -c -f publish.zip publish

"C:\Program Files\PuTTY\psftp.exe"

open 10.50.40.237
Welcome123
put "D:\Projects\lst\01-LstWebApi\LenovoSmartToolWebApi\bin\publish.zip"
exit

rem 已注釋:"C:\Program Files\PuTTY\putty.exe" user@10.40.50.237 22 -pw password

pause

另外,用一個 WinForm 的測試小程序,嘗試了 self-contained = true 這種發布方式,不需要客戶端安裝 net core 就能運行,發現編譯后從 1M+ 大幅增加到 150M+ ,妥妥的嚇壞了。即使使用了 PublishTrimmed=true 《剪裁獨立部署和可執行文件》,大小也有 100M+,果斷放棄。

其他的改動

log4net

log4net 由 1.2.13.0 升級到 2.0.8后,初始化配置文件方法新增一個參數ILoggerRepository

public static void InitLog4NetConfig(string file)
{
    var _rep = LogManager.GetRepository(System.Reflection.Assembly.GetCallingAssembly());
    log4net.Config.XmlConfigurator.Configure(_rep, new System.IO.FileInfo(file));
}

部署到 Ubuntu 后,發現 log4net 報錯。需要把 log4net.Appender.ColoredConsoleAppender 替換為 log4net.Appender.ManagedColoredConsoleAppender。是由於不同的 Appender 支持的 Framework 不同, ColoredConsoleAppender 支持 NET Framework 1.0~4.0 , ManagedColoredConsoleAppender 支持 NET Framework 2.0+ 。詳見:《Apache log4net™ Supported Frameworks》

Framework 4.0 的 md5 方法廢棄

MD5 摘要也出現問題,需要更改。原因是 HashPasswordForStoringInConfigFile 在 net core 中已經不可用了,該方法在 framework 4.5 中也提示為廢棄的方法。修改為下面新的 MD5 方法即可。

public static string GetOldMD5(string str)
{
    string result = System.Web.Security.FormsAuthentication.HashPasswordForStoringInConfigFile(str, "MD5").ToLower();
    return result;
}

public static string GetNewMD5(string str)
{
    using (MD5 md5 = MD5.Create())
    {
        byte[] retVal = md5.ComputeHash(Encoding.UTF8.GetBytes(str));
        StringBuilder _sb = new StringBuilder();
        for (int i = 0; i < retVal.Length; i++)
        {
            _sb.Append(retVal[i].ToString("x2"));
        }

        return _sb.ToString();
    }
}

DateTimeOffset 和 DateTime

以前從來沒有考慮過 UTC 時間,這次深深的上了一課, WebApi 中所有傳遞的時間全部是 UTC 時間。 DateTime.MinValue 轉化為 UTC 時間后,可能會出現小於 0001\01\01 00:00:00的時間。需要額外處理一下。

public RequestPartialContent(long _userId, DateTime _newDataTime)
{
    Uid = _userId;

    // 轉化為 utc 時間后,可能會小於 MinValue
    if (_newDataTime.ToUniversalTime() <= DateTimeOffset.MinValue.UtcDateTime)
    {
        StartDateVersion = DateTimeOffset.MinValue;
    }
    else
    {
        StartDateVersion = new DateTimeOffset(_newDataTime);
    }
}

總結

目前,只遷移了一部分的 WCF 接口過來,等待部署到生產環境,可以穩定運行后,再將剩余部分全部遷移過來。這次的嘗試比較成功:

  1. 一是滿足了基本需求,較少改動老舊代碼
  2. 二是大部分代碼由工具生成,比如 API 文檔,接口的實體類;
  3. 三是很多常用功能,都有現成的插件來完成。

我只需要修改編輯器的 ERROR 的提示就可以了。感覺沒有寫什么代碼🤣。。。頂多只寫了幾行粘合代碼🤣,一種搭積木的感覺😝。其中 asp.net web api 還有很多的功能沒有使用,還需要更加細化到項目中。路漫漫~~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM