安利一下Blazor:.NET开发者的全栈“优”选项

背景

近期在开发一个全新的管理系统,使用了微软全新的Blazor框架,开发到现在,越发觉得Blazor的设计非常巧妙,虽然它的特点是使用C#语言代替了Javascript来开发web页面中的逻辑部分,统一前后开发的技术栈,但我觉得这根本不是重点。对于想做全栈开发的.Net开发者来说,一下子跳转到Vue,React等前端开发框架的话,尤其在开发一些稍微有一点规模的项目时,需要频繁的切换开发模式,这包括你的思维要不断在前后端环境跳跃,使用的编辑器应该也会不一样,毕竟对于Vue这类开发框架,使用VS或者Rider这类IDE并不是非常好用,当然这不是什么大问题,但这一定会折损一些开发效率。尤其当你全神贯注在实现一个功能模块,需要前后对接调试的时候,还是会期望有一个更高效的开发环境。

而Blazor的价值就在于,他并不是要替代Vue,React,而是针对性的给致力于做全栈开发的.Net开发者们多一个"优"选项,这一点,如果你真正进行过中度以上的体验,才会发现,Blazor真非常好,属于那种漫热型的框架,越用越上头,它不是一个平替选项,而是完全值得开发者深度投入的开发框架,它模块化的设计思路,也和Vue,React等框架的设计思路不谋而合,使你不用太顾虑被.Net技术栈套牢,因为如果能很好的驾驭Blazor的话,后续即便是要跳到Vue这些框架,这个技术迁移成本也不会太大,当然反过来也一样。这才是Blazor最有价值的地方!有独立个性,却又兼容并包,像个成熟的大人,值得信赖!

哈哈,说多来,接下来,我将通过一个实际的小模块案例,带你领略Blazor的魅力。

注意: 关于Blazor的详细教程和深度学习,强烈推荐微软官方的教程和文档。本文仅是我结合实际项目经验,从特定场景出发,抛砖引玉,希望能为Blazor社区生态贡献一份力量。

案例

这里,我以现在项目中的一个管理模块为例,介绍下一个小功能模块的增删改查的开发过程。

注意,下面我给出的案例代码会进行一定的删减和调整,去除掉业务相关的部分

布局组件

Blazor的开发模式和Vue的组件式开发模式很像,也是模块化的,这里我们准备一个布局组件,然后其他组件都可以使用它

csharp 复制代码
// 这里要引入一些命名空间,也可以将常用的命名空间放到_Imports.razor里,我这里不再展示
@inherits LayoutComponentBase
@inject NavigationManager Navigation
@inject ISnackbar Snackbar

<MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />

<MudLayout>
    <MudAppBar Elevation="1">
        <MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
            OnClick="@((e) => DrawerToggle())" />
        <MudText Typo="Typo.h5" Class="ml-3">综合信息管理平台(孵化期🐣)</MudText>
        <MudBadge Icon="@Icons.Material.Filled.History" Color="Color.Tertiary" Overlap="true" Bordered="true" Class="mx-6 my-4">
            <MudButton Target="_blank" Href="https://www.yuque.com/yuqueyonghuhz99ts/iw926p/cgkrocxvego2aydx?singleDoc#hPlWN">开发进度</MudButton>
        </MudBadge>
        <MudSpacer />
        <MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle" />
        <AuthorizeView>
            <Authorized>
                <MudMenu AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight">
                    <ActivatorContent>
                        <MudAvatar Size="Size.Small" Class="ml-2">
                            <MudIcon Icon="@Icons.Material.Filled.Person" />
                        </MudAvatar>
                    </ActivatorContent>
                    <ChildContent>
                        <MudText Typo="Typo.body2" Class="pa-2">
                            <Magic.Declaration.Manage.Components.Shared.UserInfoDisplay ShowType="1" />
                        </MudText>
                        <MudDivider />
                        <Magic.Declaration.Manage.Components.Shared.LogoutButton></Magic.Declaration.Manage.Components.Shared.LogoutButton>
                    </ChildContent>
                </MudMenu>
            </Authorized>
        </AuthorizeView>
    </MudAppBar>
    <MudDrawer id="nav-drawer" @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2">
        <NavMenu />
    </MudDrawer>
    <MudMainContent Class="pt-16 pa-4">
        @Body
    </MudMainContent>
</MudLayout>

<div id="blazor-error-ui" data-nosnippet>
    页面出错.
    <a href="." class="reload">重载</a>
    <span class="dismiss">🗙</span>
</div>

@code {
   //省略
   //这里主要的逻辑是控制页面主题变化,代码篇幅较长不再展示
}

简单说明一下,这个布局页面,包含了一个顶部的导航栏,一个侧边菜单栏和主要内容的管理区域,另外组件内有一些通用命名空间的引入,可以放到Imports.razor组件中,这样就不用在页面里写许多引用代码了。

效果如下

正如上图中展示的一样,我的开发习惯是准备开发一个业务模块之前,先把底层要用到的服务准备好,比如读操作,写操作,模型关联等等,然后准备好接口,之后就可以去做页面开发了。注意本篇不会介绍Razor页面开发之外的内容。

进入列表开发环节后,我们看到我需要一些检索操作区域,比如按一定的条件来检索,需要一些功能按钮,比如导出,编辑等等。

列表页

面包屑组件

这里从上到下说,列表页最上面有一个面包屑,这个是可以开发成一个统一的组件,然在不同的页面导入就好

csharp 复制代码
<MudBreadcrumbs Items="_items"></MudBreadcrumbs>
<MudDivider Style="padding:10px" />
@code {

    [Parameter]
    public string PageTitle { get; set; } = "菜单";
    [Parameter]
    public string PageMain { get; set; } = "menu";
    [Parameter]
    public string PageSub { get; set; } = "create";

    private string PageSubTitle
    {
        get
        {
            //业务处理代码,省略
        }
    }

    protected override void OnInitialized()
    {
        base.OnInitialized();
        _items.Add(new BreadcrumbItem(PageTitle, $"/{PageMain}/index"));
        _items.Add(new BreadcrumbItem(PageSubTitle, $"/{PageMain}/{PageSub}"));
    }
    private List<BreadcrumbItem> _items { get; set; } = [
            new ("首页", "/"),        
        ];
}

这样一个通用组件就准备好了,在用到地方导入即可。

筛选框组件

我这里列表页里的筛选框也是一个全局的筛选框,包括一个状态选择和一个业务相关的筛选,这里我以那个状态选择为例

csharp 复制代码
<MudSelect T="ManagerStatusFilter" Label="状态" Variant="Variant" ValueChanged="OnStatusChanged">
    @foreach (var status in Enum.GetValues(typeof(ManagerStatusFilter)).Cast<ManagerStatusFilter>())
    {
        <MudSelectItem Value="@status">@EnumExtensions.GetDescription(status)</MudSelectItem>
    }
</MudSelect>

@code {
    [Parameter]
    public ManagerStatusFilter Status { get; set; }

    [Parameter]
    public Variant Variant { get; set; } = Variant.Outlined;

    [Parameter]
    public EventCallback<ManagerStatusFilter> StatusChanged { get; set; }

    private async Task OnStatusChanged(ManagerStatusFilter value)
    {
        //执行回调方法
        await StatusChanged.InvokeAsync(value);
    }
}

这样两个通用组件就封装好了,组件代码里的"code"部分定义的变量,如果标记了Parameter特性,则该参数是可以从调用页传值进来的,而传值也不仅限于参数,也包括委托事件,进而实现了组件之间的数据通信。

比如我在列表组件代码里调用这两个组件的代码

csharp 复制代码
//调用面包屑组件
<MagicBreadcrumbs PageTitle="元数据配置" PageMain="dictmetaconfig" PageSub="index" />

//调用筛选框组件
//这个组件需要传入一个委托方法,监控状态的变化,而状态值是从子组件内传过来的
<FilterSelect StatusChanged="HandleStatusChanged"></FilterSelect>

这两个组件,第一个面包屑组件的调用比较简单,直接把组建名以类似Html元素的形式写入,然后后面的元素属性就是参数,按需赋值即可。

而筛选框组件,需要传入回调方法,监控状态的变化,而状态值是从子组件里传过来的,所以要在回调方法里获取这个值

csharp 复制代码
private async Task HandleStatusChanged(ManagerStatusFilter status)
{
    filterBuilder.RemoveCondition("Status");
    if (status != ManagerStatusFilter.UnKnown)
    {
        filterBuilder.AddSingle("Status", status);
    }
    await table.ReloadServerData();
}

上面这个方法,直接以参数的方式给到组件就ok了,这样就可以完成组件件的传值。

列表页其他内容

列表页主要的呈现就是那个table元素,这个在加载之前,要先从服务端把table数据获取过来,前后分离的方式这一步是要发送一个Ajax请求来完成,而在Blazor里,就可以像写接口一样,简单调用就可以了

Code部分的关键代码如下

csharp 复制代码
@inject IDictMetaConfigProvider _dictMetaConfigProvider //注入服务,这个也可以写到code块里
@inject ISnackbar Snackbar

@code{
    private DynamicFilterBuilder filterBuilder = new DynamicFilterBuilder();
    private List<DictMetaConfigResponseDto> dictMetaConfigs = new();
    private MudTable<DictMetaConfigResponseDto> table = new();

    public async Task<TableData<DictMetaConfigResponseDto>> LoadDictMetaConfigAsync(TableState state, CancellationToken ct)
    {
    
        var pageRequest = new PageReqDto
            {
                whereJsonStr = filterBuilder.BuildJson(),
                pageindex = state.Page + 1,
                pagesize = state.PageSize,
                orderby = "a.createdat",
                isAsc = true
            };
        var result = await _dictMetaConfigProvider.GetMetaConfigPageList(pageRequest, ct);
        if (!result.IsSuccess)
        {
            Snackbar.Add(result.ErrorMessage, Severity.Warning);
            return new();
        }
        dictMetaConfigs = result.Value;
        int total = Convert.ToInt32(result.Total);
        return new TableData<DictMetaConfigResponseDto>()
        {
            TotalItems = total,
            Items = dictMetaConfigs
        };
    }
}

这段就和写Api接口比较像了,注入服务之后,获取数据即可。这里面还涉及到一些页面交互组件,比如没有数据的时候有提示等等,整个过程都是用C#代码完成的,而Blazor会通过SignalR框架将数据传送到浏览器,完成数据的渲染。

页面部分的主要代码如下

csharp 复制代码
<MudTable 
          ServerData="LoadDictMetaConfigAsync"  
          Hover="true" 
          Bordered="true" 
          Breakpoint="Breakpoint.Md"
          Style="width:-webkit-fill-available"
          @bind-SelectedItems="selectedItems"
          FixedHeader="true"
          SelectOnRowClick="_selectionChangeable"
          SelectionChangeable="true"
          MultiSelection="true"
          T="DictMetaConfigResponseDto"
          OnRowClick="@OnRowClick"
          @ref="table">
    <ToolBarContent>
        <MudText Typo="Typo.h6">元数据</MudText>
        <MudSpacer />
        <MudTextField T="string" ValueChanged="@(s => OnSearch(s))" Placeholder="输入名称后回车检索" Adornment="Adornment.Start"
                      AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField>
    </ToolBarContent>

    <HeaderContent>

        <MudTh>属性名称</MudTh>
        <MudTh>属性说明</MudTh>
        <MudTh>属性类型</MudTh>
        <MudTh>是否必填</MudTh>
        <MudTh>归属模型</MudTh>
        <MudTh>状态</MudTh>
        <MudTh>操作</MudTh>

    </HeaderContent>
    <RowTemplate>
        <MudTd DataLabel="属性名称">@context.JsonKey</MudTd>
        <MudTd DataLabel="属性说明">@context.BusinessName</MudTd>
        <MudTd DataLabel="属性类型">
            @context.FieldType.GetDescription()
        </MudTd>
        <MudTd DataLabel="是否必填">
            @{
                if (context.IsRequired)
                {
                    <MudChip T="string" Variant="Variant.Outlined" Color="Color.Secondary">必填</MudChip>
                }
                else
                {
                    <MudChip T="string" Variant="Variant.Outlined" Color="Color.Default">非必填</MudChip>

                }
            }
        </MudTd>
        <MudTd DataLabel="归属模型">@context.JoinedTitle</MudTd>
        <MudTd DataLabel="状态">
            @{
                if (context.Status == StatusEnum.Enabled)
                {
                    <MudChip T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">可用</MudChip>
                }
                else
                {
                    <MudChip T="string" Icon="@Icons.Material.Filled.DisabledByDefault" IconColor="Color.Default">不可用</MudChip>

                }
            }
        </MudTd>
        <MudTh>
            <MudButton Href="@($"/dictmetaconfig/edit/{context.Id}")"
                       Variant="Variant.Filled"
                       Color="Color.Secondary"
                       StartIcon="@Icons.Material.Filled.Edit">
                编辑
            </MudButton>
            <MudButton onclick="(() => HandleDeleteClick(context.Id))"
                       Variant="Variant.Filled"
                       Color="Color.Error"
                       StartIcon="@Icons.Material.Filled.Delete">
                删除
            </MudButton>
        </MudTh>
    </RowTemplate>
    <NoRecordsContent>
        <MudText>无记录</MudText>
    </NoRecordsContent>

    <LoadingContent>
        <MudText>Loading...</MudText>
    </LoadingContent>

    <PagerContent>
        <MudTablePager />
    </PagerContent>
</MudTable>

这里主要的部分就是在Table组件里,使用ServerData属性绑定数据加载的事件"LoadDictMetaConfigAsync",然后在RowTemplate元素中,进行模型绑定,和之前使用js进行开发的流程基本是一样的。

这一套流程完成后,我们差不多就得到了这样一个页面

受篇幅限制,我没有把所有的地方都写出来,只展示了必要的组件,和表格数据获取的过程,其他的部分还是跟业务相关,大家结合微软的文档自行探索实现即可。

表单页

这里表单页实际上也可以根据场景来做一定的封装,将表单封装成一个当前业务模块的专属组件,然后创建页和编辑页就都可以用它了。

当然这个是分场景的,并不是所有组件都适合这样做。

表单组件

我这里的表单要实现模型的创建和修改,当创建场景时,它就是一个空表单,表单元素是需要用户输入或者选择,而编辑场景,需要默认把表单渲染好,然后用户进行修改即可。

组件代码如下

csharp 复制代码
@using FluentValidation
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject Magic.Declaration.Application.Interfaces.Manage.IDecMainRequirementProvider _decMainRequirementProvider


<MudContainer MaxWidth="MaxWidth.Medium">
    <MudCard>
        <MudCardHeader>
            <CardHeaderContent>
                <MudText Typo="Typo.h4">@(Id.HasValue ? "编辑活动要求" : "添加活动要求")</MudText>
            </CardHeaderContent>
        </MudCardHeader>

        <MudCardContent>
            <MudForm Model="@model" @ref="@form" Validation="@(requriementValidator.ValidateValue)" ValidationDelay="300">
                <MudTextField @bind-Value="model.Title"
                              For="@(() => model.Title)"
                              Immediate="true"
                              
                              Label="要求标题"
                              FullWidth="true"
                              Margin="Margin.Normal" />

                <MudTextField @bind-Value="model.Description"
                              For="@(() => model.Description)"
                              Placeholder="请填写详细的活动说明"
                              AutoGrow
                              Label="要求说明" 
                              FullWidth="true" 
                              Margin="Margin.Normal" />

                <MudSelect @bind-Value="model.DecMainRequirementType"
                           Label="申报成员类型"
                           AdornmentIcon="@Icons.Material.Outlined.Tag"
                           HelperText="如学生,站点,合作单位等个体,组织等"
                           AdornmentColor="Color.Tertiary">
                    @foreach (var item in Enum.GetValues(typeof(Domain.DecMainRequirementType)).Cast<Domain.DecMainRequirementType>())
                    {
                        <MudSelectItem Value="@item">@EnumExtensions.GetDescription(item)</MudSelectItem>
                    }
                </MudSelect>



                <MudTextField @bind-Value="model.MaxQuantity"
                              For="@(() => model.MaxQuantity)"
                              Label="数值上限"
                              Immediate="true"
                              InputType="InputType.Number"
                              FullWidth="true"
                              OnlyValidateIfDirty="true"
                              Margin="Margin.Normal" />

                <MudTextField @bind-Value="model.MinQuantity"
                              For="@(() => model.MinQuantity)"
                              Immediate ="true"
                              Label="数值下限"
                              InputType="InputType.Number"
                              FullWidth="true"
                              OnlyValidateIfDirty="true"
                              Margin="Margin.Normal" />

                <MudSelect Label="状态" @bind-Value="model.Status" FullWidth="true" Margin="Margin.Normal">

                    @foreach (var status in Enum.GetValues(typeof(Domain.StatusEnum)).Cast<Domain.StatusEnum>())
                    {
                        <MudSelectItem Value="@status">@EnumExtensions.GetDescription(status)</MudSelectItem>
                    }
                </MudSelect>
                
                @{
                    if (Id.HasValue)
                    {
                        <DecMainSelect Variant="Variant.Text" NodeId="model.DecMainId"></DecMainSelect>

                    }
                    else
                    {
                        <DecMainSelect Variant="Variant.Text" MainIdChanged="HandleMainIdChanged"></DecMainSelect>
                    }

                }

            </MudForm>
            <MudCardActions>
                <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit" Class="ml-auto">保存</MudButton>
                <MudButton Href="/decmainrequirement/index" Variant="Variant.Text" Color="Color.Default">返回</MudButton>

            </MudCardActions>
        </MudCardContent>
    </MudCard>
</MudContainer>

@code {
    [Parameter] public long? Id { get; set; }

    MudForm form=new ();

    DecMainRequirementRequestDto model = new DecMainRequirementRequestDto();

    RequirementModelFluentValidator requriementValidator = new RequirementModelFluentValidator();

    protected override async Task OnInitializedAsync()
    {
        if (!Id.HasValue)
        {
            return;
        }
        var result = await _decMainRequirementProvider.GetOneAsync(u => u.Id == Id);
        if (result == null)
        {
            return;
        }
        model = result.Adapt<DecMainRequirementRequestDto>();

    }

    private void HandleMainIdChanged(DecMain.DecMainSelect.MainSelectObj mainSelect)
    {
        if (mainSelect.IsLeaf)
            model.DecMainId = mainSelect.Id;
    }

    private async Task Submit()
    {
        await form.Validate();
        if (!form.IsValid)
        {
            Snackbar.Add("表单验证失败!",MudBlazor.Severity.Error);
            return;
        }
        var result = await _decMainRequirementProvider.CreateOrModifyRequirement(model);

        if(!result.IsSuccess){
            Snackbar.Add(result.ErrorMessage, MudBlazor.Severity.Error);
            return;
        }
        Snackbar.Add("保存成功", MudBlazor.Severity.Success);
        Navigation.NavigateTo("/decmainrequirement/index");
    }

    /// <summary>
    /// 尝试一下表单验证,后续其他复杂表单可以以此为例,进行扩充调整
    /// </summary>
    public class RequirementModelFluentValidator : AbstractValidator<DecMainRequirementRequestDto>
    {
        public RequirementModelFluentValidator()
        {
            RuleFor(m => m.Title)
                .NotEmpty()
                .Length(1, 50);

            RuleFor(m => m.Description)
            .MaximumLength(1500);

            RuleFor(m => m.MaxQuantity)
            .NotEmpty()
            .GreaterThan(0)
            .GreaterThanOrEqualTo(m => m.MinQuantity);

            RuleFor(m => m.MinQuantity)
            .NotEmpty()
            .GreaterThan(0)
            .LessThanOrEqualTo(m => m.MaxQuantity);

           
            RuleFor(m => m.DecMainRequirementType)
            .NotEqual(Domain.DecMainRequirementType.None);
        }

        #region 如果验证规则不满足,这里还可以定义私有方法,然后进行配置如
        // 规则包到👆上面的构造函数里去
        // RuleFor(x => x.Email)
        //         .Cascade(CascadeMode.Stop)
        //         .NotEmpty()
        //         .EmailAddress()
        //         .MustAsync(async (value, cancellationToken) => await IsUniqueAsync(value));
        // 私有方法写外面
        // private async Task<bool> IsUniqueAsync(string email)
        // {
        //     // Simulates a long running http call
        //     await Task.Delay(2000);
        //     return email.ToLower() != "test@test.com";
        // }
        #endregion

        public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
        {
            var result = await ValidateAsync(ValidationContext<DecMainRequirementRequestDto>.CreateWithOptions((DecMainRequirementRequestDto)model, x => x.IncludeProperties(propertyName)));
            if (result.IsValid)
                return Array.Empty<string>();
            return result.Errors.Select(e => e.ErrorMessage);
        };
    }
}

表单的逻辑比较简单,这里需要说明的就是引入了FluentValidation插件进行了表单验证,这不是必须的,如果你有其他的表单验证方式,可以不使用这个

创建页

表单组件创建好后,就非常简单了,在创建页和编辑页就像常规的组件调用一样

csharp 复制代码
@page "/DecMainRequirement/create"

@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
<MagicBreadcrumbs PageTitle="活动要求" PageMain="DecMainRequirement" PageSub="create" />

<InsertOrUpdateForRequirement></InsertOrUpdateForRequirement>

这里第一行是类似一个路由定义的代码,就可以通过这个地址访问到这个页面了,当然通用组件里不需要有这一步

页引入了一些认证授权的东西,这不是本篇重点,不在介绍。下面行就是面包屑组件和我们定义的表单组件了。

编辑页

编辑页和创建页不同的是,要传入一个id,这个id在表单页会发生作用,检索到当前的数据实体并完成表单赋值

csharp 复制代码
@page "/DecMainRequirement/edit/{Id:long}"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]

<MagicBreadcrumbs PageTitle="活动要求" PageMain="DecMainRequirement" PageSub="edit" />

<InsertOrUpdateForRequirement Id="@Id"></InsertOrUpdateForRequirement>

@code {
    [Parameter]
    public long? Id { get; set; }
}

这个和Create页的不同点就是引入表单组件的时候,传入了id,而这个id也是从其他页面传入的,比如刚刚我们定义的列表页,而头部的路由也使用了Rest风格,将id拼在了URL路径里。

这样处理完后,再次进入之后,表单元素会被赋好值,页面效果如下

删除

我这里的删除,就是在列表页增加了一个删除按钮,当然这仅限于单条的删除,如果涉及到批量删除的话,可能还需要做特殊的设计

csharp 复制代码
private async Task HandleDeleteClick(long Id)
{
    var options = new DialogOptions { CloseOnEscapeKey = true };
    var parameters = new DialogParameters
    {
        ["ContentText"] = "确定要删除这条记录吗?此操作不可恢复。",
        ["ButtonText"] = "删除",
        ["Color"] = Color.Error
    };

    var dialog = await DialogService.ShowAsync<DialogConfirm>("确认删除", parameters, options);
    var result = await dialog.Result;

    if (result == null || result.Canceled)
    {
        return;
    }

    try
    {
        await ValidateAntiforgeryTokenAsync();
        var removeResult = await _dictMetaConfigProvider.RemoveMetaConfig(Id);
        if (removeResult.IsSuccess)
        {
            Snackbar.Add(removeResult.ErrorMessage, Severity.Info);
            await table.ReloadServerData();
            return;
        }
        Snackbar.Add($"删除失败:{removeResult.ErrorMessage}", Severity.Error);
    }
    catch (Exception ex)
    {
        Snackbar.Add($"删除失败:{ex.Message}", Severity.Error);
    }
}

至此,一个功能模块的基本管理功能就完成了,整个过程,的确不需要任何的JavaScript知识,甚至CSS知识也不需要,我这里是使用了MudBlazor,他本身就基于Metarial风格封装了一些样式,然后使用起来和TailwindCSS一样,约定俗成,拿来用就行,这也和那些AntDesgin,TDesign等框架一样。

*安全性

接下来,我想在简单聊一下Blazor在安全性上的独特能力,以Blazor Server为例,它的数据托管模型是下图这样

更多关于Blazor托管模型的说明👉:learn.microsoft.com/zh-cn/aspne...

因为BlazorServer在服务器端渲染UI,并通过SignalR实时通信更新客户端。这意味着所有的业务逻辑都在服务器端执行,客户端只接收到渲染后的HTML和必要的UI更新指令。这种架构天然地免疫了许多常见的Web攻击,例如跨站脚本攻击(XSS)。由于没有直接在客户端执行的JavaScript代码,恶意脚本注入的风险大大降低。此外,Blazor Server也能方便地集成ASP.NET Core的内置安全机制,如防范跨站请求伪造(CSRF)攻击,为您的应用提供更坚固的安全保障。

增加防伪和数据保护配置

为了增强Blazor的数据安全能力,比如防止CSRF攻击的配置和配置数据防护,我们可以配置相关的服务

csharp 复制代码
//定义一个私有方法,单独进行数据安全层面的配置
private static void ConfigureDataProtection(this IServiceCollection services, IConfiguration configuration)
{
    if (configuration.GetSection("RedisSentinelStr").Exists())
    {
        services.AddDataProtection()
                       .PersistKeysToStackExchangeRedis(
                       ConnectionMultiplexer.Connect(configuration.GetSection("RedisSentinelStr").Value),
                       "DataProtection-Keys");
    }

    services.AddAntiforgery(options =>
    {
        options.Cookie.Name = "X-XSRF-TOKEN";
        options.Cookie.Path = "/";
        options.HeaderName = "RequestVerificationToken";
        
    });
    services.AddSingleton<IAntiforgeryTokenService, AntiforgeryTokenService>();
}
//然后注入服务
builder.Services.ConfigureDataProtection(_configuration);

注入的防伪服务代码

csharp 复制代码
public class AntiforgeryTokenService : IAntiforgeryTokenService
{
    private readonly IAntiforgery _antiforgery;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public AntiforgeryTokenService(IAntiforgery antiforgery, IHttpContextAccessor httpContextAccessor)
    {
        _antiforgery = antiforgery;
        _httpContextAccessor = httpContextAccessor;
    }

    public string GetRequestToken()
    {
        if(_httpContextAccessor.HttpContext == null)
            return string.Empty;
        var tokens = _antiforgery.GetAndStoreTokens(_httpContextAccessor.HttpContext);
        
        return tokens.RequestToken??string.Empty;
    }
}

然后我们还是像封装Blazor组件一样,封装一个防伪组件

csharp 复制代码
using Magic.Declaration.Manage.Services;
using Microsoft.AspNetCore.Components;
namespace Magic.Declaration.Manage.Components.SmallComponents
{
    public partial class AntiforgeryFormBase : ComponentBase
    {        
        [Inject] protected IAntiforgeryTokenService? AntiforgeryTokenService { get; set; }

        protected string? AntiforgeryToken { get; private set; }

        protected override async Task OnInitializedAsync()
        {
            // 获取并存储防伪 Token
            AntiforgeryToken = AntiforgeryTokenService?.GetRequestToken();
            Console.WriteLine("防伪码:" + AntiforgeryToken);
            await base.OnInitializedAsync();
        }

        /// <summary>
        /// 在子类中调用此方法来验证当前请求的防伪 Token
        /// </summary>
        protected async Task ValidateAntiforgeryTokenAsync()
        {
            // 这里不需要手动验证 Token,因为 ASP.NET Core 已经自动处理了
            // 我们只需要确保 Token 被正确地附加到了请求中
            await Task.Delay(1);
            
        }
    }
}

然后在Blazor组件中,直接注入使用即可

csharp 复制代码
private async Task HandleValidSubmit()
{

    try
    {
        //加入这一行👇
        await ValidateAntiforgeryTokenAsync();
        
        var result = await _decMainProvider.CreateOrUpdateMain(mainDto);

        if (!result.IsSuccess)
        {
            Snackbar.Add("活动保存失败!" + result.ErrorMessage, Severity.Error);
            return;
        }
        Snackbar.Add("活动保存成功!", Severity.Success);
        Navigation.NavigateTo("/decmain/index");
    }
    catch (Exception ex)
    {
        // 处理异常
        Snackbar.Add("活动保存失败!" + ex.Message, Severity.Error);
    }
}

这样操作完成后,就像在MVC的views里一样,所有的请求就都会携带一个这样的防伪标识,进一步提高系统的安全防护能力

burp拦截的交互请求

打开Burp,我们可以看一下它的数据交互情况,可以看到系统建立许多ws的链接历史,而这里实际上是一个长链接,如下图

这里connect那个是ide建立的做热重载用的,实际生产环境环境不会有这个套接字,实际产生数据交互,也就是服务端控制web端页面变化的套接字就是_blazor的那个,我们可以打开burp看一下它的请求历史,全部都是加密之后的字符,也就是说,这整个的数据交互过程都是安全的。

作为对比,我们拦截一个基于前后分离,通过http协议进行数据交互的例子

可以看到,尽管网站使用了https协议,但其数据交互信息,在数据发起这边,依旧是明文显示的,也就是你最多只能确保在网络传输层的安全,而黑客依旧是可以通过这次网络抓包工具,完整的获取你的请求参数,并进行一些破坏性的操作。而BlazorServer就不会,你通过抓包工具获取到的信息,也是加密的,这样就提高了数据的安全性。

总结

本来就像简单的安利一下Blazor,回头看写了这么多,不会起到反作用吧,哈哈。不过话说回来,如果大家真的用了一段时间Blazor,你真的会发现这是一个宝藏框架,高性能,高安全性,生态也越来越丰富。

此外,Blazor作为微软推出的企业级的Web开发框架,除了Blazor Server模式,还有Blazor WASM和Blazor Auto模式,各有优劣,微软最新的云原生应用开发框架Aspire,进一步印证了Blazor在.NET生态系统中的重要地位。

总而言之吧,Blazor的出现,不仅为.NET开发者带来了全新的Web前端开发范式,更在历史的进程中证明了其独特的价值。它吸取了前代产品的经验教训,紧跟技术潮流,并与微软的云原生战略紧密结合。Blazor并非昙花一现,而是.NET生态系统在Web前端领域的一次重要布局,它将持续为.NET开发者提供高效、安全、现代化的Web应用开发体验。对于那些希望在.NET平台上构建未来Web应用的开发者来说,现在正是拥抱Blazor的最佳时机。

我知道很多年长一点的.NET开发者可能还是忘不了曾经Sliverlight的教训,关于这点,我也找到了一篇博客:claudiobernasconi.ch/blog/blazor...,可以参考。

总结起来还是我开头提到的,Blazor是一个成熟的框架,它极具个性,又兼容并包,而且社区生态发展繁荣,也得到了微软官方的大力支持和推广,是绝对值得深度投入学习和使用的。

好了,就这样啦。

相关推荐
JosieBook2 小时前
【开源】一款开源、跨平台的.NET WPF 通用权限开发框架 (ABP) ,功能全面、界面美观
.net·wpf
界面开发小八哥5 小时前
界面组件DevExpress WPF中文教程:网格视图数据布局 - 数据单元格
.net·wpf·界面控件·devexpress·ui开发
SEO-狼术12 小时前
Red Gate .NET Developer Crack
.net
xiaopengbc12 小时前
[Windows] 微软.Net运行库离线合集包 Microsoft .Net Packages AIO v13.05.25
windows·microsoft·.net
智者知已应修善业15 小时前
【c#窗体荔枝计算乘法,两数相乘】2022-10-6
经验分享·笔记·算法·c#·.net
时光追逐者17 小时前
C#拾遗补漏之 Dictionary 详解
开发语言·c#·.net·.net core
切糕师学AI1 天前
Spire.XLS for .NET 中, 将 Excel 转换为 PDF 时, 如何设置纸张大小为A4纸,并将excel内容分页放置?
pdf·.net·excel·spire
TLucas2 天前
Centos 7部署.NET 8网站项目
linux·nginx·postgresql·centos·.net