EF Core SQL Server 添加视图映射

最近的项目(后端:.Net Core Web API 、数据库: SQL Server、前端:Blazor WebAssembly、前端Ui框架:Ant Design Blazor)中有些列表页面需要从后端拿到多表查询的结果,一种做法是从后端多个接口分别拿到数据在前端进行整合,比如:

@page "/orders"

@inject IDictService DictService
@inject IUserService UserService
@inject IOrderService OrderService
@inject IProductService ProductService
@inject ICustomerService CustomerService
@inject NavigationManager NavigationManager

@implements IDisposable

@attribute [Authorize]

<PageTitle>全部订单</PageTitle>

<Breadcrumb Style="margin:16px 0;">
    <BreadcrumbItem>订单</BreadcrumbItem>
    <BreadcrumbItem>全部订单</BreadcrumbItem>
</Breadcrumb>

<div class="site-layout-background" style="padding: 24px;">
    <GridRow>
        <GridCol Span="24">
            <Table TItem="OrderMainDTO" DataSource="@orders" @bind-PageSize="pageSize" ScrollX="1300px">
                <ChildContent>
                    <PropertyColumn Property="c=>c.OrderRequestNo" Title="订单申请编号" Sortable Filterable>
                        @context.OrderRequestNo
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.OrderCode" Title="外部订单编号" Sortable Filterable>
                        @context.OrderCode
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.CustomerId" Title="客户" Sortable Filterable>
                        <Template>
                            @{
                                var customer = CustomerService.Customers.FirstOrDefault(c => c.Id == context.CustomerId);
                                if (customer != null)
                                {
                                    @customer.CustomerName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.IndustryId" Title="行业" Sortable Filterable>
                        <Template>
                            @{
                                var industry = ProductService.Industries.FirstOrDefault(c => c.Id == context.IndustryId);
                                if (industry != null)
                                {
                                    @industry.IndustryName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ProductLineId" Title="产品系列" Sortable Filterable>
                        <Template>
                            @{
                                var productLine = ProductService.ProductLines.FirstOrDefault(c => c.Id == context.ProductLineId);
                                if (productLine != null)
                                {
                                    @productLine.ProductLineName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.SalesOwnerId" Title="销售负责人" Sortable Filterable>
                        <Template>
                            @{
                                var salesOwner = UserService.Employees.FirstOrDefault(c => c.Id == context.SalesOwnerId);
                                if (salesOwner != null)
                                {
                                    @salesOwner.FullName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ProductLineOwnerId" Title="产品负责人" Sortable Filterable>
                        <Template>
                            @{
                                var productLineOwner = UserService.Employees.FirstOrDefault(c => c.Id == context.ProductLineOwnerId);
                                if (productLineOwner != null)
                                {
                                    @productLineOwner.FullName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ApprovalStatus" Title="审批状态" Sortable Filterable>
                        @{
                            var color = "";
                            var approvalTag = "";
                            @switch (context.ApprovalStatus)
                            {
                                case 0:
                                    approvalTag = "草稿";
                                    color = "orange";
                                    break;
                                case 1:
                                    approvalTag = "审批中";
                                    color = "purple";
                                    break;
                                case 2:
                                    approvalTag = "审批完成";
                                    color = "green";
                                    break;
                                case 3:
                                    approvalTag = "拒绝";
                                    color = "red";
                                    break;
                                case 4:
                                    approvalTag = "退回";
                                    color = "magenta";
                                    break;
                                default:
                                    approvalTag = "无";
                                    color = "default";
                                    break;
                            }

                            <Tag Color="@color">
                                @approvalTag
                            </Tag>
                        }
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ExecutingStatus" Title="执行状态" Sortable Filterable>
                        @{
                            var color = "";
                            var executingTag = "";

                            @switch (context.ExecutingStatus)
                            {
                                case 0:
                                    executingTag = "未执行";
                                    color = "orange";
                                    break;
                                case 1:
                                    executingTag = "执行中";
                                    color = "geekblue";
                                    break;
                                case 2:
                                    executingTag = "执行完成";
                                    color = "green";
                                    break;
                                default:
                                    executingTag = "无";
                                    color = "default";
                                    break;
                            }

                            <Tag Color="@color">
                                @executingTag
                            </Tag>
                        }
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.SubmitterId" Title="提交人" Sortable Filterable>
                        <Template>
                            @{
                                var submitter = UserService.Employees.FirstOrDefault(c => c.Id == context.SubmitterId);
                                if (submitter != null)
                                {
                                    @submitter.FullName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.CreateTime" Title="提交时间" Format="yyyy-MM-dd hh:mm:ss" Sortable Filterable></PropertyColumn>
                     <ActionColumn Title="操作" Width="100" Fixed="right">
                         <Tooltip Title="查看">
                             <Button Shape="@ButtonShape.Circle" Icon="@IconType.Outline.Eye" OnClick="() => GoToOrder(context.Id)" />
                         </Tooltip>
                     </ActionColumn>
                 </ChildContent>
                 <PaginationTemplate>
                     <Pagination Class="@(context.PaginationClass + " my-custom-pagination")"
                                 Total="context.Total"
                                 PageSize="context.PageSize"
                                 Current="context.PageIndex"
                                        ShowSizeChanger
                                 OnChange="context.HandlePageChange" />
                 </PaginationTemplate>
             </Table>
         </GridCol>
     </GridRow>
 </div>

 @code {
    List<OrderMainDTO> orders = new();
    IEnumerable<Dict> dicts = [];
    int total => orders.Count();
    int pageSize = 20;

    void GoToOrder(Guid orderId)
    {
        NavigationManager.NavigateTo($"/orders/detail/{orderId}");
    }

    protected override async Task OnInitializedAsync()
    {
        var dictResponse = await DictService.GetDicts();
        if (dictResponse.Success)
        {
            dicts = dictResponse.Data?.Count > 0 ? dictResponse.Data : [];
        }

        // 获取所有行业数据
        await ProductService.GetIndustries();
        // 获取所有产品线数据
        await ProductService.GetProductLines();
        // 获取所有产品
        await ProductService.GetProducts();
        // 获取所有客户数据
        await CustomerService.GetCustomers();
        // 获取所有用户数据
        await UserService.GetEmployees();

        var result = await OrderService.GetOrders();
        if (result.Success)
        {
            orders = result.Data != null ? result.Data : [];
        }

        ProductService.OnChange += StateHasChanged;
        CustomerService.OnChange += StateHasChanged;
        UserService.OnChange += StateHasChanged;
    }

    public void Dispose()
    {
        ProductService.OnChange -= StateHasChanged;
        CustomerService.OnChange -= StateHasChanged;
        UserService.OnChange -= StateHasChanged;
    }
}

从这个Blazor WebAssembly 组件中可以看到为了实现一个列表分别调用了:
行业数据、产品线数据、客户数据、用户数据、字典数据等接口,并且在Table中进行适配,这种方式虽然能实现这个订单列表的页面,然而产生了一个和UI框架的冲突,如下图:

Ant Design Blazor 的 Table 带有过滤器(Filterable),由于 Table 中的各列是遍历的原始值,再通过适配转成了文本,这直接导致过滤器作用的是未适配前的原始值,比如截图中过滤器它应该过滤审批状态,你填入“审批完成”再点确定是查不到任何结果的,审批完成对应的是2,如果你输入2再查询是可以有结果的。

订单列表直接使用视图数据可以减少调用接口的数量也顺带解决掉Table过滤器的这个冲突,EF Core 如何映射视图呢?从微软的官方文档通过关键字没找到合适的内容,搜索引擎上找了一会儿拿到的内容一尝试还是错的,花了一些时间踩坑和试错。

  • 数据库中新建好视图
  • 后端新建一个用于适配该视图的模型
  • DbContext 数据库上下文中新增视图
  • 服务和Controller中修改数据库操作和查询返回的类型
  • 前端接口请求服务中类型修改
  • 前端组件中重新适配新接口以及绑定

新建视图-下列SQL Case When 遇到中文需要在引号前加N,否则会乱码

SELECT 
O.Id,
O.OrderRequestNo,
O.OrderCode,
C.CustomerName,
I.IndustryName,
PL.ProductLineName,
O.SalesOwnerId,
U1.FullName AS SalesOwner,
O.SubmitterId,
U3.FullName AS Submitter,
O.ProductLineOwnerId,
U2.FullName AS ProductLineOwner,
O.ApprovalStatus,
CASE
	WHEN O.ApprovalStatus = 0 THEN N'草稿'
	WHEN O.ApprovalStatus = 1 THEN N'审批中'
	WHEN O.ApprovalStatus = 2 THEN N'审批完成'
	WHEN O.ApprovalStatus = 3 THEN N'拒绝'
	WHEN O.ApprovalStatus = 4 THEN N'退回'
END AS ApprovalStatusName,
O.ExecutingStatus,
CASE
	WHEN O.ExecutingStatus = 0 THEN N'未执行'
	WHEN O.ExecutingStatus = 1 THEN N'执行中'
	WHEN O.ExecutingStatus = 2 THEN N'执行完成'
END AS ExecutingStatusName,
O.CreateTime,
O.IsDisabled
FROM [dbo].[T_OrderMains] AS O 
LEFT JOIN [dbo].[T_Industries] AS I ON O.IndustryId = I.Id
LEFT JOIN [dbo].[T_ProductLines] AS PL ON PL.Id = O.ProductLineId
LEFT JOIN [dbo].[T_Users] AS U1 ON U1.Id = O.SalesOwnerId
LEFT JOIN [dbo].[T_Users] AS U2 ON U2.Id = O.ProductLineOwnerId
LEFT JOIN [dbo].[T_Users] AS U3 ON U3.Id = O.SubmitterId
LEFT JOIN [dbo].[T_Customers] AS C ON C.Id = O.CustomerId

新建模型

namespace Gatcis.Plus.Shared.Models.OrderModel
{
    public class OrderView
    {
        public Guid Id { get; set; }
        public string? OrderRequestNo { get; set; }
        public string? OrderCode { get; set; }
        public string? CustomerName { get; set; }
        public string? IndustryName { get; set; }
        public string? ProductLineName { get; set; }
        public Guid SalesOwnerId { get; set; }
        public string? SalesOwner { get; set; }
        public Guid SubmitterId { get; set; }
        public string? Submitter { get; set; }
        public Guid ProductLineOwnerId { get; set; }
        public string? ProductLineOwner { get; set; }
        public int ApprovalStatus { get; set; }
        public string? ApprovalStatusName { get; set; }
        public int ExecutingStatus { get; set; }
        public string? ExecutingStatusName { get;set; }
        public DateTime CreateTime { get; set; }
        public bool IsDisabled { get; set; }
    }
}

DbContext 中新增视图-这样去映射视图是不需要进行 Migration (数据库迁移)的

namespace Gatcis.Plus.Service.Data
{
    public class IDbContext: IdentityDbContext<IUser, IRole, Guid>
    {
        // ... 其他
        // 订单视图
        public DbSet<OrderView> View_OrderLists { get; set; }
        public IDbContext(DbContextOptions<IDbContext> options) : base(options) { }
        protected override void OnModelCreating(ModelBuilder builder)
        {
            builder.Entity<OrderView>().ToView("View_OrderLists");
        }
    }
}

修改后端接口服务

        public async Task<ServiceResponse<List<OrderView>>> GetMyOrders(Guid operatorId)
        {
            var response = new ServiceResponse<List<OrderView>>();
            var orders = await context.View_OrderLists
                .Where(o => o.IsDisabled == false && (o.ProductLineOwnerId == operatorId || o.SalesOwnerId == operatorId || o.SubmitterId == operatorId))
                .OrderByDescending(o => o.CreateTime)
                .ToListAsync();

            response.Code = 200;
            response.Data = orders;

            return response;
        }

最终前端页面适配

@page "/orders/my"

@inject IOrderService OrderService
@inject NavigationManager NavigationManager

@attribute [Authorize]

<PageTitle>我的订单</PageTitle>

<Breadcrumb Style="margin:16px 0;">
    <BreadcrumbItem>订单</BreadcrumbItem>
    <BreadcrumbItem>我的订单</BreadcrumbItem>
</Breadcrumb>

<div class="site-layout-background" style="padding: 24px;">
    <GridRow>
        <GridCol Span="24">
            <Table TItem="OrderView" DataSource="@orders" @bind-PageSize="pageSize" ScrollX="1300px">
                <ChildContent>
                    <PropertyColumn Property="c=>c.OrderRequestNo" Title="订单申请编号" Sortable Filterable>
                        @context.OrderRequestNo
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.OrderCode" Title="外部订单编号" Sortable Filterable>
                        @context.OrderCode
                    </PropertyColumn>
                    <PropertyColumn  Property="c=>c.CustomerName" Title="客户" Sortable Filterable>
                        @context.CustomerName
                    </PropertyColumn>
                     <PropertyColumn Property="c=>c.IndustryName" Title="行业" Sortable Filterable>
                        @context.IndustryName
                     </PropertyColumn>
                     <PropertyColumn Property="c=>c.ProductLineName" Title="产品系列" Sortable Filterable>
                        @context.ProductLineName
                     </PropertyColumn>
                     <PropertyColumn Property="c=>c.SalesOwner" Title="销售负责人" Sortable Filterable>
                        @context.SalesOwner
                     </PropertyColumn>
                     <PropertyColumn Property="c=>c.ProductLineOwner" Title="产品负责人" Sortable Filterable>
                        @context.ProductLineOwner
                     </PropertyColumn>
                    <PropertyColumn Property="c=>c.ApprovalStatusName" Title="审批状态" Sortable Filterable>
                        @{
                            var color = "";
                            @switch (context.ApprovalStatus)
                            {
                                case 0:
                                    color = "orange";
                                    break;
                                case 1:
                                    color = "purple";
                                    break;
                                case 2:
                                    color = "green";
                                    break;
                                case 3:
                                    color = "red";
                                    break;
                                case 4:
                                    color = "magenta";
                                    break;
                            }

                            <Tag Color="@color">
                                @context.ApprovalStatusName
                            </Tag>
                        }
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ExecutingStatusName" Title="执行状态" Sortable Filterable>
                        @{
                            var color = "";
                            @switch (context.ExecutingStatus)
                            {
                                case 0:
                                    color = "orange";
                                    break;
                                case 1:
                                    color = "geekblue";
                                    break;
                                case 2:
                                    color = "green";
                                    break;
                            }

                            <Tag Color="@color">
                                @context.ExecutingStatusName
                            </Tag>
                        }
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.Submitter" Title="提交人" Sortable Filterable>
                        @context.Submitter
                    </PropertyColumn>
                     <PropertyColumn Property="c=>c.CreateTime" Title="提交时间" Format="yyyy-MM-dd hh:mm:ss" Sortable Filterable></PropertyColumn>
                     <ActionColumn Title="操作" Width="100" Fixed="right">
                        @if (context.ApprovalStatus == 1 && context.ProductLineOwnerId == currentUserId)
                        {
                            <Tooltip Title="审批">
                                <Button Shape="@ButtonShape.Circle" Icon="@IconType.Outline.Check" OnClick="() => GoToOrder(context.Id)" />
                            </Tooltip>
                        }
                        else
                        {
                            <Tooltip Title="查看">
                                <Button Shape="@ButtonShape.Circle" Icon="@IconType.Outline.Eye" OnClick="() => GoToOrder(context.Id)" />
                            </Tooltip>
                        }
                     </ActionColumn>
                 </ChildContent>
                 <PaginationTemplate>
                     <Pagination Class="@(context.PaginationClass + " my-custom-pagination")"
                                 Total="context.Total"
                                 PageSize="context.PageSize"
                                 Current="context.PageIndex"
                                        ShowSizeChanger
                                 OnChange="context.HandlePageChange" />
                 </PaginationTemplate>
             </Table>
         </GridCol>
     </GridRow>
 </div>

 @code {
    [CascadingParameter]
    private Task<AuthenticationState>? AuthenticationState { get; set; }

    public ClaimsPrincipal? authenticatedUser { get; set; }
    Guid? currentUserId = null;

    List<OrderView> orders = new();

    int total => orders.Count();
    int pageSize = 20;

    void GoToOrder(Guid orderId)
    {
        NavigationManager.NavigateTo($"/orders/detail/{orderId}");
    }

    protected override async Task OnInitializedAsync()
    {
        var result = await OrderService.GetMyOrders();
        if (result.Success)
        {
            orders = result.Data != null ? result.Data : [];
        }

        if (AuthenticationState is not null)
        {
            var state = await AuthenticationState;
            authenticatedUser = state.User;

            string? userId = authenticatedUser.FindFirst(c => c.Type.Contains("nameidentifier"))?.Value;
            if (userId != null)
            {
                currentUserId = Guid.Parse(userId);
            }
        }
    }
}

Blazor webassembly 初体验-Ecommerce

Blazor 是微软提供的 .NET 前端 Web 框架,它也能开发单页应用程序(SPA),对于开发者而言大部分的前端逻辑都可以使用 C# 去完成编写,这对于后端兼全栈开发而言似乎更友好,随着 .NET Core 增长,相信会有更多的人去学习和使用。

Blazor 有三种托管模型:

博主选择使用 Blazor WebAssembly 来练习开发一个 Ecommerce 应用,目前完成度大约 30% 。

Blazor 项目默认是有 Bootstrap 的 UI 框架的,本练习项目使用 MudBlazor UI 框架,IDE使用 Visual studio 2022,ORM 使用 EF Core,数据库使用 SQL Server,解决方案包含:Ecommerce.Server、Ecommerce.Client、Ecommerce.Shared 三个项目,分别是Web Api、SPA、共享模型。

Web Api Program.cs 截图

Web Api Swagger 截图

项目部分截图

等到写完,或许也会考虑开源,写代码花时间,写相关文档更花时间,不确定什么时候能够完成。

基于Redis缓存的Token失效策略实现

有这么一个简单场景,某个应用采用前后端分离架构,前端有用于浏览器访问的网站、手机APP、桌面APP等,前端通过JWT认证和后端接口进行交互,用户可能会出现相同账号同时多端登录的问题,能否在验证JWT时去避免呢?Token字符中只有类似用户名、用户ID、用户Role以及过期时间等信息,只要在Token未过期前验证Token是不能规避这种问题的。

这样看来,必须对Token字符有一个能比较出差异的服务器端保存,比如往Token字符中增加版本ID,当用户每次登录系统,登录接口都往数据库表的Token版本ID进行一次自增,再将Token版本ID写入Token发放到用户终端,程序自身再构建一个拦截器,用户端的每次请求所携带的JWT Token都会校验版本是否一致,比如请求中的Token版本小于了系统数据库表中的Token版本,就给用户返回 401 Unauthorized。

上述的,使用数据库存储Token版本号是一种解决办法,不过每次用户访问都会迫使拦截器进行一次数据库查询,考虑降低数据库的压力,使用Redis对Token进行缓存也是被广泛使用的一种机制。

Mack没有看过其他人如何去写这段代码,自己琢磨着写了一个示例代码,示范项目是一个.NET Core Web API项目,它包含以下包:

  • Microsoft.AspNetCore.Authentication.JwtBearer
  • Microsoft.AspNetCore.Identity.EntityFrameworkCore
  • Microsoft.AspNetCore.OpenApi
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.Tools
  • Microsoft.Extensions.Caching.StackExchangeRedis
  • Swashbuckle.AspNetCore
  • System.IdentityModel.Tokens.Jwt

项目使用了Identity 这个RBAC框架,对于数据库映射操作依赖EF Core,数据库使用Sql Server,为了进行缓存操作也安装了Redis。

用户的每次登录所生成的Token都会被写进Redis,代码如下:

/*Controllers AuthController.cs*/
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    [Produces("application/json")]
    public class AuthController : ControllerBase
    {
        private readonly UserManager<User> _userManager;
        private readonly IDistributedCache _distributedCache;
        public AuthController(UserManager<User> userManager, IDistributedCache distributedCache)
        {
            _userManager = userManager;
            _distributedCache = distributedCache;
        }
        private static string BuildToken(IEnumerable<Claim> claims, JWTSettings options)
        {
            DateTime expires = DateTime.Now.AddSeconds(options.ExpireSeconds);
            byte[] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey);
            var secKey = new SymmetricSecurityKey(keyBytes);
            var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
            var tokenDescriptor = new JwtSecurityToken(expires: expires, signingCredentials: credentials, claims: claims);
            return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
        }
        [HttpPost]
        public async Task<ActionResult> Login([FromBody] AuthLoginViewModel model, [FromServices]IOptions<JWTSettings> jwtSettingsOpt)
        {
            var userName = model.UserName;
            var password = model.Password;
            var user = await _userManager.FindByNameAsync(userName);
            if (user == null)
            {
                return BadRequest();
            }
            var result = await _userManager.CheckPasswordAsync(user, password);
            if (!result)
            {
                return BadRequest();
            }
            var claims = new List<Claim>();
            claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
            claims.Add(new Claim(ClaimTypes.Name, user.UserName!));

            var roles = await _userManager.GetRolesAsync(user);
            foreach (var role in roles)
            {
                claims.Add(new Claim(ClaimTypes.Role, role));
            }
            string jwtToken = BuildToken(claims, jwtSettingsOpt.Value);

            // 获取Redis缓存中的用户token
            string? s = await _distributedCache.GetStringAsync("token-" + user.UserName!);
            if (s == null)
            {
                // 没有token就写入
                await _distributedCache.SetStringAsync("token-" + user.UserName!, JsonSerializer.Serialize(jwtToken));
            }
            else
            {
                // 删除旧的token,写入新的token
                await _distributedCache.RemoveAsync("token-" + user.UserName!);
                await _distributedCache.SetStringAsync("token-" + user.UserName!, JsonSerializer.Serialize(jwtToken));
            }      
            return Ok(jwtToken);
        }
    }
}

项目配置一个专门校验Token的过滤器,代码如下:

/* JWTValidationFilter.cs */
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Caching.Distributed;
using System.Security.Claims;

namespace ProjectTodo
{
    //[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
    public class JWTValidationFilter :Attribute, IAsyncActionFilter
    {
        private readonly IDistributedCache _distributedCache;
        public JWTValidationFilter(IDistributedCache distributedCache)
        {
            _distributedCache = distributedCache;
        }
        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        {
            var userName = context.HttpContext.User.FindFirst(ClaimTypes.Name)?.Value;
            if (userName == null)
            {
                await next();
                return;
            }
            string? redisToken = await _distributedCache.GetStringAsync("token-" + userName!);
            string token;
            if (redisToken != null)
            {
                redisToken = redisToken.Trim('"');
                var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString();
                if (authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
                {
                    token = authorizationHeader.Substring("Bearer ".Length).Trim();
                    bool isValid = string.Equals(token, redisToken);
                    if (!isValid)
                    {
                        context.Result = new ObjectResult("Token 已失效,请重新登录") { StatusCode = 401 };
                        return;
                    }
                }
                await next();
                return;

            }
            await next();
        }
    }
}

过滤器会从当前对接口进行访问的请求中获取到Token,也会从Redis中拿到Token,进行比较,发现差异会返回401。

过滤器需要在项目根目录Program.cs中进行注册。

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using ProjectTodo;
using System.Reflection;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMemoryCache();
builder.Services.AddLogging();

string connStr = builder.Configuration.GetConnectionString("DefaultConnection")!;

// 添加数据库上下文注册
builder.Services.AddDbContext<ProjectTodoContext>(options => options.UseSqlServer(connStr));

// 添加JWT配置
builder.Services.Configure<JWTSettings>(builder.Configuration.GetSection("JWT"));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(x =>
{
    var jwtOpt = builder.Configuration.GetSection("JWT").Get<JWTSettings>();
    byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOpt!.SigningKey);
    var secKey = new SymmetricSecurityKey(keyBytes);
    x.TokenValidationParameters = new()
    {
        ValidateIssuer = false,
        ValidateAudience = false,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = secKey
    };
});

// 添加全局过滤器
//builder.Services.Configure<MvcOptions>(options =>
//{
//    options.Filters.Add<JWTValidationFilter>();
//    //options.Filters.Add<ExceptionHandleFilter>();
//    // ... 这里还可以增加其他 Filter,但是需要注意顺序
//});

builder.Services.AddScoped<JWTValidationFilter>();

// Swagger 添加 Authorization
builder.Services.AddSwaggerGen(options => 
{
    var scheme = new OpenApiSecurityScheme()
    {
        Description = "Authorization header. \r\nExample: 'Bearer 12345abcdef'",
        Reference = new OpenApiReference
        {
            Type = ReferenceType.SecurityScheme,
            Id = "Authorization"
        },
        Scheme = "oauth2",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
    };
    options.AddSecurityDefinition("Authorization",scheme);
    var requirement = new OpenApiSecurityRequirement();
    requirement[scheme] = new List<string>();
    options.AddSecurityRequirement(requirement);

    // using System.Reflection;
    var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
});

// Redis 缓存服务注册
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost";
    options.InstanceName = "mack_";  // key 前缀
});


IServiceCollection services = builder.Services;
services.AddDbContext<IdDbContext>(opt =>
{
    opt.UseSqlServer(connStr);
});
services.AddDataProtection();
services.AddIdentityCore<User>(options =>
{
    //options.Lockout.MaxFailedAccessAttempts = 10;  // 密码错误十次锁定
    //options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(1);  // 默认锁定时间,可以通过FromDays等进行天、小时、分钟、秒的设置
    options.Password.RequireDigit = false;
    options.Password.RequireLowercase = false;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequireUppercase = false;
    options.Password.RequiredLength = 6;
    // 如果发重置链接到用户邮箱,可以注释掉下面一行
    options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;  // 这个决定了重置密码的验证码长度
    options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
});
var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), services);
idBuilder.AddEntityFrameworkStores<IdDbContext>().AddDefaultTokenProviders().AddRoleManager<RoleManager<Role>>().AddUserManager<UserManager<User>>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// 在app.UseAuthorization();上面添加
app.UseAuthentication();

app.UseAuthorization();

// 启用服务器缓存需要在 app.MapControllers() 前加上 app.UseResponseCaching()
// 如果启用了 app.UseCors 跨域,需要 app.UseCors 写到 app.UseResponseCaching 前面

/*app.UseResponseCaching();*/  // 启用服务器端缓存
app.MapControllers();

app.Run();

过滤器以[ServiceFilter(typeof(JWTValidationFilter), Order = -1)]这种标签方式作用到一个接口上(顺带吐槽一下,放在Python项目上接口被叫成视图函数,会更贴切),Order = -1 是表明过滤器的执行优先级,如果没有这个,程序会优先执行[Authorize]

        /// <summary>
        /// View project by id
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        [HttpGet("{id}")]
        [ServiceFilter(typeof(JWTValidationFilter), Order = -1)]
        [Authorize]
        public async Task<ActionResult<ProjectDTO>> GetProject(long id)
        {
            var project = await context.Projects.SingleOrDefaultAsync(x=>x.Id == id);
            if (project == null)
            {
                return NotFound();
            }
            var projectDTO = new ProjectDTO(project);

            return Ok(projectDTO);
        }

围绕上述的实现代码,调试出的结果:

使用已过期Token,接口直接返回401

使用未过期Token,并在Swagger界面对登录接口重新登录,使得Redis缓存最新Token,访问接口触发过滤器的验证

基于Django的清册系统

清册系统是用于公司硬件工程师分类登记硬件元器件的工具,属于行业性非常强的工具,功能并不复杂,旨在提供填写录入元器件信息并提供查询。

项目采用Python Django框架,前端使用AdminLTE v3.2.0,数据库Mysql 8,由于是个小系统,一个人开发和后续的维护,也不需要考虑前后端分离。

一、 物料新增

物料新增页面实现硬件工程师按分类录入电子元器件信息以及自动按规则生成编码的功能,一级分类、二级分类和一级品类、二级品类都是级联选项,它们决定了最终编码的生成规则。

二、 物料分类

物料分类页面实现一级分类和二级分类的创建,页面分别由两个表单和列表组成。

三、 物料品类

物料品类页面实现一级品类、二级品类的创建,页面分别由两个表单和两个列表组成,它们决定了物料新增到系统之后的编码前缀。

四、 物料列表

物料列表页面用于展示所有的数据,点击编码,可进入详情页面(如下图)

详情页提供对单物料进行修改和删除操作。

五、 数据初始化

在清册系统没有上线前,硬件的同事都是使用Excel去管理硬件元器件数据的,也包括编码生成,使用系统时需要进行一次数据的初始化,读取Excel数据存入数据库即可,脚本在Django项目内,而且需要调用几个数据Model,所以少不了os.environ[‘DJANGO_SETTINGS_MODULE’] = ‘gatcis_ms.settings’这句。

from io import BytesIO
import os
from datetime import datetime
import time
import xlwt, xlrd
import django

os.environ['DJANGO_SETTINGS_MODULE'] = 'gatcis_ms.settings'
django.setup()
from material.models import AmlModel, BigTypeModel, SmallTypeModel, ParentCategoryModel, SubCategoryModel


def import_excel():
    wb = xlrd.open_workbook(filename=r'C:\Users\zheng\PycharmProjects\gatcis_ms\aml.xls')
    print("读取Excel")
    table = wb.sheets()[0]
    rows = table.nrows
    counter = 1
    err_list = []
    try:
        if rows > 1:
            for i in range(1, rows):
                row_values = table.row_values(i)
                print("-----------------")
                pn = row_values[0]
                if pn_unique(pn):
                    err_list.append(pn)
                    print(err_list)
                    continue
                parent_name = row_values[1]
                sub_name = row_values[2]
                pid = parent_select_insert(parent_name)
                aml = {
                    "pn": pn,
                    "parent_category_id": pid,
                    "sub_category_id": sub_select_insert(category_name=sub_name, pid=pid),
                    "big_type_id": big_query(pn=pn),
                    "small_type_id": small_query(pn=pn),
                    "product_value": row_values[3],
                    "item_no": row_values[4],
                    "specification": row_values[5],
                    "package": row_values[6],
                    "maker_brand": row_values[7],
                    "status": True,
                    "is_show": True,
                    "remark": row_values[8],
                    "created_on": datetime.now(),
                    "modify_on": datetime.now(),
                }
                print(aml)
                AmlModel.objects.create(**aml)
                counter += 1
                start_line = '\033[1;31m '
                end_line = '\033[0m'
                print(start_line + "{c} 行数据录入成功".format(c=counter) + end_line)
                print("-----------------")
            print("当前插入数据共计 {c}".format(c=counter))
            print("重复的PN编号:", err_list)
    except Exception as e:
        return e


def parent_select_insert(category_name):
    try:
        parent = ParentCategoryModel.objects.filter(title=category_name).first()
        if parent:
            print("一级类别已经存在")
            return parent.id
        else:
            print("一级类别不存在")
            parent = {
                "title": category_name,
                "description": category_name,
                "created_on": datetime.now(),
                "modify_on": datetime.now(),
            }
            p = ParentCategoryModel.objects.create(**parent)
            print("插入一级分类")
            print("parent_id: {id}".format(id=p.id))
            return p.id
    except Exception as e:
        return e


def sub_select_insert(category_name, pid):
    try:
        sub = SubCategoryModel.objects.filter(title=category_name).first()
        if sub:
            print("二级类别已经存在")
            return sub.id
        else:
            print("二级类别不存在")
            sub = {
                "title": category_name,
                "description": category_name,
                "created_on": datetime.now(),
                "modify_on": datetime.now(),
                "parent_id": pid,
            }
            s = SubCategoryModel.objects.create(**sub)
            print("插入二级分类")
            print("sub_id: {id}".format(id=s.id))
            return s.id
    except Exception as e:
        return e


def big_query(pn):
    big_type = BigTypeModel.objects.filter(code=pn[:3]).first()
    print("获取一级品类成功")
    return big_type.id


def small_query(pn):
    big_type = BigTypeModel.objects.filter(code=pn[:3]).first()
    small_type = SmallTypeModel.objects.filter(code=pn[3], big_type=big_type.id).first()
    print("获取二级品类成功")
    return small_type.id


def pn_unique(pn):
    pn = AmlModel.objects.filter(pn=pn).first()
    if pn:
        start_line = '\033[1;31m '
        end_line = '\033[0m'
        print(start_line + "PN {pn} 重复".format(pn=pn) + end_line)
        return True


# 求一次成功,不报错
import_excel()

六、 部署

由于公司有阿里云的Windows Server,代码托管在IIS上。

七、 总结

整个小项目当时好像只用了一个星期,这还包括了重新翻看Django官方文档,毕竟不是不停重复写一个Django框架,难免会在用时重新翻文档。

清册小工具能够解决硬件工程师录入元器件信息、生成编码,以及数据的共享,它作为一个输入端向后或向上还可以接入到Bom、仓库、ERP等等,系统的推进本质上依赖需求推动,每个公司不同的发展阶段对于工具的需求和定义都不同,实际践行的每一步都非易事。

ASP.NET WEB API 缓存处理

在Web Api中,假设有用户频繁的对一个并不经常更新数据的接口发起请求,为了减少不必要的资源开销,可通过缓存进行优化:

一、 客户端缓存

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class CacheLearnController : ControllerBase
    {
        // 当用户十秒内多次向该接口发起请求,浏览器会从缓存中获取
        [ResponseCache(Duration = 10)]
        [HttpGet]

        public DateTime Now()
        {
            return DateTime.Now;
        }
    }
}

我们通过装饰器[ResponseCache(Duration = 10)]来设定接口内容缓存10秒,用户在十秒内频繁刷新接口拿到的时间都是相同的,这是一个客户端缓存方式,不同的用户进行上述操作他们会有不同的缓存结果;

不过这也需要一个前提,浏览器=>网络,设置未勾选“禁用缓存”。

二、 服务器端缓存

开启服务器端缓存,需要在 Program.cs 中增加 app.UseResponseCaching(); 也有需要注意的事项,注释在代码中了。

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

// 启用服务器缓存需要在 app.MapControllers() 前加上 app.UseResponseCaching()
// 如果启用了 app.UseCors 跨域,需要 app.UseCors 写到 app.UseResponseCaching 前面

app.UseResponseCaching();  // 启用服务器端缓存
app.MapControllers();

app.Run();

服务器端缓存意味着在一定时间范围内,无论是哪个客户端进行的请求,在缓存未更新前,都是相同的。

无论是客户端缓存或是服务器端缓存,都会受到浏览器“禁用缓存”设置的影响,由请求时头部报文中的参数决定,比如下面的两种图,当浏览器勾选了“禁用缓存”,请求标头中会多出Cache-Control: no-cache。

三、 缓存的绝对过期时间

通过 IMemoryCache Interface设置缓存的绝对过期时间,完整的示例代码如下:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using System.Runtime.CompilerServices;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    public class ProjectTodoController : ControllerBase
    {
        private readonly IMemoryCache memoryCache;

        private readonly ILogger<ProjectTodoController> logger;

        public ProjectTodoController(IMemoryCache memoryCache, ILogger<ProjectTodoController> logger)
        {
            this.memoryCache = memoryCache;
            this.logger = logger;
        }

        [HttpGet]
        public async Task<ActionResult<ProjectDTO>> GetAllProjects()
        {
            
            using ProjectTodoContext ctx = new ProjectTodoContext();

            logger.LogInformation("开始");  // 用于在控制台输出内存缓存的作用

            // 二合一的方法,从缓存取数据,没有数据会从数据库取,并且保存进缓存
            var items = await memoryCache.GetOrCreateAsync("AllProjects", async (e) =>
            {
                // 用于在控制台输出内存缓存的作用
                logger.LogInformation("缓存里没有找到,从数据库拿");

                // 缓存有效期(绝对过期时间)
                // 设置内存缓存过期时间为 10 秒
                e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);

                return await ctx.Projects.Select(x => new ProjectDTO(x)).ToListAsync();
            });
            // 用于在控制台输出内存缓存的作用
            logger.LogInformation($"{items}");

            return Ok(items);
        }
}

示例代码中设置了获取所有Project的API接口采用了绝对过期时间10秒,当一个用户在某时间访问了该接口,余下的十秒时间内,任何对该接口的访问都获得相同的内容。

四、 缓存滑动过期时间

内存缓存也可以使用滑动过期时间,当缓存内容在设定的时间内没有被访问,到达设定有效期时间之后缓存失效,当缓存内容在设定时间内被访问了,它将重置延续有效期。

应用场景如某些登录鉴权的状态,如Token失效等。

// 滑动过期时间,当缓存内容在设定时间内没有被访问,到达设定有效期时间之后缓存失效
// 当缓存内容在设定时间内又被访问,它将继续延长有效期
// 某些软件的 Token 采用的滑动过期时间
e.SlidingExpiration = TimeSpan.FromSeconds(10);

五、 绝对过期与滑动过期共用

当两种方法混用时,需要注意绝对过期时间要大于滑动过期时间。

// 两种过期时间可以混用
// 可以将绝对过期时间设置的比滑动过期时间长,这样在绝对过期时间的范围内,滑动过期的方式是有效的
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
e.SlidingExpiration = TimeSpan.FromSeconds(10);

六、 内存缓存存取的一个简单示范(缓存穿透)

当客户端访问了一个不存在的id,依照下列代码,程序会先从缓存中查找,查找结果为 Null,程序会从数据库查找,数据库查找的结果为 Null 并赋值给 b,最终这个 null值又被设置到缓存中,判断的条件成立之后,实际每次通过不存在的id进行访问时都会触发数据库的查询,这就是典型的缓存穿透。

string cacheKey = "Book" + id;  // 缓存键
Book? b = memCache.Get<Book?>(cacheKey);
if(b == null)
{
  b = await dbCtx.Books.FindAsync(id);
  memCache.Set(cacheKey, b);
}

缓存穿透的解决方案:

  1. “查不到”也当成一个数据放入缓存。
  2. 使用GetOrCreateAsync方法即可,该方法会将 null 值当成合法的缓存值。
string cacheKey = "Book" + id;
var book = await memCache.GetOrCreateAsync(cacheKey, async(e)=>{
  var b = await dbCtx.Books.FindAsync(id);
  return b;
});

七、 缓存雪崩

缓存项的集中过期引起缓存雪崩,固定的缓存过期时间在周期性的访问中存在波动,比如刚好缓存过期时又出现了大量的请求而产生的突发性数据库压力。

在基础的过期时间之上,再加上一个随机的过期时间来应对和解决缓存雪崩问题。

// 随机过期时间来应对缓存雪崩
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Random.Shared.Next(10, 15));

八、 分布式缓存(Redis)

Redis 是一个键值对数据库,它有丰富的数据类型,可以用来保存列表、字典、集合等数据类型,也可以用作消息队列、缓存服务器;

首先,在项目中通过NuGet安装Microsoft.Extensions.Caching.StackExchangeRedis,并在Program.cs中增加:

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost";  // 使用的本地Redis
    options.InstanceName = "mack_";  // key 前缀
});

然后在需要进行缓存的controller中写入如下代码:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using System.Diagnostics.Eventing.Reader;
using System.Runtime.CompilerServices;
using System.Text.Json;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    public class ProjectTodoController : ControllerBase
    {
        private readonly IDistributedCache distributedCache;

        public ProjectTodoController(IDistributedCache distributedCache)
        {
            this.distributedCache = distributedCache;
        }

        [HttpGet]
        public async Task<ActionResult<ProjectDTO>> GetAllProjects()
        {
            
            using ProjectTodoContext ctx = new ProjectTodoContext();

            List<ProjectDTO> projects;
            var opt = new DistributedCacheEntryOptions();
            opt.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Random.Shared.Next(10, 15));
            string? s = await distributedCache.GetStringAsync("AllProjects");

            if (s == null)
            {
                Console.WriteLine("从数据库里取数据");
                projects = await ctx.Projects.Select(x => new ProjectDTO(x)).ToListAsync();
                Console.WriteLine("数据写进 Redis");
                await distributedCache.SetStringAsync("AllProjects", JsonSerializer.Serialize(projects), opt);
                Console.WriteLine($"数据失效时间为 {opt}");
            }
            else
            {
                Console.WriteLine("从 Redis 中取数据");
                projects = JsonSerializer.Deserialize<List<ProjectDTO>>(s)!;
            }
            if(projects == null)
            {
                return NotFound();
            }
            else
            {
                Console.WriteLine("返回数据");
                return Ok(projects);
            }
        }
    }
}

这段代码先从Redis缓存中去获取所有的Project,当缓存中的Project不存在时再去数据库去取,也设定了缓存的失效时间,这里采用了一个随机范围,避免缓存雪崩,当取出数据为null时也会被赋值给Redis的键,代码判断为null成立调用NotFound()函数,从而避免了的缓存穿透,不过本段代码如果取不到数据返回的是一个空[],并不会触发NotFound()。

由于快发机是Windows环境,在对Redis版本没有过高要求的前提下,博主去https://github.com/tporadowski/redis/releases 下载安装了可运行在Windows系统上的Redis安装包,为了查看缓存的内容,通过https://github.com/uglide/RedisDesktopManager/releases/download/0.9.3/redis-desktop-manager-0.9.3.817.exe下载了Redis桌面管理工具。

下图是Redis分区缓存的所有的项目,键的前缀以及键组合命名:mack_AllProjects,absexp 的 value 是缓存的失效时间,失效时间一到,数据自动清除。

结合代码为了模拟出代码工作节点,通过控制台打印,可以看到:

一个ASP.NET Core Web Api示例

这是一个基于微软官方文档的 Web Api 示例,同时示范了 ORM 工具 EF Core 的简单使用,控制器的各接口都采用异步操作以及LINQ语句,C Sharp 语法上有一些空和非空处理,这点像极了微软自家的 TypeScript。

假设我们需要一个用于展示项目待办事项的 BS 应用,它运行在公司内网的一台 PC 上,用在一个大屏设备上轮播展示那些亟需完成的工作项,这听上去很像一个工厂流水线上常见的场景。由于它只是用于展示“研发部门”未完成的项目工作项,不深度去考虑信息隔离、信息安全这些条件,作为后端只需要提供一个能够CRUD操作数据库的Web API,由于数据库使用 SQL Server ,ORM 自然就使用 EF Core ,在定义数据模型时采用了 Fluent 方式。

接着,我们准备相关的 CRUD 接口:

  • 用于获取所有项目的接口 GetAllProjects
  • 用于获取单个项目信息的接口 GetProject
  • 用于获取单个项目所有的工作项的接口 GetProjectAllTodo
  • 用于新建项目的接口 AddProject
  • 用于新建项目工作项的接口 AddProjectTodo
  • 用于修改项目的接口 EditProject
  • 用于修改项目工作项的接口 EditProjectTodo
  • 用于删除项目的接口 DeleteProject
  • 用于删除项目工作项的接口 DeleteProjectTodo

从接口名称(路径)上看这并不是一个严格遵循 Restful 的 API,是的,它看上去有些 RPC风格,由于这仅仅是一个简单的案例,并没有专门为其写一个返回状态码和消息的函数,所以也省去了400派和200派的纠结。

IDE 毫无疑问使用 Visual Studio 2022,数据库使用 SQL Server 2022,先使用 Visual Studio 建立项目,先建立一个空白解决方案,比如命名成 ProjectTodoListSlideShow,再创建一个 ASP.NET Core Web API 项目,比如命名成 ProjectTodo,项目目录大致如下图:

添加一个控制器 ProjectTodoController ;

NuGet 包安装:
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools

项目根目录新建 Model 类
# Model.cs

using Microsoft.EntityFrameworkCore;

namespace ProjectTodo
{
    public class ProjectTodoContext: DbContext
    {
        public DbSet<Project> Projects { get; set; }
        public DbSet<ProjectTodo> ProjectTodos { get; set; }
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);
            string connStr = "Server=.;Database=project;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True";
            optionsBuilder.UseSqlServer(connStr);
        }
    }

    public class Project
    {
        public long Id { get; set; }
        public string Name { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public DateTime Created { get; set; }
        public string? Status { get; set; }
        public List<ProjectTodo>? projectTodos { get; set; }
    }

    public class ProjectTodo
    {
        public long Id { get; set; }
        public string Item { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public string? Remark { get; set; }
        public DateTime Deadline { get; set; }
        public DateTime Created { get; set; }
        public string? Status { get; set; }
        public long ProjectId { get; set; }
        public Project? Project { get; set; }
    }
}

项目根目录新建 Schema 类
# Schema.cs

namespace ProjectTodo
{
    public class ProjectDTO
    {
        public long Id { get; set; }
        public string Name { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
        public ProjectDTO() { }
        public ProjectDTO(Project project) => (Id, Name, Description, Owner, Created, Status) = (project.Id, project.Name, project.Description, project.Owner, project.Created, project.Status);
    }
    public class ProjectTodoDTO
    {
        public long Id { get; set; }
        public string Item { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public string? Remark { get; set; }
        public DateTime? Deadline { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
        public long ProjectId { get; set; }
        public ProjectTodoDTO() { }
        public ProjectTodoDTO(ProjectTodo projectTodo) => (Id, Item, Description, Owner, Remark, Deadline, Created, Status, ProjectId) = (projectTodo.Id, projectTodo.Item, projectTodo.Description, projectTodo.Owner, projectTodo.Remark, projectTodo.Deadline, projectTodo.Created, projectTodo.Status, projectTodo.ProjectId);
    }
    public class ProjectAddDTO
    {
        public long Id { get; set; }
        public string Name { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
    }
    public class ProjectTodoAddDTO
    {
        public long Id { get; set; }
        public string Item { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public string? Remark { get; set; }
        public DateTime? Deadline { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
        public long ProjectId { get; set; }
    }
    public class ProjectEditDTO
    {
        public long Id { get; set; }
        public string Name { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
    }
    public class ProjectTodoEditDTO
    {
        public long Id { get; set; }
        public string Item { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public string? Remark { get; set; }
        public DateTime? Deadline { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
        public long ProjectId { get; set; }
    }
}

Controllers/ProjectTodoController.cs 类代码如下:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    public class ProjectTodoController : ControllerBase
    {
        [HttpGet]
        public async Task<ActionResult<ProjectDTO>> GetAllProjects()
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var projects = await ctx.Projects.Select(x=>new ProjectDTO(x)).ToListAsync();

            return Ok(projects);
        }

        [HttpGet("{id}")]
        public async Task<ActionResult<ProjectDTO>> GetProject(long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var project = await ctx.Projects.SingleOrDefaultAsync(x=>x.Id == id);
            if (project == null)
            {
                return NotFound();
            }
            var projectDTO = new ProjectDTO(project);

            return Ok(projectDTO);
        }

        [HttpGet("{id}")]
        public async Task<ActionResult<List<ProjectTodoDTO>>> GetProjectAllTodos(long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var projectTodoDTOs = await ctx.ProjectTodos
                                .Where(t => t.ProjectId == id)
                                .Select(t => new ProjectTodoDTO(t))
                                .ToListAsync();

            return Ok(projectTodoDTOs);
        }

        [HttpPost]
        public async Task<ActionResult<ProjectAddDTO>> AddProject(ProjectAddDTO projectAddDTO)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();
            
            var project = new Project 
            { 
                Name = projectAddDTO.Name, 
                Description = projectAddDTO.Description, 
                Owner = projectAddDTO.Owner, 
                Created = DateTime.Now,
                Status = projectAddDTO.Status
            };

            ctx.Projects.Add(project);
            await ctx.SaveChangesAsync();

            projectAddDTO.Created = project.Created;
            projectAddDTO.Id = project.Id;

            return Ok(projectAddDTO);
            
        }

        [HttpPost]
        public async Task<ActionResult<ProjectTodoAddDTO>> AddProjectTodo(ProjectTodoAddDTO projectTodoAddDTO)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var todo = new ProjectTodo
            {
                Item = projectTodoAddDTO.Item,
                Description = projectTodoAddDTO.Description,
                Owner = projectTodoAddDTO.Owner,
                Remark = projectTodoAddDTO.Remark,
                Deadline = (DateTime)projectTodoAddDTO.Deadline!,
                Created = DateTime.Now,
                Status = projectTodoAddDTO.Status,
                ProjectId = projectTodoAddDTO.ProjectId
            };

            ctx.ProjectTodos.Add(todo);
            await ctx.SaveChangesAsync();

            projectTodoAddDTO.Created = todo.Created;
            projectTodoAddDTO.Id = todo.Id;

            return Ok(projectTodoAddDTO);
        }

        [HttpPut("{id}")]
        public async Task<ActionResult<ProjectEditDTO>> EditProject(ProjectEditDTO projectEditDTO, long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var project = await ctx.Projects.FindAsync(id);
            if (project == null)
            {
                return NotFound();
            }

            project.Name = projectEditDTO.Name;
            project.Description = projectEditDTO.Description;
            project.Owner = projectEditDTO.Owner;
            project.Status = projectEditDTO.Status;
            
            await ctx.SaveChangesAsync();

            projectEditDTO.Id = project.Id;
            projectEditDTO.Created = project.Created;

            return Ok(projectEditDTO);
        }

        [HttpPut("{id}")]
        public async Task<ActionResult<ProjectTodoEditDTO>> EditProjectTodo(ProjectTodoEditDTO projectTodoEditDTO, long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var todo = await ctx.ProjectTodos.FindAsync(id);
            if (todo == null)
            {
                return NotFound();
            }

            todo.Item = projectTodoEditDTO.Item!;
            todo.Description = projectTodoEditDTO?.Description;
            todo.Owner = projectTodoEditDTO?.Owner;
            todo.Remark = projectTodoEditDTO?.Remark;
            todo.Status = projectTodoEditDTO?.Status;
            if (projectTodoEditDTO?.Deadline != null)
            {
                todo.Deadline = projectTodoEditDTO.Deadline.Value;
            }

            await ctx.SaveChangesAsync();

            projectTodoEditDTO!.Id = todo.Id;
            projectTodoEditDTO.ProjectId = todo.ProjectId;

            return Ok(projectTodoEditDTO);
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteProject(long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var project = await ctx.Projects.FindAsync(id);
            if (project == null)
            {
                return NotFound();
            }

            ctx.Projects.Remove(project);
            await ctx.SaveChangesAsync();

            return NoContent();
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteProjectTodo(long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var todo = await ctx.ProjectTodos.FindAsync(id);
            if (todo == null)
            {
                return NotFound();
            }

            ctx.ProjectTodos.Remove(todo);
            await ctx.SaveChangesAsync();

            return NoContent();
        }
    }
}

使用 SQL Server Management Studio 创建数据库 project ,再从 Visual Studio 上打开程序包管理控制器,分别输入:Add-Migration xxxx、Update-Database,通过 EF Core 建立数据库迁移,建表;

最后在 Visual Studio 上运行项目,在一个控制台应用被启动之后,项目也就跑起来了,上述代码中包含了异步操作,也有一些 LINQ操作,基本演示了一个 ASP.NET CORE WEB API的小项目。

Python 开发简易 MRP 工具

上周五,生产的同事在进行生产计划时,提出了希望有个工具可以进行 MRP,由于目前公司处在起步阶段,虽然有ERP,也只是使用了该系统的财务部分的模块,仓库以及采购等业务实际都由相关同事以 Excel 进行了大量的比对和管理,对于 MRP 确实需要一个工具。

构想1:采用网页上传 Bom 表格和库存表格,转存进数据库,再进行计算,虽然用户体验稍微好一些,但对于开发而言会需要更多的时间,以及额外的部署开销;

构想2:写一个桌面应用,桌面应用比如 WPF 这个也非常耗时啊,思来想去开发一个控制台应用足够了,Python 去开发,然后再打包编译成一个可执行的 exe;

构想3:完整的开发一个集成库存管理、物料清册、采购与供应商管理、生产管理…不能再想了,太多内容,系统需要有人去使用,公司缺的不是系统,它还不是需要全套系统的阶段。

构想2显得更实际,也符合简单原则,把一个复杂的问题简单解决,这就是最佳的选择。

项目使用了 openpyxl 包来读取和写入 Excel,使用 auto-py-to-exe 打包成 exe 文件,项目目录如下图:excel 目录用于添加以 bom 开头的 Bom 表以及以 w 开头的库存表,result 目录用于输出 MRP 结果,output 是编译成控制台应用的目录;

Bom 表模板只需要两列(型号、用量),其实应该使用物料编码的,用量是单套用量或者是生产的套数,我在控制台中加入了可输入的 Input,如果 Bom 是单套用量,那在 Input 中要输入生产的套数,或者输入 1,由于表格有表头,所以代码取数从第 2 行开始。

库存表格其实只需要型号和数量,同事提供的表格内容较多,我在代码中定位了型号所在的列 6 以及库存所在的列 20,原本考虑复杂场景应该还有多仓库的概念,额…稍微复杂一些,暂时不往代码里面加逻辑,下图中增加T列是因为 R 列数据由公式计算得来,它不是 int 类。

项目可实现读取指定目录下的多份 Bom,以及一份库存表,再依次对单份 Bom 所需物料进行库存校验,然后虚拟库存占用,再进行下一份的 Bom 所需物料校验,然后将结果输出给不同的 sheet。

完整代码如下:

import openpyxl
import os
# 获取表格目录下有多少个表格,形成文件名的 List
files = os.listdir("excel")
# 获取表格目录下所有的 Bom 表,形成文件名的 List
bom_files = [f for f in files if f.startswith('bom') and f.endswith('.xlsx')]  # 获取前缀为 bom 的表格
# 获取表格目录下所有的库存表,形成文件名 List
material_inventory_files = [f for f in files if f.startswith('w') and f.endswith('.xlsx')]  # 获取前缀为 w 的表格
# 读取 Bom 表
def get_bom(bn: str, su: int):
    """
    获取 Bom Excel 表中的物料数据
    :param su: 套数,用于乘以 Bom 表物料的一套用量
    :param bn: Bom Excel 表的名称
    :return: 返回 Bom Excel 的物料和需求量字典
    """
    return read_excel(bn, False, su)
# 读取库存表
def get_material_inventory(mn: str):
    """
    获取库存 Excel 表中的物料数据
    :param mn: 库存 Excel 表的名称
    :return: 返回库存 Excel 的物料和库存量字典
    """
    return read_excel(mn, True)
# 公用读取表格函数
def read_excel(excel: str, stock: bool = False, suite: int = 1):
    """
    公用的读取 Excel 表方法
    :param suite: Bom 表中的一套用量需要乘的套数
    :param stock: 一个 bool 参数,用于判断是读取库存还是读取 Bom,由于数据列不同
    :param excel: 需要读取的 Excel 表的名称
    :return: 返回一个 Bom 或库存的字典数据
    """
    workbook = openpyxl.load_workbook(f'excel/{excel}')
    sheet = workbook.active
    data = {}
    if stock:
        for row in range(2, sheet.max_row + 1):
            cell_1 = sheet.cell(row, 6).value
            cell_2 = sheet.cell(row, 20).value
            data.update({cell_1: cell_2})
    else:
        for row in range(2, sheet.max_row + 1):
            cell_1 = sheet.cell(row, 1).value
            cell_2 = sheet.cell(row, 2).value * suite
            data.update({cell_1: cell_2})
    return data
# 比对需求物料和库存并输出结果
def calculator(bd: dict, sd: dict):
    """
    计算 Bom 用量需求和库存的 MRP
    :param sd: 库存字典
    :param bd: Bom 需求量字典
    :return: 返回一个 MRP 结果的列表
    """
    res = []
    for bk, bv in bd.items():
        if bk in sd:
            if bv > sd[bk]:
                shortage = bv - sd[bk]
                row = {'type': bk, 'dosage': bv, 'inventory': sd[bk], 'shortage': shortage, 'remain': 0}
                res.append(row)
                sd[bk] = 0
            else:
                remain = sd[bk] - bv
                row = {'type': bk, 'dosage': bv, 'inventory': sd[bk], 'shortage': 0, 'remain': remain}
                res.append(row)
                sd[bk] = remain
        else:
            row = {'type': bk, 'dosage': bv, 'inventory': 0, 'shortage': bv, 'remain': 0}
            res.append(row)
    return res
# 生成结果
def generate_result(sheet: str, mrp: list):
    """
    将 MRP 结果输出到 Excel
    :param sheet: 按 Bom 名称给 sheet 命名
    :param mrp: MRP 的结果列表
    :return: 输出 Excel,无返回值
    """
    _sheet = wb.create_sheet(sheet)
    _sheet['A1'] = '型号'
    _sheet['B1'] = '用量'
    _sheet['C1'] = '库存'
    _sheet['D1'] = '欠缺'
    _sheet['E1'] = '剩余'
    for row in mrp:
        _sheet.append([row['type'], row['dosage'], row['inventory'], row['shortage'], row['remain']])
# 库存的内存变量
material_inventory = {}
# 公用的结果对象
wb = openpyxl.Workbook()
if __name__ == '__main__':
    _bwl = {}
    _mwl = {}
    if len(bom_files) > 1:
        while True:
            for w in material_inventory_files:
                _mwl = get_material_inventory(w)
                material_inventory = _mwl
                break
            for b in bom_files:
                while True:
                    try:
                        su = int(input(f"请输入 {b} 生产数量,只能是整数:"))
                        break
                    except ValueError:
                        print("输入错误,请重新输入整数")
                _bwl = get_bom(b, su)
                generate_result(b, calculator(_bwl, _mwl))
                print(f"{b} MRP 运算结束")
            wb.save('result/res.xlsx')  # 保存输出到 Excel
            break
    else:
        print("未检测到 Bom 表格,请确保 excel 目录下有以 bom 开头的 Excel 表格")
    input("按任意键结束...")

Angular SPA 应用试水

某天,老板叫我去会议室,于是半小时内我收到一个口述的需求,公司有个非常重要的新开发客户,某个项目,为了彰显附加价值,体现公司实力,嗯哼!
应用场景:纺织机械制造-绒毛机,帮助设计人员快速预览效果。
需求:
1. 由不同长度和颜色的线段组成一条线;
2. 这条线自左向右行进,到达边界后再由右及左行进,到达边界后再从左及右…循环反复,一直到铺满整个“画布”;
3. 要求能设定线段(颜色、长度),设定画布的宽与高;
4. 最终展示出线段组成的画布效果;

最终成品


由于需求并不复杂,选择基于WEB的SPA应用就足够了,最近正学着使用Angular,于是应用到这个“项目”上,应用无须部署即可直接通过浏览器打开并使用,也通过WPF套壳封装成Windows桌面应用,当然这需要使用到Microsoft.Web.WebView2包。

“项目”基于三大前端框架之一的Angular,UI框架选择了ng-zorro-antd、material,原本认为一个material就足够了,实际material对于表格操作稍微简单了一些,ng-zorro-antd可以对表格中的行元素进行拖拽,拖拽之后进行重新排序,material原生好像是没有的,总不至于自己重新造轮子吧!?

第一版大约不到一周时间就完成了,有一些Bug,最终对于画线那段逻辑进行了梳理和重构,代码写的比较low😄,其间也和老板讨论过需求的局限,个人认为如果用来对画布进行设计,那应该是画布先设计图案,再将图案分解成线段,只有这样才能真正给工艺设计人员启示,它也不是上位机应用,无法和下位机产生任何交互操作的联动,看上去很鸡肋,如往上位机方向转,又欠缺了中间层的工艺逻辑,所以开发交付完,就终止了对它的任何迭代。

这是编译打包好的应用,解压后直接双击 index.html运行即可
https://www.solocis.com/wp-content/uploads/2023/11/dist.zip