• 如何使用ASP.NET Core、EF Core、ABP(ASP.NET Boilerplate)创建分层的Web应用程序(第一部分)


    本文是为了学习ABP的使用,是翻译ABP官方文档的一篇实战教程,我暂时是优先翻译自己感兴趣或者比较想学习的部分,后续有时间希望能将ABP系列翻译出来,除了自己能学习外,有可能的话希望帮助一些英文阅读能力稍微差一点的同学(当然我自己也不一定翻译的多好,大家共同学习)。

    其实这篇文章也花了我一些时间,突然感叹其实写文章挺不容易的,这次虽然是翻译,基本内容都是尊重原文的意思翻译,但是里面的每一句代码我都自己写了也运行测试了,截图都是自己运行的结果。

    这个ABP框架真的挺不错的,已经有很多人也已经翻译了,但是好像都是以前的,但是官网有些更新可能没同步,而且自己翻译觉得记忆更深刻一些。

    接受来自任何小伙伴任何方面的好评与差评!!!!!!!!!!!!!

    官网原文链接:https://aspnetboilerplate.com/Pages/Documents/Articles/Introduction-With-AspNet-Core-And-Entity-Framework-Core-Part-1/index.html

    --------------------------------------------------------------------------------------------------------------------------------------------------------------------

    简介

    在本文中,我将展示如何使用以下工具创建一个简单的跨平台分层web应用程序:

    • .Net Core作为基本的跨平台应用程序的开发框架
    • ABP(ASP.NET Boilerplate)作为启动模板和应用框架
    • ASP.NET Core作为Web 框架
    • Entity FrameWork作为ORM框架
    • BootStrap作为HTML/Css框架
    • jQuery作为客户端Ajax/Dom库
    • xUnit 和 Shouldly用来对服务器端的单元/集成测试

    我还将Log4NetAutoMapper,这些已经默认包含在ABP模板中。

    将要用到的技术(这些技术我们暂时都不做延伸的解释,后续有时间会有专门的文章进行说明):

    • 分层体系结构
    • 领域驱动设计(DDD)
    • 依赖注入(DI)
    • 集成测试

    我们将要开发一个任务管理的应用程序,任务可以进行分配给某些人。在这里,我们不用自己一层一层的去开发应用程序,而是在应用程序增长时切换到垂直层。随着应用程序的发展,我将根据需要介绍ABP和其他框架的一些特性。

    前期准备

    要运行和开发此示例,请提前在机器上安装下列工具:

    • Visual Studio 2017
    • SQL Server(可以将连接字符串更改为localdb)
    • Visual Studio扩展:
      • Bundler & Minifier
      • Web Compile

    创建应用程序

    使用ABP的启动模板(http://www.aspnetboilerplate.com/Templates)来创建一个名为“acme simpletaskapp”的新web应用程序。公司名称(这里的“Acme”)是可选的。我们选择多页Web应用程序(Multi Page Web Application),在这里为了保证最基本的启动模板功能,我们也不选择SPA,并且禁用了身份验证。

    它创建了一个分层的解决方案,如下所示:

    它包含6个以我们创建模板时输入的项目名称开头的项目。

    • .Core项目用于领域/业务层(实体、领域服务…)
    • .Appilcation项目为应用层(dtos,应用服务…)
    • .EntityFramework项目用于EF Core集成(从其他层抽象EF Core)
    • .Web项目就是ASP.Net MVC
    • .Tests项目用于单元测试和集成测试(直到应用层,不包括web层)
    • .Web.Tests用来对ASP.NET Core的集成测试(包括web层的完整的集成测试)

    运行一下应用程序,可以看到如下界面:

    它包含一个顶部菜单,空的主页和About页面和一个切换语言下拉选项。

    开发应用程序

    创建一个Task实体

    我想从一个简单的Task实体开始。由于实体是域层的一部分,所以我将它添加到.Core项目中:

    using Abp.Domain.Entities;
    using Abp.Domain.Entities.Auditing;
    using System;
    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    using System.ComponentModel.DataAnnotations.Schema;
    using System.Text;
    
    namespace Acme.SimpleTaskSystem
    {
        [Table("AppTasks")]
        public class Task : Entity, IHasCreationTime
        {
            public const int MaxTitleLength = 256;
            public const int MaxDescriptionLength = 64 * 1024; //64KB
    
            [Required]
            [MaxLength(MaxTitleLength)]
            public string Title { get; set; }
    
            [MaxLength(MaxDescriptionLength)]
            public string Description { get; set; }
    
            public DateTime CreationTime { get; set; }
    
            public TaskState State { get; set; }
    
            public Task()
            {
                CreationTime = Clock.Now;
                State = TaskState.Open;
            }
    
            public Task(string title, string description = null)
                : this()
            {
                Title = title;
                Description = description;
            }
        }
    
        public enum TaskState : byte
        {
            Open = 0,
            Completed = 1
        }
    }
    • Task继承ABP的Entity类,它默认包含int类型的Id属性。我们可以使用通用版本Entity<TPrimaryKey>来选择不同的PK类型。
    • IHasCreationTime是一个简单的接口,它只定义了CreationTime属性(为CreationTime使用一个标准名称非常好)。
    • Task实体定义一个必填的Title和一个可选的Description。
    • TaskState是一个简单的定义任务状态的枚举。
    • Clock.Now默认情况下返回DateTime.Now,但是它提供了一个抽象,所以有需要的话很容易的切换到DateTime.UtcNow。如果使用ABP框架,请用Clock.Now,而不是DateTime.Now
    • 将Task实体存储到数据库中的AppTasks表中。

    添加任务到DbContext

    .EntityFrameworkCore项目预定义了一个DbContext,我们应该在DbContext中添加一个Task实体的DbSet:

    using Abp.EntityFrameworkCore;
    using Microsoft.EntityFrameworkCore;
    
    namespace Acme.SimpleTaskSystem.EntityFrameworkCore
    {
        public class SimpleTaskSystemDbContext : AbpDbContext
        {
            //Add DbSet properties for your entities...
            public DbSet<Task> Tasks { get; set; }
            public SimpleTaskSystemDbContext(DbContextOptions<SimpleTaskSystemDbContext> options) 
                : base(options)
            {
    
            }
        }
    }

    现在EF Core知道我们已经有了一个Task实体。

    创建第一个数据库迁移

    我们将创建一个初始的数据库迁移来创建数据库和AppTasks表,从Visual Studio打开包管理器控制台并运行Add-Migration命令(默认项目必须是.EntityFrameworkCore项目):

    此命令在.EntityFrameworkCore项目中创建一个Migrations文件夹,该文件夹包含迁移类和数据库模型的快照:

     自动生成的“Initial”迁移类如下所示:

    using System;
    using Microsoft.EntityFrameworkCore.Metadata;
    using Microsoft.EntityFrameworkCore.Migrations;
    
    namespace Acme.SimpleTaskSystem.Migrations
    {
        public partial class Initial : Migration
        {
            protected override void Up(MigrationBuilder migrationBuilder)
            {
                migrationBuilder.CreateTable(
                    name: "AppTasks",
                    columns: table => new
                    {
                        Id = table.Column<int>(nullable: false)
                            .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn),
                        Title = table.Column<string>(maxLength: 256, nullable: false),
                        Description = table.Column<string>(maxLength: 65536, nullable: true),
                        CreationTime = table.Column<DateTime>(nullable: false),
                        State = table.Column<byte>(nullable: false)
                    },
                    constraints: table =>
                    {
                        table.PrimaryKey("PK_AppTasks", x => x.Id);
                    });
            }
    
            protected override void Down(MigrationBuilder migrationBuilder)
            {
                migrationBuilder.DropTable(
                    name: "AppTasks");
            }
        }
    }

    创建数据库

    从包管理器控制台运行Update-Database命令创建数据库:

    这条命令将在本地sql server中创建一个名为SimpleTaskSystemDb的数据库,并执行迁移:

    现在,我有一个Task实体和并在数据库中有相应的表,我们添加几条示例数据:

    注意,数据库连接字符串定义在.Web项目中的appsettings.json文件中。

    任务应用程序服务

    应用程序服务用于向表示层公开域逻辑,应用程序被表示层通过数据传输对象(DTO)作为参数(如果有需要)调用,使用域对象执行某些特定的业务逻辑,并返回一个DTO到表示层(如果需要)。

    我们在.Application项目中创建一个应用程序服务TaskAppService,以执行与任务相关的应用程序逻辑,首先定义一个应用程序服务的接口。

    public interface ITaskAppService : IApplicationService
    {
        Task<ListResultDto<TaskListDto>> GetAll(GetAllTasksInput input);
    }

    定义接口不是必须的,但是建议用接口。作为约定,在ABP中所有App服务都必须实现IApplicationService接口(它只是一个空的标记接口)。我创建了一个用于查询任务的GetAll方法。为此,我还定义了以下dto:

    public class GetAllTasksInput
        {
            public TaskState? State { get; set; }
        }
        [AutoMapFrom(typeof(Task))]
        public class TaskListDto : EntityDto, IHasCreationTime
        {
            public string Title { get; set; }
    
            public string Description { get; set; }
    
            public DateTime CreationTime { get; set; }
    
            public TaskState State { get; set; }
        }
    • GetAllTasksInput DTO定义了GetAll方法的输入参数,我没有直接将状态作为方法参数,而是将它添加到DTO对象中。这样的话,之后我们可以在DTO中添加其他参数,而不需要影响现有的客户端逻辑。
    • TaskListDto用于返回任务数据。它继承自定义了一个Id属性的EntityDto(我们可以将Id添加到Dto中,而不是从EntityDto派生出来),我们定义[AutoMapFrom]属性来创建从任务实体到TaskListDto的自动映射。这个属性在Abp.AutoMapper nuget包中定义。
    • 最后,ListResultDto是一个包含项目列表的简单类(我们可以直接返回一个列表)。

    现在我们可以去实现ITaskAppService 

    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using Abp.Application.Services.Dto;
    using Abp.Domain.Repositories;
    using Abp.Linq.Extensions;
    using Microsoft.EntityFrameworkCore;
    namespace Acme.SimpleTaskSystem
    {
        public class TaskAppService : SimpleTaskSystemAppServiceBase, ITaskAppService
        {
            private readonly IRepository<Task> _taskRepository;
    
            public TaskAppService(IRepository<Task> taskRepository)
            {
                _taskRepository = taskRepository;
            }
    
            public async Task<ListResultDto<TaskListDto>> GetAll(GetAllTasksInput input)
            {
                var tasks = await _taskRepository
                    .GetAll()
                    .WhereIf(input.State.HasValue, t => t.State == input.State.Value)
                    .OrderByDescending(t => t.CreationTime)
                    .ToListAsync();
    
                return new ListResultDto<TaskListDto>(
                    ObjectMapper.Map<List<TaskListDto>>(tasks)
                );
            }
        }
    }
    • TaskAppService继承自包含在ABP启动模板中的SimpleTaskSystemAppServiceBase(它继承于ABP的ApplicationService类),这不是必需的,应用程序服务可以是普通类。但是ApplicationService基类有一些预先注入的服务(如此处使用的ObjectMapper)。
    • 我用依赖注入去获得一个 repository
    • Repositories用来抽象对实体的数据库操作,ABP为每个执行公共任务的实体创建一个预定义的存储库(如这里的 IRepository<Task>), IRepository.GetAll()返回查询实体的IQueryable。
    • WhereIf 是ABP中的扩展方法,用来简化IQueryable.Where
    • ObjectMapper(来自ApplicationServiceBase类,默认情况下通过AutoMapper实现)用于将任务对象列表映射到TaskListDtos对象列表中。

    测试TaskAppService

    在进一步创建用户界面之前,我想测试TaskAppService。如果您对自动化测试不感兴趣,可以跳过这一部分。

     启动模板包含一个.Tests项目来测试我们的代码。它使用EF Core提供的内存数据库来代替SQL SERVER.因此我们的单元测试可以在没有真正的数据库下工作,它为每个测试创建一个单独的数据库。因此,测试是相互隔离的。我们可以使用TestDataBuilder类在运行测试之前向内存数据库添加一些初始测试数据。我更改TestDataBuilder代码如下所示:

    using Acme.SimpleTaskSystem.EntityFrameworkCore;
    
    namespace Acme.SimpleTaskSystem.Tests.TestDatas
    {
        public class TestDataBuilder
        {
            private readonly SimpleTaskSystemDbContext _context;
    
            public TestDataBuilder(SimpleTaskSystemDbContext context)
            {
                _context = context;
            }
    
            public void Build()
            {
                _context.Tasks.AddRange(new Task("Follow the white rabbit", "Follow the white rabbit in order to know the reality."),
                new Task("Clean your room") { State = TaskState.Completed });
            }
        }
    }

    可以看下示例项目的源代码,以了解TestDataBuilder在何处以及如何使用。我向dbcontext添加了两个任务(其中一个已经完成)。我可以编写测试,假设数据库中有两个任务。我的第一个集成测试测试上面创建的TaskAppService.GetAll()方法:

    using Shouldly;
    using System;
    using System.Collections.Generic;
    using System.Text;
    using Xunit;
    
    namespace Acme.SimpleTaskSystem.Tests
    {
        public class TaskAppService_Tests : SimpleTaskSystemTestBase
        {
            private readonly ITaskAppService _taskAppService;
            public TaskAppService_Tests()
            {
                _taskAppService = Resolve<ITaskAppService>();
            }
            [Fact]
            public async System.Threading.Tasks.Task Should_Get_All_Tasks()
            {
                //  act
                var output = await _taskAppService.GetAll(new GetAllTasksInput());
                //Assert
                output.Items.Count.ShouldBe(2);
            }
            [Fact]
            public async System.Threading.Tasks.Task Should_Get_Filtered_Tasks()
            {
                //Act
                var output = await _taskAppService.GetAll(new GetAllTasksInput { State = TaskState.Open });
    
                //Assert
                output.Items.ShouldAllBe(t => t.State == TaskState.Open);
            }
        }
    }

    我创建了两个不同的tests来测试GetAll()方法,现在我们从VS打开测试资源管理器(TestWindowsTest Explorer)来运行单元测试

    两个都成功了。注意ABP启动模板默认安装了xUnitShouldly ,所以我们才可以直接使用。

    创建任务列表视图

    现在,我知道TaskAppService可以正常工作,我可以开始创建一个页面来列出所有的任务。

    添加一个新的菜单项

    首先在顶部菜单中添加一个新的菜单

    using Abp.Application.Navigation;
    using Abp.Localization;
    
    namespace Acme.SimpleTaskSystem.Web.Startup
    {
        /// <summary>
        /// This class defines menus for the application.
        /// </summary>
        public class SimpleTaskSystemNavigationProvider : NavigationProvider
        {
            public override void SetNavigation(INavigationProviderContext context)
            {
                context.Manager.MainMenu
                    .AddItem(
                        new MenuItemDefinition(
                            PageNames.Home,
                            L("HomePage"),
                            url: "",
                            icon: "fa fa-home"
                            )
                    ).AddItem(
                        new MenuItemDefinition(
                            PageNames.About,
                            L("About"),
                            url: "Home/About",
                            icon: "fa fa-info"
                            )
                    ).AddItem(new MenuItemDefinition(
                        "TaskList",
                        L("TaskList"),
                        url:"Tasks",
                        icon:"fa fa-tasks"));
            }
    
            private static ILocalizableString L(string name)
            {
                return new LocalizableString(name, SimpleTaskSystemConsts.LocalizationSourceName);
            }
        }
    }

    如上所示,Startup模板附带两个页面:Home和About,我们可以修改他们,也可以自己创建新的页面,在这里我选择新创建页面。

     创建TaskController 和 ViewModel

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Mvc;
    
    namespace Acme.SimpleTaskSystem.Web.Controllers
    {
        public class TasksController : SimpleTaskSystemControllerBase
        {
            private readonly ITaskAppService _taskAppService;
            public TasksController(ITaskAppService taskAppService)
            {
                _taskAppService = taskAppService;
            }
            public async Task<ActionResult> Index(GetAllTasksInput input)
            {
                var output = await _taskAppService.GetAll(input);
                var model = new IndexViewModel(output.Items);
                return View(model);
            }
        }
    }
    • TasksController继承SimpleTaskSystemControllerBase(继承AbpController),SimpleTaskSystemControllerBase包含此应用程序中控制器的通用基本代码。
    • 为获得任务列表我注入了ITaskAppService
    • 我没有直接将GetAll方法的结果传递给视图,而是在.Web项目中创建了一个IndexViewModel类,如下所示:
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    
    namespace Acme.SimpleTaskSystem.Web
    {
        public class IndexViewModel
        {
            public IReadOnlyList<TaskListDto> Tasks { get; }
            public IndexViewModel(IReadOnlyList<TaskListDto> tasks)
            {
                Tasks = tasks;
            }
            public string GetTaskLabel(TaskListDto task)
            {
                switch(task.State)
                {
                    case TaskState.Open:
                        return "label-success";
                    default:
                        return "label-default";
                }
            }
        }
    }

    这个简单的视图模型在其构造函数中获取任务列表(由ITaskAppService提供)。它还具有GetTaskLabel方法,该方法将在视图中用于为给定任务选择Bootstrap标签类。

    创建任务列表页

    最后Index视图页如下所示:

    @using Acme.SimpleTaskSystem.Web.Startup
    @model Acme.SimpleTaskSystem.Web.IndexViewModel
    @{
        ViewBag.Title = L("TaskList");
        ViewBag.ActiveMenu = PageNames.TaskList; //和SimpleTaskSystemNavigationProvider定义的菜单名字相匹配,以高亮显示菜单项
    }
    
    <h2>@L("TaskList")</h2>
    <div class="row">
        <div>
            <ul class="list-group" id="TaskList">
                @foreach(var task in Model.Tasks)
                {
                <li class="list-group-item">
                    <span class="pull-right label @Model.GetTaskLabel(task)">@L($"TaskState_{task.State}")</span>
                    <h4 class="list-group-item-heading">@task.Title</h4>
                    <div class="list-group-item-text">
                        @task.CreationTime.ToString("yyyy-MM-dd HH:mm:ss")
                    </div>
                </li>
                }
            </ul>
        </div>
    </div>

    我们只是简单的使用给定的模型以及Bootstraplist group组件去呈现视图。在这里,我们使用了IndexViewModel.GetTaskLabel()方法来获取任务的标签类型。渲染的页面是这样的:

     本地化

    我们在视图中使用ABP框架的L方法,用于定义本地化字符串,我们已经在.Core项目中的Localization/SourceFiles文件夹下将其定义在.json文件中。en本地化如下:

    {
      "culture": "en",
      "texts": {
        "HelloWorld": "Hello World!",
        "ChangeLanguage": "Change language",
        "HomePage": "HomePage",
        "About": "About",
        "Home_Description": "Welcome to SimpleTaskSystem...",
        "About_Description": "This is a simple startup template to use ASP.NET Core with ABP framework.",
        "TaskList": "TaskList",
        "Open": "open",
        "TaskState_Open": "Open",
        "TaskState_Completed": "Completed"
      }
    }

    除了最后三行是新加的,其他全是启动模板自带的,我们可以根据情况进行删除。

    过滤任务

    正如上面所示,TasksController实际上获得一个GetAllTasksInput,可以用来过滤任务。我们可以在任务列表视图中添加下拉菜单来过滤任务。这里我们将下拉菜单添加到标题标签中:

    <h2>@L("TaskList")
        <span class="pull-right">
            @Html.DropDownListFor(
               model => model.SelectedTaskState,
               Model.GetTasksStateSelectListItems(LocalizationManager),
               new
               {
                   @class = "form-control",
                   id = "TaskStateCombobox"
               })
        </span>
    </h2>

    然后我在 IndexViewModel中增加SelectedTaskState属性和GetTasksStateSelectListItems方法:

     public TaskState? SelectedTaskState { get; set; }
    
            public List<SelectListItem> GetTasksStateSelectListItems(ILocalizationManager localizationManager)
            {
                var list = new List<SelectListItem>
            {
                new SelectListItem
                {
                    Text = localizationManager.GetString(SimpleTaskSystemConsts.LocalizationSourceName, "AllTasks"),
                    Value = "",
                    Selected = SelectedTaskState == null
                }
            };
    
                list.AddRange(Enum.GetValues(typeof(TaskState))
                        .Cast<TaskState>()
                        .Select(state =>
                            new SelectListItem
                            {
                                Text = localizationManager.GetString(SimpleTaskSystemConsts.LocalizationSourceName, $"TaskState_{state}"),
                                Value = state.ToString(),
                                Selected = state == SelectedTaskState
                            })
                );
    
                return list;
            }

    在控制器中设置SelectedTaskState:

     public async Task<ActionResult> Index(GetAllTasksInput input)
            {
                var output = await _taskAppService.GetAll(input);
                var model = new IndexViewModel(output.Items)
                {
                    SelectedTaskState = input.State
            };
                return View(model);
            }

    现在,我们可以运行应用程序查看视图右上角的combobox:

    现在这个combobox 只是显示出来了,还不能用,我们现在写一个javascript代码当combobox值改变时重新请求和刷新任务列表。

    我们在.Web项目中创建wwwrootjsviews asksindex.js文件:

    (function ($) {
        $(function () {
            var _$taskStateCombobox = $("#TaskStateCombobox");
            _$taskStateCombobox.change(function () {
                location.href = '/Tasks?state' + _$taskStateCombobox.val();
            });
        });
    })(jQuery)

    在视图中引用index.js之前,我使用了VS扩展Bundler & Minifier(这是在ASP.Net Core项目中缩小文件的默认方式,在vs->工具->扩展和更新->下载)来缩小脚本:

    这将在.Web项目的bundleconfig.json的文件中自动添加如下代码:

     {
        "outputFileName": "wwwroot/js/views/tasks/index.min.js",
        "inputFiles": [
          "wwwroot/js/views/tasks/index.js"
        ]
      }

    并创建一个缩小的index.min.js文件

    每当index.js改变时,index.min.js也会自动改变,现在我们将js文件加到对应的视图中:

    @section scripts
    {
        <environment names="Development">
            <script src="~/js/views/tasks/index.js"></script>
        </environment>
    
        <environment names="Staging,Production">
            <script src="~/js/views/tasks/index.min.js"></script>
        </environment>
    }

    有了上面的代码,我们可以在开发环境中使用index.js文件,在生产环境使用index.min.js文件,这是ASP.NET Core MVC项目中常用的方法。

    自动化测试任务列表页面

    我们可以创建继承测试,而且这已经被集成到 ASP.NET Core MVC 基础框架中。如果对自动化测试不感兴趣的小伙伴可以跳过这部分哦。

    ABP框架中的 .Web.Tests项目是用来做测试的,我创建一个简单的测试去请求TaskController.Index,然后看其如何响应:

    public class TasksController_Tests: SimpleTaskSystemWebTestBase
        {
            [Fact]
            public async System.Threading.Tasks.Task Should_Get_Tasks_By_State()
            {
                //ACT
                var response = await GetResponseAsStringAsync(
                    GetUrl<TasksController>(nameof(TasksController.Index), new
                    {
                        state = TaskState.Open
                    }
                    )
                    );
                //assert
                response.ShouldNotBeNullOrWhiteSpace();
            }
        }

    GetResponseAsStringAsyncGetUrl方法是ABP框架中AbpAspNetCoreIntegratedTestBase类提供的辅助方法。我们可以直接使用Client (HttpClient的一个实例)属性来发出请求,但是使用这些辅助类会更容易一些。

    调试测试,可以看到响应HTML:

    这说明index页面响应无异常,但是我们可能还想知道返回的HTML是不是我们所想要的,有一些库可以用来解析HTML。AngleSharp就是其中之一,它预装在ABP启动模板中的.Web.Tests项目中。所以我用它来检查创建的HTML代码:

     //Get tasks from database
                var tasksInDatabase = await UsingDbContextAsync(async dbContext =>
                {
                    return await dbContext.Tasks
                        .Where(t => t.State == TaskState.Open)
                        .ToListAsync();
                });
    
                //Parse HTML response to check if tasks in the database are returned
                var document = new HtmlParser().Parse(response);
                var listItems = document.QuerySelectorAll("#TaskList li");
    
                //Check task count
                listItems.Length.ShouldBe(tasksInDatabase.Count);
    
                //Check if returned list items are same those in the database
                foreach (var listItem in listItems)
                {
                    var header = listItem.QuerySelector(".list-group-item-heading");
                    var taskTitle = header.InnerHtml.Trim();
                    tasksInDatabase.Any(t => t.Title == taskTitle).ShouldBeTrue();
                }

    我们可以更深入和更详细地检查HTML,但是在大多数情况下,检查基本标签就足够了。

    后面我会更新翻译第二部分。。。。。。

  • 相关阅读:
    Java 第十一届 蓝桥杯 省模拟赛 梅花桩
    Java 第十一届 蓝桥杯 省模拟赛 梅花桩
    Java 第十一届 蓝桥杯 省模拟赛 梅花桩
    Java 第十一届 蓝桥杯 省模拟赛 元音字母辅音字母的数量
    Java 第十一届 蓝桥杯 省模拟赛 元音字母辅音字母的数量
    Java 第十一届 蓝桥杯 省模拟赛 元音字母辅音字母的数量
    Java 第十一届 蓝桥杯 省模拟赛 最大的元素距离
    Java 第十一届 蓝桥杯 省模拟赛 递增序列
    Java 第十一届 蓝桥杯 省模拟赛 递增序列
    Java 第十一届 蓝桥杯 省模拟赛 最大的元素距离
  • 原文地址:https://www.cnblogs.com/yixuanhan/p/9396488.html
Copyright © 2020-2023  润新知