解耦,未解耦的区别

HTTP中的幂等性意味着相同的请求可以执行多次,效果与仅执行一次一样。 如果用新资源替换某个资源的当前状态,则无论您执行多少次,最终状态都将与您仅执行一次相同。 举一个更具体的例子:删除用户是幂等的,因为无论您通过唯一标识符删除给定用户多少次,最终该用户都会被删除。 另一方面,创建新用户不是幂等的,因为两次请求该操作将创建两个用户。 用HTTP术语来说是RFC 2616:9.1.2等幂方法必须说的:

9.1.2等幂方法

方法还可以具有“ 幂等 ”的特性,因为[…] N> 0个相同请求的副作用与单个请求的副作用相同。 GET,HEAD,PUT和DELETE方法共享此属性。 同样,方法OPTIONS和TRACE不应有副作用,因此本质上是幂等的。

时间耦合是系统的不良特性,其中正确的行为隐含地取决于时间维度。 用简单的英语来说,这可能意味着例如系统仅在所有组件同时存在时才起作用。 阻塞请求-响应通信(ReST,SOAP或任何其他形式的RPC)要求客户端和服务器同时可用,这就是这种效果的一个示例。

基本了解这些概念的含义后,我们来看一个简单的案例研究- 大型多人在线角色扮演游戏 。 我们的人工用例如下:玩家发送优质短信,以在游戏内购买虚拟剑。 交付SMS时将调用我们的HTTP网关,我们需要通知部署在另一台计算机上的InventoryService 。 当前的API涉及ReST,其外观如下:

@Slf4j
@RestController
class SmsController {private final RestOperations restOperations;@Autowiredpublic SmsController(RestOperations restOperations) {this.restOperations = restOperations;}@RequestMapping(value = "/sms/{phoneNumber}", method = POST)public void handleSms(@PathVariable String phoneNumber) {Optional<Player> maybePlayer = phoneNumberToPlayer(phoneNumber);maybePlayer.map(Player::getId).map(this::purchaseSword).orElseThrow(() -> new IllegalArgumentException("Unknown player for phone number " + phoneNumber));}private long purchaseSword(long playerId) {Sword sword = new Sword();HttpEntity<String> entity = new HttpEntity<>(sword.toJson(), jsonHeaders());restOperations.postForObject("http://inventory:8080/player/{playerId}/inventory",entity, Object.class, playerId);return playerId;}private HttpHeaders jsonHeaders() {HttpHeaders headers = new HttpHeaders();headers.setContentType(MediaType.APPLICATION_JSON);return headers;}private Optional<Player> phoneNumberToPlayer(String phoneNumber) {//...}
}

依次产生类似于以下内容的请求:

> POST /player/123123/inventory HTTP/1.1
> Host: inventory:8080
> Content-type: application/json
>
> {"type": "sword", "strength": 100, ...}< HTTP/1.1 201 Created
< Content-Length: 75
< Content-Type: application/json;charset=UTF-8
< Location: http://inventory:8080/player/123123/inventory/1

这很简单。 SmsController只需通过发布购买的剑SmsController适当的数据转发到SmsController inventory:8080服务。 该服务立即或201 Created返回201 Created HTTP响应,确认操作成功。 此外,还会创建并返回到资源的链接,因此您可以对其进行查询。 有人会说:ReST是最新技术。 但是,如果您至少关心客户的钱并了解什么是ACID(比特币交易所还必须学习的东西:请参阅[1] , [2] , [3]和[4] )–该API也是易碎,容易出错。 想象所有这些类型的错误:

  1. 您的请求从未到达inventory服务器
  2. 您的请求已到达服务器,但被拒绝
  3. 服务器接受连接,但无法读取请求
  4. 服务器读取请求但挂起
  5. 服务器处理了请求,但发送响应失败
  6. 服务器发送了200 OK响应,但丢失了,您再也没有收到
  7. 收到服务器的响应,但客户端无法处理它
  8. 服务器的响应已发送,但客户端更早超时

在所有这些情况下,您仅在客户端获得一个异常,而您不知道服务器的状态是什么。 从技术上讲,您应该重试失败的请求,但是由于POST不具有幂等性,因此您最终可能会用一把以上的剑来奖励玩家(在5-8情况下)。 但是,如果不重试,您可能会失去游戏玩家的金钱而又不给他他宝贵的神器。 肯定有更好的办法。

将POST转换为幂等PUT

在某些情况下,通过将ID生成基本上从服务器转移到客户端,从POST转换为幂等PUT会非常简单。 使用POST的是服务器生成剑的ID,并将其发送到Location标头中的客户端。 事实证明,在客户端急切地生成UUID并稍稍更改语义加上在服务器端强制执行一些约束就足够了:

private long purchaseSword(long playerId) {Sword sword = new Sword();UUID uuid = sword.getUuid();HttpEntity<String> entity = new HttpEntity<>(sword.toJson(), jsonHeaders());asyncRetryExecutor.withMaxRetries(10).withExponentialBackoff(100, 2.0).doWithRetry(ctx ->restOperations.put("http://inventory:8080/player/{playerId}/inventory/{uuid}",entity, playerId, uuid));return playerId;
}

该API如下所示:

> PUT /player/123123/inventory/45e74f80-b2fb-11e4-ab27-0800200c9a66 HTTP/1.1
> Host: inventory:8080
> Content-type: application/json;charset=UTF-8
>
> {"type": "sword", "strength": 100, ...}< HTTP/1.1 201 Created
< Content-Length: 75
< Content-Type: application/json;charset=UTF-8
< Location: http://inventory:8080/player/123123/inventory/45e74f80-b2fb-11e4-ab27-0800200c9a66

为什么这么大? 简单地说(不需要双关语),客户端现在可以根据需要重试PUT请求多次。 服务器首次收到PUT时,会将剑以客户端生成的UUID( 45e74f80-b2fb-11e4-ab27-0800200c9a66 )作为主键45e74f80-b2fb-11e4-ab27-0800200c9a66在数据库中。 在第二次尝试PUT的情况下,我们可以更新或拒绝该请求。 使用POST不可能,因为每个请求都被视为购买新剑–现在我们可以跟踪是否已经有这样的PUT。 我们只需要记住,后续的PUT并不是错误,而是更新请求:

@RestController
@Slf4j
public class InventoryController {private final PlayerRepository playerRepository;@Autowiredpublic InventoryController(PlayerRepository playerRepository) {this.playerRepository = playerRepository;}@RequestMapping(value = "/player/{playerId}/inventory/{invId}", method = PUT)@Transactionalpublic void addSword(@PathVariable UUID playerId, @PathVariable UUID invId) {playerRepository.findOne(playerId).addSwordWithId(invId);}}interface PlayerRepository extends JpaRepository<Player, UUID> {}@lombok.Data
@lombok.AllArgsConstructor
@lombok.NoArgsConstructor
@Entity
class Sword {@Id@Convert(converter = UuidConverter.class)UUID id;int strength;@Overridepublic boolean equals(Object o) {if (this == o) return true;if (!(o instanceof Sword)) return false;Sword sword = (Sword) o;return id.equals(sword.id);}@Overridepublic int hashCode() {return id.hashCode();}
}@Data
@Entity
class Player {@Id@Convert(converter = UuidConverter.class)UUID id = UUID.randomUUID();@OneToMany(cascade = ALL, fetch = EAGER)@JoinColumn(name="player_id")Set<Sword> swords = new HashSet<>();public Player addSwordWithId(UUID id) {swords.add(new Sword(id, 100));return this;}}

上面的代码片段中很少有快捷方式,例如直接将存储库注入到控制器,以及使用@Transactional注释。 但是你明白了。 还要注意,假设没有完全同时插入两个具有相同UUID的剑,此代码相当乐观。 否则将发生约束违例异常。

旁注1:我在控制器和JPA模型中都使用UUID类型。 开箱即用不支持它们,对于JPA,您需要自定义转换器:

public class UuidConverter implements AttributeConverter<UUID, String> {@Overridepublic String convertToDatabaseColumn(UUID attribute) {return attribute.toString();}@Overridepublic UUID convertToEntityAttribute(String dbData) {return UUID.fromString(dbData);}
}

对于Spring MVC同样(仅单向):

@Bean
GenericConverter uuidConverter() {return new GenericConverter() {@Overridepublic Set<ConvertiblePair> getConvertibleTypes() {return Collections.singleton(new ConvertiblePair(String.class, UUID.class));}@Overridepublic Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {return UUID.fromString(source.toString());}};
}

附注2:如果无法更改客户端,则可以通过将每个请求的哈希存储在服务器端来跟踪重复项。 这样,当多次发送同一请求(客户端重试)时,它将被忽略。 但是有时我们可能会有合法的用例,可以两次发送完全相同的请求(例如,在短时间内购买两把剑)。

时间耦合–客户不可用

您认为自己很聪明,但是仅重试就不够了。 首先,客户端可以在重新尝试失败的请求时死亡。 如果服务器严重损坏或关闭,重试可能要花费几分钟甚至几小时。 您不能仅仅因为下游依赖项之一关闭而就阻止了传入的HTTP请求-如果可能,您必须在后台异步处理此类请求。 但是,延长重试时间会增加客户端死亡或重新启动的可能性,这可能会使我们的请求松动。 想象一下,我们收到了优质的SMS,但是InventoryService目前处于关闭状态。 我们可以在第二,第二,第四等之后重试,但是如果InventoryService停机了几个小时又碰巧我们的服务也重新启动了怎么办? 我们只是失去了短信和剑从未被赋予玩家的机会。

解决此问题的方法是先保留未决请求,然后在后台处理它。 收到SMS消息后,我们几乎没有将玩家ID存储在名为“ pending_purchases数据库表中。 后台调度程序或事件唤醒异步线程,该线程将收集所有未完成的购买并将尝试将其发送到InventoryService (甚至可能以批处理方式?)每隔一分钟甚至一秒钟运行一次的周期性批处理线程,并收集所有未完成的请求将不可避免地导致延迟和不必要数据库流量。 因此,我打算使用Quartz调度程序,它将为每个待处理的请求调度重试作业:

@Slf4j
@RestController
class SmsController {private Scheduler scheduler;@Autowiredpublic SmsController(Scheduler scheduler) {this.scheduler = scheduler;}@RequestMapping(value = "/sms/{phoneNumber}", method = POST)public void handleSms(@PathVariable String phoneNumber) {phoneNumberToPlayer(phoneNumber).map(Player::getId).map(this::purchaseSword).orElseThrow(() -> new IllegalArgumentException("Unknown player for phone number " + phoneNumber));}private UUID purchaseSword(UUID playerId) {UUID swordId = UUID.randomUUID();InventoryAddJob.scheduleOn(scheduler, Duration.ZERO, playerId, swordId);return swordId;}//...}

和工作本身:

@Slf4j
public class InventoryAddJob implements Job {@Autowired private RestOperations restOperations;@lombok.Setter private UUID invId;@lombok.Setter private UUID playerId;@Overridepublic void execute(JobExecutionContext context) throws JobExecutionException {try {tryPurchase();} catch (Exception e) {Duration delay = Duration.ofSeconds(5);log.error("Can't add to inventory, will retry in {}", delay, e);scheduleOn(context.getScheduler(), delay, playerId, invId);}}private void tryPurchase() {restOperations.put(/*...*/);}public static void scheduleOn(Scheduler scheduler, Duration delay, UUID playerId, UUID invId) {try {JobDetail job = newJob().ofType(InventoryAddJob.class).usingJobData("playerId", playerId.toString()).usingJobData("invId", invId.toString()).build();Date runTimestamp = Date.from(Instant.now().plus(delay));Trigger trigger = newTrigger().startAt(runTimestamp).build();scheduler.scheduleJob(job, trigger);} catch (SchedulerException e) {throw new RuntimeException(e);}}}

每当我们收到优质的SMS时,我们都会安排异步作业立即执行。 Quartz将负责持久性(如果应用程序关闭,则在重新启动后将尽快执行作业)。 而且,如果该特定实例出现故障,则另一个可以承担这项工作–或我们可以形成集群并在它们之间进行负载平衡请求:一个实例接收SMS,另一个实例在InventoryService请求剑。 显然,如果HTTP调用失败,则稍后重新安排重试时间,一切都是事务性的且具有故障保护功能。 在实际代码中,您可能会添加最大重试限制以及指数延迟,但是您了解了。

时间耦合–客户端和服务器无法满足

我们为正确执行重试所做的努力是客户端和服务器之间模糊的时间耦合的标志-它们必须同时生活在一起。 从技术上讲,这不是必需的。 想象玩家在48小时内向客户服务发送一封包含订单的电子邮件,他们手动更改了库存。 同样的情况也适用于我们的情况,但是用某种消息代理(例如JMS)替换电子邮件服务器:

@Bean
ActiveMQConnectionFactory activeMQConnectionFactory() {return new ActiveMQConnectionFactory("tcp://localhost:61616");
}@Bean
JmsTemplate jmsTemplate(ConnectionFactory connectionFactory) {return new JmsTemplate(connectionFactory);
}

建立ActiveMQ连接后,我们可以简单地将购买请求发送给经纪人:

private UUID purchaseSword(UUID playerId) {final Sword sword = new Sword(playerId);jmsTemplate.send("purchases", session -> {TextMessage textMessage = session.createTextMessage();textMessage.setText(sword.toJson());return textMessage;});return sword.getUuid();
}

通过用JMS主题上的消息传递完全替换同步请求-响应协议,我们暂时将客户端与服务器分离。 他们不再需要同时生活。 此外,不止一个生产者和消费者可以相互交流。 例如,您可以有多个购买渠道,更重要的是:多个利益相关方,而不仅仅是InventoryService 。 更好的是,如果您使用像Kafka这样的专用消息传递系统, 则从技术上讲,您可以保留数天(数月)的消息而不会降低性能。 好处是,如果将另一个购买事件的使用者添加到InventoryService旁边的系统,它将立即收到许多历史数据。 而且,现在您的应用程序在时间上与代理耦合,因此,由于Kafka是分布式和复制的,因此在这种情况下它可以更好地工作。

异步消息传递的缺点

在ReST,SOAP或任何形式的RPC中使用的同步数据交换很容易理解和实现。 从延迟的角度来看,谁在乎这种抽象会疯狂地泄漏(本地方法调用通常比远程方法快几个数量级,更不用说它可能因本地未知的众多原因而失败),因此开发起来很快。 消息传递的一个真正警告是反馈渠道。 因为没有响应管道,所以您可以不再只是“ 发送 ”(“ return ”)消息而已。 您要么需要带有一些相关性ID的响应队列,要么需要每个请求临时的一次性响应队列。 我们还撒谎了一点,声称在两个系统之间放置消息代理可修复时间耦合。 确实如此,但是现在我们耦合到了消息传递总线,它也可能会崩溃,特别是因为它通常处于高负载下,有时无法正确复制。

本文展示了在分布式系统中提供保证的一些挑战和部分解决方案。 但是,归根结底,请记住,“ 仅一次 ”语义几乎不可能轻松实现,因此仔细检查您确实需要它们。

翻译自: https://www.javacodegeeks.com/2015/02/journey-to-idempotency-and-temporal-decoupling.html

解耦,未解耦的区别

解耦,未解耦的区别_幂等与时间解耦之旅相关推荐

  1. 解耦,未解耦的区别_受干净架构启发的解耦php架构

    解耦,未解耦的区别 This article would not be possible without the help of Rodrigo Jardim da Fonseca, Edison J ...

  2. java未将对象引用设置_未将对象引用到实例怎么解决_常见问题解析,java

    PPT导入GIF图无法播放_常见问题解析 PPT导入GIF图无法播放,是因为PPT保存时会自动压缩图片,所以导致GIF图片动画效果就失效,解决方法进入图片工具栏,在"压缩图片"的& ...

  3. 像素/厘米与像素/英寸区别_像素/体素艺术入门指南

    像素/厘米与像素/英寸区别 Here's some resources I've found helpful so you can start learning pixel or voxel art ...

  4. zoho邮箱收费和免费区别_您需要了解有关适用于ios和android的新zoho vault移动应用程序的所有信息...

    zoho邮箱收费和免费区别 The secret phrase is the true standard of computerized validation and access. Any run ...

  5. 两个质数互质是_两个质数一定是互质数_互质数和质数的区别_分解质因数的方法_互为质数和互质数...

    宜城教育资源网www.ychedu.com两个质数一定是互质数_互质数和质数的区别_分解质因数的方法_互为质数和互质数质数,互质数,分解质因数,合数一个数只有1和它本身两个约数,这样的数叫做质数.一个 ...

  6. CIF、DCIF、D1区别_昂首阔步_百度空间

    CIF.DCIF.D1区别_昂首阔步_百度空间 CIF.DCIF.D1区别 关于视频监控分辨率CIF.DCIF.D1格式的介绍 什么是D1? 做闭路电视监控系统这一行久了,大家都以为D1是硬盘录像机显 ...

  7. debian uefi legacy 区别_电脑硬盘格式有Legacy与UEFI,选择哪一个好?千万别选错了!...

    硬盘是电脑中重要的硬件之一,它的作用就是存储系统与其它重要文件,硬盘的好坏直接决定了电脑的使用体验,再给硬盘安装系统的时候,我们都会给硬盘选择系统引导方式,众所周知,系统的引导方式分为UEFI与Leg ...

  8. 交换机虚拟化和堆叠的区别_企业网络基础EI CCIE设计部署如何理解三层交换和路由器的区别...

    点上方蓝字关注公众号,坚持每天技术打卡 学网络,就在IE-LAB 国内最著名的高端网络工程师培养基地 快速了解技术难点网络工程师面试常见问答三层交换和路由器的区别 学习了很长时间的网络技术,但是三层交 ...

  9. 基于51单片机十字路口交通灯_只显示绿灯时间+黄灯5s

    基于51单片机十字路口交通灯_只显示绿灯时间+黄灯5s (程序+仿真+参考报告) 仿真:proteus 7.8 程序编译器:keil 4/keil 5 编程语言:C语言 编号J011 目录标题 基于5 ...

最新文章

  1. QGC地面站参数调节
  2. java遍历数据库的东西_java遍历读取整个redis数据库实例
  3. AI如果耍起了心眼,人类就像个白痴......
  4. 织梦调用css的标签,织梦dede常用的调用标签(个人总结)
  5. GetDiskFreeSpace 和 GetDiskFreeSpaceEx
  6. ultraedit 运行的是试用模式_Wings面向企业级的单元测试用例自动编码引擎
  7. php实战https请求,用php发https请求
  8. 【渝粤题库】陕西师范大学800007 地理信息系统
  9. 【Python学习】 - 关于DataFrame中的applymap函数 和 Series中的map函数
  10. centos7:塔建pure_ftpd虚拟用户
  11. c语言线性顺序表,C语言程序——线性顺序表.doc
  12. 想要转人工智能,程序员该如何学习?
  13. 利用微搭低代码实现公差申请
  14. 配置Visual Studio Code用作51单片机C51代码编辑器,替代KeilC编辑代码事半功倍!
  15. 无公式搞懂GMSK调制原理,附详细注释的matlab GMSK调制解调原理仿真源码
  16. NPOI Word 原有表格增加一行
  17. Prioritizing Web Usability
  18. 重磅!罗振宇跨年演讲:扎心5问
  19. nodeJS学习笔记-重点难点
  20. Shell 脚本--------正则表达式的认知

热门文章

  1. jzoj5354-导弹拦截【dp,最大匹配,最少路径覆盖】
  2. Educational Codeforces Round 95 (Rated for Div. 2)
  3. BZOJ5358: [Lydsy1805月赛]口算训练
  4. 2017西安交大ACM小学期 文本查找[AC自动机]
  5. 2017西安交大ACM小学期 刷墙[折半枚举+异或]
  6. 汇编语言(三十五)之输入字符串以$结束然后输出字母个数
  7. JavaScript学习总结(七)——JavaScript函数(function)
  8. Oracle入门(六)之用户操作
  9. 利用老毛头启动盘重装win7
  10. 2017蓝桥杯省赛---java---B---3(承压计算)