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

.NET Core RESTful or WebAPI MVC Web Application

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (12投票s)

2017年7月31日

CPOL

14分钟阅读

viewsIcon

29175

downloadIcon

1217

面向.NET Core MVC和RESTful服务的现代设计示例

引言

网上有很多关于如何构建WebAPI或RESTful服务的信息,但关于如何消费它的信息却少得多,所以我决定自己构建一个并与大家分享。在这篇博文中,我将解释如何创建一个RESTful服务,并结合一个消费该服务的MVC Web应用程序。该Web应用程序具有标准的CRUD(创建、检索、更新和删除)操作以及用于浏览数据的表视图。

概述

图1显示了应用程序被划分为几个层。每一层都有其自身的职责。

GUI层为用户界面呈现网页,并使用RESTful服务来接收和存储数据。RESTful服务提供资源模型的CRUD功能。在我们的例子中,资源模型代表一个国家。业务逻辑位于资源服务中。它接收CRUD调用,并知道如何验证业务规则。数据库服务管理存储,并使用Entity Framework实现。一些业务规则也在此层实现。例如,它检查主键约束是否未被违反。在此示例中,支持MySQL和SqlServer。在此示例中,GUI与RESTful服务通信,但也可以直接与资源服务通信。我省略了这一点,因为目标之一是创建和消费RESTful服务。每一层都用一个单独的C#项目创建。

资源模型

资源模型是一个简单的POCO(Plain Old CLR Object),代表一个国家。它在应用程序的不同层之间传递。

public class CountryResource : Resource<Int32>
  {    
    [Required]
    [Range(1, 999)]
    public override Int32 Id { get; set; }

    [Required]
    [StringLength(2, MinimumLength = 2, ErrorMessage = "Must be 2 chars long")]
    public String Code2 { get; set; }

    [Required]
    [StringLength(3, MinimumLength = 3, ErrorMessage = "Must be 3 chars long")]
    public String Code3 { get; set; }

    [Required]    
    [StringLength(50, MinimumLength = 2, ErrorMessage = "Name must be 2 to 50 chars long")]
    public String Name { get; set; }
  }

它将通用字段移到了基类Resource。请注意,该类有一个泛型类型用于其Id,对于CountryResource类,它是一个Int32

public class Resource<TKey> where TKey : IEquatable<TKey>
  {
    virtual public TKey Id { get; set; }
    public String CreatedBy { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? ModifiedAt { get; set; }
    public String ModifiedBy { get; set; }
    public String RowVersion { get; set; }
  }

资源服务

我想从关键层,即Resource服务开始。它通过实现IResourceService<TResource, TKey> interface来托管所有核心资源功能。

 public interface IResourceService<TResource, TKey> where 
       TResource : Resource<TKey> where TKey : IEquatable<TKey>
  {
    TResource Create();

    Task<TResource> FindAsync(TKey id);

    IQueryable<TResource> Items();

    LoadResult<TResource> Load(String sortBy, String sortDirection, 
              Int32 skip, Int32 take, String search, String searchFields);

    Task<ResourceResult<TResource>> InsertAsync(TResource resource);
    Task<ResourceResult<TResource>> UpdateAsync(TResource resource);
    Task<ResourceResult<TResource>> DeleteAsync(TKey id);
  }

泛型TKey设置主键类型,如StringIntGuid等。这使得interface对其他资源更具灵活性。此设计不支持复合键,并且键的名称始终是Id。基于interface IResourceService,我创建了特定的interface ICountryResourceService

public interface ICountryResourceService : IResourceService<CountryResource, Int32>
  {
  }

interface方法将允许我们稍后使用DI依赖注入模式创建RESTful控制器,并使其更易于测试。现在是时候动手实现ICountryResourceService了。

public class CountryResourceService : ICountryResourceService, IDisposable
  {
    private readonly IMapper mapper;
    protected ServiceDbContext ServiceContext { get; private set; }

    public CountryResourceService(ServiceDbContext serviceContext)
    {
      ServiceContext = serviceContext;

      // Setup AutoMapper between Resource and Entity
      var config = new AutoMapper.MapperConfiguration(cfg =>
      {
        cfg.AddProfiles(typeof(CountryMapping).GetTypeInfo().Assembly);
      });

      mapper = config.CreateMapper();    }

CountryResourceServer类将ServiceDbContext serviceContext参数作为依赖注入。Context参数是数据库服务实例,它知道如何存储和检索数据。

资源映射

在这个简单的例子中,CountryResource模型和Country Entity Model是相同的。对于更复杂的资源,资源模型和实体模型很可能不同,并且需要在这两种类型之间进行映射。在资源服务层中映射这两种类型还可以消除数据库服务对资源模型的引用的需要,并使设计更易于维护。由于映射仅在资源服务层中发生,因此可以在构造函数中设置映射器实例,例如,不需要DI。AutoMapper根据CountryMapping类处理两种类型之间的转换。

 public class CountryMapping : Profile
  {
    public CountryMapping()
    {
      // 2 way mapping resource <==> entity model
      CreateMap<Resources.CountryResource, Country>();
      CreateMap<Country, Resources.CountryResource>();
    }
  }

现在所有准备工作都已完成,可以使用AutoMapper了,FindAsync函数提供了一个很好的例子来说明如何使用AutoMapper。

public async Task<CountryResource> FindAsync(Int32 id)
    {
      // Fetch entity from storage
      var entity = await ServiceContext.FindAsync<Country>(id);

      // Convert emtity to resource
      var result = mapper.Map<CountryResource>(entity);

      return result;
    }

Business Rules

业务规则设置了资源约束,并在资源服务层中强制执行。

CountryResource的业务规则是:

  • Id,唯一,范围为1-999
  • Code2唯一,长度必须是2个大写字符,范围为A-Z
  • Code3唯一,长度必须是3个大写字符,范围为A-Z
  • Name,长度范围为2-50个字符。

创建或更新期间的验证

在保存资源之前,资源服务层会触发三个业务规则验证调用。

BeautifyResource(resource);

ValidateAttributes(resource, result.Errors);
      
ValidateBusinessRules(resource, result.Errors);

BeautifyResource提供了清理资源中不必要用户输入的机会。美化只是一个任务,它不进行任何验证。美化提高了有效用户输入的成功率。在美化过程中,会删除所有非字母字符,并将剩余的string转换为大写。

 protected virtual void BeautifyResource(CountryResource resource)
    {
      // Only letter are allowed in codes
      resource.Code2 = resource.Code2?.ToUpperInvariant()?.ToLetter();
      resource.Code3 = resource.Code3?.ToUpperInvariant()?.ToLetter();

      resource.Name = resource.Name?.Trim();
    }

ValidateAttributes在属性级别强制执行简单的业务规则。通过在属性上添加属性来设置验证属性。CountryResource模型中的StringLength属性是验证属性的一个例子。一个属性可以有多个验证属性。由开发人员决定这些约束不相互冲突。

 protected void ValidateAttributes(CountryResource resource, IList<ValidationError> errors)
    {
      var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(resource);
      var validationResults = new List<ValidationResult>(); ;

      Validator.TryValidateObject(resource, validationContext, validationResults, true);

      foreach (var item in validationResults)
        errors.Add(new ValidationError(item.MemberNames?.FirstOrDefault() ?? "", item.ErrorMessage));
    }

ValidateBussinesRules强制执行不能通过验证属性涵盖的复杂业务规则。复杂规则可以编码在ValidateBusinessRules中。字段Code2Code3的唯一约束不能通过属性完成。

    protected virtual void ValidateBusinessRules
       (CountryResource resource, IList<ValidationError> errors)
    {
      // Check if Code2 and Code3 are unique 
      var code2Check = Items().Where(r => r.Code2 == resource.Code2);
      var code3Check = Items().Where(r => r.Code3 == resource.Code3);

      // Check if Id is unique for new resource
      if (resource.RowVersion.IsNullOrEmpty())
      {
        if (Items().Where(r => r.Id == resource.Id).Count() > 0)
          errors.Add(new ValidationError($"{resource.Id} is already taken", nameof(resource.Id)));
      }
      else
      {
        // Existing resource, skip resource itself in unique check
        code2Check = code2Check.Where(r => r.Code2 == resource.Code2 && r.Id != resource.Id);
        code3Check = code3Check.Where(r => r.Code3 == resource.Code3 && r.Id != resource.Id);
      }

      // set error message
      if (code2Check.Count() > 0)
        errors.Add(new ValidationError($"{resource.Code2} already exist", nameof(resource.Code2)));

      if (code3Check.Count() > 0)
        errors.Add(new ValidationError($"{resource.Code3} already exist", nameof(resource.Code3)));
    }

如果约束未满足,则会将错误返回给调用者。这些错误会在GUI中显示。

删除期间的验证

业务规则也适用于删除操作。假设一个资源在工作流中有一个特殊状态,并且禁止删除。在删除期间,会调用ValidateDelete方法。

protected virtual void ValidateDelete(CountryResource resource, IList<ValidationError> errors)
    {
      if (resource.Code2.EqualsEx("NL"))
      {
        errors.Add(new ValidationError("It's not allowed to delete the Low Lands! ;-)"));
      }
    }

如果设置了错误,则会取消删除并显示错误。

数据库服务

数据库服务存储资源数据,并使用Entity Framework构建。它只接收来自资源服务层的调用。如果您想将Entity Framework替换为您自己选择的OM(对象映射器),这会减少工作量。该服务有一些便捷的功能:

数据库无关

数据库服务不知道实际使用的数据库。数据库配置通过RESTful服务层的DI(依赖注入)进行设置。我稍后会更详细地解释这一点。

审计支持

在实体保存(插入或更新)之前,会设置审计跟踪字段。当前实现很简单,但它提供了一个很好的扩展跟踪的起点。SaveChangesAsync 方法被重写,并添加了一个小的钩子AddAuditInfo

public override Int32 SaveChanges(Boolean acceptAllChangesOnSuccess)
    {
      AddAuditInfo();

      var result = base.SaveChanges(acceptAllChangesOnSuccess);

      return result;
    }

    public override Task<Int32> SaveChangesAsync(Boolean acceptAllChangesOnSuccess, 
                       CancellationToken cancellationToken = default(CancellationToken))
    {
      AddAuditInfo();

      return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }

审计字段在AuditInfo中设置。

private void AddAuditInfo()
    {
      // process all pending items
      foreach (var entry in this.ChangeTracker.Entries())
      {
        var entity = entry.Entity as Entity;

        var currentUserName = Identity?.Name ?? "Unknown";

        if (entity != null)
        {
          // Create new row version for concurrency support
          entity.RowVersion = Guid.NewGuid().ToString();

          // set audit info new entity
          if (Entry(entity).State == EntityState.Added)
          {
            entity.CreatedBy = currentUserName;
            entity.CreatedAt = DateTime.UtcNow;
          }

          // set audit info existing entity
          if (Entry(entity).State == EntityState.Modified)
          {
            entity.ModifiedBy = currentUserName;
            entity.ModifiedAt = DateTime.UtcNow;
          }
        }
      }
    }

并发检测

数据库服务支持乐观并发。这意味着在编辑之前,不会对实体设置锁。乐观锁定有一个简单的处理方法。获取具有版本信息的实体,开始编辑并保存。在保存过程中,会将版本信息与数据库中的最新版本进行比较。如果它们相同,一切正常。如果版本不同,则其他人已进行了更新,并且发生了并发错误。如何处理此错误以及如何将其呈现给用户不属于数据库服务的职责。删除时,没有并发检测。用户想要删除资源,如果资源已不存在或内容已更改,则没有理由取消删除。UpsertAsync方法插入或更新实体并执行并发检查。

 public async Task<TEntity> UpsertAsync<TEntity>(TEntity entity) where TEntity : Entity
    {
      // transaction is required for concurrency check
      using (var transaction = Database.BeginTransaction())
      {
        try
        {
          // Detect Insert or Update
          var entityState = String.IsNullOrEmpty(entity.RowVersion) ? 
                                    EntityState.Added : EntityState.Modified;

          // Check for concurrency error before update
          if (entityState == EntityState.Modified)
          {
            var keyValues = GetKeyValues(entity);

            // Find existing entity based on keyvale(s)
            var existingEntity = await FindAsync<TEntity>(keyValues);

            var existingRowVersion = existingEntity?.RowVersion ?? null;

            // If the rowversion does not match with the entity
            // the entity is updated by an other user or process and concurrency error has occurred
            if (existingRowVersion != entity.RowVersion)
              throw new ConcurrencyException("Concurrency Error");
          }

          if (entityState == EntityState.Added)
            Add(entity);
          else
            Attach(entity);

          Entry(entity).State = entityState;

          var ra = await SaveChangesAsync();

          Database.CommitTransaction();
        }
        catch (Exception ex)
        {
          Database.RollbackTransaction();

          throw ex;
        }

        return entity;
      }
    }

RESTful服务

在深入细节之前,先简要介绍一下REST(Representational State Transfer)服务。REST是一种在互联网上交换计算机之间资源的架构风格。在过去的几年里,REST已成为构建Web服务的首选设计。大多数开发人员发现REST比SOAP或WDSL服务更容易使用。REST有一些设计原则:

  • HTTP动词:GET、POST、DELETE、UPDATE
  • 无状态的
  • 以JSON形式传输数据
  • 状态码
  • URI和API设计
  • 自描述性错误消息

HTTP方法

HTTP围绕资源和动词设计。HTTP动词指定操作类型:

  • GET检索资源。
  • POST创建一个资源。
  • PUT更新一个资源。
  • PATCH更新资源的一小部分。
  • DELETE删除一个资源(您已经猜到了)。

当您只想更新一个字段时,PATCH可能会很有用,例如,工作流应用程序中的状态。我的示例中未使用PATCH

无状态的

REST旨在快速。无状态服务可提高性能,并且更易于设计和实现。

JSON

JSON是服务器和客户端之间序列化资源的最佳选择。也可以使用XML,但XML比JSON更冗长,会导致更大的传输文档。另一个原因是,对于Web客户端开发,有很多非常好的JSON解析器,例如jQuery的JSON.parse(...)

状态码

响应的状态码指示结果。这样,就不需要在响应消息本身中添加某种状态结果。最常见的状态码是:

代码 描述 示例
2xx 成功 -
200 好的 资源已更新
201 Created 资源已创建
204 无内容 资源已删除
4xx 客户端错误 -
400 错误请求 POST/PUT具有无效业务规则的资源
404 未找到 GET命令找不到资源
409 冲突 并发错误

URI和API

URI(统一资源标识符)在设计良好的API中起着重要作用。URI必须一致、直观且易于猜测。获取国家的API可以是:

https://:50385/api/Country/528
{
  "Id": 528,
  "Code2": "NL",
  "Code3": "NLD",
  "Name": "Netherlands",
  "CreatedBy": "Unknown",
  "CreatedAt": "2017-06-08T11:56:16.187606",
  "ModifiedAt": null,
  "ModifiedBy": null,
  "RowVersion": "60985dce-f4c1-41a4-9d92-28cb62048ed8"
}
200

自描述性错误消息

一个好的REST服务会返回一条有用的错误消息。客户端如何显示错误消息取决于客户端。假设我们要使用此PUT请求更新一个资源。

{
  "Id": 528,
  "Code2": "NL",
  "Code3": "NLD",
  "Name": null,
  "CreatedBy": "Me",
  "CreatedAt": "2030-01-25T03:15:21",
  "ModifiedAt": null,
  "ModifiedBy": null,
  "RowVersion": "60985dce-f4c1-41a4-9d92-28cb62048ed8"
}

业务规则要求Name是必填项,但请求中未设置,这将导致错误。

{
  "Resource": {
    "Id": 528,
    "Code2": "NL",
    "Code3": "NLD",
    "Name": null,
    "CreatedBy": "Me",
    "CreatedAt": "2030-01-25T03:15:21",
    "ModifiedAt": null,
    "ModifiedBy": null,
    "RowVersion": "60985dce-f4c1-41a4-9d92-28cb62048ed8"
  },
  "Errors": [
    {
      "Message": "Name",
      "MemberName": "The Name field is required."
    }
  ],
  "Exceptions": []
}

Swagger

Swagger UI是一个免费插件,在RESTful开发过程中非常有帮助。使用Swagger,您可以轻松开发和测试您的解决方案。

图2 Swagger通用屏幕

概览显示了可用的API,并且可以轻松进行测试。

图3 测试API调用

RESTful控制器

在.NET Core中,REST控制器与MVC控制器相同。它们仅在路由属性上有所不同。

 [Route("api/[controller]")]
  public class CountryController : Controller
  {
    private readonly ICountryResourceService ResourceService;

    public CountryController(ICountryResourceService resourceService)
    {
      ResourceService = resourceService;
    }

数据库依赖注入

在此示例中,RESTful服务连接到MySQL或SqlServer。

控制器在启动时通过DI接收ResourceService interface作为配置。设置位于appsettings.json文件中。

"ConnectionStrings": {
    DatabaseDriver: "MySql",
     // MySql connection
    "DbConnection": "server=localhost;Database=DemoCountries;User Id=root;password=masterkey"

    // SqlServer connection
    // "DbConnection": "server=localhost;Database=DemoCountries;Trusted_Connection=True"
  },

仅当DatabaseDriver指向“MySQL”(不区分大小写)时,服务才连接到MySQL数据库,其他任何设置都将连接到SqlServer。

public void ConfigureServices(IServiceCollection services)
    {
      // Get Database connection config
      var connectionString = Configuration.GetConnectionString("DbConnection");

      // Connect by default SqlServer other wise to MySql 
      var databaseDriver = Configuration.GetConnectionString("DatabaseDriver");

      // Setup Database Service layer used in CountryResourceService
      if (databaseDriver.EqualsEx("MySQL"))
        services.AddDbContext<EntityContext>(options => options.UseMySql(connectionString));
      else
        services.AddDbContext<EntityContext>(options => options.UseSqlServer(connectionString));

      // Setup ResourceService
      services.AddTransient<ICountryResourceService, CountryResourceService>();

GET方法

[HttpGet("{id:int}")]
    public async Task<IActionResult> Get(Int32 id)
    {
      var resource = await ResourceService.FindAsync(id);

      return (resource == null) ? NotFound() as IActionResult : Json(resource);
    }

资源服务获取资源,如果找到,则返回一个包含资源的JSON结构;如果未找到,则返回一个空消息,状态码为404(未找到)。

驼峰式或帕斯卡式命名法

默认情况下,返回的JSON结构是驼峰式的。我认为这很不方便,因为在过程的某个地方,属性名会被更改并可能导致错误。幸运的是,默认行为可以在启动时设置。

// This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
       ...
           // Add framework services.
      services.AddMvc()
        .AddJsonOptions(options =>
        {
          // Override default camelCase style 
          // (yes its strange the default configuration results in camel case)
          options.SerializerSettings.ContractResolver = 
                   new Newtonsoft.Json.Serialization.DefaultContractResolver();
        });

POST方法

[HttpPost]
    public async Task<IActionResult> Post([FromBody]CountryResource resource)
    {
      try
      {
        // Create resource
        var serviceResult = await ResourceService.InsertAsync(resource);

        // if return error message if needed
        if (serviceResult.Errors.Count > 0)
          return BadRequest(serviceResult);

        // On success return URL with id and newly created resource  
        return CreatedAtAction(nameof(Get), 
               new { id = serviceResult.Resource.Id }, serviceResult.Resource);
      }
      catch (Exception ex)
      {
        var result = new ResourceResult<CountryResource>(resource);

        while (ex != null)
          result.Exceptions.Add(ex.Message);

        return BadRequest(result);
      }
    }

[HttpPost]属性告诉控制器仅响应POST请求并忽略所有其他类型。[FromBody]属性确保CountryResource是从消息正文中读取,而不是从URI或其他来源读取。Swagger不关心[FromBody]属性,但C# Web客户端没有它就会失败。请注意,成功后会返回URI和资源。

PUT方法

 [HttpPut]
    public async Task<IActionResult> Put([FromBody]CountryResource resource)
    {
      try
      {
        var currentResource = await ResourceService.FindAsync(resource.Id);

        if (currentResource == null)
          return NotFound();

        var serviceResult = await ResourceService.UpdateAsync(resource);

        if (serviceResult.Errors.Count > 0)
          return BadRequest(serviceResult);

        return Ok(serviceResult.Resource);
      }
      catch (Exception ex)
      {
        var result = new ResourceResult<CountryResource>(resource);

        while (ex != null)
        {
          result.Exceptions.Add(ex.Message);

          if (ex is ConcurrencyException)
            return StatusCode(HttpStatusCode.Conflict.ToInt32(), result);

          ex = ex.InnerException;
        }

        return BadRequest(result);
      }
    }

PUT实现与POST函数非常相似。成功后,将返回更新后的资源,状态码为200(OK),否则返回错误消息。

DELETE方法

[HttpDelete("{id}")]
    public async Task<IActionResult> Delete(Int32 id)
    {
      try
      {
        var serviceResult = await ResourceService.DeleteAsync(id);

        if (serviceResult.Resource == null)
          return NoContent();

        if (serviceResult.Errors.Count > 0)
          return BadRequest(serviceResult);

        return Ok();
      }
      catch (Exception ex)
      {
        var result = new ResourceResult<CountryResource>();

        while (ex != null)
          result.Exceptions.Add(ex.Message);

        return BadRequest(result);
      }
    }

DELETE方法与POSTPUT具有相同的模式:将实际工作委托给资源服务,并报告成功或失败及错误。

GET(修改版)

REST支持函数重载,您可以拥有带有其他参数的“相同”函数。在第一个GET示例中,根据传入的Id返回一个国家。您也可以根据其Code字段获取一个国家。

// Extra GET, based on String type Code 
[HttpGet("{code}")]
    public IActionResult Get(String code)
    {
      if (code.IsNullOrEmpty())
        return BadRequest();

      code = code.ToUpper();

      CountryResource result = null;

      switch (code.Length)
      {
        case 2:
          result = ResourceService.Items().Where(c => c.Code2 == code).FirstOrDefault();
          break;

        case 3:
          result = ResourceService.Items().Where(c => c.Code3 == code).FirstOrDefault();
          break;
      }

      return (result == null) ? NotFound() as IActionResult : Json(result);
    }

// Original GET based in Int32 type Id
[HttpGet("{id:int}")]
    public async Task<IActionResult> Get(Int32 id)
    {
      var resource = await ResourceService.FindAsync(id);

      return (resource == null) ? NotFound() as IActionResult : Json(resource);
    }

现在Get有一个string类型的参数作为code。根据其长度(2或3个char),返回相应的国家。为了实现这一点,原始函数必须在HttpGet属性中具有int类型。如果省略,将有2个get函数,它们都带有string参数,路由将无法解析。当参数计数解析路由时,不需要额外的类型信息。更复杂的Get函数演示了这一点。

    [HttpGet]
    public IActionResult Get(String sortBy, String sortDirection, Int32 skip, 
             Int32 take, String search, String searchFields)
    {
       var result = ResourceService.Load(sortBy, sortDirection, skip, take, search, searchFields);

      return Json(result);
    }

GUI

现在我们已经具备了构建GUI所需的所有服务。GUI是一个简单的Dot Net Core MVC项目,带有Bootstrap样式。我故意省略了安全部分。内容已经很多了,我将在下一篇博文中解释安全性。

您可以在我之前的一篇博文中找到更多关于bootstrap-table网格的信息。模态对话框是用优秀的Dante nakupanda Bootstrap dialog库创建的。该库消除了冗长的Bootstrap模态对话框HTML。网格和对话框使用jQuery(当然还有什么)连接在一起,有关更多详细信息,请参见文件cruddialog.js

GUI控制器

GUI控制器使用HttpClient连接到RESTful服务。HttpClient在控制器之外设置,并通过DI作为构造函数参数传递,因为它不是控制器关心RESTful服务托管在哪里。

   public CountryController(HttpClient client)
    {
      apiClient = client;

      apiUrl = "/api/country/";
      ...

apiUrl在构造函数中设置,而不是通过DI设置,因为控制器与此URL紧密耦合。

设置HttpClient

HttpClient的基地址在配置文件appsettings.json中设置。

...
 "HttpClient": {
    "BaseAddress": "https://:50385",
  },
...

依赖注入在ConfigureServicesstartup.cs)期间设置。

public void ConfigureServices(IServiceCollection services)
    {
      ...
      // Read config file for HttpClient settings
      services.Configure<HttpClientConfig>(Configuration.GetSection("HttpClient"));

      // Setup Dependency Injection HttpClient
      services.AddTransient<HttpClient, HttpRestClient>();
      ...

HttpClient不实现可以传递给GUI构造函数的interface。我创建了一个自定义的HttpRestClient来控制HttpClient的设置。

namespace System.Config
{
  public class HttpClientConfig
  {
    public String BaseAddress { get; set; }

    public String UserId { get; set; }
    public String UserPassword { get; set; }

  }
}

namespace System.Net.Http
{
  public class HttpRestClient : HttpClient
  {
    public HttpRestClient(IOptions<HttpClientConfig> config) : base()
    {
      BaseAddress = new Uri(config.Value.BaseAddress);

      DefaultRequestHeaders.Accept.Clear();
      DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    }
  }
}

通过这种方法,控制器在构造函数中接收一个HttpRestClient实例作为客户端参数。

加载网格数据

Bootstrap-Table使用一堆参数调用Load函数。这些参数必须添加到客户端URL并传递给RESTful服务。服务结果必须转换为Bootstrap-Table可以读取的格式。

[HttpGet]
    public async Task<IActionResult> Load(String sort, String order, 
              Int32 offset, Int32 limit, String search, String searchFields)
    {
      //  setup url with query parameters
      var queryString = new Dictionary<String, String>();
      queryString["sortBy"] = sort ?? "";
      queryString["sortDirection"] = order ?? "";
      queryString["skip"] = offset.ToString();
      queryString["take"] = limit.ToString();
      queryString[nameof(search)] = search ?? "";
      queryString[nameof(searchFields)] = searchFields ?? "";

      // convert dictionary to query params
      var uriBuilder = new UriBuilder(apiClient.BaseAddress + apiUrl)
      {
        Query = QueryHelpers.AddQueryString("", queryString)
      };

      using (var response = await apiClient.GetAsync(uriBuilder.Uri))
      {
        var document = await response.Content.ReadAsStringAsync();

        var loadResult = JsonConvert.DeserializeObject<LoadResult<CountryResource>>(document);

        // Convert loadResult into Bootstrap-Table compatible format
        var result = new
        {
          total = loadResult.CountUnfiltered,
          rows = loadResult.Items
        };

        return Json(result);
      }
    }

插入或编辑对话框

InsertEdit对话框稍微复杂一些。它有两个阶段。在第一阶段,控制器根据Id获取资源,并将其映射到视图模型。视图模型在模态对话框中呈现。第一步发生在带有Get属性的Edit方法中。

[HttpGet]
    public async Task<IActionResult> Edit(Int32 id)
    {
      String url = apiUrl + ((id == 0) ? "create" : $"{id}");

      using (var response = await apiClient.GetAsync(url))
      {
        var document = await response.Content.ReadAsStringAsync();

        if (response.StatusCode == HttpStatusCode.OK)
        {
          var resource = JsonConvert.DeserializeObject<CountryResource>(document);

          var result = mapper.Map<CountryModel>(resource);

          return PartialView(nameof(Edit), result);
        }

        else
        {
          var result = new ResourceResult<CountryResource>();

          if (response.StatusCode == HttpStatusCode.NotFound)
            result.Errors.Add(new ValidationError($"Record with id {id} is not found"));

          return StatusCode(response.StatusCode.ToInt32(), result);
        }
      }
    }

Id为空时,由RESTful服务创建新资源,而不是GUI控制器。RESTful服务知道如何初始化新资源,这对于GUI控制器来说不是一个问题。网页中的jQuery代码处理编辑响应。

第一阶段:编辑(GET)

提交插入或编辑对话框

第二阶段将对话框提交给控制器。视图模型被映射到资源。控制器为新资源发出POST调用,或为现有资源发出PUT调用。控制器解析RESTful服务结果。成功后,对话框关闭,表格网格显示新资源数据。错误会显示在对话框中,因此对话框将保持打开状态。

    [HttpPost]
    public async Task<IActionResult> Edit([FromForm]CountryModel model)
    {
      if (!ModelState.IsValid)
        PartialView();

      // Map model to resource
      var resource = mapper.Map<CountryResource>(model);

      // save resource to Json
      var resourceDocument = JsonConvert.SerializeObject(resource);

      using (var content = new StringContent(resourceDocument, Encoding.UTF8, "application/json"))
      {
        // determine call update or insert
        Upsert upsert = apiClient.PutAsync;

        // no RowVersion indicates insert
        if (model.RowVersion.IsNullOrEmpty())
          upsert = apiClient.PostAsync;

        using (var response = await upsert(apiUrl, content))
        {
          // init result
          var result = new ResourceResult<CountryResource>(resource);

          // read result from RESTful service
          var responseDocument = await response.Content.ReadAsStringAsync();

          if (response.StatusCode == HttpStatusCode.OK ||
              response.StatusCode == HttpStatusCode.Created)
          {
            // Fetch created or updated resource from response
            result.Resource = JsonConvert.DeserializeObject<CountryResource>(responseDocument); ;
          }
          else
          {
            // fetch errors and or exceptions
            result = JsonConvert.DeserializeObject<ResourceResult<CountryResource>>(responseDocument);
          }

          // Set error message for concurrency error
          if (response.StatusCode == HttpStatusCode.Conflict)
          {
            result.Errors.Clear();
            result.Errors.Add(new ValidationError("This record is modified by another user"));
            result.Errors.Add(new ValidationError
                ("Your work is not saved and replaced with new content"));
            result.Errors.Add(new ValidationError
                ("Please review the new content and if required edit and save again"));
          }

          if (response.StatusCode.IsInSet(HttpStatusCode.OK, 
                    HttpStatusCode.Created, HttpStatusCode.Conflict))
            return StatusCode(response.StatusCode.ToInt32(), result);

          // copy errors so they will be rendered in edit form
          foreach (var error in result.Errors)
            ModelState.AddModelError(error.MemberName ?? "", error.Message);

          // Update model with Beautify effect(s) and make it visible in the partial view
          IEnumerable<PropertyInfo> properties = 
          model.GetType().GetTypeInfo().GetProperties(BindingFlags.Public | BindingFlags.Instance);

          foreach (var property in properties)
          {
            var rawValue = property.GetValue(model);
            var attemptedValue = rawValue == null ? "" : 
                 Convert.ToString(rawValue, CultureInfo.InvariantCulture);

            ModelState.SetModelValue(property.Name, rawValue, attemptedValue);
          }
          
          // No need to specify model here, it has no effect on the render process :-(
          return PartialView();
        }
      }
    }

编辑对话框工作

提交编辑对话框

成功保存后更新的网格

成功保存后更新的网格

删除资源

在删除资源之前,用户会收到一个确认对话框。这与编辑对话框相同,只是现在编辑控件处于只读模式,并且对话框标题和按钮已调整。

创建或编辑对话框

如果用户确认删除,GUI控制器会收到一个带有资源Id的调用。控制器使用Id调用RESTful服务并读取返回结果。对话框在确认后始终关闭。成功后,从表格网格中移除。

    [HttpPost]
    public async Task<IActionResult> Delete(Int32 id)
    {
      String url = apiUrl + $"{id}";

      using (var response = await apiClient.DeleteAsync(url))
      {
        var responseDocument = await response.Content.ReadAsStringAsync();

        // create only response if something off has happened
        if (response.StatusCode != HttpStatusCode.OK)
        {
          var result = 
          JsonConvert.DeserializeObject<ResourceResult<CountryResource>>(responseDocument);

          return StatusCode(response.StatusCode.ToInt32(), result);
        }

        return Content(null);
      }
    }

错误会显示在一个新对话框中。

删除错误对话框

获取源代码已开始

该解决方案支持MySQL或SqlServer。您可以在appsettings.json中配置您选择的数据库。

{
  "ConnectionStrings": {
    // MySql connection
    //DatabaseDriver: "MySql",
    //"DbConnection": "server=localhost;Database=DemoCountries;User Id=root;password=<yourkey>"

    // SqlServer connection
     "DbConnection": "server=localhost;Database=DemoCountries;Trusted_Connection=True"
  },

  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Warning"
    }
  }
}

确保数据库帐户具有足够的权限来创建数据库。ConfigureServices读取配置并添加DbContext。

 public void ConfigureServices(IServiceCollection services)
    {
      // Get Database connection config
      var connectionString = Configuration.GetConnectionString("DbConnection");

      // Connect by default SqlServer other wise to MySql 
      var databaseDriver = Configuration.GetConnectionString("DatabaseDriver");

      // Setup Database Service layer used in CountryResourceService
      if (databaseDriver.EqualsEx("MySQL"))
        services.AddDbContext<EntityContext>(options => options.UseMySql(connectionString));
      else
        services.AddDbContext<EntityContext>(options => options.UseSqlServer(connectionString));
...

初始化数据库内容

EntityContext构造函数检查数据库是否存在。如果创建了一个新数据库,则会从嵌入的countries.json文件中添加国家。

public partial class EntityContext : DbContext
  {
    protected readonly IIdentity Identity;

    public DbSet<Country> Countries { get; set; }

    public EntityContext(DbContextOptions<EntityContext> options, IIdentity identity) : base(options)
    {
      Identity = identity;

      ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

      // Check if database exists, if created add content
      if (Database.EnsureCreated())
        InitDatabaseContent();
    }

    public void InitDatabaseContent()
    {
      // Extract Countries Json file from embedded resource
      var assembly = GetType().GetTypeInfo().Assembly;

      var fileName = assembly.GetManifestResourceNames().FirstOrDefault();

      using (var resourceStream = assembly.GetManifestResourceStream(fileName))
      {
        using (var reader = new StreamReader(resourceStream, Encoding.UTF8))
        {
          var document = reader.ReadToEnd();

          var countries = JsonConvert.DeserializeObject<List<Country>>(document);

          foreach (var country in countries)
          {
            Add(country);
            Entry(country).State = EntityState.Added;

            var ra = SaveChanges();

            Entry(country).State = EntityState.Detached;
          }
        }
      }
    }
...

版本

1.0.0 2017年7月 初始版本
 1.0.1 2017年8月 已添加“获取源代码已开始”部分

结论

感谢您阅读到这里!在这篇博文中,我展示了Dot Net Core在创建RESTful服务方面的强大功能。Swagger,这个开源插件在RESTful服务开发过程中提供了极大的帮助。该服务可以由MVC应用程序或其他第三方应用程序消费。RESTful设计提供了多项优势,如性能、易于开发和集中式存储库。请下载代码并进行尝试。希望它对您有所帮助。

延伸阅读

.NET Core RESTful或WebAPI MVC Web Application - CodeProject - 代码之家
© . All rights reserved.