【.NETCore 3】Ids4 ║ 统一角色管理(上)
前言
書(shū)接上文,咱們?cè)谏现?#xff0c;通過(guò)一篇《思考》 性質(zhì)的文章,和很多小伙伴簡(jiǎn)單的討論了下,如何統(tǒng)一同步處理角色的問(wèn)題,眾說(shuō)紛紜,這個(gè)我一會(huì)兒會(huì)在下文詳細(xì)說(shuō)到,而且我最終也定稿方案了。所以今天咱們就大刀闊斧的開(kāi)始遷移之路,這個(gè) IdentityServer4 項(xiàng)目也是要盡快的完結(jié),因?yàn)榈诹鶄€(gè)系列《設(shè)計(jì)模式》已經(jīng)開(kāi)始了,然后還有直播,和錄制視頻,積壓太多會(huì)得不償失,而且好像還有人讓我講我的項(xiàng)目,所以,這兩周先把我在線的項(xiàng)目遷移了,WPF的項(xiàng)目就留在錄制 IdentityServer4 視頻里給大家詳細(xì)講解,文字教程到時(shí)候看看要不要補(bǔ)充一下。
那既然說(shuō)到了角色管理,可能有一部分讀過(guò)我文章的小伙伴,腦海中稍微有點(diǎn)兒類(lèi)似的印象,數(shù)據(jù)管理?好像之前說(shuō)過(guò),沒(méi)錯(cuò)!在上上一篇文章中,我們說(shuō)到了《用戶數(shù)據(jù)管理》,主要就是用戶數(shù)據(jù)的增刪改查,然后添加種子數(shù)據(jù),從我的 Github 上自動(dòng)生成,除了用戶,當(dāng)時(shí)也生成了一點(diǎn) Role 信息,只不過(guò)那里的 Role 信息,是固定的,不能修改,而且也僅僅是作為User 的 Claim 聲明來(lái)做處理的,并沒(méi)有涉及到真正的 Role 管理,比如基本的CURD ,但是今天我們就正式的開(kāi)始對(duì)角色信息進(jìn)行統(tǒng)一處理了?,廢話不多說(shuō),直接開(kāi)始。
?
在寫(xiě)這篇文章的時(shí)候,是半夜,越寫(xiě)越多,最后發(fā)現(xiàn)不得已,無(wú)奈的在文章標(biāo)題里加了個(gè)(上)字,其實(shí)文章太長(zhǎng)也不好,也不知道我為啥這么話癆????。
?
?
零、今天要實(shí)現(xiàn)橙色的部分
?
?
?
?一、Role 的數(shù)據(jù)同步方案之回顧
?
在剛剛的前言中,我們說(shuō)到了上一篇文章《五 ║ 多項(xiàng)目集成統(tǒng)一認(rèn)證中心的思考》里,我們討論了幾種同步 Role 的方案,很是精彩,主要是文章下邊的評(píng)論很精彩,可能看的多了,一不小心會(huì)有一種神仙打架的意味,因此這里我簡(jiǎn)單的做下總結(jié)吧,既是對(duì)上篇文章的回顧,也是今天這篇文章的引子,為了看著更清楚,我這里用編號(hào)來(lái)表示我們的思路:
?
01、我們的 IdentityServer4 項(xiàng)目是一個(gè)去中心化的認(rèn)證服務(wù)中心,他提供一個(gè) token 令牌,來(lái)實(shí)現(xiàn)我們對(duì)資源服務(wù)器的授權(quán)處理;
02、既然要授權(quán),我們就需要對(duì) Token 做一定的處理,這里一般是增加聲明 Claim,常見(jiàn)的就是 Role 信息;
03、然后我們從服務(wù)中心成功登錄后返回,并攜帶一個(gè)含有 Role Claim信息的 Token 令牌;
04、接著在返回到的資源服務(wù)器里,對(duì) api 進(jìn)行自定義授權(quán),來(lái)對(duì)當(dāng)前 Token 令牌,也等同于 Token 的持有者進(jìn)行訪問(wèn)限制;
05、那這個(gè)時(shí)候問(wèn)題來(lái)了,我們的資源服務(wù)器看起來(lái),本應(yīng)該是不用關(guān)心我們的用戶信息和角色信息的,是要交給認(rèn)證中心的;
06、但是 Blog.Core 項(xiàng)目,我們用到了數(shù)據(jù)庫(kù)的動(dòng)態(tài)分配授權(quán),是根據(jù) Role 來(lái)分配特定的 api/url 的;
07、也就意味著我們要把 Role 放到資源服務(wù)器,但是上邊第 05 點(diǎn),我們明確 Role 的管理是在 Identity 項(xiàng)目的;
08、這個(gè)時(shí)候方案就來(lái)了,
09、一:我們可以做一個(gè)定時(shí)器,定時(shí)將 Identity 認(rèn)證項(xiàng)目的Role同步到資源服務(wù)器;
10、二:在 Identity 項(xiàng)目開(kāi)發(fā)一個(gè) api 接口,方便我們?cè)?資源服務(wù)器 里調(diào)用;
11、三:直接把 Identity 和 core 項(xiàng)目共用一個(gè) db 數(shù)據(jù)庫(kù),使用一個(gè) Role 表,就完美解決這個(gè)問(wèn)題了;
12、四:單獨(dú)抽離出一個(gè) Role 做分布式服務(wù)管理中心,可以使用 Redis,就是把 Role 單獨(dú)一個(gè)微服務(wù),讓所有項(xiàng)目使用;
13、五:最簡(jiǎn)單的方法,Identity 項(xiàng)目單獨(dú)一個(gè) db 庫(kù),但是我們的資源服務(wù)器手動(dòng)在 controller 上配置Authorize;
14、等等等等,還有其他的一些思路,不列舉。
?
從上邊的這一系列大家可以看得出來(lái),我們平時(shí)開(kāi)發(fā)還是需要很多的思考的,也是需要多多的討論,這樣才能進(jìn)步。
最終思考了很久,我還是采用了方案三和方案五,這兩個(gè)簡(jiǎn)單的方案,你可能好奇,為啥是兩個(gè)呢?而且感覺(jué)兩個(gè)背道而馳,一個(gè)是合并,一個(gè)是分庫(kù),怎么能同時(shí)使用呢,其實(shí)很簡(jiǎn)單的,因?yàn)槲矣卸鄠€(gè)資源服務(wù)器,這里目前就用兩個(gè)吧 —— Blog.Core 的前后端分離的 api 項(xiàng)目 和 ChristDDD 的 MVC 項(xiàng)目,當(dāng)然以后還會(huì)有 WPF 項(xiàng)目。
我在 Blog.Core 項(xiàng)目采用方案三,合并到一個(gè)數(shù)據(jù)庫(kù),可以很好的解決動(dòng)態(tài)授權(quán)問(wèn)題,
然后在 MVC 項(xiàng)目里,就采用手動(dòng)在 controller 添加特性的形式吧,也就是方案五,這樣就完全滿足了需求,
也能夠同時(shí)給大家展示兩種方案,從而達(dá)到學(xué)習(xí)的目的
好啦,到了現(xiàn)在,大家應(yīng)該也明白了我以后的設(shè)計(jì)思路和開(kāi)發(fā)方案了,現(xiàn)在就開(kāi)始動(dòng)手處理 Role 數(shù)據(jù)了。
?
?
?
二、兩種管理 ROLE 的方案
?
說(shuō)明:以下內(nèi)容可能有點(diǎn)兒繞,或者有點(diǎn)兒不容易懂,大家不要慌,我會(huì)這兩篇詳細(xì)講解,而且也會(huì)在視頻中,詳細(xì)給大家說(shuō)明的,但是還是盡量能跟的上。
如果你使用 Ids4 項(xiàng)目的話(這里準(zhǔn)確來(lái)講,是開(kāi)發(fā) Identity 的話,因?yàn)閮烧呤遣灰粯拥膯?#xff09;,會(huì)有兩種開(kāi)發(fā)方式.
?
1、簡(jiǎn)述 Ids4 數(shù)據(jù)庫(kù)框架三模塊
在我們的 Ids4 項(xiàng)目中,我們?cè)谥暗奈恼轮幸舱f(shuō)到了,一共有三個(gè)模塊,對(duì)應(yīng)了三個(gè)上下文,分別是配置數(shù)據(jù)ConfigurationDbContext、操作數(shù)據(jù)PersistedGrantDbContext,然后最后才是用戶數(shù)據(jù)ApplicationDbContext ,前兩個(gè)是 IdentityServer4 的相關(guān)類(lèi)庫(kù),第三個(gè)其實(shí)不是 Ids4 官方的,而且 NetCore 自帶的一個(gè)類(lèi)庫(kù),只是幫助我們更好的處理用戶數(shù)據(jù)的。
我們使用前兩個(gè)上下文來(lái)實(shí)現(xiàn) Ids4 的去中心化認(rèn)證,而第三個(gè) ApplicationDbContext ?只是來(lái)存儲(chǔ)我們的用戶和角色數(shù)據(jù)的。
因此!這個(gè)時(shí)候我們知道了,其實(shí)我們無(wú)論怎么處理用戶和角色數(shù)據(jù),是不會(huì)影響 Ids4 整體性操作的,這個(gè)時(shí)候我們就恍然大悟了,這個(gè)時(shí)候兩種方案就出來(lái)了:
?
?2、自定義封裝,實(shí)現(xiàn)用戶角色數(shù)據(jù)的持久化
這個(gè)第一種就是自己封裝一個(gè) Repository 倉(cāng)儲(chǔ)的方式,然后搭配 EFCore 持久化,還可以寫(xiě)多個(gè)上下文等等。這種就是我們自定義的開(kāi)發(fā),這種好處很明顯,就是可以很好的進(jìn)行擴(kuò)展和自定義處理,而且匹配多個(gè)上下文,還可以支持事務(wù)等等,如果自己能力較高,或者說(shuō),身邊正好有這么一個(gè)項(xiàng)目案例,可以對(duì)比著學(xué)習(xí)學(xué)習(xí),搭建搭建,今天我就不詳細(xì)的說(shuō)這個(gè)了,下次給大家詳細(xì)說(shuō)明,大家這個(gè)時(shí)候應(yīng)該懂了,我們開(kāi)發(fā) Ids4 的思路,無(wú)非就是一個(gè)持久化的過(guò)程,之所以使用 Ids4 這個(gè)框架,僅僅是使用了 Ids4 封裝了很豐富的、去中心化的 Token 生成機(jī)制而已。
我這里簡(jiǎn)單舉個(gè)例子,可以這么配置,看個(gè)思路就行了,代碼不完整,我以后會(huì)詳細(xì)說(shuō)明,這里僅僅是展示一下:
// 配置上下文public class MyDbContext : DbContext, IConfigurationDbContext { private readonly ConfigurationStoreOptions storeOptions;public MyDbContext(DbContextOptions<MyDbContext> options) : base(options) { }public DbSet<User> User { get; set; } public DbSet<Role> Roles { get; set; } public DbSet<ApiResource> ApiResources { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ConfigureClientContext(storeOptions); modelBuilder.ConfigureResourcesContext(storeOptions);
base.OnModelCreating(modelBuilder); } }
// 正常的注入服務(wù)services.AddDbContext<MyDbContext>(builder => builder.UseOracle(connectionString, options => { options.MigrationsAssembly(migrationsAssembly); options.MigrationsHistoryTable("xxxx"); }));
?
上邊的實(shí)體類(lèi)可以自定義處理,然后我們?cè)賹?xiě)一個(gè)倉(cāng)儲(chǔ),或者是一個(gè)類(lèi),來(lái)處理數(shù)據(jù):
public class UserRepository { private readonly MyDbContext _dbContext; public UserRepository(MyDbContext dbContext) { _dbContext = dbContext; } /// <summary> /// 根據(jù)SubjectID查詢用戶信息 /// </summary> /// <param name="subjectId">用戶id</param> /// <returns></returns> public IdentityUser FindBySubjectId(string subjectId) { return _dbContext.Set<User>().Where(r => r.SubjectId.Equals(subjectId)).Include(r => r.IdentityUserClaims).SingleOrDefault(); } /// <summary> /// 根據(jù)用戶名查詢用戶 /// </summary> /// <param name="username">用戶</param> /// <returns></returns> public IdentityUser FindByUsername(string username) { return _dbContext.Set<User>().Where(r => r.Username.Equals(username)).Include(r => r.IdentityUserClaims).SingleOrDefault(); }}?
但是如果自己能力不是很高,或者說(shuō)不想太麻煩的話,可以使用 IdentityServer4 中 Identity 自帶的,封裝好的一套邏輯來(lái)處理,就比如我之前來(lái)處理用戶數(shù)據(jù)的時(shí)候,用的就是 UserManager 類(lèi),我們這時(shí)候就使用一個(gè) RoleManager.cs 類(lèi)。
?
?3、使用NetCore自帶 Identity 庫(kù)
?
這個(gè)其實(shí)是很簡(jiǎn)單的,我們看一下 UserManager 類(lèi)的命名空間就知道了,這個(gè)是微軟原生自帶的類(lèi)庫(kù),和 Ids4 其實(shí)沒(méi)有太大的關(guān)系:
?
?
這個(gè)類(lèi)庫(kù)的名字和 Ids4 也很像,就是叫做 Identity ,一共七個(gè)表,來(lái)處理用戶和角色的關(guān)系:
?
?
很簡(jiǎn)單,很方便,也很豐富,那今天我們就先說(shuō)說(shuō)這個(gè)第二種方案,第一種方案,我們下次再說(shuō)。
?
?
?
?三、利用 Identity 原生結(jié)構(gòu),處理角色信息
?
?1、自定義 Role 擴(kuò)展實(shí)體類(lèi)
我們既然要對(duì) Role 進(jìn)行管理,那我們就需要做下封裝,Ids4 默認(rèn)自帶的 IdentityRole 表,僅僅只要三個(gè)屬性:
public virtual TKey Id {get;set}public virtual string Name{get;set}public virtual string NormalizedName{get;set}?
這是肯定不夠用的,不僅不夠用,我們還需要和資源服務(wù)器 Blog.Core 項(xiàng)目打通,所以兩個(gè)實(shí)體類(lèi)要取并集,就是求最全的屬性,那我就自定義了一個(gè)應(yīng)用角色表,用來(lái)滿足和 Blog.Core 項(xiàng)目的統(tǒng)一:
在項(xiàng)目的 Models 文件夾下,新建 ApplicationRole.cs 類(lèi):
// Add profile data for application roles by adding properties to the ApplicationRole classpublic class ApplicationRole : IdentityRole<int>{public bool IsDeleted { get; set; } public string Description { get; set; } /// <summary> ///排序 /// </summary> public int OrderSort { get; set; } /// <summary> /// 是否激活 /// </summary> public bool Enabled { get; set; } /// <summary> /// 創(chuàng)建ID /// </summary> public int? CreateId { get; set; } /// <summary> /// 創(chuàng)建者 /// </summary> public string CreateBy { get; set; } /// <summary> /// 創(chuàng)建時(shí)間 /// </summary> public DateTime? CreateTime { get; set; } = DateTime.Now; /// <summary> /// 修改ID /// </summary> public int? ModifyId { get; set; } /// <summary> /// 修改者 /// </summary> public string ModifyBy { get; set; } /// <summary> /// 修改時(shí)間 /// </summary> public DateTime? ModifyTime { get; set; } = DateTime.Now;
// 同理我們需要?jiǎng)?chuàng)建一個(gè) ApplicationUserRole 關(guān)系表,具體的看我源碼吧 public ICollection<ApplicationUserRole> UserRoles { get; set; }
?
這里可以做任何的自定義,只不過(guò)這里有一個(gè)小的問(wèn)題,那就是這個(gè) Id 的問(wèn)題,我們的 Blog.Core 項(xiàng)目使用的是 Int 整型自增,那 IdentityServer4 用的是 string 方式,所以說(shuō),這里要做下處理,一般有兩種辦法,一種是把 IdentityServer4 項(xiàng)目的string 全部切換成 int,然后還有一種,就是修改 Blog.Core 資源服務(wù)器的主鍵 Id 為 Guid string?.
其實(shí)這兩種都可以,而且一般人都是采用的 Guid 和 string 的形式,但是很不巧的是,我的 Blog.Core 項(xiàng)目使用的是 Int 類(lèi)型,所以,這里我就統(tǒng)一修改成 int,大家根據(jù)需要自己處理吧,具體如何處理 int 呢,大家多注意下文的類(lèi)型就行,我會(huì)點(diǎn)明注意的點(diǎn)。
?
?2、修改注入的 Identity 服務(wù)
我們需要把我們的 ApplicationRole 信息也注入到服務(wù)里去,這里不多說(shuō):
services.AddIdentity<ApplicationUser, ApplicationRole>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddDefaultTokenProviders();?
3、修改應(yīng)用上下文
因?yàn)槲覀冏远x了 ApplicationRole ,所以在數(shù)據(jù)庫(kù)上下文中,也需要對(duì) Role 信息單獨(dú)做處理,而且還比較麻煩,這個(gè)具體的,可以通過(guò) F12 查看源碼就能了解到相應(yīng)的邏輯,咱們就直接這么修改:
// 注意下 紅色的 int類(lèi)型,到時(shí)候創(chuàng)建的表的主鍵是 int 類(lèi)型的。 public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, int, IdentityUserClaim<int>, ApplicationUserRole, IdentityUserLogin<int>, IdentityRoleClaim<int>, IdentityUserToken<int>> { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder);
builder.Entity<ApplicationUserRole>(userRole => { userRole.HasKey(ur => new { ur.UserId, ur.RoleId });
userRole.HasOne(ur => ur.Role) .WithMany(r => r.UserRoles) .HasForeignKey(ur => ur.RoleId) .IsRequired();
userRole.HasOne(ur => ur.User) .WithMany(r => r.UserRoles) .HasForeignKey(ur => ur.UserId) .IsRequired(); });
builder.Entity<ApplicationRole>() .ToTable("Role"); }
?
?
4、數(shù)據(jù)庫(kù)遷移,生成 DB
打開(kāi)我們的控制臺(tái):工具 -》Nuget 包管理器 -》程序包管理器控制臺(tái),
1、add-migration UpdateRole -Context ApplicationDbContext
2、update-database -Context ApplicationDbContext?
?
這里來(lái)一個(gè)動(dòng)圖:
?
?
然后我們可以看看生成的數(shù)據(jù)庫(kù)表結(jié)構(gòu),可以看到,和之前的表結(jié)構(gòu),幾乎是一樣的,可以看到我們右側(cè)的 Identity 生成的表結(jié)構(gòu),不僅主鍵變成了一樣的 Int 類(lèi)型,相關(guān)的屬性字段也都有,如果你有強(qiáng)迫癥的話,也可以把字段的長(zhǎng)度設(shè)為一致,還記得在哪里修改把,就是上下文里,這里不多說(shuō):
?
??
這里有一個(gè)要注意一下,如果我們什么都不操作,默認(rèn)生成的數(shù)據(jù)庫(kù)表名是 AspNetRoles ,我們也可以自定義修改成自己的表名,直接修改實(shí)體類(lèi)名是不行的,因?yàn)槲覀兛梢钥匆幌律傻倪w移記錄,無(wú)論修改成什么,只要我們的擴(kuò)展實(shí)體類(lèi)是繼承了類(lèi)IdentityRole,那表名還是默認(rèn)的 AspNetRoles:
?
?
那我們可以通過(guò)配置EFCore 的實(shí)體映射來(lái)做相應(yīng)的處理,還記得我們剛剛的上下文么,就是這里:
?
?
然后我們做一下數(shù)據(jù)庫(kù)遷移,最后我們可以看到數(shù)據(jù)庫(kù)表名已經(jīng)變了,具體的可以查看上邊的遷移對(duì)比圖。
完成!是不是這么寫(xiě)已經(jīng)完成了呢,不是的,現(xiàn)在只是完成了一半,剩下的一半,就是在控制器里,去進(jìn)行業(yè)務(wù)邏輯設(shè)計(jì)了。?
?
5、設(shè)計(jì)角色的 CURD 頁(yè)面與業(yè)務(wù)邏輯
先構(gòu)造函數(shù)注入下我們的 RoleManager 服務(wù),這是 IdentityServer4 已經(jīng)給我們封裝好的類(lèi):
?
?
然后設(shè)計(jì)接口,主要就是增刪改查,很簡(jiǎn)單,當(dāng)然,你也可以像用戶管理那樣,帶上權(quán)限信息:
[HttpGet] [Route("account/Roleregister")] public IActionResult RoleRegister(string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; return View(); }[HttpPost] [Route("account/Roleregister")] [ValidateAntiForgeryToken] public async Task<IActionResult> RoleRegister(RoleRegisterViewModel model, string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; IdentityResult result = new IdentityResult();
if (ModelState.IsValid) { var roleItem = _roleManager.FindByNameAsync(model.RoleName).Result;
if (roleItem == null) { var role = new ApplicationRole { Name = model.RoleName };
result = await _roleManager.CreateAsync(role);
if (result.Succeeded) { if (result.Succeeded) { return RedirectToLocal(returnUrl); } }
} else { ModelState.AddModelError(string.Empty, $"{roleItem?.Name} already exists");
}
AddErrors(result); }
// If we got this far, something failed, redisplay form return View(model); }
[HttpGet] [Route("account/Roles")] [Authorize] public IActionResult Roles(string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; var roles = _roleManager.Roles.Where(d => !d.IsDeleted).ToList(); return View(roles); }
[HttpGet("{id}")] [Route("account/Roleedit/{id}")] [Authorize(Roles = "SuperAdmin")] public async Task<IActionResult> RoleEdit(string id, string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; if (id == null) { return NotFound(); }
var user = await _roleManager.FindByIdAsync(id);
if (user == null) { return NotFound(); }
return View(new RoleEditViewModel(user.Id, user.Name)); }
[HttpPost] [Route("account/Roleedit/{id}")] [ValidateAntiForgeryToken] [Authorize(Roles = "SuperAdmin")] public async Task<IActionResult> RoleEdit(RoleEditViewModel model, string id, string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; IdentityResult result = new IdentityResult();
if (ModelState.IsValid) { var roleItem = _roleManager.FindByIdAsync(model.Id).Result;
if (roleItem != null) { roleItem.Name = model.RoleName;
result = await _roleManager.UpdateAsync(roleItem);
if (result.Succeeded) { return RedirectToLocal(returnUrl); }
} else { ModelState.AddModelError(string.Empty, $"{roleItem?.Name} no exist!"); }
AddErrors(result); }
// If we got this far, something failed, redisplay form return View(model); }
[HttpPost] [Route("account/Roledelete/{id}")] [Authorize(Roles = "SuperAdmin")] public async Task<JsonResult> RoleDelete(string id) { IdentityResult result = new IdentityResult();
if (ModelState.IsValid) { var roleItem = _roleManager.FindByIdAsync(id).Result;
if (roleItem != null) { roleItem.IsDeleted = true;
result = await _roleManager.UpdateAsync(roleItem);
if (result.Succeeded) { return Json(result); } } else { ModelState.AddModelError(string.Empty, $"{roleItem?.Name} no exist!"); }
AddErrors(result); }
return Json(result.Errors);
}
?
那剩下的就是我們修改用戶的角色信息了,畢竟我們要給用戶進(jìn)行加權(quán)限,也就是賦角色操作嘛。但是。
時(shí)間很晚了,篇幅太長(zhǎng)了,今天就暫時(shí)先到這里了,總結(jié)來(lái)說(shuō),今天主要是把角色的相關(guān)操作給完整的走了一遍,還是很不錯(cuò)的,很有收獲的,以后更精彩,下次再見(jiàn)。
?
?
四、Github && Gitee
https://github.com/anjoy8/Blog.IdentityServer
?
往期精彩 Ids4 文章
多項(xiàng)目集成統(tǒng)一認(rèn)證中心的思考
【詳細(xì)內(nèi)容,點(diǎn)擊原文,查看博客園】
總結(jié)
以上是生活随笔為你收集整理的【.NETCore 3】Ids4 ║ 统一角色管理(上)的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 扎心了,程序员2017到2019经历了什
- 下一篇: .NET Core 3.0 使用Nswa