65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET Core 微服务之路 第一部分:构建视图

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (36投票s)

2019年4月20日

CPOL

19分钟阅读

viewsIcon

50626

downloadIcon

1297

一系列关于设计模式、架构设计、框架和技术的文章的开篇之作,旨在引领大家深入了解 ASP.NET Core 微服务。

引言

话虽如此,如果您对本文未找到真正的微服务感到有些失望,那是因为我想涵盖的主题太多了,以至于无法在一篇文章中全部讲完(或进行深入讨论)。此外,我不会立即介绍微服务,因为我希望采用一种“渐进式”的、分步的方法,在接下来的过程中重构和改进代码库。所以请耐心等待,享受这段旅程。

鉴于这是整个系列的第一篇文章,我想列出下一部分计划涵盖的主题:

  • 第一部分:构建视图
  • 第二部分:视图组件
  • 第三部分:ASP.NET Core 身份验证
  • 第四部分:SQLite
  • 第五部分:Dapper
  • 第六部分:SignalR
  • 第七部分:Web API 单元测试
  • 第八部分:Web MVC 应用单元测试
  • 第九部分:监控健康检查
  • 第十部分:Redis 数据库
  • 第十一部分:IdentityServer4
  • 第十二部分:订单 Web API
  • 第十三部分:购物车 Web API
  • 第十四部分:目录 Web API
  • 第十五部分:使用 Polly 实现有弹性的 HTTP 客户端
  • 第十六部分:使用 Swagger 文档化 Web API
  • 第十七部分:Docker 容器
  • 第十八部分:Docker 配置
  • 第十九部分:使用 Kibana 进行集中式日志记录

正如我们所见,有许多主题需要涵盖。尽管各部分已编号,但这仅用于计数。实际上,随着项目的进展,实际顺序可能会发生变化。

软件要求

Resultado de imagem para Visual Studio

创建项目

我们将使用 Visual Studio Community 创建项目(也可以通过 Visual Studio Code 甚至命令行工具来完成),并选择 MVC 项目模板。

MVC 代表 Model-View-Controller(模型-视图-控制器),这是当今用于构建用户界面并应用关注点分离原则的普遍软件架构模式。

Model 部分指的是数据承载对象,负责保存显示在用户界面的信息,以及验证、收集和传输用户输入的信息到应用程序后端。

View 部分负责渲染/显示用户界面组件。通常,这在通俗的说法中被称为网页,但实际上网页在技术上是一整套 HTML 文件(包括标题、正文和页脚)、图像、图标、CSS 样式表、JavaScript 代码等。单个视图可能渲染整个网页,但通常每个视图仅负责页面内部的内容。

Controllers 是负责处理发往一组视图的传入请求的组件,决定需要为视图提供哪些数据,请求并准备这些数据,然后相应地调用视图。Controllers 还会处理数据违规,并在需要时将应用程序重定向到错误页面。

那么,让我们开始使用 Visual Studio 创建一个新的 ASP.NET Core MVC 项目吧。

首先,我们从 Visual Studio 菜单中单击“新建项目”,然后选择“Web 应用程序”选项。

New Project

图 1:新建项目对话框

这将打开向导窗口,我们必须选择“Web 应用程序(模型-视图-控制器)”选项。请务必取消选中“配置 HTTPS”选项,因为为了简单起见,我们目前不使用安全 HTTP(HTTPS)。

New Project Mvc

图 2:新建 ASP.NET Core Web 应用程序选择器

项目从选定的 MVC 模板加载后,我们就可以运行它(按 F5 键),然后在我们喜欢的 Web 浏览器中看到应用程序的主页。

Running

图 3:运行应用程序主页

上图显示了一个相当简单的 Web 应用程序。新项目已经为我们提供了基本 MVC 架构所需的文件。

那么,我们说的是哪些文件呢?让我们检查一下 Visual Studio 中的解决方案树。

Project Files

图 4:ASP.NET Core MVC 项目结构

请注意上图中的项目文件夹,其中包含了 MVC 的所有部分:ModelViewController

但是,我们的 ASP.NET Core MVC 应用程序是如何启动的呢?与所有 .NET 应用程序一样,可执行文件有一个入口点,它必须是一个包含在 Program 类中的 Main() 方法。

在 ASP.NET Core 应用程序中,Main() 方法必须设置并启动一个Web 主机,即我们 Web 应用程序的主机。

public class Program
{
    public static void Main(string[] args)
    {
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>();
}
列表 1:Program 类

从这里可以看出,调用了 WebHost.CreateDefaultBuilder() 方法来创建 Web 主机,但由于需要对其进行配置,因此我们还必须调用 UseStartup() 来传递 Startup 类名,该类负责 Web 主机的配置。让我们看看这个类是如何工作的,以及它将在我们的应用程序中如何使用。

Startup 类结构简单。它只包含两个方法:

  • ConfigureServices()
  • Configure()

在这种情况下,“服务”是任何可以添加以向我们的应用程序提供特定功能的组件,例如:MVC、日志记录、数据库、身份验证、Cookie、Session 等。

这些组件也称为“中间件”,可以是“管道”的一部分。每个中间件决定请求是否可以传递给管道中的下一个组件,并且可能包含在管道中的下一个组件之前或之后执行的算法。

通常,一个名为“MyService”的服务会在我们的 Startup 类中被引用两次:

  • 首先,在 ConfigureServices() 方法中的 AddMyService() 方法中。在这里,会为 AddMyService() 方法提供适当的配置,以便服务能够正常运行;
  • 然后,在 Configure() 方法中的 UseMyService() 方法中。

让我们看一下 Startup 类中的方法。第一个方法是 ConfigureServices(),它最初只配置 Cookie 策略选项并将 MVC 服务添加到应用程序中。

public class Startup
{
    ...
    // This method gets called by the runtime. Use this method 
    // to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<CookiePolicyOptions>(options =>
        {
            // This lambda determines whether user consent 
            // for non-essential cookies is needed for a given request.
            options.CheckConsentNeeded = context => true;
            options.MinimumSameSitePolicy = SameSiteMode.None;
        });
        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
    }
    ...
列表 2:Startup 类中的 ConfigureServices() 方法

然后,Configure 方法定义了哪些中间件通过一组“Use-Service”方法进行引用。

public class Startup
{
    ...
    // This method gets called by the runtime. 
    // Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/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();
        }

        app.UseHttpsRedirection();
        app.UseStaticFiles();
        app.UseCookiePolicy();

        app.UseMvc(routes =>
        {
            routes.MapRoute(
                name: "default",
                template: "{controller=Home}/{action=Index}/{id?}");
        });
    }
}
列表 3:Startup 类中的 Configure() 方法

这里,我们对要添加到请求管道的每个服务都有一个简短的描述:

  • app.UseDeveloperExceptionPage:对操作完成后应用程序的引用。
  • app.UseExceptionHandler:向管道添加一个中间件,该中间件将捕获异常、记录它们、重置请求路径,并重新执行请求。
  • app.UseHsts:为当前请求路径启用静态文件服务。
  • app.UseHttpsRedirection:将 CookiePolicyMiddleware 处理程序添加到指定的 IApplicationBuilder,从而启用 Cookie 策略功能。
  • app.UseStaticFiles:为当前请求路径启用静态文件服务。
  • app.UseCookiePolicy:将 CookiePolicyMiddleware 处理程序添加到指定的 IApplicationBuilder,从而启用 Cookie 策略功能。
  • app.UseMvc:将 MVC 添加到 IApplicationBuilder 请求执行管道。

索引页

我们的商店将使用有限的 30 种产品。每种产品都有一个图像,需要将其添加到 wwwroot 项目文件夹内的 /images/catalog 文件夹中。

Catalog Images

图 5:产品目录图像

这些产品将以“目录”视图的形式显示在主页上。此目录显示为按产品类别分组的一系列产品。每个类别都有一个 Bootstrap 4 组件,称为“Carousel”(轮播),它会自动轮播每组 4 个产品的类别产品。

@{
    ViewData["Title"] = "Home Page";
}

@for (int category = 0; category < 6; category++)
{
    <h3>Category Name</h3>

    <div id="carouselExampleIndicators-@category" class="carousel slide" data-ride="carousel">
        <ol class="carousel-indicators">
            <li data-target="#carouselExampleIndicators-@category" 
                data-slide-to="0" class="active"></li>
            <li data-target="#carouselExampleIndicators-@category" data-slide-to="1"></li>
            <li data-target="#carouselExampleIndicators-@category" data-slide-to="2"></li>
        </ol>
        <div class="carousel-inner">
            <div class="carousel-item active">
                <div class="container">
                    <div class="row">
                        @for (int i = 0; i < 4; i++)
                        {
                        <div class="col-sm-3">
                            <div class="card">
                                <div class="card-body">
                                    <img class="d-block w-100" 
                                     src="~/images/catalog/large_@((i+1 + 
                                     category * 5).ToString("000")).jpg">
                                </div>
                                <div class="card-footer">
                                    <p class="card-text">Product Name</p>
                                    <h5 class="card-title text-center">$ 39.90</h5>
                                    <div class="text-center">
                                        <a href="#" class="btn btn-success">
                                            Add to basket
                                        </a>
                                    </div>
                                </div>
                            </div>
                        </div>
                        }
                    </div>
                </div>
            </div>
            <div class="carousel-item">
                <div class="container">
                    <div class="row">
                        @for (int i = 0; i < 1; i++)
                        {
                        <div class="col-sm-3">
                            <div class="card">
                                <div class="card-body">
                                    <img class="d-block w-100" 
                                     src="~/images/catalog/large_@((i+5 + 
                                     category * 5).ToString("000")).jpg">
                                </div>
                                <div class="card-footer">
                                    <p class="card-text">Product Name</p>
                                    <h5 class="card-title text-center">$ 39.90</h5>
                                    <div class="text-center">
                                        <a href="#" class="btn btn-success">
                                            Add to basket
                                        </a>
                                    </div>
                                </div>
                            </div>
                        </div>
                        }
                    </div>
                </div>
            </div>
        </div>
        <a class="carousel-control-prev" href="#carouselExampleIndicators-@category" 
         role="button" data-slide="prev">
            <span class="carousel-control-prev-icon" aria-hidden="true"></span>
            <span class="sr-only">Previous</span>
        </a>
        <a class="carousel-control-next" href="#carouselExampleIndicators-@category" 
         role="button" data-slide="next">
            <span class="carousel-control-next-icon" aria-hidden="true"></span>
            <span class="sr-only">Next</span>
        </a>
    </div>
}
列表 4:Catalog Index 视图

样式

这是一个小步骤,但由于 Bootstrap 4 不再附带图标字体(glyphicons),因此需要我们自己安装。

Visual Studio 允许我们安装客户端库,例如 Font Awesome,这是一个流行的图标字体包。

Add Client Library

图 6:添加客户端库菜单

Add Font Awesome

图 7:添加客户端库对话框

现在字体文件已安装,我们必须在 _Layout.cshtml 文件中引用它们。

<link href="~/lib/font-awesome/css/font-awesome.css" rel="stylesheet" />
<link rel="stylesheet" href="~/css/site.css" />
列表 5:将 Font Awesome 引用添加到 _Layout.cshtml 文件

让我们看看如何添加我们的第一个图标。在 Home/Index.cshtml 视图中,我们添加一个带有 fa fa-shopping-cart 类的 HTML 元素。

<a href="#" class="btn btn-success">
    <span class="fa fa-shopping-cart"></span>
    Add to basket
</a>
列表 6:在目录的 Index 视图中使用 Font Awesome 图标

这将在“添加到购物车”按钮的左侧自动显示购物车图标。

再次运行应用程序,我们将看到购物车图标是如何渲染的。

Index Page

图 8:Font Awesome 图标

品牌

通过打开 _Layout.cshtml 文件,我们可以更改品牌名称为我们公司的名称。

<div class="container">
    &copy; 2019 - The Grocery Store - <a asp-area="" 
                  asp-controller="Home" asp-action="Privacy">Privacy</a>
</div>
列表 7:在 _Layout.cshtml 中更改公司名称

现在,由于默认的 ASP.NET Core MVC 模板不包含品牌,让我们自己添加它。

我们还需要在顶部栏中包含公司徽标,首先通过为导航栏定义一个背景 CSS 规则。

Pic

图 9:logo.png 文件
a.navbar-brand {
    ...
    background: url('../images/logo.png');
    ...
}
列表 8:在导航栏背景中定义公司徽标

部分视图 (Partial Views)

如果您查看我们的目录索引 Razor 文件,您会发现它变得很大且复杂,这可能会影响其内容的可读性和理解性。

使用 ASP.NET Core,我们可以使用部分视图轻松地将大型标记文件(如我们的目录视图)分解成更小的组件。

部分视图是一个 Razor 文件(.cshtml),它在另一个标记文件渲染的输出中渲染 HTML 元素。

现在,我们的目录视图将由多个逻辑部分组成,而不是一个单独的视图文件:

  • Views/Catalog
    • Index.cshtml
    • _SearchProducts.cshtml
    • _Categories.cshtml
    • _ProductCard.cshtml

通过使用隔离的部分视图,每个文件现在比一个包含所有内容的视图文件更易于维护。

要将部分视图应用于我们的应用程序,首先我们将大部分标记内容提取到一个新的 _Categories.cshtml 文件中。请注意,_Categories.cshtml 以下划线开头,这是部分视图的默认命名约定。

原始的 Index.cshtml 文件必须包含一个 <partial> 元素来渲染 _Categories.cshtml 的标记。该标签实际上是一个标签助手(Microsoft.AspNetCore.Mvc.PartialTagHelper 类),它在服务器上运行并在该位置渲染类别。

@{
    ViewData["Title"] = "Catalog";
    var products = Enumerable.Range(0, 30);
}

<partial name="_Categories" for="@products" />
列表 9:Catalog/Index.cshtml 文件

除了 PartialTagHelper,还可以使用 HtmlHelper 来引用部分视图。使用 HtmlHelper 的最佳实践是调用 PartialAsync。在以下代码片段中,PartialAsync 方法返回一个包装在 Task<TResult> 中的 IHtmlContent 类型。该方法通过在 await 调用前加上“@”符号来引用,以指示 Razor 引擎这是一个 C# 代码。

@{
    ViewData["Title"] = "Catalog";
    var products = Enumerable.Range(0, 30);
}

@await Html.PartialAsync("_Categories", products);
列表 9.1:Catalog/Index.cshtml 文件中的 Html.PartialAsync 变体

请注意,列表 9.1 中的代码与列表 9 中的代码产生完全相同的结果。请注意,我们还必须将 products 模型作为参数传递给该方法。

另一方面,_Categories.cshtml 文件看起来就像任何普通的 Razor 标记文件一样:我们可以定义 @model 指令、HTML 元素、标签助手、C# 代码等。您也可以使用标签助手在其中包含内部部分视图,如下面的文件所示。

@model IEnumerable<int>;

@{
    var products = Model;
    const int productsPerCategory = 5;
    const int PageSize = 4;
}

@for (int category = 0; category < (products.Count() / productsPerCategory); category++)
{
    <h3>Category @(category + 1)</h3>

    <div id="carouselExampleIndicators-@category" 
    class="carousel slide" data-ride="carousel">
        <div class="carousel-inner">
            @{
                int pageCount = (int)Math.Ceiling((double)productsPerCategory / PageSize);
                var productsInCategory =
                    products
                    .Skip(category * productsPerCategory)
                    .Take(productsPerCategory);

                for (int pageIndex = 0; pageIndex < pageCount; pageIndex++)
                {
                <div class="carousel-item @(pageIndex == 0 ? "active" : "")">
                    <div class="container">
                        <div class="row">
                            @{
                                var productsInPage =
                                    productsInCategory
                                    .Skip(pageIndex * PageSize)
                                    .Take(PageSize);

                                foreach (var productIndex in productsInPage)
                                {
                                    <partial name="_ProductCard" for="@productIndex"/>
                                }
                            }
                        </div>
                    </div>
                </div>
                }
            }
        </div>
        <a class="carousel-control-prev" href="#carouselExampleIndicators-@category" 
         role="button" data-slide="prev">
            <span class="carousel-control-prev-icon" aria-hidden="true"></span>
            <span class="sr-only">Previous</span>
        </a>
        <a class="carousel-control-next" href="#carouselExampleIndicators-@category" 
         role="button" data-slide="next">
            <span class="carousel-control-next-icon" aria-hidden="true"></span>
            <span class="sr-only">Next</span>
        </a>
    </div>
}
列表 10:Catalog/_Categories.cshtml 文件

现在,最后一个目录部分视图应该是包含产品卡详细信息的视图。

@model int;

@{ 
    var productIndex = Model;
}

<div class="col-sm-3">
    <div class="card">
        <div class="card-body">
            <img class="d-block w-100" 
             src="~/images/catalog/large_@((productIndex + 1).ToString("000")).jpg">
        </div>
        <div class="card-footer">
            <p class="card-text">Product Name</p>
            <h5 class="card-title text-center">$ 39.90</h5>
            <a href="#" class="btn btn-success">
                <span class="fa fa-shopping-cart"></span>
                Add to basket
            </a>
            </div>
        </div>
    </div>
</div>
列表 11:Catalog/_ProductCard.cshtml 文件

请注意,产品图像 URL 是通过将产品代码与图像路径的其余部分连接起来而提供的。

搜索产品部分视图

目录索引视图不仅用于显示产品,还用于搜索产品。顶部将是一个表单,用户可以在其中输入并提交搜索文本,以便只有匹配的产品或类别名称会显示在目录中。

再次,我们应该在主 Index.cshtml Razor 文件中添加一个新的部分视图标签助手(<partial>)。

@ {
    var products = Enumerable.Range(0, 30);
}

<partial name="_SearchProducts"/>

<partial name="_Categories" for="@products" />
列表 12:Catalog/_ProductCard.cshtml 文件

请注意,Index 视图保持整洁简洁。并且由于 _SearchProducts 部分视图不需要任何数据,因此不向其传递任何参数。

_SearchProducts 部分视图基本上是一个包含一些元素(标签 + 文本字段 + 提交按钮)的表单,用于将信息发送到服务器。

<div class="container">
    <h2>Search products</h2>
    <div id="custom-search-input">
        <div class="input-group col-md-12">
            <form>
                <div class="container">
                    <div class="row">
                        <div>
                            <input type="text" name="search"
                                   class="form-control input-lg"
                                   placeholder="category or product" />
                        </div>
                        <div>
                            <div class="input-group-btn text-center">
                                <a href="#" class="btn btn-success">
                                    <span class="fa fa-search"></span>
                                </a>
                            </div>
                        </div>
                    </div>
                </div>
            </form>
        </div>
    </div>
</div>
列表 13:Catalog/_SearchProducts.cshtml 文件

到目前为止,表单没有任何作用。但在接下来的文章中,我们将实现搜索功能。

购物车视图

用户选择任何产品后,必须将其重定向到“我的购物车”视图。此视图负责购物车功能,并将包含订单项信息的列表,例如:

  • 产品图片
  • 产品名称
  • 商品数量
  • 单价
  • 小计

到目前为止,我们只有 HomeController,其中包含我们的目录 Index() 操作。我们可以使用 HomeController 来保存购物车索引,但为了避免使我们应用程序中唯一的控制器变得混乱,我们为目录保留一个控制器,为购物车保留另一个控制器。

但是,由于“HomeController”的名称不够明确,让我们将其更名为“CatalogController”。这还需要我们将 View/Home 文件夹重命名为 View/Catalog

Home To Catalog

图 10:将 Home 控制器重命名为 Catalog

而且,由于 CatalogController 还包含一个显示 Error 视图的通用操作,因此最好将其提取到一个超类中,即一个由 CatalogControllerBasketController 继承的基类。

public abstract class BaseController : Controller
{
    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel 
           { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}
列表 14:BaseController.cs 文件

现在,让这两个控制器都继承自基类。

public class CatalogController : BaseController
{
    public IActionResult Index()
    {
        return View();
    }
}

public class BasketController : BaseController
{
    public IActionResult Index()
    {
        return View();
    }
}
列表 15:CatalogController 和 BasketController 类

此时,如果您再次尝试运行应用程序,您会注意到应用程序崩溃了,因为它仍然在查找位于名为 HomeController 的控制器中的 Index 操作。这被称为“默认路由”,它是在使用 MVC 项目模板创建新项目时配置的。

现在,我们必须通过将默认控制器从“Home”重命名为“Catalog”来更改默认路由。

app.UseMvc(routes =>
{
    routes.MapRoute(
        name: "default",
        template: "{controller=Catalog}/{action=Index}/{id?}");
});
列表 16:在 Startup.cs 中将新的默认路由设置为 CatalogController

至于 Basket 视图,我们再次使用 Bootstrap 组件来创建用户界面。它基本上是一个 Bootstrap Card 组件,包含一个卡片头部(带有多个列标题用于购物车商品名称)、一个卡片主体(用于购物车商品详细信息)和一个卡片底部(用于总计/商品数量)。

图 11:Bootstrap 4 Card 组件

正如我们所见,到目前为止,购物车商品数据只是在视图本身中声明的一个数组。稍后,这些数据将替换为来自控制器的数据。

@{
    ViewData["Title"] = "My Basket";

    var items = new[]
    {
        new { Id = 1, ProductId = 1, Name = "Broccoli", UnitPrice = 59.90, Quantity = 2 },
        new { Id = 2, ProductId = 5, Name = "Green Grapes", UnitPrice = 59.90, Quantity = 3 },
        new { Id = 3, ProductId = 9, Name = "Tomato", UnitPrice = 59.90, Quantity = 4 }
    };
}

<div class="row">
    <div class="col-sm-12">
        <div class="pull-right">
            <a class="btn btn-success" href="/">
                Add More Products
            </a>
            <a class="btn btn-success" href="/registration">
                Fill in Registration
            </a>
        </div>
    </div>
</div>

<h3>My Basket</h3>

<div class="card">
    <div class="card-header">
        <div class="row">
            <div class="col-sm-6">
                Item
            </div>
            <div class="col-sm-2 text-center">
                Unit Price
            </div>
            <div class="col-sm-2 text-center">
                Quantity
            </div>
            <div class="col-sm-2">
                <span class="pull-right">
                    Subtotal
                </span>
            </div>
        </div>
    </div>
    <div class="card-body">
        @foreach (var item in items)
        {
        <div class="row row-center">
            <div class="col-sm-2">
                <img class="img-product-basket w-75" 
                 src="/images/catalog/large_@(item.ProductId.ToString("000")).jpg" />
            </div>
            <input type="hidden" name="productId" value="012" />
            <div class="col-sm-4">@item.Name</div>
            <div class="col-sm-2 text-center">@item.UnitPrice.ToString("C")</div>
            <div class="col-sm-2 text-center">
                <div class="input-group">
                    <button type="button" class="btn btn-light">
                        <span class="fa fa-minus"></span>
                    </button>
                    <input type="text" value="@item.Quantity"
                            class="form-control text-center quantity" />
                    <button type="button" class="btn btn-light">
                        <span class="fa fa-plus"></span>
                    </button>
                </div>
            </div>
            <div class="col-sm-2">
                <div class="pull-right">
                    <span class="pull-right" subtotal>
                        @((item.Quantity * item.UnitPrice).ToString("C"))
                    </span>
                </div>
            </div>
        </div>
        <br />
        }
    </div>
    <div class="card-footer">
        <div class="row">
            <div class="col-sm-10">
                <span numero-items>
                    Total: @items.Length
                    item@(items.Length > 1 ? "s" : "")
                </span>
            </div>
            <div class="col-sm-2">
                Total: <span class="pull-right" total>
                    @(items.Sum(item => item.Quantity * item.UnitPrice).ToString("C"))
                </span>
            </div>
        </div>
    </div>
</div>

<br />

<div class="row">
    <div class="col-sm-12">
        <div class="pull-right">
            <a class="btn btn-success" href="/">
                Add More Products
            </a>
            <a class="btn btn-success" href="/registration">
                Fill in Registration
            </a>
        </div>
    </div>
</div>
列表 17:Catalog/Index.cshtml 文件

作为最后的调整,我们现在可以通过添加 CSS 规则来对齐购物车商品。

.row-center {
    display: flex;
    align-items: center;
}
列表 18:site.css 文件

请注意,我们使用了 flexbox 布局,这与 Bootstrap 4 使用的布局完全相同。

Basket

图 12:购物车列表

购物车部分视图

再次,我们通过将大的 Basket 视图拆分成部分视图来对其进行分解,就像我们对 Catalog 标记所做的那样。

在处理部分视图之前,让我们创建一个新的类来保存购物车商品数据。

public class BasketItem
{
    public int Id { get; set; }
    public int ProductId { get; set; }
    public string Name { get; set; }
    public decimal UnitPrice { get; set; }
    public int Quantity { get; set; }
}
列表 19:在 BasketController.cs 中定义的 Mock Basket Item 类

部分视图的一个优点是可重用性。我们的购物车商品有两个部分,一个在购物车列表卡片上方,一个在下方,两者都具有完全相同的控制按钮。

  • 添加更多产品
  • 填写注册信息
<div class="row">
    <div class="col-sm-12">
        <div class="pull-right">
            <a class="btn btn-success" href="/">
                Add More Products
            </a>
            <a class="btn btn-success" href="/">
                Fill in Registration
            </a>
        </div>
    </div>
</div>
列表 20:Basket/_BasketControls.cshtml 文件

正如我们所见,这些标记是重复的。幸运的是,部分视图使我们能够避免这种重复。

主购物车视图现在看起来更简单了,_BasketControls 部分视图实现位于购物车列表部分视图的上方和下方。

@using MVC.Controllers
@{
    ViewData["Title"] = "My Basket";
    List<BasketItem> items = new List<BasketItem>
    {
        new BasketItem { Id = 1, ProductId = 1, Name = "Broccoli", 
                         UnitPrice = 59.90m, Quantity = 2 },
        new BasketItem { Id = 2, ProductId = 5, Name = "Green Grapes", 
                         UnitPrice = 59.90m, Quantity = 3 },
        new BasketItem { Id = 3, ProductId = 9, Name = "Tomato", 
                         UnitPrice = 59.90m, Quantity = 4 }
    };
}

<partial name="_BasketControls" />

<h3>My Basket</h3>

<partial name="_BasketList" for="@items" />
<br />
<partial name="_BasketControls" />
列表 21:Basket/Index.cshtml 文件

这是提取到新的部分视图(_BasketList.cshtml)中的购物车列表标记。

@using MVC.Controllers
@model List<BasketItem>;

@{
    var items = Model;
}

<div class="card">
    <div class="card-header">
        <div class="row">
            <div class="col-sm-6">
                Item
            </div>
            <div class="col-sm-2 text-center">
                Unit Price
            </div>
            <div class="col-sm-2 text-center">
                Quantity
            </div>
            <div class="col-sm-2">
                <span class="pull-right">
                    Subtotal
                </span>
            </div>
        </div>
    </div>
    <div class="card-body">
        @foreach (var item in items)
        {
            <partial name="_BasketItem" for="@item" />
        }
    </div>
    <div class="card-footer">
        <div class="row">
            <div class="col-sm-10">
                <span numero-items>
                    Total: @items.Count
                    item@(items.Count > 1 ? "s" : "")
                </span>
            </div>
            <div class="col-sm-2">
                Total: <span class="pull-right" total>
                    @(items.Sum(item => item.Quantity* item.UnitPrice).ToString("C"))
                </span>
            </div>
        </div>
    </div>
</div>
列表 22:Basket/_BasketList.cshtml 文件

对于购物车商品详细信息,我们然后创建最后一个部分视图,即 _BasketItem.cshtml 文件。请注意,小计是如何通过将数量乘以单价来就地计算的。

@using MVC.Controllers

@model BasketItem

@{
    var item = Model;
}

<div class="row row-center product-line" item-id="@item.Id.ToString("000")">
    <div class="col-sm-2">
        <img class="img-product-basket w-75" 
         src="/images/catalog/large_@(item.ProductId.ToString("000")).jpg" />
    </div>
    <input type="hidden" name="productId" value="012" />
    <div class="col-sm-4">@item.Name</div>
    <div class="col-sm-2 text-center">@item.UnitPrice.ToString("C")</div>
    <div class="col-sm-2 text-center">
        <div class="input-group">
            <button type="button" class="btn btn-light">
                <span class="fa fa-minus"></span>
            </button>
            <input type="text" value="@item.Quantity"
                   class="form-control text-center quantity" />
            <button type="button" class="btn btn-light">
                <span class="fa fa-plus"></span>
            </button>
        </div>
    </div>
    <div class="col-sm-2">
        <div class="pull-right">
            <span class="pull-right" subtotal>
                @((item.Quantity * item.UnitPrice).ToString("C"))
            </span>
        </div>
    </div>
</div>
<br />
列表 23:Basket/_BasketItem.cshtml 文件

注册视图

在用户选择要包含在购物车中的产品和数量后,用户可以选择继续完成订单。但首先,需要一些个人信息,这通常是典型的电子商务流程所需的,例如计费、发票和配送等。

Registration

图 13:注册视图
using Microsoft.AspNetCore.Mvc;

namespace MVC.Controllers
{
    public class RegistrationController : BaseController
    {
        public IActionResult Index()
        {
            return View();
        }
    }
}
列表 24:RegistrationController 类

接下来,注册视图必须包含收集个人信息所需的所有字段。

<h3>Registration</h3>

<form method="post" action="/">
    <div class="card">
        <div class="card-body">
            <div class="row">
                <div class="col-sm-4">
                    <div class="form-group">
                        <label class="control-label">Customer Name</label>
                        <input type="text" class="form-control" />
                    </div>
                    <div class="form-group">
                        <label class="control-label">Email</label>
                        <input type="email" class="form-control" />
                    </div>
                    <div class="form-group">
                        <label class="control-label">Phone</label>
                        <input type="text" class="form-control" />
                    </div>
                </div>
                <div class="col-sm-4">
                    <div class="form-group">
                        <label class="control-label">Address</label>
                        <input type="text" class="form-control" />
                    </div>
                    <div class="form-group">
                        <label class="control-label">Additional Address</label>
                        <input type="text" class="form-control" />
                    </div>
                    <div class="form-group">
                        <label class="control-label">District</label>
                        <input type="text" class="form-control" />
                    </div>
                </div>
                <div class="col-sm-4">
                    <div class="form-group">
                        <label class="control-label">City</label>
                        <input type="text" class="form-control" />
                    </div>
                    <div class="form-group">
                        <label class="control-label">State</label>
                        <input type="text" class="form-control" />
                    </div>
                    <div class="form-group">
                        <label class="control-label">Zip Code</label>
                        <input type="text" class="form-control" />
                    </div>

                    <div class="form-group">
                        <a class="btn btn-success" href="/">
                            Keep buying
                        </a>
                    </div>
                    <div class="form-group">
                        <button type="submit"
                                class="btn btn-success button-notification">
                            Check out
                        </button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</form>
列表 25:Registration/Index.cshtml 文件

请注意,我们再次省略了表单操作,因为数据库更新功能将在未来提供。

结算视图

一旦客户填写了个人信息,我们就假设整个过程一切正常,然后将他/她重定向到一个新的网页,告知我们的客户订单已下达,并请他在订单处理和生成后等待进一步通知。

目前,Checkout 控制器也像其他控制器一样是一个相当简单的类。

public class CheckoutController : BaseController
{
    public IActionResult Index()
    {
        return View();
    }
}
列表 26:CheckoutController 类

该视图只有几行标记,其中包含有关购物车后处理的静态内容。唯一动态的信息是客户的电子邮件地址。

@{ 
    ViewData["Title"] = "Checkout";
    var email = "alice@smith.com";
}
<h3>Order Has Been Placed!</h3>

<div class="panel-info">
    <p>Your order has been placed.</p>
    <p>Soon you will receive an e-mail at <b>@email</b> including all order details.</p>
    <p><a href="/" class="btn btn-success">Back to product catalog</a></p>
</div>
列表 27:Checkout/Index.cshtml 文件

Checkout

图 14:Checkout 视图

我们的应用程序流程要求订单不应在购物车结算时立即处理,而应在未来某个时候异步处理。

通知视图

public class NotificationsController : BaseController
{
    public IActionResult Index()
    {
        return View();
    }
}
列表 28:NotificationsController 类

随着客户的不断购买,异步订单流程可能需要一些时间才能将实际数据库订单数据持久化。因此,我们有一个通知视图,客户可以在其中查看他/她之前的购买记录,并从那里获取有关实际订单的更多信息,例如发票、配送等。

@{
    ViewData["Title"] = "Notifications";
}
<h3>User Notifications</h3>

<div class="row">
    <div class="col-sm-12">
        <div class="pull-right">
            <a class="btn btn-success" href="/">
                Back to Catalog
            </a>
        </div>
    </div>
</div>
<br />

<div class="card">
    <div class="card-header">
        <div class="row">
            <div class="col-sm-2 text-center">
                <!--NEW?-->
            </div>
            <div class="col-sm-8">
                Message
            </div>
            <div class="col-sm-2 text-center">
                Date / Time
            </div>
        </div>
    </div>
    <div class="card-body notifications">
        <div class="row">
            <div class="col-sm-2 text-center">
                <span class="fa fa-envelope-open"></span>
            </div>
            <div class="col-sm-8">
                New order placed successfully: 2
            </div>
            <div class="col-sm-2 text-center">
                <span>
                    13/04/2019
                </span>
                &nbsp;
                <span>
                    18:04
                </span>
            </div>
        </div>
    </div>
</div>
<br />
<div class="row">
    <div class="col-sm-12">
        <div class="pull-right">
            <a class="btn btn-success" href="/">
                Back to Catalog
            </a>
        </div>
    </div>
</div>
列表 29:Notifications/Index.cshtml 文件

Notifications

图 15:Notification 视图

JSON 产品加载

到目前为止,我们有一个目录,它不显示实际产品,而是显示模拟数据。让我们开始一个新的重构周期,以便我们可以将更多真实数据注入到我们的目录视图中。

这类数据通常来自数据库或 Web 服务。但在我们的例子中,我们只需通过读取静态 JSON 文件来检索它们。products.json 文件位于我们项目文件夹的根目录下,其内容如下:

[
  {
    "number": 1,
    "name": "Oranges",
    "category": "Fruits",
    "price": 5.90
  },
  {
    "number": 2,
    "name": "Lemons",
    "category": "Fruits",
    "price": 5.90
  },
  .
  .
  .
]
列表 30:products.json 文件

在现实场景中,我们的目录数据库将最初用此 JSON 文件数据进行填充。这个过程称为“ seeding”(种子填充)。我们会用 JSON 文件“seed”数据库。但由于我们还没有数据库,我们将使用种子数据作为我们目录视图的直接来源。

我们在“MVC”中的“M”部分还没有做太多工作。对于模型,我们创建了两个类:ProductCategory。由于这两个类都有 Id 属性,我们可以将其移动到一个超类中,供模型类继承。

using System.Runtime.Serialization;

namespace MVC.Models
{
    public abstract class BaseModel
    {
        public int Id { get; set; }
    }
}
列表 31:BaseModel 类
public class Category : BaseModel
{
    public Category(int id, string name)
    {
        Id = id;
        Name = name;
    }

    public string Name { get; private set; }
}
列表 32:Category 类

对于 Product 类,我们可以提供一个新的只读 ImageURL 属性来计算图像路径。这将把构建路径的责任从视图中移开。

public class Product : BaseModel
{
    public Category Category { get; set; }
    public string Code { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string ImageURL { get { return $"/images/catalog/large_{Code}.jpg"; } }

    public Product(int id, string code, string name, decimal price, Category category)
    {
        Id = id;
        Code = code;
        Name = name;
        Price = price;
        Category = category;
    }
}
列表 33:Product 类

以下类负责读取 products.json 文件,将其反序列化为 product 对象集合,然后返回 product 列表。

public class SeedData
{
    public static async Task<List<Product>> GetProducts()
    {
        var json = await File.ReadAllTextAsync("products.json");
        var data = JsonConvert.DeserializeObject<List<ProductData>>(json);

        var dict = new Dictionary<string, Category>();

        var categories = 
            data
            .Select(i => i.category)
            .Distinct();

        foreach (var name in categories)
        {
            var category = new Category(dict.Count + 1, name);
            dict.Add(name, category);
        }

        var products = new List<Product>();

        foreach (var item in data)
        {
            Product product = new Product(
                products.Count + 1,
                item.number.ToString("000"),
                item.name,
                item.price,
                dict[item.category]);
            products.Add(product);
        }

        return products;
    }
}

public class ProductData
{
    public int number { get; set; }
    public string name { get; set; }
    public string category { get; set; }
    public decimal price { get; set; }
}
列表 34:SeedData 类

当然,我们也有一些代码需要重构。第一个要修改的组件是目录控制器。

我们将产品列表加载到一个局部变量中,然后将其作为模型参数传递给视图。

public class CatalogController : BaseController
{
    public async Task<IActionResult> Index()
    {
        var products = await SeedData.GetProducts();
        return View(products);
    }
}
列表 35:CatalogController 类

此外,目录 Index 视图中的模型类型必须修改为 List

@model List<Product>;
@using MVC.Models;
@{
    ViewData["Title"] = "Catalog";
}

<partial name="_SearchProducts"/>

<partial name="_Categories" for="@Model" />
列表 36:Index.cshtml

现在,我们需要用 C# 表达式替换产品字段,这些表达式从模型中提取数据。

  • @(product.ImageURL)
  • @product.Name
  • @product.Price.ToString("C")
@model Product;
@using MVC.Models;

@{ 
    var product = Model;
}

<div class="col-sm-3">
    <div class="card">
        <div class="card-body">
            <img class="d-block w-100" src="@(product.ImageURL)">
        </div>
        <div class="card-footer">
            <p class="card-text">@product.Name</p>
            <h5 class="card-title text-center">@product.Price.ToString("C")</h5>
            <div class="text-center">
                <a href="#" class="btn btn-success">
                    <span class="fa fa-shopping-cart"></span>
.
.
.
列表 37:Catalog/_ProductCard.cshtml 文件

此外,_Categories 部分视图也将被重构。首先,我们将模型类型更改为 List,并将 categories 变量赋值更改为 LINQ 查询,该查询仅返回 product 列表中的不重复类别对象。

@model List<Product>;

@{
    var products = Model;
    const int PageSize = 4;
    var categories = products.Select(p => p.Category).Distinct();
}
.
.
.
@foreach (var category in categories)
{
    <h3>@category.Name</h3>

    <div id="carouselExampleIndicators-@category.Id" 
     class="carousel slide" data-ride="carousel">
.
.
.
        var productsInCategory = products
            .Where(p => p.Category.Id == category.Id);
        int pageCount = (int)Math.Ceiling((double)productsInCategory.Count() / PageSize);
.
.
.
<a class="carousel-control-prev" href="#carouselExampleIndicators-@category.Id" 
 role="button" data-slide="prev">
.
.
.
<a class="carousel-control-next" href="#carouselExampleIndicators-@category.Id" 
 role="button" data-slide="next">
列表 38:Catalog/_Categories.cshtml 文件

由于我们使用的是不同的 Bootstrap 4 Carousel 组件,因此它们必须通过类别 ID 属性(@category.Id)进行标识。

productsInCategory 局部变量现在保存每个类别中的产品集合,我们将这些产品分组,以便每个轮播可以适当地填充。

应用程序导航

到目前为止,每个视图仍然是孤立的,没有链接来连接视图。让我们使用 AnchorTagHelper 来生成正确的链接,从而提供导航。

尽管 AnchorTagHelper 的外观与 HTML 标签相同,但它实际上在服务器端运行,在那里它根据以下属性计算锚点 URL:

  • asp-controller:MVC 控制器名称。省略时,假定为当前控制器。
  • asp-action:路径名称。省略时,假定为默认操作(Index)。
  • asp-route-*: 操作参数。必须单独提供每个操作参数。

第一个链接将是从目录视图到购物车列表。每当客户选择一个产品时,必须显示购物车,其中显示所选项目,数量为一。

如何将普通的 HTML anchor 元素更改为 AnchorTagHelper

首先,我们取当前的 anchor 元素……

<a href="#" class="btn btn-success">
列表 39:标签助手之前的 Anchor 元素

然后,用新的 asp-controller 属性替换 href 属性。

<a asp-controller="basket" class="btn btn-success">
列表 40:标签助手之后的 Anchor 元素

源代码中的这个小改动产生了巨大的影响:当 ASP.NET Core 使用 Razor SDK 编译视图时,它会注意到 asp-controller 属性,因此新的链接将不再被当作 HTML anchor 元素处理。相反,就像任何其他标签助手一样,它现在是一个服务器端组件,它在服务器上运行并渲染实际的 HTML 链接。

<a class="btn btn-success" href="/Basket">
列表 41:服务器端由标签助手渲染的 Anchor 元素

现在,让我们也将 AnchorTagHelper 应用到购物车控件部分视图。

.
.
.
    <div class="pull-right">
        <a asp-controller="catalog" class="btn btn-success">
            Add More Products
        </a>
        <a asp-controller="registration" class="btn btn-success">
            Fill in Registration
        </a>
    </div>
列表 42:Basket/_BasketControls.cshtml 文件

结论

本文系列的第一部分到此结束。如果您阅读到这里,非常感谢您的耐心。如果您喜欢这篇文章,或者有任何投诉或建议,请在下方留言。我将非常乐意收到您的反馈!

我们已经学习了如何使用 Visual Studio 创建新的 ASP.NET Core 项目,使用 Razor 引擎开发基本视图,为视图提供基本模型,并使用 anchor tag helpers 将它们链接在一起。我们将使用同一个项目作为下一篇文章的起点,在下一篇文章中,我们将讨论视图组件。

历史

  • 2019-04-20:初始版本
ASP.NET Core 通往微服务之路 第一部分:构建视图 - CodeProject - 代码之家
© . All rights reserved.