夜雨聆风学习资料网

ARTICLE · 1156955

eShop项目解读-WebApp(一)

eShop项目解读-WebApp(一)
WebApp是整个eShop项目最前端,提供面向用户的交互界面,整个解决方案的功能解读从这个项目开始,逐层深入。我们从Program.cs开始,逐行代码进行分析。
builder.AddServiceDefaults();

AddServiceDefaults在eShop.ServiceDefaults.Extensions中定义,包含了健康检查、服务发现、弹性伸缩。F12找到方法定义,可以发现以下方法

AddDefaultHealthChecks注册健康检查,添加了名为self带live标签的探活指针

AddServiceDiscovery注册服务发现,使服务能够通过逻辑名而非硬编码地址相互访问

ConfigureOpenTelemetry配置日志、指标和追踪

AddStandardResilienceHandler为所有HttpClient加上基于polly重试、超时、熔断弹性伸缩策略

http.AddServiceDiscovery()为所有出站http调用也提供服务发现的能力
builder.Services.AddRazorComponents().AddInteractiveServerComponents();

AddRazorComponents向DI容器注册Razor Component所需服务,启用Blazor的组件渲染模型

AddInteractiveServerComponents进一步注册交互式服务器渲染所需服务,包括SignalR端点,维护每个用户电路的Ciruit服务,使组件能够处理点击、表单提交等事件。

AddApplicationServices

这个方法是一组能力的封装,下面进入方法实现分析代码。

builder.AddAuthenticationServices();//认证和鉴权builder.AddRabbitMqEventBus("EventBus")       .AddEventBusSubscriptions();//注册RabbitMQ事件总线,订阅6个订单状态变更事件,用于接收订单状态更新通知       builder.Services.AddHttpForwarderWithServiceDiscovery();//http头转发       builder.Services.AddScoped<BasketState>();//注册购物车状态,用scoped是因为每个用户分开算builder.Services.AddScoped<LogOutService>();//注册登出服务builder.Services.AddSingleton<BasketService>();//注册购物车服务builder.Services.AddSingleton<OrderStatusNotificationService>();//注册订单状态通知服务builder.Services.AddSingleton<IProductImageUrlProvider, ProductImageUrlProvider>();//注册产品图片链接提供者builder.AddAIServices();//注册AI购物注册(基于Ollama)builder.Services.AddGrpcClient<Basket.BasketClient>(o => o.Address = new("http://basket-api"))    .AddAuthToken();//注册grpc购物车客户端,地址http://basket-apibuilder.Services.AddHttpClient<CatalogService>(o => o.BaseAddress = new("https+http://catalog-api"))    .AddApiVersion(2.0)    .AddAuthToken();//注册grpc商品目录服务,地址http(s)://basket-apibuilder.Services.AddHttpClient<OrderingService>(o => o.BaseAddress = new("https+http://ordering-api"))    .AddApiVersion(1.0)    .AddAuthToken();//注册grpc订单服务,地址http(s)://ordering-apiapp.MapDefaultEndpoints();//为webapp项目添加/heath和/alive健康检查端点if (!app.Environment.IsDevelopment()){    app.UseExceptionHandler("/Error");//在生产环境下,跳转到/Error    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.    app.UseHsts();//启用Https严格传输,http请求自动升级为https,这个是管浏览器端的}app.UseAntiforgery();//启用防跨站请求伪造app.UseHttpsRedirection();//http请求自动跳转https,这个是管服务器端的app.UseStaticFiles();//启用静态文件访问app.MapRazorComponents<App>().AddInteractiveServerRenderMode();//将App.razor为入口,启用交互式服务器渲染模式app.MapForwarder("/product-images/{id}", "https+http://catalog-api", "/api/catalog/items/{id}/pic");//配置yarp反向代理,把前端地址(参数1)转发到catalog-api的后端地址(参数3)
从以上代码可以得知App.razor是WebApp的根组件,在该文件中可以找到两个组件引用<HeadOutlet/>和<Routes/>,这两个组件是Blazor的内置组件,Headoutlet负责把各个自定义组件中被包含的内容(如script、css、meta等)渲染到页面的Head区域,Routes是路由组件,根据当前页面url匹配@page指令注册的路由,渲染对应页面找不到时则显示NotFound(这个和vue的路由很像)。所以访问首页时的路由就是/
<RouterAppAssembly="@typeof(Program).Assembly">    <FoundContext="routeData">        <RouteViewRouteData="@routeData"DefaultLayout="@typeof(Layout.MainLayout)" />        <FocusOnNavigateRouteData="@routeData"Selector="h1" />    </Found></Router>
接下来阅读Router组件,在RouteView标签中定义了全局布局文件MainLayout,Router在AppAssembly指定的程序集中扫描所有包含@page的razor组件,Found标签根据路由进行组件匹配,Context把匹配结果命名为routeData,RouteView根据routeData渲染匹配到的组件。在WebApp中查找@page,可以找到/page/catalog/catalog.razor有@page /,所以网站根目录加载的就是这个blazor组件。DefaultLayout指向的组件将作为默认布局渲染到页面body。FocusOnNavigate在导航完成后把焦点自动移到Selector指向的元素,当前代码为页面第一个h1元素。
根据代码,我们知道默认的布局页是MainLayout.razor,我们再找到MainLayout.razor打开代码
@inherits LayoutComponentBase //声明本页面是整个站点的页面框架@using eShop.WebApp.Components.Chatbot<HeaderBar />  //插入HeaderBar模块到main标签之前,是顶部导航啦<mainclass="eshop-main">    @Body  // 这里是渲染被嵌套路由页面的内容,是布局页的核心占位符</main><FooterBar />  //插入FootBar模块到main标签的后面,是页脚<ShowChatbotButton /> //渲染AI聊天机器人悬浮按钮<divid="blazor-error-ui"> // blazor抛出异常时的错误提示框    An unhandled error has occurred.    <ahref=""class="reload">Reload</a>    <aclass="dismiss">🗙</a></div>
先看完其他部分,最后再看路由嵌套页面。首先是HeaderBar
@using Microsoft.AspNetCore.Components.Endpoints<header class="eshop-topbar">    <divclass="eshop-topbar-inner">        <aclass="eshop-brand"href=""aria-label="AdventureWorks home">            <imgalt="AdventureWorks"src="images/logo-header.svg" />        </a>        <navclass="eshop-topnav"aria-label="Primary">            <aclass="eshop-topnav-link @(IsCatalog ? "is-current" : "")" href="">Shop all</a>        </nav>        <divclass="eshop-topbar-actions">            <UserMenu />            <CartMenu />        </div>    </div></header><divclass="eshop-hero @(IsCatalog ? "is-home" : "") @(IsCompactHero ? "is-compact" : "")">    @{        var headerImage = IsCatalog ? "images/header-home.webp" : "images/header.webp";    }    <divclass="eshop-hero-media">        <imgrole="presentation"src="@headerImage" />    </div>    <divclass="eshop-hero-content">        <divclass="eshop-hero-intro">            <h1><SectionOutletSectionName="page-header-title" /></h1>            <p><SectionOutletSectionName="page-header-subtitle" /></p>        </div>    </div></div>@code {    [CascadingParameter]  //该注解允许父组件向任意层级的后代组件传递值,解决属性穿透的问题    public HttpContext? HttpContext { get; set; }    // We can use Endpoint Metadata to determine the page currently being visited    private Type? PageComponentType => HttpContext?.GetEndpoint()?.Metadata.OfType<ComponentTypeMetadata>().FirstOrDefault()?.Type;    private bool IsCatalog => PageComponentType == typeof(Pages.Catalog.Catalog);    private bool IsItemPage => PageComponentType == typeof(Pages.Item.ItemPage);    // Pages whose content should sit near the top under a shorter hero band.    private bool IsCompactHero =>        IsItemPage        || PageComponentType == typeof(Pages.Cart.CartPage)        || PageComponentType == typeof(Pages.User.Orders)        || PageComponentType == typeof(Pages.Checkout.Checkout);}
HttpContext?.GetEndpoint()?.Metadata.OfType<ComponentTypeMetadata>()获取到当前页面的组件类型,即带有@page的那个路由页面,具体是哪个页面是不确定的,需要后续判断。
IsCatalog判断是否商品目录页面,IsItemPage判断是否商品明细页面,IsCompactHero判断是否商品明细、购物车、订单、结算页面。
IsCatalog将影响logo高亮、导航样式、首页大图
IsCompactHero令影响到的页面使用更矮的横幅
FooterBar组件是纯HTML代码,略过不做赘述,接下来分析ShowChatbotButton,在这个模块中的重点是被引用的ChatBot.razor模块,下面是源码
//声明组件渲染模式为Blazor Server交互式,prerender=false不做预渲染@rendermode @(new InteractiveServerRenderMode(prerender: false))@using Microsoft.AspNetCore.Components.Authorization@using Microsoft.Extensions.AI@using eShop.WebApp.Chatbot//注入JS互操作对象,允许加载和调用Chatbot.razor.js@inject IJSRuntime JS//注入商品目录服务@inject CatalogService CatalogService// 注入商品图片URL生成器@inject IProductImageUrlProvider ProductImages// 注入购物车状态@inject BasketState BasketState// 注入当前用户@inject AuthenticationStateProvider AuthenticationStateProvider// 注入日志工厂@inject ILoggerFactory LoggerFactory@inject IServiceProvider ServiceProvider<div class="floating-pane" id="chatbot-pane" role="dialog" aria-label="Shopping assistant">    <headerclass="chatbot-header"data-chat-drag-handle>        <spanclass="chatbot-title">            Shopping assistant        </span>        <divclass="chatbot-header-actions">            <buttontype="button"class="chatbot-size"data-chat-sizearia-pressed="false"title="Toggle wide view"aria-label="Toggle wide view">                <svgclass="chatbot-size-expand"viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true">                    <pathd="M8 8 4 12l4 4" />                    <pathd="m16 8 4 4-4 4" />                    <pathd="M4 12h16" />                </svg>                <svgclass="chatbot-size-condense"viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true">                    <pathd="m4 8 4 4-4 4" />                    <pathd="m20 8-4 4 4 4" />                    <pathd="M9 12h6" />                </svg>            </button>            <buttontype="button"class="chatbot-close"data-chat-closetitle="Close"aria-label="Close assistant">                <svgviewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true">                    <pathd="M18 6 6 18M6 6l12 12" />                </svg>            </button>        </div>    </header>    <divclass="chatbot-chat @(chatState is null && missingConfiguration ? "is-centered" : "")" @ref="chat">        @if (chatState is not null)        {//循环输出对话消息            foreach (var message in chatState.Messages.Where(m => m.Role == ChatRole.Assistant || m.Role == ChatRole.User))            {                if (!string.IsNullOrEmpty(message.Text))                {                    <p @key="@message"class="message message-@message.Role">@MessageProcessor.AllowImages(message.Text)</p>                }            }        }        else if (missingConfiguration)        {//未配置AI则输出这部分            <divclass="chatbot-notice">                <spanclass="chatbot-notice-icon"aria-hidden="true">                    <svgviewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="1.7"stroke-linecap="round"stroke-linejoin="round">                        <circlecx="12"cy="12"r="9" />                        <pathd="M12 16v-4" />                        <pathd="M12 8h.01" />                    </svg>                </span>                <pclass="chatbot-notice-title">Assistant isn't switched on</p>                <p>Turn on Microsoft Foundry (<code>UseFoundry=true</code> in the AppHost) or enable Ollama for local AI, then reopen this panel.</p>            </div>        }        @if (thinking)        {            <pclass="thinking">Thinking...</p>        }    </div>//下面的form是对话输入框,提交消息由SendMessageAsync方法进行处理        <formclass="chatbot-input" @onsubmit="SendMessageAsync">// @Bind实现messageToSend字段的双向绑定                <textareaplaceholder="Ask about products, orders or recommendations..."aria-label="Message to the shopping assistant" @ref="@textbox" @bind="messageToSend"></textarea>        <buttontype="submit"class="chatbot-send"title="Send"aria-label="Send message"disabled="@(chatState is null)">            <svgviewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true">                <pathd="M12 19V5M5 12l7-7 7 7" />            </svg>        </button>    </form>    <spanclass="chatbot-grip chatbot-grip-n"data-chat-resize="n"aria-hidden="true"></span>    <spanclass="chatbot-grip chatbot-grip-s"data-chat-resize="s"aria-hidden="true"></span>    <spanclass="chatbot-grip chatbot-grip-e"data-chat-resize="e"aria-hidden="true"></span>    <spanclass="chatbot-grip chatbot-grip-w"data-chat-resize="w"aria-hidden="true"></span>    <spanclass="chatbot-grip chatbot-grip-nw"data-chat-resize="nw"aria-hidden="true"></span>    <spanclass="chatbot-grip chatbot-grip-ne"data-chat-resize="ne"aria-hidden="true"></span>    <spanclass="chatbot-grip chatbot-grip-sw"data-chat-resize="sw"aria-hidden="true"></span>    <spanclass="chatbot-grip chatbot-grip-se"data-chat-resize="se"aria-hidden="true">        <spanclass="chatbot-grip-mark"aria-hidden="true"></span>    </span></div>@code {    bool missingConfiguration;// AI是否已配置    ChatState? chatState; //聊天状态     ElementReference textbox; //对话输入框引用    ElementReference chat; // 来往消息显示DOM引用    string? messageToSend; //输入框绑定消息    bool thinking; //是否等待回复    IJSObjectReference? jsModule; //缓存的JS模块引用    protected override async Task OnInitializedAsync()    {        // 初始化组件生命周期        var client = ServiceProvider.GetService<IChatClient>();        if (client is not null)        {            AuthenticationState auth = await AuthenticationStateProvider.GetAuthenticationStateAsync();            // 注入商品目录、购物车状态、当前用户、产品图片、日志工厂            chatState = new ChatState(CatalogService, BasketState, auth.User, ProductImages, LoggerFactory, client);        }        else        {            missingConfiguration = true;        }    }    private async Task SendMessageAsync()    {        var messageCopy = messageToSend?.Trim();        messageToSend = null;        if (chatState is not null && !string.IsNullOrEmpty(messageCopy))        {            thinking = true;            //与AI进行对话交互,并更新来往消息,AI应答前显示Thinking            await chatState.AddUserMessageAsync(messageCopy, onMessageAdded: StateHasChanged);            thinking = false;        }    }//每次渲染后加载并缓存js模块,调用scrollToEnd滚动到底部    protected override async Task OnAfterRenderAsync(bool firstRender)    {        jsModule ??= await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Chatbot/Chatbot.razor.js");        await jsModule.InvokeVoidAsync("scrollToEnd", chat);        if (firstRender)        {            await jsModule.InvokeVoidAsync("submitOnEnter", textbox);        }    }}
最后看/路由对应的模块Catalog.razor,源码如下
@page "/"@inject NavigationManager Nav@inject CatalogService CatalogService@attribute [StreamRendering]<PageTitle>AdventureWorks</PageTitle><SectionContentSectionName="page-header-title">Ready for a new adventure?</SectionContent><SectionContentSectionName="page-header-subtitle">Start the season with the latest in clothing and equipment.</SectionContent><divclass="catalog">    <CatalogSearchBrandIds="@BrandIds"TypeIds="@TypeIds" />    @if (catalogResult is null)    {        <divclass="catalog-results"role="status"aria-label="Loading products">            <divclass="catalog-toolbar">                <spanclass="catalog-count-skeleton"aria-hidden="true"></span>            </div>            <divclass="catalog-items">                @for (var i = 0; i < PageSize; i++)                {                    <divclass="catalog-card-skeleton"aria-hidden="true">                        <divclass="skeleton-media"></div>                        <divclass="skeleton-line"></div>                        <divclass="skeleton-line short"></div>                    </div>                }            </div>        </div>    }    else if (catalogResult.Count == 0)    {        <divclass="catalog-results">            <divclass="catalog-empty">                <spanclass="catalog-empty-icon"aria-hidden="true">                    <svgviewBox="0 0 48 48"fill="none"stroke="currentColor"stroke-width="2.5"stroke-linecap="round"stroke-linejoin="round">                        <circlecx="21"cy="21"r="13" />                        <pathd="m38 38-7.6-7.6" />                    </svg>                </span>                <h2>No products match these filters</h2>                <p>Try removing a filter, or start over with the full collection.</p>                <aclass="button button-primary"href="@ResetFiltersUri()">Clear all filters</a>            </div>        </div>    }    else    {        <divclass="catalog-results">            <divclass="catalog-toolbar">                <pclass="catalog-count">@catalogResult.Count @(catalogResult.Count == 1 ? "product" : "products")</p>                <divclass="catalog-sort"data-catalog-sort>                    <spanclass="catalog-sort-label"id="catalog-sort-label">Sort</span>                    <divclass="catalog-sort-field">                        <divclass="catalog-sort-trigger"                             id="catalog-sort-trigger"                             role="combobox"                             tabindex="0"                             aria-haspopup="listbox"                             aria-expanded="false"                             aria-controls="catalog-sort-listbox"                             aria-labelledby="catalog-sort-label catalog-sort-trigger"                             data-catalog-sort-trigger>                            <spanclass="catalog-sort-value"data-catalog-sort-value>Name: A to Z</span>                            <svgclass="catalog-sort-caret"viewBox="0 0 12 12"aria-hidden="true"fill="none"stroke="currentColor"stroke-width="1.6"stroke-linecap="round"stroke-linejoin="round">                                <pathd="m2.5 4.5 3.5 3.5 3.5-3.5" />                            </svg>                        </div>                        <ulclass="catalog-sort-listbox"                            id="catalog-sort-listbox"                            role="listbox"                            tabindex="-1"                            aria-labelledby="catalog-sort-label"                            data-catalog-sort-listbox                            hidden>                            <liclass="catalog-sort-option"role="option"id="catalog-sort-opt-name-asc"data-value="name-asc"aria-selected="true">Name: A to Z</li>                            <liclass="catalog-sort-option"role="option"id="catalog-sort-opt-name-desc"data-value="name-desc"aria-selected="false">Name: Z to A</li>                            <liclass="catalog-sort-option"role="option"id="catalog-sort-opt-price-asc"data-value="price-asc"aria-selected="false">Price: Low to High</li>                            <liclass="catalog-sort-option"role="option"id="catalog-sort-opt-price-desc"data-value="price-desc"aria-selected="false">Price: High to Low</li>                        </ul>                    </div>                </div>            </div>            <divclass="catalog-items">                @foreach (var item in catalogResult.Data)                {                    <CatalogListItemItem="@item" />                }            </div>            @{                var totalPages = (int)Math.Ceiling(1.0 * catalogResult.Count / PageSize);                var current = Page.GetValueOrDefault(1);            }            @if (totalPages > 1)            {                <navclass="catalog-pagination"aria-label="Catalog pages">                    @if (current > 1)                    {                        <aclass="page-nav"href="@PageUri(current - 1)"rel="prev"aria-label="Previous page">                            <imgrole="presentation"src="icons/arrow-left.svg" /> Prev                        </a>                    }                    else                    {                        <spanclass="page-nav is-disabled"aria-disabled="true">                            <imgrole="presentation"src="icons/arrow-left.svg" /> Prev                        </span>                    }                    <spanclass="page-numbers">                        @foreach (var token in GetPageTokens(totalPages, current))                        {                            @if (token == -1)                            {                                <spanclass="page-ellipsis"aria-hidden="true">&hellip;</span>                            }                            else if (token == current)                            {                                <spanclass="page-number active-page"aria-current="page">@token</span>                            }                            else                            {                                <aclass="page-number"href="@PageUri(token)">@token</a>                            }                        }                    </span>                    <spanclass="page-status"aria-hidden="true">@current&nbsp;/&nbsp;@totalPages</span>                    @if (current < totalPages)                    {                        <aclass="page-nav"href="@PageUri(current + 1)"rel="next"aria-label="Next page">                            Next <imgclass="flip"role="presentation"src="icons/arrow-left.svg" />                        </a>                    }                    else                    {                        <spanclass="page-nav is-disabled"aria-disabled="true">                            Next <imgclass="flip"role="presentation"src="icons/arrow-left.svg" />                        </span>                    }                </nav>            }        </div>    }</div>@code {    const int PageSize = 9;    [SupplyParameterFromQuery]    public int? Page { get; set; }    [SupplyParameterFromQuery(Name = "brand")]    public int[]? BrandIds { get; set; }    [SupplyParameterFromQuery(Name = "type")]    public int[]? TypeIds { get; set; }    CatalogResult? catalogResult;    static IEnumerable<int> GetPageTokens(int totalPages, int current)    {        var pages = new SortedSet<int> { 1, totalPages };        for (var p = current - 1; p <= current + 1; p++)        {            if (p >= 1 && p <= totalPages)            {                pages.Add(p);            }        }        var prev = 0;        foreach (var p in pages)        {            if (prev != 0 && p - prev > 1)            {                yield return -1; // ellipsis marker            }            yield return p;            prev = p;        }    }    private string PageUri(int page) => Nav.GetUriWithQueryParameter("page", page <= 1 ? (int?)null : page);    private string ResetFiltersUri() => Nav.GetUriWithQueryParameters(new Dictionary<string, object?>()    {        { "page", null },        { "brand", null },        { "type", null },    });    protected override async Task OnInitializedAsync()    {        catalogResult = await CatalogService.GetCatalogItems(            Page.GetValueOrDefault(1) - 1,            PageSize,            BrandIds,            TypeIds);    }}
这里我们先看@Code部分代码。SupplyParameterFromQuery是把querystring参数和属性进行绑定,当query参数名和属性吗一致时可以省略Name属性,否则必须通过Name属性声明query的什么参数绑定到代码属性,这里页面的querystring有page、brand、type三个参数。GetPageTokens方法生成分页页面数组(只有首页、末页、上一页、下一页和当前页),代码中yield使得当前代码成为迭代器,每次都是迭代器中一个元素。PageUri是分页链接,ResetFiltersUri是清除所有筛选条件的链接,OnInitializedAsync载入商品清单。
再看上面页面部分,@Page /定义了当前组件的路由,在服务器收到请求时加载当前页面。

NavigationManager是Blazor的导航服务,用于读取当前URL和URL跳转,querystring也是url的一部分,所以代码中也是用它来读取。

CatalogService是自定义业务层服务,在实现上通过调用catalog.api的rest接口实现对数据库的访问。

@attribute [StreamRendering]启用流式渲染,在数据加载完成前先输出页面骨架,数据拿到后再补充真实内容,提升体验。
<PageTitle>是路由页面定义页面title的标签,系统自带,会自动插入到HeadOutlet这个插槽上,往上找到App.Razor的代码可以看到。

<SectionContent>是系统定义的内容渲染标签,根据SectionName插入到对应插槽,我们可以在HeaderBar.razor中找到。

<CatalogSearch>是一个自定义模块,实现条件筛选,BrandIds和TypeIds是通过[Parameter]注解的属性。

catalogResult加载成功后,即可通过for语句逐一渲染。
通过对这个页面的解析,我们已经对Blazor进行页面开发有了一定了解。

相关学习资料