ARTICLE · 1156955
eShop项目解读-WebApp(一)
builder.AddServiceDefaults();AddServiceDefaults在eShop.ServiceDefaults.Extensions中定义,包含了健康检查、服务发现、弹性伸缩。F12找到方法定义,可以发现以下方法
AddDefaultHealthChecks注册健康检查,添加了名为self带live标签的探活指针
AddServiceDiscovery注册服务发现,使服务能够通过逻辑名而非硬编码地址相互访问
ConfigureOpenTelemetry配置日志、指标和追踪
AddStandardResilienceHandler为所有HttpClient加上基于polly重试、超时、熔断弹性伸缩策略
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)
<RouterAppAssembly="@typeof(Program).Assembly"><FoundContext="routeData"><RouteViewRouteData="@routeData"DefaultLayout="@typeof(Layout.MainLayout)" /><FocusOnNavigateRouteData="@routeData"Selector="h1" /></Found></Router>
@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>
@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 visitedprivate 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);}
//声明组件渲染模式为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应答前显示Thinkingawait 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);}}}
@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-listboxhidden><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">…</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 / @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);}}
NavigationManager是Blazor的导航服务,用于读取当前URL和URL跳转,querystring也是url的一部分,所以代码中也是用它来读取。
CatalogService是自定义业务层服务,在实现上通过调用catalog.api的rest接口实现对数据库的访问。
<SectionContent>是系统定义的内容渲染标签,根据SectionName插入到对应插槽,我们可以在HeaderBar.razor中找到。
<CatalogSearch>是一个自定义模块,实现条件筛选,BrandIds和TypeIds是通过[Parameter]注解的属性。