.NET 微服务实践(2)-搭建平台服务

213 阅读5分钟

本文系油管上一个系列教程的学习记录第二章,原链接是.NET Microservices – Full Course。本文分成本文分成五部分:项目创建和包安装、数据层(模型定义、读写Dto创建以及映射关系确定)、仓储层定义和实现、准备内存数据、控制器(用于外部调用服务)。目的是搭建平台服务,测试通过,为之后整体的微服务方案做铺垫。

创建项目

.NET Core Web API + .NET Core 5.0

添加安装包

  • AutoMapper.Extensions.Microsoft.DependencyInject
  • Microsoft.EntityFrameworkCore.5.0.8
  • Microsoft.EntityFrameworkCore.Design.5.0.8
  • Microsoft.EntityFrameworkCore.InMemory.5.0.8

数据层模型

创建Platform模型

using System.ComponentModel.DataAnnotations;

namespace PlatformService.PlatformDomain
{
    public class Platform
    {
        [Key]
        [Required]
        public int PlatformId { get; set; }

        [Required]
        public string Name { get; set; }

        [Required]
        public string Publisher { get; set; }

        [Required]
        public string Cost { get; set; }
    }
}

除了定义模型属性外,这里还做了两件事情:

  1. 定义了主键
  2. 要求属性必填

另外,和原视频不同的是,这里采用的是领域驱动设计的思路,具体实施的时候命名空间和视频里面不一样。在PlatformDomain里,除了模型外,后续还有模型仓储接口以及Dto。

创建DTO

DTO全称Data Transfer Object。它的目的在于隐藏数据结构——通过从Model与DTO的转化,可以控制属性是否应该对调用者可见。保证了数据的隐私性。 相比于ViewModel,我的感觉是它的定位更加灵活,DTO可以分成读和写、ViewModel一般我会将其当做ReadDTO。 这里就创建读和写的DTO

Read DTO

namespace PlatformService.PlatformDomain
{
    public class PlatformReadDto
    {
        public int PlatformId { get; set; }

        public string Name { get; set; }

        public string Publisher { get; set; }

        public string Cost { get; set; }
    }
}

Write DTO

using System.ComponentModel.DataAnnotations;

namespace PlatformService.PlatformDomain
{
    public class PlatformWriteDto
    {
        [Key]
        [Required]
        public int PlatformId { get; set; }

        [Required]
        public string Name { get; set; }

        [Required]
        public string Publisher { get; set; }

        [Required]
        public string Cost { get; set; }
    }
}

这里将DTO和Model放在一起。 对于读操作来说,不需要加注解来做模型的验证;对于写操作而言,主键由于是int类型可以在Mapping之后自动生成,因此也不需要Id。但是由于我还考虑了更新操作(也是写操作),所以需要主键来定位。

实现Model和DTO的双向Mapping

注册AutoMapper

在Startup.cs的ConfigureService中注册AutoMapper

public void ConfigureServices(IServiceCollection services)
{
    // Register AutoMapper
    var domainAssemblies = AppDomain.CurrentDomain.GetAssemblies();
    services.AddAutoMapper(domainAssemblies);
}

创建Mapping Profile

在Profile配置Model和DTO的Mapping

using AutoMapper;
using PlatformService.PlatformDomain;

namespace PlatformService.Utils
{
    public class PlatformMappingProfile: Profile
    {
        public PlatformMappingProfile()
        {
            //Source -> Target
            CreateMap<Platform, PlatformReadDto>();
            CreateMap<PlatformWriteDto, Platform>();
        }
    }
}

仓储层定义与实现

配置DbContext

实现EntityFramework的DbContext

using Microsoft.EntityFrameworkCore;
using PlatformService.PlatformDomain;

namespace PlatformService.Data
{
    public class ApplicationDbContext: DbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> opt)
            :base(opt)
        {

        }

        public DbSet<Platform> Platforms { get; set; }
    }
}

ApplicationDbContext继承了DbContext,并且定义了DbSet。这一步的目的,是提供了一个上下文,用于程序和数据库的交互通信。

注册DbContext服务

在Startup.cs文件中的ConfigureService方法里添加服务

using PlatformService.Data;
using Microsoft.EntityFrameworkCore;
......

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext>(opt =>
        opt.UseInMemoryDatabase("InMemory"));
    services.AddControllers();
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "PlatformService", Version = "v1" });
    });
}

由于.NET Core自带依赖注入容器,这里就是先往容器中添加DbContext服务。注意,尽管最终平台服务的持久化是交给SQL Server来做,但是这里还是先用内存数据库来实现。

仓储层实现

定义仓储层接口

using System.Collections.Generic;
using System.Threading.Tasks;

namespace PlatformService.PlatformDomain
{
    public interface IPlatformRepository
    {
        public Task<int> CreatePlatform(Platform createdPlatform);
        public Task<IEnumerable<Platform>> GetAllPlatformsAsync();
        public Task<Platform> GetPlatformById(int platformId);
        public Task<Platform> UpdatePlatform(Platform updatedPlatform);
    }
}

这里和视频教程不一样:

  • 把接口和模型放在了一起(作为Domain的组成部分)、而不是把接口及其实现放在一起。这样的好处在于,接口和实现可以分离,若是在实际项目中,接口可以根据环境已经需要调整不同的实现;同时(仓储层)实现应该和数据持久层保持紧密关联。
  • 按照自己的习惯定义了创建、读取和更新的接口,同时考虑并发。

实现仓储接口

using PlatformService.PlatformDomain;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace PlatformService.Data
{
    public class PlatformRepository : IPlatformRepository
    {
        private readonly ApplicationDbContext _context;
        public PlatformRepository(ApplicationDbContext context)
        {
            _context = context; 
        }

        public Task<int> CreatePlatformAsync(Platform createdPlatform)
        {
            throw new NotImplementedException();
        }

        public Task<IEnumerable<Platform>> GetAllPlatformsAsync()
        {
            throw new NotImplementedException();
        }

        public Task<Platform> GetPlatformById(int platformId)
        {
            throw new NotImplementedException();
        }

        public Task<Platform> UpdatePlatformAsync(Platform updatedPlatform)
        {
            throw new NotImplementedException();
        }

        private async Task<bool> SaveChnagesAsync()
        {
            return await _context.SaveChangesAsync() >= 0;
        }
    }
}

一般会在Data文件夹下再加一个Repository的folder用于专门放仓储层的实现,考虑到只有一个Repo,所以没有这么操作。 这里有两点说明:

  • PlatformRepository的构造中利用了依赖注入的方式调用了之前注册的ApplicationContext服务。
  • 实现了一个私有方法用于EFCore整个事务的保存刷新。该方法后续可以用于Create以及Update。 具体实现对Platform对象的读写
public async Task<int> CreatePlatformAsync(Platform createdPlatform)
{
    _context.Platforms.Add(createdPlatform);
    _ = await this.SaveChnagesAsync();
    return createdPlatform.PlatformId;
}

public async Task<IEnumerable<Platform>> GetAllPlatformsAsync()
{
    return await _context.Platforms.AsNoTracking().ToListAsync();
}

public Task<Platform> GetPlatformById(int platformId)
{
    return _context.Platforms
        .Where(platform => platform.PlatformId == platformId)
        .AsNoTracking().FirstOrDefaultAsync();
}

public async Task<Platform> UpdatePlatformAsync(Platform updatedPlatform)
{
    var currentPlatform = _context.Platforms
        .First(platform => platform.PlatformId == platform.PlatformId);
    if(currentPlatform is null)
    {
        throw new Exception($"Platform with Id {updatedPlatform.PlatformId} does not exist");
    }
    _context.Platforms.Update(updatedPlatform);
    _ = await this.SaveChnagesAsync();
    return updatedPlatform;
}

这里和视频不一样的地方是:

  • 对于数据的验证会放在Controller层完成
  • 读操作加了NoTracking,提高读取的效率

注册仓储层服务

类似之前ApplicationDBContext,对于仓储层的接口及其实现,也可以通过注册在依赖注入容器的方式进行调用。只需要在Startup中的ConfigureService中加入如下代码即可:

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<IPlatformRepository, PlatformRepository>();
}

在可以获取仓储层服务的基础上,可以尝试基于测试数据进行在内存数据库中的读写测试。

准备内存数据库以及测试数据

准备内存数据库

目前不使用SQL Server,所以要初始化一个内存数据库。

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace PlatformService.Data
{
    public class MockInMemoryDatabase
    {
        public static void MockPopulation(IApplicationBuilder app)
        {
            using (var serviceScope = app.ApplicationServices.CreateScope())
            {
                SeedData(serviceScope.ServiceProvider.GetService<ApplicationDbContext>());
            }
        }

        private static void SeedData(ApplicationDbContext context)
        {

        }
    }
}

首先构造一个静态方法,能够调用ApplicationBuilder获取依赖注入容器,然后将容器中的ApplicationDbContext取出——在应用实例化时就有对应的DbContext,生命周期类型为Scope。后期就可以用这个DbContext实现Mock数据的读写了。 然后要在Startup.cs的Configure中调用该静态方法。

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    MockInMemoryDatabase.MockPopulation(app);
}

Configure方法中各个同步操作,可以理解为HTTP请求的pipeline。相当于在最后一步中调用Mock数据进行操作测试。

准备测试数据

private static void SeedData(ApplicationDbContext context)
{
    if (!context.Platforms.Any())
    {
        Console.WriteLine(">>>Seeding data...");

        context.Platforms.AddRange(
            new Platform() { Name = "Dot Net", Publisher = "Microsoft", Cost="Free"},
            new Platform() { Name = "SQL Server Express", Publisher = "Microsoft", Cost = "Free" },
            new Platform() { Name = "Kubernetes", Publisher = "Cloud Native Computing Foundation", Cost = "Free" }
        );

        context.SaveChanges();
    }
    else {
        Console.WriteLine(">>>Seed data exist...");
    }
}

编译后运行,可以在输出中看到结果

2-1 Seeding data.png

控制器

创建Controller

using AutoMapper;
using Microsoft.AspNetCore.Mvc;
using PlatformService.PlatformDomain;

namespace PlatformService.Controllers
{
    [Route("api/v1/[controller]s")]
    [ApiController]
    public class PlatformController: ControllerBase
    {
        private readonly IMapper _mapper;
        private readonly IPlatformRepository _platformRepository;

        public PlatformController(
            IPlatformRepository platformRepository,
            IMapper mapper)
        {
            _mapper = mapper;
            _platformRepository = platformRepository;
        }
    }
}

类似IPlatformRepository的构造注入时从依赖注入容器中引入DbContext实例,PlatformController实例化也通过构造注入的方式调用IPlatformRepository和AutoMapper的实例。

路由的注解中,“[controller]”是默认读取类名中Controller之前的部分、也就是“Platform”。

构造Action

[HttpGet]
public async Task<ActionResult<IEnumerable<PlatformReadDto>>> GetAllPlatformsAsync()
{
    Console.WriteLine(">>>Getting Platforms...");
    var platforms = await _platformRepository.GetAllPlatformsAsync();
    return Ok(_mapper.Map<IEnumerable<PlatformReadDto>>(platforms));
}

这里以获取全部Platform,再通过AutoMapper转化Platform为PlatformReadDto。

测试Action

使用Postman进行调用验证 2-4 Postman Call.png 其中调用的API端口号是在平台服务的launchSettings中定义的:

{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:58885",
      "sslPort": 44388
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "launchUrl": "swagger",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "PlatformService": {
      "commandName": "Project",
      "dotnetRunMessages": "true",
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "https://localhost:5001;http://localhost:5000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

返回的platformId是往内存数据库中添加mock数据时自动添加的,返回值就是在内存数据库中的Mock数据。 调用接口之前,实际上App是在依赖注入容器中添加了所有的Controller、并在Configure中实例化所有的Controller;这样外部应用才可以通过路由进行访问。 2-2 Controller Configure & Call.png

完成全部Actions

[HttpGet]
[Route("all")]
public async Task<ActionResult<IEnumerable<PlatformReadDto>>> GetAllPlatformsAsync()
{
    Console.WriteLine(">>>Getting Platforms...");
    var platforms = await _platformRepository.GetAllPlatformsAsync();
    return** Ok(_mapper.Map<IEnumerable<PlatformReadDto>>(platforms));
}

[HttpGet("{platformId}", Name = "GetPlatformByIdAsync")]
public async Task<ActionResult<PlatformReadDto>> GetPlatformByIdAsync([FromRoute] **int** platformId)
{
    if (platformId <= 0)
    {
        return this.BadRequest(new{
                Message = "Platform Id should be greater than 0."
            });
    }

    Console.WriteLine(">>>Getting target Platform...");
    var platform = await _platformRepository.GetPlatformById(platformId);
    if (!(platform is null))
    {
        return Ok(_mapper.Map<PlatformReadDto>(platform));
    }
    return NotFound();
}

[HttpPost]
public async Task<ActionResult<PlatformReadDto>> CreatePlatformAsync(
    [FromBody] PlatformWriteDto platformWriteDto)
{
    if (platformWriteDto is null)
    {
        return this.BadRequest(new{
            Message = "Platform data should not be bull."
        });
    }

    Console.WriteLine(">>>Creating target Platform...");
    var platform = _mapper.Map<Platform>(platformWriteDto);
    _ = await _platformRepository.CreatePlatformAsync(platform);

    var platformReadDto = _mapper.Map<PlatformReadDto>(platform);
    return CreatedAtRoute(
        nameof(GetPlatformByIdAsync),
        new { PlatformId = platform.PlatformId },
        platformReadDto);
}

[HttpPut]
public async Task<ActionResult<PlatformReadDto>> UpdatePlatformAsync(
    [FromBody] PlatformWriteDto platformWriteDto)
{
    if (platformWriteDto is null)
    {
        return this.BadRequest(new{
            Message = "Platform data should not be bull."
        });
    }

    if (platformWriteDto.PlatformId <= 0)
    {
        return this.BadRequest(new{
            Message = "Platform id should greater than 0."
        });
    }

    Console.WriteLine(">>>Update target Platform...");
    var platform = _mapper.Map<Platform>(platformWriteDto);
    _ = await _platformRepository.UpdatePlatformAsync(platform);
    return Ok(_mapper.Map<PlatformReadDto>(platform));
}

几点想法:

  • 由于有多个HttpGet请求,所以需要通过路由对它们进行区分
  • 应该对可预见的错误进行规避,即调用IPlatformRepository前进行类型检查、Mapping前进行检查等
  • 实际上,还应该考虑错误处理、也可以考虑做一个全局错误处理
  • 和视频里面的做法不同,这里还是倾向于在一个Create或者Update中完成SaveAsync,这让我感觉整个方法比较完整。

结果检查

Get Platform By Id

2-5 GetById.png

Create Platform

2-6 CreatePlatform.png 注意Response中有创建的Platform的GetById的路由地址,是由创建Platform后返回的路径。

Update Platform

2-7 UpdatePlatform.png

Get All Platforms

2-8 Check.png