記一次使用Asp.Net Core WebApi 5.0+Dapper+Mysql+Redis+Docker的開發過程

夏贊長發表於2021-01-13

前言

我可能有三年沒怎麼碰C#了,目前的工作是在全職搞前端,最近有時間抽空看了一下Asp.net Core,Core版本號都到了5.0了,也越來越好用了,下面將記錄一下這幾天以來使用Asp.Net Core WebApi+Dapper+Mysql+Redis+Docker的一次開發過程。

專案結構

最終專案結構如下,CodeUin.Dapper資料訪問層,CodeUin.WebApi應用層,其中涉及到具體業務邏輯的我將直接寫在Controllers中,不再做過多分層。CodeUin.Helpers我將存放一些專案的通用幫助類,如果是隻涉及到當前層的幫助類將直接在所在層級種的Helpers資料夾中儲存即可。

專案結構

安裝環境

MySQL

# 下載映象
docker pull mysql
# 執行
docker run -itd --name 容器名稱 -p 3306:3306 -e MYSQL_ROOT_PASSWORD=你的密碼 mysql

如果正在使用的客戶端工具連線MySQL提示1251,這是因為客戶端不支援新的加密方式造成的,解決辦法如下。

1251

# 檢視當前執行的容器
docker ps 
# 進入容器
docker exec -it 容器名稱 bash
# 訪問MySQL
mysql -u root -p
# 檢視加密規則
select host,user,plugin,authentication_string from mysql.user;
# 對遠端連線進行授權
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
# 更改密碼加密規則
ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY '你的密碼';
# 重新整理許可權
flush privileges;

最後,使用MySQL客戶端工具進行連線測試,我使用的工具是Navicat Premium。

MySQL

Redis

# 下載映象
docker pull redis
# 執行
docker run -itd -p 6379:6379 redis

使用Redis客戶端工具進行連線測試,我使用的工具是Another Redis DeskTop Manager。

Redis

.NET 環境

伺服器我使用的是CentOS 8,使用的NET SDK版本5.0,下面將記錄我是如何在CentOS 8中安裝.NET SDK和.NET執行時的。

# 安裝SDK
sudo dnf install dotnet-sdk-5.0
# 安裝執行時
sudo dnf install aspnetcore-runtime-5.0

檢查是否安裝成功,使用dotnet --info命令檢視安裝資訊

SDK

建立專案

下面將實現一個使用者的登入註冊,和獲取使用者資訊的小功能。

資料服務層

該層設計參考了 玉龍雪山 的架構,我也比較喜歡這種結構,一看結構就知道是要做什麼的,簡單清晰。

首先,新建一個專案命名為CodeUin.Dapper,只用來提供介面,為業務層服務。

  • Entities
    • 存放實體類
  • IRepository
    • 存放倉庫介面
  • Repository
    • 存放倉庫介面實現類
  • BaseModel
    • 實體類的基類,用來存放通用欄位
  • DataBaseConfig
    • 資料訪問配置類
  • IRepositoryBase
    • 存放最基本的倉儲介面 增刪改查等
  • RepositoryBase
    • 基本倉儲介面的具體實現

Dapper

建立BaseModel基類

該類存放在專案的根目錄下,主要作用是將資料庫實體類中都有的欄位獨立出來。

using System;

namespace CodeUin.Dapper
{
    /// <summary>
    /// 基礎實體類
    /// </summary>
    public class BaseModel
    {
        /// <summary>
        /// 主鍵Id
        /// </summary>
        public int Id { get; set; }

        /// <summary>
        /// 建立時間
        /// </summary>
        public DateTime CreateTime { get; set; }
    }
}

建立DataBaseConfig類

該類存放在專案的根目錄下,我這裡使用的是MySQL,需要安裝以下依賴包,如果使用的其他資料庫,自行安裝對應的依賴包即可。

依賴

該類具體程式碼如下:

using MySql.Data.MySqlClient;
using System.Data;

namespace CodeUin.Dapper
{
    public class DataBaseConfig
    {
        private static string MySqlConnectionString = @"Data Source=資料庫地址;Initial Catalog=codeuin;Charset=utf8mb4;User 		ID=root;Password=資料庫密碼;";
        
        public static IDbConnection GetMySqlConnection(string sqlConnectionString = null)
        {
            if (string.IsNullOrWhiteSpace(sqlConnectionString))
            {
                sqlConnectionString = MySqlConnectionString;
            }
            IDbConnection conn = new MySqlConnection(sqlConnectionString);
            conn.Open();
            return conn;
        }
    }
}

建立IRepositoryBase類

該類存放在專案的根目錄下,存放常用的倉儲介面。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace CodeUin.Dapper
{
    public interface IRepositoryBase<T>
    {
        Task<int> Insert(T entity, string insertSql);

        Task Update(T entity, string updateSql);

        Task Delete(int Id, string deleteSql);

        Task<List<T>> Select(string selectSql);

        Task<T> Detail(int Id, string detailSql);
    }
}

建立RepositoryBase類

該類存放在專案的根目錄下,是IRepositoryBase類的具體實現。

using Dapper;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;

namespace CodeUin.Dapper
{
    public class RepositoryBase<T> : IRepositoryBase<T>
    {
        public async Task Delete(int Id, string deleteSql)
        {
            using (IDbConnection conn = DataBaseConfig.GetMySqlConnection())
            {
                await conn.ExecuteAsync(deleteSql, new { Id });
            }
        }

        public async Task<T> Detail(int Id, string detailSql)
        {
            using (IDbConnection conn = DataBaseConfig.GetMySqlConnection())
            {
                return await conn.QueryFirstOrDefaultAsync<T>(detailSql, new { Id });
            }
        }

        public async Task<List<T>> ExecQuerySP(string SPName)
        {
            using (IDbConnection conn = DataBaseConfig.GetMySqlConnection())
            {
                return await Task.Run(() => conn.Query<T>(SPName, null, null, true, null, CommandType.StoredProcedure).ToList());
            }
        }

        public async Task<int> Insert(T entity, string insertSql)
        {
            using (IDbConnection conn = DataBaseConfig.GetMySqlConnection())
            {
                return await conn.ExecuteAsync(insertSql, entity);
            }
        }

        public async Task<List<T>> Select(string selectSql)
        {
            using (IDbConnection conn = DataBaseConfig.GetMySqlConnection())
            {
                return await Task.Run(() => conn.Query<T>(selectSql).ToList());
            }
        }

        public async Task Update(T entity, string updateSql)
        {
            using (IDbConnection conn = DataBaseConfig.GetMySqlConnection())
            {
                await conn.ExecuteAsync(updateSql, entity);
            }
        }
    }
}

好了,基礎類基本已經定義完成。下面將新建一個Users類,並定義幾個常用的介面。

建立Users實體類

該類存放在Entities資料夾中,該類繼承BaseModel。

namespace CodeUin.Dapper.Entities
{
    /// <summary>
    /// 使用者表
    /// </summary>
    public class Users : BaseModel
    {
        /// <summary>
        /// 使用者名稱
        /// </summary>
        public string UserName { get; set; }

        /// <summary>
        /// 密碼
        /// </summary>
        public string Password { get; set; }

        /// <summary>
        /// 鹽
        /// </summary>
        public string Salt { get; set; }

        /// <summary>
        /// 郵箱
        /// </summary>
        public string Email { get; set; }

        /// <summary>
        /// 手機號
        /// </summary>
        public string Mobile { get; set; }

        /// <summary>
        /// 性別
        /// </summary>
        public int Gender { get; set; }

        /// <summary>
        /// 年齡
        /// </summary>
        public int Age { get; set; }

        /// <summary>
        /// 頭像
        /// </summary>
        public string Avatar { get; set; }

        /// <summary>
        /// 是否刪除
        /// </summary>
        public int IsDelete { get; set; }
    }
}

建立IUserRepository類

該類存放在IRepository資料夾中,繼承IRepositoryBase,並定義了額外的介面。

using CodeUin.Dapper.Entities;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace CodeUin.Dapper.IRepository
{
    public interface IUserRepository : IRepositoryBase<Users>
    {
        Task<List<Users>> GetUsers();

        Task<int> AddUser(Users entity);

        Task DeleteUser(int d);

        Task<Users> GetUserDetail(int id);

        Task<Users> GetUserDetailByEmail(string email);
    }
}

建立UserRepository類

該類存放在Repository資料夾中,繼承RepositoryBase, IUserRepository ,是IUserRepository類的具體實現。

using CodeUin.Dapper.Entities;
using CodeUin.Dapper.IRepository;
using Dapper;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;

namespace CodeUin.Dapper.Repository
{
    public class UserRepository : RepositoryBase<Users>, IUserRepository
    {
        public async Task DeleteUser(int id)
        {
            string deleteSql = "DELETE FROM [dbo].[Users] WHERE Id=@Id";
            await Delete(id, deleteSql);
        }


        public async Task<Users> GetUserDetail(int id)
        {
            string detailSql = @"SELECT Id, Email, UserName, Mobile, Password, Age, Gender, CreateTime,Salt, IsDelete FROM Users WHERE Id=@Id";
            return await Detail(id, detailSql);
        }

        public async Task<Users> GetUserDetailByEmail(string email)
        {
            string detailSql = @"SELECT Id, Email, UserName, Mobile, Password, Age, Gender, CreateTime, Salt, IsDelete FROM Users WHERE Email=@email";

            using (IDbConnection conn = DataBaseConfig.GetMySqlConnection())
            {
                return await conn.QueryFirstOrDefaultAsync<Users>(detailSql, new { email });
            }
        }

        public async Task<List<Users>> GetUsers()
        {
            string selectSql = @"SELECT * FROM Users";
            return await Select(selectSql);
        }

        public async Task<int> AddUser(Users entity)
        {
            string insertSql = @"INSERT INTO Users (UserName, Gender, Avatar, Mobile, CreateTime, Password, Salt, IsDelete, Email) VALUES (@UserName, @Gender, @Avatar, @Mobile, now(),@Password, @Salt, @IsDelete,@Email);SELECT @id= LAST_INSERT_ID();";
            return await Insert(entity, insertSql);
        }
    }
}

大功告成,接下來需要手動建立資料庫和表結構,不能像使用EF那樣自動生成了,使用Dapper基本上是要純寫SQL的,如果想像EF那樣使用,就要額外的安裝一個擴充套件 Dapper.Contrib

資料庫表結構如下,比較簡單。

DROP TABLE IF EXISTS `Users`;
CREATE TABLE `Users` (
  `Id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `Email` varchar(255) DEFAULT NULL COMMENT '郵箱',
  `UserName` varchar(20) DEFAULT NULL COMMENT '使用者名稱稱',
  `Mobile` varchar(11) DEFAULT NULL COMMENT '手機號',
  `Age` int(11) DEFAULT NULL COMMENT '年齡',
  `Gender` int(1) DEFAULT '0' COMMENT '性別',
  `Avatar` varchar(255) DEFAULT NULL COMMENT '頭像',
  `Salt` varchar(255) DEFAULT NULL COMMENT '加鹽',
  `Password` varchar(255) DEFAULT NULL COMMENT '密碼',
  `IsDelete` int(2) DEFAULT '0' COMMENT '0-正常 1-刪除',
  `CreateTime` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
  PRIMARY KEY (`Id`),
  UNIQUE KEY `USER_MOBILE_INDEX` (`Mobile`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=10000 DEFAULT CHARSET=utf8mb4 COMMENT='使用者資訊表';

好了,資料訪問層大概就這樣子了,下面來看看應用層的具體實現方式。

應用程式層

建立一個WebApi專案,主要對外提供Api介面服務,具體結構如下。

  • Autofac
    • 存放IOC 依賴注入的配置項
  • AutoMapper
    • 存放實體物件對映關係的配置項
  • Controllers
    • 控制器,具體業務邏輯也將寫在這
  • Fliters
    • 存放自定義的過濾器
  • Helpers
    • 存放本層中用到的一些幫助類
  • Models
    • 存放輸入/輸出/DTO等實體類

WebApi

好了,結構大概就是這樣。錯誤優先,先處理程式異常,和整合日誌程式吧。

自定義異常處理

在Helpers資料夾中建立一個ErrorHandingMiddleware中介軟體,新增擴充套件方法ErrorHandlingExtensions,在Startup中將會使用到。

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Threading.Tasks;

namespace CodeUin.WebApi.Helpers
{
    public class ErrorHandlingMiddleware
    {
        private readonly RequestDelegate next;
        private readonly ILogger<ErrorHandlingMiddleware> _logger;

        public ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger)
        {
            this.next = next;
            _logger = logger;
        }

        public async Task Invoke(HttpContext context)
        {
            try
            {
                await next(context);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message);

                var statusCode = 500;

                await HandleExceptionAsync(context, statusCode, ex.Message);
            }
            finally
            {
                var statusCode = context.Response.StatusCode;
                var msg = "";

                if (statusCode == 401)
                {
                    msg = "未授權";
                }
                else if (statusCode == 404)
                {
                    msg = "未找到服務";
                }
                else if (statusCode == 502)
                {
                    msg = "請求錯誤";
                }
                else if (statusCode != 200)
                {
                    msg = "未知錯誤";
                }
                if (!string.IsNullOrWhiteSpace(msg))
                {
                    await HandleExceptionAsync(context, statusCode, msg);
                }
            }
        }

        // 異常錯誤資訊捕獲,將錯誤資訊用Json方式返回
        private static Task HandleExceptionAsync(HttpContext context, int statusCode, string msg)
        {
            var result = JsonConvert.SerializeObject(new { Msg = msg, Code = statusCode });

            context.Response.ContentType = "application/json;charset=utf-8";

            return context.Response.WriteAsync(result);
        }
    }

    // 擴充套件方法
    public static class ErrorHandlingExtensions
    {
        public static IApplicationBuilder UseErrorHandling(this IApplicationBuilder builder)
        {
            return builder.UseMiddleware<ErrorHandlingMiddleware>();
        }
    }
}

最後,在 Startup 的 Configure 方法中新增 app.UseErrorHandling() ,當程式傳送異常時,會走我們的自定義異常處理。

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

    app.UseHttpsRedirection();

    // 請求錯誤提示配置
    app.UseErrorHandling();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
  		endpoints.MapControllers();
    });
}

日誌程式

我這裡使用的是NLog,需要在專案中先安裝依賴包。

Nlog

首先在專案根目錄建立一個 nlog.config 的配置檔案,具體內容如下。

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true"
      internalLogLevel="Info"
      internalLogFile="c:\temp\internal-nlog.txt">

	<!-- enable asp.net core layout renderers -->
	<extensions>
		<add assembly="NLog.Web.AspNetCore"/>
	</extensions>

	<!-- the targets to write to -->
	<targets>

		<target xsi:type="File" name="allfile" fileName="${currentdir}\logs\nlog-all-${shortdate}.log"
				layout="${longdate}|${event-properties:item=EventId_Id}|${uppercase:${level}}|${aspnet-request-ip}|${logger}|${message} ${exception:format=tostring}" />

		<target xsi:type="Console" name="ownFile-web"
				layout="${longdate}|${event-properties:item=EventId_Id}|${uppercase:${level}}|${logger}|${aspnet-request-ip}|${message} ${exception:format=tostring}|url: ${aspnet-request-url}|action: ${aspnet-mvc-action}" />
	</targets>
	<!-- rules to map from logger name to target -->
	<rules>
		<!--All logs, including from Microsoft-->
		<logger name="*" minlevel="Info" writeTo="allfile" />

		<!--Skip non-critical Microsoft logs and so log only own logs-->
		<logger name="Microsoft.*" maxlevel="Info" final="true" />
		<!-- BlackHole without writeTo -->
		<logger name="*" minlevel="Info" writeTo="ownFile-web" />
	</rules>
</nlog>

更多配置資訊可以直接去官網檢視 https://nlog-project.org

最後,在 Program 入口檔案中整合 Nlog

using Autofac.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NLog.Web;

namespace CodeUin.WebApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            NLogBuilder.ConfigureNLog("nlog.config");
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .UseServiceProviderFactory(new AutofacServiceProviderFactory())
                .ConfigureLogging(logging =>
                {
                    logging.ClearProviders();
                    logging.AddConsole();
                })
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                })
                .UseNLog();
    }
}

現在,我們可以直接使用NLog了,使用方法可以檢視上面的 ErrorHandlingMiddleware 類中有使用到。

依賴注入

將使用 Autofac 來管理類之間的依賴關係,Autofac 是一款超級讚的.NET IoC 容器 。首先我們需要安裝依賴包。

Autofac

在 專案根目錄的 Autofac 資料夾中新建一個 CustomAutofacModule 類,用來管理我們類之間的依賴關係。

using Autofac;
using CodeUin.Dapper.IRepository;
using CodeUin.Dapper.Repository;

namespace CodeUin.WebApi.Autofac
{
    public class CustomAutofacModule:Module
    {
        protected override void Load(ContainerBuilder builder)
        {
            builder.RegisterType<UserRepository>().As<IUserRepository>();
        }
    }
}

最後,在 Startup 類中新增方法

public void ConfigureContainer(ContainerBuilder builder)
{
    // 依賴注入
    builder.RegisterModule(new CustomAutofacModule());
}

實體對映

將使用 Automapper 幫我們解決物件對映到另外一個物件中的問題,比如這種程式碼。

// 如果有幾十個屬性是相當的可怕的
var users = new Users
{
    Email = user.Email,
    Password = user.Password,
    UserName = user.UserName
};
// 使用Automapper就容易多了
var model = _mapper.Map<Users>(user);

先安裝依賴包

Automapper

在專案根目錄的 AutoMapper 資料夾中 新建 AutoMapperConfig 類,來管理我們的對映關係。

using AutoMapper;
using CodeUin.Dapper.Entities;
using CodeUin.WebApi.Models;

namespace CodeUin.WebApi.AutoMapper
{
    public class AutoMapperConfig : Profile
    {
        public AutoMapperConfig()
        {
            CreateMap<UserRegisterModel, Users>().ReverseMap();
            CreateMap<UserLoginModel, Users>().ReverseMap();
            CreateMap<UserLoginModel, UserModel>().ReverseMap();
            CreateMap<UserModel, Users>().ReverseMap();
        }
    }
}

最後,在 Startup 檔案的 ConfigureServices 方法中 新增 services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies()) 即可。

使用JWT

下面將整合JWT,來處理授權等資訊。首先,需要安裝依賴包。

JWT

修改 appsttings.json 檔案,新增 Jwt 配置資訊。

{
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    },
    "AllowedHosts": "*",
    "Jwt": {
        "Key": "e816f4e9d7a7be785a",  // 這個key必須大於16位數,非常生成的時候會報錯
        "Issuer": "codeuin.com"
    }
}

最後,在 Startup 類的 ConfigureServices 方法中新增 Jwt 的使用。

     services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    ValidateLifetime = true,
                    ClockSkew = TimeSpan.FromMinutes(5),   //緩衝過期時間 預設5分鐘
                    ValidateIssuerSigningKey = true,
                    ValidIssuer = Configuration["Jwt:Issuer"],
                    ValidAudience = Configuration["Jwt:Issuer"],
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]))
                };
            });

好了,最終我們的 Startup 類是這樣子的,關於自定義的引數驗證後面會講到。

using Autofac;
using AutoMapper;
using CodeUin.WebApi.Autofac;
using CodeUin.WebApi.Filters;
using CodeUin.WebApi.Helpers;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Text;

namespace CodeUin.WebApi
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureContainer(ContainerBuilder builder)
        {
            // 依賴注入
            builder.RegisterModule(new CustomAutofacModule());
        }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidateAudience = true,
                    ValidateLifetime = true,
                    ClockSkew = TimeSpan.FromMinutes(5),   //緩衝過期時間 預設5分鐘
                    ValidateIssuerSigningKey = true,
                    ValidIssuer = Configuration["Jwt:Issuer"],
                    ValidAudience = Configuration["Jwt:Issuer"],
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]))
                };
            });

            services.AddHttpContextAccessor();

            // 使用AutoMapper
            services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());

            // 關閉引數自動校驗
            services.Configure<ApiBehaviorOptions>((options) =>
            {
                options.SuppressModelStateInvalidFilter = true;
            });

            // 使用自定義驗證器
            services.AddControllers(options =>
            {
                options.Filters.Add<ValidateModelAttribute>();
            }).
            AddJsonOptions(options =>
            {
                // 忽略null值
                options.JsonSerializerOptions.IgnoreNullValues = true;
            });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();

            // 請求錯誤提示配置
            app.UseErrorHandling();

            // 授權
            app.UseAuthentication();

            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

新建實體類

我將新建三個實體類,分別是 UserLoginModel 使用者登入,UserRegisterModel 使用者註冊,UserModel 使用者基本資訊。

UserLoginModel 和 UserRegisterModel 將根據我們在屬性中配置的特性自動驗證合法性,就不需要在控制器中單獨寫驗證邏輯了,極大的節省了工作量。

using System;
using System.ComponentModel.DataAnnotations;

namespace CodeUin.WebApi.Models
{
    /// <summary>
    /// 使用者實體類
    /// </summary>
    public class UserModel
    {
        public int Id { get; set; }

        public string Email { get; set; }
        public string UserName { get; set; }

        public string Mobile { get; set; }

        public int Gender { get; set; }

        public int Age { get; set; }

        public string Avatar { get; set; }
    }

    public class UserLoginModel
    {
        [Required(ErrorMessage = "請輸入郵箱")]
        public string Email { get; set; }

        [Required(ErrorMessage = "請輸入密碼")]
        public string Password { get; set; }
    }

    public class UserRegisterModel
    {
        [Required(ErrorMessage = "請輸入郵箱")]
        [EmailAddress(ErrorMessage = "請輸入正確的郵箱地址")]
        public string Email { get; set; }

        [Required(ErrorMessage = "請輸入使用者名稱")]
        [MaxLength(length: 12, ErrorMessage = "使用者名稱最大長度不能超過12")]
        [MinLength(length: 2, ErrorMessage = "使用者名稱最小長度不能小於2")]
        public string UserName { get; set; }

        [Required(ErrorMessage = "請輸入密碼")]
        [MaxLength(length: 20, ErrorMessage = "密碼最大長度不能超過20")]
        [MinLength(length: 6, ErrorMessage = "密碼最小長度不能小於6")]
        public string Password { get; set; }
    }
}

驗證器

在專案根目錄的 Filters 資料夾中 新增 ValidateModelAttribute 資料夾,將在 Action 請求中先進入我們的過濾器,如果不符合我們定義的規則將直接輸出錯誤項。

具體程式碼如下。

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System.Linq;

namespace CodeUin.WebApi.Filters
{
    public class ValidateModelAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuting(ActionExecutingContext context)
        {
            if (!context.ModelState.IsValid)
            {
                var item = context.ModelState.Keys.ToList().FirstOrDefault();

                //返回第一個驗證引數錯誤的資訊
                context.Result = new BadRequestObjectResult(new
                {
                    Code = 400,
                    Msg = context.ModelState[item].Errors[0].ErrorMessage
                });
            }
        }
    }
}

新增自定義驗證特性

有時候我們需要自己額外的擴充套件一些規則,只需要繼承 ValidationAttribute 類然後實現 IsValid 方法即可,比如我這裡驗證了中國的手機號碼。

using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;

namespace CodeUin.WebApi.Filters
{
    public class ChineMobileAttribute : ValidationAttribute
    {
        public override bool IsValid(object value)
        {
            if (!(value is string)) return false;

            var val = (string)value;

            return Regex.IsMatch(val, @"^[1]{1}[2,3,4,5,6,7,8,9]{1}\d{9}$");
        }
    }
}

實現登入註冊

我們來實現一個簡單的業務需求,使用者註冊,登入,和獲取使用者資訊,其他的功能都大同小異,無非就是CRUD!。

介面我們在資料服務層已經寫好了,接下來是處理業務邏輯的時候到了,將直接在 Controllers 中編寫。

新建一個控制器 UsersController ,業務很簡單,不過多介紹了,具體程式碼如下。

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using AutoMapper;
using CodeUin.Dapper.Entities;
using CodeUin.Dapper.IRepository;
using CodeUin.Helpers;
using CodeUin.WebApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;

namespace CodeUin.WebApi.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    [Authorize]
    public class UsersController : Controller
    {
        private readonly ILogger<UsersController> _logger;
        private readonly IUserRepository _userRepository;
        private readonly IMapper _mapper;
        private readonly IConfiguration _config;
        private readonly IHttpContextAccessor _httpContextAccessor;

        public UsersController(ILogger<UsersController> logger, IUserRepository userRepository, IMapper mapper, IConfiguration config, IHttpContextAccessor httpContextAccessor)
        {
            _logger = logger;
            _userRepository = userRepository;
            _mapper = mapper;
            _config = config;
            _httpContextAccessor = httpContextAccessor;
        }

        [HttpGet]
        public async Task<JsonResult> Get()
        {
            var userId = int.Parse(_httpContextAccessor.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value);

            var userInfo = await _userRepository.GetUserDetail(userId);

            if (userInfo == null)
            {
                return Json(new { Code = 200, Msg = "未找到該使用者的資訊" });
            }

            var outputModel = _mapper.Map<UserModel>(userInfo);

            return Json(new { Code = 200, Data = outputModel }); ;
        }

        [HttpPost]
        [AllowAnonymous]
        public async Task<JsonResult> Login([FromBody] UserLoginModel user)
        {
            // 查詢使用者資訊
            var data = await _userRepository.GetUserDetailByEmail(user.Email);

            // 賬號不存在
            if (data == null)
            {
                return Json(new { Code = 200, Msg = "賬號或密碼錯誤" });
            }

            user.Password = Encrypt.Md5(data.Salt + user.Password);

            // 密碼不一致
            if (!user.Password.Equals(data.Password))
            {
                return Json(new { Code = 200, Msg = "賬號或密碼錯誤" });
            }

            var userModel = _mapper.Map<UserModel>(data);

            // 生成token
            var token = GenerateJwtToken(userModel);

            // 存入Redis
            await new RedisHelper().StringSetAsync($"token:{data.Id}", token);

            return Json(new
            {
                Code = 200,
                Msg = "登入成功",
                Data = userModel,
                Token = token
            });
        }

        [HttpPost]
        [AllowAnonymous]
        public async Task<JsonResult> Register([FromBody] UserRegisterModel user)
        {
            // 查詢使用者資訊
            var data = await _userRepository.GetUserDetailByEmail(user.Email);

            if (data != null)
            {
                return Json(new { Code = 200, Msg = "該郵箱已被註冊" });
            }

            var salt = Guid.NewGuid().ToString("N");

            user.Password = Encrypt.Md5(salt + user.Password);

            var users = new Users
            {
                Email = user.Email,
                Password = user.Password,
                UserName = user.UserName
            };

            var model = _mapper.Map<Users>(user);

            model.Salt = salt;

            await _userRepository.AddUser(model);

            return Json(new { Code = 200, Msg = "註冊成功" });
        }

        /// <summary>
        /// 生成Token
        /// </summary>
        /// <param name="user">使用者資訊</param>
        /// <returns></returns>
        private string GenerateJwtToken(UserModel user)
        {
            var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
            var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

            var claims = new[] {
                new Claim(JwtRegisteredClaimNames.Email, user.Email),
                new Claim(JwtRegisteredClaimNames.Gender, user.Gender.ToString()),
                new Claim(ClaimTypes.NameIdentifier,user.Id.ToString()),
                new Claim(ClaimTypes.Name,user.UserName),
                new Claim(ClaimTypes.MobilePhone,user.Mobile??""),
            };

            var token = new JwtSecurityToken(_config["Jwt:Issuer"],
                _config["Jwt:Issuer"],
                claims,
                expires: DateTime.Now.AddMinutes(120),
                signingCredentials: credentials);

            return new JwtSecurityTokenHandler().WriteToken(token);
        }
    }
}

最後,來測試一下我們的功能,首先是註冊。

先來驗證一下我們的傳入的引數是否符合我們定義的規則。

輸入一個錯誤的郵箱號試試看!

註冊

ok,沒有問題,和我們在 UserRegisterModel 中 新增的驗證特性返回結果一致,最後我們測試一下完全符合規則的情況。

註冊成功

最後,註冊成功了,查詢下資料庫也是存在的。

user

我們來試試登入介面,在呼叫登入介面之前我們先來測試一下我們的配置的許可權驗證是否已經生效,在不登入的情況下直接訪問獲取使用者資訊介面。

未授權

直接訪問會返回未授權,那是因為我們沒有登入,自然也就沒有 Token,目前來看是沒問題的,但要看看我們傳入正確的Token 是否能過許可權驗證。

現在,我們需要呼叫登入介面,登入成功後會返回一個Token,後面的介面請求都需要用到,不然會無許可權訪問。

先來測試一下密碼錯誤的情況。

密碼錯誤

返回正確,符合我們的預期結果,下面將試試正確的密碼登入,看是否能夠返回我們想要的結果。

登入成功

登入成功,介面也返回了我們預期的結果,最後看看生成的 token 是否按照我們寫的邏輯那樣,存一份到 redis 當中。

redis

也是沒有問題的,和我們預想的一樣。

下面將攜帶正確的 token 請求獲取使用者資訊的介面,看看是否能夠正確返回。

獲取使用者資訊的介面不會攜帶任何引數,只會在請求頭的 Headers 中 新增 Authorization ,將我們正確的 token 傳入其中。

獲取使用者資訊

能夠正確獲取到我們的使用者資訊,也就是說我們的許可權這一塊也是沒有問題的了,下面將使用 Docker 打包部署到 Linux 伺服器中。

打包部署

在專案的根目錄下新增 Dockerfile 檔案,內容如下。

#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging.

FROM mcr.microsoft.com/dotnet/aspnet:5.0-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim AS build
WORKDIR /src
COPY ["CodeUin.WebApi/CodeUin.WebApi.csproj", "CodeUin.WebApi/"]
COPY ["CodeUin.Helpers/CodeUin.Helpers.csproj", "CodeUin.Helpers/"]
COPY ["CodeUin.Dapper/CodeUin.Dapper.csproj", "CodeUin.Dapper/"]
RUN dotnet restore "CodeUin.WebApi/CodeUin.WebApi.csproj"
COPY . .
WORKDIR "/src/CodeUin.WebApi"
RUN dotnet build "CodeUin.WebApi.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "CodeUin.WebApi.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "CodeUin.WebApi.dll"]

在 Dockerfile 檔案的目錄下執行打包命令

# 在當前資料夾(末尾的句點)中查詢 Dockerfile
docker build -t codeuin-api .
# 檢視映象
docker images
# 儲存映象到本地
docker save -o codeuin-api.tar codeuin-api

最後,將我們儲存的映象通過上傳的伺服器後匯入即可。

通過 ssh 命令 連線伺服器,在剛上傳包的目錄下執行匯入命令。

# 載入映象
docker load -i codeuin-api.tar
# 執行映象
docker run -itd -p 8888:80 --name codeuin-api codeuin-api
# 檢視執行狀態
docker stats

到此為止,我們整個部署工作已經完成了,最後在請求伺服器的介面測試一下是否ok。

伺服器請求

最終的結果也是ok的,到此為止,我們所有基礎的工作都完成了。

相關文章