新项目从零到一DDD实战思考与总结

article/2025/9/18 13:44:47

抱歉了,前面几篇DDD的文章我删除了,本篇是前面发表的几篇DDD的汇总,内容有修改。

领域驱动设计(DDD)是一种业务领域建模方法论、业务架构设计方法论,战略设计阶段从业务领域视角划分领域边界,抽象业务建立领域模型;战术设计阶段则根据清晰的领域边界、领域模型进行架构设计与开发实现。

DDD解决了核心复杂业务设计问题,简化业务系统的实现,让业务逻辑高度内聚,与基础设施、框架解耦,清晰的领域边界解决微服务的拆分问题。聚合之间通过聚合根ID引用与领域事件松耦合,保持高内聚、松耦合让项目代码随着业务需求的不断迭代保持整洁。

本篇笔者以近期的一个项目实战跟大家分享笔者目前对DDD的理解,以及在实战DDD过程中遇到的问题思考与总结,仅个人经验,偏战术设计。

从实战项目中理解DDD核心概念

领域通常指的就是业务范围,每个公司都有自己明确的业务范围。通常每个公司内部都有很多个系统,如一家电商公司可能会有物流系统、电商系统、直播系统等等,每个系统做的事情则是更细分的领域。

茉莉红交所(红探长)项目是笔者入职茉莉数科集团后做的第一个项目,也是一个新的项目,由于没有历史包袱,笔者从零开始搭建整个项目,因此选择在该项目试行DDD。

该项目业务是OTO(线上到线下)探店,OTO探店就是该项目的领域。

探店其实是一种商家线上付费下单、平台为商家匹配达人、达人线下探店线上内容推广的内容营销模式,可以是品尝美食或是免门票游玩景点等,达人最终通过短视频、直播、图文内容等方式为商家做推广。那么无论是探美食店、探游乐园,探店都是这个领域的核心。

在探店这个领域中,核心的业务名词有:商家、达人、店铺、订单、任务,而核心事件有:店铺入驻、商家发布订单、达人接单等。

附上此项目对应版本的架构设计图:

 提示:中间部分不是每个限界上下文对应一个微服务,可以是多个限界上下文合并在一个微服务。对于为什么要这样设计可参考我的另一篇文章《这个项目为什么这样架构设计?》。

限界上下文是业务概念的边界,是业务问题最小粒度的划分。在OTO探店业务领域中会包含多个限界上下文,我们通过找出这些确定的限界上下文对系统进行解耦,要求每一个限界上下文其内部必须是紧密组织的、职责明确的、具有较高的内聚性。

我们划分出的限界上下文如下图所示。

提示:为什么将任务和订单拆分为不同限界上下文(任务不是作为订单聚合根的实体,而是作为一个独立聚合的聚合根)?这是因为商家发布的一个订单允许有不同的多个达人接单,一个达人也可以接不同商家的订单,这并不是简单的一对多关系。这更像是商品与订单的关系,而不是订单与订单item的关系。

在划分出限界上下文后,还需要根据限界上下文识别出问题子域。问题子域是对业务问题的划分,相对限界上下文来说,是对业务问题更大粒度的划分。

  • 核心(子)域:产品的核心竞争力、盈利来源;

  • 通用子域:常见的,不同领域都可共用的,可通过购买就能使用的;

  • 支撑子域:非核心域、又非通用域,具有个性化需求,用于支撑核心域运作;

根据限界上下文,我们划分出的子域如下图所示。

  • OTO探店核心域:商家下单、平台审核订单、达人接单、平台审核任务、达人回填内容链接等;

  • 店铺支撑子域:商家店铺入驻、平台审核店铺、商家绑定店铺、店铺转移等;

  • ......

在划分出子域后,我们需要为领域建模。

领域建模是通过将业务抽象为聚合、实体、聚合根、值对象模型的方式,封装和承载全部的业务逻辑,保持业务的高内聚和低耦合。

聚合:负责封装业务逻辑,内聚决策命令和领域事件,容纳实体、聚合根、值对象。

  • 聚合根:也是一种实体,是聚合的根节点,如订单;

  • 实体:聚合的主干,具有唯一标识和生命周期,如订单Item;

  • 值对象:实体的附加业务概念,用于描述实体所包含的业务信息,如订单收件地址。

70%的场景下,一个聚合内都只有一个实体,那就是聚合根。

在技术实现上,一个聚合就是一个包,里面存放领域服务、工厂、资源库、聚合根、实体、值对象。

领域层包的划分规则通常为:

----限界上下文
--domain
------聚合A
-------- (聚合根、实体、值对象、领域服务、资源库、领域事件)
------聚合B
-------- (聚合根、实体、值对象、领域服务、资源库、领域事件)

特别的,一个限界上下文可能包含多个聚合,但一个聚合只能存在于一个限界上下文。

以上包的划分只是领域层的划分,要求聚合根、实体、值对象、领域服务、资源库、领域事件等类存放在聚合包下,无论是使用DDD经典四层架构,还是六边形架构。

以店铺上下文为例,我们采样六边形架构实现,整个模块的层次、包划分如下:

com.mmg.hjs.storecontext(限界上下文)
--adapters(适配层)
----api(API接口适配)
------webmvc(前端接口,请求从网关进来)
------dubbo(内部微服务调用,实现应用层gateway包下的接口)
----persistence(持久化适配)
------dao(mybatis的Mapper类与xml文件)
------po(对应数据库表生成的类)
------StoreAssembler.class(领域实体转PO的转换器)
------StoreRepositoryImpl.class(实现领域层的资源库接口)
----cache(缓存适配)
--application(应用层)
----gateway(与其它限界上下文通信并与适配层接耦的抽象接口)
----job(可选,定时任务)
----usecase(应用服务,按用例拆分多个类,避免单个类臃肿)
----assembler(聚合根转DTO转换器)
----dto(DTO类)
----cqe(CQE模式)
------command(请求参数)
------query(查询参数)
------event(可选,事件参数,注意:非领域事件)
--domain(领域层)
----store(店铺聚合)
------model(聚合根、实体、值对象)
------event(领域事件)
------StoreDomainService.class(领域服务)
------StoreRepository.class(资源库抽象接口)

在分层架构模式中,我们需要严格遵循:上层只能依赖下层,下层不能依赖上层。

例如,在一次创建订单的操作中,用于接收前端请求参数的CreateOrderCommand属于应用层的类,虽然我们在Controller(接口层)可直接使用CreateOrderCommand,但这属于上层依赖下层,并且不是领域层,也并未暴露聚合根内部结构,因此是允许的。

如果反过来,直接将CreateOrderCommand对象传递给聚合根,那就构成下层依赖上层了,因此这是不允许的。CreateOrderCommand必须在应用层拆解为创建订单所需要的值对象,或者实体对象,再调用领域服务方法,领域服务方法调用订单工厂创建订单,最后才交给资源库持久化订单聚合根。

在领域层里面,更准确的说是在聚合包内,存储的是聚合下的聚合根、实体、值对象、资源库、领域服务、聚合根工厂类,而由于资源库的实现需要依赖orm框架或其它框架实现持久化聚合根、事件的发布需要依赖MQ实现等,所以资源库定义成接口由上层实现,事件发布也定义为接口由上层实现。

对于资源库与事件发布者,由于领域服务需要依赖资源库获取和保存聚合根,也依赖事件发布者发布事件,这种情况下我们可以使用Spring框架的自动注入,但应该使用构造函数注入,而不是在字段上加注解的方式注入,避免注入资源库、事件发布者为NULL的情况,且不应该添加Spring框架的注解(尽量不耦合)。

应用服务、领域服务、聚合根、资源库的职责

在实现DDD的过程中,我们需要严格遵守代码规范才能保持代码的整洁,否则随着需求的迭代,项目很容易就失去DDD该有的模样,变得即不DDD也不MVC。

在DDD中,Repository(资源库)是聚合根的容器,与DAO扮演相同角色,但它只提供持久化聚合根的操作(新增或更新),以及提供根据ID获取聚合根的查询操作。在所有的领域对象中,只有聚合根才拥有Repository,因为Repository不同于DAO,它所扮演的角色只是向领域模型提供聚合根。

资源库(Repository) 的职责是提供聚合根或者持久化聚合根,除此之外应「尽可能」的没有其它行为,否则聚合根就会严重退化成DAO。

public interface Repository<DO, KEY> {void save(DO obj);DO findById(KEY id);void deleteById(KEY id);
}

聚合根与领域服务(DomainService) 负则封装实现业务逻辑,应用服务(ApplicationService) 不处理业务逻辑,而只是对领域服务/聚合根方法调用的封装。

正常情况下,处理一次业务请求将经过:

应用服务->领域服务->通过资源库获取聚合根->通过资源库持久化聚合根->发布领域事件

但也允许:

应用服务->通过资源库获取聚合根->通过资源库持久化聚合根->发布领域事件

对于不能直接通过聚合根完成的业务操作就需要通过领域服务。

但必须遵守原则:

  • 聚合根不能直接操作其它聚合根,聚合根与聚合根之间只能通过聚合根ID引用;

  • 同限界上下文内的聚合之间的领域服务可直接调用;

  • 两个限界上下文的交互必须通过应用服务层抽离接口->适配层适配。

聚合根工厂负责创建聚合根,但并非必须的,可以将聚合根的创建写到聚合根下并改为静态方法,非常复杂的创建过程才建议写工厂类。

以修改用户信息为例,可在应用服务通过资源库获取用户聚合根,再调用用户聚合根的修改用户信息方法,最后通过资源库持久化用户聚合根。

public class UserModifyInfoUseCase{/*** 更新用户基本信息** @param command* @param token*/@Transactional(rollbackFor = Throwable.class, isolation = Isolation.READ_COMMITTED)public void updateUserInfo(ModifyUserInfoCommand command, Long loginUserId) {// 获取聚合根Account account = findByAccountId(loginUserId);// 调用业务方法account.modifyAccountInfo(AccountInfoValobj.builder().nickname(command.getNickname()).avatarUrl(command.getAvatarUrl()).country(command.getCountry()).province(command.getProvince()).city(command.getCity()).gender(Sex.valueBy(command.getGender())).build());// 通过资源库持久化repository.save(account);// 更新缓存accountCache.cache(loginUserId, getUserById(account.getId()));}}

这里用户聚合根能到看到自己的信息,用户自己修改自己的信息可直接通过聚合根完成,因此这种场景下我们不需要领域服务。

复杂场景如用户绑定手机号码就不能直接在领域服务中完成。

绑定手机号码一般流程为:获取短信验证码、校验短信验证码、校验手机号码是否已经绑定了别的账号。

其中获取短信验证码与校验短信验证码应放在应用服务完成,而校验手机号码是否已经绑定了别的账号就需要由领域服务完成,因为聚合根无法完成这个判断, 聚合根看不到别的账号,聚合根不能拥有资源库,且应用服务不能处理业务逻辑。

聚合根

public class Account extends BaseAggregate<AccountEvent>{// .....private String phone;public void bindMobilePhone(String phoneNumber) {if (!StringUtils.isEmpty(this.phone)) {throw new AccountParamException("已经绑定过手机号码了,如需更新可走更换手机号码流程");}this.phone = phoneNumber;}}

领域服务

@Service
public class AccountDomainService {private AccountRepository repository;public AccountDomainService(AccountRepository repository) {this.repository = repository;}public void bindMobilePhone(Long userId, String phone) {Account account = repository.findById(userId);if (account == null) {throw new AccountNotFoundException(userId);}// 号码被其它账户绑定了boolean exist = repository.findByPhone(phone) != null;if (exist) {throw new AccountBindPhoneException(phone);}account.bindMobilePhone(phone);repository.save(account);}}

应用服务

@Service
public class UserBindPhoneUseCase {/*** 绑定手机号码-发送验证码** @param command* @param token*/public void bindMobilePhoneSendVerifyCode(VerifyCodeSendCommand command, Long loginUserId) {// 生成验证码String verifyCode = ValidCodeUtils.generateNumberValidCode(4);// 缓存验证码verifyCodeCache.save(command.getPhone(),verifyCode,timeout);// 调用消息服务发送验证码messageClientGateway.sendSmsVerifyCode(command.getPhone(), verifyCode);}/*** 绑定手机号码-提交绑定** @param command* @param token*/public void bindMobilePhone(BindPhoneCommand command, Long userId) {// 校验验证码String verifyCode = verifyCodeCache.get(command.getPhone());if (!command.getVerifyCode().equalsIgnoreCase(verifyCode)) {throw new VerifyPhoneCodeApplicationException();}// 通过领域服务绑定手机号码accountDomainService.bindMobilePhone(userId, command.getPhone());// 更新账号缓存accountCache.cache(userId, getUserById(userId));}
}

接口层

@RestController
@RequestMapping("account/bindMobilePhone")
public class UserBindPhoneController {@Resourceprivate UserBindPhoneUseCase useCase;@ApiOperation("绑定手机号-获取验证码")@GetMapping("/verifyCode")public Response<Void> bindMobilePhone(@RequestParam("phone") String phone) {Long userId = WebUtils.getLoginUserId();useCase.bindMobilePhoneSendVerifyCode(phone, userId);return Response.success();}@ApiOperation("绑定手机号-提交绑定")@PostMapping("/submit")public Response<Void> bindMobilePhone(@RequestBody @Validated BindPhoneCommand command) {Long userId = WebUtils.getLoginUserId();useCase.bindMobilePhone(command, userId);return Response.success();}}

CQE模式

CQE即Command、Query、Event。接收前端创建订单请求使用Command,接收前端分页查询请求使用Query,消费事件(非领域事件)则使用Event。

除Event外,所有写请求都应该使用Command接收参数,而所有查询都应该使用Query接收参数,只在参数只有一个ID的查询情况下,可省略Query。

在查询分离情况下,Query是可直接传递到DAO的(接口层->应用层->DAO)。因此使用Query封装查询条件能够提高方法的复用,当添加查询条件时,无需给方法加多一个参数。

CQRS模式

CQRS(Command Query Resposibility Segregation),即命令查询职责分离模式。软件模型中存在读模型和写模型之分,以我们写业务代码的经验也知道,一次请求,要么是作为一个“命令”执行一次操作,要么作为一个”查询“向调用方返回数据,两者不可能共存。CQRS是将“命令”和“查询”分别使用不同的对象模型来表示。

CQRS的读操作放在应用层。

共享存储-共享模型-CQRS

共享存储指同一个表结构存储数据,共享模型指使用聚合根从数据库读取数据。

例如查询订单详情,订单的聚合根为Order。

// 订单聚合根
public class Order extends BaseAggregate {
}

在应用层OrderDetailsUseCase通过OrderRepository查询订单聚合根,再调用装配器将聚合根转为读模型。

public class OrderDetailsUseCase {public OrderDto byId(String id) {Order order = orderRepository.byId(id);return orderDaoAssembler.toDto(order);}
}
  • OrderDaoAssembler方法是将Order转为读模型实体,也就是将DO转为DTO。

注意:读操作和写操作不要写在同一个应用服务中,避免耦合,且应用服务应按用例拆分多个类(不需要很细),避免应用服务越写越臃肿。

共享存储-读写分离模型-CQRS

共享存储-读写分离模型指读写还是操作同一张表,只是写模型与读模型不同,写通过聚合根操作,而读模型绕过聚合根、Repository,直接操作数据库,此时的读模型就是用于装载从数据库查询的数据,并且不需要再作转换就可以响应给调用方,这里的读模型就是DTO。

对于单个聚合根内的查询,使用「共享存储-读写分离模型」模型可以应付复杂的查询场景,并且可以提升性能。

对于需要跨多个聚合根的查询,「共享存储-共享模型」无法实现此需求场景,而分别查询多个聚合根后,再合并查询结果不仅是将原本简单的事情变复杂,还大大影响性能,因此更有必要采用「共享存储-读写分离模型」。

对于查询订单详情希望带上商品信息,如果商品与订单在同一个服务,并且同一个数据库,那么便可以使用join多表查询。

共享存储-读写分离模型-CQRS实战举例:

接口层

@RequestMapping("/order")
@RestController
public class OrderQueryController {@GetMapping("/query")public Response<PageInfo<OrderQueryDto>> queryOrder(OrderQuery query) {return Response.success(orderQueryUseCase.queryOrder(query,WebUtils.getLoginUserId()));}}

应用层

@Service
public class OrderQueryUseCase implements Cqrs {public PageInfo<OrderQueryDto> queryOrder(OrderQuery query, Long loginUserId) {Long merchantId = merchantGateway.getMerchantId(loginUserId);IPage<OrderQueryDto> orderPage = new Page<>(query.getPage(), query.getPageSize());List<OrderQueryDto> orders = orderMapper.selectOrderBy(merchantId,query,orderPage);PageInfo<OrderQueryDto> pageInfo = new PageInfo<>(page, pageSize);pageInfo.setTotalCount((int) orderPage.getTotal());pageInfo.setList(orders);return pageInfo;}
}

读写分离存储-读写分离模型

即读与写操作不同数据库。例如,对于查询订单详情希望带上商品信息,如果商品是一个微服务、订单是一个微服务,并且两个微服务使用不同的数据库,如果要提升性能,就需要通过额外的数据同步服务,将订单与商品查询结果合并后存入一个新的表(分离存储)或者是存储到NoSQL数据库。数据同步可通过底层数据库Binlog+Kafka消费实现,还有一种是通过消费领域事件实现,但影响应用性能。

提示:对于复杂的报表统计,建议通过Binlog+Kafka同步到一张大表,为不与业务耦合,应独立为一个数据服务。

领域事件的发布

在DDD中有一个原则,一个业务用例对应一个事务,一个事务对应一个聚合根,即在一次事务中只能对一个聚合根操作。

但在实际应用中,一个业务用例往往需要修改多个聚合根,而不同的聚合根可能在不同的限界上下文中,引入领域事件即不破坏DDD的一个事务只修改一个聚合根的原则,也能实现限界上下文之间的解耦。

在DDD中,领域层是业务逻辑的具体实现,所有以解决问题子域的业务代码都高度内聚在限界上下文中、高度内聚在聚合中,即聚合根、实体以及领域服务内。

对于领域事件发布,我们的实现是在聚合根中临时保存,最后在领域服务/应用服务发布,领域层抽象事件发布接口,由适配器层实现,并注入到领域服务/应用服务。

一个原因是领域层不应该依赖其它框架的Api,另一个原因则与领域事件是由聚合根/领域服务创建有关。

那为什么领域事件由聚合根/领域服务创建,而不是在应用层创建?

发布领域事件当然是在领域层发出,可以是聚合根发出,也可以在领域服务发出,业务在聚合下高度内聚,什么时候该发出什么事件也只有聚合内最清楚,应用服务不过是封装业务实现的步骤。

由于业务逻辑最核心的实现是聚合根内,而聚合根是一个实体,不能说每次构造聚合根都传入一个事件发布者,那从资源库获取聚合根时又由谁传入事件发布者?

所以推荐的做法是在聚合根下临时存储聚合根发出的事件,在领域服务中、在调用资源库持久化聚合根之后再发布领域事件。当然,在不使用领域服务的情况下,则由应用层在调用资源库持久化聚合根之后再发布领域事件。

应用服务一个业务用例只是对应领域服务方法的一层很薄的封装,即不会在一个应用服务方法中调用两个领域服务方法。这实际上也是我们要注意,如果出现这种情况,说明领域服务方法封装的不够好。所以领域事件由领域服务发布是允许的,但事件发布者必须抽象为接口,与资源库一样在领域服务的构建方法传入。

关于我们为什么先通过Spring框架发布事件再在订阅者中实现将事件发布到MQ。

一个事件可能当前限界上下文内也需要消费,即可能有多个限界上下文需要消费,一个事件对应多个消费者。

如订单限界上下文内订单聚合产生的创建订单事件,订单限界上下文内需要消费订单创建事件,用于构造消息通知,然后给消息通知队列写入消息;同时,其它限界上下文也需要消费订单创建事件。因此,一个领域事件我们可能需要发布到多个消息队列中。

先通过Spring框架发布事件再在订阅者中实现将事件发布到MQ其实是借助Spring框架实现责任链模式。这当然不是必须的,也算不上是规范。

但不管如何,不要直接在领域服务/应用服务中调用MQ的API直接发布事件,发布事件到MQ应在适配层实现。并且封装事件发布者的好处在于,当需要确保消息至少投递一次时,这些逻辑不需要写在应用服务中。

最令人头疼的代码

在实战DDD的过程中,我们编写最多的代码无疑就是DO(聚合根)转DTO(读模型)以及DO转PO(映射到数据库表)和PO转DO的转换器代码。百分之八十的BUG都来自这些整齐划一的属性拷贝代码,容易漏字段。

那为什么需要这么多层转换呢,直接将聚合根响应给请求、直接持久化聚合根不行吗?

首先,在DDD中我们必须先获取到聚合根再通过聚合根完成业务逻辑,最终通过资源库持久化聚合根。

为什么需要将DO转PO,这是必须要做的事情吗?

如果我们选择关系型数据库持久化聚合根,那么就可能需要将聚合根拆分存储到多个表,并且对于枚举类型我们也需要转成数值类型再存储。基于这些场景就需要将聚合根转为PO再调用对应表的DAO存储到数据库中。

为什么需要将DO转DTO?

除了我们必须要遵守不暴露聚合根内部结构给外部之外,前端需要的数据也是不一样的,比如我们需要将枚举类型字段拆成值和名称两个字段,以及需要屏蔽一些字段。

为了不暴露聚合根内部结构,聚合根应只暴露GET方法,用于外部获取字段值,同时使用Builder模式提供builder方法给资源库(使用装配器)将PO转为聚合根。对于实体也可以这样做,而值对象只提供所有参数的构造方法和GET方法。当然了,使用哪种做法都没有错。

对于通用的转换操作我们为每个聚合根提供一个实现将DO(聚合根)转为DTO的装配器(转换器)、以及一个实现PO和DO相互转换的装配器(转换器)。

基于这种实现,笔者也寻找过能够解决这些繁琐操作提升工作效率的方法,我们试过用mapstruct框架,但mapstruct也只适用于简单的聚合根,对于复杂内部结构的聚合根映射也需要写一堆注解,工作量没有减少反而增加了问题排查的难度。使用Spring提供的属性拷贝工作类也是一样的,无法解决问题。

优化聚合根的持久化性能

对于使用关系型数据库持久化聚合根的场景,在“只能通过Repository的save方法持久化聚合根”这个约束下,save方法在性能上是有非常大的损耗的,因为更新一个聚合根需要同时更新聚合根下的实体。为了降低性能影响,可在更新之前对比一下内存中的快照,只对有更新的实体执行更新操作,笔者单独写了一篇文章介绍如何实现:《DDD资源库Repository的性能优化》。

如今分布式数据库已经成熟,不建议在新项目中引入分库分表ORM框架以及分布式事务框架,更不建议使用分库分表,这些应该交由底层数据库完成,或增加一层代理完成。

总结

因为DDD缺少权威性的实践指导和代码约束,我们只能是通过实践慢慢积累经验。个人的理解也并非完全正确的,对于限界上下文的划分,我们只是凭经验划分,但又缺少经验,新业务也处于不断摸索状态,现在的限界上下文划分、建模不代表将来不会推倒重来。

参考文献:

  • 领域驱动实战思考(三):DDD的分段式协作设计

  • 领域驱动设计(DDD)在美团点评业务系统的实践

  • 领域驱动设计在爱奇艺打赏业务的实践

  • 《领域驱动设计(Thoughtworks洞见)》

[Java艺术] 微信号:javaskill

深耕后端架构、探索底层实现原理,关注Java艺术,我们在架构师道路上一起成长!


http://chatgpt.dhexx.cn/article/XyGMAswv.shtml

相关文章

设计模式 | 四、代理模式(静态代理、JDK动态代理、Cglib动态代理、手写动态代理核心部分)[ProxyPattern]

代理模式 源码&#xff1a;https://github.com/GiraffePeng/design-patterns 1、应用场景 在生活中&#xff0c;我们经常见到这样的场景&#xff0c;如&#xff1a;租房中介、售票黄牛、婚介、经纪人、快递、事务代理、非侵入式日志监听等&#xff0c;这些都是代理模式的实际…

23种设计模式 原型设计模式

原型模式的定义&#xff1a; 原型模式&#xff1a;使用原型实例指定待创建对象的类型&#xff0c;并且通过复制这个原型来创建新的对象。 原型模式的结构&#xff1a; 原型模式主要包含3个角色&#xff1a; &#xff08;1&#xff09;Prototype(抽象原型类)&#xff1a;声明克…

OTO模式 传统产业掘金互联网时代的利器

OTO模式 传统产业掘金互联网时代的利器 2013年10月16日 07:11 来源&#xff1a;中国经济网—《证券日报》 编者按&#xff1a;今年以来&#xff0c;一种新型的商业模式OTO模式迅速被国内众多传统企业所熟悉&#xff0c;很多传统企业借助OTO模式实现了从传统产业向互联网时…

OTO电子商务商业模式探析

一、前言 OTO&#xff08;O2O&#xff09;&#xff0c;即Online to Offline&#xff0c;是将线下商务机会与线上互联网结合在一起&#xff0c;让互联网成为线下交易前台的一种电子商务商业模式&#xff0c;简单来说就是“线上拉客&#xff0c;线下消费”。其核心理念是通过电子…

2020-1024

2020 - 1024 996

1024程序员节

1024是一种逐渐流行的回帖方式&#xff0c;常见于BBS、网络社区和论坛的网友交流中。其常见形式为"1024"、"1024顶"两种形式。 1024所表达的含义&#xff0c;类似于"顶"、"非常棒&#xff0c;一级棒" &#xff0c;还有表达回帖者的身…

1024,干程序才懂得节日!

1024程序员节 1024程序员节是广大程序员的共同节日。1024是2的十次方&#xff0c;二进制计数的基本计量单位之一。针对程序员经常周末加班与工作日熬夜的情况&#xff0c;部分互联网机构倡议每年的10月24日为1024程序员节&#xff0c;在这一天建议程序员拒绝加班。 程序员就像是…

2022年的1024

一年一度的1024来啦&#xff01; 今年是我的第6个1024. 在这个专属节日里&#xff0c;我想随性记录一下当下的心境吧&#xff1a; 从2016年接触编程以来&#xff0c;自己各方面都有成长&#xff0c;可是不变的是从最初知道自己是小白到现在知道自己还是小白&#xff0c;笑死…

1024,鸽了1024篇博文的我。。。

1024&#xff0c;鸽了1024篇博文的我。。。 最近&#xff0c;硕士研究生刚开学&#xff0c;鉴于小菜鸡的我刚开始我的“填坑”之路&#xff0c;很忙&#xff0c;对&#xff0c;真的&#xff01;&#xff01;&#xff01; 我在忙什么&#xff1f; 忙于脑网络的学习&#xff0c…

「1024」福利

重点突出 这不是一篇纯技术文&#xff0c;也不是一篇广告文&#xff0c;而是我和明哥等几个 Android 开发者共同在做的一件事&#xff1a;「Android答疑解惑」。 我们在做一个付费的 Android 圈子&#xff0c;这里可以提供 Android系统层和 Android应用层方面的技术&#xff0c…

浅谈1024

说起1024&#xff0c;大家都知道在数学计算中1GB1024MB&#xff0c;1MB1024KB&#xff0c;1KB1024Byte。 对于程序员来说&#xff0c;接触1024的机会非常多&#xff0c;所以被广大程序员称为&#xff1a;程序员节 浅谈自己的1024 我的第1024行代码 if (devicesBean.getMetadat…

1024 - 在这里起航!

写在前面&#xff1a;博主是一只经过实战开发历练后投身培训事业的“小山猪”&#xff0c;昵称取自动画片《狮子王》中的“彭彭”&#xff0c;总是以乐观、积极的心态对待周边的事物。本人的技术路线从Java全栈工程师一路奔向大数据开发、数据挖掘领域&#xff0c;如今终有小成…

又一年1024 - 我们在路上!

写在前面&#xff1a;博主是一只经过实战开发历练后投身培训事业的“小山猪”&#xff0c;昵称取自动画片《狮子王》中的“彭彭”&#xff0c;总是以乐观、积极的心态对待周边的事物。本人的技术路线从Java全栈工程师一路奔向大数据开发、数据挖掘领域&#xff0c;如今终有小成…

1024——今天我们不加班

今天&#xff0c;是技术人的节日&#xff0c;在二进制构筑的计算机世界里&#xff0c;1024或许是技术人最熟悉的数字。 你知道为什么选择这一天作为中国程序员的共同节日吗&#xff1f; 1024是2的十次方&#xff0c;二进制计数的基本计量单位之一。程序员(英文Programmer)是从…

1024你懂得

1024程序员节是广大程序员的共同节日。1024是2的十次方&#xff0c;二进制计数的基本计量单位之一。针对程序员经常周末加班与工作日熬夜的情况&#xff0c;部分互联网机构倡议每年的10月24日为1024程序员节&#xff0c;在这一天建议程序员拒绝加班。 程序员就像是一个个1024&a…

1024共创程序世界

1024共创程序世界 1024定为程序员节的缘由: 1024是2的十次方,是二进制计数的基本计量单位之一, 也是运行程序的基础&#xff0c;而且1024是程序员常用的数字。 程序员是从事程序开发、程序维护的专业人员。一般将程序员分为程序设计人员和程序编码人员,但两者的界限并不非常清…

什么是1024,为什么是1024?

非常感谢你阅读本文~ 欢迎【&#x1f44d;点赞】【⭐收藏】【&#x1f4dd;评论】~ 放弃不难&#xff0c;但坚持一定很酷~ 希望我们大家都能每天进步一点点~ 本文由 二当家的白帽子 https://le-yi.blog.csdn.net/ 博客原创~ 二当家的祝所有的 程序猿/媛 节日快乐&#xff0c;一…

1024,你懂的

哎呀,是不是被标题党套路了,不过认真读完,会有收获的哦,相信我。话说小拾君最近逛知乎,无意间被某些帖子指引,发现知乎已经快被1024攻占了,哈哈,然后今天准备跟大家科普一下1024。各位老司机们看看就好,千万不要开车....... 1024的起源。 经小拾君向诸多老司机求证,以…

smooth L1 loss个人理解

最近在整理目标检测损失函数&#xff0c;特将Fast R-CNN损失函数记录如下&#xff1a; smooth L1 损失函数图像如下所示&#xff1a; L1损失的缺点就是有折点&#xff0c;不光滑&#xff0c;导致不稳定。 L2 loss的导数&#xff08;梯度&#xff09;中包含预测值与目标值的差值…