经过lms.samples熟悉lms微服务框架的使用

通过一段时间的开发与测试,终于发布了Lms框架的第一个正式版本(1.0.0版本),并给出了lms框架的样例项目lms.samples。本文经过对lms.samples的介绍,简述如何经过lms框架快速的构建一个微服务的业务框架,并进行应用开发。mysql

lms.samples项目基本介绍

lms.sample项目由三个独立的微服务应用模块组成:account、stock、order和一个网关项目gateway构成。git

业务应用模块

每一个独立的微服务应用采用模块化设计,主要由以下几部分组成:github

  1. 主机(Host): 主要用于托管微服务应用自己,主机经过引用应用服务项目(应用接口的实现),托管微服务应用,经过托管应用服务,在主机启动的过程当中,向服务注册中心注册服务路由。redis

  2. 应用接口层(Application.Contracts): 用于定义应用服务接口,经过应用接口,该微服务模块与其余微服务模块或是网关进行rpc通讯的能力。在该项目中,除了定义应用服务接口以前,通常还定义与该应用接口相关的DTO对象。应用接口除了被该微服务应用项目引用,并实现应用服务以前,还能够被网关或是其余微服务模块引用。网关或是其余微服务项目经过应用接口生成的代理与该微服务模块经过rpc进行通讯。sql

  3. 应用服务层(Application): 应用服务是该微服务定义的应用接口的实现。应用服务与DDD传统分层架构的应用层的概念一致。主要负责外部通讯与领域层之间的协调。通常地,应用服务进行业务流程控制,可是不包含业务逻辑的实现。docker

  4. 领域层(Domain): 负责表达业务概念,业务状态信息以及业务规则,是该微服务模块的业务核心。通常地,在该层能够定义聚合根、实体、领域服务等对象。数据库

  5. 领域共享层(Domain.Shared): 该层用于定义与领域对象相关的模型、实体等相关类型。不包含任何业务实现,能够被其余微服务引用。json

  6. 数据访问(DataAccess)层: 该层通常用于封装数据访问相关的对象。例如:仓库对象、 SqlHelper、或是ORM相关的类型等。在lms.samples中,经过efcore实现数据的读写操做。windows

(image)api

服务聚合与网关

lms框架不容许服务外部与微服务主机直接通讯,应用请求必须经过http请求到达网关,网关经过lms提供的中间件解析到服务条目,并经过rpc与集群内部的微服务进行通讯。因此,若是服务须要与集群外部进行通讯,那么,开发者定义的网关必需要引用各个微服务模块的应用接口层;以及必需要使用lms相关的中间件。

开发环境

  1. .net版本: 5.0.101

  2. lms版本: 1.0.0

  3. IDE: (1) visual studio 最新版 (2) Rider(推荐)

主机与应用托管

主机的建立步骤

经过lms框架建立一个业务模块很是方便,只须要经过以下4个步骤,就能够轻松的建立一个lms应用业务模块。

  1. 建立项目

建立控制台应用(Console Application)项目,而且引用Silky.Lms.NormHost包。

dotnet add package Silky.Lms.NormHost --version 1.0.0
  1. 应用程序入口与主机构建

main方法中,通用.net的主机Host构建并注册lms微服务。在注册lms微服务时,须要指定lms启动的依赖模块。

通常地,若是开发者不须要额外依赖其余模块,也无需在应用启动或中止时执行方法,那么您能够直接指定NormHostModule模块。

public class Program
    {
        public static async Task Main(string[] args)
        {
            await CreateHostBuilder(args).Build().RunAsync();
        }

        private static IHostBuilder CreateHostBuilder(string[] args)
        {
            return Host.CreateDefaultBuilder(args)
                    .RegisterLmsServices<NormHostModule>()
                ;
        }
    }
  1. 配置文件

lms框架支持yml或是json格式做为配置文件。经过appsettings.yml对lms框架进行统一配置,经过appsettings.${Environment}.yml对不一样环境变量下的配置项进行设置。

开发者若是直接经过项目的方式启动应用,那么能够经过Properties/launchSettings.jsonenvironmentVariables.DOTNET_ENVIRONMENT环境变量。若是经过docker-compose的方式启动应用,那么能够经过.env设置DOTNET_ENVIRONMENT环境变量。

为保证配置文件有效,开发者须要显式的将配置文件拷贝到项目生成目录下。

  1. 引用应用服务层和数据访问层

通常地,主机项目须要引用该微服务模块的应用服务层和数据访问层。只有主机引用应用服务层,主机在启动时,才会生成服务条目的路由,而且将服务路由注册到服务注册中心。

一个典型的主机项目文件以下所示:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net5.0</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
      <PackageReference Include="Silky.Lms.NormHost" Version="$(LmsVersion)" />
    </ItemGroup>

    <ItemGroup>
      <None Update="appsettings.yml">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </None>
      <None Update="appsettings.Production.yml">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </None>
      <None Update="appsettings.Development.yml">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </None>
    </ItemGroup>

    <ItemGroup>
      <ProjectReference Include="..\Lms.Account.Application\Lms.Account.Application.csproj" />
      <ProjectReference Include="..\Lms.Account.EntityFrameworkCore\Lms.Account.EntityFrameworkCore.csproj" />
    </ItemGroup>
</Project>

配置

通常地,一个微服务模块的主机必需要配置:服务注册中心、分布式锁连接、分布式缓存地址、集群rpc通讯token、数据库连接地址等。

若是使用docker-compose来启动和调试应用的话,那么,rpc配置节点下的的host和port能够缺省,由于生成的每一个容器的都有本身的地址和端口号。

若是直接经过项目的方式启动和调试应用的话,那么,必需要配置rpc节点下的port,每一个微服务模块的主机应用有本身的端口号。

lms框架的必要配置以下所示:

rpc:
  host: 0.0.0.0
  rpcPort: 2201
  token: ypjdYOzNd4FwENJiEARMLWwK0v7QUHPW
registrycenter:
  connectionStrings: 127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183;127.0.0.1:2184,127.0.0.1:2185,127.0.0.1:2186 # 使用分号;来区分不一样的服务注册中心
  registryCenterType: Zookeeper
distributedCache:
  redis:
    isEnabled: true 
    configuration: 127.0.0.1:6379,defaultDatabase=0
lock:
  lockRedisConnection: 127.0.0.1:6379,defaultDatabase=1
connectionStrings:
    default: server=127.0.0.1;port=3306;database=account;uid=root;pwd=qwe!P4ss;

应用接口

应用接口定义

通常地,在应用接口层开发者须要安装Silky.Lms.Rpc包。若是该微服务模块还涉及到分布式事务,那么还须要安装Silky.Lms.Transaction.Tcc,固然,您也能够选择在应用接口层安装Silky.Lms.Transaction包,在应用服务层安装Silky.Lms.Transaction.Tcc包。

  1. 开发者只须要在应用接口经过ServiceRouteAttribute特性对应用接口进行直接便可。

  2. Lms约定应用接口应当以IXxxAppService命名,这样,服务条目生成的路由则会以api/xxx形式生成。固然这并非强制的。

  3. 每一个应用接口的方法都对应着一个服务条目,服务条目的Id为: 方法的彻底限定名 + 参数名

  4. 您能够在应用接口层对方法的缓存、路由、服务治理、分布式事务进行相关配置。该部份内容请参考官方文档

  5. 网关或是其余模块的微服务项目须要引用服务应用接口项目或是经过nuget的方式安装服务应用接口生成的包。

  6. [Governance(ProhibitExtranet = true)]能够标识一个方法禁止与集群外部进行通讯,经过网关也不会生成swagger文档。

  7. 应用接口方法生成的WebApi支持restful API风格。Lms支持经过方法的约定命名生成对应http方法请求的WebApi。您固然开发者也能够经过HttpMethodAttribute特性对某个方法进行注解。

一个典型的应用接口的定义

/// <summary>
    /// 帐号服务
    /// </summary>
    [ServiceRoute]
    public interface IAccountAppService
    {
        /// <summary>
        /// 新增帐号
        /// </summary>
        /// <param name="input">帐号信息</param>
        /// <returns></returns>
        Task<GetAccountOutput> Create(CreateAccountInput input);

        /// <summary>
        /// 经过帐号名称获取帐号
        /// </summary>
        /// <param name="name">帐号名称</param>
        /// <returns></returns>
        [GetCachingIntercept("Account:Name:{0}")]
        [HttpGet("{name:string}")]
        Task<GetAccountOutput> GetAccountByName([CacheKey(0)] string name);

        /// <summary>
        /// 经过Id获取帐号信息
        /// </summary>
        /// <param name="id">帐号Id</param>
        /// <returns></returns>
        [GetCachingIntercept("Account:Id:{0}")]
        [HttpGet("{id:long}")]
        Task<GetAccountOutput> GetAccountById([CacheKey(0)] long id);

        /// <summary>
        /// 更新帐号信息
        /// </summary>
        /// <param name="input"></param>
        /// <returns></returns>
        [UpdateCachingIntercept( "Account:Id:{0}")]
        Task<GetAccountOutput> Update(UpdateAccountInput input);

        /// <summary>
        /// 删除帐号信息
        /// </summary>
        /// <param name="id">帐号Id</param>
        /// <returns></returns>
        [RemoveCachingIntercept("GetAccountOutput","Account:Id:{0}")]
        [HttpDelete("{id:long}")]
        Task Delete([CacheKey(0)]long id);

        /// <summary>
        /// 订单扣款
        /// </summary>
        /// <param name="input"></param>
        /// <returns></returns>
        [Governance(ProhibitExtranet = true)]
        [RemoveCachingIntercept("GetAccountOutput","Account:Id:{0}")]
        [Transaction]
        Task<long?> DeductBalance(DeductBalanceInput input);
    }

应用服务--应用接口的实现

  1. 应用服务层只须要引用应用服务接口层以及领域服务层,并实现应用接口相关的方法。

  2. 确保该微服务模块的主机引用了该模块的应用服务层,这样主机才可以托管该应用自己。

  3. 应用服务层能够经过引用其余微服务模块的应用接口层项目(或是安装nuget包,取决于开发团队的项目管理方法),与其余微服务模块进行rpc通讯。

  4. 应用服务层须要依赖领域服务,经过调用领域服务的相关接口,实现该模块的核心业务逻辑。

  5. DTO到实体对象或是实体对DTO对象的映射关系能够在该层指定映射关系。

一个典型的应用服务的实现以下所示:

public class AccountAppService : IAccountAppService
    {
        private readonly IAccountDomainService _accountDomainService;

        public AccountAppService(IAccountDomainService accountDomainService)
        {
            _accountDomainService = accountDomainService;
        }

        public async Task<GetAccountOutput> Create(CreateAccountInput input)
        {
            var account = input.MapTo<Domain.Accounts.Account>();
            account = await _accountDomainService.Create(account);
            return account.MapTo<GetAccountOutput>();
        }

        public async Task<GetAccountOutput> GetAccountByName(string name)
        {
            var account = await _accountDomainService.GetAccountByName(name);
            return account.MapTo<GetAccountOutput>();
        }

        public async Task<GetAccountOutput> GetAccountById(long id)
        {
            var account = await _accountDomainService.GetAccountById(id);
            return account.MapTo<GetAccountOutput>();
        }

        public async Task<GetAccountOutput> Update(UpdateAccountInput input)
        {
            var account = await _accountDomainService.Update(input);
            return account.MapTo<GetAccountOutput>();
        }

        public Task Delete(long id)
        {
            return _accountDomainService.Delete(id);
        }

        [TccTransaction(ConfirmMethod = "DeductBalanceConfirm", CancelMethod = "DeductBalanceCancel")]
        public async Task<long?> DeductBalance(DeductBalanceInput input)
        {
            var account = await _accountDomainService.GetAccountById(input.AccountId);
            if (input.OrderBalance > account.Balance)
            {
                throw new BusinessException("帐号余额不足");
            }
            return await _accountDomainService.DeductBalance(input, TccMethodType.Try);
        }

        public Task DeductBalanceConfirm(DeductBalanceInput input)
        {
            return _accountDomainService.DeductBalance(input, TccMethodType.Confirm);
        }

        public Task DeductBalanceCancel(DeductBalanceInput input)
        {
            return _accountDomainService.DeductBalance(input, TccMethodType.Cancel);
        }
    }

领域层--微服务的核心业务实现

  1. 领域层是该微服务模块核心业务处理的模块,通常用于定于聚合根、实体、领域服务、仓储等业务对象。

  2. 领域层引用该微服务模块的应用接口层,方便使用dto对象。

  3. 领域层能够经过引用其余微服务模块的应用接口层项目(或是安装nuget包,取决于开发团队的项目管理方法),与其余微服务模块进行rpc通讯。

  4. 领域服务必需要直接或间接继承ITransientDependency接口,这样,该领域服务才会被注入到ioc容器。

  5. lms.samples 项目使用TanvirArjel.EFCore.GenericRepository包实现数据的读写操做。

一个典型的领域服务的实现以下所示:

public class AccountDomainService : IAccountDomainService
    {
        private readonly IRepository _repository;
        private readonly IDistributedCache<GetAccountOutput, string> _accountCache;

        public AccountDomainService(IRepository repository,
            IDistributedCache<GetAccountOutput, string> accountCache)
        {
            _repository = repository;
            _accountCache = accountCache;
        }

        public async Task<Account> Create(Account account)
        {
            var exsitAccountCount = await _repository.GetCountAsync<Account>(p => p.Name == account.Name);
            if (exsitAccountCount > 0)
            {
                throw new BusinessException($"已经存在{account.Name}名称的帐号");
            }

            exsitAccountCount = await _repository.GetCountAsync<Account>(p => p.Email == account.Email);
            if (exsitAccountCount > 0)
            {
                throw new BusinessException($"已经存在{account.Email}Email的帐号");
            }

            await _repository.InsertAsync<Account>(account);
            return account;
        }

        public async Task<Account> GetAccountByName(string name)
        {
            var accountEntry = _repository.GetQueryable<Account>().FirstOrDefault(p => p.Name == name);
            if (accountEntry == null)
            {
                throw new BusinessException($"不存在名称为{name}的帐号");
            }

            return accountEntry;
        }

        public async Task<Account> GetAccountById(long id)
        {
            var accountEntry = _repository.GetQueryable<Account>().FirstOrDefault(p => p.Id == id);
            if (accountEntry == null)
            {
                throw new BusinessException($"不存在Id为{id}的帐号");
            }

            return accountEntry;
        }

        public async Task<Account> Update(UpdateAccountInput input)
        {
            var account = await GetAccountById(input.Id);
            if (!account.Email.Equals(input.Email))
            {
                var exsitAccountCount = await _repository.GetCountAsync<Account>(p => p.Email == input.Email);
                if (exsitAccountCount > 0)
                {
                    throw new BusinessException($"系统中已经存在Email为{input.Email}的帐号");
                }
            }

            if (!account.Name.Equals(input.Name))
            {
                var exsitAccountCount = await _repository.GetCountAsync<Account>(p => p.Name == input.Name);
                if (exsitAccountCount > 0)
                {
                    throw new BusinessException($"系统中已经存在Name为{input.Name}的帐号");
                }
            }

            await _accountCache.RemoveAsync($"Account:Name:{account.Name}");
            account = input.MapTo(account);
            await _repository.UpdateAsync(account);
            return account;
        }

        public async Task Delete(long id)
        {
            var account = await GetAccountById(id);
            await _accountCache.RemoveAsync($"Account:Name:{account.Name}");
            await _repository.DeleteAsync(account);
        }

        public async Task<long?> DeductBalance(DeductBalanceInput input, TccMethodType tccMethodType)
        {
            var account = await GetAccountById(input.AccountId);
            var trans = await _repository.BeginTransactionAsync();
            BalanceRecord balanceRecord = null;
            switch (tccMethodType)
            {
                case TccMethodType.Try:
                    account.Balance -= input.OrderBalance;
                    account.LockBalance += input.OrderBalance;
                    balanceRecord = new BalanceRecord()
                    {
                        OrderBalance = input.OrderBalance,
                        OrderId = input.OrderId,
                        PayStatus = PayStatus.NoPay
                    };
                    await _repository.InsertAsync(balanceRecord);
                    RpcContext.GetContext().SetAttachment("balanceRecordId",balanceRecord.Id);
                    break;
                case TccMethodType.Confirm:
                    account.LockBalance -= input.OrderBalance;
                    var balanceRecordId1 = RpcContext.GetContext().GetAttachment("orderBalanceId")?.To<long>();
                    if (balanceRecordId1.HasValue)
                    {
                        balanceRecord = await _repository.GetByIdAsync<BalanceRecord>(balanceRecordId1.Value);
                        balanceRecord.PayStatus = PayStatus.Payed;
                        await _repository.UpdateAsync(balanceRecord);
                    }
                    break;
                case TccMethodType.Cancel:
                    account.Balance += input.OrderBalance;
                    account.LockBalance -= input.OrderBalance;
                    var balanceRecordId2 = RpcContext.GetContext().GetAttachment("orderBalanceId")?.To<long>();
                    if (balanceRecordId2.HasValue)
                    {
                        balanceRecord = await _repository.GetByIdAsync<BalanceRecord>(balanceRecordId2.Value);
                        balanceRecord.PayStatus = PayStatus.Cancel;
                        await _repository.UpdateAsync(balanceRecord);
                    }
                    break;
            }

           
            await _repository.UpdateAsync(account);
            await trans.CommitAsync();
            await _accountCache.RemoveAsync($"Account:Name:{account.Name}");
            return balanceRecord?.Id;
        }
    }

数据访问(EntityFrameworkCore)--经过efcore实现数据读写

  1. lms.samples项目使用orm框架efcore进行数据读写。

  2. lms提供了IConfigureService,经过继承该接口便可使用IServiceCollection的实例指定数据上下文对象和注册仓库服务。

public class EfCoreConfigureService : IConfigureService
    {
        public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
        {
            services.AddDbContext<OrderDbContext>(opt =>
                    opt.UseMySql(configuration.GetConnectionString("Default"),
                        ServerVersion.AutoDetect(configuration.GetConnectionString("Default"))))
                .AddGenericRepository<OrderDbContext>(ServiceLifetime.Transient)
                ;
        }

        public int Order { get; } = 1;
    }
  1. 主机项目须要显式的引用该项目,只有这样,该项目的ConfigureServices才会被调用。

  2. 数据迁移,请参考

应用启动与调试

获取源码

  1. 使用git 克隆lms项目源代码,lms.samples存放在samples目录下
# github
git clone https://github.com/liuhll/lms.git

# gitee
git clone https://gitee.com/liuhll2/lms.git

必要的前提

  1. 服务注册中心zookeeper

  2. 缓存服务redis

  3. mysql数据库

若是您电脑已经安装了docker以及docker-compose命令,那么您只须要进入samples\docker-compose\infrastr目录下,打开命令行工做,执行以下命令就能够自动安装zookeeperredismysql等服务:

docker-compose -f .\docker-compose.mysql.yml -f .\docker-compose.redis.yml -f .\docker-compose.zookeeper.yml up -d

数据库迁移

须要分别进入到各个微服务模块下的EntityFrameworkCore项目(例如:),执行以下命令:

dotnet ef database update

例如: 须要迁移account模块的数据库以下所示:

image

order模块和stock模块与account模块一致,在服务运行前都须要经过数据库迁移命令生成相关数据库。

  1. 数据库迁移指定数据库链接地址默认指定的是appsettings.Development.yml中配置的,您能够经过修改该配置文件中的connectionStrings.default配置项来指定本身的数据库服务地址。

  2. 若是没有dotnet ef命令,则须要经过dotnet tool install --global dotnet-ef安装ef工具,请[参考] (https://docs.microsoft.com/zh-cn/ef/core/get-started/overview/install)

以项目的方式启动和调试

使用visual studio做为开发工具

进入到samples目录下,使用visual studio打开lms.samples.sln解决方案,将项目设置为多启动项目,并将网关和各个模块的微服务主机设置为启动项目,以下图:

(image)

设置完成后直接启动便可。

使用rider做为开发工具

  1. 进入到samples目录下,使用rider打开lms.samples.sln解决方案,打开各个微服务模块下的Properties/launchSettings.json,点击图中绿色的箭头便可启动项目。

(image)

  1. 启动网关项目后,能够看到应用接口的服务条目生成的swagger api文档 http://localhost:5000/swagger

(image)

  1. 默认的环境变量为: Development,若是须要修改环境变量的话,能够经过Properties/launchSettings.json下的environmentVariables节点修改相关环境变量,请参考在 ASP.NET Core 中使用多个环境

  2. 数据库链接、服务注册中心地址、以及redis缓存地址和分布式锁链接等配置项能够经过修改appsettings.Development.yml配置项自定义指定。

以docker-compose的方式启动和调试

  1. 进入到samples目录下,使用visual studio打开lms.samples.dockercompose.sln解决方案,将docker-compose设置为启动项目,便可启动和调式。

  2. 应用启动成功后,打开: http://127.0.0.1/swagger,便可看到swagger api文档

(image)

  1. 以docker-compose的方式启动和调试,则指定的环境变量为:ContainerDev

  2. 数据库链接、服务注册中心地址、以及redis缓存地址和分布式锁链接等配置项能够经过修改appsettings.ContainerDev.yml配置项自定义指定,配置的服务链接地址不容许为: 127.0.0.1或是localhost

测试和调式

服务启动成功后,您能够经过写入/api/account-post接口和/api/product-post新增帐号和产品,而后经过/api/order-post接口进行测试和调式。

开源地址

github: https://github.com/liuhll/lms

gitee: https://gitee.com/liuhll2/lms

相关文章
相关标签/搜索