开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

IO模型之BIO、NIO详讲(通俗易懂) IO模型

发表于 2021-10-31

IO模型

简单地理解,IO模型就是用什么样的通道进行数据的发送和接收,IO模型很大程度上决定了程序通信的性能

java共支持三种网络编程模型:BIO、NIO、AIO

BIO

同步并阻塞(blockingIO),服务端实现模式为一个连接一个线程,即一个客户端请求服务端就需要启动一个线程来进行处理,并且这个线程在读数据时会堵塞,具体跟accept()和read()方法有关。下面直接上代码讲解

注:所谓的同步,就是指所有操作都由当前线程来处理,亲力亲为。而异步呢,是指将操作交由其它线程来处理,其它线程处理完毕之后,返回处理的结果

代码

服务端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
csharp复制代码public class BIOserver {
   public static void main(String[] args) throws IOException {
       ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 1, TimeUnit.SECONDS,new ArrayBlockingQueue<>(8));
       // 绑定端口
       ServerSocket serverSocket = new ServerSocket(6666);
       while(true){
           // 监听端口
           System.out.println("线程"+ Thread.currentThread().getName()+"正在监听端口6666......");
           Socket accept = serverSocket.accept();
           System.out.println("连接已建立!");
           // 一监听到请求就启动线程进行处理
           threadPoolExecutor.execute(new Runnable() {
               @Override
               public void run() {
                   System.out.println("启动线程"+Thread.currentThread().getName()+"处理请求");
                   try {
                       sockerHandler(accept);
                  } catch (IOException e) {
                       e.printStackTrace();
                  }
              }
          });
      }
  }
​
   public  static void sockerHandler(Socket socket) throws IOException {
       byte[] bytes = new byte[1024];
       // 从管理中获得流
       System.out.println("线程"+Thread.currentThread().getName()+"正在等待连接发送请求......");
       InputStream inputStream = socket.getInputStream();
       while(true) {
           // 将流写到bytes里,打印出来
           int read = inputStream.read(bytes);
           if (read != -1) System.out.println(new String(bytes, 0, read));
           else {
               break;
          }
      }
  }
}

客户端代码

1
2
3
4
5
6
7
8
9
ini复制代码public class BIOclient {
   public static void main(String[] args) throws IOException {
       Socket socket = new Socket();
       socket.connect(new InetSocketAddress(6666));
       OutputStream stream = socket.getOutputStream();
       String str="Hello,world!";
       stream.write(str.getBytes());
  }
}

运行

先启动服务端,可以看到,服务端启动后就阻塞在accept()方法上,等待客户端的连接。如果没有连接,则会一直阻塞

image-20211031210839784.png

紧接着,我们打上断点,启动客户端来建立与服务端的连接。如下,可以看到,建立完连接之后,服务端阻塞在read()方法上,等待读取数据。如果客户端没有发送数据,则thread-1会一直阻塞在read()上

image-20211031212403663.png

接着让客户端断点往下走,则服务端读取到数据,打印出来

image-20211031212506956.png

我们再启动一个客户端,继续上面的流程。可以看到,这时候创建了一个新的线程thread-2,如果我们启动100个客户端,那么服务端自然也就要创建100个线程来处理客户端的请求咯

image-20211031212725695.png

分析

从上面的分析可以看到,在accept()等待连接时会阻塞,在read()等待读取数据时也会阻塞,所以我们必须对每个客户端创建一个线程,否则accept()的阻塞会影响到对其它客户端的读操作。

一个连接就要创建一个线程,10万个连接就要创建10万个线程,这样显然是不合理的,因为服务器没有那么多的资源来分配给线程。另一个方面,如果建立连接之后,线程没有数据可读,那么就会阻塞在read操作上,造成线程资源的浪费。

image-20210603205309802.png
其实整个问题的根源就在于accept()和read()的阻塞,也就是所谓优化点,接下来我们引入NIO

NIO(未完待续)

NIO,即non-blocking IO,同步非阻塞IO,是指从JDK1.4开始,java提供的一系列改进的输入/输出新特性,NIO也称为new IO。NIO的三大核心部分分别为:Channel、Buffer和Selector

流

对于所谓的IO流,刚开始学习java IO时我一直都无法理解,为什么要叫IO流呢?后面不断深入学习才开始理解

要理解IO流,我们可以通过水流来类比。在生活中,水流通常是从供水站经过水管输送到居民家中,我们可以把IO流比作水管,传输的数据比作水管中的水,数据源和数据接收方则分别对应供水站和居民。

image-20211031221638725.png

而所谓的输入、输出流呢,其实都是相对于当前主机的内存而言的。如果把数据从磁盘或网卡输入到内存,那么就要使用输入流;如果把数据从内存输出到网卡或者磁盘,那么就要使用输出流。即,输入内存用输入流,输出内存用输出流!

image-20211031222123520.png

image-20211031222210254.png

明天继续更新……

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

怎么让kafka变得可靠 前言 Broker 生产者 消费者

发表于 2021-10-31

前言

数据投递可靠性指当给生产者回复“投递成功”时,消息一定不会丢失

kafka可以应用在多种场合里,有些场合注重更高的可靠性,例如银行业务,有些则不然,例如记录日志。因此kafka提供各种配置来满足不同程度的可靠性需求

可靠性不是单一组件的事,而是整个系统层面的概念,因此要保证kafka的可靠性,需要集群运维人员,应用开发人员一起努力,才能构建一个可靠的系统

当然原则上基础服务的可靠性尽量由基础架构团队保障,业务团队更专注业务本身

下面依次从broker,生产者和消费者来介绍如何保证kafka的可靠性

Broker

kafka中每个topic中,每个分区的多副本机制是保障数据可靠性的核心。试想如果只有一个副本,则其发生崩溃时就是造成数据丢失,而如果有多个副本,则只要有一个副本可用,数据就不会丢失

复制系数

复制系数(replication.factor)即每个分区的副本个数,默认是3。

默认值一般够用,当然也可以往上加,复制系数越高代表更高的可靠性与可用性,同时也意味着更高的存储成本,这是可靠性与硬件成本之间的权衡

如果不需要太高的可靠性,可将该值设为1,即每个分区只有一个副本。这样不仅影响可靠性,同时也会影响可用性:当唯一的副本所在broker宕机时,由于没有其他副本出来顶上,会造成一段时间内不可用

多个副本之间的分布情况是值得考虑的点,kafka默认会把分区的每个副本尽量分配到不同的broker上,这样能保证实例层面的可靠性

在部署时可以将broker分布在不同的机架上,并使用 broker.rack参数来配置机架名。kafka能保证分区的每个副本尽量在不同的机架上,进一步提高数据可靠性

因此比较好的分区分配方式如下:

流程图3 (1).jpg

其实可靠性还可以往上加,在机房,城市,国家层面容灾,不过这就是通用的容灾手段,本文不进行考虑

最少同步副本

如果一个分区有3个副本,但可能只有一个同步副本

同步副本:最近向主副本获取过消息,且获取的消息是最新的,则称该副本是同步的。一个副本如果不同步,可能因为已经崩溃,和主副本无联系,或还有联系,但请求的数据落后较多

可通过参数 min.insync.replicas设置最小同步副本。例如对于一个3个副本的分区,若该参数设为2,那至少存在2个同步副本,才能向该分区写入数据,否则生产者会受到 NotEnoughReplicasException 异常

那这个参数有啥用呢?假设最小同步副本为1,即容许只有1个同步副本。

消息只有在被写入所有同步副本时,才被认为是已提交的,这里的“所有副本”只有1个。若此时该副本所在的broker宕机,且还没复制到其他副本(其他副本不是同步副本,可能没有追上主副本最新数据),就会发生数据丢失

即当broker给生产者回复了“提交成功”,但数据还是丢了,丧失数据可靠性

如果设为2或3,则将提升数据可靠性,同时会带来响应时间增大的缺点

生产者

我们把broker端配置得很可靠,但如果没有生产者和消费者在可靠性方面进行相应的配置和处理,数据也会变得不可靠

在发送者端,我们需要做的是

  • 正确配置发送确认
  • 正确处理发送错误

发送确认

发送确认(acks)有以下三种配置选项

  • acks=0
+ 只要生产者通过**网络**将消息发出去,则认为发送成功。在发出去之前的操作(序列化,选择分分区,网卡故障)如果失败会提示到生产者端,但如果遇到分区离线等broker端的异常,发送端无法感知,也就造成了数据丢失
+ 该模式一般用来压测性能,生成环境中不建议使用
  • acks=1
+ 将消息写入分区的主副本时,才会返回写入成功或失败。该模式比acks=0有更高的可靠性,但也有概率发生数据丢失,即主副本没来得及将数据同步到其他副本前发生崩溃
  • acks=all
+ 将消息写入该分区的所有同步副本时,才会返回写入成功或失败。至于写入多少个副本,根据该分区中当前有多少个同步副本决定,可结合broker端的最小同步副本使用,即至少写入 min.insync.replicas个副本,broker端才给生产者端响应“写入成功”
+ 该模式能最大限度保障可靠性,但会带来吞吐量降低的缺点,需根据业务场景对可靠性的要求进行选择

处理错误

生产者发送消息的错误分为两类,可重试和不可重试

  • 可重试:
+ 例如正在选举主分区的 LEADER\_NOT\_AVAILABLE(主分区不可用)错误,或网络抖动导致的连接失败错误,该类错误可以通过重试来解决
+ 需要注意总重试时间((重试次数-1)\* 每次重试直接的间隔)要比选举过程长,否则生产者会过早放弃重试
  • 不可重试:
+ 有些错误无法通过重试来解决,即无论重试多少次都会失败,例如消息过大错误

可通过retries配置重试次数,通过retry.backoff.ms配置重试间隔,生产者客户端会自动处理可重试错误,而程序代码中需要处理不可重试错误,和超过重试次数上限的错误,一般是记录到日志或保存到数据库里,依据业务判断是直接忽略还是人工接入处理

需要注意的是,重试可能带来消息重复的问题,例如:生产者因为网络问题没有收到 broker 的确认,但实际上消息已经写入成功,生产者却不知道,认为网络或broker出现了临时故障,就重试发送该消息。此时消费者端需要考虑消息处理的幂等性

消费者

作为消费者来说,需要做到不漏处理消息,在此基础上尽量不重复处理消息

因此消费者需要跟踪哪些消息是处理过的,哪些还没有处理

消费者的处理流程为:从broker获取一批数据,进行处理,提交偏移量

为什么需要提交偏移量?如果一个消费者退出(可能是宕机或重启),另一个消费者来接手(或“再均衡”)该分区时,需要知道上一个消费者已经消费到哪了。这里“另一个消费者”也可能是重启后的自己

因此这里提交偏移量的时间就比较关键,如果消费者在没有处理完消息前,就提交了偏移量,就会造成数据丢失,如果提交偏移量比较保守,会造成重复处理消息

重复消费:

流程图1.jpg

消息丢失:、

流程图2.jpg

提交偏移量

提交偏移量有自动·和手动两种方式:

  • 自动提交偏移量

可通过配置 enable.auto.commit=true来开启自动提交偏移量

其提交时机为,每次拉去消息时,且距离上次提交偏移量经过了提交间隔(auto.commit.interval.ms)的时间,就会自动提交上一次拉去的最大偏移量

自动提交的优缺点为:

  • 优点:
+ 方便,开发者可以少考虑偏移量提交
+ 若程序保证在新一次拉去之前的消息都被处理完毕,则自动提交能保证消息不会丢失
  • 缺点:
+ 如果提交间隔过长,可能导致实际处理的偏移量比提交的偏移量大很多,若发生消费者切换,会导致消息重复处理
+ 不能异步处理消息(破坏**在新一次拉去之前的消息都被处理完毕**条件),否则会造成数据丢失
  • 手动提交偏移量

若希望更多地控制提交时机,则需要手动控制偏移量提交,若在每次处理完一批消息都就提交,可以减少处理重复消息的可能性,但无法避免(若刚处理完就宕机,则一定会重复处理该条消息),并且会损耗一定的吞吐量

仅处理一次

不管是生产者端的重试,还是消费者端提交偏移量,都会导致消息被重复处理,若业务上消息重复处理了也没事,则可以忽略,否则可以在消息里加上唯一id标识,处理时根据该id进行幂等性校验

总结

本文从broker,生产者,消费者的角度介绍如何提高kafka的可靠性:

  1. broker端需要配置合理的复制系数,最小同步副本数越多,数据可靠性越强
  2. 生产者端需要配置可靠的发送确认,以及正确地错误处理
  3. 消费者端需要保证提交偏移量时,该批次消息已经处理完毕

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

分布式系统,这样保证幂等性,很简单嘛

发表于 2021-10-31

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

前言

今天和大家分享一些关于分布式中幂等性的知识点,这属于分布式中的基础和重点,学习完本文可以学习以下知识:

  • 幂等性是什么
  • 为什么要保证幂等性
  • 如何保证幂等性

幂等性是什么?

举一个具体的例子,比如在秒杀系统中的,对一个秒杀商品,多次发起下单按钮,对于下单接口来说,该接口必须保证订单记录只能插入一条,库存只能扣减一次,这就是幂等性。

为什么保证幂等性?

对于单机部署的系统来说,我们可以通过在内存中添加一个map,当接收到一个客户端请求后,在map中增加一条该记录的key,当下次该id又再次请求时,就去map中进行查询,如果查询到key已经存在,就直接返回。

对于分布式来说,因为系统分别部署在不同的机器上,无法使用上述方式来解决,但是这个问题又必须考虑,否则后续会导致很多的问题,比如遇到网络波动,前端没有做防抖等都会造成接口被重复请求的情况存在。

image.png

比如上述部署了3台机器,每台机器部署一个系统A的实例,当用户在客户端发起一次请求时,因为网络阻塞,虽然后台已经接收到请求,但是前端返回请求失败,此时用户再次发起提交请求,这就导致多了一次提交请求,如果不加以控制,会生成多条订单信息。

之前花哥也是遇到一个可能出现幂等性的场景,对结果微信支付的小伙伴应该了解,当我们调用微信统一支付,并完成支付时,微信平台会进行回调,这时会有一种情况,如果我们收到微信回调,但是没有返回成功给微信端,微信平台会继续回到业务平台,此时我们就要保证接口的幂等性。

如何保证幂等性?

分布式系统中,可以通过下面三个方式来保证幂等性:

  1. 在每个请求上添加唯一标识,比如订单支付时,增加一个订单id,同一个订单id只能处理一次;
  2. 在数据库中添加记录,比如微信回调后,修改订单状态,或者增加记录流水号,这样微信每次回调时,接口先进行查库判断,如果已经存在记录,就直接返回;
  1. 增加一个redis中间件,其实这个类似单机系统中的map,我们每次处理完一个请求后,将该请求的唯一id记录在redis中,下次该请求再来请求时,因为redis已经存在该记录的key,就不再处理。

写在最后

今天和大家分享了一些常见的保证分布式幂等性的解决方案,当然还有很多种其他方式,根据具体的业务场景选择合适的方案。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

RuoYi-Vue 前后端分离版代码浅析-防重复提交 前言

发表于 2021-10-31

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

前言

本节介绍RuoYi-Vue的ruoyi-admin模块中的上传下载模块SysConfigController 部分的代码,这个接口主要用来展示系统参数模块的情况。这个模块就是普通的增删改查,其中比较有意思的是防止重复提交的这个注解。

RepeatSubmit 注解

日常开发中我们经常会遇到前端多次请求同一个接口的问题,比如前端没有做遮罩层,用户点击了多次按钮。全指望着用户来等待也并不靠谱,如果各个接口自己进行防止重复提交的逻辑又显得不是十分优雅了,所以,我们就将对应的逻辑抽出来形成一个注解,方便我们的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码package com.ruoyi.common.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* 自定义注解防止表单重复提交
*
* @author ruoyi
*
*/
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit
{
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
public int interval() default 5000;

/**
* 提示消息
*/
public String message() default "不允许重复提交,请稍后再试";
}

通过这块的代码历史,我们可以看到间隔时间和提示消息都是后加的,定义这两个参数有助于我们定制各个接口不同的重复提交的逻辑,有的接口耗时就是很长,就需要将间隔时间定的长一些,有的接口需要的提示消息需要修改,我们也可以通过这个message来进行自主定义。

具体实现方法

首先我们定义了一个抽象类RepeatSubmitInterceptor,对preHandle方法进行了定义,判断方法上是否存在RepeatSubmit 注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
java复制代码
package com.ruoyi.framework.interceptor;

/**
* 防止重复提交拦截器
*
* @author ruoyi
*/
@Component
public abstract class RepeatSubmitInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
if (annotation != null) {
if (this.isRepeatSubmit(request, annotation)) {
AjaxResult ajaxResult = AjaxResult.error(annotation.message());
ServletUtils.renderString(response, JSONObject.toJSONString(ajaxResult));
return false;
}
}
return true;
} else {
return super.preHandle(request, response, handler);
}
}

/**
* 验证是否重复提交由子类实现具体的防重复提交的规则
*
* @param request
* @return
* @throws Exception
*/
public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation);
}

需要注意到这里的

1
java复制代码   ServletUtils.renderString

直接将提示信息放入response中返回了。
在子类中我们要实现对应的父类中的isRepeatSubmit方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
java复制代码package com.ruoyi.framework.interceptor.impl;



/**
* 判断请求url和数据是否和上一次相同,
* 如果和上次相同,则是重复提交表单。 有效时间为10秒内。
*
* @author ruoyi
*/
@Component
public class SameUrlDataInterceptor extends RepeatSubmitInterceptor {
public final String REPEAT_PARAMS = "repeatParams";

public final String REPEAT_TIME = "repeatTime";

// 令牌自定义标识
@Value("${token.header}")
private String header;

@Autowired
private RedisCache redisCache;

@SuppressWarnings("unchecked")
@Override
public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation) {
String nowParams = "";
if (request instanceof RepeatedlyRequestWrapper) {
RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
nowParams = HttpHelper.getBodyString(repeatedlyRequest);
}

// body参数为空,获取Parameter的数据
if (StringUtils.isEmpty(nowParams)) {
nowParams = JSONObject.toJSONString(request.getParameterMap());
}
Map<String, Object> nowDataMap = new HashMap<String, Object>();
nowDataMap.put(REPEAT_PARAMS, nowParams);
nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());

// 请求地址(作为存放cache的key值)
String url = request.getRequestURI();

// 唯一值(没有消息头则使用请求地址)
String submitKey = request.getHeader(header);
if (StringUtils.isEmpty(submitKey)) {
submitKey = url;
}

// 唯一标识(指定key + 消息头)
String cacheRepeatKey = Constants.REPEAT_SUBMIT_KEY + submitKey;

Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
if (sessionObj != null) {
Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
if (sessionMap.containsKey(url)) {
Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);
if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap, annotation.interval())) {
return true;
}
}
}
Map<String, Object> cacheMap = new HashMap<String, Object>();
cacheMap.put(url, nowDataMap);
redisCache.setCacheObject(cacheRepeatKey, cacheMap, annotation.interval(), TimeUnit.MILLISECONDS);
return false;
}

/**
* 判断参数是否相同
*/
private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap) {
String nowParams = (String) nowMap.get(REPEAT_PARAMS);
String preParams = (String) preMap.get(REPEAT_PARAMS);
return nowParams.equals(preParams);
}

/**
* 判断两次间隔时间
*/
private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap, int interval) {
long time1 = (Long) nowMap.get(REPEAT_TIME);
long time2 = (Long) preMap.get(REPEAT_TIME);
if ((time1 - time2) < interval) {
return true;
}
return false;
}
}

这里面比较有意思的是

1
2
3
4
5
java复制代码// 唯一值(没有消息头则使用请求地址)
String submitKey = request.getHeader(header);
if (StringUtils.isEmpty(submitKey)) {
submitKey = url;
}

这是后面进行的优化,为什么要进行这个操作呢,我觉得原因是有的时候并不是所有的请求都有token,这个时候如果我们不对对应的url进行拦截的话,那么他们就可以在未登录的情况下对某些无需登录却十分耗时的页面进行多次请求,而如果对url也进行了拦截,就不会有这个问题了。可以对这个url的访问次数进行限制了。

拦截规则需要注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Configuration
public class ResourcesConfig implements WebMvcConfigurer
{
@Autowired
private RepeatSubmitInterceptor repeatSubmitInterceptor;


/**
* 自定义拦截规则
*/
@Override
public void addInterceptors(InterceptorRegistry registry)
{
registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

LeetCode刷题之删除无效括号

发表于 2021-10-31

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

题目描述

给你一个由若干括号和字母组成的字符串 s ,删除最小数量的无效括号,使得输入的字符串有效。

返回所有可能的结果。答案可以按 任意顺序 返回。

示例1 :

1
2
scss复制代码输入:s = "()())()"
输出:["(())()","()()()"]

示例2 :

1
2
scss复制代码输入:s = "(a)())()"
输出:["(a())()","(a)()()"]

示例3 :

1
2
ini复制代码输入:s = ")("
输出:[""]

思路:DFS+剪枝

  • 从左向右遍历字符串,只要出现右括号比左括号多的情况,可以删除前面任意一个右括号,进入下次递归。
  • 如果一直不出现右括号比左括号多的情况,说明右括号已经删除完毕,这时可能有多余的左括号
  • 从右向左遍历字符串,删除可能的多余左括号,与删除右括号逻辑一致
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
java复制代码class Solution {
public List<String> removeInvalidParentheses(String s) {
List<String> result = new ArrayList<>();
if (s.equals("()") || s.equals("")) {
result.add(s);
return result;
}

Deque<String> queue = new ArrayDeque<>();
queue.offer(s);
HashSet<String> set = new HashSet<>(); //用于存储裁剪后的元素,防止重复元素加入队列
boolean isFound = false; //判断是否找到了有效字符串

while (!queue.isEmpty()) { //队列不为空
for(String cur:queue){
if (isValid(cur)) {
result.add(cur);
isFound = true;
}
}
if (isFound) { //找到后不再进行裁剪
break;
}
//裁剪过
Deque<String> queue2 = new ArrayDeque<>();
for( String curr:queue) {
for (int i = 0; i < curr.length(); i++) {
if (curr.charAt(i) == '(' || curr.charAt(i) == ')') { //只对'('或')'进行裁剪
String str;
if (i == curr.length() - 1) {
str = curr.substring(0, curr.length() - 1);
} else {
str = curr.substring(0, i) + curr.substring(i + 1);
}
if (set.add(str)) { //如果集合中还未有该字符串
queue2.offer(str);
}
}
}
}
queue = queue2;
}

if (result.isEmpty()) {
result.add("");
}
return result;
}
private static boolean isValid(String s) {
int left = 0;
for (int i = 0; i < s.length(); i++) {
int curr = s.charAt(i);
if (curr == '(') {
left++;
} else if (curr == ')') {
if (left != 0) {
left--;
} else {
return false;
}
}
}
return left == 0;
}
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Go语言服务、请求、响应、错误码设计与实现 一般的请求响应格

发表于 2021-10-31

一般的请求响应格式

对于现在大多数系统来说,请求一般就是原始请求参数的JSON格式+HTTP头的Token,响应一般会带上错误码和错误信息,封装为【错误码,错误信息,数据】的JSON格式,请求和响应都会放在HTTP的Body。

对于错误码,常见的选择有数字,字母,和字母+数字这几种方式。

对于HTTP方法,常见的选择有统一POST,获取操作用GET+修改操作用POST,还有RESTful风格的GET、POST、DELETE、PUT等。

对于HTTP状态码,常见的使用方式有统一200,或者使用200、400、401、403、404、500等常见状态码。

对于HTTP头,一般会放置一些权限相关的信息,比如Token。

实现

我们的实现错误码选择字母格式,HTTP方法统一POST,请求参数都放在Body(Token等除外),HTTP状态码使用统一200,使用Gin框架实现。

Service格式

Service是业务逻辑实现的地方,在有异常体系的语言里,我们经常使用:服务名(请求) 响应 throws 异常这种结构。但是Go一般使用error表示错误,因此Go的服务结构为:服务名(请求) (响应, 错误)。

比如:登录服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
go复制代码package login

import (
"github.com/google/uuid"
)

type Service struct{}

func NewService() *Service {
return &Service{}
}

func (s *Service) Login(req *LoginReq) (*LoginRsp, error) {
if req.Username != "admin" || req.Password != "admin" {
return nil, ErrCodeInvalidParameterUsernameOrPassword
}

return &LoginRsp{
Token: uuid.New().String(),
}, nil
}

其中req和rsp都是简单的结构体:

1
2
3
4
5
6
7
8
9
10
go复制代码package login

type LoginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}

type LoginRsp struct {
Token string `json:"token"`
}

而ErrCodeInvalidParameterUsernameOrPassword是一个ErrCode类型的实例:

1
2
3
4
5
6
7
8
go复制代码package login

import "github.com/jiaxwu/go_service_req_rsp_errcode_demo/service/common"

var (
ErrCodeInvalidParameterUsernameOrPassword = common.NewErrCode("InvalidParameterUsernameOrPassword",
"The username or password is not valid.", "账号或密码错误")
)

这里可以直接返回错误码是因为错误码实现了error接口,下面给出ErrCode的定义。

错误码实现

ErrCode使用了三个字段,错误码、错误信息(给开发者看)和建议(给用户看)。这里ErrCode实现了error接口,因此我们才可以把它当成error类型使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
go复制代码package common

import (
"fmt"
)

var (
ErrCodeOK = NewErrCode("OK", "", "")

ErrCodeInvalidParameter = NewErrCode("InvalidParameter", "the required parameter is not validate",
"非法参数")

ErrCodeForbidden = NewErrCode("Forbidden", "forbidden", "无权访问")

ErrCodeNotFound = NewErrCode("NotFound", "the request resource not found", "找不到要访问的资源")

ErrCodeInternalError = NewErrCode("InternalError",
"the request processing has failed due to some unknown error", "给您带来的不便,深感抱歉,请稍后再试")
)

// ErrCode 错误码
type ErrCode struct {
Code string `json:"Code"`
Msg string `json:"Msg"`
Advice string `json:"Advice"`
}

func (e *ErrCode) Error() string {
return fmt.Sprintf("code: %s, msg: %s, advice: %s", e.Code, e.Msg, e.Advice)
}

// NewErrCode 新建一个错误码
func NewErrCode(code, msg, advice string) *ErrCode {
return &ErrCode{
Code: code,
Msg: msg,
Advice: advice,
}
}

这里在最上面定义了一些全局通用的错误码,这样不用每个服务都重复定义这些错误码。

HTTP响应

对于HTTP响应,我们不仅需要带上Service的结果,还需要带上HTTP状态码,错误码,对用户的提示信息。为了简化操作,这里封装了几个工具函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
go复制代码package common

import (
"github.com/gin-gonic/gin"
"net/http"
)

// Rsp 响应
type Rsp struct {
Code string `json:"Code,omitempty"` // 错误码
Msg string `json:"Msg,omitempty"` // 消息
Data interface{} `json:"Data,omitempty"` // 数据
}

// SuccessRsp 成功响应
func SuccessRsp(data interface{}) *Rsp {
return rsp(ErrCodeOK, data)
}

// FailureRsp 失败响应
func FailureRsp(err error) *Rsp {
errCode, ok := err.(*ErrCode)
if !ok {
errCode = ErrCodeInternalError
}
return rsp(errCode, nil)
}

// Success 请求成功
func Success(c *gin.Context, data interface{}) {
ginRsp(c, SuccessRsp(data))
}

// Failure 请求失败
func Failure(c *gin.Context, err error) {
ginRsp(c, FailureRsp(err))
}

// gin响应
func ginRsp(c *gin.Context, rsp *Rsp) {
c.JSON(http.StatusOK, rsp)
}

// 响应
func rsp(errCode *ErrCode, data interface{}) *Rsp {
return &Rsp{
Code: errCode.Code,
Msg: errCode.Advice,
Data: data,
}
}

Rsp的Msg使用了advice,因为这是给用户看的。

Gin HandlerFunc包装

对于一个服务,在使用Gin框架时,如果我们想把它暴露出去,需要编写一个HandlerFunc函数,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
go复制代码func LoginHandler(c *gin.Context) {
// 参数绑定
var req LoginReq
if err := c.ShouldBindJSON(&req); err != nil {
result.Failure(c, common.ErrCodeInvalidParameter)
return
}

// 调用服务
service := NewService()
rsp, err := service.Login(&req)

// 结果处理
if err != nil {
common.Failure(c, err)
return
}
common.Success(c, rsp)
}

但是由于我们的服务、请求、响应、错误码、状态码结构都是统一的,对于不同服务的handler,代码除了在请求参数类型不同外,其余都是一样的,这会导致handler的代码非常的冗余,因此我们利用反射机制对Service进行简单的包装,消除对handler的编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
go复制代码package wrap

import (
"github.com/gin-gonic/gin"
"github.com/jiaxwu/go_service_req_rsp_errcode_demo/service/common"
"reflect"
)

func WrapService(service interface{}) func(*gin.Context) {
return func(c *gin.Context) {
// 参数绑定
s := reflect.TypeOf(service)
reqPointType := s.In(0)
reqStructType := reqPointType.Elem()
req := reflect.New(reqStructType)
if err := c.ShouldBindJSON(req.Interface()); err != nil {
common.Failure(c, common.ErrCodeInvalidParameter)
return
}

// 调用服务
params := []reflect.Value{reflect.ValueOf(req.Interface())}
rets := reflect.ValueOf(service).Call(params)

// 结果处理
if !rets[1].IsNil() {
common.Failure(c, rets[1].Interface().(*common.ErrCode))
return
}
common.Success(c, rets[0].Interface())
}
}

这样,我们对外暴露服务只需要一行代码:

1
2
3
4
5
6
7
8
9
10
11
go复制代码package login

import (
"github.com/gin-gonic/gin"
"github.com/jiaxwu/go_service_req_rsp_errcode_demo/service/wrap"
)

// RegisterHandler 注册handler
func RegisterHandler(engine *gin.Engine, service *Service) {
engine.POST("/login", wrap.WrapService(service.Login))
}

还可以对wrapper进行扩展, 使其支持进行身份认证,选择是否有返回值(加入一个Config参数),自动注入multipart.Form,header等(使用Go的.(类型)操作对参数类型进行匹配)。

全部代码

Github:github.com/jiaxwu/go_s…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

RESTful API 设计规范

发表于 2021-10-31

你必须非常努力,才能看起来毫不费力!

微信搜索公众号[ 漫漫Coding路 ],一起From Zero To Hero !

前言

需求开发中设计了几个API接口,组长说我的设计不规范(手动狗头),那周末必须学习一波,防止下次被嘲讽。

简介

在主流公司的程序开发中,为了提高程序开发迭代的速度,基本都是前后端分离架构,而前端既包括网页、App、小程序等等,因此必须要有一个统一的规范用于约束前后端的通信,RESTful API则是目前比较成熟的API设计理论。

要想理解RESTful,就需要先明白REST。REST是 Roy T. Fielding 在其2000年的博士论文中提出的,是REpresentational State Transfer 词组的缩写,可以翻译为“表现层状态转移”,其实这个词组前面省略了个主语–“Resource”,加上主语后就是“资源表现层状态转移”。每个词都能看懂,连在一起不知道什么意思了有木有?

 Roy T. Fielding

  1. Resource(资源)

所谓资源,就是互联网上的一个实体。URI(Uniform Resource Identifier)的全称就是统一资源标识符,和我们这里的资源一个意思。一个资源可以是一段文字、一张图片、一段音频、一个服务。
2. 表现层(Representation)

“资源”是一种信息实体,它可以有多种外在表现形式。我们把”资源”具体呈现出来的形式,叫做它的”表现层”(Representation)。比如一篇文章,可以使用XML、JSON、HTML的形式呈现出来。
3. 状态转移(State Transfer)

访问一个网站,就代表了客户端和服务器的一个互动过程。在这个过程中,势必涉及到数据和状态的变化。互联网通信协议HTTP协议,是一个无状态协议,这意味着所有的状态都保存在服务器端。因此,如果客户端想要操作服务器,必须通过某种手段,让服务器端发生”状态转化”(State Transfer)。而这种转化是建立在表现层之上的,所以就是”表现层状态转化”。

上面我们介绍了REST的基本概念,那么服务REST规范的设计,我们就可以称为RESTful。接下来我们就来看一下RESTful API的设计规范。

协议

协议是最基本的设计,表示前后端通信规范,现阶段应该使用HTTPs协议。

域名

API 的根入口点应尽可能保持足够简单,这里有两个常见的例子:

  • api.example.com/* (子域名下)
  • example.com/api/* (主域名下)

域名应该考虑拓展性,如果不确定API后续是否会拓展,应该将其放在子域名下,这样可以保持一定的灵活性。

路径

路径又称为端点,表示API的具体地址。在路径的设计中,需遵守下列约定:

  • 命名必须全部小写
  • 资源(resource)的命名必须是名词,并且必须是复数形式
  • 如果要使用连字符,建议使用‘-’而不是‘_’,‘_’字符可能会在某些浏览器或屏幕中被部分遮挡或完全隐藏
  • 易读

命名必须全部小写和易读都无需解释,可以理解为规定,那么为什么命名必须是名词且需要复数形式呢?这是因为在RESTful中,主语是资源,资源肯定是名词,不能是动词。其次,一个资源往往对应数据库中一张表,表就是实体的集合,因此需要是复数形式。

下面是一些反例:

  • api.example.com/getUser
  • api.example.com/addUser

下面是一些正例:

  • api.example.com/zoos
  • api.example.com/zoos/animal…

HTTP动词

对于如何操作资源,有相应的HTTP动词对应,常见的动词有如下五个(括号里表示SQL对应的命令):

  • GET(SELECT):从服务器取出资源(一项或多项)
  • POST(CREATE):在服务器新建一个资源
  • PUT(UPDATE):在服务器更新资源(客户端提供改变后的完整资源)
  • PATCH(UPDATE):在服务器更新资源(客户端提供改变的属性)
  • DELETE(DELETE):从服务器删除资源

示例:

HTTP动词 路径 表述
GET /zoos 获取所有动物园信息
POST /zoos 新建一个动物园
GET /zoos/ID 获取指定动物园的信息
PUT /zoos/ID 更新指定动物园的信息(前端提供该动物园的全部信息)
PATCH /zoos/ID 更新某个指定动物园的信息(提供该动物园改动部分的信息)
DELETE /zoos/ID 删除某个动物园
GET /zoos/ID/animals 获取某个动物园里面的所有动物信息

过滤

如果数据量很大,服务器不可能将全部数据都返回给前端,因此前端需要提供一些参数进行过滤,用于分页展示或者排序等,下面是一些常见的参数:

  • ?limit=10:指定返回记录的数量
  • ?offset=10:指定返回记录的开始位置。
  • ?page=2&per_page=100:指定第几页,以及每页的记录数。
  • ?sortby=name&order=asc:指定返回结果按照哪个属性排序,以及排序顺序。
  • ?animal_type_id=1:指定筛选条件

HATEOAS

HATEOAS 是 Hypermedia As The Engine Of Application State 的缩写,从字面上理解是 “超媒体即应用状态引擎” 。其原则就是客户端与服务器的交互完全由API动态提供,客户端无需事先了解如何与服务器交互,即返回结果中提供链接,连向其他API方法,使得用户不查文档,也知道下一步应该做什么。

例如,若要处理订单与客户之间的关系,可以在订单的表示形式中包含链接,用于指定下单客户可以执行的操作(查看客户信息、查看订单信息、删除订单等操作)。rel表示这个API与当前网址的关系,href表示API的路径,title表示API的标题,type表示返回类型,action表示支持的操作类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
json复制代码{
"orderID":3,
"productID":2,
"quantity":4,
"orderValue":16.60,
"links":[
{
"rel":"customer",
"href":"https://adventure-works.com/customers/3",
"action":"GET",
"title":"get customer info",
"types":["text/xml","application/json"]
},
{
"rel":"self",
"href":"https://adventure-works.com/orders/3",
"action":"GET",
"title":"get order info",
"types":["text/xml","application/json"]
}]
}

Github的API就是这种设计,访问api.github.com会得到一个所有可用API的网址列表。

1
2
3
4
5
6
json复制代码{
"current_user_url": "https://api.github.com/user",
"emojis_url": "https://api.github.com/emojis",
"events_url": "https://api.github.com/events",
......
}

版本控制

API一直保持静态的可能性很小,随着业务需求变化,可能会添加新的资源,底层的数据结构可能也会有更改。在更新API提供新功能的同时,需要考虑对已使用该API用户的影响,因此需要保持向前兼容,这就引出了版本控制。主要的版本控制方法有如下几种:

  1. URI版本管理

每次修改 Web API 或更改资源的架构时,向每个资源的 URI 添加版本号。 以前存在的 URI 应像以前一样继续运行,并返回符合原始架构的资源。

1
2
text复制代码api.example.com/v1/*
api.example.com/v2/*

该方法的版本控制机制非常简单,但是随着 API 多次迭代,服务器需要支持多个版本的路由,增大了维护的成本。 此方案也增加了 HATEOAS 实现的复杂性,因为所有链接都需要在其 URI 中包括版本号。

  1. 查询字符串版本控制

不是提供多个 URI,而是通过在追加查询字符串的方式来指定版本,例如 https://adventure-works.com/customers/3?version=2。 如果 version 参数被较旧的客户端应用程序省略,则应默认为有意义的值(例如 1)。

此方法具有语义优势(即,同一资源始终从同一 URI 进行检索),但它依赖于代码处理请求以分析查询字符串并发送回相应的 HTTP 响应。 该方法也与 URI 版本控制机制一样,增加了实现 HATEOAS 的复杂性。

  1. 自定义请求标头进行版本控制

在请求的header中自定义版本控制选项。

1
2
http复制代码GET https://adventure-works.com/customers/3 HTTP/1.1
Custom-Header: api-version=1
  1. Accept标头进行版本控制

当客户端应用程序向 Web 服务器发送 HTTP GET 请求时,可以 Accept 标头规定它可以处理的内容的格式。 通常,Accept 标头的用途是客户端指定响应的正文应是 XML、JSON 或者其他可处理的的格式。 但是,我们也可以指定该标头为使客户端需要的资源版本。

1
2
http复制代码GET https://adventure-works.com/customers/3 HTTP/1.1
Accept: application/vnd.adventure-works.v1+json

上例将 Accept 标头指定为 application/vnd.adventure-works.v1+json*。 *vnd.adventure-works.v1 元素向 Web 服务器指示它应返回资源的版本 v1,而 json 元素则指定响应正文的格式应为 JSON。

此方法可以说是最纯粹的版本控制机制并自然地适用于 HATEOAS,后者可以在资源链接中包含相关数据的 MIME 类型。

在现实世界中,API永远不会完全稳定。因此,如何管理这一变化非常重要。对于大多数API而言,商定好部分版本的控制策略,然后对API详细记录和逐步弃用是可接受的做法。

服务端响应

API响应,需要遵守HTTP设计规范,选择合适的状态码返回。你可能见过有的接口始终返回状态码200,然后通过返回体中的code字段进行区分请求是否成功,这种是不符合规范的,相当于状态码没有了任何作用,下面就是一个反例。

1
2
3
4
5
6
7
8
http复制代码HTTP/1.1 200 ok
Content-Type: application/json
Server: example.com

{
"code": -1,
"msg": "该活动不存在",
}

其次,在出现错误时,需要返回错误信息,常见的返回方式就是放在返回体中。

1
2
3
4
5
6
7
8
9
http复制代码HTTP/1.1 401 Unauthorized
Server: nginx/1.11.9
Content-Type: application/json
Transfer-Encoding: chunked
Cache-Control: no-cache, private
Date: Sun, 24 Jun 2018 10:02:59 GMT
Connection: keep-alive

{"error_code":40100,"message":"Unauthorized"}

状态码

HTTP状态码由三个十进制数字组成,第一个十进制数字定义了状态码的类型,HTTP状态码共分为5种类型:

分类 描述
1xx 信息,服务器收到请求,需要请求者继续执行操作
2xx 成功,操作被成功接收并处理
3xx 重定向,需要进一步的操作以完成请求
4xx 客户端错误,请求包含语法错误或无法完成请求
5xx 服务器错误,服务器在处理请求的过程中发生了错误

API不需要1xx类型的状态码,因此我们主要看下其他几个类型常见的状态码:

  1. 2xx状态码
状态码 英文名称 描述
200 OK 请求成功,一般用于GET和POST请求
201 Created 请求成功并创建了新的资源,用于POST、PUT、PATCH请求。例如新增用户、修改用户信息等,同时在返回体中,我们既可以返回创建后实体的所有信息数据,也可以不返回相关信息。
202 Accepted 已接受请求,但未处理完成,会在未来再处理,通常用于异步操作
204 No Content 该状态码表示响应实体不包含任何数据,使用DELETE进行删除操作时,需返回该状态码
  1. 3xx状态码

API 用不到301状态码(永久重定向)和302状态码(暂时重定向,307也是这个含义),因为它们可以由应用级别返回,浏览器会直接跳转,API 级别可以不考虑这两种情况。

API 用到的3xx状态码,主要是303 See Other,表示参考另一个 URL。它与302和307的含义一样,也是”暂时重定向”,区别在于302和307用于GET请求,而303用于POST、PUT和DELETE请求。收到303以后,浏览器不会自动跳转,而会让用户自己决定下一步怎么办。

下面是一个例子。

1
2
html复制代码HTTP/1.1 303 See Other
Location: /api/orders/12345
  1. 4xx状态码
状态码 英文名称 描述
400 Bad Request 客户端请求的语法错误,服务器无法理解
401 Unauthorized 表示用户没有权限(令牌、用户名、密码错误)
403 Forbidden 没有权限访问该请求,服务器收到请求但拒绝提供服务
404 Not Found 服务器无法根据客户端的请求找到资源(如路径不存在)
405 Method Not Allowed 客户端请求的方法服务端不支持,例如使用 POST 方法请求只支持 GET 方法的接口
406 Not Acceptable 用户GET请求的格式不可得(比如用户请求 JSON 格式,但是只有 XML 格式)
408 Request Time-out 客户端请求超时
410 Gone 客户端GET请求的资源已经不存在。410 不同于 404,如果资源以前有现在被永久删除了可使用410 代码,网站设计人员可通过 301 代码指定资源的新位置
415 Unsupported Media Type 通常表示服务器不支持客户端请求首部 Content-Type 指定的数据格式。如在只接受 JSON 格式的 API 中放入 XML 类型的数据并向服务器发送
429 Too Many Requests 客户端的请求次数超过限额
  1. 5xx状态码

5xx状态码表示服务端错误。一般来说,API 不会向用户透露服务器的详细信息,所以只要两个状态码就够了。

状态码 英文名称 描述
500 Internal Server Error 客户端请求有效,服务器处理时发生了意外
503 Service Unavailable 服务器无法处理请求,一般用于网站维护状态

其他

  • API的身份认证应该使用OAuth 2.0框架
  • 服务器返回的数据格式,应该尽量使用JSON,避免使用XML

参考

www.ruanyifeng.com/blog/2011/0…

www.ruanyifeng.com/blog/2014/0…

zhuanlan.zhihu.com/p/68103094

segmentfault.com/a/119000001…

docs.microsoft.com/zh-cn/azure…

总结

本篇文章我们一起来学习了RESTful API的基本概念和设计规范,基于该规范,在工作中可以根据团队来做些适配。

更多

个人博客: lifelmy.github.io/

微信公众号:漫漫Coding路

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

MVI 架构更佳实践:支持 LiveData 属性监听

发表于 2021-10-31

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

前言

前面我们介绍了MVI架构的基本原理与使用:MVVM 进阶版:MVI 架构了解一下~

MVI架构为了解决MVVM在逻辑复杂时需要写多个LiveData(可变+不可变)的问题,使用ViewState对State集中管理,只需要订阅一个 ViewState 便可获取页面的所有状态

通过集中管理ViewState,只需对外暴露一个LiveData,解决了MVVM模式下LiveData膨胀的问题

但页面的所有状态都通过一个LiveData来管理,也带来了一个严重的问题,即页面不支持局部刷新

虽说如果是RecyclerView可以通过DifferUtil来解决,但毕竟不是所有页面都是通过RecyclerView写的,支持DifferUtil也有一定的开发成本

因此直接使用MVI架构会带来一定的性能损耗,相信这是很多人不愿意用MVI架构的原因之一

本文主要介绍如何通过监听LiveData的属性,来实现MVI架构下的局部刷新

Mavericks框架介绍

Mavericks框架是Airbnb开源的一个MVI框架,Mavericks基于Android Jetpack与Kotlin Coroutines,
主要目标是使页面开发更高效,更容易,更有趣,目前已经在Airbnb的数百个页面上使用

下面我们来看下Mavericks是怎么使用的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
kotlin复制代码// 1. 包含页面所有状态的data class
data class CounterState(val count: Int = 0) : MavericksState

// 2.负责处理业务逻辑的ViewModel,易于单元测试
class CounterViewModel(initialState: CounterState) : MavericksViewModel<CounterState>(initialState) {
// 通过setState更新页面状态
fun incrementCount() = setState { copy(count = count + 1) }
}


// 3. View层,必须实现MavericksView接口
class CounterFragment : Fragment(R.layout.counter_fragment), MavericksView {
private val viewModel: CounterViewModel by fragmentViewModel()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
counterText.setOnClickListener {
viewModel.incrementCount()
}
}

//4. 页面刷新回调,每当状态刷新时会回调这里
override fun invalidate() = withState(viewModel) { state ->
counterText.text = "Count: ${state.count}"
}
}

如上所示,看上去也很简单,主要包括几个模块

  1. 包括页面所有状态的Model层,其中的状态全都是不可变的,并且有默认值
  2. 负责处理业务逻辑的ViewModel,在其中通过setState来更新页面状态
  3. View层,必须实现MavericksView接口,每当状态刷新时都会回调invalidate函数,在这里渲染UI

可以看出,Mavericks中View层与Model层的交互,也并没有包装成Action,而是直接暴露的方法

上篇文章也的确有很多同学说使用Action交互比较麻烦,看起来Action这层的确可要可不要,Airbnb也没有使用,主要看个人开发习惯吧

支持局部刷新

上面介绍了Mavericks的简单使用,下面我们来看下Mavericks是怎么实现局部刷新的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码data class UserState(
val score: Int = 0,
val previousHighScore: Int = 150,
val livesLeft: Int = 99,
) : MavericksState {
val pointsUntilHighScore = (previousHighScore - score).coerceAtLeast(0)
val isHighScore = score >= previousHighScore
}

class CounterFragment : Fragment(R.layout.counter_fragment), MavericksView {

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
//直接监听State的属性,并且支持设置监听模式
viewModel.onEach(UserState::pointsUntilHighScore,deliveryMode = uniqueOnly()) {
//..
}

viewModel.onEach(UserState::score) {
//...
}
}
}
  1. 如上所示,Mavericks可以只监听State的其中一个属性来实现局部刷新,只有当这个属性发生变化时才触发回调
  2. onEach也可以设置监听模式,主要是为了防止数据倒灌,例如Toast这些只需要弹一次,页面重建时不应该恢复的状态,就适合使用uniqueOnly的监听模式

Mavericks实现属性监听的原理也很简单,我们一起来看下源码

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码fun <VM : MavericksViewModel<S>, S : MavericksState, A> VM._internal1(
owner: LifecycleOwner?,
prop1: KProperty1<S, A>,
deliveryMode: DeliveryMode = RedeliverOnStart,
action: suspend (A) -> Unit
) = stateFlow
// 通过对象取出属性的值
.map { MavericksTuple1(prop1.get(it)) }
// 值发生变化了才会触发回调
.distinctUntilChanged()
.resolveSubscription(owner, deliveryMode.appendPropertiesToId(prop1)) { (a) ->
action(a)
}
  1. 主要是通过map将State转化为它的属性值
  2. 通过distinctUntilChanged方法开启防抖,相同的值不会回调,只有值修改了才会回调
  3. 需要注意的是因为使用了KProperty1,因此State的承载数据类必须避免混淆

如上,就是Mavericks的基本介绍,想了解更多的同学可参考:github.com/airbnb/mave…

LiveData实现属性监听

上面介绍了Mavericks是怎么实现局部刷新的,但直接使用它主要有两个问题

  1. 接入起来略微有点麻烦,例如Fragment必须实现MavericksView,有一定接入成本
  2. Mavericks的局部刷新是通过Flow实现的,但相信大多数人用的还是LiveData,有一定学习成本

下面我们就来看下LiveData怎么实现属性监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
kotlin复制代码//监听一个属性
fun <T, A> LiveData<T>.observeState(
lifecycleOwner: LifecycleOwner,
prop1: KProperty1<T, A>,
action: (A) -> Unit
) {
this.map {
StateTuple1(prop1.get(it))
}.distinctUntilChanged().observe(lifecycleOwner) { (a) ->
action.invoke(a)
}
}

//监听两个属性
fun <T, A, B> LiveData<T>.observeState(
lifecycleOwner: LifecycleOwner,
prop1: KProperty1<T, A>,
prop2: KProperty1<T, B>,
action: (A, B) -> Unit
) {
this.map {
StateTuple2(prop1.get(it), prop2.get(it))
}.distinctUntilChanged().observe(lifecycleOwner) { (a, b) ->
action.invoke(a, b)
}
}

internal data class StateTuple1<A>(val a: A)
internal data class StateTuple2<A, B>(val a: A, val b: B)

//更新State
fun <T> MutableLiveData<T>.setState(reducer: T.() -> T) {
this.value = this.value?.reducer()
}
  1. 如上所示,主要是添加一个扩展方法,也是通过distinctUntilChanged来实现防抖
  2. 如果需要监听多个属性,例如两个属性有其中一个变化了就触发刷新,也支持传入两个属性
  3. 需要注意的是LiveData默认是不防抖的,这样改造后就是防抖的了,所以传入相同的值是不会回调的
  4. 同时需要注意下承载State的数据类需要防混淆

简单使用

上面介绍了LiveData如何实现属性监听,下面看下简单的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
kotlin复制代码//页面状态,需要避免混淆
data class MainViewState(
val fetchStatus: FetchStatus = FetchStatus.NotFetched,
val newsList: List<NewsItem> = emptyList()
)

//ViewModel
class MainViewModel : ViewModel() {
private val _viewStates: MutableLiveData<MainViewState> = MutableLiveData(MainViewState())
//只需要暴露一个LiveData,包括页面所有状态
val viewStates = _viewStates.asLiveData()

private fun fetchNews() {
//更新页面状态
_viewStates.setState {
copy(fetchStatus = FetchStatus.Fetching)
}
viewModelScope.launch {
when (val result = repository.getMockApiResponse()) {
//...
is PageState.Success -> {
_viewStates.setState {
copy(fetchStatus = FetchStatus.Fetched, newsList = result.data)
}
}
}
}
}

}

//View层
class MainActivity : AppCompatActivity() {
private fun initViewModel() {
viewModel.viewStates.run {
//监听newsList
observeState(this@MainActivity, MainViewState::newsList) {
newsRvAdapter.submitList(it)
}
//监听网络状态
observeState(this@MainActivity, MainViewState::fetchStatus) {
//..
}
}
}
}

如上所示,其实使用起来也很简单方便

  1. ViewModel只需对外暴露一个ViewState,避免了定义多个可变不可变LiveData的问题
  2. View层支持监听LiveData的一个属性或多个属性,支持局部刷新

总结

本文主要介绍了MVI架构下如何实现局部刷新,并重点介绍了Mavericks的基本使用与原理,并在其基础上使用LiveData实现了属性监听与局部刷新

通过以上方式,解决了MVI架构的性能问题,实现了MVI架构的更佳实践

如果你的ViewModel中定义了多个可变与不可变的LiveData,就算你不使用MVI架构,支持监听LiveData属性相信也可以帮助你精简一定的代码

如果本文对你有所帮助,欢迎点赞关注Star~

项目地址

本文所有代码可见:github.com/shenzhen201…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

MySQL是如何保证不丢失数据的呢? 介绍 写作思路 步入正

发表于 2021-10-31

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

介绍

大家好,我是Leo,从事Java后端开发。之前的文章大概介绍了WAL机制,如果不太清楚的小伙伴下面第一部分我们可以再回顾一下。今天这里主要介绍一下WAL的安全性这一块。

image.png

写作思路

根据读者与朋友的反馈,所以从这篇文章开始我会加一个写作的思路。可以先让读者了解到学完这一篇下来之后能收获到哪些知识,以防看了半个小时最后啥也没学到,这样的确挺气人的。

image.png

步入正题

binlog 写入机制

binlog写入日志这个是比较简单的。提到binlog,必然提到binlog cache。那么binlog cache是什么?

我们有必要了解一些前提知识,再学习binlog。会的人可以跳过这段,照顾一下不懂的朋友。

binlog cache是一个二进制日志文件的缓冲区,他是由一个参数 binlog_cache_size 控制大小的缓冲区。

一个事务在执行是时候是不允许被拆开的,因此无论事务多大,都是要一次性保存执行的。那么这个就涉及到了binlog cache 的保存问题。如果所占的内存大小超过了这个binlog_cache_size 参数的设定。就会采用暂存到磁盘。事务在提交的时候,会先把binlog cache里的数据写入到binlog中,并清空binlog cache数据。

如下图,我们可以从图中了解一下。

image.png

每个binlog_cache是由单独的一个线程享有的。也就是说多个线程带着多个binlog_cache去进入write操作的时候是写入到一个binlog 文件的。效率是非常快的,因为并没有涉及到磁盘IO的开销。

当进行到了fsync的时候,才是将数据持久化到磁盘操作。这个时候才会占用磁盘IO,也就是我们常说的IOPS。

我们我们可以深入讨论一个问题,什么时候进行write,什么时候需要fsync操作呢?

image.png

下面我们介绍一下write与fsync的时机。

1
2
3
4
5
6
7
8
9
10
Java复制代码int sync_binlog=0;
if(sync_binlog==0){
每次提交事务都只 write,不 fsync
}
if(sync_binlog==1){
每次提交事务都会执行 fsync
}
if(sync_binlog>1){
每次提交事务都 write,但累积 N 个事务后才 fsync。
}

image.png

因此,在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。

但是,将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。

扩展:上面我们介绍了通过binlog_cahe_size 控制一个参数的大小。进行磁盘与内存缓存区的抉择。那么还有哪些参数是控制这一类问题的。你能想到几个?

下面我们延伸介绍一下sort_buffer_size ,从前几篇文章中引用的一段。详细的可以去order by那篇文章回顾一下。

image.png

binlog 写入机制大概就是这样了,以后技术的进步优化会再完善的。有不对的地方也可以加以指出!互相探讨学习!

redo log 写入机制

还是老样子吧。我感觉MySQL的设计很多地方都很相像。比如change buffer,binlog cache都是一些缓存区,内存临时存放的地方。那么redolog的缓冲区是什么呢? redo log buffer

我们可以先介绍一下 redo log buffer

redo log buffer 要做的是一个事务在插入一条数据的时候,需要先写入日志。但是又不能在还没有提交事务的时候直接写到redo log文件中。这个日志的临时存放处就是redo log buffer。真正在写入redo log文件的过程是在commit这一步完成的。

这里是开启一个事务下执行的。如果我们只执行一条insert 语句它又是如何实现的呢?

单独执行一个更新语句的时候,InnoDB 会自己启动一个事务,在语句执行完成的时候提交。过程跟上面是一样的,只不过是“压缩”到了一个语句里面完成。

image.png

上面我们说了 buffer是他的一个临时缓存区,那么 是不是所有buffe都要持久化到磁盘呢?

不需要 如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。

那么事务还没提交的时候,redo log buffer 中的部分日志有没有可能被持久化到磁盘呢?

确实会有。这个问题,要从 redo log 可能存在的三种状态说起 如下图

image.png

  • redo log buffer:物理上这是MySQL的进程内存
  • FS page cache:写入到磁盘,但是还没有进行持久化。物理上是page cache文件系统。
  • hard disk,这个就是持久化到磁盘了。

图中的红色区域是内存操作,不涉及到磁盘IO。所以性能的非常快的。write也是非常快的,也就是图中的黄色部分。fsync的速度就慢了很多。因为持久化到磁盘。

image.png

MySQL没那么简单,这里的redo log是有一个写入策略的。我们下面介绍一下策略与案例。

写入策略这个就涉及到了一个参数innodb_flush_log_at_trx_commit 这个参数控制写入redo log,写入到磁盘的走向。为了提供更好的性能保障!

  • 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
  • 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
  • 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。

InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。

image.png

下面我们可以细想一下,redo log buffer帮redo log解决了那么大 的一个难题。那么redo log buffer又是绝对的安全或者说绝对的性能吗?

如果我事务正在执行,还没有提交。那么MySQL肯定会把数据从redo log写入到redo log buffer!上面我们介绍了每隔一秒会把redo log buffer里的数据做一边写入。那么有没有可能事务没执行完,可能已经写盘了呢?

答案是肯定的。下面我们介绍一下redo log buffer的刷新策略

控制这个策略的参数是innodb_log_buffer_size

  • redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。

(注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。)

  • 另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘

(假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。)

image.png

说明: 如上图所示, 在做两阶段提交的时候会有一个prepare。先写入redolog处于prepare阶段。再写binlog。最后再commit。

假设: 如果innodb_log_buffer_size 设置成1,那么redo log在prepare就要持久化一次,因为有一个崩溃恢复的逻辑还要依赖prepare的redo log,再加上binlog来恢复。

每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB 就认为 redo log 在 commit 的时候就不需要 fsync 了,只会 write 到文件系统的 page cache 中就够了。

通常我们说 MySQL 的“双 1”配置,指的就是 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog。

image.png

还远远不止这些,有些时候我们听说大厂的TPS每秒两万。也就是说每秒就会写四万次磁盘。但是,我用工具测试出来,磁盘能力也就两万左右,怎么能实现两万的 TPS? 组提交机制

image.png

虽然是最后一个模块,不过还是少不了概念!

这里,先介绍日志逻辑序列号(log sequence number,LSN)的概念。LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。

后续会介绍一下lsn,redo log,checkpoint他们三者的区别。checkpoint这个还没忘记吧。就是前面介绍的刷内存的时候利用的就是这个checkpoint。redo log和LSN就是正在介绍的。

如下图所示,trx1,trx2,trx3三个并发事务在prepare阶段,都写完了redo log buffer持久化到磁盘的过程。

image.png

由图中可以得知

  • trx1是最先到达的,会被选为这组的leader。
  • 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;
  • trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;
  • 这时候 trx2 和 trx3 就可以直接返回了。

所以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。

在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。

优化:

为了提升MySQL的性能,一般会选择在这个地方进行延迟,因为这样可以用节省更多的IOPS。

我们借助上文的两阶段提交的图!这里把写binlog日志这一过程分成了两步。

  • 先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
  • 调用 fsync 持久化。

根据这里的优化改一下。MySQL为了让组提交效果更好如下图。

image.png

这么一来,binlog也可以组提交了。为什么这么说呢。可以看上图的第二步。如果多个事务都已经write了(也就是说写入到redo log buffer了),再到第四步的时候就可以一起持久化到磁盘了。不是提升IOPS嘛的这个优化过程嘛!

不过通常情况下第 3 步执行得会很快,所以 binlog 的 write 和 fsync 间的间隔时间短,导致能集合到一起持久化的 binlog 比较少,因此 binlog 的组提交的效果通常不如 redo log 的效果那么好。

如果你想提升 binlog 组提交的效果,可以通过设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 来实现。这两个只要有一个满足条件就会调用 fsync。

  • binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
  • binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。

所以,当 binlog_group_commit_sync_delay 设置为 0 的时候,binlog_group_commit_sync_no_delay_count 也无效了。

image.png

回到上文的WAL机制那里我们继续讨论一下。WAL机制主要得以于两方面

  • redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
  • 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。

实战案例

如果你的 MySQL 现在出现了IO性能瓶颈,可以通过哪些方法来提升性能呢?

image.png

  • 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。
  • 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。
  • 将 innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。

我不建议你把 innodb_flush_log_at_trx_commit 设置成 0。因为把这个参数设置成 0,表示 redo log 只保存在内存中,这样的话 MySQL 本身异常重启也会丢数据,风险太大。而 redo log 写到文件系统的 page cache 的速度也是很快的,所以将这个参数设置成 2 跟设置成 0 其实性能差不多,但这样做 MySQL 异常重启时就不会丢数据了,相比之下风险会更小。

总结

今天我们已经介绍完了两大日志的写入机制。以及日志的两阶段提交的优缺点。组提交机制的流程与性能

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

ShardingJDBC 主键生成策略

发表于 2021-10-31

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

前言

大部分场景按照MySQL主键ID自增就OK了,但是有些分库分表之后需要全局唯一的ID,标识唯一性。比如会员表的会员ID, 订单表的订单ID, 营销的券ID等等。

sharding-jdbc提供了两种主键生成策略UUID、SNOWFLAKE ,默认使用SNOWFLAKE,其对应实现类为UUIDShardingKeyGenerator和SnowflakeShardingKeyGenerator。

除了以上两种内置的策略类,也可以基于ShardingKeyGenerator,定制主键生成器。

  1. 自定义主键生成器

1.1 自定义代码 MyShardingKeyGenerator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码import lombok.Data;
import org.apache.shardingsphere.spi.keygen.ShardingKeyGenerator;

import java.util.Properties;
import java.util.concurrent.atomic.AtomicLong;


@Data
public class MyShardingKeyGenerator implements ShardingKeyGenerator {

private AtomicLong atomicLong = new AtomicLong(0);
private Properties properties = new Properties();

@Override
public Comparable<?> generateKey() {
// 单机版本的,分布式的可以用Redis自增等等
return atomicLong.incrementAndGet();
}

@Override
public String getType() {

//声明类型
return "MyAtomicLong";
}

}

1.2.SPI接口配置

在Apache ShardingSphere中,很多功能实现类的加载方式是通过SPI注入的方式完成的。 Service Provider Interface (SPI)是一种为了被第三方实现或扩展的API,它可以用于实现框架扩展或组件替换。

Apache ShardingSphere之所以采用SPI方式进行扩展,是出于整体架构最优设计考虑。 为了让高级用户通过实现Apache ShardingSphere提供的相应接口,动态将用户自定义的实现类加载其中,从而在保持Apache ShardingSphere架构完整性与功能稳定性的情况下,满足用户不同场景的实际需求。

添加如下文件:META-INF/services/org.apache.shardingsphere.spi.keygen.ShardingKeyGenerator,

文件内容为:com.sharding.jdbc.demo.util.MyShardingKeyGenerator

image.png

1.3. 配置ID生成策略

image.png

2.UUID生成器

ShardingJdbc内置ID生成器实现类有UUIDShardingKeyGenerator和SnowflakeShardingKeyGenerator。

image.png

  1. SnowFlake 算法

SnowflakeShardingKeyGenerator 有兴趣的可以阅读一下这个源码

3.1 雪花算法概述

雪花算法生成的ID是纯数字且具有时间顺序的。其原始版本是scala版,后面出现了许多其他语言的版本如Java、C++等。

3.2 基本原理

image.png

大致由:首位无效符、时间戳差值,机器(进程)编码,序列号四部分组成。

基于Twitter Snowflake算法实现,长度为64bit;64bit组成如下:

  • 1bit sign bit.
  • 41bits timestamp offset from 2016.11.01(Sharding-JDBC distributed primary key published data) to now.
  • 10bits worker process id.
  • 12bits auto increment offset in one mills.

image.png

3.3 雪花算法生成

雪花算法生成我们直接用Hutool中的工具类,直接实现

image.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…447448449…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%