前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用golang开发电商类后台业务

用golang开发电商类后台业务

原创
作者头像
用户8717915
修改2021-07-01 18:12:00
1.7K0
修改2021-07-01 18:12:00
举报
文章被收录于专栏:golang落地dddgolang落地ddd

顺应公司的开发趋势,我们团队选择了golang作为后台开发的主语言,但golang所推崇的简洁,原生,轻量,对于我这种java出身的同学来说,太过原生也就意味着配套的工具链和开发框架的缺失。而恰巧,我们又是一个电商类后台团队,电商后台业务如果用一个词来概括的话,我想应该是复杂。那么如何用简洁的开发模式来应对复杂的业务系统,是我们所面临的一个很大的挑战。

应对复杂系统的解决之道总的来说就是两个:分而治之,抽象模型。

是的,无论多么复杂的事情,这两个就是我们拆解和应对的最大武器,将复杂的系统拆解,并抽象出具体的业务模型,借助这两者,我们才能够很好的抵抗外部的复杂性,并聚焦在最核心的事情上。而这也是为何我们需要ddd,需要设计模式,需要领域模型的原因。

因此,我们团队是在近一年前很多工具缺失的情况下,启动了电商后台的开发。这里分享一下我们是如何应对和解决的

1.分而治之:

无论是系统的微服务划分,还是细粒度到每个函数的定义,分而治之的思想都横贯在开发的始终,具体到工程代码的维度上,我们的第一个方式是分包。

对于一个典型的后台服务来说,它的分包模式我们设计如下图所示:

我们首先明确的是,各个不同子包负责不同的功能,保证了职责的独立和分离;

service:

服务层,作用是对外提供的服务,是数据的入口,包括trpc的接口以及各类消息的消费者等等;

ao:

防腐层,作用是参数转换,逻辑编排,以及部分errocode的封装;

do:

业务领域层,作用是业务的核心领域,负责最核心的业务逻辑实现;

repo:

数据仓库层,负责数据存储查询等功能,包含mysql,redis等各类实现,这里均以接口形式对外提供服务;

integration:

第三方服务层,负责收拢所有的外部接口依赖,包括trpc接口和cmq的生产者等等;

config:

配置层,包括tconf配置,常量,错误码等等

2.抽象模型:

我们将复杂的产品需求抽象成聚焦的业务模型,并把对业务模型的抽象和定义放在do层,用来表示最聚焦的业务模型。

这里我们提供一个提现的case,来说明下各个子包的功能以及具体的实践。

我们业务上会有用户个人钱包的概念,这个钱包要支持收钱、转账、提现等的能力,那我们以提现为例,看下这个代码的具体实现:

面向领域模型开发的一个最重要的地方在于,最早设计开发的不是数据库,而是do层的领域模型;

这里我们设计了三个核心的领域实体:

1.订单;

2.用户账户余额;

3.流水记录;

试想下,无论是怎样的业务场景(收,转账,提现等)都是由这三个“原子”型的模型所组合和定义的。

因此,我们首先在do层设计了这三个实体:

账户:

代码语言:javascript
复制
type VirtualAssetBalanceDo interface {
/**
    * @Description    资产状态有效
    * @Author         yang
    * @Date           2020/12/1
    */
   IsValid(ctx context.Context) bool
   /**
    * @Description    获取
    * @Author         yang
    * @Date           2020/6/30
    */
   Get(ctx context.Context) VirtualAssetBalance
   /**
    * @Description    扣除
    * @Author         yang
    * @Date           2020/5/29
    */
   Del(ctx context.Context, Amount uint64) error

   /**
    * @Description    增加
    * @Author         yang
    * @Date           2020/5/29
    */
   Add(ctx context.Context, Amount uint64) error
....
}

订单:

代码语言:javascript
复制
type WithDrawOrderCertificateDO interface {
Get(ctx context.Context) WithDrawOrderCertificate
   ChangeCurStatus(ctx context.Context, status string) error
   Create(ctx context.Context) error
   AddVersion(ctx context.Context) error
   FillWXPayInfo(ctx context.Context, item *comm.DetailOrderItem)
}

流水:

代码语言:javascript
复制
type RecordInfo struct {
   Id                         uint64
   VirtualAssetID             uint64
   VirtualAccountBusinessType uint64         //账户业务类型 1:小鹅农场金币  10:现金账户
   VirtualAssetType           uint64         //资产类型: 1:现金类账户 2:交易类账户 3:负债类账户
   BeforeBalance              uint64         //更新前余额
   AfterBalance               uint64         //更新后余额
   LastRecordID               uint64         //上次流水的ID
   Amount                     uint64         //本次增加或减少的额度
   RecordType                 RecordInfoEnum // 0:nil 1:收入 2:支出 3.转账
   RecordID                   uint64         //
   FromID                     uint64         //来源ID: income:fromid  outcome:orderid,transfer:transferorderid,
   FromName                   string         //收入来源/支出去向
   RuleID                     uint64         //渠道规则ID
   RuleName                   string         //渠道规则名称
   ExtMsg                     string         //
}

这三个核心领域模型是基石,同时,注意,我们使用的是接口/实现类的模式,我们是面向接口编程的模式,为啥这样,也是为了方便扩展,比如订单,可能会有多种实现类;

在此基础上,我们会设计聚合类:

提现聚合接口:

代码语言:javascript
复制
type WithDrawAggregateDO interface {
WithDraw(ctx context.Context) (*WithDrawRspInfo, error)
}

提现聚合接口实现:

代码语言:javascript
复制
/**
 * @Description    提现聚合
 * @Author         yang
 * @Date           2020/12/2
 */
type WithDrawAggregateDOImpl struct {
   AssetBalanceDo     do.VirtualAssetBalanceDo
   PayRecord          *do.RecordInfo
   CertificateOrderDO do.WithDrawOrderCertificateDO
}
代码语言:javascript
复制
func (s *WithDrawAggregateDOImpl) WithDraw(ctx context.Context) (*do.WithDrawRspInfo, error) {

//1.创建提现订单并预扣,并创建超时任务
   err := s.createOrderAndLockBalance(ctx)
if err != nil {
xlog.WarnContext(ctx, 0, "CreateOrderAndLockBalanceError", "err", err)
return nil, err
   }
//2.调用接口,若出现error则什么都不做,静待超时任务重试
   certificate := s.CertificateOrderDO.Get(ctx)
   rsp, err := integration.WXPayWithDraw(ctx, certificate.WithDrawOrderID, certificate.UserID, certificate.Amount)
if err != nil {
xlog.WarnContext(ctx, 0, "WXPayWithDrawError", "err", err)
return nil, err
   }
//rsp为空即可认为rsp为fail
   if rsp == nil {
xlog.WarnContext(ctx, 0, "WXPayWithDrawError", "err", err)
return nil, errors.New("WXPayWithDrawEmptyRSP")
   }
//3.rebuild VirtualAssetBalanceDo 和WithDrawOrderCertificateDO,主要是前面持久化了之后需要version+1,省去查询
   s.AssetBalanceDo.AddVersion(ctx)
s.CertificateOrderDO.AddVersion(ctx)

//4.根据接口返回决定实扣、回滚或什么都不做
   err = DealWithByStatus(ctx, s, rsp.Item)
if err != nil {
xlog.WarnContext(ctx, 0, "更新失败", "e", err)
return nil, err
   }
return &do.WithDrawRspInfo{
      WithDrawOrderID: s.CertificateOrderDO.Get(ctx).WithDrawOrderID,
      Status:          s.CertificateOrderDO.Get(ctx).CurState,
      WxErrorMsg:      s.CertificateOrderDO.Get(ctx).PayErrDesc,
      WxErrorCode:     s.CertificateOrderDO.Get(ctx).PayErrCode,
   }, nil
}

这里可以看到,很明显的,接口实现类中有刚刚上面提到的三个实体,订单、余额和流水,同时在这个聚合实现类里面做具体的实现;

OK,那么问题来了,这个接口实现类是怎么生成的呢?这三个实体哪里来的呢?

这个时候,就是工厂模式的思想出现了,我们作为do层的业务领域,是不需要关注所依赖的实体是从哪里来的,只要关心在拥有了实体之后具体的业务逻辑怎么处理。

这个道理有点像是在厨房做菜,大厨就是我们的do层的聚合类,他只要加工食材就好,至于食材的采购,择洗,他不需要关心,这就是所谓的构建和执行分离,也是工厂模式的思想核心;

那么回到问题里来,实体哪里来呢?

我们将实体的构建和生成,放在factory里面,是的,golang并没有类似spring的bean管理框架(或者有了我也不知道😀),那我们就造一个factory出来,专门用来做实体的构建。

比如在factory文件中,存在如下方法作为余额实体的构建:

代码语言:javascript
复制
func BuildVirtualAcountDoByAssetID(ctx context.Context,
   assetID uint64) (do.BaseAccountAggregate, error) {
   po, e := baseAccountDAO.GetByAssetID(ctx, assetID)
if e != nil {
log.ErrorContext(ctx, e)
return nil, e
   }
if po == nil {
xlog.WarnContext(ctx, 0, "no accountfound")
return nil, nil
   } else {
      dos := make([]*do.BaseAssetAccount, 0)
      accountDO := transVirtualAssetAccount2DO(po)
      dos = append(dos, accountDO)
return &BaseAccountAggregateImpl{BaseAssetAccountList: dos}, nil
   }
}

那么,问题又来了,这个baseAccountDAO又是在哪里定义的呢?

是在repo层的factory文件中维护所有的数据层的接口与实现类:

代码语言:javascript
复制
var balanceDAO dao.VirtualAccountBalanceDAO
var RecordDAO dao.RecordDAO
var baseAccountDAO dao.BaseAccountDAO
var ruleDAO dao.RuleDAO
var transferOrderDAO dao.TransferOrderCertificateDAO
var WithdrawOrderDAO dao.WithDrawOrderCertificateDAO

var accountProcessDAO trans.AccountProcessDAO
var deductDAO trans.DeductDAO
var incomeDAO trans.IncomeDAO
var transferDAO trans.TransferDAO

func Init(yaml string) {
if yaml != "" {
//for test
      _ = cfg.InitCfg(yaml)
   }
   db := cfg.GetString("db", "db_virtual_asset")
   url := cfg.GetString("virtualassetdb", "")
   baseDBServer = base.NewDao(db, url)
   balanceDAO = dao.NewAllowanceEntityDAOImpl(baseDBServer)
   RecordDAO = dao.NewRecordDAOImpl(baseDBServer)
   baseAccountDAO = dao.NewBaseAccountDAOImpl(baseDBServer)
   ruleDAO = dao.NewRuleDAOImpl(baseDBServer)
   transferOrderDAO = dao.NewTransferOrderCertificateDAOImpl(baseDBServer)
   WithdrawOrderDAO = dao.NewWithDrawOrderCertificateDAOImpl(baseDBServer)

//exchangeDAO = trans.NewExchangeDAOImpl(baseDBServer, RecordDAO, balanceDAO)

}

所以为什么要有factory?

是为了将构建与执行二者分离开,让构建的管构建,执行的管执行,这样,复杂实体的构建就被收拢了。

最后,我们会发现那么这个factory的方法在哪里调用呢?参数校验在哪里做呢?入参和出参怎么转换呢?

这就是ao的作用,像我们之前说的,如果说do是厨房里的大厨,那么ao就是传菜员+洗菜工,负责这些杂活累活,这种事情往往琐碎而且容易变化,所以我们将它放在了ao层,因为它并不聚焦在我们的业务模型上,反而是琐碎且容易变化的。这也是将变化的和不变的分离开,将重要的和无关的分离开。

具体代码如下所示(出于风险考虑省略了部分代码):

代码语言:javascript
复制
func WithDraw(ctx context.Context, req *mvp.WithDrawReq) (info *do.WithDrawRspInfo, e error) {

if req.VirtualAssetID == 0 || req.WithDrawAmount == 0 ||
      req.Token == "" {
return nil, xerr.New(utils.InvalidParam, "InvalidParam")
   }
//1.构造余额
   assetBalanceDo, err := impl.BuildVirtualAssetBalanceDo(ctx, req.VirtualAssetID)
if err != nil {
xlog.WarnContext(ctx, 0, "BuildVirtualAssetBalanceDoError", "e", err)
return nil, err
   }
......
//2.构造订单
   certificateInfo := &do.WithDrawOrderCertificate{
      WithDrawOrderID:     orderID,
      AssetID:             assetBalanceDo.Get(ctx).VirtualAssetID,
      VirtualAssetType:    uint64(assetBalanceDo.Get(ctx).VirtualAssetType),
      VirtualBusinessType: uint64(assetBalanceDo.Get(ctx).VirtualAccountBusinessType),
      Amount:              req.WithDrawAmount,
      CurState:            utils.WITHDRAW_ORDER_STATUS_INIT,
      AccessToken:         req.Token,
      UserID:              uid,
      CurDay:              utils.GetCurrentDayStr(),
   }
   certificateDo := &impl.WithDrawOrderCertificateDOImpl{Order: certificateInfo}

//3.构造流水
   record := &do.RecordInfo{
      VirtualAssetID:             assetBalanceDo.Get(ctx).VirtualAssetID,
      VirtualAccountBusinessType: uint64(assetBalanceDo.Get(ctx).VirtualAccountBusinessType),
      VirtualAssetType:           uint64(assetBalanceDo.Get(ctx).VirtualAssetType),
      RecordType:                 do.RecordTypeEnum_Outcome,
      RecordID:                   recordID,
      FromID:                     orderID,
      FromName:                   fromName,
      Amount:                     req.WithDrawAmount,
   }
.....
   withDrawDo := impl.WithDrawAggregateDOImpl{
      AssetBalanceDo:     assetBalanceDo,
      PayRecord:          record,
      CertificateOrderDO: certificateDo,
   }
   info, e = withDrawDo.WithDraw(ctx)
if e != nil {
xlog.WarnContext(ctx, 0, "WithDrawError", "e", err)
return nil, err
   }
return info, nil
}

我们思考一下,为啥要有设计模式,要做ddd? 其实最终的目的我认为,是为了降低我们自己的开发心智。

试想一下,如果什么都没有,把所有的业务逻辑都写在sql脚本里面,能否满足业务需求呢?

其实也不是不行,但当业务变更时,或者需要定位问题时,对个人的要求是非常高的;但如果我们分门别类的将那些魔鬼般的细节关在自己的盒子里,也就意味着我们可以非常方便的聚集在自己关注的模块里,是大大节省了自己的开发心智的,从团队的角度上来说,这种统一的开发范式也能够大幅度的降低团队成员之间的交接成本。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • service:
  • ao:
  • do:
  • repo:
  • integration:
  • config:
相关产品与服务
数据保险箱
数据保险箱(Cloud Data Coffer Service,CDCS)为您提供更高安全系数的企业核心数据存储服务。您可以通过自定义过期天数的方法删除数据,避免误删带来的损害,还可以将数据跨地域存储,防止一些不可抗因素导致的数据丢失。数据保险箱支持通过控制台、API 等多样化方式快速简单接入,实现海量数据的存储管理。您可以使用数据保险箱对文件数据进行上传、下载,最终实现数据的安全存储和提取。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档