將傳統 ASP.NET 應用遷移到 .NET Core
點選藍字
關注我
現在越來越多的人在談論. NET Core。誠然,.NET Core 是未來, 但是.NET Framework 仍在支援, 因為大量的應用程式無法在短時間內遷移。
.NET Core 和 .NET Framework 就像電動汽車和汽油動力汽車。汽油車是成熟的,你可以毫無任何問題駕駛它,但電動車有它們的優勢,並正在取代汽油車。所以,不要誤會,你應該從今天開始遷移到. NET Core。
長文預警
我已經遷移了幾個執行在完整.NET Framework和IIS上的傳統ASP.NET/MVC專案到ASP.NET Core 2.x,可以執行在IIS或非IIS環境下。
我的部落格是其中之一。這是一個有10年曆史的部落格系統,最初由 ASP.NET 2.0 Web Form以及Visual Basic編寫。從2008年起,我一直在面向最新的.NET技術更新程式碼庫。.NET Core版本的部落格系統將在今年年底到來。我寫這篇文章,記錄我遇到的路障和如何解決它們的方法。
這篇文章針對的是新接觸.NET Core,但有.NET Framework經驗的開發人員,幫助他們將現有的應用更平滑的過渡到.NET Core上。
1遷移或重寫
有時候,我更喜歡用“重寫“而不是”遷移“這個詞,因為在有些情況下,.NET Core和.NET Framework是完全不同的兩個東西。
根據我的經驗,大部分前端程式碼可以只做少量修改就直接移植到.NET Core,因為它們的本質畢竟是伺服器技術無關的,天生跨平臺的技術。至於後端程式碼,遷移成本取決於它們對Windows及IIS的耦合程度。我理解,有些應用會充分利用Windows 及 IIS 的特性,這樣開發者就可以避免自己費力去實現一些功能。這些包括計劃任務、登錄檔、活動目錄或Windows服務等。這些並不能夠直接遷移,因為.NET Core是跨平臺的。
對於無法遷移的歷史遺留程式碼,你可能需要考慮重新設計整個應用的架構,將這些功能作為REST API暴露出來,可以使用.NET Framework上的ASP.NET Web API來實現。這樣的話,你的ASP.NET Core 應用得以繼續使用這些API並繼續完成業務功能。
即使你的應用使用了WCF服務,甚至更老的 ASMX 服務,也是可以搞的。因為.NET Core目前有WCF客戶端可以呼叫WCF。
2
NuGet 包管理
請確保你需要使用的NuGet包支援 .NET Core 或 .NET Standard
.NET Standard 意味著這個包可以同時使用在.NET Framework 4.6.1+ 以及.NET Core,這是取代老的 Portable Class Library (PCL)的技術。所以,如果你看到一個包的依賴項裡有.NET Standard,這意味著你能夠將它安裝到你的.NET Core工程中。
部分包,比如NLog有專門的.NET Core版本,比如 NLog.Web.AspNetCore,你應該選擇使用這樣的版本。
你依然可以在.NET Core工程裡引用一個.NET Framework的包,但是這會讓你的應用只能跑在Windows上,不推薦這麼做。
我列出了一些熱門使用的NuGet 包,它們都已經支援.NET Core:
NLog.Web.AspNetCore
Newtonsoft.Json
HtmlAgilityPack
RestSharp
NUnit
Dapper
AutoMapper
Moq
對於客戶端包,比如 jQuery,請不要使用NuGet 將它們安裝到.NET Core工程中,參見本文的 “客戶端包管理” 章節。
如果你使用 Visual Studio Code 做 .NET Core 開發,請注意,安裝NuGet包的命令不是 Install-Package,那是給Visual Studio的 PowerShell host用的,在VSCode裡,你需要使用dotnet CLI工具,比如:
dotnet add package Newtonsoft.Json
參見 https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-add-package
3
客戶端包管理
ASP.NET Core 曾經使用 Bower 去管理客戶端包。但在最新的ASP.NET Core 2.1 裡,Bower 已經被移除了,因為作者不幹了。因此,微軟預設使用自家的包管理器 “Library Manager” 也叫 “libman” 去管理前端包。它能夠在 Visual Studio 和 Visual Studio Code 中使用,甚至也能用 CLI 在命令列下使用。
libman.json 可以直接編輯,也能在UI中更改,都有智慧感知支援。我的建議是,如果你的應用不是重客戶端的話,使用 libman 去管理前端包,因為其他技術比如NPM 太重量級了。你會希望在你的編譯伺服器上安裝和配置NodeJS以及其他一切東西,僅僅為了拉取一個jQuery 庫。
更多詳情可參見官方文件 https://docs.microsoft.com/en-us/aspnet/core/client-side/libman/?view=aspnetcore-2.1
4
Html / JavaScript / CSS
你可以直接將這些檔案複製到.NET Core工程裡。但是請確保你已經把檔案路徑修改正確,比如CSS裡的圖片檔案路徑。因為傳統ASP.NET / MVC 模板預設使用 “/Content/” 目錄,而.NET Core模板使用“/css/”, “/js/”, “/lib/” 等目錄,這並不是強制的,只是約定俗成的規範。
如果你希望捆綁並壓縮CSS 和JS 檔案,有許多工具可以辦到。我個人喜歡用VS的一款外掛,叫做 “Bundler & Minifier” ,你可以從這裡獲取https://github.com/madskristensen/BundlerMinifier.
這款外掛可以在開發時生成捆綁及壓縮的檔案,但非編譯或執行時。
5
App_Data 資料夾
在傳統ASP.NET/MVC 應用中,你可以將資料檔案儲存到一個名為“App_Data”的特殊資料夾中,但這個東西在.NET Core裡不復存在了。為了實現類似的功能,你需要自己建立一個名為“App_Data” 的資料夾,但位於“wwwroot”目錄之外。
然後像這樣使用
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// set
string baseDir = env.ContentRootPath;
AppDomain.CurrentDomain.SetData("DataDirectory", Path.Combine(baseDir, "App_Data"));
// use
var feedDirectoryPath = $"{AppDomain.CurrentDomain.GetData("DataDirectory")}\\feed";
}
6
自定義 Http Headers
在傳統ASP.NET裡,你可以在Web.Config 裡像這樣為每個響應都配置自定義的HTTP Header:
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
</customHeaders>
</httpProtocol>
而在.NET Core裡,如果你希望脫離Windows去部署你的應用,不可以使用Web.config檔案。因此,你需要一個三方的 NuGet 包來完成這個功能:NetEscapades.AspNetCore.SecurityHeaders
app.UseSecurityHeaders(new HeaderPolicyCollection()
.AddCustomHeader("X-UA-Compatible", "IE=edge")
.AddCustomHeader("X-Developed-By", "Edi Wang")
);
詳情參考 https://github.com/andrewlock/NetEscapades.AspNetCore.SecurityHeaders
7
獲取客戶端IP地址以及 HttpContext
在傳統ASP.NET 裡,我們能夠通過 Request.UserHostAddress 來獲取客戶端IP地址。但這個屬性在 ASP.NET Core 2.x 裡是不存在的。我們需要通過另一種方式獲取HTTP 請求資訊。
1. 在你的 MVC 控制器裡定義一個私有變數
private IHttpContextAccessor _accessor;
2. 使用建構函式注入初始化它
public SomeController(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
3. 獲取客戶端IP地址
_accessor.HttpContext.Connection.RemoteIpAddress.ToString()
就是如此簡單。
如果你的 ASP.NET Core 工程是用MVC預設模板建立的,針對HttpContextAcccessor 依賴注入註冊應該在Startup.cs 中完成:
services.AddHttpContextAccessor();
services.TryAddSingleton<IActionContextAccessor, ActionContextAccessor>();
RemoteIpAddress 的型別是 IPAddress 並不是string。它包含 IPv4, IPv6 以及其他資訊。這和傳統ASP.NET不太一樣,對我們更加有用一些。
如果你希望在Razor 檢視(cshtml) 裡使用,只需要用 @inject 指令注入到view中:
@inject Microsoft.AspNetCore.Http.IHttpContextAccessor HttpContextAccessor
使用方法:
Client IP: @HttpContextAccessor.HttpContext.Connection.RemoteIpAddress.ToString()
8
JsonResult
預設情況下,ASP.NET Core 會使用 camelCase 序列化 JsonResult ,而傳統 ASP.NET MVC 使用的是PascalCase,這會導致依賴Json結果的 JavaScript 程式碼爆掉。
例如以下程式碼:
public IActionResult JsonTest()
{
return Json(new { Foo = 1, Goo = true, Koo = "Test" });
}
它會返回camelCase 的Json給客戶端:
如果你有大量JavaScript 程式碼並不能及時改為使用camelCase,你仍然可以配置 ASP.NET Core 向客戶端輸出 PascalCase 的Json
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc()
.AddJsonOptions(options => options.SerializerSettings.ContractResolver = new DefaultContractResolver());
}
現在,之前的程式碼會返回PascalCase 的結果:
1HttpModules 和 HttpHandlers
這兩者在ASP.NET Core中被替換為了 Middleware。但在遷移之前,你可以考慮使用別的方法,在一個普通ASP.NET Core Controller 中實現這些功能。
例如,我的老部落格系統裡有個名為“opml.axd” 的HttpHandler 作用是向客戶端輸出一個XML文件,這其實完全可以用 Controller 來實現:
public async Task<IActionResult> Index()
{
var opmlDataFile = $"{AppDomain.CurrentDomain.GetData(Constants.DataDirectory)}\\opml.xml";
if (!System.IO.File.Exists(opmlDataFile))
{
Logger.LogInformation($"OPML file not found, writing new file on {opmlDataFile}");
await WriteOpmlFileAsync(HttpContext);
if (!System.IO.File.Exists(opmlDataFile))
{
Logger.LogInformation($"OPML file still not found, something just went very very wrong...");
return NotFound();
}
}
string opmlContent = await Utils.ReadTextAsync(opmlDataFile, Encoding.UTF8);
if (opmlContent.Length > 0)
{
return Content(opmlContent, "text/xml");
}
return NotFound();
}
我也曾經使用HttpHandler 完成Open Search,RSS/Atom等功能,它們也能夠被 重寫為Controller。
對於其他一些不能夠被重寫為MVC Controller的元件,例如處理特殊拓展名的請求。請參見:
https://docs.microsoft.com/en-us/aspnet/core/migration/http-modules?view=aspnetcore-2.1
10
IIS URL Rewrite
你依然可以使用和舊應用裡完全一樣的配置檔案,不管你的 .NET Core 應用是否部署在IIS上。
例如,在應用根目錄底下建立一個名為"UrlRewrite.xml"的檔案,內容如下:
<rewrite>
<rules>
<rule name="Redirect Misc Homepage URLs to canonical homepage URL" stopProcessing="false">
<match url="(index|default).(aspx?|htm|s?html|php|pl|jsp|cfm)"/>
<conditions logicalGrouping="MatchAll" trackAllCaptures="false">
<add input="{REQUEST_METHOD}" pattern="GET"/>
</conditions>
<action type="Redirect" url="/"/>
</rule>
</rules>
</rewrite>
注意:你必須把這個檔案設定為always copy到輸出目錄,不然無效!
<ItemGroup>
<None Update="UrlRewrite.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
開啟 Startup.cs,在Configure 方法中新增如下程式碼:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
...
using (var urlRewriteStreamReader = File.OpenText("UrlRewrite.xml"))
{
var options = new RewriteOptions().AddIISUrlRewrite(urlRewriteStreamReader);
app.UseRewriter(options);
}
...
}
這在我之前的文章中提到過https://edi.wang/post/2018/9/18/prevent-image-hotlink-aspnet-core.
更多選項和用法可以參考 https://docs.microsoft.com/en-us/aspnet/core/fundamentals/url-rewriting?view=aspnetcore-2.1
11
Web.config
Web.config 檔案並沒有完全消亡。在 In .NET Core 裡,一個 web.config 檔案仍然用於在IIS環境下部署網站。在這種場景下,Web.config 裡的配置僅作用於 IIS,和你的應用程式碼沒有任何關係。可以參考 https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/iis/index?view=aspnetcore-2.1#configuration-of-iis-with-webconfig
一個典型的IIS下部署ASP.NET Core應用的web.config 檔案如下:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments=".\Moonglade.Web.dll" stdoutLogEnabled="false" stdoutLogFile="\\?\%home%\LogFiles\stdout" />
</system.webServer>
</location>
</configuration>
曾經的 AppSettings 節點可遷移到 appsettings.json,在這篇文章中有詳解:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-2.1
12
Session 和 Cookie
ASP.NET Core 預設沒有開啟Session支援,你必須手工新增Session 支援。
services.AddDistributedMemoryCache();
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(20);
options.Cookie.HttpOnly = true;
});
以及
app.UseSession();
設定和獲取Session值:
HttpContext.Session.SetString("CaptchaCode", result.CaptchaCode);
HttpContext.Session.GetString("CaptchaCode");
清除值:
context.Session.Remove("CaptchaCode");
詳情參見:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/app-state?view=aspnetcore-2.1
13
Html.Action
我們曾經使用 Html.Action 去呼叫一個Action ,返回一個Partial View ,然後放在主要的View 中顯示,比如layout頁。這在Layout頁面中的應用非常廣泛,比如在一個部落格系統中顯示分類列表之類的小部件。
@Html.Action("GetTreeList", "Category")
在ASP.NET Core裡,它被替換為了 ViewComponents,參見 https://docs.microsoft.com/en-us/aspnet/core/mvc/views/view-components
一個要注意的地方是Invoke方法只能是 async 簽名的:
async Task<IViewComponentResult> InvokeAsync()
但如果你的程式碼並不是天生非同步的,為了不讓編譯器警報,你可以加入這行程式碼:
await Task.CompletedTask;
14
檢查執行環境是 Debug 或 Release
在我的老系統裡,我使用 HttpContext.Current.IsDebuggingEnabled 去檢查當前執行環境是否為Debug,並在標題欄上顯示 “(Debug)” 字樣。
@if (HttpContext.Current.IsDebuggingEnabled)
{
<text>(Debug)</text>
}
在 ASP.NET Core 裡,我們可以使用新的razor tag helper 去完成這件事
<environment include="Development">
(Debug)
</environment>
在下面的章節裡,你會看到更多razor tag helper 的用法。
15
新的Razor Tag Helpers
Tag helper 可以幫助你講老的HTML helper 簡化為更加面向HTML可讀的程式碼,例如一個表單,我們曾經要這樣寫:
轉換為 Tag Helpers 的結果是這樣的:
我個人最喜歡的功能是給JS或CSS檔案自動增加版本字串:
<script src="~/js/app/ediblog.app.min.js" asp-append-version="true"></script>
它的結果是:
<script src="/js/app/ediblog.app.min.js?v=lvNJVuWBoD_RVZwyBT15T_i3_ZuEIaV_w0t7zI_UYxY"></script>
新的razor 語法能夠相容以前的 HTML helpers,也就是說,你依然能在ASP.NET Core中毫無問題的使用老的 HTML helpers。如果你的應用遷移時間緊迫,你可以儘管先使用老程式碼,隨後再逐步轉換到Tag Helpers。
完整的介紹和語法列表,可參見https://docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-2.1
16
Anti-Forgery Token
Anti-forgery token 有一些改進。首先,你能夠自定義cookie 以及欄位的名字了。
services.AddAntiforgery(options =>
{
options.Cookie.Name = "X-CSRF-TOKEN-MOONGLADE";
options.FormFieldName = "CSRF-TOKEN-MOONGLADE-FORM";
});
第二,你再也不需要手工給每一個表單都增加這行程式碼了:
@Html.AntiForgeryToken()
如果你使用新的form tag helper,那麼anti-forgery 欄位會自動在輸出到客戶端時自動加上。
但你依然需要在後臺對應的Action上加上 [ValidateAntiForgeryToken] 屬性。
然而,有另一種自動給每一個POST請求都驗證anti-forgery token 的辦法。
services.AddMvc(options =>
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));
或者你可以單獨給一個 Controller 加上這個屬性。
[Authorize]
[AutoValidateAntiforgeryToken]
public class ManageController : Controller
17
對非Controller 使用依賴注入
ASP.NET Core 有自帶的 DI 框架可以用在 Controller 上。我們可以修改一個Controller 的建構函式去注入它執行所依賴的服務。
public class HomeController : Controller
{
private readonly IDateTime _dateTime;
public HomeController(IDateTime dateTime)
{
_dateTime = dateTime;
}
}
但這不意味著自帶的DI框架只能用在Controller 上。對於其他類,你可以使用完全一樣的DI,例如,我自定義的類,也可以使用建構函式注入:
public class CommentService : MoongladeService
{
private readonly EmailService _emailService;
public CommentService(MoongladeDbContext context,
ILogger<CommentService> logger,
IOptions<AppSettings> settings,
EmailService emailService) : base(context, logger, settings)
{
_emailService = emailService;
}
// ....
}
方法是,只要你把自定義的類註冊到Startup.cs中的 DI 容器裡即可。
services.AddTransient<CommentService>();
更多ASP.NET Core 依賴注入的使用方法參見 https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/dependency-injection?view=aspnetcore-2.1
18
API 行為不一致
有些來自傳統 ASP.NET 的程式碼可以無錯誤編譯通過,但這不保證執行時能夠成功。比如,這段來自ASP.NET (.NET Framework) 的程式碼在 ASP.NET Core 中會丟擲異常:
var buffer = new byte[context.Request.Body.Length];
context.Request.Body.Read(buffer, 0, buffer.Length);
var xml = Encoding.Default.GetString(buffer);
它的結果是:
System.NotSupportedException: Specified method is not supported.
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpRequestStream.get_Length()
在.NET Core裡,我們需要用一種不同的方式去實現:
var xml = await new StreamReader(context.Request.Body, Encoding.Default).ReadToEndAsync();
19
小心GDPR 帶來的問題
ASP.NET Core 2.1 預設添加了 GDPR 的支援,但也會給我們帶來一些問題。關於GDPR可參見 https://docs.microsoft.com/en-us/aspnet/core/security/gdpr?view=aspnetcore-2.1
主要問題是,在使用者接受GDPR協議之前,Cookie 是不起作用的。你需要檢查哪些Cookie是你應用執行所必須的,即時使用者沒有接受GDPR協議,並且把它們標記為IsEssential
這是我部落格系統中的一個例子:
private void SetPostTrackingCookie(CookieNames cookieName, string id)
{
var options = new CookieOptions
{
Expires = DateTime.UtcNow.AddDays(1),
SameSite = SameSiteMode.Strict,
Secure = Request.IsHttps,
// Mark as essential to pass GDPR
// https://docs.microsoft.com/en-us/aspnet/core/security/gdpr?view=aspnetcore-2.1
IsEssential = true
};
Response.Cookies.Append(cookieName.ToString(), id, options);
}
另一個問題是,如果你要使用Session,那麼使用者必須接受GDPR 策略,否則 Session是不工作的。因為 Session 需要依賴 Cookie 在客戶端儲存 SessionID 。
20
熱更新 Views
在傳統 ASP.NET MVC 中,Views 資料夾預設不會編譯到 DLL 檔案中,所以我們能夠不需要編譯整個應用就能更新razor頁面。這在不需要更新C#程式碼的情況下僅修改文字或一些layout修改的場景下非常實用。我有時候也利用這個特性直接向生產環境釋出一些修改後的頁面。
然而,ASP.NET Core 2.1 預設情況下會將我們的 Views 編譯到DLL 中以提高效能。因此,你無法在伺服器上直接修改一個檢視,因為資料夾中根本就不存在 Views,只有一個 *.Views.dll:
如果你仍然希望在ASP.NET Core中熱更新Views,需要手工修改csproj檔案:
<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
<RazorCompileOnBuild>false</RazorCompileOnBuild>
<RazorCompileOnPublish>false</RazorCompileOnPublish>
</PropertyGroup>
21
編譯版本號自增長
在傳統 .NET 應用程式裡,我們可以修改 “AssemblyInfo.cs” 在每次編譯時自動增加版本號。這在編譯伺服器裡十分常用。
[assembly: AssemblyVersion("9.0.*")]
結果是這樣:
9.0.6836.29475
不幸的是,.NET Core 目前還沒有一個自帶的方法來完成這個操作。只有一個三方解決方案可能有用:https://github.com/BalassaMarton/MSBump
能看到這裡的都是我的真愛粉啊……
結束
ASP.NET Core 相對傳統 ASP.NET 有了不少區別,目前也有一定的限制。本文僅涵蓋了我自己所遇到的問題,也一定還有很多我沒有遇到過的情況。歡迎留言或Email給我交流你的發現。