注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

多端登录如何实现踢人下线

1:项目背景 一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。 或者你登录了PC端,这时候你登陆了AP...
继续阅读 »

1:项目背景


一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。


或者你登录了PC端,这时候你登陆了APP或者小程序,这时候PC端的账号也会被强制下线


2:项目只有PC端


假设我们现在的项目只有PC端,没有小程序或者APP,那么这时候就是很简单了,用户的sessin(也就是所谓的Token)一般都是存储在redis中,session中包括用户ID等一些信息,当然还有一个最重要的就是登录的ip地址。


image.png


1:用户在登录的时候,从redis中获取用户session,如果没有就可以直接登录了


2:用户在另外一台电脑登录,从redis中获取到用户session,这时候用户session是有的,说明用户之前已经登录过了


3:这时候从用户session中获取IP,判断二者的ip是不是相同,如果不同,这时候就要发送一个通知给客户端,让另外一台设备登录的账号强制下线即可


3:项目有PC端和APP端和小程序端


当你的应用有PC端和APP端的时候,我们用户的session如果还是只存一个ip地址,那明显就是不够的,因为很多情况下,我们PC端和APP端是可以同时登录的,比如淘宝,京东等都是,也就是所谓的双端登录


这时候就会有多种情况


单端登录:PC端,APP端,小程序只能有一端登录
双端登录:允许其中二个端登录
三端登录:三个端都可以同时登录

对于三端可以同时登录就很简单,但是现在有个限制,就是app端只能登录一次,不能同时登录,也就是我一个手机登录了APP,另外一个手机登录的话,之前登录的APP端就要强制下线


所以我们的用户session存储的格式如下


{
userId:用户的id
clientType:PC端,小程序端,APP端
imei:就是设备的唯一编号(对于PC端这个值就是ip地址,其余的就是手机设备的一个唯一编号)
}


单端登录


首先我们要知道,用户登录不同的设备那么用户session是不一样的。对于单端登录,那么我们可以拿到用户的所有的session,然后根据clientType和imei号来强制将其它端的用户session删除掉,然后通知客户端强制下线


双端登录


同样拿到所有用户的session,然后根据自己的业务需求来判定哪一端需要强制下线,比如我们现在已经登录了PC端和APP端,这时候登录小程序,现在要让APP端的强制下线。


这时候登录之后获取用户所有的session,这时候会有二个用户session,首先拿到clientType = APP的session,然后来通知客户端这个端需要强制下线。


如果这时候我登录了PC端和一个APP端,这时候我用另外一台手机登录APP端,那么之前那台手机上登录的APP端就要被强制下线,这个时候仅通过clientType是不行的,因为我二个手机登录的clientType都是APP端。所以这时候就要根据imei号来判断了。因为不同的手机imei号是不一样的。


这时候我拿到用户所有的session



PC端的session
sessionA{
userId: 1,
clientType: PC,
imei: "123"
}

APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "12345"
}

这时候我从另外一台手机登录的时候,生成的session应该是这样的


 APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "1234567"
}

我发现同一个clientType的session已经有了,这时候我要判断imei号是否一样,imei一样说明是同一台设备,不同说明不是同一台设备,我们只需要把对应设备的账号强制下线即可了


总结


不管是单端登录,双端登录还是多端登录,我们都是根据用户session来判断。只要根据clientType和imei号来就可以满足我们大部分的踢人下线需求了。


作者:我是小趴菜
来源:juejin.cn/post/7213598216884486204
收起阅读 »

用了这两款插件,同事再也不说我代码写的烂了

大家好,我是风筝同事:你的代码写的不行啊,不够规范啊。我:我写的代码怎么可能不规范,不要胡说。于是同事打开我的 IDEA ,安装了一个插件,然后执行了一下,规范不规范,看报告吧。这可怎么是好,这玩意竟然给我挑出来这么多问题,到底靠谱不。同事潇洒的走掉了,只留下...
继续阅读 »

大家好,我是风筝

同事:你的代码写的不行啊,不够规范啊。

我:我写的代码怎么可能不规范,不要胡说。

于是同事打开我的 IDEA ,安装了一个插件,然后执行了一下,规范不规范,看报告吧。

这可怎么是好,这玩意竟然给我挑出来这么多问题,到底靠谱不。

同事潇洒的走掉了,只留下我在座位上盯着屏幕惊慌失措。我仔细的查看了这个报告的每一项,越看越觉得这插件指出的问题有道理,果然是我大意了,竟然还给我挑出一个 bug 来。

这是什么插件,review 代码无敌了。

这个插件就是 SonarLint,官网的 Slogan 是 clean code begins in your IDE with {SonarLint}

作为一个程序员,我们当然希望自己写的代码无懈可击了,但是由于种种原因,有一些问题甚至bug都无法避免,尤其是刚接触开发不久的同学,也有很多有着多年开发经验的程序员同样会有一些不好的代码习惯。

代码质量和代码规范首先肯定是靠程序员自身的水平和素养决定的,但是提高水平的是需要方法的,方法就有很多了,比如参考大厂的规范和代码、比如有大佬带着,剩下的就靠平时的一点点积累了,而一些好用的插件能够时时刻刻提醒我们什么是好的代码规范,什么是好的代码。

SonarLint 就是这样一款好用的插件,它可以实时帮我们 review代码,甚至可以发现代码中潜在的问题并提供解决方案。

SonarLint 使用静态代码分析技术来检测代码中的常见错误和漏洞。例如,它可以检测空指针引用、类型转换错误、重复代码和逻辑错误等。这些都是常见的问题,但是有时候很难发现。使用 SonarLint 插件,可以在编写代码的同时发现这些问题,并及时纠正它们,这有助于避免这些问题影响应用程序的稳定性。

比如下面这段代码没有结束循环的条件设置,SonarLint 就给出提示了,有强迫症的能受的了这红下划线在这儿?

SonarLint 插件可以帮助我提高代码的可读性。代码应该易于阅读和理解,这有助于其他开发人员更轻松地维护和修改代码。 SonarLint 插件可以检测代码中的代码坏味道,例如不必要的注释、过长的函数和变量名不具有描述性等等。通过使用 SonarLint 插件,可以更好地了解如何编写清晰、简洁和易于理解的代码。

例如下面这个名称为 hello_world的静态 final变量,SonarLint 给出了两项建议。

  1. 因为变量没有被使用过,建议移除;
  2. 静态不可变变量名称不符合规范;

SonarLint 插件可以帮助我遵循最佳实践和标准。编写符合标准和最佳实践的代码可以确保应用程序的质量和可靠性。 SonarLint 插件可以检测代码中的违反规则的地方,例如不安全的类型转换、未使用的变量和方法、不正确的异常处理等等。通过使用 SonarLint 插件,可以学习如何编写符合最佳实践和标准的代码,并使代码更加健壮和可靠。

例如下面的异常抛出方式,直接抛出了 Exception,然后 SonarLint 建议不要使用 Exception,而是自定义一个异常,自定义的异常可能让人直观的看出这个异常是干什么的,而不是 Exception基本类型导出传递。

安装 SonarLint

可以直接打开 IDEA 设置 -> Plugins,在 MarketPlace中搜索SonarLint,直接安装就可以。

还可以直接在官网下载,打开页面https://www.sonarsource.com/products/sonarlint/,在页面中可以看到多种语言、多种开发工具的下载图标,点击下方的 EXPLORE即可到下载页面去下载了。虽然我们只是在 IDEA 中使用,但是它不管支持 Java 、不只支持 IDEA ,还支持 Python、PHP等众多语言,以及 Visual Studio 、VS Code 等众多 IDE。

在 IDEA 中使用

SonarLint 插件安装好之后,默认就开启了实时分析的功能,就跟智能提示的功能一样,随着你噼里啪啦的敲键盘,SonarLint插件就默默的进行分析,一旦发现问题就会以红框、红波浪线、黄波浪线的方式提示。

当然你也可以在某一文件中点击右键,也可在项目根目录点击右键,在弹出菜单中点击Analyze with SonarLint,对当前文件或整个项目进行分析。

分析结束后,会生成分析报告。

左侧是对各个文件的分析结果,右侧是对这个问题的建议和修改示例。

SonarLint 对问题分成了三种类型

类型说明
Bug代码中的 bug,影响程序运行
Vulnerability漏洞,可能被作为攻击入口
Code smell代码意味,可能影响代码可维护性

问题按照严重程度分为5类

严重性说明
BLOCKER已经影响程序正常运行了,不改不行
CRITICAL可能会影响程序运行,可能威胁程序安全,一般也是不改不行
MAJOR代码质量问题,但是比较严重
MINOR同样是代码质量问题,但是严重程度较低
INFO一些友好的建议

SonarQube

SonarLint 是在 IDE 层面进行分析的插件,另外还可以使用 SonarQube功能,它以一个 web 的形式展现,可以为整个开发团队的项目提供一个web可视化的效果。并且可以和 CI\CD 等部署工具集成,在发版前提供代码分析。

SonarQube是一个 Java 项目,你可以在官网下载项目本地启动,也可以以 docker 的方式启动。之后可以在 IDEA 中配置全局 SonarQube配置。

也可以在 SonarQube web 中单独配置一个项目,创建好项目后,直接将 mvn 命令在待分析的项目中执行,即可生成对应项目的分析报告,然后在 SonarQube web 中查看。

5

对于绝大多数开发者和开发团队来说,SonarQube 其实是没有必要的,只要我们每个人都解决了 IDE 中 SonarLint 给出的建议,当然最终的代码质量就是符合标准的。

阿里 Java 规约插件

每一个开发团队都有团队内部的代码规范,比如变量命名、注释格式、以及各种类库的使用方式等等。阿里一直在更新 Java 版的阿里巴巴开发者手册,有什么泰山版、终极版,想必各位都听过吧,里面的规约如果开发者都能遵守,那别人恐怕再没办法 diss 你的代码不规范了。

对应这个开发手册的语言层面的规范,阿里也出了一款 IDEA 插件,叫做 Alibaba Java Coding Guidelines,可以在插件商店直接下载。

比如前面说的那个 hello_world变量名,插件直接提示「修正为以下划线分隔的大写模式」。

再比如一些注释上的提示,不建议使用行尾注释。

image-20230314165107639

还有,比如对线程池的使用,有根据规范建议的内容,建议自己定义核心线程数和最大线程数等参数,不建议使用 Excutors工具类。

有了这俩插件,看谁还能说我代码写的不规范了。

作者:古时的风筝
来源:juejin.cn/post/7211151196328804408
收起阅读 »

保姆级JAVA对接ChatGPT教程,实现自己的AI对话助手

1.前言 大家好,我是王老狮,近期OpenAI开放了chatGPT的最新gpt-3.5-turbo模型,据介绍该模型是和当前官网使用的相同的模型,如果你还没体验过ChatGPT,那么今天就教大家如何打破网络壁垒,打造一个属于自己的智能助手把。本文包括API K...
继续阅读 »

1.前言


大家好,我是王老狮,近期OpenAI开放了chatGPT的最新gpt-3.5-turbo模型,据介绍该模型是和当前官网使用的相同的模型,如果你还没体验过ChatGPT,那么今天就教大家如何打破网络壁垒,打造一个属于自己的智能助手把。本文包括API Key的申请以及网络代理的搭建,那么事不宜迟,我们现在开始。


2.对接流程


2.1.API-Key的获取


首先第一步要获取OpenAI接口的API Key,该Key是你用来调用接口的token,主要用于接口鉴权。获取该key首先要注册OpenAi的账号,具体可以见我的另外一篇文章,ChatGPT保姆级注册教程



  1. 打开platform.openai.com/网站,点击view API Key,


image.png



  1. 点击创建key


image.png



  1. 弹窗显示生成的key,记得把key复制,不然等会就找不到这个key了,只能重新创建。


image.png


将API Key保存好以备用


2.2.API用量的查看


这里可以查看API的使用情况,新账号注册默认有5美元的试用额度,之前都是18美元,API成本降了之后试用额度也狠狠地砍了一刀啊,哈哈。


image.png


2.3.核心代码实现


2.3.1.pom依赖


<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0modelVersion>
<groupId>com.webtapgroupId>
<artifactId>webtapartifactId>
<version>0.0.1version>
<packaging>jarpackaging>

<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.1.2.RELEASEversion>
parent>

<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
<dependency>
<groupId>nz.net.ultraq.thymeleafgroupId>
<artifactId>thymeleaf-layout-dialectartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-jpaartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-mailartifactId>
dependency>

<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-lang3artifactId>
<version>3.4version>
dependency>
<dependency>
<groupId>commons-codecgroupId>
<artifactId>commons-codecartifactId>
dependency>
<dependency>
<groupId>org.jsoupgroupId>
<artifactId>jsoupartifactId>
<version>1.9.2version>
dependency>

<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.56version>
dependency>
<dependency>
<groupId>net.sourceforge.nekohtmlgroupId>
<artifactId>nekohtmlartifactId>
<version>1.9.22version>
dependency>
<dependency>
<groupId>com.github.pagehelpergroupId>
<artifactId>pagehelper-spring-boot-starterartifactId>
<version>1.4.1version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpasyncclientartifactId>
<version>4.0.2version>
dependency>
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpcore-nioartifactId>
<version>4.3.2version>
dependency>

<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpclientartifactId>
<version>4.3.5version>
<exclusions>
<exclusion>
<artifactId>commons-codecartifactId>
<groupId>commons-codecgroupId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>commons-httpclientgroupId>
<artifactId>commons-httpclientartifactId>
<version>3.1version>
<exclusions>
<exclusion>
<artifactId>commons-codecartifactId>
<groupId>commons-codecgroupId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>1.3.1version>
dependency>
<dependency>
<groupId>com.github.ulisesbocchiogroupId>
<artifactId>jasypt-spring-boot-starterartifactId>
<version>2.0.0version>
dependency>

dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>

project>

2.3.2.实体类ChatMessage.java


用于存放发送的消息信息,注解使用了lombok,如果没有使用lombok可以自动生成构造方法以及get和set方法


@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
//消息角色
String role;
//消息内容
String content;
}

2.3.3.实体类ChatCompletionRequest.java


用于发送的请求的参数实体类,参数释义如下:


model:选择使用的模型,如gpt-3.5-turbo


messages :发送的消息列表


temperature :温度,参数从0-2,越低表示越精准,越高表示越广发,回答的内容重复率越低


n :回复条数,一次对话回复的条数


stream :是否流式处理,就像ChatGPT一样的处理方式,会增量的发送信息。


max_tokens :生成的答案允许的最大token数


user :对话用户


@Data
@Builder
public class ChatCompletionRequest {

String model;

List<ChatMessage> messages;

Double temperature;

Integer n;

Boolean stream;

List<String> stop;

Integer max_tokens;

String user;
}

2.3.4.实体类ExecuteRet .java


用于接收请求返回的信息以及执行结果



/**
* 调用返回
*/

public class ExecuteRet {

/**
* 操作是否成功
*/

private final boolean success;

/**
* 返回的内容
*/

private final String respStr;

/**
* 请求的地址
*/

private final HttpMethod method;

/**
* statusCode
*/

private final int statusCode;

public ExecuteRet(booleansuccess, StringrespStr, HttpMethodmethod, intstatusCode) {
this.success =success;
this.respStr =respStr;
this.method =method;
this.statusCode =statusCode;
}

@Override
public String toString()
{
return String.format("[success:%s,respStr:%s,statusCode:%s]", success, respStr, statusCode);
}

/**
*@returnthe isSuccess
*/

public boolean isSuccess() {
return success;
}

/**
*@returnthe !isSuccess
*/

public boolean isNotSuccess() {
return !success;
}

/**
*@returnthe respStr
*/

public String getRespStr() {
return respStr;
}

/**
*@returnthe statusCode
*/

public int getStatusCode() {
return statusCode;
}

/**
*@returnthe method
*/

public HttpMethod getMethod() {
return method;
}
}

2.3.5.实体类ChatCompletionChoice .java


用于接收ChatGPT返回的数据


@Data
public class ChatCompletionChoice {

Integer index;

ChatMessage message;

String finishReason;
}

2.3.6.接口调用核心类OpenAiApi .java


使用httpclient用于进行api接口的调用,支持post和get方法请求。


url为配置文件open.ai.url的值,表示调用api的地址:https://api.openai.com/ ,token为获取的api-key。
执行post或者get方法时增加头部信息headers.put("Authorization", "Bearer " + token); 用于通过接口鉴权。



@Slf4j
@Component
public class OpenAiApi {

@Value("${open.ai.url}")
private String url;
@Value("${open.ai.token}")
private String token;

private static final MultiThreadedHttpConnectionManagerCONNECTION_MANAGER= new MultiThreadedHttpConnectionManager();

static {
// 默认单个host最大链接数
CONNECTION_MANAGER.getParams().setDefaultMaxConnectionsPerHost(
Integer.valueOf(20));
// 最大总连接数,默认20
CONNECTION_MANAGER.getParams()
.setMaxTotalConnections(20);
// 连接超时时间
CONNECTION_MANAGER.getParams()
.setConnectionTimeout(60000);
// 读取超时时间
CONNECTION_MANAGER.getParams().setSoTimeout(60000);
}

public ExecuteRet get(Stringpath, Map headers) {
GetMethod method = new GetMethod(url +path);
if (headers== null) {
headers = new HashMap<>();
}
headers.put("Authorization", "Bearer " + token);
for (Map.Entry h : headers.entrySet()) {
method.setRequestHeader(h.getKey(), h.getValue());
}
return execute(method);
}

public ExecuteRet post(Stringpath, Stringjson, Map headers) {
try {
PostMethod method = new PostMethod(url +path);
//log.info("POST Url is {} ", url + path);
// 输出传入参数
log.info(String.format("POST JSON HttpMethod's Params = %s",json));
StringRequestEntity entity = new StringRequestEntity(json, "application/json", "UTF-8");
method.setRequestEntity(entity);
if (headers== null) {
headers = new HashMap<>();
}
headers.put("Authorization", "Bearer " + token);
for (Map.Entry h : headers.entrySet()) {
method.setRequestHeader(h.getKey(), h.getValue());
}
return execute(method);
} catch (UnsupportedEncodingExceptionex) {
log.error(ex.getMessage(),ex);
}
return new ExecuteRet(false, "", null, -1);
}

public ExecuteRet execute(HttpMethodmethod) {
HttpClient client = new HttpClient(CONNECTION_MANAGER);
int statusCode = -1;
String respStr = null;
boolean isSuccess = false;
try {
client.getParams().setParameter(HttpMethodParams.HTTP_CONTENT_CHARSET, "UTF8");
statusCode = client.executeMethod(method);
method.getRequestHeaders();

// log.info("执行结果statusCode = " + statusCode);
InputStreamReader inputStreamReader = new InputStreamReader(method.getResponseBodyAsStream(), "UTF-8");
BufferedReader reader = new BufferedReader(inputStreamReader);
StringBuilder stringBuffer = new StringBuilder(100);
String str;
while ((str = reader.readLine()) != null) {
log.debug("逐行读取String = " + str);
stringBuffer.append(str.trim());
}
respStr = stringBuffer.toString();
if (respStr != null) {
log.info(String.format("执行结果String = %s, Length = %d", respStr, respStr.length()));
}
inputStreamReader.close();
reader.close();
// 返回200,接口调用成功
isSuccess = (statusCode == HttpStatus.SC_OK);
} catch (IOExceptionex) {
} finally {
method.releaseConnection();
}
return new ExecuteRet(isSuccess, respStr,method, statusCode);
}

}

2.3.7.定义接口常量类PathConstant.class


用于维护支持的api接口列表


public class PathConstant {
public static class MODEL {
//获取模型列表
public static String MODEL_LIST = "/v1/models";
}

public static class COMPLETIONS {
public static String CREATE_COMPLETION = "/v1/completions";
//创建对话
public static String CREATE_CHAT_COMPLETION = "/v1/chat/completions";

}
}

2.3.8.接口调用调试单元测试类OpenAiApplicationTests.class


核心代码都已经准备完毕,接下来写个单元测试测试下接口调用情况。



@SpringBootTest
@RunWith(SpringRunner.class)
public class OpenAiApplicationTests {

@Autowired
private OpenAiApi openAiApi;
@Test
public void createChatCompletion2() {
Scanner in = new Scanner(System.in);
String input = in.next();
ChatMessage systemMessage = new ChatMessage('user', input);
messages.add(systemMessage);
ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder()
.model("gpt-3.5-turbo-0301")
.messages(messages)
.user("testing")
.max_tokens(500)
.temperature(1.0)
.build();
ExecuteRet executeRet = openAiApi.post(PathConstant.COMPLETIONS.CREATE_CHAT_COMPLETION, JSONObject.toJSONString(chatCompletionRequest),
null);
JSONObject result = JSONObject.parseObject(executeRet.getRespStr());
List choices = result.getJSONArray("choices").toJavaList(ChatCompletionChoice.class);
System.out.println(choices.get(0).getMessage().getContent());
ChatMessage context = new ChatMessage(choices.get(0).getMessage().getRole(), choices.get(0).getMessage().getContent());
System.out.println(context.getContent());
}

}


  • 使用Scanner 用于控制台输入信息,如果单元测试时控制台不能输入,那么进入IDEA的安装目录,修改以下文件。增加最后一行增加-Deditable.java.test.console=true即可。


image.png
image.png




  • 创建ChatMessage对象,用于存放参数,role有user,system,assistant,一般接口返回的响应为assistant角色,我们一般使用user就好。




  • 定义请求参数ChatCompletionRequest,这里我们使用3.1日发布的最新模型gpt-3.5-turbo-0301。具体都有哪些模型大家可以调用v1/model接口查看支持的模型。




  • 之后调用openAiApi.post进行接口的请求,并将请求结果转为JSON对象。取其中的choices字段转为ChatCompletionChoice对象,该对象是存放api返回的具体信息。


    接口返回信息格式如下:


    {
    "id": "chatcmpl-6rNPw1hqm5xMVMsyf6PXClRHtNQAI",
    "object": "chat.completion",
    "created": 1678179420,
    "model": "gpt-3.5-turbo-0301",
    "usage": {
    "prompt_tokens": 16,
    "completion_tokens": 339,
    "total_tokens": 355
    },
    "choices": [{
    "message": {
    "role": "assistant",
    "content": "\n\nI. 介绍数字孪生的概念和背景\n A. 数字孪生的定义和意义\n B. 数字孪生的发展历程\n C. 数字孪生在现代工业的应用\n\nII. 数字孪生的构建方法\n A. 数字孪生的数据采集和处理\n B. 数字孪生的建模和仿真\n C. 数字孪生的验证和测试\n\nIII. 数字孪生的应用领域和案例分析\n A. 制造业领域中的数字孪生应用\n B. 建筑和城市领域中的数字孪生应用\n C. 医疗和健康领域中的数字孪生应用\n\nIV. 数字孪生的挑战和发展趋势\n A. 数字孪生的技术挑战\n B. 数字孪生的实践难点\n C. 数字孪生的未来发展趋势\n\nV. 结论和展望\n A. 总结数字孪生的意义和价值\n B. 展望数字孪生的未来发展趋势和研究方向"
    },
    "finish_reason": "stop",
    "index": 0
    }]
    }



  • 输出对应的信息。




2.3.9.结果演示


image.png


2.4.连续对话实现


2.4.1连续对话的功能实现


基本接口调通之后,发现一次会话之后,没有返回完,输入继续又重新发起了新的会话。那么那么我们该如何实现联系上下文呢?其实只要做一些简单地改动,将每次对话的信息都保存到一个消息列表中,这样问答就支持上下文了,代码如下:


List messages = new ArrayList<>();
@Test
public void createChatCompletion() {
Scanner in = new Scanner(System.in);
String input = in.next();
while (!"exit".equals(input)) {
ChatMessage systemMessage = new ChatMessage(ChatMessageRole.USER.value(), input);
messages.add(systemMessage);
ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder()
.model("gpt-3.5-turbo-0301")
.messages(messages)
.user("testing")
.max_tokens(500)
.temperature(1.0)
.build();
ExecuteRet executeRet = openAiApi.post(PathConstant.COMPLETIONS.CREATE_CHAT_COMPLETION, JSONObject.toJSONString(chatCompletionRequest),
null);
JSONObject result = JSONObject.parseObject(executeRet.getRespStr());
List choices = result.getJSONArray("choices").toJavaList(ChatCompletionChoice.class);
System.out.println(choices.get(0).getMessage().getContent());
ChatMessage context = new ChatMessage(choices.get(0).getMessage().getRole(), choices.get(0).getMessage().getContent());
messages.add(context);
in = new Scanner(System.in);
input = in.next();
}
}

因为OpenAi的/v1/chat/completions接口消息参数是个list,这个是用来保存我们的上下文的,因此我们只要将每次对话的内容用list进行保存即可。


2.4.2结果如下:


image.png


image.png


4.常见问题


4.1.OpenAi接口调用不通


因为https://api.openai.com/地址也被限制了,但是接口没有对地区做校验,因此可以自己搭建一个香港代理,也可以走科学上网。


我采用的是香港代理的模式,一劳永逸,具体代理配置流程如下:



  1. 购买一台香港的虚拟机,反正以后都会用得到,作为开发者建议搞一个。搞活动的时候新人很便宜,基本3年的才200块钱。

  2. 访问nginx.org/download/ng… 下载最新版nginx

  3. 部署nginx并修改/nginx/config/nginx.conf文件,配置接口代理路径如下


server {
listen 19999;
server_name ai;

ssl_certificate /usr/local/nginx/ssl/server.crt;
ssl_certificate_key /usr/local/nginx/ssl/server.key;

ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;

ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;

#charset koi8-r;

location /v1/ {
proxy_pass ;
}
}


  1. 启动nginx

  2. 将接口访问地址改为nginx的机器出口IP+端口即可


如果代理配置大家还不了解,可以留下评论我单独出一期教程。


4.2.接口返回401


检查请求方法是否增加token字段以及key是否正确


5.总结


至此JAVA对OpenAI对接就已经完成了,并且也支持连续对话,大家可以在此基础上不断地完善和桥接到web服务,定制自己的ChatGPT助手了。我自己也搭建了个平台,不断地在完善中,具体可见下图,后续会开源出来,想要体验的可以私信我获取地址和账号哈


image.png



作者:王老狮
来源:juejin.cn/post/7208907027841171512
收起阅读 »

删库跑路后的现场还原

数据库是公司重要资产,在此类重要资产平台上,尤其是重要操作,应该保持敬畏心。 数据库被删了?可怎么证明是某某某删了数据库?或者根本都不知道谁删除了数据库,又没抓现行,该怎么办? 正文 第一步 证据先行,有录屏有真相 删库动作的录制回放 录制回放让团队能清楚...
继续阅读 »

数据库是公司重要资产,在此类重要资产平台上,尤其是重要操作,应该保持敬畏心。



数据库被删了?可怎么证明是某某某删了数据库?或者根本都不知道谁删除了数据库,又没抓现行,该怎么办?



正文


第一步 证据先行,有录屏有真相


删库动作的录制回放


录制回放让团队能清楚了解和学习用户路径和行为,其中对于关键页面诸如删除等高价值的动作,可以开启录制回放功能,比如下图,就是某一用户某一行为的屏幕录制情况。
tutieshi_640x360_15s.gif


删库成功的页面截图


针对录制回放的内容,可以看到用户点击删除按钮这一高风险行为。


image.png


第二步 录屏背后是详细的用户访问数据


rum中查看用户会话


在用户使用产品的那一刻,用户体验就开始了。用户体验数据洞见很多,加购物车、下单、视频播放等高价值按钮背后的性能等相关数据和业务息息相关:比如下图展示了成功删除数据库的提示弹窗。


image.png


发现用户登录并浏览数据库平台的详细信息


每一次用户会话中,记录着用户的来源、访问时长,以及用户行为,这里面就包含对页面的加载(切换)和按钮点击。下图便是一个用户登录数据库管理平台后,0-20分钟以内的用户旅程
image.png


发现用户点击删除库的按钮的详细信息


链接或者按钮背后隐藏着逻辑和用户动机,充分利用能转化良好化学反应。反之,在用户旅程中,也能看到用户点击删除数据库的按钮的行为,如下图所示:
image.png


点击按钮成功触发删除数据库的接口请求


为了明白请求或行为在系统中的'前世今生',链路追踪已经成了必备,在下图中,用户行为触发的请求的完整上下文就被“追踪”到了:
image.png


后台处理接口请求


在产品使用流畅度中,丝滑不一定是卖点,但“慢”肯定是用户卡点,通过全链路链路追踪综合分析,可以得到请求耗时占比,进一步定位卡在哪里(前端、后端、网络),详情见下图:
image.png


第三步 成功删库的链路详情


前后端加上数据库形成可视化闭环,构成的业务链路,能够高效定位业务情况,下图能完整看出一次删库的效率:
image.png


第四步 自动关联删库日志


全链路追踪能锦上添花的要数自动关联日志的功能了,下图能清晰看到链路所产生的日志:
image.png


以上我们便通过用户删库的录屏用户行为链路信息、操作日志等,还原了删库现场。当然,其中涉及了很多技术内容,下面整理了其中一些常见问题


相关技术点的FAQ :


1. 如何针对关键步骤开启录制回放功能


删除按钮 为例 ,用户点击删除按钮后 可以开启 录制回放功能


  function deleteDB(){
showConfirm(deleteDB).then((yes,no)=>{
if(yes)=>[ datafluxRum.startSessionReplayRecording();]
})

}

2. 录制回放是否涉及密码等用户私密信息


出于数据安全考虑,任何情况下,以下元素都会被屏蔽:



  • password、email 和 tel 类型的输入

  • 具有 autocomplete 属性的元素,例如信用卡号、到期日期和安全代码


3 . 如何将 用户行为后端 进行关联


前后端关联通过http请求头的traceID进行关联,开启rumapm简单设置即可实现关联。
rum中仅仅需要在启动时注明后端地址。以本文的后台管理系统为例,需要在启动rum时开启allowTracingOrigin这个字段,配置见下图


image.png


可以参照如下代码


 window.DATAFLUX_RUM &&
window.DATAFLUX_RUM.init({
applicationId: "node_mongo_admin_express",
datakitOrigin: "http://mongodb_admin:9529", // 协议(包括://),域名(或IP地址)[和端口号]
env: "production",
service:"node_mongo_admin_express",
version: "1.0.0",
trackInteractions: true,
allowedTracingOrigins: ["http://mongodb_admin:1234"], // 非必填,允许注入trace采集器所需header头部的所有请求列表。可以是请求的origin,也可以是是正则
sessionSampleRate: 100,
sessionReplaySampleRate: 100,
defaultPrivacyLevel: 'allow',
});
window.DATAFLUX_RUM && window.DATAFLUX_RUM.startSessionReplayRecording()

4. 如何自动将采集的日志链路信息进行关联


需要将traceID注入日志,进行切分,就可以实现链路日志的关联。本文仅用一行进行了关联,代码见下图。


image.png


5. 如何从后端下钻到数据库


仅需要接入追踪工具即可实现下图全链路追踪,本文后端使用node的express框架,链路追踪展示图如下:


image.png


其中服务调用拓扑关系如下,也就是web端访问后端(node技术栈)的,后端调用数据库(mongo


image.png


6. 后端支持java吗?


支持javapythongo以及.net等,接入的学习成本是有的,整体对于开发而言,接入配置问题不大。


7. 前端的技术架构或技术栈有兼容性吗?


目前不论是mpa还是spa,不论是ssr、还是csr,亦或是vuereactjQuery等,都支持,但针对不同架构,需要选择接入的场景。


8. 还支持哪些场景?


支持的场景很多,比如:



  • 线上告警的故障定位

  • 开发、测试环境的bug调试

  • 用户行为的追踪与回放

  • 性能瓶颈的查找与性能提升


9.有关请求耗时占比,能更详细的举个例子吗?


我们以后端为例,看到db_create这个接口:


image.png


这些数据是如何统计得出的呢?感兴趣的同学可以查看下图:
image.png


其中每个部分的计算原理如下:


Queueing(队列)耗时 = Duration - First Byte - Download  
First Byte(首包)耗时 = responseStart - domainLookupStart
Download(下载)耗时 = responseEnd - responseStart


更深入的技术内容,我将在今后的文章继续为大家整理。


综上所述


可观测性切入点很多,聪明的团队会观测;可观测性是研发质量的试金石,是企业城墙的基石,用好可观测性,能更多的了解系统,扩宽业务。



本文由观测云高级产品技术专家刘刚和交付工程师 苏桐桐共同撰写,其中所有截图及数据,均来自模拟数据,此外也欢迎一起探讨技术和业务。



参考词汇



  • adminMongo:mongo数据库管理平台

  • rum: 真实用户体验

  • apm: 应用性能管理

  • metrics:指标

  • logs:日志

  • trace:链路


作者:Yestodorrow
来源:juejin.cn/post/7207787191622893624
收起阅读 »

Spring Boot+微信小程序_保存微信登录者的个人信息

1. 前言 微信小程序开发平台,提供有一类 API,可以让开发者获取到微信登录用户的个人数据。这类 API 统称为开放接口。 Tip:微信小程序开发平台,会把微信登录用户的个人信息分为明文数据和敏感数据。 明文数据也称为公开数据,开发者可以直接获取到,如登录...
继续阅读 »

1. 前言


微信小程序开发平台,提供有一类 API,可以让开发者获取到微信登录用户的个人数据。这类 API 统称为开放接口



Tip:微信小程序开发平台,会把微信登录用户的个人信息分为明文数据敏感数据


明文数据也称为公开数据,开发者可以直接获取到,如登录者的昵称、头像……


敏感数据如电话号码、唯一标识符……等数据,只有高级认证开发者和经过登录者授权后才能解密获取到。



这一类 API较多,且 API之间功能有重叠之处,相互之间的区别较微小。有的适用于低版本,有的适用于高版本。


为了避免在使用时出现选择混乱,本文将通过具体应用案例介绍几个常用 API的使用。


2. 开放接口


开放接口是对一类 API的统称,开发者可以通过调用这类接口得到微信登录用户的授权或获取登录者的个人数据
开放接口又分成几个子类 API



  • 登录接口: 包括 wx.pluginLogin(Object args)wx.login(Object object)wx.checkSession(Object object) 几 个 API

  • 账号信息: 包括Object wx.getAccountInfoSync()此接口用来获取开发者的账号信息。

  • 用户信息: 包括 wx.getUserProfile(Object object)wx.getUserInfo(Object object)UserInfo。使用频率非常高的接口,常用于小程序中获取登录者个人公开数据。

  • 授权接口:wx.authorizeForMiniProgram(Object object)wx.authorize(Object object)


除上述列出的子类接口,还有收货地址、生物认证……等诸多子类 API,有兴趣者可以自行了解。


2.1 登录接口


登录接口中有 3API,对于开发者来说,使用频率较高的是 login接口,此环节将重点介绍此接口。



非本文特别关注的接口,会简略带过。



wx.pluginLogin(Object args):此接口只能在插件中可以调用,调用此接口获得插件用户的标志凭证code,插件可使用此凭证换取用于识别用户的唯一标识 OpenpId


用户不同、宿主小程序不同或插件不同的情况下,该标识均不相同,即当且仅当同一个用户在同一个宿主小程序中使用同一个插件时,OpenpId 才会相同。


对于一般开发者,此 接口用的不是很多,具体使用细节在此处也不做过多复述。



什么是 OpenId?


当微信用户登录公众号或小程序时,微信平台为每一个微信登录者分配的一个唯一标识符号。



2.1.1 wx.login(Object object)


功能描述:




  • 开发者使用此接口可以获取到微信登录者登录凭证(code)



    登录凭证具有临时性,也就是每次调用时都会不一样,所以code 只能使用一次。





  • 开发者可以通过临时code,再向微信接口服务器索取登录者的唯一标识符 OpenId、微信开发平台账号的唯一标识 UnionID(需要当前小程序已绑定到微信开放平台帐号)、以及会话密钥 session_key




那么,获取到的openIdsession_key对于开发者而言,有什么实质性的意义?




  • 根据 OpenId的唯一性特点,可以在微信用户第一次登录时,把OpenID保存在数据库或缓存中,在后续登录时,只需要检查用户的 OpenId是否存在于数据库或缓存中,便能实现自动登录功能。




  • session_key 也称会话密钥,用来解密微信登录者的敏感数据。



    后文将详细介绍。





如何获取OpenId


现通过一个简单案例,实现微信小程序端与开发者服务器之间的数据交互。以此了解开发者服务器如何通过微信小程序传递过来的用户临时 code换取到登录者的更多信息。


实现之前,先通过一个简易演示图了解其过程。


wx01.png


简单描述整个请求过程:



  • 微信用户打开微信小程序后,开发者在微信小程序中通过调用wx.login接口获取到临时登录凭证 code

  • 在微信小程序中调用 wx.request 接口向开发者服务器发送 http 请求,需要把登录凭证 code一并发送过去。

  • 开发者服务器使用发送过来的 code 以及开发者凭证信息向微信接口服务器索取微信登录者的 openIdsession_key


简而言之,就是 3 者(微信小程序、开发者服务器、微信接口服务器)之间的一个击鼓传花游戏。


开发流程:


第一步:项目结构分析


完整的系统由 2 个部分组成:




  • 微信小程序端 APP



    如对微信小程序开发不是很了解,请先阅读官方提供的相关文档。





  • 服务器端应用程序。



    本文的服务器端应用程序基于 Spring Boot开发平台。





本项目结构是标准的前后端分离模式,微信小程序是前端应用,服务器端应用程序为后台应用。


第二步:新建微信小程序(前端应用)


打开微信开发工具,新建一个名为 guokeai 的小程序项目 ,项目会初始化一个index 页面。在 index.js中编写如下代码。


//index.js
const app = getApp()
const httpRequest = require("../../utils/request.js")

Page({
data: {
isHasUserInfo: null,
userInfo: null
},
//启动时
onLoad: function () {
let this_ = this
/***
* 检查微信用户是否已经登录到后台服务器
* 已经登录的标志,数据库中存在 OPENID
*/

let code = null
//调用 login 接口
wx.login({
success: (res) => {
//得到登录用户的临时 code
code = res.code
//向开发者服务器发送请求
let api = "wx/getLoginCertificate"
let config = {
url: api,
method: "GET",
data: {
code: code
}
}
let promise = httpRequest.wxRequest(config)
promise.then(res => {
let isHas = null
// 有没有完整的微信登录者信息
isHas = res.data == 0 ? false : true
app.globalData.isHasUserInfo = isHas
this_.setData({
isHasUserInfo: isHas
})
}).catch(res => {
console.log("fail", res)
});
}
})
}
})

代码解释:



  • 一般会在微信小程序启动时,也就是在页面onload 函数中调用 wx.login接口,检查用户是否登录过。

  • http://127.0.0.1:8080/wx/getLoginCertificate开发者服务器提供的对外处理微信用户信息的接口。

  • 最后只是简单地输出开发者服务器端返回的数据。

  • httpRequest.wxRequest(config)是自定义的封装wx.request接口的请求组件。


function wxRequest(config) {
//返回的数据类型
let dataType = config.dataType == null ? "json" : config.dataType;
let responseType = config.responseType == null ? "text" : config.responseType;
//服务器基地址
let serverUrl = "http://127.0.0.1:8080/"
//超时
let timeout = config.timeout == null ? 50000 : config.timeout;
//目标地址,基地址+接口
let url = serverUrl + config.url;
//数据提交方式
let method = config.method == null ? "GET" : config.method;
//提交数据
let data = config.data == null ? null : config.data
//头信息
let header = {
// 默认值
'content-type': 'application/json',
'x-requested-with': 'XMLHttpRequest'
}
let sessionId = wx.getStorageSync('sessionId')
if (sessionId) {
header["cookie"] = sessionId
}
return new Promise(function (resolve, reject) {
wx.request({
url: url,
data: data,
//返回的数据类型(json)
dataType: dataType,
enableCache: false,
enableHttp2: false,
enableQuic: false,
method: method,
header: header,
responseType: responseType,
timeout: timeout,
success: (res) => {
console.log("requestData", res)
if (res.cookies != null && res.cookies.length != 0)
wx.setStorageSync('sessionId', res.cookies[0])
resolve(res)
},
fail: (res) => {
console.log("requestException", res)
reject(res)
}
})
})
}

第三步:创建开发者服务器程序(后台应用)


本文使用 spring boot快速搭建后台应用程序。在项目的 pom.xml文件中除了必要的依赖包外,还需要添加以下 的依赖包。


<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.73</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>



  • fastjson阿里云提供的开源 JSON解析框架。



    微信小程序开发者服务器构建的项目结构,是标准的前后端分离模式。


    请求与响应时,数据交互常使用JSON格式。这时使用 fastjson 作为json解析器,当然,也可以选择其它的类似解析器。





  • httpclient 是一个http请求组件。




  • mysql-connector-java 本文案例使用 MySQL数据库,需要加载相应的驱动包。




  • mybatis-plus-boot-startermybatis-plus 依赖包。




在后台应用中编写处理器(响应)组件:


@RestController
@RequestMapping("/wx")
public class WxAction {
@Autowired
private IWxService wxService;
/***
* 获取到微信用户的 OPENID
*/

@GetMapping("/getLoginCertificate")
public String getLoginCertificate(@RequestParam("code") String code) throws Exception {
WxUserInfo wxInfo = this.wxService.getLoginCertificate(code);
//用户不存在,或者用户的信息不全
return wxInfo==null || wxInfo.getNickName()==null?"0":"1";
}

代码解释:



  • IWxService是处理器依赖的业务组件,提供有 getLoginCertificate()方法用来实现通过code微信接口服务器换取微信登录者的 openIdsession_key


编写业务组件:


@Service
public class WxService implements IWxService {
@Override
public WxUserInfo getLoginCertificate(String code) throws Exception {
//请求地址
String requestUrl = WxUtil.getWxServerUrl(code);
// 发送请求
String response = HttpClientUtils.getRequest(requestUrl);
//格式化JSON数据
WxUserInfo wxUserInfo = JSONObject.parseObject(response, WxUserInfo.class);
//检查数据库中是否存在 OPENID
WxUserInfo wxUserInfo_ = this.wxUserMapper.selectById(wxUserInfo.getOpenId());
if (wxUserInfo_ == null) {
//数据库中没有用户的 OPENID,添加到数据库中
this.wxUserMapper.insert(wxUserInfo);
} else {
if (!wxUserInfo.getSessionKey().equals(wxUserInfo_.getSessionKey())) {
//如果数据库保存的session_key和最新的session_key 不相同,则更新
wxUserInfo_.setSessionKey(wxUserInfo.getSessionKey());
this.wxUserMapper.updateById(wxUserInfo_);
}
}
return wxUserInfo_;
}
}

代码解释:




  • WxUtil 是自定义的一个工具组件,用来构建请求微信接口服务器url


    https://api.weixin.qq.com/sns/jscode2session微信接口服务器对外提供的接口,请求此接口时,需要提供 4 个请求数据。


    appid:小程序 appId。


    secret:小程序 appSecret。


    js_code:获取到的微信登录者的临时 code


    grant_type:授权类型,此处只需填写 authorization_code




public class WxUtil {
private final static String APP_ID = "微信小程序开发者申请的 appid";
private final static String APP_SECRET = "微信小程序开发者申请的 APP_SECRET";
//
private final static String WX_LOGIN_SERVER_URL = "https://api.weixin.qq.com/sns/jscode2session?appid={0}&secret={1}&js_code={2}&grant_type=authorization_code";
public static String getWxServerUrl(String code) throws IOException {
String url = MessageFormat.format(WX_LOGIN_SERVER_URL, new String[]{APP_ID, APP_SECRET, code});
return url;
}
}


  • HttpClientUtils也是一个自定义组件,用来向指定的服务器发送 http请求。


public class HttpClientUtils {
/**
* GET请求
*/

public static String getRequest(String url) throws Exception {
//HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = null;
try {
HttpGet httpGet = new HttpGet(url);
response = httpClient.execute(httpGet);
//响应体
HttpEntity entity = response.getEntity();
if (entity != null) {
//格式化响应体
return EntityUtils.toString(entity);
}
} catch (ClientProtocolException e) {
throw e;
} catch (IOException e) {
throw e;
} finally {
response.close();
httpClient.close();
}
return null;
}
}


  • WxUserInfo 是自定义的数据封装类。微信接口服务器返回的数据是以JSON格式组装的,这里需要格式成对象数据,便于在 java中处理。本文使用 MyBatisPlus操作数据库,此类也对应数据库中的gk_wx_user表。


@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("gk_wx_user")
public class WxUserInfo {
//OPEN_id
@TableId(type = IdType.ASSIGN_ID, value = "open_id")
private String openId;
//会话密钥
@TableField(value = "session_key")
private String sessionKey;
//头像路径
@TableField("avatar_url")
private String avatarUrl;
//城市
private String city;
//国家
private String country;
//性别
private String gender;
//语言
private String language;
//昵称
@TableField("nick_name")
private String nickName;
//备注名或真实名
@TableField("real_name")
private String realName;
//省份
private String province;
//学生ID
@TableField("stu_id")
private Integer stuId;
}

MyBatis 数据库映射组件:


@Repository
public interface WxUserMapper extends BaseMapper<WxUserInfo> {

}

第四步:测试。


先启动后台应用程序,再启动微信小程序,可以在数据库表中查看到如下信息。


数据库.png


微信用户的openidsession_key已经保存到后台的数据库表中。


2.1.2 wx.checkSession(Object object)


官方文档中,有一段对 session_key的生命周期的描述。



  • session_key的生命周期有不确定性,可以使用 wx.login接口刷新 session_key。为了避免频繁调用 wx.login 接口,可以通过调用 wx.checkSession(Object object)接口判断session_key是否已经过期。

  • 当开发者在实现自定义登录态时,可以考虑以 session_key 有效期作为自身登录态有效期,也可以实现自定义的时效性策略。


wx.checkSession 的功能,可以使用此接口判断session_key是否过期。



  • 调用成功说明当前 session_key 未过期。

  • 调用失败说明 session_key 已过期。


2.2 用户信息接口


wx.login接口仅能获取到微信登录者的有限数据,如果想要获取到登录者的更多个人信息,可以使用用户信息接口中的相关API



  • wx.getUserProfile(Object object)。获取用户信息,页面产生点击事件(例如 buttonbindtap 的回调中)后才可调用,每次请求都会弹出授权窗口,用户同意后返回 userInfo

  • wx.getUserInfo(Object object) 。和 wx.getUserProfile的功能一样,在基础库 2.10 的后续版本中,其功能已经被削弱。

  • UserInfo是用户信息封装类。


getUserProfile是从 基础库2.10.4版本开始支持的接口,该接口用来替换 wx.getUserInfo,意味着官方不建议再使用getUserInfo接口获取用户的个人信息。


下图是官方提供的 2 个接口的功能对比图。


接口调整.png


为了避免频繁弹窗,可以在第一次获取到用户信息后保存在数据库中以备以后所用。为了获取到用户的敏感数据,在后台要通过getUserProfile接口所获取的数据进行解密操作。


2.2.2 wx.getUserProfile


下面通过具体代码讲解如何保存微信登录者的个人数据。先了解一下整个数据获取的流程,这里直接截取官方提供的一张流程图。


解密码.jpg


获取微信登录者的个人信息,需要经过 2 个步骤。


签名效验:



  • 通过调用wx.getUserProfile接口获取数据时,接口会同时返回 rawDatasignature,其中 signature = sha1( rawData + session_key )

  • 开发者将 signaturerawData 发送到开发者服务器进行校验。服务器利用用户对应的 session_key 使用相同的算法计算出签名 signature2 ,比对signaturesignature2 即可校验数据的完整性。


解密加密数据:



  • 对称解密使用的算法为 AES-128-CBC,数据采用PKCS#7填充。

  • 对称解密的目标密文为 Base64_Decode(encryptedData)

  • 对称解密秘钥 aeskey = Base64_Decode(session_key), aeskey16字节。

  • 对称解密算法初始向量 为Base64_Decode(iv),其中iv由数据接口返回。


具体编写实现。


**第一步:**在微信小程序端编码。


index.wxml页面中添加一个按钮,并注册bindtap事件。


<view>
<button bindtap="getUserProfile">获取用户数据</button>
</view>

index.js中添加一个名为getUserProfile的事件回调函数。为了避免不必要的弹窗,只有当后台没有获取到个人数据时,才调用wx.getUserProfile接口。


getUserProfile: function (e) {
let this_ = this
if (!this.data.isHasUserInfo) {
//如果服务器端没有保存完整的微信登录者信息
wx.getUserProfile({
desc: '需要完善您的资料!',
success: (res) => {
this_.setData({
//小程序中用来显示个人信息
userInfo: res.userInfo,
isHasUserInfo: true
})
//再次登录,因为 session_key 有生命中周期
wx.login({
success(res_) {
//保存到服务器端
let config = {
url: "wx/wxLogin",
method: "GET",
data: {
code: res_.code,
//明文数据
rawData: res.rawData,
//加密数据
encryptedData: res.encryptedData,
iv: res.iv,
//数字签名
signature: res.signature
}
}
let promise = httpRequest.wxRequest(config)
promise.then(res => {
//返回
console.log("wxLogin", res)
}).catch(res => {
console.log("fail", res)
});
}
})
}
})
}
}

服务器端代码:


pom.xml文件中添加如下依赖包,用来解密数据。


<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk16</artifactId>
<version>1.46</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>

在处理器类WxAction中添加wxLogin响应方法。


@RestController
@RequestMapping("/wx")
public class WxAction {
@Autowired
private IWxService wxService;
/***
*
* @param code
* @param rawData
* @param encryptedData
* @param iv
* @param signature
* @return
* @throws Exception
*/

@GetMapping("/wxLogin")
public WxUserInfo wxLogin(@RequestParam("code") String code, @RequestParam("rawData") String rawData,
@RequestParam("encryptedData") String encryptedData, @RequestParam("iv") String iv,
@RequestParam("signature") String signature)
throws Exception {
WxUserInfo wxInfo = this.wxService.getWxUserInfo(code, rawData, encryptedData, iv, signature);
return wxInfo;
}
}

业务代码:


小程序中传递过来的数据是经过base64编码以及加密的数据,需要使用 Base64解码字符串,再使用解密算法解密数据。先提供一个解密方法。


public String decrypt(String session_key, String iv, String encryptData) {

String decryptString = "";
//解码经过 base64 编码的字符串
byte[] sessionKeyByte = Base64.getDecoder().decode(session_key);
byte[] ivByte = Base64.getDecoder().decode(iv);
byte[] encryptDataByte = Base64.getDecoder().decode(encryptData);

try {
Security.addProvider(new BouncyCastleProvider());
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
//得到密钥
Key key = new SecretKeySpec(sessionKeyByte, "AES");
//AES 加密算法
AlgorithmParameters algorithmParameters = AlgorithmParameters.getInstance("AES");
algorithmParameters.init(new IvParameterSpec(ivByte));
cipher.init(Cipher.DECRYPT_MODE, key, algorithmParameters);
byte[] bytes = cipher.doFinal(encryptDataByte);
decryptString = new String(bytes);
} catch (Exception e) {
e.printStackTrace();
}
return decryptString;
}

具体获取数据的业务实现:


@Override
public WxUserInfo getWxUserInfo(@NotNull String code, @NotNull String rawData, @NotNull String encryptedData, @NotNull String iv, @NotNull String signature) throws Exception {
//会话密钥
WxUserInfo wxUserInfo = this.getLoginCertificate(code);
String signature2 = DigestUtils.sha1Hex(rawData + wxUserInfo.getSessionKey());
if (!signature.equals(signature2)) {
throw new Exception("数字签名验证失败");
}
//数字签名验证成功,解密
String infos = this.decrypt(wxUserInfo.getSessionKey(), iv, encryptedData);
//反序列化 JSON 数据
WxUserInfo wxUserInfo_ = JSONObject.parseObject(infos, WxUserInfo.class);
wxUserInfo_.setSessionKey(wxUserInfo.getSessionKey());
wxUserInfo_.setOpenId(wxUserInfo.getOpenId());
//更新数据库
this.wxUserMapper.updateById(wxUserInfo_);
return wxUserInfo_;
}

测试,启动微信小程序和后台应用,在小程序中触发按钮事件。


wx03.png


在弹出的对话框中,选择允许


wx04.png


查看后台数据库表中的数据。


wx05.png


能够获取到的微信登录者个人信息都保存到了数据库表中。至于怎么使用这些数据,可以根据自己的业务需要定制。


3.总结


微信开发平台,提供有诸多接口,可以帮助开发者获取到有用的数据。本文主要介绍 wx.loginwx.getProfile接口,因篇幅所限,不能对其它接口做详细介绍 ,有兴趣者可以查阅官方文档。


官方文档只会对接口功能做些介绍 ,如要灵活运用这些接口,还需要结合实际需要演练一下,如此方能有切身体会。


作者:一枚大果壳
来源:juejin.cn/post/7098216504302403591
收起阅读 »

序列化和反序列化

序列化隐秘的吭,你踩过了没? 序列化和反序列化 Java序列化的目的主要有2个: 网络传输 对象持久化 当2个相对独立的进程,需要进行跨进程服务调用时,就需要把被传输的Java对象编码为字节数组或者ByteBuffer对象。 接收方只需要把这些字节数...
继续阅读 »

序列化隐秘的吭,你踩过了没?


序列化和反序列化



Java序列化的目的主要有2个:




  • 网络传输

  • 对象持久化


image-20230301144505527


当2个相对独立的进程,需要进行跨进程服务调用时,就需要把被传输的Java对象编码为字节数组或者ByteBuffer对象


接收方只需要把这些字节数组或者Bytebuf对象重新解码成内存对象即可实现通信、调用的作用。


image-20230301145117301


那么在我们使用序列化的时候有哪些需要注意的,避免的坑呢?


成员变量不能以is开头



阿里的《Java开发手册》明文规定了:成员变量禁止使用类似 isXxxx 的命名方式,也不要有isXxx命名的方法



image-20230301150018030


image-20230301145401694


大概的意思就是:不要加is前缀,因为部分框架序列化的时候,会以为对应的字段名是isXxxx后面的Xxxx



  • 比如:isSucceed序列化成Succeed,前端读取isSucceed的时候就会发现没有这个字段,然后出错了。


u=4214115302,3196714167&fm=253&fmt=auto&app=120&f=JPEG


这里面的序列化框架其实就是fastjson,我们可以直接去看他的源码


fastjson源码分析:computeGetters



去找get前缀的方法,然后进行字符串切割找到get后面的



image-20230301161434898



去找is前缀的方法,然后进行字符串切割



image-20230301161413220



  • 这里还进行了驼峰命名的判断:ixXxx,第三个字符是否是大写等判断


所以isSucceed字段会被fastjson框架认为Succeed字段。


image.png


默认值



成员变量的默认值同样会带来坑



同样是阿里的《Java开发手册》里面也是规定了:POJO类、RPC方法必须使用包装类型


image.png


关于包装类型和基本类型的区别,如果还有不清楚的,赶紧去看,这是最基础的面试知识点..


POJO类必须使用包装类型



尽量让错误暴露在编译期,不要拖到运行期



基本类型具有初始值,比如:



  • Int:0

  • float:0.0f

  • boolean:false


一个统计点赞的接口里面的返回值包含一个表示点赞数变化的字段,当发生错误的时候,这个字段没有进行赋初始值,就会出现以下情况:



  • 基本类型:读默认值,0,表达的意思就是没有点赞数变化,程序上并不知道是服务器那边出了错。

  • 包装类型:读到了个null,程序上是知道服务器那边出错了,可以进行对应的显示,比如显示 - ,表示读取不到等操作。


u=3180711090,4079282331&fm=253&fmt=auto&app=138&f=JPEG


总的来说就是:如果字段设置为基础类型并且基础类型的默认值具有业务意义,那么就会出错,并且无法感知错误


RPC方法的返回值和参数必须使用包装类型



RPC调用常常具有超时导致调用失败的情况



如果用包装类型,那么在接收方,就能感知到,这次RPC调用是成功,还是失败。


包装数据类型的null值具有表示额外的信息功能



彦祖来都来了,点个赞👍再走吧,这对我来说真的非常重要



作者:Ashleejy
来源:juejin.cn/post/7205478140914843709
收起阅读 »

扯什么 try-catch 性能问题?

“yes,你看着这鬼代码,竟然在 for 循环里面搞了个 try-catch,不知道try-catch有性能损耗吗?”老陈煞有其事地指着屏幕里的代码: for (int i = 0; i < 5000; i++) { try { ...
继续阅读 »

“yes,你看着这鬼代码,竟然在 for 循环里面搞了个 try-catch,不知道try-catch有性能损耗吗?”老陈煞有其事地指着屏幕里的代码:


 for (int i = 0; i < 5000; i++) {
try {
dosth
} catch (Exception e) {
e.printStackTrace();
}
}

我探过头去看了眼代码,“那老陈你觉得该怎么改?”


“当然是把 try-catch 提到外面啊!”老陈脑子都不转一下,脱口而出。


“你是不是傻?且不说性能,这代码的目的明显是让循环内部单次调用出错不影响循环的运行,你其到外面业务逻辑不就变了吗!”


老陈挠了挠他的地中海,“好像也是啊!”



“回过头来,catch 整个 for 循环和在循环内部 catch,在不出错的情况下,其实性能差不多。” 我喝一口咖啡不经意地提到,准备在老陈前面秀一下。


“啥意思?”老陈有点懵地看着我,“try-catch是有性能损耗的,我可是看过网上资料的!”


果然,老陈上钩了,我二话不说直接打开 idea,一顿操作敲了以下代码:


public class TryCatchTest {

@Benchmark
public void tryfor(Blackhole blackhole) {
try {
for (int i = 0; i < 5000; i++) {
blackhole.consume(i);
}
} catch (Exception e) {
e.printStackTrace();
}
}

@Benchmark
public void fortry(Blackhole blackhole) {
for (int i = 0; i < 5000; i++) {
try {
blackhole.consume(i);
} catch (Exception e) {
e.printStackTrace();
}
}
}

}

“BB 不如 show code,看到没,老陈,我把 try-catch 从 for 循环里面提出来跟在for循环里面做个对比跑一下,你猜猜两个差多少?”


“切,肯定 tryfor 性能好,想都不用想,不是的话我倒立洗头!”老陈信誓旦旦道。


我懒得跟他BB,直接开始了 benchmark,跑的结果如下:



可以看到,两者的性能(数字越大越好)其实差不多:



  • fortry: 86,261(100359-14098) ~ 114,457(100359+14098)

  • tryfor: 95,961(103216-7255) ~ 110,471(103216+7255)


我再调小(一般业务场景 for 循环次数都不会很多)下 for 循环的次数为 1000 ,结果也是差不多:



老陈一看傻了:“说好的性能影响呢?怎么没了?”


我直接一个javap,让老陈看看,其实两个实现在字节码层面没啥区别:



tryfor 的字节码



异常表记录的是 0 - 20 行,如果这些行里面的代码出现问题,直接跳到 23 行处理




fortry 的字节码



差别也就是异常表的范围小点,包的是 9-14 行,其它跟 tryfor 都差不多。



所以从字节码层面来看,没抛错两者的执行效率其实没啥差别。


“那为什么网上流传着try-catch会有性能问题的说法啊?”老陈觉得非常奇怪。


这个说法确实有,在《Effective Java》这本书里就提到了 try-catch 性能问题:



并且还有下面一段话:



正所谓听话不能听一半,以前读书时候最怕的就是一知半解,因为完全理解选择题能选对,完全不懂蒙可能蒙对,一知半解必定选到错误的选项!


《Effective Java》书中说的其实是不要用 try-catch 来代替正常的代码,书中的举例了正常的 for 循环肯定这样实现:



但有个卧龙偏偏不这样实现,要通过 try-catch 拐着弯来实现循环:



这操作我只能说有点逆天,这两个实现的对比就有性能损耗了


我们直接再跑下有try-catch 的代码和没 try-catch的 for 循环区别,代码如下:



结果如下:



+-差不多,直接看前面的分数对比,没有 ry-catch 的性能确实好些,这也和书中说的 try-catch 会影响 JVM 一些特定的优化说法吻合,但是具体没有说影响哪些优化,我猜测可能是指令重排之类的。


好了,我再总结下有关 try-catch 性能问题说法:



  1. try-catch 相比较没 try-catch,确实有一定的性能影响,但是旨在不推荐我们用 try-catch 来代替正常能不用 try-catch 的实现,而不是不让用 try-catch

  2. for循环内用 try-catch 和用 try-catch 包裹整个 for 循环性能差不多,但是其实两者本质上是业务处理方式的不同,跟性能扯不上关系,关键看你的业务流程处理。

  3. 虽然知道try-catch会有性能影响,但是业务上不需要避讳其使用,业务实现优先(只要不是书中举例的那种逆天代码就行),非特殊情况下性能都是其次,有意识地避免大范围的try-catch,只 catch 需要的部分即可(没把握全 catch 也行,代码安全执行第一)。


“好了,老陈你懂了没?”


“行啊yes,BB是一套一套的,走请你喝燕麦拿铁!” 老陈一把拉起我,我直接一个挣脱,“少来,我刚喝过咖啡,你那个倒立洗头,赶紧的!”我立马意识到老陈想岔开话题。


“洗洗洗,我们先喝个咖啡,晚上回去给你洗!”


晚上22点,老陈发来一张图片:



你别说,这头发至少比三毛多。


我是yes,我们下篇见~


作者:yes的练级攻略
来源:juejin.cn/post/7204121228016091197
收起阅读 »

Java 中为什么要设计 throws 关键词,是故意的还是不小心

我们平时在写代码的时候经常会遇到这样的一种情况 提示说没有处理xxx异常 然后解决办法可以在外面加上try-catch,就像这样 所以我之前经常这样处理 //重新抛出 RuntimeException public class ThrowsDemo { ...
继续阅读 »

我们平时在写代码的时候经常会遇到这样的一种情况


throws.png


提示说没有处理xxx异常


然后解决办法可以在外面加上try-catch,就像这样


trycatch.png


所以我之前经常这样处理


//重新抛出 RuntimeException
public class ThrowsDemo {

public void demo4throws() {
try {
new ThrowsSample().sample4throws();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

//打印日志
@Slf4j
public class ThrowsDemo {

public void demo4throws() {
try {
new ThrowsSample().sample4throws();
} catch (IOException e) {
log.error("sample4throws", e);
}
}
}

//继续往外抛,但是需要每个方法都添加 throws
public class ThrowsDemo {

public void demo4throws() throws IOException {
new ThrowsSample().sample4throws();
}
}

但是我一直不明白


这个方法为什么不直接帮我做


反而要让我很多余的加上一步


我处理和它处理有什么区别吗?


而且变的好不美观


本来缩进就多,现在加个try-catch更是火上浇油


public class ThrowsDemo {

public void demo4throws() {
try {
if (xxx) {
try {
if (yyy) {

} else {

}
} catch (Throwable e) {
}
} else {

}
} catch (IOException e) {

}
}
}

上面的代码,就算里面没有业务,看起来也已经比较乱了,分不清哪个括号和哪个括号是一对


还有就是对Lambda很不友好


lambda.png


没有办法直接用::来优化代码,所以就变成了下面这样


lambdatry.png


本来看起来很简单很舒服的Lambda,现在又变得又臭又长


为什么会强制 try-catch


为什么我们平时写的方法不需要强制try-catch,而很多jdk中的方法却要呢


那是因为那些方法在方法的定义上添加了throws关键字,并且后面跟的异常不是RuntimeException


一旦你显式的添加了这个关键字在方法上,同时后面跟的异常不是RuntimeException,那么使用这个方法的时候就必须要显示的处理


比如使用try-catch或者是给调用这个方法的方法也添加throws以及对应的异常


throws 是用来干什么的


那么为什么要给方法添加throws关键字呢?


给方法添加throws关键字是为了表明这个方法可能会抛出哪些异常


就像一个风险告知


这样你在看到这个方法的定义的时候就一目了然了:这个方法可能会出现什么异常


为什么 RuntimeException 不强制 try-catch


那为什么RuntimeException不强制try-catch呢?


因为很多的RuntimeException都是因为程序的BUG而产生的


比如我们调用Integer.parseInt("A")会抛出NumberFormatException


当我们的代码中出现了这个异常,那么我们就需要修复这个异常


当我们修复了这个异常之后,就不会再抛出这个异常了,所以try-catch就没有必要了


当然像下面这种代码除外


public boolean isInteger(String s) {
try {
Integer.parseInt(s);
return true;
} catch (NumberFormatException e) {
return false;
}
}

这是我们利用这个异常来达成我们的需求,是有意为之的


而另外一些异常是属于没办法用代码解决的异常,比如IOException


我们在进行网络请求的时候就有可能抛出这类异常


因为网络可能会出现不稳定的情况,而我们对这个情况是无法干预的


所以我们需要提前考虑各种突发情况


强制try-catch相当于间接的保证了程序的健壮性


毕竟我们平时写代码,如果IDE没有提示异常处理,我们完全不会认为这个方法会抛出异常


我的代码怎么可能有问题.gif


我的代码怎么可能有问题!


不可能绝对不可能.gif


看来Java之父完全预判到了程序员的脑回路


throws 和 throw 的区别


java中还有一个关键词throw,和throws只有一个s的差别


throw是用来主动抛出一个异常


public class ThrowsDemo {

public void demo4throws() throws RuntimeException {
throw new RuntimeException();
}
}

两者完全是不同的功能,大家不要弄错了


什么场景用 throws


我们可以发现我们平时写代码的时候其实很少使用throws


因为当我们在开发业务的时候,所有的分支都已经确定了


比如网络请求出现异常的时候,我们常用的方式可能是打印日志,或是进行重试,把异常往外抛等等


所以我们没有那么有必要去使用throws这个关键字来说明异常信息


但是当我们没有办法确定异常要怎么处理的时候呢?


比如我在GitHub上维护了一个功能库,本身没有什么业务属性,主要就是对于一些复杂的功能做了相应的封装,提供给自己或别人使用(如果有兴趣可以看看我的库,顺便给Star,嘿嘿


对我来说,当我的方法中出现异常时,我是不清楚调用这个方法的人是想要怎么处理的


可能有的想要重试,有的想要打印日志,那么我干脆就往外抛,让调用方法的人自己去考虑,自己去处理


所以简单来说,如果方法主要是给别人用的最好用throws把异常往外抛,反之就是可加可不加


结束


很多时候你的不理解只是因为你还不够了解


作者:不够优雅
来源:juejin.cn/post/7204594495996100664
收起阅读 »

Flutter动态化调研实践

一,前言 1,什么是动态化? 目前移动端应用的版本更新, 最常见的方式是定期发版,无论是安卓还是iOS,都需要提交新的安装包到应用市场进行审核。审核通过后,用户在应用市场进行App的下载更新。 而动态化, 就是不依赖更新程序安装包, 就能动态实时更新页面的技术...
继续阅读 »

一,前言


1,什么是动态化?


目前移动端应用的版本更新, 最常见的方式是定期发版,无论是安卓还是iOS,都需要提交新的安装包到应用市场进行审核。审核通过后,用户在应用市场进行App的下载更新。


而动态化, 就是不依赖更新程序安装包, 就能动态实时更新页面的技术。


2,动态化的必要性


为什么需要动态化技术呢? 因为上述定期发版更新应用的方式存在一些问题,比如:



  1. 审核周期长, 且可能审核不通过。 周期长导致发版本不够灵活, 紧急的业务需求不能及时上线。

  2. 线上出现急需修复的bug时,需要较长修复周期,影响用户体验。

  3. 安装包过大, 动辄几十兆几百兆的应用升级可能会让用户比较抗拒。

  4. 即使上线了,也无法达到全部用户升级, 服务端存在兼容多版本App的问题。


面对这些问题,如果能实现app增量、无感知更新,实现功能同步。无论是对公司还是用户都是非常重要的需求,能实现app动态化更新就显得非常重要,能很好的解决以上问题:



  1. 随时实现功能升级,不存在应用市场长时间审核和拒绝上线问题,达到业务需求快速上线的目的。

  2. 线上bug可以实时修复,提高用户体验。

  3. 可以减小发版功能包体积,只需要替换新增功能即可。

  4. 功能保持一致,类似网页一样,发版后用户同步更新,不存在旧版本兼容问题。


接下来,我们就来分析一下,目前业内主要的Flutter动态化更新方式。


二,动态化方案调研


在Flutter实践层面,简单来说分为三个流派:




  • 方案一:JavaScript是最好的语言(🤣碰瓷PHP)
    主要思路:利用Flutter做渲染,开发使用js,逻辑层通过v8/jscore解释运行。代表框架是腾讯的MXFlutter。这个框架是开源的,大写的👍。




  • 方案三:布局,逻辑,一把梭


    主要思路:与方案一最主要的区别是,逻辑层也是使用dart,增加了一层语法解析和运行时。有一个代表,美团的MTFlutter,然而没有开源动向,无从考察更多。




  • 方案二:DSL + JS


    主要思路:基于模板实现动态化,主要布局层采用Dart转DSL的方式,逻辑层使用JS。代表框架是58同城开源的Fair




MXFlutter


项目简介



MXFlutter 是一套基于 JavaScript 的 Flutter 框架,可以用极其类似 Dart 的开发方式,通过编写 JavaScript 代码,来开发 Flutter 应用,或者使用 mxjsbuilder 编译器,把现有Flutter 工程编译为JS,运行在 mxflutter 之上。



核心思想



核心思路是把 Flutter 的渲染逻辑中的三棵树中的第一棵,放到 JavaScript 中生成。用 JavaScript 完整实现了 Flutter 控件层封装,可以使用 JavaScript,用极其类似 Dart 的开发方式,开发Flutter应用,利用JavaScript版的轻量级Flutter Runtime,生成UI描述,传递给Dart层的UI引擎,UI引擎把UI描述生产真正的 Flutter 控件。



MxFlutter 目前已经停止维护,具体请看MXFlutter
MxFlutter通过JavaScript编写Dart,加载线上js文件,通过引擎在运行时转化并显示,从而达到动态化效果。 官方在0.7.0版本开始接入TypeScript,引入npm生态,优化了js开发的成本,向前端生态进一步靠拢。
很遗憾,在对比各大厂的方案时,发现MxFlutter的性价比极低,学习成本也高,而且又抛弃Dart生态。开发及维护成本都很高。


MTFlutter


项目简介



美团的MTFlutter团队flap项目采用的静态生产DSL方案,通过对Dart语言注解,保证平台一致性。实现了动态下发与解释的逻辑页面一体化的 Flutter 动态化方案。Flap 的出现让 Flutter 动态化和包大小这两个短板得到了一定程度的弥补,促进了 Flutter 生态的发展。



核心思想



通过静态生产 DSL+Runtime 解释运行的思路,实现了动态下发与解释的逻辑页面一体化的 Flutter 动态化方案,建设了一套 Flap 生态体系,涵盖了开发、发布、测试、运维各阶段。



布局和逻辑层都使用Dart, 增加了一层语法解析和运行时。然而没有开源动向,无从考察更多。


Fair


项目简介



Fair是为Flutter设计的动态化框架,通过Fair Compiler工具对原生Dart源文件的自动转化,使项目获得动态更新Widget Tree和State的能力。


创建Fair的目标是支持不发版(Android、iOS、Web)的情况下,通过业务bundle和JS下发实现更新,方式类似于React Native。与Flutter Fair集成后,您可以快速发布新的页面,而无需等待应用的下一个发布日期。Fair提供了标准的Widget,它可以被用作一个新的动态页面或作为现有Flutter页面的一部分,诸如运营位的排版/样式修改,整页面替换,局部替换等都可以使用。



核心思想



Fair是58自研的的动态化框架,通过Fair Compiler工具对原生Dart源文件的自动转化,使项目获得动态更新Widget Tree和State的能力。



pic_d3WbXUd1d1V9d1WcXU37U7U75aXdd17b


三,方案对比


经过上述三个方案的调研,我们来大概对比一下上述框架


方案开源方核心思想优点缺点
MXFlutter腾讯用js编写Dart,动态拉取js脚本目前相对最完整的Flutter使用JS开发方案采用js方式编写Dart,维护困难
MTFlutter美团布局,逻辑都使用Dart,增加语法解析和运行时支持布局动态化和逻辑动态化未开源
Fair58通过bundle和js实现热更新支持布局动态化和逻辑动态化开源社区活跃, 开发工具丰富部分语法不支持

可以看到, MXFlutter需要使用js写Dart, 官方已经停止更新,而这种方式我们不能接受, MTFlutter目前未开源,无从继续研究。 接下来着重看一下 Fair


四,Fair接入过程


1,添加依赖


推荐使用pub形式引入


# add Fair dependency
dependencies:
fair: 2.7.0

# add compiler dependency
dev_dependencies:
build_runner: ^2.0.0
fair_compiler: ^1.2.0

# switch "fair_version" according to the local Flutter SDK version
dependency_overrides:
fair_version: 3.0.0

Flutter版本切换


通过切换 flutter_version 版本进行版本兼容。例如,将本机切换为 flutter 2.0.6 后,Fair 需要同步切换


# switch to another stable flutter version
dependency_overrides:
fair_version: 2.0.6

2,使用 Fair


在App中接入Fair步骤如下:


将 FairApp 添加为需要动态化部分的顶级节点

常见做法是作为 App 的根节点,如果不是全局采用也可以作为子页面的根节点


void main() {
WidgetsFlutterBinding.ensureInitialized();

FairApp.runApplication(
_getApp(),
plugins: {
},
);
}

dynamic _getApp() => FairApp(
modules: {
},
delegate: {
},
child: MaterialApp(
home: FairWidget(
name: 'DynamicWidget',
path: 'assets/bundle/lib_src_page_dynamic_widget.fair.json',
data: {"fairProps": json.encode({})}),
),
);

添加动态组件

每一个动态组件由一个FairWidget表示。


FairWidget(
name: 'DynamicWidget',
path: 'assets/bundle/lib_src_page_dynamic_widget.fair.json',
data: {"fairProps": json.encode({})}),

根据不同场景诉求,FairWidget可以混合和使用



  1. 可以作为不同组件混合使用

  2. 一般作为一个全屏页面

  3. 支持嵌套使用,即可以局部嵌套在普通Widget下,也可以嵌套在另一个FairWidget下


五,Fair接入体验


1,fork,下载工程


将官方Github工程fork到自己仓库后, 下载工程。使用官方提供的 test_case/best_ui_templates工程体验fair的体验。


2, 执行 pub get


在 best_ui_templates工程中,执行 pub get命令获取依赖。


3,开发业务


接下来正式开始开发流程。 把一个页面改写为 用Fair 编写:



  1. 创建需要动态化的 componnet, 并添加 @FairPatch() 注解。添加上注解后,在Fair生成产物时,会把此Component build生成 FairWidget加载的产物。


image-20221116173528472


2, 执行 Fair工具链插件的命令生成产物, 如图:


<u>image-20221116173837910</u>


3, 最终生成的产物,拷贝到 assets/bundle目录下(配置config.json后,会自动拷贝)


<u><u>image-20221116182132104</u></u>


4, 看效果, 下图为使用 Fair 改造后的页面:



Screenshot_2022_1116_172859
Screenshot_2022_1116_192940

六,Fair优势


1,社区活跃度高


官方对Fair维护力度大,版本更新较快,问题修复及时,活跃的开发者社区氛围。


使得开发者在开发Fair过程中遇到的问题, 能够及时反馈给官方, 并能得到快速的帮助和解决。


2, 一份代码,灵活使用


Fair的区别于MTFlutter和MXFlutter这2种动态化方案,Fair能让同一份代码在Flutter原生和动态之间随意切换。在开发跟版本需求时,使用原生代码发布,以此持续保持Flutter的性能优势;而在热更新场景可以通过下发动态文件来达到动态更新Widget的目的。使用方式更加灵活。


3,配套开发工具丰富


Faircli配套工具链

官方为了让开发者快速上手,降低接入门槛, 解决在接入过程中的痛点。 Fair团队开发了Faircli配套工具链,主要包含三个部分:



  • 工程创建:快速搭建Fair载体工程及动态化工程。

  • 模板代码:提供页面及组件模板。

  • 本地热更新:线下开发使用,实现开发阶段快速预览Fair动态化功能。


在安装了工具链提供的dart命令行工具及AS插件后, 通过创建模板, 构建产物, 本地启服务,体验热更新功能,开发者可以轻松接入并体验Fair。


Fair语法检测插件

官方为了让开发者在Fair开发过程中,出现不正确或者不支持的语法问题。 开发了配套插件去提示用户使用Fair语法糖。


查看以下示例:


1,build方法下if的代码检测,及提示引导信息


44b58320-e608-420f-854f-799b5bf03cf5image


2,点击more action 或者 AS代码提示快捷键


41094a86-2aea-43e6-b7f0-69aef1c653c0image


3,根据提示点击替换


image.png


通过插件,在编写fair过程中,可以快速识别并解决不支持的语法问题。 提高开发Fair效率。


Fair Web代码编辑器

Fair其中一个方向是在线动态化平台,即在网页中编辑dart代码,在线预览Flutter效果和Fair动态化效果,并且发布Fair动态化产物。


通过在Fair Web代码编辑器,开发者可以在没有复杂的IDE配置的情况下,在网页端开发Fair并预览。 这无疑是降低了接入成本, 为开发者可以快速体验Fair提供了非常便捷的方式。


七,总结


通过近期对各大互联网公司在Flutter动态化方向上的探究方案。 发现这些方案都还没有达到成熟阶段,想在实际业务上落地, 还得看各团队后期的维护力度和开发投入程度。


MXFlutter使用js编写Dart的方式, 抛弃了原本Flutter的开发模式, 导致开发成本大,以及后续维护成本也大,官方已停止维护。


MTFlutter采用布局,逻辑都是使用Dart, 通过静态生产 DSL+Runtime 解释运行的思路,解决布局和逻辑的动态化,然而并没有开源计划,无从深入研究。


Fair通过Fair Compiler工具对原生Dart源文件的自动转化,使项目获得动态更新Widget Tree和State的能力。目前官方维护力度较大, 社区活跃,并且有比较全面的Fair生态工具。 期待 Fair 团队可以解决在开发Fair过程中一些体验问题,如语法支持不全等, 让Fair成为真正能够让开发者可以快速接入,能够达到和正常开发Flutter接近的体验。 为广大Flutter开发人员解决动态化的痛点。


支持我们


欢迎大家使用 Fair,也欢迎大家为我们点亮star

Github地址:github.com/wuba/fair

Fair官网:fair.58.com/


欢迎贡献


通过Issue提交问题,贡献代码请提交Pull Request,管理员将对代码进行审核。


作者:王猛猛
来源:juejin.cn/post/7174978087879671865
收起阅读 »

全网最优雅安卓控件可见性检测

引子 view.setOnClickListener { // 当控件被点击时触发的逻辑 } 正是因为 View 对控件点击采用了策略模式,才使得监听任何控件的点击事件变得易如反掌。 我有一个愿望。。。 如果 View 能有一个可见性监听该多好啊! view...
继续阅读 »

引子


view.setOnClickListener { // 当控件被点击时触发的逻辑 }

正是因为 View 对控件点击采用了策略模式,才使得监听任何控件的点击事件变得易如反掌。


我有一个愿望。。。


如果 View 能有一个可见性监听该多好啊!


view.setOnVisibilityChangeListener { isVisible: Boolean ->   }

系统并未提供这个方法。。。


但业务上有可见性监听的需要,比如曝光埋点。当某控件可见时,上报XXX。


数据分析同学经常抱怨曝光数据不准确,有的场景曝光多报了,有的场景曝光少报了。。。


开发同学看到曝光埋点也很头痛,不同场景的曝光检测有不同的方法,缺乏统一的可见性检测入口,存在一定重复开发。


本文就试图为单个控件以及列表项的可见性提供统一的检测入口。


控件的可见性受到诸多因素的影响,下面是影响控件可见性的十大因素:



  1. 手机电源开关

  2. Home 键

  3. 动态替换的 Fragment 遮挡了原有控件

  4. ScrollView, NestedScrollView 的滚动

  5. ViewPager, ViewPager2 的滚动

  6. RecyclerView 的滚动

  7. 被 Dialog 遮挡

  8. Activity 切换

  9. 同一 Activity 中 Fragment 的切换

  10. 手动调用 View.setVisibility(View.GONE)

  11. 被输入法遮盖


能否把这所有的情况都通过一个回调方法表达?目标是通过一个 View 的扩展方法完成上述所有情况的检测,并将可见性回调给上层,形如:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {}

若能实现就极大化简了上层可见性检测的复杂度,只需要如下代码就能实现任意控件的曝光上报埋点:


view.onVisibilityChange { view, isVisible ->
if(isVisible) { // 曝光埋点 }
else {}
}

控件全局可见性检测


可见性检测分为两步:



  1. 捕获时机:调用检测算法检测控件可见性的时机。

  2. 检测算法:描述如何检测控件是否对用户可见。


拿“手动调用 View.setVisibility(View.GONE)”举例,得先捕获 View Visibility 发生变化的时机,并在此刻检测控件的可见性。


下面是View.setVisibility()的源码:


// android.view.View.java
public void setVisibility(@Visibility int visibility) {
setFlags(visibility, VISIBILITY_MASK);
}

系统并未在该方法中提供类似于回调的接口,即一个 View 的实例无法通过回调的方式捕获到 visibility 变化的时机。


难道通过自定义 View,然后重写 setVisibility() 方法?


这个做法接入成本太高且不具备通用性。


除了“手动调用 View.setVisibility(View.GONE)”,剩下的影响可见性的因素大多都可找到对应回调。难道得在fun View.onVisibilityChange()中对每个因素逐个添加回调吗?


这样实现太过复杂了,而且也不具备通用性,假设有例外情况,fun View.onVisibilityChange()的实现就得修改。


上面列出的十种影响控件可见性的因素都是现象,不同的现象背后可能对应相同的本质。


经过深挖,上述现象的本质可被收敛为下面四个:



  1. 控件全局重绘

  2. 控件全局滚动

  3. 控件全局焦点变化

  4. 容器控件新增子控件


下面就针对这四个本质编程。


捕获全局重绘时机


系统提供了ViewTreeObserver


public final class ViewTreeObserver {
public void addOnGlobalLayoutListener(OnGlobalLayoutListener listener) {
checkIsAlive();
if (mOnGlobalLayoutListeners == null) {
mOnGlobalLayoutListeners = new CopyOnWriteArray();
}
mOnGlobalLayoutListeners.add(listener);
}
}

ViewTreeObserver 是一个全局的 View 树变更观察者,它提供了一系列全局的监听器,全局重绘即是其中OnGlobalLayoutListener


public interface OnGlobalLayoutListener {
public void onGlobalLayout();
}

当 View 树发生变化需要重绘的时候,就会触发该回调。


调用 View.setVisibility(View.GONE) 之所以能将控件隐藏,正是因为整个 View 树触发了一次重绘。(任何一次微小的重绘都是从 View 树的树根自顶向下的遍历并触发每一个控件的重绘,不需要重绘的控件会跳过,关于 Adroid 绘制机制的分析可以点击Android自定义控件 | View绘制原理(画多大?)


在可见性检测扩展方法中捕获第一个时机:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
}

其中viewTreeObserver是 View 的方法:


// android.view.View.java
public ViewTreeObserver getViewTreeObserver() {
if (mAttachInfo != null) {
return mAttachInfo.mTreeObserver;
}
if (mFloatingTreeObserver == null) {
mFloatingTreeObserver = new ViewTreeObserver(mContext);
}
return mFloatingTreeObserver;
}

getViewTreeObserver() 用于返回当前 View 所在 View 树的观察者。


全局重绘其实覆盖了上述的两个场景:



  1. 同一 Activity 中 Fragment 的切换

  2. 手动调用 View.setVisibility(View.GONE)

  3. 被输入法覆盖


这两个场景都会发生 View 树的重绘。


捕获全局滚动时机



  1. ScrollView, NestedScrollView 的滚动

  2. ViewPager, ViewPager2 的滚动

  3. RecyclerView 的滚动


上述三个时机的共同特点是“发生了滚动”。


每个可滚动的容器控件都提供了各自滚动的监听


// android.view.ScrollView.java
public interface OnScrollChangeListener {
void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY);
}

// androidx.viewpager2.widget.ViewPager2.java
public abstract static class OnPageChangeCallback {
public void onPageScrolled(int position, float positionOffset, @Px int positionOffsetPixels) {}
public void onPageSelected(int position) {}
public void onPageScrollStateChanged(@ScrollState int state) {}
}

// androidx.recyclerview.widget.RecyclerView.java
public abstract static class OnScrollListener {
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {}
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {}
}

难道要针对不同的滚动控件设置不同的滚动监听器?


这样可见性检测就和控件耦合了,不具有通用性,也愧对View.onVisibilityChange()这个名字。


还好又在ViewTreeObserver中找到了全局的滚动监听:


public final class ViewTreeObserver {
public void addOnScrollChangedListener(OnScrollChangedListener listener) {
checkIsAlive();

if (mOnScrollChangedListeners == null) {
mOnScrollChangedListeners = new CopyOnWriteArray();
}

mOnScrollChangedListeners.add(listener);
}
}

public interface OnScrollChangedListener {
public void onScrollChanged();
}

在可见性检测扩展方法中捕获第二个时机:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
viewTreeObserver.addOnScrollChangedListener {}
}

捕获全局焦点变化时机


下面这些 case 都是焦点发生了变化:



  1. 手机电源开关

  2. Home 键

  3. 被 Dialog 遮挡

  4. Activity 切换


同样借助于 ViewTreeObserver 可以捕获到焦点变化的时机。


到目前为止,全局可见性扩展方法中已经监听了三种时机,分别是全局重绘、全局滚动、全局焦点变化:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
viewTreeObserver.addOnScrollChangedListener {}
viewTreeObserver.addOnWindowFocusChangeListener {}
}

捕获新增子控件时机


最后一个 case 是最复杂的:动态替换的 Fragment 遮挡了原有控件。


该场景如下图所示:
1668323952343.gif


界面中有一个底边栏,其中包含各种 tab 标签,点击其中的标签会以 Fragment 的形式从底部弹出。此时,底边栏各 tab 从可见变为不可见,当点击返回时,又从不可见变为可见。


一开始的思路是“从被遮挡的 View 本身出发”,看看某个 View 被遮挡后,其本身的属性是否会发生变化?


View 内部以is开头的方法如下所示:


微信截图_20221113152433.png


我把其中名字看上去可能和被遮挡有关联的方法值全都打印出来了,然后触发 gif 中的场景,观察这些值在触发前后是否会发生变化。


几十个属性,一一比对,在看花眼之前,log 告诉我,被遮挡之后,这些都没有发生任何变化。。。。


绝望。。。但还不死心,继续寻找其他方法:


微信截图_20221113152922.png


我又找了 View 内部所有has开头的方法,也把其中看上去和被遮挡有关的方法全打印出来了。。。你猜结果怎么着?依然是徒劳。。。。


我开始质疑出发点是否正确。。。此时一声雷鸣劈醒了我。


视图只可能了解其自身以及其下层视图的情况,它无法得知它的平级甚至是父亲的绘制情况。而 gif 中的场景,即是在底边栏的同级有一个 Fragment 的容器。而且当视图被其他层级的控件遮挡时,整个绘制体系也不必通知那个被遮挡的视图,否则多低效啊(我yy的,若有大佬知道内情,欢迎留言指点一二。)


经过这层思考之后,我跳出了被遮挡的那个视图,转而去 Fragment 的容器哪里寻求解决方案。


Fragment 要被添加到 Activity 必须提供一个容器控件,容器控件提供了一个回调用于监听子控件被添加:


// android.view.ViewGroup.java
public void setOnHierarchyChangeListener(OnHierarchyChangeListener listener) {
mOnHierarchyChangeListener = listener;
}

public interface OnHierarchyChangeListener {
void onChildViewAdded(View parent, View child);
void onChildViewRemoved(View parent, View child);
}

为了监听 Fragment 被添加的这个瞬间,得为可见性检测扩展方法添加一个参数:


fun View.onVisibilityChange(
viewGroup:
ViewGroup? = null, // 容器
block: (
view: View, isVisible: Boolean) -> Unit
)
{ }

其中 viewGroup 表示 Fragment 的容器控件。


既然 Fragment 的添加也是往 View 树中插入子控件,那 View 树必定会重绘,可以在全局重绘回调中进行分类讨论,下面是伪代码:


fun View.onVisibilityChange(
viewGroup:
ViewGroup? = null,
block: (
view: View, isVisible: Boolean) -> Unit
)
{
var viewAdded = false
// View 树重绘时机
viewTreeObserver.addOnGlobalLayoutListener {
if(viewAdded){
// 检测新插入控件是否遮挡当前控件
}
else {
// 检测当前控件是否出现在屏幕中
}
}
// 监听子控件插入
viewGroup?.setOnHierarchyChangeListener(object : OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View?, child: View?) {
viewAdded = true
}
}
override fun onChildViewRemoved(parent: View?, child: View?) {
viewAdded = false
}
})
}

子控件的插入回调总是先于 View 树重绘回调。所以先在插入时置标志位viewAdded = true,以便在重绘回调中做分类讨论。(因为检测子控件遮挡和是否出现在屏幕中是两种不同的检测方案)


可见性检测算法


检测控件的可见性的算法是:“判断控件的矩形区域是否和屏幕有交集”


为此新增扩展属性:


val View.isInScreen: Boolean
get() = ViewCompat.isAttachedToWindow(this) && visibility == View.VISIBLE && getLocalVisibleRect(Rect())

val 类名.属性名: 属性类型这样的语法用于为类的实例添加一个扩展属性,它并不是真地给类新增了一个成员变量,而是在类的外部新增属性值的获取方法。


当前新增的属性是 val 类型的,即常量,所以只需要为其定义个 get() 方法来表达如何获取它的值。


View 是否在屏幕中由三个表达式共同决定。



  1. 先通过 ViewCompat.isAttachedToWindow(this) 判断控件是否依附于窗口。

  2. 再通过 visibility == View.VISIBLE 判断视图是否可见。

  3. 最后调用getLocalVisibleRect()判断它的矩形相对于屏幕是否可见:


// android.view.View.java
public final boolean getLocalVisibleRect(Rect r) {
final Point offset = mAttachInfo != null ? mAttachInfo.mPoint : new Point();
if (getGlobalVisibleRect(r, offset)) {
r.offset(-offset.x, -offset.y);
return true;
}
return false;
}

该方法会先获取控件相对于屏幕的矩形区域并存放在传入的 Rect 参数中,然后再将其偏移到控件坐标系。如果矩形区域为空,则返回 false 表示不在屏幕中,否则为 true。


刚才捕获的那一系列时机,有可能会被多次触发。为了只将可见性发生变化的事件回调给上层,得做一次过滤:


val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()

val checkVisibility = {
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 获取当前可见性
val isInScreen = this.isInScreen() && visibility == View.VISIBLE
// 无上一次可见性,表示第一次检测
if (lastVisibility == null) {
if (isInScreen) {
// 回调可见性回调给上层
block(this, true)
// 更新可见性
setTag(KEY_VISIBILITY, true)
}
}
// 当前可见性和上次不同
else if (lastVisibility != isInScreen) {
// 回调可见性给上层
block(this, isInScreen)
// 更新可见性
setTag(KEY_VISIBILITY, isInScreen)
}
}

过滤重复事件的方案是记录上一次可见性(记录在 View 的 tag 中),如果这一次可见性检测结果和上一次相同则不回调给上层。


将可见性检测定义为一个 lambda,这样就可以在捕获不同时机时复用。


以下是完整的可见性检测代码:


fun View.onVisibilityChange(
viewGroups:
List<ViewGroup> = emptyList()
, // 会被插入 Fragment 的容器集合
needScrollListener: Boolean = true,
block: (view: View, isVisible: Boolean) -> Unit
) {
val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()
val KEY_HAS_LISTENER = "KEY_HAS_LISTENER".hashCode()
// 若当前控件已监听可见性,则返回
if (getTag(KEY_HAS_LISTENER) == true) return

// 检测可见性
val checkVisibility = {
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 判断控件是否出现在屏幕中
val isInScreen = this.isInScreen
// 首次可见性变更
if (lastVisibility == null) {
if (isInScreen) {
block(this, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非首次可见性变更
else if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
}

// 全局重绘监听器
class LayoutListener : ViewTreeObserver.OnGlobalLayoutListener {
// 标记位用于区别是否是遮挡case
var addedView: View? = null
override fun onGlobalLayout() {
// 遮挡 case
if (addedView != null) {
// 插入视图矩形区域
val addedRect = Rect().also { addedView?.getGlobalVisibleRect(it) }
// 当前视图矩形区域
val rect = Rect().also { this@onVisibilityChange.getGlobalVisibleRect(it) }
// 如果插入视图矩形区域包含当前视图矩形区域,则视为当前控件不可见
if (addedRect.contains(rect)) {
block(this@onVisibilityChange, false)
setTag(KEY_VISIBILITY, false)
} else {
block(this@onVisibilityChange, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非遮挡 case
else {
checkVisibility()
}
}
}

val layoutListener = LayoutListener()
// 编辑容器监听其插入视图时机
viewGroups.forEachIndexed { index, viewGroup ->
viewGroup.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View?, child: View?) {
// 当控件插入,则置标记位
layoutListener.addedView = child
}

override fun onChildViewRemoved(parent: View?, child: View?) {
// 当控件移除,则置标记位
layoutListener.addedView = null
}
})
}
viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
// 全局滚动监听器
var scrollListener:ViewTreeObserver.OnScrollChangedListener? = null
if (needScrollListener) {
scrollListener = ViewTreeObserver.OnScrollChangedListener { checkVisibility() }
viewTreeObserver.addOnScrollChangedListener(scrollListener)
}
// 全局焦点变化监听器
val focusChangeListener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
val isInScreen = this.isInScreen
if (hasFocus) {
if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
} else {
if (lastVisibility == true) {
block(this, false)
setTag(KEY_VISIBILITY, false)
}
}
}
viewTreeObserver.addOnWindowFocusChangeListener(focusChangeListener)
// 为避免内存泄漏,当视图被移出的同时反注册监听器
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
}

override fun onViewDetachedFromWindow(v: View?) {
v ?: return
// 有时候 View detach 后,还会执行全局重绘,为此退后反注册
post {
try {
v.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
} catch (_: java.lang.Exception) {
v.viewTreeObserver.removeGlobalOnLayoutListener(layoutListener)
}
v.viewTreeObserver.removeOnWindowFocusChangeListener(focusChangeListener)
if(scrollListener !=null) v.viewTreeObserver.removeOnScrollChangedListener(scrollListener)
viewGroups.forEach { it.setOnHierarchyChangeListener(null) }
}
removeOnAttachStateChangeListener(this)
}
})
// 标记已设置监听器
setTag(KEY_HAS_LISTENER, true)
}

该控件可见性检测方法,最大的用处在于检测 Fragment 的可见性。详细讲解可以点击 页面曝光难点分析及应对方案


Talk is cheap,show me the code


上述源码可以在这里找到。


推荐阅读


业务代码参数透传满天飞?(一)


业务代码参数透传满天飞?(二)


全网最优雅安卓控件可见性检测


全网最优雅安卓列表项可见性检测


页面曝光难点分析及应对方案


你的代码太啰嗦了 | 这么多对象名?


你的代码太啰嗦了 | 这么多方法调用?


作者:唐子玄
来源:juejin.cn/post/7165427955902971918
收起阅读 »

一个艰难就业的23年应届生的2022年

自我介绍 我的家乡是浙江-宁波-余姚,是一名就读于一所位于宁波-慈溪(学校:笑死,这就我一所大学,你直接报我名字得了)的双非独立学院的软件工程专业的23年应届生,7到10月有在南京实习,现在是孤身一人在杭州实习的社恐前端实习生,前端练习时长一年半,擅长唱、跳、...
继续阅读 »

自我介绍


我的家乡是浙江-宁波-余姚,是一名就读于一所位于宁波-慈溪(学校:笑死,这就我一所大学,你直接报我名字得了)的双非独立学院的软件工程专业的23年应届生,7到10月有在南京实习,现在是孤身一人在杭州实习的社恐前端实习生,前端练习时长一年半,擅长唱、跳、rap... 还只擅长Vue的渣渣前端程序猿,有兴趣可以关注我的公众号程序猿青空,23年开始我会时不时分享各种优秀文章、学习资源、学习课程,探索初期,还请多多关照。这篇文章会是我公众号的第一篇文章,主要对我这一年来的经历做一个简单的流水账总结,涉及到恋爱、租房、学习、工作等各方面内容,希望这份经验对你也能有所帮助。


学习


大二下半年的时候分流,自主报名到了我们学校的产业学院——企业和学校联合创办的培养应用型人才的学院。我文科相当薄弱,埋头考研会相当痛苦,也很清楚自己做不来官僚主义那一套,公职也不是适合我的职业(没错我对公职有偏见),很坚定就业这条路。因为还没有毕业,我的身份归根结底就是一个双非下流本科的一名大学生,为了避免自己毕业即失业,看当时产业学院的宣传也不错就去了。


事实上因为产业学院刚创办不久,而且并不是所有人来到这里都是为了就业的,也有可能是为了学分、助学金等其他方面的原因,课程设计、师资力量、同学质量等各方面都良莠不齐、鱼龙混杂。每门课程的期末大作业基本都是一个小项目,大三一年里两个期末都有为了大作业通宵的几天,再加上1500💰凑活过的生活费,死贵的电费和食堂伙食费,在这里学习和生活有时候还蛮辛苦的。好在我很清楚自己应该做什么,天赋不够,努力来凑,本来起跑线就低,更应该比别人卷一点。当然我也不是那种能够没日没夜卷的人(👀),关注了鱼皮,加入了他的知识星球,在星球天天学习健身(没错我还健身💪)打卡的flag没两个礼拜就立不住了,知识付费的事咱也没少干,就是说能一直坚持下来的着实不多,咱也明白咱就是个普通人,逆袭这种事确实还是很难做到的,我这人还是比较佛系的。


大三这一年我用一年的时间从零学前端,自认为还算是没有辜负自己,这一年时间的学习也还算有成果,虽然没法和卷王们争第一,也能跟在他们后面做个万年老二(😭呜呜呜)。下半年开始实习后更别说了,新的技术栈的学习基本就停滞了。实习前我还天真的以为能有更多的时间学习,正相反,比在学校学的更少,因为下班到家七八点,生活琐事会比在学校里多得多,而且我下班后还要花一个多钟头健身,再加上忙碌一天后更无心学习,只想躺平。


下半年做过的最卷的事也就参与了字节青训营,课题选择了前端监控平台,可惜的就是没能在青训营期间完成(😭呜呜呜,队友都摆烂了),当然也就没有结营证书。但我也不甘心就这样算罢,这个项目我就自己拉出来,作为我的毕业设计去完成它。解决实习期间学习效率低的最好办法就是在公司学习一些对公司业务有关或者优化公司项目的知识,名正言顺地摸鱼。我是Vue入门的,这一年里也一直守着Vue,来年第一季度目标就是学习React和Nest,开发一个自己的数据聚合的网站,能变现就最好了(😎欸嘿)。


生活&实习


大三下,也就是今年上半年,为了冲刺暑期实习,也就没去做兼职了,感叹本就艰难的生活的同时,殊不知这是为数不多还能自己自由掌控的日子了(😥我哭死)。其实我开始准备实习还是挺晚了,再加上期末没有太多时间,准备并不是太充分,没有太多自信心,投了几家大厂,不是没回应,就是笔试挂,就有点望而却步。


在我一个大佬同学的介绍下,面试了一家南京的小厂,过程很顺利,实习薪资给的也很可观,当时就没考虑那么多,就选择接受offer了(后来在杭州实习认识了几个小伙伴,才学了没几个月,暑假就面试进了独角兽企业,我那个时候确实应该再多投一投的)。刚开始的想法是第一次出门实习,有份经验就可以,在什么城市没关系,然而事实是工作上确实没什么关系,生活上关系可大了。7月13日第一次一个人拎上行李,义无反顾地去了南京,以为自己终于能够大展拳脚,再不济也能够在公司有所贡献,然而现实总是没那么理想。


上路


因为一个人前往外地工作,第一件事情便是租房,为了省点钱就托南京实习公司的一个同事看房子,因为他的房租到期也要找房子就顺便可以租在一起,有个照应。然而实际上因为是第一次出远门工作和生活,一切和自己的理想差距显然大了许多:因为不是自己实地看的房,而且也是第一次租房,虽然房租只有850💰,但是也可能因为是夏季大家都开空调,差不多50多💰一个礼拜的电费和其他乱七八糟的费用,一个月光租房子就差不多得1200💰,并不算贵,但是性价比极低;我的房间没地方晒衣服,只能晒在那个同事的房间的阳台,作为一个社恐患者,每次去都要做很多心理斗争(他会不会睡了,他会不会在忙....🙃);桌上只能堪堪放下我的显示器和笔记本,鼠标活动范围极小;床应该是睡过好几个租客了,明显的不舒服;吃的方面因为有点水土不服不能随便乱吃,同时也是为了省钱所以选择自己做饭,因此还得购置很多厨具调味品等等,一次性的开销💰不小;回学校的频率比我想象的高,因此来回车费也成为一大负担;当时租房合同是同事代签的,他签了一年,我那时候也不懂也没问,再加上当时换工作离开的比较急,没时间找转租,违约金直接血亏1700💰。


日常挤地铁


生活的种种问题都还能接受或者解决,然而工作方面,因为进入公司的时间段比较特殊再加上疫情影响,在南京实习的三个月里,我始终没有能够在技术上得到足够的提升,再加上与公司和领导的气场不合,使得我在公司整天如坐针毡,甚至有点无所事事(总之就是过的很不开心),虽然有不低的实习薪资,但是我始终没法在那里躺平。因此在中秋决定参与秋招,开始寻找第二份实习工作。


然而今年找工作并不简单,因为频繁发作的疫情,再加上互联网行业这些年的发展,行业的形势非常的严峻,各大公司都削减了HC(head count,人头数,就是最终录用的人数,肯定有小伙伴不懂这个词,我一开始就不懂🤏),作为一个民本23年应届生,在今年的秋招着实很难找到一份理想的工作。那段时间的想法就是尽快找到下一份工作(急急急急急急,我是急急国王),找到一份离家近、工资高、平台大至少满足两个的工作。从9月10日中秋就开始投出第一份简历,到10月19日确定来到杭州的一家四五百人的SaaS公司,这期间投出过几百份简历,得到的回应却寥寥无几,这是一段非常难忘的经历。


这一个月里每一天都在为找工作烦恼,一开始专注于线上面试,却始终的得不到理想工作的认可,持续的碰壁使得开始怀疑自己这些年的学习,自己的选择是不是错了,是不是自己能力确实没法满足他们的要求(被ktv了),后来也决定不放过线下面试的机会,顶着疫情在南京、杭州、家、学校几地频繁奔波,在杭州线下面试的那一天还是顶着自己身体上的各种不适(持续拉肚子,全身酸痛,萎靡不振),仍然要拿出饱满的精神去面对面试,好在当时就获得了面试官也是现在的leader的认可,简直就是久旱逢甘霖,虽然并不是直接发的offer,但是也是十分有信心。杭州比起南京的工作,实习薪资低了很多,但是因为线下面试,对于当时感受到的公司的氛围十分的心动,也就放弃了其他小公司更高薪资的offer,决定了自己的第二份实习工作。


又上路啦


换工作又是换城市,所以又需要租房搬家,购置各种必需品,又是一大笔开销,在还没进公司前始终在担忧自己先择了薪资更低的工作,到时候会不会付出了这么多,结果又远不如预期让自己更痛苦。不过在经过了一个月左右实习后,我在杭州的公司工作的感受让我相信自己的选择没有错。


10月23日我再一次拖着一大堆行李开始了迁徙,本来打算先简单看房子,先回家住几天再自驾,拖着行李回来看房子签合同,所以我把被子等一些大件的行李都寄回家了,但是这次进入杭州后就黄🐎了(之前几地来回跑黄都没黄一下),只能多看几套房子然后就签下来,好在当天就看到一个自己满意的,10几平,押一付一,一个月算上水电差不多也就1300💰,不至于睡大街,但是我没有被子,当时杭州刚开始降温,温度也就个位数,但是买被子太亏了,之后用不上,就买了床毛毯,多盖几件衣服,凑活过了两天(真的凑活,冷的雅痞)。


杭州租的房


11月1日正式入职,正式开启了在杭州的工作生活,有条不紊的入职手续,时长1周的实习生培训,认识了许多和我一起实习的小伙伴,刚进来还赶上公司的双十一活动,让我对未来的工作生活充满希望。


双十一零食自助


第一月开始接触了一些简单的业务,重新开始了健身,第二个月就参与开发了一个简单的项目,还封装了公共组件、开发了简单的提高开发效率的脚手架工具,我终于能够继续有条不紊运转了。


在南京实习的期间除了参加了字节青训营和准备面试而巩固基础外,专业上可以说是没有丝毫提升,不过生活经验确实收获满满,坚定了自己的目标,职业生涯规划更加清晰,为了达到目标去学会自律。这几个月的开销给自己和父母都增添了不小得负担,好在现在稳定下来勉强能够在杭州自给自足,生活重新步入正轨,比起在南京,杭州的生活更加得心应手。但是并不是说南京不好,南京是一个非常优雅的城市,这里有他躺在超市里超乖的猫猫,超治愈


超乖的猫猫


离开南京前我也花时间去好好游玩了两天(去了一些免费的博物馆,景点)。


忘记叫啥地了


比起杭州,我认为南京更适合生活,我只是去到了一个不适合我的公司和因为经验不足吃了不少亏才离开了这个城市。我很珍惜在杭州的这份工作,也非常享受现在忙碌充实的生活,我也希望自己的能力能够不断得到认可,继续探索自己的人生价值。


感情


呜呜呜,鼠鼠该死啊,鼠鼠长了个恋爱脑,但是好在现在穷的雅痞,我还社恐,可以心无旁骛地工作学习(搞💰)。出来实习没几个礼拜就跟在一起一年的女孩子分手了,其实在上半年因为我们对未来规划的分歧就吵过架,她想留在慈溪,而我更向往大城市(当然不止这一点原因啦),那个时候我就很清楚这段感情肯定没法坚持很久,下半年又异地,在各自的城市实习,天天吵架,自然而然就吵分了,累觉不爱。我深知自己不是啥好男人(男人没一个好东西),还没有资本,毕业前绝对要水泥封心(做杭州第一深情)。


其实我家离学校很近,但是从念大学开始还是很少回家了,在学校里没有什么感觉,直到独自出门在外工作才知道在家真好,爸爸妈妈真好(我是妈宝男,呜呜呜😭),看这篇文章的小伙伴不要再随便跟爸爸妈妈撒气了哦。家里的老人只剩下奶奶独自在乡下了,以后一定要多打电话。


展望


在未来的一年中,希望自己能够吸收已经犯过的错误的经验,保质保量地完成未来的各项工作,作为一名程序员最重要的最重要的就是自我驱动,持续学习,通过不断学习才能够在未来的工作中创造更多的价值,以下是我23年的一些计划


学习



  • 这个月先抓紧时间把自己的毕设解决,写复盘的分享博客,之后顺利毕业

  • 上半年学习React,Nest,开发一个数据聚合分享平台,同样做分享

  • 运营自己的博客和各平台账号,不说多少粉丝,能坚持不凉就行,争取每周一个博客

  • 每季至少阅读一本书,学习一个技术栈

  • 坚持自己的每日计划和每月复盘总结(包含年中和年终总结)


工作



  • 因为现在常态化了,不知道今年的就业形势会是什么样的,着实不想再像去年那样被支配了,所以还是希望得到自己满意的薪资的前提下在这里转正,但愿不要出什么幺蛾子吧

  • 继续卷进部门更深层业务,目标负责6个项目

  • 学习更多优化开发效率和质量的技术栈,明年就简单定个两个的目标吧,要求不高


生活



  • 我真的超级想买机车的,但是杭州主城区禁摩,所以先23年下半年花时间考个D照,看情况决定买个机车还是电驴

  • 3月份房租到期了,看房肯定又要放进日程了,看看到时候有没有合租的小伙伴吧,如果有人有兴趣到时候可以分享一下杭州租房经验

  • 健身肯定是要继续的,有一说一我肉体确实没啥天赋(也可能是吃得不够多),健身更多的是一种生活态度吧

  • 我是一个很不喜欢打电话的人,尤其是和长辈,感觉没话聊,但是老人家接到自己孩子的电话,知道孩子过得不错,真的会很开心。明年定个小目标,一个月给奶奶打一通电话。


2022年好像所有人都过的很艰难,或许所有人都想离开浪浪山,但是也不要忘记看看浪浪山的风景,让我们一起加油吧。最后再打个广告,关注公众号程序猿青空,免费领取191本计算机领域黑皮书电子书,更有集赞活动免费挑选精品课程(各个领域的都有),不定期分享各种优秀文章、学习资源、学习课程,能在未来(因为现在还没啥东西)享受更多福利。


作者:CyanSky
来源:juejin.cn/post/7189562801159929915
收起阅读 »

iOS 3年开发迷茫随心聊

iOS 3年开发迷茫随心聊 从毕业开始做iOS,到现在已经是第4个年头了。第一家公司,做了一年,项目没上线就倒闭了,导致找第二家公司的时候也没有一个项目能拿的出手。第二家公司误入一家游戏公司,每天工作就是将H5小游戏做成一个App,想办法上线,一年过去了,技术...
继续阅读 »

iOS 3年开发迷茫随心聊


从毕业开始做iOS,到现在已经是第4个年头了。第一家公司,做了一年,项目没上线就倒闭了,导致找第二家公司的时候也没有一个项目能拿的出手。第二家公司误入一家游戏公司,每天工作就是将H5小游戏做成一个App,想办法上线,一年过去了,技术其实也没什么长进,但是通过这个过程了解一些苹果上架的知识。也有了几个上线项目经验了。由于想找个正经互联网公司做App,也离职找工作。


找工作头一个月,发现面试面的问题都是与底层相关,一问三不知,在家埋头学了2个月底层相关知识(可以理解背题)。原理是懂一点了,但是没有在实际项目中运用,工资也上不太去。在面了20多家公司后,终于找到现在第三份工作。


由于有第二份工作的经历,在第三家公司上班的时候一直在学习,有意识的去面试的原理去解决一些开发中的问题,例如使用runtime解决一些问题,却发现runtime如果没有很强的理解,还是不要用在项目里,因为可能出现未知的风险。例如使用交换方法,全局做了修改,但是后期项目需求更改,保持全局修改的前提下,对其他情况要做不同处理。也没有太多需求会使用到原理的内容,性能也不需要优化。


小公司对技术不太感冒,能完成需求就行,虽然自己力所能及的去做一些规范,但觉得做的还是不够,也不清楚其他公司到底是如何做的。小公司个人感觉对员工做事的责任心更加看重。需求就是写页面,页面还原的好,做的快一点,bug少一点就行了。不是理想的一个团队有什么方案,规范,让开发更有效率。最大感触是还好没有成为一个油腻的开发~。


在现在的公司,做了几个项目,也没有大的bug。学会了Swift进行开发。也许也算是一种收获吧。但是公司不加薪,今年的目标是想学点东西换一份工作。


学了1个月RxSwift,感觉也快学不下去了,公司是不可能用了,网上也有人说这个架构太重了。语言是个问题,自己英语水平有限,学习速度太慢了。如果有看到的这篇文章的小伙伴,也可以给我点意见。


最近想学一点提高开发效率的技能。和面试相关的内容。如果有大神经历过我这个时期,还麻烦给点建议。建议退iOS坑的就不必留言了。个人虽然菜,但是如果还没有做到小公司天花板的话,目前不考虑退坑。


第一次发文章也不知道说啥,后面会更新一些学习

作者:MissSunRise
来源:juejin.cn/post/7071892765763698719
笔记啥的。感谢包容。

收起阅读 »

乱打日志的男孩运气怎么样我不知道,加班肯定很多!

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

前言


线上出现问题,你的第一反应是什么?


如果是我的话,第一时间想的应该是查日志:



  1. if…else 到底进入了哪个分支?

  2. 关键参数是不是有缺失?

  3. 入参是不是有问题,没做好校验放进去了?


良好的日志能帮我们快速定位到问题所在,坑你的东西往往最为无形,良好的日志就是要让这些玩意无所遁形!


日志级别


Java应用中,日志一般分为以下5个级别:



  • ERROR 错误信息

  • WARN 警告信息

  • INFO 一般信息

  • DEBUG 调试信息

  • TRACE 跟踪信息


1)ERROR


ERROR 级别的日志一般在 catch 块里面出现,用于记录影响当前线程正常运行的错误,出现 Exception 的地方就可以考虑打印 ERROR 日志,但不包括业务异常。


需要注意的是,如果你抛出了异常,就不要记录 ERROR 日志了,应该在最终的地方处理,下面这样做就是不对的:


try {
   int i = 1 / 0;
} catch (Exception e) {
   log.error("出错了,什么错我不知道,啊哈哈哈!", e);
   throw new CloudBaseException();
}

2)WARN


不应该出现,但是不会影响当前线程执行的情况可以考虑打印 WARN 级别的日志,这种情况有很多,比如:



  • 各种池(线程池、连接池、缓存池)的使用超过阈值,达到告警线

  • 记录业务异常

  • 出现了错误,但是设计了容错机制,因此程序能正常运行,但需要记录一下


3)INFO


使用最多的日志级别,使用范围很广,用来记录系统的运行信息,比如:



  • 重要模块中的逻辑步骤呈现

  • 客户端请求参数记录

  • 调用第三方时的参数和返回结构


4)DEBUG


Debug 日志用来记录自己想知道的所有信息,常常是某个功能模块运行的详细信息,已经中间的数据变化,以及性能信息。


Debug 信息在生产环境一般是关闭状态的,需要使用开关管理(比如 SpringBoot Admin 可以做到),一直开启会产生大量的 Debug,而 Debug 日志在程序正常运行时大部分时间都没什么用。


if (log.isDebugEnabled()) {
   log.debug("开始执行,开始时间:[{}],参数:[{}]", startTime, params);
   log.debug("通过计算,得到参数1:[{}],参数2:[{}]", param1, param2);
   log.debug("最后处理结果:[{}]", result);
}

5)TRACE


特别详细的系统运行完成信息,业务代码中一般不使用,除非有特殊的意义,不然一般用 DEBUG 代替,事实上,我编码到现在,也没有用过这个级别的日志。


使用正确的格式


如果你是这样打印日志的:


log.info("根据条件id:{}" + id + "查询用户信息");

不要这样做,会产生大量的字符串对象,占用空间的同时也会影响性能。


正确的做法是使用参数化信息的方式:


log.info("根据条件id:[{}],查询用户信息", id);

这样做除了能避免大量创建字符串之外,还能明确的把参数隔离出去,当你需要把参数复制出来的时候,只需要双击鼠标即可,而不是用鼠标慢慢对准再划拉一下。


这样打出来的日志,可读性强,对排查问题的帮助也很大!


小技巧


1)多线程


遇到多个线程一起执行的日志怎么打?


有些系统,涉及到并发执行,定时调度等等,就会出现多次执行的日志混在一起,出问题不好排查,我们可以把线程名打印进去,或者加一个标识用来表明这条日志属于哪一次执行:


if (log.isDebugEnabled()) {
   log.debug("执行ID=[{}],处理了ID=[{}]的消息,处理结果:[{}]", execId, id, result);
}

2)使用 SpringBoot Admin 灵活开关日志级别


image-20220727155526217


写在最后


一开始写代码的时候,没有规范日志的意识,不管哪里,都打个 INFO,打印出来的东西也没有思考过,有没有意义,其实让自己踩了不少坑,加了不少班,回过头,我想对学习时期的我说一句:”能让你加班的东西,都藏在各种细节里!写代码之前,先好好学习如何打日志!“


作者:你算哪块小蛋糕
来源:juejin.cn/post/7124958610123128839
收起阅读 »

Vue2 Diff 算法

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

Diff 算法


写在前面


因为之前看面试直播也经常问到 Diff 算法,然后作者本人用 Vue2 比较多,所以打算研究一下 Vue2 的 Diff 算法,其实很早就想学的,但是可能因为感觉 Diff 算法比较深奥,就一直拖着没学,但是最近在准备面试,就想着迟早都要学的,趁这个机会把 Diff 算法搞懂吧 🧐,作者就花了一天的时间研究了一下,可能没有完全理解 Diff 算法的精髓,请各位见谅。



💡 这个其实算作者的学习笔记,而且作者水平有限,改文章仅代表作者个人观点,如果有错误可以评论区指出来,会不断完善;同时本文很长,所以请读者们有耐心的看完,看完后作者相信你们会对 Diff 算法有更深的了解。本人觉得本文比目前网上讲解 Diff 算法的大部分文章要更好,因为本文从问题出发,教会大家如何思考,而不是直接从答案出发,就像读答案一样,这样感觉没什么意思,本文一步一步的引导大家去感受 Diff 算法的精妙,同时最后也做了一下小实验,让大家对 Diff 算法有更加直观的感受 🎉。



为什么要用 Diff 算法


虚拟 DOM


因为 Vue2 底层是用虚拟 DOM 来表示页面结构的,虚拟 DOM其实就是一个对象,如果想知道怎么生成的,其实大概流程就是:



  • 首先解析模板字符串,也就是 .vue 文件

  • 然后转换成 AST 语法树

  • 接着生成 render 函数

  • 最后调用 render 函数,就能生成虚拟 DOM


最小量更新


其实框架为了性能才使用的虚拟 DOM,因为 js 生成 DOM 然后展示页面是很消耗性能的,如果每一次的更新都把整个页面重新生成一遍那体验肯定不好,所以需要找到两个页面中变化的地方,然后只要把变化的地方用 js 更新 (可能是增加、删除或者更新) 一下就行了,也就是最小量更新。
那么怎么实现最小量更新呢?那么就要用 Diff 算法了,那么 Diff 算法对比的到底是什么呢?可能这是刚学 Diff 算法比较容易误解的地方,其实比对的是新旧虚拟 DOM,所以 Diff 算法就是找不同,找到两次虚拟 DOM 的不同之处,然后将不同反应到页面上,这就实现了最小量更新,如果只更新变化的地方那性能肯定更好。


页面更新流程


其实这个比较难解释,作者也就大致说一下,学了 Vue 的都知道这个框架的特点之一就有数据响应式,什么是响应式,也就是数据更新页面也更新,那么页面是怎么知道自己要更新了呢?其实这就是这个框架比较高明的地方了,大致流程如下:



  • 之前也说了会运行 render 函数,那么运行 render 函数的时候会被数据劫持,也就是进入 Object.definePropertyget,那么在这里收集依赖,那么是谁收集依赖呢?是每个组件,每个组件就是一个 Watcher,会记录这个组件内的所有变量 (也就是依赖),当然每个依赖 (Dep) 也会收集自己所在组件的 Watcher;

  • 然后当页面中的数据发生变化,那么就会出发 Object.definePropertyset,在这个函数里面数据就会通知每个 Watcher 更新页面,然后每个页面就会调用更新方法,这个方法就是 patch

  • 接着,就要找到两个页面之间的变化量,那么就要用到 Diff 算法了

  • 最后找到变化量后就可以进行更新页面了



其实是边找边更新的,为了让大家理解容易就将这两个部分分开了



Diff 算法简单介绍


面试问到 Diff 算法是什么,大家肯定会说两句,比如 头头、尾尾、尾头、头尾深度优先遍历(dfs)同层比较 类似这些话语,虽然能说一两句其实也是浅尝辄止。
其实作者看了 CSDN 上发的关于 Diff 算法的文章,就是阅读量很高的文章,作者觉得他也没讲明白,可能他自己没明白,或者自己明白了但是没讲清楚,那么作者会用自己的感悟和大家分享一下。


Diff 算法的前提


为了让大家能够了解清楚,这里先说明一下函数调用流程:



  • patch

  • patchVnode

  • updateChildren


Diff 算法的 前提 这个是很重要的,可能大家会问什么是前提?不就是之前说的那些比较嘛?说的没错但也不对,因为 Diff 算法到达之前说的 头头、尾尾、尾头、头尾 这一步的前提就是两次对比的节点是 相同的,这里的相同不是大家想的完全相同,只是符合某些条件就是相同了,为了简化说明,文章就只考虑一个标签只包含 key标签名(tag),那么之前说的相同就是 key 相同以及 tag 相同,为了证明作者的说法是有点正确的,那么这里也贴上源码:


// https://github.com/vuejs/vue/blob/main/src/core/vdom/patch.ts
// 36行
function sameVnode(a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))
)
}

如果怕乱了,下面的可以省略不看也没事不影响整体了解,下面只是为了考虑所有情况才加的一个判断:
那么如果两个虚拟 DOM 不相同其实就不用继续比较了,而且如果相同也不用比较了,这里的相同是真的完全相同,也就是两个虚拟 DOM 的地址是一样的,那么也贴上源码:


function patchVnode(......) {
if (oldVnode === vnode) {
return
}
......
}

到目前为止大家可能会比较乱,现在总结一下:



  • patch 函数里比较的是新老虚拟 DOM 是否是 key 相同以及 tag 相同,如果不相同那么就直接替换,如果相同用 patchVnode


说了这么多,其实作者也就想表达一个观点,就是只有当两次比较的虚拟 DOM 是 相同的 才会考虑 Diff 算法,如果不符合那直接把原来的删除,替换新的 DOM 就行了。


patchVnode 函数


这个函数里的才是真正意义上的 Diff 算法,那么接下来会结合源码向大家介绍一下。



源码中核心代码在 patch.ts 的 638 行至 655 行。



其实,目前介绍 patchVnode 的都是直接对着源码来介绍的,但是大家可能不清楚为啥要这么分类,那么作者在这里就让大家明白为什么这么分类,首先在这里说一个结论:



  • 就是 text 属性和 children 属性不可能同时存在,这个需要大家看模板解析源码部分


那么在对比新旧节点的情况下,主要比较的就是是否存在 textchildren 的情况,那么会有如下九种情况


情况老节点 text老节点 children新节点 text新节点 children
1
2
3
4
5
6
7
8
9

按照上面的表格,因为如果新节点有文本节点,其实老节点不管是什么都会被替换掉,那么就可以按照 新节点 text 是否存在来分类,其实 Vue 源码也是这么来分类的:


if (isUndef(vnode.text)) {
// 新虚拟 DOM 有子节点
} else if (oldVnode.text !== vnode.text) {
// 如果新虚拟 DOM 是文本节点,直接用 textContent 替换掉
nodeOps.setTextContent(elm, vnode.text)
}

那么如果有子节点的话,那应该怎么分类呢?我们可以按照每种情况需要做什么来进行分类,比如说:



  • 第一种情况,我们啥都不用做,因此也可以不用考虑

  • 第二种情况,我们应该把原来 DOM 的 textContent 设置为 ''

  • 第三种情况,我们也应该把原来 DOM 的 textContent 设置为 ''

  • 第四种情况,我们应该加入新的子节点

  • 第五种情况,这个情况比较复杂,需要对比新老子节点的不同

  • 第六种情况,我们应该把原来的 textContent 设置为 '' 后再加入新的子节点


那么通过以上六种情况 (新虚拟 DOM 不含有 text,也就是不是文本节点的情况),我们可以很容易地进行归类:



  • 分类 1️⃣: 第二种情况第三种情况。进行的是操作是:把原来 DOM 的 textContent 设置为 ''

  • 分类 2️⃣: 第四种情况第六种情况。进行的是操作是:如果老虚拟 DOM 有 text,就置空,然后加入新的子节点

  • 分类 3️⃣:第五种情况。进行的是操作是:需要进行精细比较,即对比新老子节点的不同


其实源码也是这么来进行分类的,而且之前说的 同层比较 也就得出来了,因为每次比较都是比较的同一个父节点每一个子元素 (这里的子元素包括文本节点和子节点) 是否相同,而 深度优先遍历(dfs) 是因为每次比较中,如果该节点有子节点 (这里的子节点指的是有 children 属性,而不包括文本节点) 的话需要进行递归遍历,知道最后到文本节点结束。



⭕️ 这里需要搞清楚子节点和子元素的区别和联系



然后我们来看看源码是怎么写吧,只看新虚拟 DOM 不含有 text,也就是不是文本节点的情况:


if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch)
// 递归处理,精细比较
// 对应分类 3️⃣
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (__DEV__) {
checkDuplicateKeys(ch) // 可以忽略不看
}
// 对应分类 2️⃣
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 对应分类 1️⃣
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 对应分类 1️⃣
nodeOps.setTextContent(elm, '')
}
}

❓我们可以看到源码把分类 1️⃣ 拆开来了,这是因为如果老虚拟 DOM 有子节,那么可能绑定了一些函数,需要进行解绑等一系列操作,作者也没自信看,大致瞄了一眼,但是如果我们要求不高,如果只是想自己手动实现 Diff 算法,那么没有拆开的必要。


作者觉得这么讲可能比网上其他介绍 Diff 算法的要好,其他的可能直接给你说源码是怎么写的,可能没有说明白为啥这么写,但是通过之前这么分析讲解后可能你对为什么这么写会有更深的理解和帮助吧。


updateChildren 函数



同层比较



因为当都含有子节点,即都包含 children 属性后,需要精细比较不同,不能像之前那些情况一样进行简单处理就可以了
那么这个函数中就会介绍大家经常说的 头头、尾尾、尾头、头尾 比较了,其实这不是 Vue 提出来的,是很早就提出来的算法,就一直沿用至今,大家可以参考【snabbdom 库】


🌟 在这之前我们要定义四个指针 newStartIdxnewEndIdxoldStartIdxoldEndIdx,分别指向 新头节点新尾节点旧头节点旧尾节点


循环条件如下:


while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
......
}

其实这个比较也就是按人类的习惯来进行比较的,比较顺序如下 :



  • 1️⃣ 新头节点旧头节点++newStartIdx++oldStartIdx

  • 2️⃣ 新尾节点旧尾节点--newEndIdx--oldEndIdx

  • 3️⃣ 新尾节点旧头节点:需要将 旧头节点 移动到 旧尾节点之前,为什么要这么做,讲起来比较复杂,记住就好,然后 --newEndIdx++oldStartIdx

  • 4️⃣ 新头节点旧尾节点:需要将 旧尾节点 移动到 旧头节点之前,为什么要这么做,讲起来比较复杂,记住就好,然后 ++newStartIdx--oldEndIdx

  • 5️⃣ 如果都没有匹配的话,就把 新头节点 在旧节点列表 (也就是 children 属性的值) 中进行查找,查找方式按照如下:

    • 如果有 key 就把 keyoldKeyToIdx 进行匹配,oldKeyToIdx 根据旧节点列表中元素的 key 生成对应的下标

    • 如果没有,就按顺序遍历旧节点列表找到该节点所在的下标

    • 如果在旧节点列表是否找到也分为两种情况:

      • 找到了,那么只要将 新头节点 添加到 旧头节点 之前即可

      • 没找到,那么需要创建新的元素然后添加到 旧头节点 之前

      • 然后把这个节点设置为 undefined






根据循环条件我们可以得到两种剩余情况,如下:



  • 6️⃣ 如果 oldStartIdx > oldEndIdx 说明老节点先遍历完成,那么新节点比老节点多,就要把 newStartIdxnewEndIdx 之间的元素添加

  • 7️⃣ 如果 newStartIdx > newEndIdx 说明新节点先遍历完成,那么老节点比新节点多,就要把 oldStartIdxoldEndIdx 之间的元素删除


其实我们上面还没有考虑如果节点为 undefined 的情况,因为在上面也提到过,如果四种都不匹配后会将该节点置为 undefined,也只有旧节点列表中才有,因此要在开头考虑这两种情况:



  • 8️⃣ 当 oldStartVnodeundefined++oldStartIdx

  • 9️⃣ 当 oldEndVnodeundefined--oldEndIdx


那么我们来看源码怎么写的吧,其中用到的函数可以查看源码附录


// https://github.com/vuejs/vue/blob/main/src/core/vdom/patch.ts
// 439 行至 556 行
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
// 情况 8️⃣
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
// 情况 9️⃣
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 情况 1️⃣
patchVnode(...)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 情况 2️⃣
patchVnode(...)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
// 情况 3️⃣
patchVnode(...)
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
// 情况 4️⃣
patchVnode(...)
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 情况 5️⃣
if (isUndef(oldKeyToIdx)) // 创建 key -> index 的 Map
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 找到 新头节点 的下标
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) {
// New element
// 如果没找到
createElm(...)
} else {
// 如果找到了
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(...)
oldCh[idxInOld] = undefined
canMove &&
nodeOps.insertBefore(
parentElm,
vnodeToMove.elm,
oldStartVnode.elm
)
} else {
// same key but different element. treat as new element
createElm(...)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
// 情况 6️⃣
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(...)
} else if (newStartIdx > newEndIdx) {
// 情况 7️⃣
removeVnodes(...)
}


如果问为什么这么比较,回答就是经过很多人很多年的讨论得出的,其实只要记住过程就行了,如果想要更深了解 Diff 算法,可以去 B 站看【尚硅谷】Vue源码解析之虚拟DOM和diff算法



v-for 中为什么要加 key


这个问题面试很常见,但是可能大部分人也就只会背八股,没有完全理解,那么经过以上的介绍,我们可以得到自己的理解:



  • 首先,如果不加 key 的话,那么就不会去 Map 里匹配 (O(1)),而是循环遍历整个列表 (O(n)),肯定加 key 要快一点,性能更高

  • 其次,如果不加 key 那么在插入或删除的时候就会出现,原本不是同一个节点的元素被认为是相同节点,上面也有说过是 sameVnode 函数判断的,因此可能会有额外 DOM 操作



为什么说可能有额外 DOM 操作呢?这个和插入的地方有关,之后会讨论,同理删除也一样



证明 key 的性能


我们分为三个实验:没有 key、key 为 index、key 唯一,仅证明加了 key 可以进行最小化更新操作。


实验代码


有小伙伴评论说可以把代码贴上这样更好,那么作者就把代码附上 🥳:


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
.box {
display: flex;
flex-direction: row;
}
.item {
flex: 1;
}
</style>
</head>

<body>
<div id="app">
<div class="box">
<div class="item">
<h3>没有 key</h3>
<p v-for="(item, index) in list">{{ item }}</p>
</div>
<div class="item">
<h3>key 为 index</h3>
<p v-for="(item, index) in list" :key="index">{{ item }}</p>
</div>
<div class="item">
<h3>key 唯一</h3>
<p v-for="(item, index) in list" :key="item">{{ item }}</p>
</div>
</div>
<button @click="click1">push(4)</button>
<button @click="click2">splice(1, 0, 666)</button>
<button @click="click3">unshift(999)</button>
<br /><br />
<button @click="click4">pop()</button>
<button @click="click5">splice(1, 1)</button>
<button @click="click6">shift()</button>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
show: false,
list: [1, 2, 3],
},
methods: {
click1() {
this.list.push(4);
},
click2() {
this.list.splice(1, 0, 666);
},
click3() {
this.list.unshift(999);
},
click4() {
this.list.pop();
},
click5() {
this.list.splice(1, 1);
},
click6() {
this.list.shift();
}
},
})
</script>
</body>

</html>

增加实验


实验如下所示,我们首先更改原文字,然后点击按钮**「观察节点发生变化的个数」**:


在队尾增加


在这里插入图片描述


在队内增加


在这里插入图片描述


在队首增加


在这里插入图片描述


删除实验


在队尾删除


在这里插入图片描述


在队内删除


在这里插入图片描述


在队首删除


在这里插入图片描述


实验结论


增加实验


表格为每次实验中,每种情况的最小更新量,假设列表原来的长度为 n


实验没有 keykey 为 indexkey 唯一
在队尾增加111
在队中增加n - i + 1n - i + 11
在队首增加n + 1n + 11

删除实验


表格为每次实验中,每种情况的最小更新量,假设列表原来的长度为 n


实验没有 keykey 为 indexkey 唯一
在队尾删除111
在队中删除n - in - i1
在队首删除nn1

通过以上实验和表格可以得到加上 key 的性能和最小量更新的个数是最小的,虽然在 在队尾增加在队尾删除 的最小更新量相同,但是之前也说了,如果没有 key 是要循环整个列表查找的,时间复杂度是 O(n),而加了 key 的查找时间复杂度为 O(1),因此总体来说加了 key 的性能要更好。


写在最后


本文从源码和实验的角度介绍了 Diff 算法,相信大家对 Diff 算法有了更深的了解了,如果有问题可私信交流或者评论区交流,如果大家喜欢的话可以点赞 ➕ 收藏 🌟


源码函数附录



列举一些源码中出现的简单函数



setTextContent


function setTextContent(node: Node, text: string) {
node.textContent = text
}

isUndef


function isUndef(v: any): v is undefined | null {
return v === undefined || v === null
}

isDef


function isDef<T>(v: T): v is NonNullable<T> {
return v !== undefined && v !== null
}

insertBefore


function insertBefore(
parentNode: Node,
newNode: Node,
referenceNode: Node
) {
parentNode.insertBefore(newNode, referenceNode)
}

nextSibling


function nextSibling(node: Node) {
return node.nextSibling
}

createKeyToOldIdx


function createKeyToOldIdx(children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}

作者:Karl_fang
来源:juejin.cn/post/7204752219915747388
收起阅读 »

技术管理者应有的 4 种基本思维模式

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

在看文章之前先思考两个问题:



  • 人和人的差别究竟在哪?

  • 人和人之间为什么会有差别?


在各种场合我们经常听到这样一句话:「听懂了很多道理,却依然过不好这一生」。
这里有两个逻辑,一个是知行合一的逻辑,另一个是人的成长在于是否超越了昨天的自己。


今天我们聊一聊作为一个技术管理者应该要有的 4 种基本思维:掌控者思维、杠杆思维,终局思维,闭环思维


掌控者思维


在聊掌控者思维之前我们先看一下掌控者思维的反面:受害者思维。


受害者思维又称为弱者思维,有受害者思维的人在任何时候都会把自己当作一个受害者或弱者,极端者会把整个世界都投射为「伤害者」,「都是别人不对,都是别人对不起我」。


受害者思维是一种思维定势,其本质上是一种忽视自己的主观能动性的行为。


在《乡村爱情 13》里面,王木生是老总儿子,富二代,但是干啥啥不行,还总是给自己找借口,说大环境不好,其他没这背景的都比他强。赵本山饰演的他爹王大拿就教训他:「自己没能力就说没能力,怎么你到哪,哪都大环境不好,咋,你是破坏大环境的人啊?」


这就是比较明显的受害者思维,也就是我们常说的「甩锅」。
受害者思维不仅仅只有这一种表现,在《拆掉思维里的墙》里面,作者总结了 5 种受害者思维的表现。



  1. 推卸责任,保住面子: 上面王木生就是明显的例子,还有像一些网上的评论说的「便秘都怪地球没引力」都是类似的表现。在这种情况下,受害者思维就会认为自己没有责任,不需要承担责任,相信地球没引力,相信大环境不好……

  2. 安心做坏事:这种表现就是为自己的行为找到一个自洽的逻辑,让自己有一个完美的故事,从而在做坏事的时候能心安理得。比如 2001 被判死刑的「杀人魔头张君」(实施团伙抢劫,犯故意杀人罪 2 2次,致 28 人死亡、22 人重伤)在 2001.4.17 一审审理结束后对公众说的话:「我还要向没有枪的受害者和你们的家庭说声‘对不起’,现在想起来,以前有些事情确实做错了,但我没办法,因为我要生存。」

  3. 让我们一起分享凄惨故事会:寻找同伴,和他们分享你的受害过程,在他们那里得到安慰。比如有个女孩失恋了,她的闺蜜会陪她喝酒,说男人没有一个好东西;

  4. 用受害获得同情和帮助:第 4 种和第 3 种有些类似,其区别是上面是通过群体得到安慰,得到是一种情感或者心理上的安慰,而这种是更直接的帮助或者说利益,如我们在职场里面看到的一些人会假装自己不会,从而让热心的同事帮忙解决,自己偷懒,或者在路上我们看到的一些乞丐,可能他们的收入比你想象中多很多;

  5. 自我伤害,绑架他人:这种就更极端了,生活中常见于情侣分手,某一方说,你要是离开我,我就自杀,或者电视剧中的男主站在女主楼下淋雨以求得原谅或者求得表白成功。


如此种种,这些可以让你有短暂的快乐和安全感,但是慢慢就会失去自信、勇气以及自我改进的能力,就会真的变成一个受害者,一个弱者。


我们活在这个尘世,很多事情已经无法掌握,如古罗马哲学家爱比克泰德(Epictetus, 约55~约135年)所说:「我们登上的并非我们所选择的舞台,演出并非我们所选择的剧本」,如果连我们自己也无法掌控,让自己的快乐、成功都掌控在别人手里,这会是怎样一个光景呢?


当你带一个技术团队的时候,当你的团队遇到问题的时候,当你的下属遇到他认为过不去的坎的时候,当发生线上事故的时候,当老板怼到脸上的时候,你会怎么办呢?


当一个受害者吗?是大环境不好?是技术太难?还是坏人太多?


不,绝对不是这样,一个技术管理者应该是强于面对现实,敢于直面问题,像汪涵在我是歌手第三季总决赛面对大型事故时所说的那样:「没事儿不惹事,事儿来了也不要怕事」,以一种掌控的状态来扛起事情,解决问题。


这就是我们所说的掌控者思维,一种强者思维,一种充分发挥自己主观能动性的行为。


掌控者思维的核心思考是:「如果把所有经历过的事情重新倒推一遍,所有条件都不改变,只有自己改变,你能否得到一个更好的结果呢


掌控者思维是从责任心,自我成长,主动改变等方面来提升一个技术管理者的修养,以达到自我的精进。
并且不管什么情况,你都可以负全责。只要你愿意,你就可以做得更好。


除了不怕事,负起责任,还有一些是我们必须要做的。



  1. 学习,提升自己的认知,把自己从知道自己不知道变成知道自己知道,换句话说,保持有计划的学习;

  2. 自省,持续的自省,比如当你有一件事做得不好,自我反思一下哪里做得不好,下一次如何改进,或者更深入一些读一些相关的系统的书,写一篇文章或文档来复盘,掌控自己,从卷自己开始;

  3. 计划,凡事预则立,不预则废,如下棋一般,走一步,算三步,把事情想在前头,如美国首席数据科学家 DJ Patil 所说流传很广的那个便签上的话:「Dream in Years. Plan in Months Evaluate in Weeks. Ship Daily. 」

  4. 知行合一,只有做到,才是真的知道。


当然,做一个掌控者会累一些,但是与自由相比,累又算得了什么呢?


杠杆思维


阿基米德说:「给我一个支点,我就能撬起整个地球。」。


一个优秀的人,都有「杠杆思维」,懂得利用杠杆原理,以较小的付出,撬动更大的回报。这里我们所要说的是杠杆思维中的团队杠杆。


当我们从一个开发变成一个技术管理者时,就不再是一个人在战斗了,你的主要责任就不再是写好代码,而是「使众人行」。


时间对于每个人都是公平的,一个人一天都有 24 小时,它是有限的,而我们所面对的这个世界,工作这些都是无限的。


在一个健康经营的互联网企业中,需求也是无限的,而开发同学的时间都是有限的,除了优先级,我们还能做什么呢?此时有人会说:加人啊。是的,加人。但是加人不是随便加的,他有一个底层逻辑,这个就是今天我们要说的团队杠杆。


团队杠杆的本质是叠加效率,人的时间是有限的,而事情是无限的。


什么是团队杠杆?


通过团队,团队的管理者确定好方向,通过良好的机制和人才梯队,系统化成功的路径,叠加个体的优势,创造出远超个人贡献总和的价值,让 1+1>2,这就是团队杠杆。


既然是杠杆,就一定有一个支点,咱们的支点是什么呢?


对一个团队来说,其支点是已经达成了共识的目标和价值观。


目标是什么,是方向,是团队成员能看到的远方。


为什么是达成了共识?是因为只有达成了共识才能保证目标的统一,才能有意愿,有内在的动力去做事情,才能起到杠杆的效果。


目标和价值观只是最基础的,对于一个团队来说,不同的时候其支点也会不同。



  • 在野蛮生长的团队中,核心岗位上的同学的能力提升是主要支点,这里的核心岗位上的能力提升有两层意思,一个是培养员工,给他们训练的机会,培训,从而快速成长;另一个层面是考虑换一个人,当你这认为这个岗位上的员工的能力、态度差不多到顶了,那就可以考虑用更好、更合适的人来替换,这里的逻辑是选拔大于培养

  • 在快速扩张的团队中,流程和效率是主要支点,通过流程提升协同的效率,通过流程让新人快速融入团队;

  • 在稳定发展的团队中,标准和系统是主要支点,当业务趋于稳定,通过标准和系统固化操作,以系统代码流程和人工。


有了支点,我们就需要开始寻找杠杆,对于一个技术团队来说,团队的杠杆有多大,由人决定。
一个团队里面的人是怎样的,人才梯队是怎样的,人才密度是怎样的?


像麦肯锡,每年都会从全球各个顶尖大学,比如哈佛大学、斯坦福大学、麻省理工学院等,招一大批刚毕业的年轻人,不管是不是商学院毕业的。这些绝顶聪明的年轻人,就是麦肯锡充沛而有效的「团队杠杆」。


对于我们一个普通的互联网创业公司来说,作为一个技术团队的负责人,在有限的范围内选出符合要求的同学,同时尽我所能的带好团队。对于如何来带好团队,我们可以从如下 4 个大的方面来



  1. 信任:不管是平级,还是上下级,信任是必须品,通过良好的沟通,多次的合作,一起的奋斗达成信任,这样才是一个好的团队;

  2. 习惯:管理者以身作则培养团队良好的工作习惯,如做事闭环,分级处理,不越红线等等;

  3. 标准:制定能落地的标准,让大家知道什么是好的,什么是对的;

  4. 能力:流程机制决定事情的下限,人才梯队和个人能力决定事情的上限,管理者要帮忙团队成员成长,发现他们的短板,坦率的沟通并提出改进期望,提升成员的技能,以达成更好的结果。


以上带好团队的表述有些虚,但是是这么个逻辑,各团队各公司术法不同,但方向一致。


用好团队杠杆,一个人走得更快,一群人走得更远。


终局思维


什么是终局思维?


所谓终局思维,是指从终点或者未来的某个时间节点出发,回看现在所做选择,并进行推演的一种思考模式。


这里可能有人会问,什么是终局,是否有终局?这就有点哲学的味道了,先假定有吧,至少在某个未来的时间段是会有一个确定的终局。


我们经常听到人们感叹,如果这辈子能够再来一回,很多错误就不会犯,很多人也不会错过,这算是终局思维的一种表现。只是这个终局思维是等到了终局再提起,有些晚了,我们可以再早一些。


我们在做一件事情的时候,如果能思考一下这件事情的最终达成的目标,或者把最终的形态在脑海里面勾画出来,以终为始,站在未来看当下,修正当下正在做的事情,那么这件事情成功的概率可能就不一样了。


终局思维有 4 个核心点:



  1. 认清方向,所谓的终局思维,一定是你要知道终局是什么,一定要有目标有方向;

  2. 拉长时间周期看问题,在一个较长时间维里来反复推演的逻辑;

  3. 从历史或者更宏观的层面看问题,不局限于当下,不局限于眼前的一亩三分地,把视野拉到历史长河等更大的层面;

  4. 反推机制,终局思维中非常重要的点,从未来反向推演现在要做什么,或者从现在推演未来怎样。


终局思维有什么用?


当一个企业的老板大概知道了未来是什么样子,又能不断地判断和复盘自己的能力,他自然就有了非常强的战略驱动能力,也非常敢为未来投资。


当一个技术管理者知道了团队未来是怎么样的,又能反思当前的情况,从未来形态反思当下的状态,他自然就知道现在最重要的是做什么。


当一个开发同学知道了自己在职场上想成为什么样的人,又能反思自己提升自己,他自然知道现在应该做什么,应该学什么,应该选择什么样的团队,什么样的路。


但是,这里一定会有一个痛苦的过程,甚至不止一个,可能是一直在痛苦中,折腾自己,反思自己,提升自己,一直有危机感,一直在学习中。


心理学研究说,人类对于外部世界的认识可分为三个区域:舒适区(comfort zone),学习区(stretch zone),和恐慌区(stress zone)。我们反思自己、提升自己的过程基本都是在学习区,人只有在学习区的时候,才会是进步的时候。


闭环思维


经常听到人人说:「凡事有交代,件件有着落,事事有回音」,这是典型的闭环思维,是一个职场人的必备思维。作为一个技术管理者更应该具备闭环思维,因为这将是你带团队的核心逻辑之一,它强调责任、敏感性和团队协作,是使众人行的必备。


以「逆向」的逻辑来推演一下,假设你带的团队成员都具备闭环思维,将是什么样的场景?



  • 高效率:闭环思维要求在一定时间内,无论任务完成的效果如何,都要认真、负责的反馈给发起人。这可以帮助团队及时发现问题并加以解决,从而提高工作效率。

  • 强协作:闭环思维需要团队成员之间相互配合,洞察干系人每个阶段的需求,并在截止期内给对方反馈。这可以增强团队协作,提高团队的工作效率和成果。

  • 靠谱:闭环思维要求做事靠谱,做事清楚明白,不会出现意外差错,就算出了问题也会提前告知,让对方可以启用方案 B。这样的技术团队会让人觉得可靠和可信,提高团队在公司中的信任度。

  • 好形象:闭环思维是一种良好的职业素养,可以让你和你的团队建立良好的职业形象。在职场中,「闭环思维」不仅仅说明了一个员工拥有完成任务的能力,还体现出「良好的合作意识」,这样的你会让上级、兄弟部门觉得很「靠谱」,能够放心的将工作交给你和你的团队。


那么如何做到闭环思维呢?


闭环思维有两层逻辑,一个是完整,即有完整的闭环,做事情的时候需要考虑事情的起点、目标、影响因素和结果,明确需求和关键点,以及可能遇到的问题,在过程中设置检查点,让事情可控;另一个是主动反馈,在我们完成一项工作或参加一项事情,不管执行效果如何,都要及时将结果反馈给发起人。


第一层逻辑我们可以使用 PDCA 环来辅助达成,第二层反馈逻辑还是需要刻意去练习,甚至给自己设置一些任务提醒来达到反馈的目的,并且在反馈时尽量做到 STAR 的表述。


番外


最近 chatGPT 一直在火,于是就今天的题目我问了 chatGPT 一些问题:



  • 技术管理者必备的4种基本思维模式

  • 技术管理者必备的思维模式

  • 技术管理的四种思维

  • 技术管理者必备的4种思维


大概的答案如下:


openAI 的回答


问:技术管理者必备的思维模式


作为一名技术管理者,以下是一些必备的思维模式:



  1. 系统思维模式:技术管理者需要具备系统思维模式,能够理解和处理复杂系统的各种关系和相互作用,从而在整个系统中发现问题并提供解决方案。

  2. 创新思维模式:技术管理者需要能够持续地推动创新,寻找新的解决方案并将其应用到业务中,从而实现业务目标。

  3. 问题解决思维模式:技术管理者需要能够分析和解决各种问题,包括技术问题、组织问题和人员问题等。

  4. 客户导向思维模式:技术管理者需要关注客户需求和体验,从客户的角度出发思考问题,为客户提供高质量的产品和服务。

  5. 团队合作思维模式:技术管理者需要具备团队合作意识,能够有效地管理和领导团队,激发员工的潜力,协调各种资源,实现业务目标。

  6. 风险管理思维模式:技术管理者需要对业务中可能出现的风险进行预测和管理,制定相应的应对措施,确保业务的可持续发展。

  7. 数据驱动思维模式:技术管理者需要依据数据进行决策和管理,从数据中发现问题和机会,制定相应的战略和计划,提高业务效率和效果。

  8. 学习和成长思维模式:技术管理者需要保持学习和成长的态度,不断学习新知识和技能,从他人的经验中汲取营养,不断提高自身的领导能力和管理水平。


phind 的回答


问:技术管理者必备的4种基本思维模式


答案1


作为技术管理者,必备的4种基本思维模式如下:




  1. 系统思维:技术管理者需要具备系统思维,可以将复杂的问题分解为简单的部分,然后逐一解决。例如,当公司需要开发一个新产品时,技术管理者需要思考每个阶段的具体步骤,确定每个步骤的时间表和责任人,确保整个过程的顺利进行。




  2. 创新思维:技术管理者需要具备创新思维,可以在技术方面寻找新的解决方案,以提高公司的竞争力。例如,当公司需要开发一项新技术时,技术管理者需要思考如何利用现有技术来开发出更好的产品,或者如何开发出全新的技术来满足市场需求。




  3. 沟通思维:技术管理者需要具备良好的沟通能力,可以与员工、客户和其他部门进行有效的沟通。例如,当公司需要与客户沟通产品需求时,技术管理者需要了解客户的需求,并将这些需求传达给开发团队。




  4. 领导思维:技术管理者需要具备领导思维,可以激励员工,推动项目进展,达成公司目标。例如,当公司需要完成一个重要项目时,技术管理者需要领导开发团队,确保项目按时完成,并向管理层报告项目进展情况。




技术管理者不仅需要具备以上4种基本思维模式,还需要具备自我驱动、架构把握、目标管理等核心能力,才能胜任管理岗位。在实际工作中,技术管理者应该灵活运用这些思维模式和能力,不断提升自己的管理水平。


答案 2


根据多个来源,以下是技术管理必备的四种思维和其他相关思维模型:




  1. 抽象思维:这种思维能力可以帮助技术人快速抽取面对问题的关键要素和本质,从而更好地分析和解决问题。




  2. 系统思维:这种思维能力可以帮助技术人将问题看作一个整体,理解各个组成部分之间的相互作用和影响,从而更好地进行系统设计和管理。




  3. 创新思维:这种思维能力可以帮助技术人寻找新的解决方案和创新点,从而更好地推动技术进步和发展。




  4. 逻辑思维:这种思维能力可以帮助技术人分析和推理问题,从而更好地发现问题根源和解决方案。




除了以上四种思维,以下是其他常用的思维模型:




  • PEST分析:这是一种常用的管理咨询框架,可以帮助企业从政治、经济、社会和技术等多个角度分析市场环境和竞争对手,从而更好地制定战略和决策。




  • PDCA循环:这是一种管理模型,可以帮助企业进行持续改进和优化,包括计划、执行、检查和行动四个环节。




  • SMART原则:这是一种目标管理模型,可以帮助企业制定具体、可衡量、可达成、有意义和时限的目标,从而更好地推动业务发展。




  • STAR法:这是一种面试技巧,可以帮助面试者回答问题更具体、更有条理、更有说服力,包括情境(situation)、任务(task)、行动(action)和结果(result)四个要素。




  • 5W2H法:这是一种问题分析和解决模型,可以帮助企业和个人从五个W(what、why、who、when、where)和两个H(how、how much)多个角度分析问题和制定解决方案。




  • PERT 技术:这是一种项目管理工具,可以帮助企业规划和安排整个项目行程,跟踪实施阶段,从而更好地控制项目进度和成本。




总之,技术管理必备的思维能力和模型是多种多样的,需要根据具体情况选择和应用。除了上述思维能力和模型外,还有很多其他的思维工具和技术,需要技术人员不断学习和掌握,以更好


答案 3


根据多个来源的信息,以下是四种技术管理必备的思维能力:




  1. 抽象思维:这种思维能力可以帮助技术人员快速抽取问题的关键要素和本质,从而更快地理解问题和解决问题。例如,当面对一个新的编程语言时,抽象思维可以帮助技术人员快速理解其核心概念和语法规则。




  2. 产品思维:这种思维能力可以帮助技术人员更好地理解用户需求,并将技术解决方案转化为实际的产品。例如,一个架构师需要了解产品的功能需求,以便设计出最佳的技术架构来支持这些需求。




  3. 创新思维:这种思维能力可以帮助技术人员不断创新,寻找新的解决方案和技术。例如,当面对一个复杂的技术问题时,创新思维可以帮助技术人员找到一种独特的解决方案,从而提高效率和质量。




  4. 商业思维:这种思维能力可以帮助技术人员更好地理解商业环境和商业需求,以便为企业提供最佳的技术支持和解决方案。例如,在设计一个新的软件系统时,商业思维可以帮助技术人员理解客户的商业需求,并设计出最佳的系统架构来支持这些需求。




除了以上四种思维能力外,还有其他常见的思维模型和方法可以帮助技术人员更好地管理和解决问题,例如:



  • PDCA 循环:这是一种常见的问题解决方法,包括计划、实施、检查和行动四个阶段,可以帮助技术人员系统地解决问题。

  • SMART 原则:这是一种有效的目标设定方法,包括具体、可衡量、可实现、相关和时间限制五个要素,可以帮助技术人员设定清晰的目标和指标。

  • 5W2H 法:这是一种问题分析方法,包括什么、为什么、谁、何时、何地、如何和多少七个问题,可以帮助技术人员全面地分析和解决问题。


作者:潘锦
来源:juejin.cn/post/7203880677195661373
收起阅读 »

三十岁前端的破冰尝试

@charset "UTF-8";.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:15px;overflow-x:hidden;color:#2b2...
继续阅读 »

大多数人没有意识到精力的有限而盲目学习,从没有停下来认真咀嚼已有的东西。



本人简介


JavaScrip码农,今年三十,宿主是非互联网类型的外企,提供的内存虽然不大,但也基本够存活。


工作之余,我的主题就是咸鱼。但或许是我的咸度不够,最近开始腐烂了,尤其是夜深人静,主要的信息输入被关闭之后,我就感觉内在的信息流在脑海里乱窜,各种健康指数开始飙升。就像是一台老旧的电脑,非要带最新的显卡游戏,发出嘤嘤嘤的EMO声,最后在卡死在昏睡页面。


大多时候醒来会一切安好,像是被删去了前一晚的日志。但有时也会存有一些没删除干净的缓存,它们就像是病毒,随着第二天的重启复苏。我会感到无比的寒冷,冷到我哪怕是饥饿也不敢出门,只有戴上口罩会给我一丝丝的勇气。


这种寒冷会刺激着我无病呻吟,我会感到惊恐和害怕,害怕某天被宿主的回收机制发现这里的不正常,然后被文明的光辉抹除,就如新冠背后那鲜红的死亡人数一样。


或许是幼年求学寄人篱下时烙下的病根,但那时候心田干涸了还可以哭泣。如今呢,心田之上早已是白雪皑皑。


这些年也有人帮助过我,我也努力挣扎过,但大多时候毫无章法,不仅伤了别人的心,也盲目地消耗着心中的热血,愧疚与自责的泪水最终只是让冰层越积越深。


今天也不知哪根筋抽抽了,想着破冰。


嗯,就是字面上的意思,满脑子都是“破冰”二字……


破冰项目


发表这个稿子算是破冰的第一步~


项目的组织架构初步定为凌凌漆,敏捷周期为一周,其中周日进行复盘和制定新计划,其余作为执行日。由于项目长期且紧迫,年假就不予考虑了,病假可以另算,津贴方面目前只考虑早餐,其他看项目发展情况再做调整。


硬件层面


目前作息相当紊乱,供电稳定性差,从近几年的硬件体验报告可以看出,总体运行还算正常,但小毛病层出不穷,电压不稳是当前主要矛盾。OKR如下:


O:保持一个良好的作息

KR1: 保证每天八小时的睡眠。

KR2:保证每天凌晨前关灯睡下。

KR3:保证每天早上九点前起床。


软件层面


英语是硬伤,其次是底层算法需要重写,不然跑着跑着还是会宕机。


翻译是个不错的路子,但数据源是个头痛的问题……肯定得找和技术相关的东西来翻译,并且可以有反馈。嗯…… 想到可以找掘金里已经有的翻译文章,截取其中一小段来进行快速试错。


至于底层算法的问题,此前在leetcode练过一段时间,但仅停留在已知的变得熟练,未知的依旧不会。


因此我觉得有必要先梳理出关于算法的个人认知的知识体系……


总结下来下一阶段任务:



  1. 选择一篇翻译文章,找到其原文,选其中完整的一段进行翻译。

  2. 根据当前认知画个关于算法的思维导图。


下周日会出这周的运行报告以及新一期的计划表。


最后随想


若是觉得我这样的尝试也想试一试,欢迎在评论附上自己的链接,一起尝试,

作者:行僧
来源:juejin.cn/post/7152143987225133086
相互借鉴,共同进步~

收起阅读 »

聊一聊过度设计!

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

  新手程序员在做设计时,因为缺乏经验,很容易写出欠设计的代码,但有一些经验的程序员,尤其是在刚学习过设计模式之后,很容易写出过度设计的代码,而这种代码比新手程序员的代码更可怕,过度设计的代码不仅写出来时的成本很高,后续维护的成本也高。因为相对于毫无设计的代码,过度设计的代码有比较高的理解成本。说这么多,到底什么是过度设计?


什么是过度设计?


  为了解释清楚,我这里用个类比,假如你想拧一颗螺丝,正常的解决方案是找一把螺丝刀,这很合理对吧。 但是有些人就想:“我就要一个不止能拧螺丝的工具,我想要一个可以干各种事的工具!”,于是就花大价钱搞了把瑞士军刀。在你解决“拧螺丝”问题的时候,重心早已从解决问题转变为搞一个工具,这就是过度设计。

在这里插入图片描述
  再举个更技术的例子,假设你出去面试,面试官让你写一个程序,可以实现两个数的加减乘除,方法出入参都给你提供好了 int calc(int x, int y, char op),普通程序员可能会写出以下实现。


    public int calc(int x, int y, int op) {
if (op == '+') {
return x + y;
} else if (op == '-') {
return x - y;
} else if (op == '*') {
return x * y;
} else {
return x / y;
}
}
复制代码

  而高级程序员会运用设计模式,写出这样的代码:


public interface Strategy {
int calc(int x, int y);
}

public class AddStrategy implements Strategy{
@Override
public int calc(int x, int y) {
return x + y;
}
}

public class MinusStrategy implements Strategy{
@Override
public int calc(int x, int y) {
return x - y;
}
}
/**
* 其他实现
*/

public class Main {
public int calc(int x, int y, int op) {
Strategy add = new AddStrategy();
Strategy minux = new MinusStrategy();
Strategy multi = new MultiStrategy();
Strategy div = new DivStrategy();
if (op == '+') {
return add.calc(x, y);
} else if (op == '-') {
return minux.calc(x, y);
} else if (op == '*') {
return multi.calc(x, y);
} else {
return div.calc(x, y);
}
}
}
复制代码

  策略模式好处在于将计算(calc)和具体的实现(strategy)拆分,后续如果修改具体实现,也不需要改动计算的逻辑,而且之后也可以加各种新的计算,比如求模、次幂……,扩展性明显增强,很是牛x。 但光从代码量来看,复杂度也明显增加。回到我们原始的需求上来看,如果我们只是需要实现两个整数的加减乘除,这明显过度设计了。


过度设计的坏处


  个人总结过度设计有两大坏处,首先就是前期的设计和开发的成本问题。过度设计的方案,首先设计的过程就需要投入额外的时间成本,其次越复杂的方案实现成本也就越高、耗时越长,如果是在快速迭代的业务中,这些可能都会决定到业务的生死。其次即便是代码正常上线后,其复杂度也会导致后期的维护成本高,比如当你想将这些代码交接给别人时,别人也需要付出额外的学习成本。


  如果成本问题你都可以接受,接下来这个问题可能影响更大,那就是过度设计可能会影响到代码的灵活性,这点听起来和做设计的目的有些矛盾,做设计不就是为了提升代码的灵活性和扩展性吗!实际上很多过度设计的方案搞错了扩展点,导致该灵活的地方不灵活,不该灵活的地方瞎灵活。在机器学习领域,有个术语叫做“过拟合”,指的是算法模型在测试数据上表现完美,但在更广泛的数据上表现非常差,模式缺少通用性。 过度设计也会出现类似的现象,就是缺少通用性,在面对稍有差异的需求上时可能就需要伤筋动骨级别的改造了。


如何避免过度设计


  既然过度设计有着成本高和欠灵活的问题,那如何避免过度设计呢!我这里总结了几个方法,希望可以帮到大家。


充分理解问题本身


  在设计的过程中,要确保充分理解了真正的问题是什么,明确真正的需求是什么,这样才可以避免做出错误的设计。


保持简单


  过度设计毫无例外都是复杂的设计,很多时候未来有诸多的不确定性,如果过早的针对某个不确定的问题做出方案,很可能就白做了,等遇到真正问题的时候再去解决问题就行。


小步快跑


  不要一开始就想着做出完美的方案,很多时候优秀的方案不是设计出来的,而是逐渐演变出来的,一点点优化已有的设计方案比一开始就设计出一个完美的方案容易得多。


征求其他人的意见


  如果你不确定自己的方案是不是过度设计了,可以咨询下其他人的,尤其是比较资深的人,交叉验证可以快速让你确认问题。


总结


  其实在业务的快速迭代之下,很难判定当前的设计是欠设计还是过度设计,你当前设计了一个简单的方案,未来可能无法适应更复杂的业务需求,但如果你当前设计了一个复杂的方案,有可能会浪费时间……。 在面对类似这种不确定性的时候,我个人还是比较推崇大道至简的哲学,当前用最简单的方案,等需要复杂性扩展的时候再去重构代码。

作者:xindoo
来源:juejin.cn/post/7204423284905738298

收起阅读 »

三行代码让你的git记录保持整洁

Git
前言 笔者最近在主导一个项目的架构迁移工作,由于迁移项目的历史包袱较重,人员合作较多,在迁移过程中免不了进行多分支、多次commit的情况,时间一长,git的提交记录便混乱不堪,随便截一个图形化的git提交历史给大家感受一下。 各种分支疯狂打架宛如后宫争宠的...
继续阅读 »

前言


笔者最近在主导一个项目的架构迁移工作,由于迁移项目的历史包袱较重,人员合作较多,在迁移过程中免不了进行多分支、多次commit的情况,时间一长,git的提交记录便混乱不堪,随便截一个图形化的git提交历史给大家感受一下。



各种分支疯狂打架宛如后宫争宠的妃子们,之所以会出现这种情况,主要还是因为滥用git merge命令并且不考虑后续的理解成本导致的。如今在大厂工作的程序员们,频繁接受变更的需求,一旦一开始考虑不周到,就一定会出现了大量无意义的commit log,加上“敏捷”理念的推广,产品的快速迭代上线变成了核心指标,这些无意义的commit log便被“下次再处理”,久而久之就混乱不堪了。


而我们在看一些开源仓库时,会发现他们的commit记录十分整洁,其实这并不是社区的程序员能力更强,而是因为他们没有KPI大棒的鞭笞,在提交代码前会花时间整理自己的commit log。而这就是本文的主角了——“Git Rebase”。


git rebase和git merge


git rebase,中文翻译为“变基”,通常用于分支合并。既然提到了分支合并,那就一定离不开git merge这个命令。


相信每个新手程序员刚进入职场的时候,都会听到“xxx你把这个分支merge一下”这样的话。那么问题来了,假如你有6个程序员一起工作, 你就会有6个程序员的分支, 如果你使用merge, 你的代码历史树就会有六个branch跟这个主的branch交织在一起。



上图是 git merge 操作的流程示意图,Merge命令会保留所有commit的历史时间。每个人对代码的提交是各式各样的。尽管这些时间对于程序本身并没有任何意义。但是merge的命令初衷就是为了保留这些时间不被修改。于是也就形成了以merge时间为基准的网状历史结构。每个分支上都会继续保留各自的代码记录,主分支上只保留merge的历史记录。子分支随时都有可能被删除。子分子删除以后,你能够看到的记录也就是,merge某branch到某branch上了。这个历史记录描述基本上是没有意义的。


git rebase 中文翻译为“变基”,变得这个基指的是基准。如何理解这个基准呢?我们看一下下图。



我们可以看到经过变基后的feature分支的基准分支发生了变化,变成了最新的master。这就是所谓的“变基”。


通过上面的两张图可以很明显的发现,这两种合并分支的方式最大的区别在于,merge后的分支,会保留两个分支的操作记录,这在git commit log 树中会以交叉的形式保存。而rebase后的分支会基于最新的master分支,从而不会形成分叉,自始至终都是一条干净的直线。



关于 git rebasegit merge 的详细用法不在本文的介绍范围内,详情可以参考互联网上的其他资料。



在变基过程中,我们通常需要进行commit的修改,而这也为我们整理git记录提供了一个可选方案。


保持最近的几条记录整洁


假设我们有一个仓库,我在这个仓库里执行了4次提交,通过 git reflog 命令查看提交记录如下。



如果我们想将Commit-3、Commit-2和Commit-1的提交合并成一次提交(假设某次提交至改了一些pom文件),我们可以直接执行下面的命令


git rebase -i HEAD~3
复制代码

-i 指的是 --interactiveHEAD~3 指的是最近三次commit。


当然我们也可以直接指定最新的一个想保留的 Commit的ID,在上面的例子中就是Commit-0的ID,因此我们也可以写成


git rebase -i d2b9b78
复制代码

执行该命令后,我们会进入到这么如下一个界面:



这个界面是一个Vim界面,我们可以在这个界面中查看、编辑变更记录。有关Vim的操作,可以看我之前写的文章和录制的视频👉《和Vim的初次见面》


在看前三行之前,我们先来看一下第5行的命令加深一下我们对git rebase的认识。



翻译过来就是,将d2b9b78..0e65e22这几个分支变基到d2b9b78这个分支,也就是将Commit-3/2/1/0这几次变更合并到Commit-0上。


回到前面三行,这三行表示的是我们需要操作的三个 Commit,每行最前面的是对该 Commit 操作的 Command。而每个命令指的是什么,命令行里都已经详细的告诉我们了。




  • pick:使用该commit

  • squash:使用该 Commit,但会被合并到前一个 Commit 当中

  • fixup:就像 squash 那样,但会抛弃这个 Commit 的 Commit message


因此我们可以直接改成下面这样




这里使用fixup,而不是squash的主要原因是squash会让你再输入一遍commit的log,图省事的话,可以无脑选择fixup模式。



然后执行:wq退出vim编辑器,我们可以看到控制台已经输出Successful了。



这个时候我们再来看下log 记录,执行git log --oneline


于是最近三次的提交记录就被合并成一条提交记录了。


保持中间某些记录整洁


那如果不是最后的几个commit合并,而是中间连续的几个Commit记录,可以用上述方法整理合并吗?答案是可以的,只不过需要注意一下。


我们重新创建一个新的仓库



如果这次我们想将"third commit"和"second commit"合并为一个提交,其实和上面的方式一样,我们只需执行git rebase -i HEAD~3,然后将中间的提交改成fixup/squash模式即可,如下图所示:




之所以是HEAD~3,是因为我们要做的变更是基于first commit做的,因此我们也可以写成git rebase -i a1f3929



我们来看下更改完的commit log,如下图所示:



是不是就干掉了third commit了。


三行代码让git提交记录保持整洁


上面我们都是在本地的git仓库中进行的commit记录整理,但是在实际的开发过程中,我们基本上都是写完就直接push到远程仓库了,那应该如何让远程的开发分支也保持记录的整洁呢?


第一种做法是在push代码前就做在本地整理好自己的代码,但是这种做法并不适用于那种本地无法部署,需要部署到远程环境才能调试的场景。


这时我们只需要执行git push -f命令,将自己的修改同步到远程分支即可。


-f是force强制的意思,之所以要强制推送是因为本地分支的变更和远程分支出现了分歧,需要用本地的变更覆盖远程的。


而远程分支更新后,如果其他人也在这条分支上更改的话,还需要执行一个git pull命令来同步远程分支。


这里我们来总结下让git提交记录保持整洁的三行代码。


git rebase -i xxx
git push -f
git pull
复制代码


❗️❗️❗️Tips:由于rebase和push -f是有些危险的操作,因此只建议在自己的分支上执行哦。


作者:插猹的闰土
链接:https://juejin.cn/post/7203989318272237624
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

被泼冷水后,谁能超越微服务?

历史总会重演。一切刚过去的,又会被重新提起。开源项目Codename One的联合创始人Shai,曾是Sun Microsystems开源LWUIT项目的共同作者,参与了无数开源项目。作为最早一批Java开发者,最近感慨道:单体,又回来了!Shai说道:我已经...
继续阅读 »

历史总会重演。一切刚过去的,又会被重新提起。开源项目Codename One的联合创始人Shai,曾是Sun Microsystems开源LWUIT项目的共同作者,参与了无数开源项目。作为最早一批Java开发者,最近感慨道:单体,又回来了!

Shai说道:我已经在这个圈子里很久时间了,看到了一次次被抛弃、被重新发现的想法,超越“时髦词汇”,并凯旋而归。

他进一步举例,“近年来,SQL也挣扎过后,死而复生。我们再次热爱关系数据库。我认为单体架构将再次迎来奇幻之旅。微服务和无服务器是云供应商推动的趋势,目的当然是在向我们兜售更多的云计算资源。然而对于大多数用例来说,微服务在财务上意义不大。是的,供应商当然也可以降低成本。但当他们扩大规模时,他们会以股息来覆盖掉成本。单是可观测性成本的增加,就让‘大型云’供应商的腰包鼓起来了!”

作为从业近30年的资深技术大神,为何做此感叹?本文通过一场“利用模块降低架构成本”的探讨,帮助大家梳理现在的架构设计难题,希望对诸君有所启发。

问题背景

我最近领导了一个会议小组,讨论了微服务与单体服务的主题。组内认为,单块的规模不如微服务。这对于亚马逊、eBay等所取代的那些庞然大物来说可能是正确的。这些确实是巨大的代码库,其中的每一次修改都是痛苦的,而且它们的扩展都是具有挑战性的。但这不是一个公平的比较。较新的方法通常优于旧的方法。但如果我们用更新的工具构建一个整体,我们会得到更好的可扩展性吗?它的局限性是什么?现代的单体(也称巨石)到底该是什么样子?

单体回归范例:Modulith

Spring Modulith是一个模块化的单体结构,可以让我们使用动态隔离件构建单体结构。通过这种方法,我们可以分离测试、开发、文档和依赖项。这有助于微服务开发的独立方面,而所涉及的开销很少。它消除了远程调用和功能复制(存储、身份验证等)的开销。

Spring Modulith不是基于Java平台模块化(Jigsaw)。他们在测试期间和运行时强制分离,这是一个常规的Spring Boot项目。它有一些额外的运行时功能,可以实现模块化的可观测性,但它主要是“最佳实践”的执行者。这种分离的价值超出了我们通常使用微服务的价值,但也有一些权衡。

举个例子,传统的Spring monolith将采用分层架构,其包如下:

com.debugagent.myapp
com.debugagent.myapp.services
com.debugagent.myapp.db
com.debugagent.myapp.rest

这很有价值,因为它可以帮助我们避免层之间的依赖关系;例如,DB层不应依赖于服务层。我们可以使用这样的模块,并有效地将依赖关系图推向一个方向:向下。但随着我们的成长,这没有多大意义。每一层都将充满业务逻辑类和数据库复杂性。

有了Modulith,我们的架构看起来更像这样:

com.debugagent.myapp.customers
com.debugagent.myapp.customers.services
com.debugagent.myapp.customers.db
com.debugagent.myapp.customers.rest

com.debugagent.myapp.invoicing
com.debugagent.myapp.invoicing.services
com.debugagent.myapp.invoicing.db
com.debugagent.myapp.invoicing.rest

com.debugagent.myapp.hr
com.debugagent.myapp.hr.services
com.debugagent.myapp.hr.db
com.debugagent.myapp.hr.rest

这看起来非常接近一个合适的微服务架构。我们根据业务逻辑分离了所有部分。在这里,可以更好地控制交叉依赖关系,团队可以专注于自己的孤立区域,而不必互相踩脚。这是微服务的价值之一,且没有开销。

我们可以使用注释进一步深入地和声明性地实现分离。我们可以定义哪个模块使用哪个并强制单向依赖关系,因此人力资源模块将与发票无关。客户模块也不会。我们可以在客户和发票之间建立单向关系,并使用事件进行反馈。Modulith中的事件是简单、快速和事务性的。它们消除了模块之间的依赖关系,无需麻烦。这可以用微服务实现,但很难实现。比如,开票需要向不同的模块公开接口。如何防止客户使用该界面?

有了模块,我们就可以做到。对用户可以更改代码并提供访问权限,但这需要经过代码审查,这会带来自己的问题。请注意,对于模块,我们仍然可以依赖常见的微服务,如功能标志、消息传递系统等。您可以在文档和Nicolas Fränkel的博客中阅读有关Spring Modulith的更多信息。

模块系统中的每个依赖项都被映射并记录在代码中。Spring实现包括使用方便的最新图表自动记录所有内容的能力。你可能会认为,依赖性是Terraform的原因。对于这样的“高级”设计来说,这是正确的地方吗?

对于Modulith部署,像Terraform这样的基础设施即代码(IaC)解决方案仍然存在,但它们会简单得多。问题是责任的划分。正如下图所展示,微服务并没有消除整体结构的复杂性。我们只把“难啃的骨头”踢给了DevOps团队。更糟糕的是,我们没有给他们正确的工具来理解这种复杂性,所以他们不得不从外部管理。


图源:Twitter

这就是为什么我们行业的基础设施成本在上升,而传统行业的基础设施价格却在下降。当DevOps团队遇到问题时,他们会投入资源。这显然不是正确的做法。

其他模块

我们可以使用标准Java平台模块(Jigsaw)来构建Spring Boot应用程序。这样做的好处是可以分解应用程序和标准Java语法,但有时可能会很尴尬。当使用外部库或将一些工作拆分为通用工具时,可能会更有效。

另一个选项是Maven中的模块系统。这个系统允许我们将构建分解为多个单独的项目。这是一个非常方便的过程,可以让我们省去大量项目的麻烦。每个项目都是独立的,易于使用。它可以使用自己的构建过程。然后,当我们构建主项目时,这些全部都变成了一个单体。在某种程度上,这才是我们真正想要的。

单体架构:扩展,有解吗

可以使用大多数微服务扩展工具来扩展我们的单体们。许多与扩展和集群相关的开发都是在单体架构的情况下进行的。这是一个更简单的过程,因为只有一个移动部分:应用程序。我们复制其他实例并观察它们。没有哪项服务是失败的。我们有细粒度的性能工具,所有的功能都可以作为一个统一的版本。

我认为扩展单体为微服务比直接构建微服务更简单——

  • 我们可以使用分析工具,并获得瓶颈的合理近似值。

  • 我们的团队可以轻松地(并且经济实惠地)设置运行测试的登台环境。

  • 我们拥有整个系统及其依赖关系的单一视图。

  • 我们可以单独测试单个模块并验证性能假设。

跟踪和可观测性工具非常棒。但它们也会影响生产,有时还会产生噪音。当我们试图解决伸缩瓶颈或性能问题时,这些工具可能会让设计者踩一些坑。

我们可以将Kubernetes与monolits一起使用,就像将其与微服务一起使用一样有效。镜像尺寸会更大(如果我们使用GraalVM这样的工具,则可能不会太大)。有了这一点,我们可以跨区域复制monolith ,并提供与微服务相同的故障转移行为。相当多的开发人员将monolics部署到Lambdas。笔者不太喜欢这种方法,因为非常昂贵。

单体的瓶颈问题:有解

但仍有一点是巨大的障碍:数据库。由于微服务固有地具有多个独立的数据库,因此它们实现了巨大的规模。单体架构通常与单个数据存储一起工作。这通常是应用程序的真正瓶颈。有多种方法可以扩展现代数据库。集群和分布式缓存是强大的工具,可以让我们达到在微服务架构中很难达到的性能水平。

在一个单体结构中,也并不需要单个数据库。例如:在使用Redis进行缓存时,选择使用SQL数据库也是很常见的事情。但我们也可以为时间序列或空间数据使用单独的数据库。我们也可以使用单独的数据库来提高性能,尽管根据笔者经验,这种情况从未发生过。将数据保存在同一数据库中的好处是巨大的。

回归单体的好处

事实上,这样做有一个惊人的好处,我们可以在不依赖“最终一致性”的情况下完成交易。当我们尝试调试和复制分布式系统时,可能会遇到一个很难在本地复制的过渡状态,甚至很难通过查看可观测性数据来完全理解。

原始性能消除了大量网络开销。通过适当调整的二级缓存,我们可以进一步删除80-90%的读IO。在微服务中,要实现这一点要困难得多,而且可能不会删除网络调用的开销。

正如我之前提到的,应用程序的复杂性在微服务架构中不会消失。我们只是把它搬到了另一个地方。所以从这个层面讲,微服务并不算真正的进步,因为在此过程中平白添加了许多移动部件,增加了整体复杂性。因此,回归更智能、更简单的统一架构更有意义。

再看微服务的卖点

编程语言的选择是微服务亲和力的首要指标之一。微服务的兴起与Python和JavaScript的兴起相关。这两种语言非常适合小型应用程序,对于较大型的应用就不太适用了。

Kubernetes使得扩展此类部署相对容易,因此为已经增长的趋势增添了动力。微服务也有一些相对快速的升降能力。这可以以更细粒度的方式控制成本。在这方面,微服务被出售给组织,作为降低成本的一种方式。

这并非完全没有优点。如果以前的服务器部署需要强大(昂贵)的服务器,那么这一论点可能有一定道理。这可能适用于极端使用的情况,比如:突然面临非常高的负载,但随后没有堵塞。在这些情况下,可以从托管的Kubernetes提供商动态(廉价)获取资源。

微服务的主要卖点之一是组织调度方面。这使得各个敏捷团队能够在不完全了解“大局”的情况下解决小问题。问题也在于此,这就会创造一种“单干”文化,让每个团队都“自己做自己的事情”。在缩减规模的过程中,尤其是在代码“腐烂”的情况下,问题更甚。系统可能仍能工作数年,但实际上无法维护。

互联网建立在单体之上为什么要离开呢

笔者组内中的一个共识是,我们应该始终从单体开始。它更容易构建,如果我们选择使用微服务,我们可以稍后将单体分解。

提及具体某个软件相关的复杂性,我们讨论单个模块而不是单个应用程序要更有意义些。二者在资源使用和财务浪费上的差异是巨大的。在这个追求“降本”的时代,为什么人们还要不知变通地默认构建微服务,而不是动态的模块化单体架构呢?

我们可以从这两大“架构阵营”学到很多东西。诚然,微服务为亚马逊创造了奇迹。但公平地说,他们的云成本已包含在这个奇迹之中。所以,一位的搞微服务教条肯定是有问题的。

另一方面,互联网是建立在单体之上的。它们中的大多数都不是模块化的。两者都有普遍适用的技术。因此,笔者看来,正确的选择是构建一个模块化的单体结构,先搭建好合适的身份验证基础设施,如果我们想在未来转向微服务,我们可以利用这些基础设施来进行解构拆分。

后记

在设计应用时,我们目前更多是面临“二选一”的架构选择:单体和微服务。它们二者通常被视为相反的方法。

在小型系统演进过程中,有这样一个不争的事实:单体应用程序往往会随着时间的推移而在架构上降级,即使在其生命周期开始阶段就定义其为架构。随着时间的推移,各种架构的禁止事项会不知不觉地进入项目,久而久之,系统变得更难改变,进化性受到影响。

另一方面,微服务提供了更强的分离手段,但同时也带来了许多复杂性,因为即使对于小型应用程序,团队也必须应对分布式系统的挑战。

单体回归,也是具体的有条件的回归。我们看到,趋势的改变,代表着某段时期具体任务或者目标正在变化。出于目标的变化,我们对于微服务和单体架构的二选一的选择问题,也不能再教条式的看待。

事物往往都在螺旋式的演进,对于架构而言,亦如是。

来源:mp.weixin.qq.com/s/2peN_MezvkR9UwtalhG6kA

原文链接:dzone.com/articles/is-it-time-to-go-back-to-the-monolith

收起阅读 »

我终于统一了团队的技术方案设计模板

团队的技术方案设计模板不管我们是做业务开发,还是做基础建设,虽然产品诉求千差万别,但是我们必然需要做好方案设计,然后还需要进行方案设计评审。之前我们团队的一些成员,甚至有些 T9 级别的同学,竟然都写不好一个技术方案设计文档。究其根本,主要还是没有形成自己的方...
继续阅读 »

团队的技术方案设计模板

不管我们是做业务开发,还是做基础建设,虽然产品诉求千差万别,但是我们必然需要做好方案设计,然后还需要进行方案设计评审。

之前我们团队的一些成员,甚至有些 T9 级别的同学,竟然都写不好一个技术方案设计文档。究其根本,主要还是没有形成自己的方法论,从我个人工作这么多年的经验来看,技术方案设计是可以总结出一套方法论或者说框架套路来的。为此,我总结出了一套通用的技术方案设计模板(提纲),然后在我们团队内部进行了统一,后面还推广到了整个中心,大家按照这个模板来写方案设计,绝对让你的领导满意。

大家参考我这个方案设计模板(提纲)和相关介绍来做自己的方案设计的时候,可以根据自己的实际业务情况和背景做相关目录的删减,最后得出自己最终的方案设计,然后再去进行方案评审。

精简版-技术方案设计模板(提纲)

精简版的模板如下,一般的方案设计,大家都可以参考这个提纲来写:


详细版-技术方案设计模板(提纲)

相对详细和复杂的版本如下:



下面是技术方案设计模板在每一章节的简单说明,用来帮助你理清每个章节大概要写什么内容

一,现状

现状,主要是用来描述当前这个业务(项目)的一些基本情况介绍和相关的背景。你的方案设计出来之后,是需要给你的 leader 或者团队其他成员进行评审或者查看,甚至是要给更高级别的人来评审。但是别人不可能都和你一样清楚你的项目,因此首先,你要把你项目的基本情况和背景都说清楚,让大家达成一个共识,站在同一个起点上,才能进行后面的方案评审和讨论。

业务背景

业务背景就是你这个业务(项目)的基本介绍,包括但不限于:

  • 项目名称

  • 业务描述

技术背景

技术背景就是你这个业务是基于什么样的技术背景下来构建的,我们的技术方案可能是从 0 到 1 来构建,也能是基于现有的方案来优化,但是不管是什么场景,一定都会存在相关的技术背景,因此包括但不限于:

  • 现有技术积淀

  • 现有架构描述

  • 现有系统的整体容量

二,需求

需求,很重要!技术人员千万不要忽略需求,因为不管你的技术有多牛逼,都一定为需求服务的,不管这个需求是技术需求,还是业务需求,一定都是要为需求服务。而需求,就是你这个技术方案的起点,技术方案一切都是围绕需求来设计,当然,这个需求可以是当下的需求,也可以包含未来潜在的需求。

只有把需求介绍清楚之后,大家才能知道你方案设计里面的所有设计和对应的折中点是否可行,也才能比较好的去评审你的方案。

业务需求

业务需求就是你这个业务具体要做的事情,包括但不限于:

  • 要改造的内容

  • 要实现的新需求

业务痛点

  • 涉及到的业务痛点有哪些

性能需求

我们做需求的时候,对于技术人员,不能只看业务需求,业务需求可能是项目管理人员,也可能是产品人员提出来的,他们只会重点关注业务的可行性,只会关注业务的逻辑。但是技术人员,要从这个业务需求里面考虑清楚我们满足这个业务之下的性能需求点,比如我做一个秒杀活动,如果你不考虑性能,可能活动一上来,服务就挂掉了。性能需求包括但不限于:

  • 预估系统平均容量

  • 预估系统峰值容量

  • 可伸缩性

  • 其他的一些性能要求点,比如安全性等

三,方案描述

前面把现状和需求说清楚后,终于到了我们的重头戏,方案描述这里了。一般我们做方案,可能会有几个可选的方案,但是你不清楚哪个方案最合适,因此你需要把相关可能的方案都描述清楚,然后给出你认为的最合适的方案,然后让大家来评审和决策,看是否同意你的意见或者有其他更好的意见。

如果没有方案对比,那么可以省略掉这一章节

方案1

概述

一句话概括方案的亮点,比如说:高性能、可扩展、双写、主从分离、分库分表、扩容等。

详细说明

详细说明这里需要图文结合,包括但不限于架构图、流程图 等。把你整个方案的架构和模块、细节流程都描述清楚

性能目标

性能一般来说可能包含以下部分:

  • 日平均请求:一般来自产品人员的评估;

  • 平均QPS:日平均请求 除以 4w秒得出,为什么是4w秒呢,24小时化为86400秒,取用户活跃时间为白天算,除2得4w秒;

  • 峰值QPS:一般可以以QPS的2~4倍计算;

性能评估

给出方案的基准数据,并按性能需求评估需要使用的资源数量。

  • 单机并发量

  • 单机容量

  • 按照预估性能需求,预估资源数量(应用服务器、缓存、存储、队列等)

  • 伸缩方式

方案优缺点

列出方案的优缺点,优缺点要具有确定性,最好是通过量化的指标来说明

方案2

可选的另外一种方案,模板和上面一样。

方案对比

前面给出了多种可选的方案,那么这里就是进行一个简单的对比,然后给出你觉得最优的方案和原因,这就是你的决策。

有了你自己的决策(倾向)的方案后,接下来的设计就应该更多的偏向你倾向的方案去做设计和描述

四,线上方案

线上方案是对上面你更倾向的方案的更为细致的描述。

架构图

整体架构是如何,把架构图画上

关键设计点 和 设计折衷

把几个关键、重点的点的设计思想表述出来,用来确保你的方案的大体方向是 OK 的。

因为没有一个方案设计是最完美,方案设计都是逐步演进和优化的,方案设计是要最符合当前的背景的。因此,一定会有你设计的关键点和折衷点,这也就是前面为何要把项目的各种业务背景和技术背景都说清楚的原因。

业务流程

整体流程是如何,弄一个整体流程图、核心流程图出来,然后分业务场景把各个业务场景的流程图也画出来,并且做好相关介绍

模块划分

有了业务流程,那么必然要针对这个业务流程的各个环节来划分模块,模块的划分需要考虑我们架构设计的一些原则,比如:架构分层、业务分模块、微服务化、高内聚低耦合 等。然后把每个模块的功能点都说清楚

异常边界【重要】

异常边界是比较重要的,一般情况下,大部分人都能考虑到正常的处理流程,对于异常的边界考虑的比较少,但是线上出问题,大部分都是异常情况导致,因此这里非常重要!!!

我们可以通过一个 xmind 格式去整理相关的异常边界,这样有助于自己在实现的时候有足够的把控度,也便于别人去 review 你的方案和具体实现(如 coding)

异常边界需要考虑:

  • 涉及到了哪些模块

  • 涉及到了哪些流程

  • 每个模块、流程出现了各种可能情况的处理是?

  • 系统底层原因导致的异常的处理是 ?

统计、监控

线上运行的项目,一定需要有各种监控,除了公司内部的基建的监控外,我们可能还需要从业务内部实现自定义的一些业务监控和相关技术统计

灰度、回滚策略

  • 如何灰度?

  • 如何回滚?

容灾方案

容灾就是当出现 IDC 异常的情况下,怎么容灾,这个可以根据实际情况去考虑。

五,部署拓扑

线上部署拓扑如何,上下游是如何

六,风险评估

标识所选方案的风险,提出解决此风险发生时候的应对策略,比如:上线失败时的回滚策略。

潜在风险

  • 相关的改动有哪些风险点

  • 不兼容点?

  • 当前设计方案目前存在哪些问题?

  • 潜在有哪些问题

七,阶段规划【架构演进规划】

架构怎么演进

阶段如何规划

每个阶段该达成什么目标

第一阶段

第二阶段

第三阶段

八,工作量评估

工作量评估也是一个重要的环节,这里需要细化到每个模块、每个接口的设计分别需要多长时间,一定要同时包括开发时间、联调时间、测试时间。

来源: 后端系统和架构

收起阅读 »

TCP 长连接层的设计和在 IM 项目的实战应用

TCP 长连接接入层的连接管理TCP 长连接的管理思路实现思路IM 架构中的 TCP 长连接接入层的 NET 连接一般会很多,比如单台服务器至少会有几十万,有的甚至会到百万连接;这个长连接的维持,也就代表中会有这么多客户端(用户)的接入。那么我们怎么去管理这些...
继续阅读 »

TCP 长连接接入层的连接管理

TCP 长连接的管理思路

实现思路

IM 架构中的 TCP 长连接接入层的 NET 连接一般会很多,比如单台服务器至少会有几十万,有的甚至会到百万连接;这个长连接的维持,也就代表中会有这么多客户端(用户)的接入。那么我们怎么去管理这些连接?当有数据需要下发的时候,怎么能够快速根据连接信息找到用户、或者根据用户快速定位到网络连接?这就需要我们能够有一个合适的数据结构去维护,并且我们需要考虑一些其他的点比如快速定位、机器内存大小等。

最容易想到的一个思路是通过 map 数据结构来管理,比如 map<conn,user>,因为每个用户的 uid(user)是唯一,因此,这样做,初期来看,并不会有太大的影响;但是试想一下,这个只能单向定位,只能根据 Conn 网络连接查找用户,那么我想根据用户信息快速查找到对应的 Conn 然后下发数据怎么办呢?

为此,一个更为合适的做法就是将用户和网络连接进行一一对应,这样,不仅是可以相互查找,并且查找定位的时间复杂度总是 O(1)。具体实现的 Golang 代码如下,只列出关键信息:

// Conn 与 User 一一映射,用来优化 map 查询方式

type Conn struct {
  conn       net.Conn // TCP 网络 连接信息
    user       *User     // 客户端用户信息(一般包含 uid、name等)
}

type User struct {
  uid             int64
  Name             string
  conn             *Conn
}

这样的结构设计,就是 Conn 里面包含了 User、User 里面包含了 Conn,这样就是一一对应,不管多少数据量,查询定位的时间复杂度都是 O(1)。这里应用了一个思想就是空间换时间,因为我们当前的机器的内存是很大的,所以就可以利用这个空间换时间的思想,快速查询。

应用场景

IM 系统中,必然会有这么几个操作:

  • • 用来连接(accept)

  • • 用户登录(login)

TCP Socket 编程模型是:

socket -> bind -> listen -> accept -> recv -> send -> close

因此对 IM 接入层来说,首先会收到用户的 accept 请求,accept 成功之后,我们就有了 Conn 信息,然后我们开始填充 Conn 结构 和 User 结构,这里算是初步建立起了对应关系,但是 User 中的信息还不够,还需要用户登录之后才有更多的数据。

连接成功之后,用户就会发起登录请求,登录成功之后,就会有了足够的 User 信息,这样就可以根据相关信息相互定位了。登录成功之后,长连接接入层还需要给用户回应 ACK ,因此在登录包之后,长连接接入层就可以从 User 结构中取出 Conn 进行回包给用户(客户端)。

随后的操作中,我们可以根据业务场景的需要,从 User(uid)中快速定位到 Conn,然后发送消息给客户端;也可以根据 Conn 快速定位到 User,更新 User 信息,或者获取 User 信息。

TCP 长连接心跳超时的处理

再来看看另外一个场景,首先,我们要清楚,长连接接入层一定是有多个的,一台机器肯定扛不住,也无法做到高可用。因此在每个接入层节点中的处理上,还有一点非常重要的就是,维持着大量长连接后,如果客户端一直没有请求,或者客户端以为异常导致关闭了连接但是服务端并不知晓,那么这些无用的长连接,服务端肯定是需要清理的,避免占用大量资源。

怎么实现?当然需要通过心跳来保持连接,如果心跳超时则踢出连接。心跳这里多说一句,一般固定心跳设置为 4.5 分钟,还有更为合适的智能心跳策略。我们现在重点在于管理 TCP 长连接,不讨论心跳策略的实现。

上面的 TCP 长连接的管理思路是需要一一对应,方便相互查找,那么针对心跳是否超时,这个和用户没有关系,因此只需 Conn 的处理。通过一个红黑树可以搞定,通过递归地从根节点向左遍历节点,直到左节点为空,可以查找树中的所有 Conn 的超时情况。

Golang 的代码片段如下:

var timeoutTree *rbtree.Rbtree  //红黑树


type TimeoutInfo struct {
  conn   *Conn         // 连接信息
  latestTime time.Time //心跳的最新时间
}


每次收到心跳包都重新更新时间
func AddTimeoutCheckInfo(conn *Conn) {
  timeoutTree.Insert(&TimeoutInfo{conn: conn, latestTime: time.Now()})
}

独立协程来遍历扫描并清除超时的连接:

    for {
      // 遍历
      item := timeoutTree.Min()

      // 取连接、取最新时间
      latestTime := item.(*TimeoutInfo).latestTime
      conn := item.(*TimeoutInfo).conn

      // 计算连接的最新时间是否超时,超时则关闭连接和清理
      if timeout {
          timeoutTree.Delete(item)
          conn.Close()
      }
  }

TCP 长连接层的负载均衡策略

既然长连接接入层节点有多个,并且可以随时根据需要扩缩容,然而客户端并不清楚你服务端到底部署了多少台节点,那么客户端该怎么发起连接呢?怎么做才能保证合理的负载均衡呢?

一般的负载均衡策略如 RR 轮询,是否能够满足 IM 的诉求呢?试想这么一个真实的场景,当前线上有 5 台机器,每台机器负载都很高了,此时连接会很不稳定,客户端出现频繁重连。此时肯定需要扩容,OK,那么扩容了 2 台,然后 client 建连如果还是轮询,那么新扩容的机器,还是不能马上分散其他机器上的压力,压力还是会往老的机器上面去打,显然不合理。

因此,针对 IM 场景,最合理的负载均衡策略,就是根据连接数来负载均衡,客户端新发起连接需要接入的接入层节点一定是连接数最少的,因为每台节点会需要控制最大连接数的限制才能保证最优性能,并且能够及时给压力大的节点减压。

怎么实现呢?这里就需要有一个服务注册发现的组件(如 Etcd)来帮助我们达到诉求。首先,接入层启动后,往 Etcd 里面注册信息,并且再在后续的生命周期中,定期更新当前节点已有的连接数到 Etcd 中;然后我们需要有一个 Router Server,这个服务去 watch Etcd 中的接入层节点信息,Etcd 的使用可以参考etcd/clientv3;然后实时计算,得到一个列表排序,这个排序是按照节点数最少的节点排序的。

然后 Router Server 提供一个 HTTP 服务的 API 接口,用来返回所有节点中连接数最少的节点的一批 IP 列表(一般可以 3 个)给到客户端。为何不是返回一个呢?因为我们返回的节点,可能因为其他原因导致连接不上,或者连接不稳定,那么此时 客户端就可以有备选方案,选择返回的下一个节点建连。

涉及点包括:

  • • 接入层注册信息(节点 IP 和 port、节点连接数)

  • • 路由层 watch 接入层的信息

  • • 路由层计算路由算法

  • • 路由层提供 HTTP 接口返回合适的节点 IP 列表

TCP 长连接接入层服务的优雅重启和缩容

对于通用的长连接接入层而言

长连接接入层是和用户客户端直接相连的,客户端通过 TCP 长连接连接到接入层,因此接入层如果需要重启,那么必然会导致客户端连接断开,发生重连。如果此时用户正在发送消息,那么必然会导致发送异常,从而影响用户体验。

那么我们需要怎么实现接入层,才能保证重启或者缩容的时候,不影响用户、对用户无感知呢?有这么几个思路:

  1. \1. 接入层做的足够轻量,尽量只是维持 TCP 长连接和数据包的转发,所有其他业务逻辑,尽量转发到业务层去处理,接入层与业务逻辑层严格分离;因为业务层逻辑是需要频繁变动,而接入层的长连接维持可以做到尽量不变,这样就会尽可能的减少重启。

  2. \2. 接入层尽可能的做到无状态化,方便随时的扩缩容;这样就需要有一个叫用户中心的服务来保存用户的各种状态和信息,如在线状态、离线状态、用户是通过哪个接入层节点连接的;通过这个方式,用户就可以随意接入到任何接入层节点,并且接入层节点也可随时扩缩容;这样的话,业务逻辑层就可以和用户中心通过 RPC 通信获取用户的各种连接信息和是否在线的状态,然后精准下发消息到指定接入层,然后接入层将消息下发给客户端用户。

  3. \3. 主动迁移信令。增加一条信令和客户端进行交互,服务端如果要重启/缩容,那么主动告知连接在此接入层节点上的所有客户端,服务端主动发送迁移信令,比如 publish(迁移信令,100%),表示发送给所有此接入层节点上的客户端,客户端收到此迁移信令后,就主动进行重新连接其他节点。因为是客户端主动断开重连其他节点的,虽然还是会有重连,但是客户端是主动发起的,因此可以通过代码逻辑来保证从业务逻辑上不会影响用户的体验,这样的话,用户在操作上就会无感知,从而提升用户体验。同时,接入层节点要发送主动迁移信令之前,需要先从服务发现与注册中心(Etcd)中下线自己,避免重连的时候还继续连接到此节点。然后当重启之前,也需要判断一下是否当前节点上所有的用户连接都已经迁移到其他节点上了。

长连接接入层的优雅扩容方案

扩容方案是指在线用户越来越多,当前已有的接入层节点已经扛不住了,需要扩容接入层节点来分摊在线用户的连接和请求。这里分两种情况考虑:

  • • 其他节点的压力还相对较小,但是事先预知到需要扩容,也就是提前扩容。此时按照路由层的最小连接数优先接入请求的策略并无不妥,新扩容的可以均摊流量,原有的节点也不会因为压力过大而导致性能问题。

  • • 其他节点压力已经扛不住了,需要紧急扩容并且快速给老的节点减压。这个时候,如果还仅仅只是新增节点,然后根据原有的负载均衡路由策略来减压是达不到减压效果的,因为只有新的连接才会接入到新扩容的节点;原有老的节点上的连接如果没有断连那么还是继续维持在原有节点上,因此根本不能给老的节点减压。

  • • 所以,就需要服务端有更好的机制,通过服务端的机制来促使客户端重新连接到新的节点上,从而进行减压。这里,还是需要一个迁移信令,但是这个信令服务端只是需要随机发送给部分比例的用户,比如 publish(迁移信令,30%),表示发送迁移信令给 30% 比例的用户,让这 30%的用户重连到新的节点上。

TCP 长连接层节点怎么防止攻击

基本的防火墙策略

公司内常规的防火墙策略,通过 iptable 设置 iptables 的防火墙策略。比如限制只能接收指定 IP 和 Port 的包,避免攻击者通过节点上其他端口的漏洞登录机器;比如只接收某些协议(TCP)的包。

SYN 攻击

SYN 攻击是一个典型的 DDOS 攻击,具体就是攻击客户端在短时间内伪造大量不存在的 IP 地址,然后向服务端发送 TCP 握手连接的 SYN 请求包,服务端收到 SYN 包后会回复 ACK 确认包,并等待客户端的 ACK 确认。但是,由于源 IP 地址不是真实有效的,因此服务端需要不断的重发直至 63s 超时后才会断开连接。这些伪造的 SYN 包将长时间占用未连接队列,引起网络堵塞甚至系统瘫痪,让正常的 TCP 握手连接请求不能处理。通过 netstat -n -p TCP | grep SYN_RECV 可以查看是否有大量 SYN_RECV 状态,如果有则可能存在 SYN 攻击。

Linux 在系统层面上,提供了三个选项来应对相关攻击:

  • • tcp_max_syn_backlog,增大 SYN 连接数

  • • tcp_synack_retries,减少重试次数

  • • tcp_abort_on_overflow,过载直接丢弃,拒绝连接

另外,还有一个 tcp_syncookies 参数可以缓解,当 SYN 队列满了后,TCP 会通过相关信息(源 IP、源 port)制造出一个 SYN Cookie 返回,如果是攻击者则不会有响应,如果是正常连接,则会把这个 SYN Cookie 发回来,然后服务端可以通过 SYN Cookie 建连接。

TCP 长连接层面上

黑名单机制

可以静态或者动态配置黑名单列表,处于黑名单中的 IP 列表则直接拒绝 accept 建连;服务端执行 accept 之后,首先先判断 remote IP 是否存在于黑名单中,如果是则直接 close 连接;如果不是则继续下一步。

限制建连速度

IM 系统为了防止恶意攻击,需要防止单个 IP 大量频繁建连,避免异常 socket 连接数爆满;因此需要限制每个 IP 每秒建立速度,如果单个 IP 在单位时间内建连的连接数超过一定阈值(如 100)该值,则将 IP 列入黑名单并且同时关闭此连接

怎么实现呢?分如下几步。

\1. 定义一个合理的防攻击的数据结构,里面包含 connRates 字段、startTime 字段。

  • • startTime 表示此连接接入的初始时间

  • • connRates 用来对统计时间内的接入 IP 做累加

\2. 服务端每次 accept 之后,针对这个 Conn 连接,先判断当前时间和此连接的 startTime 的差值是否已经超过一个统计周期,如果超过则清零重置;如果没有超过,则对此连接的 IP 做累加。

\3. 然后判断 IP 累加的结果是否超过阈值,如果超过则加入黑名单并且 close 连接;如果没有超过则进行下一步的请求。

限制发包速度

IM 系统要能够发送消息包,必然需要先进行登录操作,登录主要是为了鉴权,从而获取得到正确的 token,才能正常登录。为了避免 token 等被窃取,为了更为安全,登录之后发送消息的频率也需要进行控制;控制的机制就是针对单个连接限制每秒处理包的上限,在单位时间内收到的包的请求数量超过一定阈值(如 100p/s)则直接丢弃。

怎么实现呢?需要几个步骤:

  • • 针对每个 Conn 的数据结构,增加一个 packetsNum 字段;

  • • 当前 Conn 每收到一个包,先计算统计时间内 packetsNum 的次数是否超过阈值,然后 packetsNum++;如果超过阈值则丢包并返回错误;

  • • 开一个定时器,每隔一个统计时间周期,清零 packetsNum。

TLS 加密传输

TLS 安全传输层协议用于在两个通信应用程序之间提供保密性和数据完整性,是我们 IM 系统中保证消息传输过程中不被截获、篡改、伪造的常用手段。

TLS 过程使用到了对称加密、非对称加密、CA 认证等,安全性非常高;但是相比于 TCP 传输会多了几个秘钥相关的环节,从而导致整个握手阶段会多出 1~2 个 RTT 的耗时;不过只是握手阶段的耗时对我们 IM 的应用场景并不影响。为此,为了安全性,尽可能的使用 TLS 来建立 TCP 连接

来源: 后端系统和架构

收起阅读 »

一文了解高性能架构和系统设计经验

高性能架构和系统设计经验高性能和高并发,听着就有点类似,并且他们还经常一起提及,比如提高我们的并发性能,显然,高性能可以提高我们的并发,但是细化来看,他们是有区别的,他们的考量点的维度不同。高性能需要我们从单机维度到整体维度去考虑,更多的是先从编码角度、架构使...
继续阅读 »

高性能架构和系统设计经验


高性能和高并发,听着就有点类似,并且他们还经常一起提及,比如提高我们的并发性能,显然,高性能可以提高我们的并发,但是细化来看,他们是有区别的,他们的考量点的维度不同。高性能需要我们从单机维度到整体维度去考虑,更多的是先从编码角度、架构使用角度去让我们的单机(单实例)有更好的性能,然后再从整个系统层面来拥有更好的性能;高并发则直接是全局角度来让我们的系统在全链路下都能够抗住更多的并发请求。

一、高性能架构和系统设计的几个层面

高性能架构设计主要集中在单机优化、服务集群优化、编码优化三方面。但架构层面的设计是高性能的基础,如果架构层面的设计没有做到高性能,仅依靠优化编码,对整体系统的提升是有限的。我们从一个全局角度来看高性能的系统设计,需要整体考虑的包括如下几个层面:

  • 前端层面。 后端优化的再好,如果前端(客户端)的性能不 ok,那么对用户而言,他们的体感还是很差的,因此前端层也是有必要考虑的,只是不在我们本文的设计范围之内,在实际工作中是需要进行探讨的。

  • 编码实现层面:代码逻辑的分层、分模块、协程、资源复用(对象池,线程池等)、异步、IO 多路复用(异步非阻塞)、并发、无锁设计、设计模式等。

  • 单机架构设计层面: IO 多路复用、Reactor 和 Proactor 架构模式

  • 系统架构设计层面:架构分层、业务分模块、集群(集中式、分布式)、缓存(多级缓存、本地缓存)、消息队列(异步、削峰)

  • 基础建设层面:机房、机器、资源分配

  • 运维部署层面:容器化部署、弹性伸缩

  • 性能测试优化层面: 性能压测、性能分析、性能优化

二、前端层面

后端优化的再好,如果前端(客户端)的性能不 ok,那么对用户而言,他们的体感还是很差的,因此前端层也是有必要考虑的,只是不在我们本文的设计范围之内,在实际工作中是需要进行探讨的。

这里简单说明下,从我个人工作的经历来看,前端(客户端)这里可以优化的点包括但不限于:数据预加载、数据本地缓存、业务逻辑前置处理、CDN 加速、请求压缩、异步处理、合并请求、长连接、静态资源等

三、编码实现层面

编码实现层面:代码逻辑的分层、分模块、协程、资源复用(对象池,线程池等)、异步、IO 多路复用(异步非阻塞)、并发、无锁设计、设计模式等。

多线程、多协程

大多数情况下,多进程、多线程、多协程都可以大大提高我们的并发性能,尤其是是多协程。

在网络框架层面,现在一般成熟的后端系统框架(服务化框架)都是支持多线程、多协程的,因此对于网络框架这点,我们只要是引用相对成熟的服务化框架来实现我们的业务,基本上可以不用过多考虑和设计。

在业务层面,如果是 Go 语言,天然支持大量并发,并且创建 Go 的协程非常容易,一个 go 关键字就搞定,因此多协程那就非常容易了,Go 里面可以创建大量协程来提高我们的并发性能。如果是其他语言,我们尽可能的使用多协程、多线程去执行我们的业务逻辑。

无锁设计(lock free)

在多线程、多协程的框架下,如果我们并发的线程(协程)之间访问共享资源,那么需要特别注意,要么通过加锁、要么通过无锁化设计,否则没有任何处理的访问共享资源会产生意想不到的结果。而加锁的设计,在并发较大的时候,如果锁的力度不合适,或者频繁的加锁解锁,又会使我们的性能严重下降。

为此,在追求高性能的时候,大家就比较推崇无锁化的设计。目前很多后台底层设计,为了避免共享资源的竞争,都采用了无锁化设计,特别是在底层框架上。无锁化主要有两种实现,无锁队列和原子操作。

  • 无锁队列。可以通过 链表或者 RingBuffer(循环数组)来实现无锁队列。

  • 原子操作。利用硬件同步原语 CAS 来实现各种无锁的数据结构。比如 Go 语言中的 atomic 包、C++11 语言中的 atomic 库。

数据序列化

为什么要说 数据序列化协议?因为我们的系统,要么就是各个后端微服务之间通过 RPC 做交互,要么就是通过 HTTP/TCP 协议和前端(终端)做交互,因此不可避免的需要我们进行网络数据传输。而数据,只有序列化后,才方便进行网络传输。

序列化就是将数据结构或对象转换成二进制串的过程,也就是编码的过程,序列化后,会把数据转换为二进制串,然后可以进行网络传输;反序列化就是在序列化过程中所生成的二进制串转换成数据结构或者对象的过程,将二进制转换为对象后业务才好进行后续的逻辑处理。

常见的序列化协议如下

  • Protocol Buffer(PB)

  • JSON

  • XML

  • 内置类型(如 java 语言就有 java.io.Serializable)

常见的序列化协议的对比在网上有各种性能的对比,这里就不在贴相关截图了,只说结论:从性能上和使用广泛度上来看,后端服务之间现在一般推荐使用 PB。如果和前端交互,由于 HTTP 协议只能支持 JSON,因此一般只能 JSON。

池化技术(资源复用)

池化技术是非常常见的一个提高性能的技术,池化的核心思想就是对资源进行复用,减少重复创建销毁所带来的开销。复用就是创建一个池子,然后再在这个池子里面对各种资源进行统一分配和调度,不是创建后就释放,而是统一放到池子里面来复用,这样可以减少重复创建和销毁,从而提高性能。而这个资源就包括我们编程中常见到如 线程资源、网络连接资源、内存资源,具体到对应的池化技术层面就是 线程池(协程池)、连接池、内存池等。

  • 线程池(协程池)。本质都是进程、线程、协程这些维度的一个池子,先创建合适数量的线程(协程)并且初始处于休眠状态,然后当需要用到的时候,从池子里面唤醒一个,然后执行业务逻辑,处理完业务逻辑后,资源并不释放,而是直接放回池子里面休眠,等待后续的请求被唤醒,这样重复利用。

    • 创建线程的开销是很大的,因此如果来一个请求就频创建一个线程、进程,那么请求的性能肯定不会太高。

  • 连接池。这个是最常用的,一般我们都要操作 MySQL、Redis 等存储资源,同样的,我们并不是每次请求 MySQL、Redis 等存储的时候就新创建一个连接去访问数据,而是初始化的时候就创建合适数量的连接放到池子里面,当需要连接去访问数据的时候,从池子里面获取一个空闲的连接去访问数据,访问完了之后不释放连接,而是放回池子里面。

    • 连接池需要保证连接的可用性,就是这个连接和 MySQL、Redis 等存储是必须要定期发送数据来保证连接的,要不然会被断开。同时我们要针对已经失效(断开)的连接进行检测和摘除。

  • 内存池。常规的情况下,我们都是直接调用 new、malloc 等 Linux 操作系统的 API 来申请分配内存,而每次申请的内存块的大小不定,所以,当我们频繁 分配内存、回收内存的时候,会造成大量的内存碎片,同时每次使用内存都要重新分配也会降低性能。内存池就是先预先分配足够大的一块内存,当做我们的内存池,然后每次用户请求分配内存的时候,就会返回内存池中的一块空闲的内存,并将这块内存的标志置为已使用,当内存使用完毕释放内存的时候,也不是真正地调用 free 或 delete 来释放内存,而是把这块内存直接放回内存池内并且同时把标志置为空闲。一般业内都有相关的套件来帮我们来做这个事情,比如在 C/C++ 语言里面,都有相关库去封装原生的 malloc,glibc 实现了一个 ptmalloc 库,Google 实现了一个 tcmalloc 库。

  • 对象池。其实前面几种类型的池化技术,其实都可以作为对象池的各种应用,因为各种资源都可以当做一个对象。对象池就是避免大量创建同一个类型的对象,从而进行池化,保证对象的可复用性。

异步IO 和 异步流程

异步有两个层面的意思:

  • IO 层面的异步调用

  • 业务逻辑层面的异步流程

异步是相对同步而言的,同步就是要等待前面一个事情执行完毕才能继续执行,异步就是可以不用等待,可想而知,异步的性能要比同步好很多。

IO 层面的异步调用

针对 IO 层面的异步调用,就是我们常说的 I/O 模型,有 阻塞、非阻塞、同步、异步这几种类型。在 Linux 操作系统内核中,内置了 5 种不同的 IO 交互模式,分别是阻塞 IO、非阻塞 IO、多路复用 IO、信号驱动 IO、异步 IO

针对网络 IO 模型而言,Linux 下,使用最多性能较好的是同步非阻塞模型,具体代表是 AIO,而 Windows 下的代表作 IOCP 则实现了真正的异步非阻塞 I/O。

业务逻辑层面的异步流程

业务逻辑层面的异步流程,就是指让我们的应用程序在业务逻辑上可以异步的执行。通常比较复杂的业务,都会有很多步骤流程,如果所有步骤都是同步的话,那么当这些步骤中有一步卡住,那么整个流程都会卡住,这样的流程显然性能不会很高。为此,在业内,我们如果想要提高性能,提高并发,那么基本上都会采用异步流程的方式。

举个实际的应用案例,针对 IM 系统的发送消息的这个场景,比如微信发送消息,那么当客户端发送的消息,服务端收到后,这个消息肯定要落地存储,这个发送的流程才能算完毕,但是,如果每条消息,服务端都真正存储到 DB 后再返回给客户端说已经正确收到,那么这个性能显然会很低,因为我们知道,写 DB 的性能是很低的,尤其是像微信这种每天有大量消息的 APP。那么这个流程就可以异步化,服务端收到消息后,先把消息写入消息队列,写入队列成功就返回给客户端,然后异步流程去从消息队列里面消费数据然后落地存储到 DB 里面,这样性能就非常高了,因为消息队列的性能会很高。而比较低性能的操作都是异步处理。

并发流程

并发流程,同样是针对我们上层的应用程序而言的,我们在处理业务逻辑的时候,尤其是相对负责的业务逻辑,一般下游都可能会有多个请求,或者说多个流程,如果依赖的下游多个请求之间没有强依赖关系,那么我们可以将这些请求的流程并发处理,这个是后端系统设计里面非常常见的优化手段。

通过并发的处理流程,可以将串行的叠加处理耗时优化为单个处理耗时,这样就大大的降低了整体耗时,举个例子,一个商品活动页面,渲染的数据包括 用户基本信息、用户活动积分、用户推荐商品列表。那么当收到这个用户的请求的时候,我们需要 查询用户的基本信息、用户的活动积分,还有用户的商品推荐,而这 3 个步骤完全是没有相互依赖关系的,因此,我们可以并发去分别查询,这样可以极大的减少耗时,从而提高我们的性能。

四、单机架构设计层面

单机优化的关键点

单机优化层面就是要尽量提升单机的性能,将单机的性能发挥到极致的其中一个关键点就是我们服务器采取的并发模型,然后在这个模型下,去设计好我们的服务器对连接的管理、对请求的处理流程。而这些就涉及到我们的多协程、多线程的进程模型和异步非阻塞、同步非阻塞的 IO 模型。

在具体实现细节上,针对连接的管理,要想提高性能,那么就要采用 IO 多路复用技术,可以参考I/O Multiplexing查看,I/O 多路复用技术的两个关键点在于:

  • 当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有 select、epoll、kqueue 等。

  • 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。

IO 多路复用(epoll 模型)

基本上来说,异步 I/O 模型的发展技术是: select -> poll -> epoll -> aio -> libevent -> libuv。

而且现在大家比较熟悉和使用的最多的恐怕就是 epoll 和 aio ,尤其是 epoll 模型,基本是 Linux 后端系统下的大部分框架和软件都是采用 epoll 模型。

但是,需要特别强调的是,仅仅依靠 epoll 不是万能的,连接数太多的时候单进程的 epoll 也是不行的。

Reactor 和 Proactor 架构模式

epoll 只是一个 IO 多路复用的模型,在后端系统设计里面,要想实现单机的高性能,那在 IO 多路复用基础之上,我们的整个网络框架,还需要配合池化技术来提高我们的性能。因此,业界一般都是采用 I/O 多路复用 + 线程池(协程池、进程池)的方式来提高性能。与之对应的,在业界常用的两个单机高性能的架构模式就是Reactor 和 Proactor 模式。Reactor 模式属于非阻塞同步网络模型,Proactor 模式属于非阻塞异步网络模型。

在业内开源软件里面,Redis 采用的是 单 Reactor 单进程的方式,Memcache 采用的是 多 Reactor 多线程的方式,Nginx 采用的是多 Reactor 多进程的方式。关于 的详细介绍,可以查看The Design and Implementation of the Reactor

Redis 可以用单进程 Reactor 模式的是因为 Redis 的应用场景是内部访问,并发数一般不会超过 1w,而 Nginx 必须用多进程 Reactor 模式是因为 Nginx 是外网访问,并发数很容易超过 1w,因此我们的网络架构模式,必须要通过 I/O 多路复用 + 线程池(协程池、进程池)来配合。

可以看到,单机优化层面其实和编码层面上的多协程、异步 IO、 池化技术都是有强关联的。这里也是一个知识相通的典型,我们所学的一些基础层面的知识点,在架构层面、模型层面都是有用武之地的。

五、系统架构设计层面

架构设计层面:架构分层、业务分模块、集群(集中式、分布式)、缓存(多级缓存、本地缓存)、消息队列(异步、削峰)

架构和模块划分的设计

整个系统想要有一个高性能,那么首先就需要有个合理的架构设计,这里需要根据一些架构设计原则,比如高内聚低耦合,职责单一等来去构建我们的架构。最有效的方式包括架构分层设计、业务分模块设计。

这么设计之后,在整体的系统性能优化上,后面就会有比较大的优化空间,从而不至于后面想要优化就根本无从下手,只能重构系统。

服务化框架的设计

目前的互联网时代,我们基本上都是采用微服务来搭建我们的系统,而微服务化的必要条件就是要有一套服务化框架,这个服务化框架最核心的功能包括 RPC 请求和最基础的服务治理策略(服务注册和发现、负载均衡等)。

为此,这里服务化框架的性能就尤为重要,这里主要包括这个服务化框架里面实现:

  • 数据处理。

    • 数据序列化协议,一般有些采用 PB 协议,不管是从性能还是维护都是最优的。

    • 数据压缩,一般采用 gzip 压缩,压缩后可以减少网络上的数据传输。

  • 网络模型。

    • 同步还是异步流程,如果是 Go 语言,那么可以来一个请求 go 一个协程来处理。

    • 是否有相关连接池的能力。

    • 其他的一些优化。

负载均衡

负载均衡系统是水平扩展的关键技术,通过负载均衡,相当于可以把流量分散到不同的机器的不同的服务实例里面,这样每个服务实例都可以承担一部分请求,从而可以提高我们的整体系统的性能。

对于负载均衡的方式,大都是在客户端发现模式(client-side) 来实现服务路和负载均衡,一般也都会支持常见的负载均衡策略,如随机,轮训,hash,权重,连接数【连接数越少,优先级越高】。

合理采用各种队列

在后端系统设计里面,很多流程和请求并不要求实时处理,更不需要做到强一致,大部分情况下,我们只需要实现最终一致性就可以了。故而,我们通过队列,就可以使我们的系统能够实现异步处理逻辑、流程削峰、业务模块解耦、柔性事务等多种效果,从而可以完成最终一致性,并且能够极大的提高我们系统的性能。

我们常见的队列包括

  • 消息队列:使用的最为广泛的队列之一,代表作有 RabbitMQ、RocketMQ、Kafka 等。可以用来实现异步逻辑、削峰、解耦等多种效果。从而可以极大的提高我们的性能

  • 延迟队列:延时队列相比于普通队列最大的区别就体现在其延时的属性上,普通队列的元素是先进先出,按入队顺序进行处理,而延时队列中的元素在入队时会指定一个延迟时间,表示其希望能够在经过该指定时间后处理。延迟队列的目的是为了异步处理。延迟队列的应用场景其实也非常的广泛,比如说以下的场景:

    • 到期后自动执行指定操作。

    • 在指定时间之前自动执行某些动作

    • 查询某个任务是否完成,未完成等待一定时间再次查询

    • 回调通知,当回调失败时,等待后重试

  • 任务队列:将任务提交到队列中异步执行,最常见的就是线程池的任务队列。

各级缓存的设计

分布式缓存

分布式缓存的代表作有 Redis、Memcache。通过分布式缓存,我们可以不直接读数据库,而是读取缓存来获取数据,可以极大的提高我们读数据的性能。而一般的业务都是读多写少,因此,对我们的整体性能的提高是非常有效的手段,而且是必须的手段。

本地缓存

本地缓存可以从几个维度来看:

  • 客户端的本地缓存:针对一些不常改变的数据,客户端也可以缓存,这样就可以避免请求后端,从而可以改善性能

  • 后端服务的本地缓存:后端服务中,一般都会采用分布式缓存,但是,有些场景下,如果我们的数据量比较小,那么可以直接将这些数据缓存到进程里面,这样直接通过内存读取,而不用网络耗时,性能会更高。但是本地缓存一般只会缓存少量数据。数据量太大就不合适。

多级缓存

多级缓存是一个更为高级的缓存架构设计,比如最简单的模式可以是 本地缓存 + 分布式缓存这样形成一个多级缓存架构。

我们把全量要缓存的数据都放到分布式缓存里面,然后把一些热点的少量缓存放到本地缓存里面,这样大部分热点数据都能够从本地直接读取,而其他非热点的数据还是通过分布式缓存读取,这样可以极大的提高我们的性能,提高并发能力。

举个例子,电商系统里面,我们做一个活动页,活动页的前面 10 个商品是特卖商品,然后后面的其他商品就是常规商品,因为是活动页面,那么这个页面的访问肯定就会非常大。而活动页面的前 10 个商品,必然是用户首先进来页面就一定会看到的,而用户想要继续看其他商品,那么就需要在手机上手动上滑刷新一下。这个场景下,前面 10 个商品的访问量无疑是最大的,而用户手动上滑刷新后的请求就会少很多。为此,我们可以把全量商品都缓存在分布式缓存如 redis 里面,然后再在这个基础之上,把前面 10 个商品的信息缓存到本地,这样,当活动开始后,拉取的第一页 10 个商品数据,都是从本地缓存拉取的,本地读取性能会非常高,因为内存读取就行,完全不需要网络交互。

其他的模式,可以 本地缓存 + 二级分布式缓存 + 一级分布式缓存,也就是针对分布式缓存再做一层分级,这样每一级的缓存都能抗一部分的量,因此整体来看,能够对外提供的性能就足够高。

缓存预热

通过异步任务提前将接下来要大量访问的数据预热到我们缓存里面。这样当有请求的突峰的时候,可以从容应对。

其他高性能的 NoSQL

除了 Redis、本地缓存这些,其他的一些 NoSQL 中,MongoDB、Elasticserach 也是常见的性能很高的组件,我们可以根据适用场景,合理选用。

比如我们在电商系统里面,我们针对商品的搜索、推荐都是采用 Elasticserach 来实现。

存储的设计

数据分区

数据分区是把数据按一定的方式分成多个区(比如通过地理位置),不同的数据区来分担不同区的流量,这需要一个数据路由的中间件,但会导致跨库的 Join 和跨库的事务非常复杂。

将数据分布到多个分区有两种比较典型的方案:

  • 根据键做哈希,根据哈希值选择对应的数据节点。

  • 根据范围分区,某一段连续的键都保存在一个数据节点上。

分库分表

一般来说,影响数据库最大的性能问题有两个,一个是对数据库的操作,一个是数据库中数据的大小。对于前者,我们需要从业务上来优化。一方面,简化业务,不要在数据库上做太多的关联查询,而对于一些更为复杂的用于做报表或是搜索的数据库操作,应该把其移到更适合的地方。比如,用 ElasticSearch 来做查询,用 Hadoop 或别的数据分析软件来做报表分析。对于后者,一般就是拆分。分库分表技术,有些地方也称为 Sharding、分片,通过分库分表可以提高我们的读写性能

分库分表有垂直切分和水平切分两种:

  • 垂直切分(分库),一般按照业务功能模块来划分,分库后分表部署到不同的库上。分库是为了提高并发能力,比如读写请求量大就需要分库。

  • 水平切分(分表),当一个表中的数据量过大时,我们可以把该表的数据通过各种 ID 的 hash 散列来划分,比如 用户 ID、订单 ID 的 hash。分表更多的是应对性能问题,比如查询慢的问题。单表一般情况下,千万级别后各种性能就开始下降了,就要考虑开始分表了。

分表包括垂直切分和水平切分,而分区只能起到水平切分的作用。

读写分离

互联网系统大多数都是读多写少,因此读写分离可以帮助主库抗量,读写分离就是将读的请求量改为从库承担,写还是主库来承担。一般我们都是一主多从的架构,既可以抗量,又可以保证数据不丢。

冷热分离

针对业务场景而言,如果数据有冷热之分的话,可以将历史冷数据与当前热数据分开存储,这样可以减轻当前热数据的存储量,可以提高性能。

我们常见的存储系统比如 MySQL、Elasticserach 等都可以支持。

分布式数据库

分布式数据库的基本思想是将原来集中式数据库中的数据分散存储到多个通过网络连接的数据存储节点上,以获取更大的存储容量和更高的并发访问量,从而提高我们的性能。现在传统的关系型数据库已经开始从集中式模型向分布式架构发展了。一般云服务厂商,都会提供分布式数据库的解决方案,比如腾讯云的 TDSQL MySQL 版,TDSQL for MySQL 是腾讯打造的一款分布式数据库产品,具备强一致高可用、全球部署架构、分布式水平扩展、高性能、企业级安全等特性,同时提供智能 DBA、自动化运营、监控告警等配套设施,为客户提供完整的分布式数据库解决方案。

六、基础建设层面

基础建设层面,大体分为 3 大块:

  • 机房层面,主要关注机房的网络出口带宽、入口带宽。一般这个对我们业务开发来说,都接触不到,但是这里还是需要注意,如果机房带宽不够,那么我们的服务就支撑不了大的并发,从而也没法让我们的系统有一个好的性能。

  • 机器配置层面,服务器本身的性能要足够好,包括 CPU、内存、磁盘(SSD)等资源。同理,一般这个对我们业务开发来说,都接触不到,但是如果机器配置较差,那么我们的服务部署在这样的机器上面,也无法充分发挥,从而使得我们的业系统也无法拥有一个好的性能。

  • 资源使用层面,我们要合理的分配 CPU 和内存等相关资源,一般 CPU 的使用率不要超过 70%-80%,超过这个阈值后,我们服务的性能就会开始下降,因此一般我们在 70% 的时候就要开始执行扩容。如果是 K8s 容器部署的话,我们可以设置 CPU 使用率超过指定阈值后就自动扩容。当然,如果是物理机部署,或者其他方式,可以同样的进行监控和及时扩容。也就是说,要保证我们所需的各种资源(CPU、内存、磁盘、带宽)都在一个合理的范围。

七、运维部署层面

在运维部署层面做好相关建设,是有助于提高我们系统的整体性能的。比如,我们可以通过容器化部署做到弹性伸缩,通过弹性伸缩的能力,可以使得我们的服务,在资源分配使用上,一直保持合理的 CPU、内存等资源的使用率。

八、性能测试优化层面

我们从架构设计层面、编码实现层面按照高性能的解决方案和思路实现了我们系统之后,理论上,我们的系统性能不会太差,但是,具体我们的系统性能如何?是否存在可优化点?代码的实现是否有性能问题?我们的依赖服务是否存在性能问题?等等,这些对我们大部分人来说,如果没有一个合理的性能压测和分析,那么可能还是黑盒的。

因此,针对我们研发人员而言,在高性能架构设计方面的最后一个环节,就是进行性能测试优化,具体包括三个环节:

  • 性能压测。针对系统的各个环节先分别做压测,然后有条件的情况下,最好能够做全链路压测。

  • 性能分析。压测后,最优的分析方式是结合火焰图去分析,看看性能最差的是哪里,是否有可优化的点。一定是先找到性能最差的进行优化,这样事半功倍

  • 性能优化。找到可优化点后,进行优化。然后反复这三个步骤,直到你认为性能已经完全符合预期。

作者:AllenWu
来源:juejin.cn/post/7198476152633163831

收起阅读 »

关于我加了一行日志搞崩了服务这件小事(下)

接:关于我加了一行日志搞崩了服务这件小事(上)// 方案一 - 这里会根据当前属性名和clazz来判断是否被忽略了,详见@JsonType注解           boolean ignor...
继续阅读 »

接:关于我加了一行日志搞崩了服务这件小事(上)

// 方案一 - 这里会根据当前属性名和clazz来判断是否被忽略了,详见@JsonType注解
           boolean ignore = isJSONTypeIgnore(clazzpropertyName);
// 如果忽略了,就不再往下走了
           if (ignore) {
               continue;
          }
//此时根据属性和类获取对应的值对象。
           Field field = ParserConfig.getField(clazzpropertyName);
           JSONField fieldAnnotation = null;
           if (field != null) {
               //方案二 - 会获取属性对应的JSONField注解
               // 如果该注解的serialize属性是false,那么也不会继续往下去加载逻辑
               fieldAnnotation = field.getAnnotation(JSONField.class);
               if (fieldAnnotation != null) {
                   if (!fieldAnnotation.serialize()) {
                       continue;
                  }
//获取顺序
                   ordinal = fieldAnnotation.ordinal();
                   serialzeFeatures = SerializerFeature.of(fieldAnnotation.serialzeFeatures());
                   if (fieldAnnotation.name().length() != 0) {
                       //获取名字
                       propertyName = fieldAnnotation.name();
                       if (aliasMap != null) {
                           propertyName = aliasMap.get(propertyName);
                           if (propertyName == null) {
                               continue;
                          }
                      }
                  }
                   if (fieldAnnotation.label().length() != 0) {
                       label = fieldAnnotation.label();
                  }
              }
          }
           if (aliasMap != null) {
               propertyName = aliasMap.get(propertyName);
               if (propertyName == null) {
                   continue;
              }
          }
           //这里会新构建一个fieldInfo对象,并存放到fieldInfoMap中进行保存
           FieldInfo fieldInfo = new FieldInfo(propertyNamemethodfieldclazznullordinalserialzeFeatures,
                                               annotationfieldAnnotationlabel);
           fieldInfoMap.put(propertyNamefieldInfo);
      }
       //紧接着第二部分是关于isXXX的方法
       if (methodName.startsWith("is")) {
           if (methodName.length() < 3) {
               continue;
          }
           char c2 = methodName.charAt(2);
           String propertyName;
           if (Character.isUpperCase(c2)) {
               if (compatibleWithJavaBean) {
                   propertyName = decapitalize(methodName.substring(2));
              } else {
                   propertyName = Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3);
              }
          } else if (...) {
          //同上面几乎一样,也是针对is_x这类特殊写法做了处理。
          }else {
               continue;
          }
           Field field = ParserConfig.getField(clazzpropertyName);
           if (field == null) {
               field = ParserConfig.getField(clazzmethodName);
          }
           JSONField fieldAnnotation = null;
           if (field != null) {
               //同样是对JSONField注解做处理。
               fieldAnnotation = field.getAnnotation(JSONField.class);
               if (fieldAnnotation != null) {
                   if (!fieldAnnotation.serialize()) {
                       continue;
                  }
                   ordinal = fieldAnnotation.ordinal();
                   serialzeFeatures = SerializerFeature.of(fieldAnnotation.serialzeFeatures());
                   if (fieldAnnotation.name().length() != 0) {
                       propertyName = fieldAnnotation.name();
                       if (aliasMap != null) {
                           propertyName = aliasMap.get(propertyName);
                           if (propertyName == null) {
                               continue;
                          }
                      }
                  }
                   if (fieldAnnotation.label().length() != 0) {
                       label = fieldAnnotation.label();
                  }
              }
          }
           if (aliasMap != null) {
               propertyName = aliasMap.get(propertyName);
               if (propertyName == null) {
                   continue;
              }
          }
           FieldInfo fieldInfo = new FieldInfo(propertyNamemethodfieldclazznullordinalserialzeFeatures,
                                               annotationfieldAnnotationlabel);
           fieldInfoMap.put(propertyNamefieldInfo);
      }
  }
//最后,又是对所有的常规属性做相应的处理,避免因为某个属性没写getX()方法而得不到序列化。整体的加载逻辑同上。
   for (Field field : clazz.getFields()) {
       if (Modifier.isStatic(field.getModifiers())) {
           continue;
      }
       JSONField fieldAnnotation = field.getAnnotation(JSONField.class);
       int ordinal = 0serialzeFeatures = 0;
       String propertyName = field.getName();
       String label = null;
       if (fieldAnnotation != null) {
           if (!fieldAnnotation.serialize()) {
               continue;
          }
           ordinal = fieldAnnotation.ordinal();
           serialzeFeatures = SerializerFeature.of(fieldAnnotation.serialzeFeatures());
           if (fieldAnnotation.name().length() != 0) {
               propertyName = fieldAnnotation.name();
          }
           if (fieldAnnotation.label().length() != 0) {
               label = fieldAnnotation.label();
          }
      }
       if (aliasMap != null) {
           propertyName = aliasMap.get(propertyName);
           if (propertyName == null) {
               continue;
          }
      }

       if (!fieldInfoMap.containsKey(propertyName)) {
           FieldInfo fieldInfo = new FieldInfo(propertyNamenullfieldclazznullordinalserialzeFeatures,
                                               nullfieldAnnotationlabel);
           fieldInfoMap.put(propertyNamefieldInfo);
      }
  }

   List<FieldInfo> fieldInfoList = new ArrayList<FieldInfo>();

   boolean containsAll = false;
   String[] orders = null;

   JSONType annotation = clazz.getAnnotation(JSONType.class);
   if (annotation != null) {
       orders = annotation.orders();

       if (orders != null && orders.length == fieldInfoMap.size()) {
           containsAll = true;
           for (String item : orders) {
               if (!fieldInfoMap.containsKey(item)) {
                   containsAll = false;
                   break;
              }
          }
      } else {
           containsAll = false;
      }
  }

   if (containsAll) {
       for (String item : orders) {
           FieldInfo fieldInfo = fieldInfoMap.get(item);
           fieldInfoList.add(fieldInfo);
      }
  } else {
       for (FieldInfo fieldInfo : fieldInfoMap.values()) {
           fieldInfoList.add(fieldInfo);
      }
       if (sorted) {
           Collections.sort(fieldInfoList);
      }
  }
   return fieldInfoList;
}

代码有点长,听我一点点地慢慢解释。整个代码其实比较容易理解,我尝试从我们常规角度来理解下。fastJson组件的发明者认为,类中常见需要序列化的类型有三种:

1、getX()方法;

2、isX()方法;

3、没有写getX()方法的固有变量。

围绕这三种类型他做的事都是类似的。这里我们先以getX()方法为例子展开说明,要获取到所有的getX()方法,并对他们解析,主要分为以下四个步骤:

1、获取到所有的类下的方法信息

这个可以通过class<?>.getMethods()方法获得,如下是我coreData类的所有方法。


2、判断符合规范的getXXX方法

在获取到了所有的method以后,我们自然需要判断哪些是符合规范的getXX方法。在组件中是这么判断的:

if (methodName.startsWith("get")) {
   //此时做相应的处理逻辑  
}

没错,就是这么粗暴简单。

3、根据JSONType判断是否需要加载

那么获取到这些方法就一定要加载了吗?当然不是!对于getter方法,fastJson会首先判断当前的属性,是否已被包含在了类的@JSONType(ignores = "xxx")下,如果包含在了其中,那么此时就不会去将该方法保存到待序列化的列表中。局限点在于该种写法只会对get方法生效,对于isXXX和普通属性是不会生效的。

// 方案一 - 这里会根据当前属性名和clazz来判断是否被忽略了,详见@JsonType注解
boolean ignore = isJSONTypeIgnore(clazzpropertyName);
// 如果忽略了,就不再往下走了
if (ignore) {
   continue;
}


4、根据JSONField判断是否需要加载

什么?你说采用JSONType写一大堆不方便?fastJson自然也是想到了,那么此时就可以采用@JSONField(serialize = false)的方式在对单独的属性或方法进行标注。也能起到忽略的作用。


到此,以getXX()方法的解析判断就完成了,当然其中还有一些更为细致的判断逻辑,如跳过getMetaClass、返回值为空的跳过等等逻辑。但大体上已经不影响我们的分析了。isXXX和固有变亮的解析几乎相似。至此,我们已经大致了解了整个解析的原理。当然为了验证我们的逻辑的正确性,我对原本coreData的代码做了一下改造并进行了试验,具体内容如下所示:

@Data
@JSONType(ignores = "funcProperties")
public class CoreData {

   //正常的属性
   public String normalProperties = "normalProperties";

   /**
    * 以get开头的方法
    * @return
    */
   public String getFuncProperties(){
       double a = 2/0;
       return "getFuncProperties";
  }

   /**
    * 以is开头的方法
    * @return
    */
   @JSONField(serialize = false)
   public Boolean isType(){
       return true;
  }

   /**
    * 用于跳过,检查方法是否判断
    * @return
    */
   public String skipFuncProperties(){
       double a = 2/0;
       return "getFuncProperties";
  }
}

简要来说,这里对getFuncProperties方法,我才用了@JSONType(ignores = "funcProperties")将其进行忽略,而对于isType方法,我则用单个的@JSONField(serialize = false)对其进行忽略,如果我们的结论成立,那么此时应该只会保存一个normalProperties属性的输出,且不存在出现报错的情况。


事实证明,我们是对的。

案件总结与反思:

在经历了这次惨痛的教训之后,有哪些是值得我们深入关注去思考和反思的呢?

1、在编写方法的时候尽量避免才用getXXX、isXXX的方法进行书写,这会导致部分框架的解析出现问题。(这个点也是我曾经在JAVA开发手册中看到的,想必也是前人被坑过了。)

2、如果非要这样写,那么此时需要评估好当前这个方法是否需要被一些框架进行解析,如果不需要,尝试对这些类型属性添加基本的忽略操作。类似@JSONField(seralize = false)、@Trasient等注解。

3、避免在对象中参杂进复杂的业务逻辑。(当然这条并不一定正常,对于DDD的充血模型,有时候是需要一定的业务逻辑的混合的。)

吃一堑长一智,如此一来才能避免在未来犯下相同的错误呀~


作者:DrLauPen
来源:
juejin.cn/post/7134513215890784293



收起阅读 »

关于我加了一行日志搞崩了服务这件小事(上)

周三的时候,组内出现了一个线上问题,影响到了若干个用户的下单、支付等操作。然而实际查询到问题的原因时,发现只是由于一行小小的日志打印导致的错误。1、对案件的发生进行回顾;3、对案件总结与反思案件回顾 找到代码行后却让值班同学感到疑惑:“这个明显是fastjso...
继续阅读 »

前言

周三的时候,组内出现了一个线上问题,影响到了若干个用户的下单、支付等操作。然而实际查询到问题的原因时,发现只是由于一行小小的日志打印导致的错误。

以下的文章内容分为主要分为三部分:

1、对案件的发生进行回顾;

2、分析案件发生的原因;

3、对案件总结与反思

以三章内容来回顾出现的问题,以及提供未来的预防策略。

案件回顾

周三的时候,服务频繁收到报警,系统频繁爆出空指针异常。值班同学根据报错的错误栈,快速定位到了错误的代码行。

at com.alibaba.fastjson.serializer.JSONSerializer.write(JSONSerializer.java:285)
at com.alibaba.fastjson.JSON.toJSONString(JSON.java:696)

找到代码行后却让值班同学感到疑惑:“这个明显是fastjson的日志打印呀,这也会有什么错误么?”。旁边的同事看完却惊呼一声:“fastJson打印日志会调用对象内的其余的get方法的呀!”。

(PS:该对象是一个DDD的核心域对象,其中包含一些业务场景方法被命名为getXXX方法的,因此执行Json序列化打印也就可能因为部分数据为空而出现空指针。)

定位到了问题原因,本着优先止损的原则,值班同事快速上线代码删除了这行日志打印。系统暂时的恢复了正常,没有再出现新增的报错信息了。然而后续还有漫长的数据修复、更正的过程。

案件分析:

案件复原:

本质上来说,这起线上事故出现的原因主要是因为fastJson序列化时,会将手工编写的一些方法认为是待输出属性对象,那么如果这些方法包含一些业务逻辑代码的时候,就会存在出现异常的风险。这里我们简单复现一下场景:

@Data
public class CoreData {
   //正常的属性
   public String normalProperties = "normalProperties";

   /**
    * 以get开头的方法 不是期望输出的属性
    * @return
    */
   public String getFuncProperties(){
       return "getFuncProperties";
  }

   /**
    * 以is开头的方法 不是期望输出的属性
    * @return
    */
   public Boolean isType(){
       return true;
  }
}

如上代码是我们编写的一个纯代码类,可以看到,我们实际期望设置的属性应该只有一个normalPropertites。

public static void main(String[] args) {
   CoreData data = new CoreData();
   String dataString = JSONObject.toJSONString(data);
   System.out.println(dataString); // 对应正常的业务逻辑
}

进而我还写了一段针对当前对象进行打印的代码,从上可以看到,就是简单的对对象进行JSON序列化后打印输出。按照我们的期望来说,只是期望输出normalProperties这一个固有的字符串属性。随后我运行了代码,得到了如下的结果:


可以看到,一个类型+两个方法,都被JSON序列化后输出了。那么如果此时我们在getFuncProperties()这样的方法中如果出现了异常,就会影响整个业务的运行。例如我们把方法改成如下的例子:

public String getFuncProperties(){
   double a = 2/0;
   return "getFuncProperties";
}


可以看到,我们原本的逻辑可能只是想输出normalProperties属性,但是因为getFuncProperties2/0是无法进行运算的,导致了系统直接报错了。那么此时,main函数中的输出方法(对应于我们正常业务逻辑),也就无法再继续执行了,而这在生产环境上无疑是致命的。

背后原理:

(PS: 以下讨论内容均基于1.2.9版本的fastJson。)

根据报错的问题点,结合debug,很快找到了问题所在:


com.alibaba.fastjson.serializer.JSONSerializer#write(java.lang.Object)这个方法中,Fastjson所创建的ObjectSerializer对象中,nature下所包含的getters对象有三个。这明显不符合我们的预期。那么我们就需要找到他是如何获取到这三个方法的。紧跟着我们进行追入,在com.alibaba.fastjson.serializer.SerializeConfig#getObjectWriter方法下找到了这行代码:

put(clazzcreateJavaBeanSerializer(clazz));

很明显,这里的createJavaBeanSerializer(clazz)创建了javaBean的序列化器。对于该方法,其主要的逻辑流程就是判断当前的对象类型是否符合使用ASM的序列化器。这里一通判断下来,是符合采用ASM序列化的要求的,因此,我们又进一步定位到了如下代码:

ObjectSerializer asmSerializer = createASMSerializer(clazz);

createASMSerializer对应的方法中,最关键的代码莫过于下面这行了:

List<FieldInfo> unsortedGetters = TypeUtils.computeGetters(clazzjsonTypealiasMapfalse);

这力的代码会生成对应的fieldInfo对象,也正好对应了前面我们涉及到的那三个方法,这里让我们仔细看一下com.alibaba.fastjson.util.TypeUtils#computeGetters所对应的代码:

public static List<FieldInfo> computeGetters(Class clazzJSONType jsonTypeMap<StringString> aliasMapboolean sorted) {
   Map<StringFieldInfo> fieldInfoMap = new LinkedHashMap<StringFieldInfo>();
   for (Method method : clazz.getMethods()) {
       String methodName = method.getName();
       int ordinal = 0serialzeFeatures = 0;
       String label = null;
//判读当前方法是否为静态的
       if (Modifier.isStatic(method.getModifiers())) {
           continue;
      }
//若返回值为void则此时不需要处理
       if (method.getReturnType().equals(Void.TYPE)) {
           continue;
      }
//若此时入参不为空则跳过
       if (method.getParameterTypes().length != 0) {
           continue;
      }
//若返回类型是类加载器也进行跳过。
       if (method.getReturnType() == ClassLoader.class) {
           continue;
      }
//若方法名是getMetaClass也跳过
       if (method.getName().equals("getMetaClass")
           && method.getReturnType().getName().equals("groovy.lang.MetaClass")) {
           continue;
      }
//获取方法的有关JSONField的注释
       JSONField annotation = method.getAnnotation(JSONField.class);
       if (annotation == null) {
           //若当前类为空,则再获取父类的。
           annotation = getSupperMethodAnnotation(clazzmethod);
      }
       if (annotation != null) {
           //若父类不为空则进行序列化的判断,我们使用的例子无继承,这部分先忽略不看。
          ......
      }
       //重点来了,判断当前是否以get开头
       if (methodName.startsWith("get")) {
           //长度小于4,即不满足getXX的格式的,直接跳过。
           if (methodName.length() < 4) {
               continue;
          }
           //getClass的进行跳过
           if (methodName.equals("getClass")) {
               continue;
          }
//获取第四个位置的字符
           char c3 = methodName.charAt(3);
           String propertyName;
           if (Character.isUpperCase(c3|| c3 > 512 ) {
               //若方法遵循驼峰的写法:则依次取出对应的名称信息
               if (compatibleWithJavaBean) {
                   propertyName = decapitalize(methodName.substring(3));
              } else {
                   propertyName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
              }
          } else if (...) {
               //这里针对部分特殊的写法:如get_X、getfX做了特殊的判断处理。
          } else {
               continue;
          }

续:关于我加了一行日志搞崩了服务这件小事(下)

作者:DrLauPen
来源:juejin.cn/post/7134513215890784293

收起阅读 »

宽表为什么横行?

宽表在BI业务中比比皆是,每次建设BI系统时首先要做的就是准备宽表。有时系统中的宽表可能会有上千个字段,经常因为“过宽”超过了数据库表字段数量限制还要再拆分。为什么大家乐此不疲地造宽表呢?主要原因有两个。一是为了提高查询性能。现代BI通常使用关系数据库作为后台...
继续阅读 »

宽表在BI业务中比比皆是,每次建设BI系统时首先要做的就是准备宽表。有时系统中的宽表可能会有上千个字段,经常因为“过宽”超过了数据库表字段数量限制还要再拆分。

为什么大家乐此不疲地造宽表呢?主要原因有两个。

一是为了提高查询性能。现代BI通常使用关系数据库作为后台,而SQL通常使用的HASH JOIN算法,在关联表数量和关联层级变多的时候,计算性能会急剧下降,有七八个表三四层级关联时就能观察到这个现象,而BI业务中的关联复杂度远远超过这个规模,直接使用SQL的JOIN就无法达到前端立等可取的查询需要了。为了避免关联带来的性能问题,就要先将关联消除,即将多表事先关联好采用单表存储(也就是宽表),再查询的时候就可以不用再关联,从而达到提升查询性能的目的。

二是为了降低业务难度。因为多表关联尤其是复杂关联在BI前端很难表达和使用。如果采用自动关联(根据字段类型等信息匹配)当遇到同维字段(如一个表有2个以上地区字段)时会“晕掉”不知道该关联哪个,表间循环关联或自关联的情况也无法处理;如果将众多表开放给用户来自行选择关联,由于业务用户无法理解表间关系而几乎没有可用性;分步关联可以描述复杂的关联需求,但一旦前一步出错就要推倒重来。所以,无论采用何种方式,工程实现和用户使用都很麻烦。但是基于单表来做就会简单很多,业务用户使用时没有什么障碍,因此将多表组织成宽表就成了“自然而然”的事情。

不过,凡事都有两面性,我们看到宽表好处而大量应用的同时,其缺点也不容忽视,有些缺点会对应用产生极大影响。下面来看一下。

宽表的缺点

数据冗余容量大

宽表不符合范式要求,将多个表合并成一个表会存在大量冗余数据,冗余程度跟原表数据量和表间关系有关,通常如果存在多层外键表,其冗余程度会呈指数级上升。大量数据冗余不仅会带来存储上的压力(多个表组合出来的宽表数量可能非常多)造成数据库容量问题,在查询计算时由于大量冗余数据参与运算还会影响计算性能,导致虽然用了宽表但仍然查询很慢。

数据错误

由于宽表不符合三范式要求,数据存储时可能出现一致性错误(脏写)。比如同一个销售员在不同记录中可能存储了不同的性别,同一个供应商在不同记录中的所在地可能出现矛盾。基于这样的数据做分析结果显然不对,而这种错误非常隐蔽很难被发现。

另外,如果构建的宽表不合理还会出现汇总错误。比如基于一对多的A表和B表构建宽表,如果A中有计算指标(如金额),在宽表中就会重复,基于重复的指标再汇总就会出现错误。

灵活性差

宽表本质上是一种按需建模的手段,根据业务需求来构建宽表(虽然理论上可以把所有表的组合都形成宽表,但这只存在于理论上,如果要实际操作会发现需要的存储空间大到完全无法接受的程度),这就出现了一个矛盾:BI系统建设的初衷主要是为了满足业务灵活查询的需要,即事先并不知道业务需求,有些查询是在业务开展过程中逐渐催生出来的,有些是业务用户临时起意的查询,这种灵活多变的需求采用宽表这种要事先加工的解决办法极为矛盾,想要获得宽表的好就得牺牲灵活性,可谓鱼与熊掌不可兼得。

可用性问题

除了以上问题,宽表由于字段过多还会引起可用性低的问题。一个事实表会对应多个维表,维表又有维表,而且表之间还可能存在自关联/循环关联的情况,这种结构在数据库系统中很常见,基于这些结构的表构建宽表,尤其要表达多个层级的时候,宽表字段数量会急剧增加,经常可能达到成百上千个(有的数据库表有字段数量限制,这时又要横向分表),试想一下,在用户接入界面如果出现上千个字段要怎么用?这就是宽表带来的可用性差的问题。

总体来看,宽表的坏处在很多场景中经常要大于好处,那为什么宽表还大量横行呢?

因为没办法。一直没有比宽表更好的方案来解决前面提到的查询性能和业务难度的问题。其实只要解决这两个问题,宽表就可以不用,由宽表产生的各类问题也就解决了。

SPL+DQL消灭宽表

借助开源集算器SPL可以完成这个目标。

SPL(Structured Process Language)是一个开源结构化数据计算引擎,本身提供了不依赖数据库的强大计算能力,SPL内置了很多高性能算法,尤其是对关联运算做了优化,对不同的关联场景采用不同的手段,可以大幅提升关联性能,从而不用宽表也能实时关联以满足多维分析时效性的需要。同时,SPL还提供了高性能存储,配合高效算法可以进一步发挥性能优势。

只有高性能还不够,SPL原生的计算语法不适合多维分析应用接入(生成SPL语句对BI系统改造较大)。目前大部分多维分析前端都是基于SQL开发的,但SQL体系(不用宽表时)在描述复杂关联计算上又很困难,基于这样的原因,SPL设计了专门的类SQL查询语法DQL(Dimensional Query Language)用于构建语义层。前端生成DQL语句,DQL Server将其转换成SPL语句,再基于SPL计算引擎和存储引擎完成查询返回给前端,实现全链路BI查询。需要注意的是,SPL只作为计算引擎存在,前端界面仍要由用户自行实现(或选用相应产品)。


SPL:关联实现技术

SPL如何不用宽表也能实现实时关联以满足性能要求的目标?

在BI业务中绝大部分的JOIN都是等值JOIN,也就是关联条件为等式的 JOIN。SPL把等值关联分为外键关联和主键关联。外键关联是指用一个表的非主键字段,去关联另一个表的主键,前者称为事实表,后者称为维表,两个表是多对一的关系,比如订单表和客户表。主键关联是指用一个表的主键关联另一个表的主键或部分主键,比如客户表和 VIP 客户表(一对一)、订单表和订单明细表(一对多)。

这两类 JOIN 都涉及到主键,如果充分利用这个特征采用不同的算法,就可以实现高性能的实时关联了。

不过很遗憾,SQL 对 JOIN 的定义并不涉及主键,只是两个表做笛卡尔积后再按某种条件过滤。这个定义很简单也很宽泛,几乎可以描述一切。但是,如果严格按这个定义去实现 JOIN,理论上没办法在计算时利用主键的特征来提高性能,只能是工程上做些有限的优化,在情况较复杂时(表多且层次多)经常无效。

SPL 改变了 JOIN 的定义,针对这两类 JOIN 分别处理,就可以利用主键的特征来减少运算量,从而提高计算性能。

外键关联

和SQL不同,SPL中明确地区分了维表和事实表。BI系统中的维表都通常不大,可以事先读入内存建立索引,这样在关联时可以少计算一半的HASH值。

对于多层维表(维表还有维表的情况)还可以用外键地址化的技术做好预关联。即将维表(本表)的外键字段值转换成对应维表(外键表)记录的地址。这样被关联的维表数据可以直接用地址取出而不必再进行HASH值计算和比对,多层维表仅仅是多个按地址取值的时间,和单层维表时的关联性能基本相当。

类似的,如果事实表也不大可以全部读入内存时,也可以通过预关联的方式解决事实表与维表的关联问题,提升关联效率。

预关联可以在系统启动时一次性读入并做好,以后直接使用即可。

当事实表较大无法全内存时,SPL 提供了外键序号化方法:将事实表中的外键字段值转换为维表对应记录的序号。关联计算时,用序号取出对应维表记录,这样可以获得和外键地址化类似的效果,同样能避免HASH值的计算和比对,大幅提升关联性能。

主键关联

有的事实表还有明细表,比如订单和订单明细,二者通过主键和部分主键进行关联,前者作为主表后者作为子表(还有通过全部主键关联的称为同维表,可以看做主子表的特例)。主子表都是事实表,涉及的数据量都比较大。

SPL为此采用了有序归并方法:预先将外存表按照主键有序存储,关联时顺序取出数据做归并,不需要产生临时缓存,只用很小的内存就可以完成计算。而SQL采用的HASH分堆算法复杂度较高,不仅要计算HASH值进行对比,还会产生临时缓存的读写动作,运算性能很差。

HASH 分堆技术实现并行困难,多线程要同时向某个分堆缓存数据,造成共享资源冲突;某个分堆关联时又会消费大量内存,无法实施较大的并行数量。而有序归则易于分段并行。数据有序时,子表就可以根据主表键值进行同步对齐分段以保证正确性,无需缓存,且因为占用内存很少可以采用较大的并行数,从而获得更高性能。

预先排序的成本虽高,但是一次性做好即可,以后就总能使用归并算法实现 JOIN,性能可以提高很多。同时,SPL 也提供了在有追加数据时仍然保持数据整体有序的方案。

对于主子表关联SPL还可以采用更有效的存储形式将主子表一体化存储,子表作为主表的集合字段,其取值是由与该主表数据相关的多条子表记录构成。这相当于预先实现了关联,再计算时直接取数计算即可,不需要比对,存储量也更少,性能更高。

存储机制

高性能离不开有效的存储。SPL也提供了列式存储,在BI计算中可以大幅降低数据读取量以提升读取效率。SPL列存采用了独有的倍增分段技术,相对传统列存分块并行方案要在很大数据量时(否则并行会受到限制)才会发挥优势不同,这个技术可以使SPL列存在数据量不很大时也能获得良好的并行分段效果,充分发挥并行优势。

SPL还提供了针对数据类型的优化机制,可以显著提升多维分析中的切片运算性能。比如将枚举型维度转换成整数,在查询时将切片条件转换成布尔值构成的对位序列,在比较时就可以直接从序列指定位置取出切片判断结果。还有将多个标签维度(取值是或否的维度,这种维度在多维分析中大量存在)存储在一个整数字段中的标签位维度技术(一个整数字段可以存储16个标签),不仅大幅减少存储量,在计算时还可以针对多个标签同时做按位计算从而大幅提升计算性能。

有了这些高效机制以后,我们就可以在BI分析中不再使用宽表,转而基于SPL存储和算法做实时关联,性能比宽表还更高(没有冗余数据读取量更小,更快)。

不过,只有这些还不够,SPL原生语法还不适合BI前端直接访问,这就需要适合的语义转换技术,通过适合的方式将用户操作转换成SPL语法进行查询。

这就需要DQL了。

DQL:关联描述技术

DQL是SPL之上的语义层构建工具,在这一层完成对于SPL数据关联关系的描述(建模)再为上层应用服务。即将SPL存储映射成DQL表,再基于表来描述数据关联关系。


通过对数据表关系描述以后形成了一种以维度为中心的总线式结构(不同于E-R图中的网状结构),中间是维度,表与表之间不直接相关都通过维度过渡。


基于这种结构下的关联查询(DQL语句)会很好表达。比如要根据订单表(orders)、客户表(customer)、销售员表(employee)以及城市表(city)查询:本年度华东的销售人员,在全国各销售区的销售额

用SQL写起来是这样的:

SEL ECT
ct1.area,o.emp_id,sum(o.amount) somt
FROM
orders o
JOIN customer c ON o.cus_id = c.cus_id
JOIN city ct1 ON c.city_id = ct1.city_id
JOIN employee e ON o.emp_id = e.emp_id
JOIN city ct2 ON e.city_id = ct2.city_id
WHERE
ct2.area = 'east' AND year(o.order_date)= 2022
GRO UP BY
ct1.area, o.emp_id

多个表关联要JOIN多次,同一个地区表要反复关联两次才能查到销售员和客户的所在区域,对于这种情况BI前端表达起来会很吃力,如果将关联开放出来,用户又很难理解。

那么DQL是怎么处理的呢?

DQL写法:

SEL ECT
cus_id.city_id.area,emp_id,sum(amount) somt
FROM
orders
WHERE
emp_id.city_id.area == "east" AND year(order_date)== 2022
BY
cus_id.city_id.area,emp_id

DQL不需要JOIN多个表,只基于orders单表查询就可以了,外键指向表的字段当成属性直接使用,有多少层都可以引用下去,很好表达。像查询客户所在地区通过cus_id.city_id.area一直写下去就可以了,这样就消除了关联,将多表关联查询转化成单表查询。

更进一步,我们再基于DQL开发BI前端界面就很容易,比如可以做成这样:


用树结构分多级表达多层维表关联,这样的多维分析页面不仅容易开发,普通业务用户使用时也很容易理解,这就是DQL的效力。

总结一下,宽表的目的是为了解决BI查询性能和前端工程实现问题,而宽表会带来数据冗余和灵活性差等问题。通过SPL的实时关联技术与高效存储可以解决性能问题,而且性能比宽表更高,同时不存在数据冗余,存储空间也更小(压缩);DQL构建的语义层解决了多维分析前端工程的实现问题,让实时关联成为可能,,灵活性更高(不再局限于宽表的按需建模),界面也更容易实现,应用范围更广。

SPL+DQL继承(超越)宽表的优点同时改善其缺点,这才是BI该有的样子。

SPL资料

作者:Java中文社群
来源:juejin.cn/post/7200033099752554553

收起阅读 »

由浅入深,聊聊OkHttp的那些事(很长,很细节)

引言 在 Android 开发的世界中,有一些组件,无论应用层技术再怎么迭代,作为基础支持,它们依然在那里。 比如当我们提到网络库时,总会下意识想到一个名字,即 OkHttp 。 尽管对于大多数开发者而言,通常情况下使用的是往往它的封装版本 Retrofit ...
继续阅读 »

引言


Android 开发的世界中,有一些组件,无论应用层技术再怎么迭代,作为基础支持,它们依然在那里。
比如当我们提到网络库时,总会下意识想到一个名字,即 OkHttp


尽管对于大多数开发者而言,通常情况下使用的是往往它的封装版本 Retrofit ,不过其底层依然离不开 Okhttp 作为基础支撑。而无论是自研网络库的二次封装,还是个人使用,OkHttp 也往往都是不二之选。


故本篇将以最新视角开始,用力一瞥 OkHttp 的设计魅力。


本文对应的 OkHttp 版本: 4.10.0



本篇定位 中高难度,将从背景到使用方式,再到设计思想与源码解析,尽可能全面、易懂。



背景


每一个技术都有其变迁的历史背景与特性,本小节,我们将聊一聊 Android网络库 的迭代史,作为开篇引语,润润眼。 🔖


关于 Android网络库 的迭代历史,如下图所示:


petterp-image


具体进展如下:




  • HttpClient


    Android1.0 时推出。但存在诸多问题,比如内存泄漏,频繁的GC等。5.0后,已被弃用;




  • HttpURLConnection


    Android2.2 时推出,比 HttpClient 更快更稳定,Android4.4 之后底层已经被 Okhttp 替代;




  • volley


    Google 2013年开源,基于 HttpURLConnection 的封装,具有良好的扩展性和适用性,不过对于复杂请求或者大量网络请求时,性能较差。目前依然有不少项目使用(通常是老代码的维护);




  • okhttp


    Square 2013年开源,基于 原生Http 的底层设计,具有 快速稳定节省资源 等特点。是目前诸多热门网络请求库的底层实现,比如 RetrofitRxHttp 等;




  • Retrofit


    Square 2013年开源,基于 OkHttp 的封装,目前 主流 的网络请求库。


    通过注解方式配置网络请求、REST风格 api、解耦彻底、经常会搭配 Rx等 实现 框架联动;







上述的整个过程,也正是伴随了 Android 开发的各个时期,如果将上述分为 5个阶段 的话,那么则为:



HttpClient -> HttpURLConnection -> volley -> okhttp -> Retrofit*



通过 Android网络库 的迭代历史,我们不难发现,技术变迁越来越趋于稳定,而 OkHttp 也已经成为了基础组件中不可所缺的一员。


设计思想


当聊到OkHttp的设计思想,我们想知道什么?



应用层去看,熟练的开发者会直接喊出拦截器,巴拉巴拉…


而作为初学者,可能更希望的事广度与解惑,OkHttp 到底牛在了什么地方,或者说常说的 拦截器到底是什么 ? 🧐



在官方的描述中,OkHttp 是一个高效的 Http请求框架 ,旨在 简化 客户端网络请求,提高 请求效率。


具体设计思想与特性如下:



  • 连接复用 :避免在每个请求之间重新建立连接。

  • 连接池 降低了请求延迟 (HTTP/2不可用情况下);

  • 自动重试 :在请求失败时自动重试请求,从而提高请求可靠性。

  • 自动处理缓存 :会按照预定的缓存策略处理缓存,以便最大化网络效率。

  • 支持HTTP/2, 并且允许对同一个主机的所有请求共享一个套接字(HTTP/2);

  • 简化Api:Api设计简单明了,易于使用,可以轻松发起请求获取响应,并处理异常。

  • 支持gzip压缩 :OkHttp支持gzip压缩,以便通过减少网络数据的大小来提高网络效率。


特别的,如果我们的服务器或者域名有 多个IP地址OkHttp 将在 第一次 连接失败时尝试替代原有的地址(对于 IPv4+IPv6 和托管在冗余数据中心的服务是必需的)。并且支持现代 TLS 功能(TLS 1.3、ALPN、证书固定)。它可以配置为回退以实现广泛的连接。



总的来说,其设计思想是通过 简化请求过程提高请求效率提高请求可靠性,从而提供 更快的响应速度



应用层的整个请求框架图如下:


okhttp


使用方式


在开始探究设计原理与思想之前,我们还是要先看看最基础的使用方式,以便为后续做一些铺垫。


// build.gradle
implementation "com.squareup.okhttp3:okhttp:4.10.0"
复制代码

// Android Manifest
<uses-permission android:name="android.permission.INTERNET" />
复制代码

发起一个get请求


image-20230210152634416


拦截器的使用


image-20230210152655622


总结起来就是下面几步:




  1. 创建 OkHttpClient 对象;

  2. 构建 Request ;

  3. 调用 OkHttpClient 执行 request 请求 ;

  4. 同步阻塞 或者 异步回调 方式接收结果;



更多使用方式,可以在搜索其他同学的教程,这里仅仅只是作为后续解析原理时的必要基础支撑。


源码分析


基础配置


OkHttpClient


val client = OkHttpClient.Builder().xxx.build()
复制代码

由上述调用方式,我们便可以猜出,这里使用了 构建者模式 去配置默认的参数,所以直接去看 OkHttpClient.Builder 支持的参数即可,具体如下:


image-20230210152738025


具体的属性意思在代码中也都有注释,这里我们就不在多提了。


需要注意的是,在使用过程中,对于 OkHttpClient 我们还是应该缓存下来或者使用单例模式以便后续复用,因为其相对而言还是比较重。




Request


指客户端发送到服务器的 HTTP请求


OkHttp 中,可以使用 Request 对象来构建请求,然后使用 OkHttpClient 对象来发送请求。
通常情况下,一个请求包括了 请求头请求方法请求路径请求参数url地址 等信息。主要是用来请求服务器返回某些资源,如网页、图片、数据等。


具体源码如下所示:


Request.Builder().url("https://www.baidu.com").build()
复制代码

open class Builder {
// url地址
internal var url: HttpUrl? = null
// 请求方式
internal var method: String
// 请求头
internal var headers: Headers.Builder
// 请求体
internal var body: RequestBody? = null
// 请求tag
internal var tags: MutableMap<Class<*>, Any>
}
复制代码



发起请求


execute()


用于执行 同步请求 时调用,具体源码如下:


client.newCall(request).execute()
复制代码

接下来我们再去看看 client.newCall() , 即请求发起时的逻辑。


image-20230210152833933


当我们使用 OkHttpClient.newCall() 方法时,实际是创建了一个新的 RealCall 对象,用于 应用层与网络层之间的桥梁,用于处理连接、请求、响应以及流 ,其默认构造函数中需要传递 okhttpClient 对象以及 request


接着,使用了 RealCall 对象调用了其 execute() 方法开始发起请求,该方法内部会将当前的 call 加入我们 Dispatcher 分发器内部的 runningSyncCalls 队列中取,等待被执行。接着调用 getResponseWithInterceptorChain() ,使用拦截器获取本次请求响应的内容,这也即我们接下来要关注的步骤。




enqueue()


执行 异步请求 时调用,具体源码如下:


client.newCall(request).enqueue(CallBack)
复制代码

image-20230210153019986


当我们调用 RealCall.enqueue() 执行异步请求时,会先将本次请求加入 Dispather.readyAsyncCalls 队列中等待执行,如果当前请求是 webSocket 请求,则查找与当前请求是同一个 host 的请求,如果存在一致的请求,则复用先前的请求。


接下来调用 promoteAndExecute() 将所有符合条件可以请求的 Call 从等待队列中添加到 可请求队列 中,再遍历该请求队列,将其添加到 线程池 中去执行。


继续沿着上面的源码,我们去看 asyncCall.executeOn(executorService) ,如下所示:


image-20230210153055350


上述逻辑也很简单,当我们将任务添加到线程池后,当任务被执行时,即触发 run() 方法的调用。该方法中会去调用 getResponseWithInterceptorChain() 从而使用拦截器链获取服务器响应,从而完成本次请求。请求成功后则调用我们开始时的 callback对象 的 onResponse() 方法,异常(即失败时)则调用 onFailure() 方法。




拦截器链


在上面我们知道,他们最终都走到了 RealCall.getResponseWithInterceptorChain() 方法,即使用 拦截器链 获取本次请求的响应内容。不过对于初看OkHttp源码的同学,这一步应用会有点迷惑,拦截器链 是什么东东👾?


在解释 拦截器链 之前,我们不妨先看一下 RealCall.getResponseWithInterceptorChain() 方法对应的源码实现,然后再去解释为什么,也许更容易理解。


具体源码如下:


image-20230210155112457


上述的逻辑非常简单,内部会先创建一个局部拦截器集合,然后将我们自己设置的普通拦截器添加到该集合中,然后添加核心的5大拦截器,接着再将我们自定义的网络拦截器也添加到该集合中,最终才添加了真正用于执行网络请求的拦截器。接着创建了一个拦截器责任链 RealInterceptorChain ,并调用其 proceed() 方法开始执行本次请求。




责任链模式


在上面我们说到了,要解释 OkHttp 的拦截器链,我们有必要简单聊一下什么是责任链模式?



责任链模式(Chain of Responsibility)是一种处理请求的模式,它让多个处理器都有机会处理该请求,直到其中某个处理成功为止。责任链模式把多个处理器串成链,然后让请求在链上传递。


摘自 责任链模式 @廖雪峰



Android 中常见的事件分发为例:当我们的手指点击屏幕开始,用户的触摸事件从 Activity 开始分发,接着从 windows 开始分发到具体的 contentView(ViewGroup) 上,开始调用其 dispatchTouEvent() 方法进行事件分发。在这个方法内,如果当前 ViewGroup 不进行拦截,则默认会继续向下分发,寻找当前 ViewGroup 下对应的触摸位置 View ,如果该 View 是一个 ViewGroup ,则重复上述步骤。如果事件被某个 view 拦截,则触发其 onTouchEvent() 方法,接着交由该view去消费该事件。而如果事件传递到最上层 view 还是没人消费,则该事件开始按照原路返回,先交给当前 view 自己的 onTouchEvent() ,因为自己不消费,则调用其 父ViewGrouponTouchEvent() ,如此层层传递,最终又交给了 Act 自行处理。上述这个流程,就是 责任链模式 的一种体现。


如下图所示:


img



上图来自 Android事件分发机制三:事件分发工作流程 @一只修仙的猿





看完什么是责任链模式,让我们将思路转回到 OkHttp 上面,我们再去看一下 RealInterceptorChain 源码。


image-20230210153338845


image-20230210153424035


上述逻辑如下:




  • getResponseWithInterceptorChain() 方法内部最终调用 RealInterceptorChain.proceed() 时,内部传入了一个默认的index ,这个 index 就代表了当前要调用的 拦截器item ,并在方法内部每次创建一个新的 RealInterceptorChain 链,index+1,再调用当前拦截器 intercept() 方法时,然后将下一个链传入;




  • 最开始调用的是用户自定义的 普通拦截器,如果上述我们添加了一个 CustomLogInterceptor 的拦截器,当获取 response 时,我们需要调用 Interceptor.Chain.proceed() ,而此时的 chain 正是下一个拦截器对应的 RealInterceptorChain




  • 上述流程里,index从0开始,以此类推,一直到链条末尾,即 拦截器集合长度-1处;




  • 当遇到最后一个拦截器 CallServerInterceptor 时,此时因为已经是最后一个拦截器,链条肯定要结束了,所以其内部肯定也不会调用 proceed() 方法。


    相应的,为什么我们在前面说 它 是真正执行与服务器建立实际通讯的拦截器?


    因为这个里会获取与服务器通讯的 response ,即最初响应结果,然后将其返回上一个拦截器,即我们的网络拦截器,再接着又向上返回,最终返回到我们的普通拦截器处,从而完成整个链路的路由。




参照上面的流程,即大致思路图如下:


petterp-image


拦截器


RetryAndFollowUpInterceptor


见名知意,用于 请求失败重试 工作以及 重定向 的后续请求工作,同时还会对 连接 做一些初始化工作。


image-20230210155454132


上述的逻辑,我们分为四段进行分析:



  1. 请求时如果遇到异常,则根据情况去尝试恢复,如果不能恢复,则抛出异常,跳过本次请求;如果请求成功,则在 finally 里释放资源;

  2. 如果请求是重试之后的请求,那么将重试前请求的响应体设置为null,并添加到当前响应体的 priorResponse 字段中;

  3. 根据当前的responseCode判断是否需要重试,若不需要,则返回 response ;若需要,则返回 request ,并在后续检查当前重试次数是否达到阈值;

  4. 重复上述步骤,直到步骤三成功。


在第一步时,获取 response 时,需要调用 realChain.proceed(request) ,如果你还记得上述的责任链,所以这里触发了下面的拦截器执行,即 BridgeInterceptor




BridgeInterceptor


用于 客户端和服务器 之间的沟通 桥梁 ,负责将用户构建的请求转换为服务器需要的请求。比如添加 content-typecookie 等,再将服务器返回的 response 做一些处理,转换为客户端所需要的 response,比如移除 Content-Encoding ,具体见下面源码所示:


image-20230210154444955


上述逻辑如下:



  1. 首先调用 chain.request() 获取原始请求数据,然后开始重新构建请求头,添加 header 以及 cookie 等信息;

  2. 将第一步构建好的新的 request 传入 chain.proceed() ,从而触发下一个拦截器的执行,并得到 服务器返回的 response。然后保存 response 携带的 cookie,并移除 header 中的 Content-EncodingContent-Length,并同步修改 body




CacheInterceptor


见名知意,其用于网络缓存,开发者可以通过 OkHttpClient.cache() 方法来配置缓存,在底层的实现处,缓存拦截器通过 CacheStrategy 来判断是使用网络还是缓存来构建 response。具体的 cache 策略采用的是 DiskLruCache


Cache的策略如下图所示:


image-20230210155727822


具体源码如下所示:


image-20230210155609448


具体的逻辑如上图所示,具体可以参照上述的 Cache 流程图,这里我们再说一下 CacheStrategy 这个类,即决定何时使用 网络请求、响应缓存。


CacheStrategy


image-20230210155853603




ConnectInterceptor


实现与服务器真正的连接。


image-20230210154605273


上述流程如下:



  • 初始化 一个 exchange 对象;

  • 根据 exchange 对象来复制创建一个新的连接责任链;

  • 执行该连接责任链。


那 Exchange 是什么呢?



在官方的解释里,其用于 传递单个 HTTP 请求和响应对,在 ExchangeCode 的基础上担负了一些管理及事件分发的作用。


具体而言,ExchangeRequest 相对应,新建一个请求时就会创建一个 Exchange,该 Exchange 负责将这个请求发送出去并读取到响应数据,而具体的发送与接收数据使用的则是 ExchangeCodec



相应的,ExchangeCode 又是什么呢?



ExchangeCodec 负责对 request 编码及解码 Response ,即写入请求及读取响应,我们的请求及响应数据都是通过它来读写。


通俗一点就是,ExchangeCodec 是请求处理器,它内部封装了 OkHttp 中执行网络请求的细节实现,其通过接受一个 Request 对象,并在内部进行处理,最终生成一个符合 HTTP 协议标准的网络请求,然后接受服务器返回的HTTP响应,并生成一个 Response 对象,从而完成网络请求的整个过程。



额外的,我们还需要再提一个类,ExchangeFinder



用于寻找可用的 Exchange ,然后发送下一个请求并接受下一个响应。



虽然上述流程看起来似乎很简单,但我们还是要分析下具体的流程,源码如下所示:


RealCall.initExchange()

初始化 Exchage 的过程。


ExchangeFinder 找到一个新的或者已经存在的 ExchangeCodec,然后初始化 Exchange ,以此来承载接下来的HTTP请求和响应对。


image-20230210154713820




ExchangeFinder.find()

查找 ExchangeCodec(请求响应编码器) 的过程。


image-20230210154640516


接下来我们看看查找 RealConnection 的具体过程:


image-20230210160033258


上述的整个流程如下:


上述会先通过 ExchangeFinderRealConnecionPool 中尝试寻找已经存在的连接,未找到则会重新创建一个 RealConnection(连接) 对象,并将其添加到连接池里,开始连接。然后根据找到或者新创建 RealConnection 对象,并根据当前请求协议创建不同的 ExchangeCodec 对象并返回,最后初始化一个 Exchange 交换器并返回,从而实现了 Exchange 的初始化过程。


在具体找寻 RealConnection 的过程中,一共尝试了5次,具体如下:



  1. 尝试重连 call 中的 connection,此时不需要重新获取连接;

  2. 尝试从连接池中获取一个连接,不带路由与多路复用;

  3. 再次尝试从连接池中获取一个连接,带路由,不带多路复用;

  4. 手动创建一个新连接;

  5. 再次尝试从连接池中获取一个连接,带路由与多路复用;


Exchange 初始化完成后,再复制该对象创建一个新的 Exchange ,并执行下一个责任链,从而完成连接的建立。




networkInterceptors


网络拦截器,即 client.networkInterceptors 中自定义拦截器,与普通的拦截器 client.interceptors 不同的是:


由于网络拦截器处于倒数第二层,在 RetryAndFollowUpInterceptor 失败或者 CacheInterceptor 返回缓存的情况下,网络拦截器无法被执行。而普通拦截器由于第一步就被就执行到,所以不受这个限制。




CallServerInterceptor


链中的最后一个拦截器,也即与服务器进行通信的拦截器,利用 HttpCodec 进行数据请求、响应数据的读写。


具体源码如下:


image-20230210160138216


先写入要发送的请求头,然后根据条件判断是否写入要发送的请求体。当请求结束后,解析服务器返回的响应头,构建一个新的 response 并返回;如果 response.code100,则重新读取响应体并构建新的 response。因为这是最底层的拦截器,所以这里肯定不会再调用 proceed() 再往下执行。


小结


至此,关于 OkHttp 的分析,到这里就结束了。为了便于理解,我们再串一遍整个思路:


OkHttp 中,RealCallCall 的实现类,其负责 执行网络请求 。其中,请求 requestDispatcher 进行调度,其中 异步调用 时,会将请求放到到线程池中去执行; 而同步的请求则只是会添加到 Dispatcher 中去管理,并不会有线程池参与协调执行。


在具体的请求过程中,网络请求依次会经过下列拦截器组成的责任链,最后发送到服务器。



  1. 普通拦截器,client.interceptors()

  2. 重试、重定向拦截器 RetryAndFollowUpInterceptor

  3. 用于客户端与服务器桥梁,将用户请求转换为服务器请求,将服务器响应转换为用户响应的的 BridgeInterceptor

  4. 决定是否需要请求服务器并写入缓存再返回还是直接返回服务器响应缓存的 CacheInterceptor;

  5. 与服务器建立连接的 ConnectInterceptor

  6. 网络拦截器,client.networkInterceptors();

  7. 执行网络请求的 CallServerInterceptor;


而相应的服务器响应体则会从 CallServerInterceptor 开始依次往前开始返回,最后由客户端进行处理。



需要注意的是,当我们 RetryAndFollowUpInterceptor 异常或者 CacheInterceptor 拦截器直接返回了有效缓存,后续的拦截器将不会执行。



常见问题


OkHttp如何判断缓存有效性?


这里其实主要说的是 CacheInterceptor 拦截器里的逻辑,具体如下:


OkHttp 使用 HTTP协议 中的 缓存控制机制 来判断缓存是否有效。如果请求头中包含 "Cache-Control""If-None-Match" / "If-Modified-Since" 字段,OkHttp 将根据这些字段的值来决定是否使用缓存或从网络请求响应。



Cache-Control 指 包含缓存控制的指令,例如 "no-cache""max-age" ;


If-None-Match 指 客户端缓存的响应的ETag值,如果服务器返回相同的 ETag 值,则说明响应未修改,缓存有效;


If-Modified-Since 指 客户端缓存的响应的最后修改时间,如果服务器确定响应在此时间后未更改,则返回304 Not Modified状态码,表示缓存有效。



相应的,OkHttp 也支持自定义缓存有效性控制,开发者可以创建一个 CacheControl 对象,并将其作为请求头添加到 Request 中,如下所示:


// 禁止OkHttp使用缓存
val cacheControl = CacheControl.Builder()
.noCache()
.build()
val request = Request.Builder()
.cacheControl(cacheControl)
.url("https://www.baidu.com")
.build()
复制代码

OkHttp如何复用TCP连接?


这个其实主要说的是 ConnectInterceptor 拦截器中初始化 Exchange 时内部做的事,具体如下:


OkHttp 使用连接池 RealConnectionPool 管理所有连接,连接池将所有活动的连接存储在池中,并维护了一个空闲的连接列表(TaskQueue),当需要新的连接时,优先尝试从这个池中找,如果没找到,则 重新创建 一个 RealConnection 连接对象,并将其添加到连接池中。在具体的寻找连接的过程中,一共进行了下面5次尝试:



  1. 尝试重连 RealCall 中的 connection,此时不需要重新获取连接;

  2. 尝试从连接池中获取一个连接,不带路由与多路复用;

  3. 再次尝试从连接池中获取一个连接,带路由,不带多路复用;

  4. 手动创建一个新连接;

  5. 再次尝试从连接池中获取一个连接,带路由与多路复用;


当然 OkHttp 也支持自定义连接池,具体如下:


image-20230210154740343


上述代码中,创建了一个新的连接池,并设置其保留最多 maxIdleConnections 个空闲连接,并且连接的存活期为 keepAliveDuration 分钟。


OKHttp复用TCP连接的好处是什么?


OkHttp 是由连接池管理所有连接,通过连接池,从而可以限制连接的 最大数量,并且对于空闲的连接有相应的 存活期限 ,以便在长时间不使用后关闭连接。当请求结束时,并且将保留该连接,便于后续 复用 。从而实现了在多个请求之间共享连接,避免多次建立和关闭TCP连接的开销,提高请求效率。


OkHttp中的请求和响应 与 网络请求和响应,这两者有什么不同?


OkHttp 中的的请求和响应指的是客户端创建的请求对象 Request 和 服务端返回的响应对象 Response,这两个对象用于定义请求和响应的信息。网络请求和响应指的是客户端向服务端发送请求,服务端返回相应的过程。


总的来说就是,请求和响应是应用程序内部自己的事,网络请求和响应则是发生在网络上的请求和响应过程


OkHttp 应用拦截器和网络拦截器的区别?



  • 从调用方式上而言,应用拦截器指的是 OkhttpClient.intercetors ,网络拦截器指的是 OkHttpClient.netIntercetors

  • 从整个责任链的调用来看,应用拦截器一定会被执行一次,而网络拦截器不一定会执行或者执行多次情况,比如当我们 RetryAndFollowUpInterceptor 异常或者 CacheInterceptor 拦截器直接返回了有效缓存,后续的拦截器将不会执行,相应的网络拦截器也自然不会执行到;当我们发生 错误重试 或者 网络重定向 时,网络拦截器此时可能就会执行多次。

  • 其次,除了 CallServerInterceptorCacheIntercerceptor 缓存有效之外,每个拦截器都应该至少调用一次 realChain.proceed() 方法。但应用拦截器可以调用多次 processed() 方法,因为其在请求流程中是可以递归调用;而网络拦截器只能调用一次 processed() 方法,否则将导致请求重复提交,影响性能,另外,网络拦截器没有对请求做修改的可能性,因此不需要再次调用 processed() 方法。

  • 使用方式的 本质而言,应用拦截器可以 拦截和修改请求和响应 ,但 不能修改网络请求和响应 。比如使用应用拦截器添加请求参数、缓存请求结果;网络拦截器可以拦截和修改网络请求和响应。例如使用网络拦截器添加请求头、修改请求内容、检查响应码等。

  • 在相应的执行顺序上,网络拦截器是 先进先出(FIFO) ,应用拦截器是 先进后出(FILO) 的方式执行。


结语


本篇中,我们从网络库的迭代历史,一直到 OkHttp 的使用方式、设计思想、源码探索,最后又聊了聊常见的一些问题,从而较系统的了解了 OkHttp 的方方面面,也解释了 OkHttp应用层 的相关问题,当然这些问题我相信也仅仅只是冰山一角🧩。 更多面试相关,或者实际问题,仍需要我们自己再进行完善,从而形成全面的透析力。


这篇文章断断续续写了将近两周,其中肯定有不少部分存在缺陷或者逻辑漏洞,如果您发现了,也可以告诉我。


通过这篇文章,于我个人而言,也是完成了对于 OkHttp应用层 一次较系统的了解,从而也完善了知识拼图中重要的一块,期待作为读者的你也能有如此或者更深的体会。🏃🏻


更多


这是 解码系列 - OkHttp 篇,如果你觉得这个系列写的还不错,不妨点个关注催更一波,当然也可以看看其他篇:





参阅



作者:Petterp
链接:https://juejin.cn/post/7199431845367922745
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

鸿蒙3.0应用开发若干问题

1.如何去掉默认标题栏,实现全屏显示?在config.json中的ability配置信息中添加属性:2.应用冷启动白屏?这个问题类似与安卓应用冷启动时白屏一样,鸿蒙应用的解决办法同问题1,将主题设置为:注意是Translucent。3.如何获取屏幕尺寸?4.如...
继续阅读 »

在这里插入图片描述

1.如何去掉默认标题栏,实现全屏显示?

在config.json中的ability配置信息中添加属性:

2.应用冷启动白屏?

这个问题类似与安卓应用冷启动时白屏一样,鸿蒙应用的解决办法同问题1,将主题设置为:


注意是Translucent。

3.如何获取屏幕尺寸?

4.如何获取状态栏高度,以及设置状态栏背景色?

5.如何显示Toast提示?



6.网络请求


本文转载自CSDN博客博主白玉梁,原文地址:https://blog.csdn.net/baiyuliang2013/article/details/128236417

收起阅读 »

Builder模式拯救了我的强迫症

前言 Builder模式大家应该不陌生,在我们的编码生涯中,总会碰到它的身影。无论是Android开发中的AlertDialog,还是网络框架中的OkHttp和Retrofit,亦或是JavaPoet中,都有这哥们的身影。 之所以它这么受欢迎,除了它的上手难度...
继续阅读 »

前言


Builder模式大家应该不陌生,在我们的编码生涯中,总会碰到它的身影。无论是Android开发中的AlertDialog,还是网络框架中的OkHttp和Retrofit,亦或是JavaPoet中,都有这哥们的身影。


之所以它这么受欢迎,除了它的上手难度比较低以外,还有一点就是它的的确确的解决了我们日常开发中的一个难题,创建对象时需要的参数过多


举个小例子


过去几年大家都流行炒币,导致市面上一卡难求。随着政府政策的出台,以及虚拟货币的崩盘。显卡不再是有价无市的一种状态。大学刚毕业的小龙开了个电脑店,专门给人配电脑。最开始的时候需求比较简单,只给人记录电脑的CPU,GPU,硬盘等相关信息。


传统的创建对象方式


// 电脑类
class Computer {
private String mBroad;
private String mCPU;
private String mGPU;

public Computer(String broad, String CPU, String GPU) {
mBroad = broad;
mCPU = CPU;
mGPU = GPU;
}

@Override
public String toString() {
return "Computer{" +
", mBroad='" + mBroad + ''' +
", mCPU='" + mCPU + ''' +
", mGPU='" + mGPU + ''' +
'}';
}
}
复制代码

这个时候创建一个Computer对象是这样的:


Computer computer = new Computer("微星 B550M","INTEL I5","NV 3060TI");
复制代码

随着业务量的增大,客户的要求也越来越多。对鼠标,键盘,系统也有了相应的需求。所以Computer类也不得不有了相应的改变。


static class Computer {
private String mOS;
private String mBroad;
private String mKeyBoard;
private String mMouse;
private String mCPU;
private String mGPU;

public Computer(String OS, String broad, String keyBoard, String mouse, String CPU, String GPU) {
mOS = OS;
mBroad = broad;
mKeyBoard = keyBoard;
mMouse = mouse;
mCPU = CPU;
mGPU = GPU;
}

// 就写一个set方法否则文章太长,其他就不写了
public void setmBroad(String mBroad) {
this.mBroad = mBroad;
}

@Override
public String toString() {
return "Computer{" +
"mOS='" + mOS + ''' +
", mBroad='" + mBroad + ''' +
", mKeyBoard='" + mKeyBoard + ''' +
", mMouse='" + mMouse + ''' +
", mCPU='" + mCPU + ''' +
", mGPU='" + mGPU + ''' +
'}';
}
}
复制代码

而创建Computer对象的参数也越来越长:


Computer computer = new Computer("MAC OS","微星 B550M","IQUNIX F97"
,"罗技 MX MASTER3","INTEL I5","NV 3060TI");
复制代码

如果再有新的需求参数,电源,机箱,散热,内存条,硬盘......简直不敢想象。


对象初始化参数问题


此时我们面对的是编程中常见的一个问题,对象中需求的参数过多,而都在构造函数传递,则构造函数就会同例子中一样,太长,要是用set方法来传递,则更为恐怖。


这个时候一个模式就应运而生,他就是建造者模式


建造者模式处理方式


/**
* @author:TianLong
* @date:2022/10/17 19:58
* @detail:产品类
*/
class Computer{
private String mOS;
private String mBroad;
private String mKeyBoard;
private String mMouse;
private String mCPU;
private String mGPU;
private Computer(String OS, String broad, String keyBoard, String mouse, String CPU, String GPU) {
mOS = OS;
mBroad = broad;
mKeyBoard = keyBoard;
mMouse = mouse;
mCPU = CPU;
mGPU = GPU;
}

public static ComputerBuilder createBuilder(){
return new ComputerBuilder();
}

@Override
public String toString() {
return "Computer{" +
"mOS='" + mOS + ''' +
", mBroad='" + mBroad + ''' +
", mKeyBoard='" + mKeyBoard + ''' +
", mMouse='" + mMouse + ''' +
", mCPU='" + mCPU + ''' +
", mGPU='" + mGPU + ''' +
'}';
}

/**
* @author:TianLong
* @date:2022/10/17 19:58
* @detail:产品建造者类
*/
public static class ComputerBuilder{
private String mOS = "Windows";
private String mBroad= "微星 B550M";
private String mKeyBoard= "无";
private String mMouse= "无";
private String mCPU= "Intel I5";
private String mGPU= "AMD 6600XT";

public ComputerBuilder setOS(String OS) {
mOS = OS;
return this;
}

public ComputerBuilder setBroad(String broad) {
mBroad = broad;
return this;
}

public ComputerBuilder setKeyBoard(String keyBoard) {
mKeyBoard = keyBoard;
return this;
}

public ComputerBuilder setMouse(String mouse) {
mMouse = mouse;
return this;
}

public ComputerBuilder setCPU(String CPU) {
mCPU = CPU;
return this;
}

public ComputerBuilder setGPU(String GPU) {
mGPU = GPU;
return this;
}

public Computer build(){
// 可以在build方法中做一些校验等其他工作
if (mBroad.contains("技嘉")){
throw new RuntimeException("技嘉辱华,不支持技嘉主板");
}

Computer computer = new Computer(mOS,mBroad,mKeyBoard,mMouse,mCPU,mGPU);
return computer;
}
}
复制代码

老版本和Builder版本创建对象


// 老版本的Computer对象创建
Computer computer = new Computer("MAC OS","微星 B550M","IQUNIX F97"
,"罗技 MX MASTER3","INTEL I5","NV 3060TI");

// Builder版本的Computer对象创建
Computer computer =Computer.createBuilder()
.setCPU("AMD 5600X")
.setGPU("NV 3060TI")
.setMouse("罗技 MX MASTER3")
.setKeyBoard("IQUNIX F97")
.build();
复制代码

两个版本一对比就能体现出来优势。老版本构造函数中的参数太多太长,同一个类型的参数很容易传错位,经常传参数的时候,还要看看第几个参数应该传什么。


Builder模式的对象创建,简单明了,更容易理解,而且流式的调用更加美观,不会出错。


从代码中可以看到,Computer类的构造函数是私有的,保证了所有对象的创建都必须从ComputerBuilder这个类来创建。且ComputerBuilder这个类的build方法中,可以进行校验或者其他操作。


同时,Computer这个类中是否存在Set方法,由你的实际应用场景决定,反正我的使用场景里,没有修改需求。


注意事项



  1. 上述代码为常见写法,并非固定模板。只要能通过Builder类创建目标对象,都可以算是建造者模式

  2. 建造者模式中的目标对象的构造函数必须是private修饰。否则可以直接创建对象。Builder类就没有意义了

  3. 建造者模式中的目标对象是否需要Set方法,由具体需求决定。一般情况下没有Set方法,可以避免对该对象中的参数进行修改。

  4. Builder中的build方法,可以处理一些逻辑问题,比如校验信息等

  5. 工厂模式注重的是同一类型的对象中通过参数来控制具体创建哪个对象。Builder模式关注的是单一对象中的参数传递

作者:OpenGL
链接:https://juejin.cn/post/7197326179934191676
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

百万级数据excel导出功能如何实现?

前言最近我做过一个MySQL百万级别数据的excel导出功能,已经正常上线使用了。这个功能挺有意思的,里面需要注意的细节还真不少,现在拿出来跟大家分享一下,希望对你会有所帮助。原始需求:用户在UI界面上点击全部导出按钮,就能导出所有商品数据。咋一看,这个需求挺...
继续阅读 »

前言

最近我做过一个MySQL百万级别数据的excel导出功能,已经正常上线使用了。

这个功能挺有意思的,里面需要注意的细节还真不少,现在拿出来跟大家分享一下,希望对你会有所帮助。

原始需求:用户在UI界面上点击全部导出按钮,就能导出所有商品数据。

咋一看,这个需求挺简单的。

但如果我告诉你,导出的记录条数,可能有一百多万,甚至两百万呢?

这时你可能会倒吸一口气。

因为你可能会面临如下问题:

  1. 如果同步导数据,接口很容易超时。

  2. 如果把所有数据一次性装载到内存,很容易引起OOM。

  3. 数据量太大sql语句必定很慢。

  4. 相同商品编号的数据要放到一起。

  5. 如果走异步,如何通知用户导出结果?

  6. 如果excel文件太大,目标用户打不开怎么办?

我们要如何才能解决这些问题,实现一个百万级别的excel数据快速导出功能呢?


1.异步处理

做一个MySQL百万数据级别的excel导出功能,如果走接口同步导出,该接口肯定会非常容易超时

因此,我们在做系统设计的时候,第一选择应该是接口走异步处理。

说起异步处理,其实有很多种,比如:使用开启一个线程,或者使用线程池,或者使用job,或者使用mq等。

为了防止服务重启时数据的丢失问题,我们大多数情况下,会使用job或者mq来实现异步功能。

1.1 使用job

如果使用job的话,需要增加一张执行任务表,记录每次的导出任务。

用户点击全部导出按钮,会调用一个后端接口,该接口会向表中写入一条记录,该记录的状态为:待执行

有个job,每隔一段时间(比如:5分钟),扫描一次执行任务表,查出所有状态是待执行的记录。

然后遍历这些记录,挨个执行。

需要注意的是:如果用job的话,要避免重复执行的情况。比如job每隔5分钟执行一次,但如果数据导出的功能所花费的时间超过了5分钟,在一个job周期内执行不完,就会被下一个job执行周期执行。

所以使用job时可能会出现重复执行的情况。

为了防止job重复执行的情况,该执行任务需要增加一个执行中的状态。

具体的状态变化如下:

  1. 执行任务被刚记录到执行任务表,是待执行状态。

  2. 当job第一次执行该执行任务时,该记录再数据库中的状态改为:执行中

  3. 当job跑完了,该记录的状态变成:完成失败

这样导出数据的功能,在第一个job周期内执行不完,在第二次job执行时,查询待处理状态,并不会查询出执行中状态的数据,也就是说不会重复执行。

此外,使用job还有一个硬伤即:它不是立马执行的,有一定的延迟。

如果对时间不太敏感的业务场景,可以考虑使用该方案。

1.2 使用mq

用户点击全部导出按钮,会调用一个后端接口,该接口会向mq服务端,发送一条mq消息

有个专门的mq消费者,消费该消息,然后就可以实现excel的数据导出了。

相较于job方案,使用mq方案的话,实时性更好一些。

对于mq消费者处理失败的情况,可以增加补偿机制,自动发起重试

RocketMQ自带了失败重试功能,如果失败次数超过了一定的阀值,则会将该消息自动放入死信队列

2.使用easyexcel

我们知道在Java中解析和生成Excel,比较有名的框架有Apache POIjxl

但它们都存在一个严重的问题就是:非常耗内存,POI有一套SAX模式的API可以一定程度的解决一些内存溢出的问题,但POI还是有一些缺陷,比如07版Excel解压缩以及解压后存储都是在内存中完成的,内存消耗依然很大。

百万级别的excel数据导出功能,如果使用传统的Apache POI框架去处理,可能会消耗很大的内存,容易引发OOM问题。

easyexcel重写了POI对07版Excel的解析,之前一个3M的excel用POI sax解析,需要100M左右内存,如果改用easyexcel可以降低到几M,并且再大的Excel也不会出现内存溢出;03版依赖POI的sax模式,在上层做了模型转换的封装,让使用者更加简单方便。

需要在mavenpom.xml文件中引入easyexcel的jar包:

<dependency>
   <groupId>com.alibaba</groupId>
   <artifactId>easyexcel</artifactId>
   <version>3.0.2</version>
</dependency>
复制代码

之后,使用起来非常方便。

读excel数据非常方便:

@Test
public void simpleRead() {
  String fileName = TestFileUtil.getPath() + "demo" + File.separator + "demo.xlsx";
  // 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭
  EasyExcel.read(fileName, DemoData.class, new DemoDataListener()).sheet().doRead();
}
复制代码

写excel数据也非常方便:

 @Test
public void simpleWrite() {
   String fileName = TestFileUtil.getPath() + "write" + System.currentTimeMillis() + ".xlsx";
   // 这里 需要指定写用哪个class去读,然后写到第一个sheet,名字为模板 然后文件流会自动关闭
   // 如果这里想使用03 则 传入excelType参数即可
   EasyExcel.write(fileName, DemoData.class).sheet("模板").doWrite(data());
}
复制代码

easyexcel能大大减少占用内存的主要原因是:在解析Excel时没有将文件数据一次性全部加载到内存中,而是从磁盘上一行行读取数据,逐个解析。

3.分页查询

百万级别的数据,从数据库一次性查询出来,是一件非常耗时的工作。

即使我们可以从数据库中一次性查询出所有数据,没出现连接超时问题,这么多的数据全部加载到应用服务的内存中,也有可能会导致应用服务出现OOM问题。

因此,我们从数据库中查询数据时,有必要使用分页查询。比如:每页5000条记录,分为200页查询。

public Page<User> searchUser(SearchModel searchModel) {
  List<User> userList = userMapper.searchUser(searchModel);
  Page<User> pageResponse = Page.create(userList, searchModel);
  pageResponse.setTotal(userMapper.searchUserCount(searchModel));
  return pageResponse;
}
复制代码

每页大小pageSize和页码pageNo,是SearchModel类中的成员变量,在创建searchModel对象时,可以设置设置这两个参数。

然后在Mybatis的sql文件中,通过limit语句实现分页功能:

limit #{pageStart}, #{pageSize}
复制代码

其中的pagetStart参数,是通过pageNo和pageSize动态计算出来的,比如:

pageStart = (pageNo - 1) * pageSize;
复制代码

4.多个sheet

我们知道,excel对一个sheet存放的最大数据量,是有做限制的,一个sheet最多可以保存1048576行数据。否则在保存数据时会直接报错:

invalid row number (1048576) outside allowable range (0..1048575)
复制代码

如果你想导出一百万以上的数据,excel的一个sheet肯定是存放不下的。

因此我们需要把数据保存到多个sheet中。

5.计算limit的起始位置

我之前说过,我们一般是通过limit语句来实现分页查询功能的:

limit #{pageStart}, #{pageSize}
复制代码

其中的pagetStart参数,是通过pageNo和pageSize动态计算出来的,比如:

pageStart = (pageNo - 1) * pageSize;
复制代码

如果只有一个sheet可以这么玩,但如果有多个sheet就会有问题。因此,我们需要重新计算limit的起始位置。

例如:

ExcelWriter excelWriter = EasyExcelFactory.write(out).build();
int totalPage = searchUserTotalPage(searchModel);

if(totalPage > 0) {
  Page<User> page = Page.create(searchModel);
  int sheet = (totalPage % maxSheetCount == 0) ? totalPage / maxSheetCount: (totalPage / maxSheetCount) + 1;
  for(int i=0;i<sheet;i++) {
    WriterSheet writeSheet = buildSheet(i,"sheet"+i);
    int startPageNo = i*(maxSheetCount/pageSize)+1;
    int endPageNo = (i+1)*(maxSheetCount/pageSize);
    while(page.getPageNo()>=startPageNo && page.getPageNo()<=endPageNo) {
      page = searchUser(searchModel);
      if(CollectionUtils.isEmpty(page.getList())) {
          break;
      }
       
      excelWriter.write(page.getList(),writeSheet);
      page.setPageNo(page.getPageNo()+1);
    }
  }
}
复制代码

这样就能实现分页查询,将数据导出到不同的excel的sheet当中。

6.文件上传到OSS

由于现在我们导出excel数据的方案改成了异步,所以没法直接将excel文件,同步返回给用户。

因此我们需要先将excel文件存放到一个地方,当用户有需要时,可以访问到。

这时,我们可以直接将文件上传到OSS文件服务器上。

通过OSS提供的上传接口,将excel上传成功后,会返回文件名称访问路径

我们可以将excel名称和访问路径保存到中,这样的话,后面就可以直接通过浏览器,访问远程excel文件了。

而如果将excel文件保存到应用服务器,可能会占用比较多的磁盘空间

一般建议将应用服务器文件服务器分开,应用服务器需要更多的内存资源或者CPU资源,而文件服务器需要更多的磁盘资源

7.通过WebSocket推送通知

通过上面的功能已经导出了excel文件,并且上传到了OSS文件服务器上。

接下来的任务是要本次excel导出结果,成功还是失败,通知目标用户。

有种做法是在页面上提示:正在导出excel数据,请耐心等待

然后用户可以主动刷新当前页面,获取本地导出excel的结果。

但这种用户交互功能,不太友好。

还有一种方式是通过webSocket建立长连接,进行实时通知推送。

如果你使用了SpringBoot框架,可以直接引入webSocket的相关jar包:

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
复制代码

使用起来挺方便的。

我们可以加一张专门的通知表,记录通过webSocket推送的通知的标题、用户、附件地址、阅读状态、类型等信息。

能更好的追溯通知记录。

webSocket给客户端推送一个通知之后,用户的右上角的收件箱上,实时出现了一个小窗口,提示本次导出excel功能是成功还是失败,并且有文件下载链接。

当前通知的阅读状态是未读

用户点击该窗口,可以看到通知的详细内容,然后通知状态变成已读

8.总条数可配置

我们在做导百万级数据这个需求时,是给用户用的,也有可能是给运营同学用的。

其实我们应该站在实际用户的角度出发,去思考一下,这个需求是否合理。

用户拿到这个百万级别的excel文件,到底有什么用途,在他们的电脑上能否打开该excel文件,电脑是否会出现太大的卡顿了,导致文件使用不了。

如果该功能上线之后,真的发生发生这些情况,那么导出excel也没有啥意义了。

因此,非常有必要把记录的总条数,做成可配置的,可以根据用户的实际情况调整这个配置。

比如:用户发现excel中有50万的数据,可以正常访问和操作excel,这时候我们可以将总条数调整成500000,把多余的数据截取掉。

其实,在用户的操作界面,增加更多的查询条件,用户通过修改查询条件,多次导数据,可以实现将所有数据都导出的功能,这样可能更合理一些。

此外,分页查询时,每页的大小,也建议做成可配置的。

通过总条数和每页大小,可以动态调整记录数量和分页查询次数,有助于更好满足用户的需求。

9.order by商品编号

之前的需求是要将相同商品编号的数据放到一起。

例如:

编号商品名称仓库名称价格
1笔记本北京仓7234
1笔记本上海仓7235
1笔记本武汉仓7236
2平板电脑成都仓7236
2平板电脑大连仓3339

但我们做了分页查询的功能,没法将数据一次性查询出来,直接在Java内存中分组或者排序。

因此,我们需要考虑在sql语句中使用order by 商品编号,先把数据排好顺序,再查询出数据,这样就能将相同商品编号,仓库不同的数据放到一起。

此外,还有一种情况需要考虑一下,通过配置的总记录数将全部数据做了截取。

但如果最后一个商品编号在最后一页中没有查询完,可能会导致导出的最后一个商品的数据不完整。

因此,我们需要在程序中处理一下,将最后一个商品删除。

但加了order by关键字进行排序之后,如果查询sql中join了很多张表,可能会导致查询性能变差。

那么,该怎么办呢?

总结

最后用两张图,总结一下excel异步导数据的流程。

如果是使用mq导数据:


如果是使用job导数据:


这两种方式都可以,可以根据实际情况选择使用。

我们按照这套方案的开发了代码,发到了pre环境,原本以为会非常顺利,但后面却还是出现了性能问题。

后来,我们用了两招轻松解决了性能问题。

作者:苏三说技术
来源:juejin.cn/post/7196140566111043643

收起阅读 »

对于单点登录,你不得不了解的CAS

之前我们通过面试的形式,讲了JWT实现单点登录(SSO)的设计思路,并且到最后也留下了疑问,什么是CAS。寒暄开始今天是上班的第一天,刚进公司就见到了上次的面试官,穿着格子衬衫和拖鞋,我们就叫他老余吧。老余看见我就开始勾肩搭背聊起来了,完全就是自来熟的样子,和...
继续阅读 »

之前我们通过面试的形式,讲了JWT实现单点登录(SSO)的设计思路,并且到最后也留下了疑问,什么是CAS

寒暄开始

今天是上班的第一天,刚进公司就见到了上次的面试官,穿着格子衬衫和拖鞋,我们就叫他老余吧。老余看见我就开始勾肩搭背聊起来了,完全就是自来熟的样子,和我最近看的少年歌行里的某人很像。

什么是CAS呢

老余:上次你说到了CAS,你觉得CAS是什么?

我:之前我们面试的时候,我讲了JWT单点登录带来的问题,然后慢慢优化,最后衍变成了中心化单点登录系统,也就是CAS的方案。

CAS(Central Authentication Service),中心认证服务,就是单点登录的某种实现方案。你可以把它理解为它是一个登录中转站,通过SSO站点,既解决了Cookie跨域的问题,同时还通过SSO服务端实现了登录验证的中心化。

这里的SSO指的是:SSO系统

它的设计流程是怎样的

老余:你能不能讲下它的大致实现思路,这说的也太虚头巴脑了,简直是听君一席话,如听一席话。
我:你别急呀,先看下它的官方流程图。


重定向到SSO

首先,用户想要访问系统A的页面1,自然会调用http://www.chezhe1.com的限制接口,(比如说用户信息等接口登录后才能访问)。

接下来 系统A 服务端一般会在拦截器【也可以是过滤器,AOP 啥的】中根据Cookie中的SessionId判断用户是否已登录。如果未登录,则重定向到SSO系统的登录页面,并且带上自己的回调地址,便于用户在SSO系统登录成功后返回。此时回调地址是:http://www.sso.com?url=www.chezhe1.com

这个回调地址大家应该都不会陌生吧,像那种异步接口或者微信授权、支付都会涉及到这块内容。不是很了解的下面会解释~
另外这个回调地址还必须是前端页面地址,主要用于回调后和当前系统建立会话。

此时如下图所示:


用户登录

  1. 在重定向到SSO登录页后,需要在页面加载时调用接口,根据SessionId判断当前用户在SSO系统下是否已登录。【注意这时候已经在 SSO 系统的域名下了,也就意味着此时Cookie中的domain已经变成了sso.com

为什么又要判断是否登录?因为在 CAS 这个方案中,只有在SSO系统中为登录状态才能表明用户已登录。

  1. 如果未登录,展现账号密码框,让用户输入后进行SSO系统的登录。登录成功后,SSO页面和SSO服务端建立起了会话。 此时流程图如下所示:


安全验证

老余:你这里有一个很大的漏洞你发现没有?
我:emm,我当然知道。

对于中心化系统,我们一般会分发对应的AppId,然后要求每个应用设置白名单域名。所以在这里我们还得验证AppId的有效性,白名单域名和回调地址域名是否匹配。否则有些人在回调地址上写个黄色网站那不是凉凉。


获取用户信息登录

  1. 在正常的系统中用户登录后,一般需要跳转到业务界面。但是在SSO系统登录后,需要跳转到原先的系统A,这个系统A地址怎么来?还记得重定向到SSO页面时带的回调地址吗?


通过这个回调地址,我们就能很轻易的在用户登录成功后,返回到原先的业务系统。

  1. 于是用户登录成功后根据回调地址,带上ticket,重定向回系统A,重定向地址为:http://www.chezhe1.com?ticket=123456a

  2. 接着根据ticket,从SSO服务端中获取Token。在此过程中,需要对ticket进行验证。

  3. 根据tokenSSO服务端中获取用户信息。在此过程中,需要对token进行验证。

  4. 获取用户信息后进行登录,至此系统A页面和系统A服务端建立起了会话,登录成功。

此时流程图如下所示:


别以为这么快就结束了哦,我这边提出几个问题,只有把这些想明白了,才算是真的清楚了。

  • 为什么需要 Ticket?

  • 验证 Ticket 需要验证哪些内容?

  • 为什么需要 Token?

  • 验证 Token 需要验证哪些内容?

  • 如果没有Token,我直接通过Ticket 获取用户信息是否可行?

为什么需要 Ticket

我们可以反着想,如果没有Ticket,我们该用哪种方式获取Token或者说用户信息?你又该怎么证明你已经登录成功?用Cookie吗,明显是不行的。

所以说,Ticket是一个凭证,是当前用户登录成功后的产物。没了它,你证明不了你自己。

验证 Ticket 需要验证哪些内容

  1. 签名:对于这种中心化系统,为了安全,绝大数接口请求都会有着验签机制,也就是验证这个数据是否被篡改。至于验签的具体实现,五花八门都有。

  2. 真实性:验签成功后拿到Ticket,需要验证Ticket是否是真实存在的,不能说随便造一个我就给你返回Token吧。

  3. 使用次数:为了安全性,Ticket只能使用一次,否则就报错,因为Ticket很多情况下是拼接在URL上的,肉眼可见。

  4. 有效期:另外则是Ticket的时效,超过一定时间内,这个Ticket会过期。比如微信授权的Code只有5分钟的有效期。

  5. ......

为什么需要 Token?

只有通过Token我们才能从SSO系统中获取用户信息,但是为什么需要Token呢?我直接通过Ticket获取用户信息不行吗?

答案当然是不行的,首先为了保证安全性Ticket只能使用一次,另外Ticket具有时效性。但这与某些系统的业务存在一定冲突。因此通过使用Token增加有效时间,同时保证重复使用。

验证 Token 需要验证哪些内容?

和验证 Ticket类似

  1. 签名 2. 真实性 3. 有效期

如果没有 Token,我直接通过 Ticket 获取用户信息是否可行?

这个内容其实上面已经给出答案了,从实现上是可行的,从设计上不应该,因为TicketToken的职责不一样,Ticket 是登录成功的票据,Token是获取用户信息的票据。

用户登录系统B流程

老余:系统A登录成功后,那系统B的流程呢?
我:那就更简单了。

比如说此时用户想要访问系统B,http://www.chezhe2.com的限制接口,系统B服务端一般会在拦截器【也可以是过滤器,AOP 啥的】中根据Cookie中的SessionId判断用户是否已登录。此时在系统B中该系统肯定未登录,于是重定向到SSO系统的登录页面,并且带上自己的回调地址,便于用户在SSO系统登录成功后返回。回调地址是:http://www.sso.com?url=www.chezhe2.com。

我们知道之前SSO页面已经与SSO服务端建立了会话,并且因为CookieSSO这个域名下是共享的,所以此时SSO系统会判断当前用户已登录。然后就是之前的那一套逻辑了。 此时流程图如下所示:


技术以外的事

老余:不错不错,理解的还可以。你发现这套系统里,做的最多的是什么,有什么技术之外的感悟没。说到这,老余叹了口气。

我:我懂,做的最多的就是验证了,验证真实性、有效性、白名单这些。明明一件很简单的事,最后搞的那么复杂。像现在银行里取钱一样,各种条条框框的限制。我有时候会在想,技术发展、思想变革对于人类文明毋庸置疑是有益的,但是对于我们人类真的是一件好事吗?如果我们人类全是机器人那样的思维是不是会更好点?


老余:我就随便一提,你咋巴拉巴拉了这么多。我只清楚一点,拥有七情六欲的人总是好过没有情感的机器人的。好了,干活去吧。

总结

这一篇内容就到这了,我们聊了下关于单点登录的 CAS 设计思路,其实CAS 往大了讲还能讲很多,可惜我的技术储备还不够,以后有机会补充。如果想理解的更深刻,也可以去看下微信授权流程,应该会有帮助。

最后还顺便提了点技术之外的事,记得有句话叫做:科学的尽头是哲学,我好像开始慢慢理解这句话的意思了。

作者:车辙cz
来源:juejin.cn/post/7196924295310262328

收起阅读 »

一杯咖啡的时间☕️,搞懂 API 和 RESTful API!

☀️ 前言API和RESTful API 是每个程序员都应该了解并掌握的基本知识,我们在开发过程中设计 API 的时候也应该至少要满足一些最基本的要求。如果你还不了解什么是API或你没有了解RESTful API,你可以选择花5分钟时间看下去,我会最通俗易懂的...
继续阅读 »

☀️ 前言

  • APIRESTful API 是每个程序员都应该了解并掌握的基本知识,我们在开发过程中设计 API 的时候也应该至少要满足一些最基本的要求。

  • 如果你还不了解什么是API或你没有了解RESTful API,你可以选择花5分钟时间看下去,我会最通俗易懂的帮你普及这一切。

❓ 什么是 API

  • 举个简单的例子你就会明白:

    • 早在2000年我们还在用小灵通的时代,网上购票已经慢慢兴起,但是绝大部分人出行还是通过电话查询航班来去选择购票,我们首先需要打电话到附近的站台根据时间询问航班或车次,得到结果后再去到对应站台进行购票。


  • 随着时代的飞速发展和智能手机的普及,各种旅游App也映入眼帘,大家也学会了如何在App上进行购票

  • 这时候我们买票就没有以前那么麻烦了,在App输入你的起点终点后,会展现所有符合条件的车次,航班,不仅仅只有时间、座位,还有航空公司、预计时间等等等等详细信息一目了然,你只需要根据你的需求购买即可。


  • 连接是一件很棒的事情,在我们现在的生活中,我们可以很轻松的通过App进行购物、阅读、直播,我们以前所未有的方式和世界与人们相连接。

  • 那这些是怎么做到的?为什么一个App能够这么便利?这些资料为什么会可以从A到达B,为什么我们只需要动动手指就可以达到这一切?

  • 而这个桥梁,这个互联网世界的无名英雄就是APIAPI,全名 Application Programming Interface (应用程式界面),简单来说,是品牌开发的一种接口,让第三方可以额外开发、应用在自身的产品上的系统沟通界面。

  • 简单来说,你可以把它比喻成古人的鸽子,通过飞鸽传书来传达你的需求,而接收方再把回应通过鸽子传达给你。

  • 再说回上面举的例子。

    • 旧时代我们需要知道航班的信息,我们就需要一个信差,而这个电话员就是这个信差,也就是我们说的 API,他传达你的要求到系统,而站台就是这个系统,比如告诉它查询明天飞往广州的飞机,那么他就会得出结果,由电话员传递给你。

    • 而现在我们需要购买机票等,只需要通过购票系统选择日期,城市,舱位等,他会从不同的航空公司网站汇集资料,而汇集资料的手段就是通过API和航空公司互动。

  • 我们现在知道是API让我们使用这些旅游 App,那么这个道理也一样适用于生活中任何应用程序、资料和装置之间的互动,都有各自的API进行连接。

❓ 什么是 RESTful API

  • 在互联网并没有完全流行的初期,移动端也没有那么盛行,页面请求和并发量也不高,那时候人们对接口(API)的要求没那么高。

  • 当初的 web 应用程序主要是在服务器端实现的,因此需要使用复杂的协议来操作和传输数据。然而,随着移动端设备的普及,需要在移动端也能够访问 web 应用程序,而客户端和服务端就需要接口进行通信,但接口的规范性就又成了一个问题。


  • 所以一套简化开发、结构清晰、符合标准、易于理解、易于扩展让大部分人都能够理解接受的接口风格就显得越来越重要,而RESTful风格的接口(RESTful API)刚好有以上特点,就逐渐应运而生。

REST

  • REST,全名 Representational State Transfer(表现层状态转移),他是一种设计风格,一种软件架构风格,而不是标准,只是提供了一组设计原则和约束条件

RESTful

  • RESTful 只是转为形容詞,就像那么 RESTful API 就是满足 REST风格的,以此规范设计的 API

RESTful API

  • 我们常见的 API 一般都长这样子:


  • RESTful 风格的 API 却长这样子:


🔘 六大原则

  • Roy FieldingHTTP 协议的主要设计者之一,他在论文中阐述了 REST 架构的概念并给出了 REST 架构的六个限制条件,也就是六大原则

Uniform Interface(统一接口)
  • 就像我们上面两幅图看到的 API,这是最直观的特征,是 REST 架构的核心,统一的接口对于 RESTful 服务非常重要。客户端只需要关注实现接口就可以,接口的可读性加强,使用人员方便调用

  • RESTful API通过 URL定位资源,并通过

    HTTP方法操作该资源,对资源的操作包括获取、创建、修改和删除,这些操作正好对应 HTTP 协议提供的GETPOSTPUTDELETE方法。

    • GET:获取资源信息。

    • POST:创建一个新资源。

    • PUT:更新已有的资源。

    • DELETE:删除已有的资源。

  • 在一个完全遵循 RESTful 的团队里,后端只需要告诉前端 /users 这个 API,前端就应该知道:

    • 获取所有用户:GET /users

    • 获取特定用户:GET /users/{id}

    • 创建用户:POST /users

    • 更新用户:PUT /users/{id}

    • 删除用户:DELETE /users/{id}

  • API 数量非常多,系统非常复杂时,RESTful 的好处会越来越明显。理解系统时,可以直接围绕一系列资源来理解和记忆。

Client-Server(客户端和服务端分离)
  • 它意味着客户端和服务器是独立的、可以分离的

  • 客户端是负责请求和处理数据的组件,服务器是负责存储数据处理请求的组件。

  • 这两个组件之间通过一组约定来协作,以便客户端能够获取所需的数据。

Statelessness(无状态)
  • 它指的是每个请求都是独立的没有前后关系。服务器不保存客户端的状态信息,并且每个请求都必须包含所有所需的信息。

  • 这样做的好处是可以使每个请求变得简单容易理解处理,并且可以更容易地扩展和维护

  • 例如,假设你在登录一个网站,你需要在登录界面输入用户名和密码通过接口获取到了 token 。接下来的所有请求都需要携带上这个 token 而不是系统在第一次登录成功之后记录了你的状态。

Cacheability(可缓存)
  • 客户端和服务端可以协商缓存内容,通过设置 HTTP 状态码,服务器可以告诉客户端这个数据是否可以被缓存。

  • 例如,一个 HTTP 响应头中包含一个 Cache-Control 字段,用于告诉客户端该数据可以缓存多长时间。这样可以提高数据传输的效率,从而降低网络带宽的开销,加速数据的访问速度。

Layered System(分层)
  • 客户端不应该关心请求经过了多少中间层,只需要关心请求的结果。

  • 架构的系统可以分为多个层次,每一层独立完成自己的任务。这样的架构结构使得系统更容易维护,并且允许独立替换不同层次。

  • 例如,数据库存储层可以独立于其他层,在不影响其他层的情况下进行替换或扩展。

Code on Demand(可选的代码请求)
  • 它提倡服务器可以将客户端代码下载到客户端并执行。这样,客户端可以根据服务器发送的代码来扩展它的功能。

  • 这个限制可以使客户端代码变得更加灵活,并且可以通过服务器提供的代码来解决问题,而不必再等待下一个版本。

  • Code on Demand 是可选的,但它可以使 RESTful API 变得更加灵活和可扩展。

🔥 RESTful API 设计规范

  • 说了这么多的理论,那我们该如何去设计一个最简单 RESTful 风格的 API 呢?

HTTP 方法
  • HTTP 设计了很多动词,来标识不同的操作,不同的HTTP请求方法有各自的含义,就像上面所展示的,RESTful API 应该使用 HTTP 方法(如 GET、POST、PUTDELETE)来描述操作。

版本控制
URL 明确标识资源
  • API 应该使用简洁明了的 URL 来标识资源,并且在同一个资源上使用不同的 HTTP 方法来执行不同的操作。

  • 这样的设计使得客户端在无需任何额外信息的情况下就可以找到所需的资源。

  • 不规范的的 URL,形式千奇百怪,不同的开发者还需要了解文档才能调用。

  • 规范后的 RESTful 风格的 URL,形式固定,可读性强,根据名词和 HTTP 动词就可以操作这些资源。


  • 给大家一个小 tips,如果你遇到了实在想不到的 URL ,你可以参考github开放平台 ,这里面有很多很规范的 URL 设计。

HTTP 状态码
  • HTTP状态码是 RESTful API设计的重要一环,是表示 API请求的状态,用于告知客户端是否成功请求并处理数据。常用的 HTTP状态码有:

    • 200 OK:请求成功,表示获得了请求的数据

    • 201 Created:请求成功,表示创建了一个新的资源

    • 204 No Content:请求成功,表示操作成功,但没有返回数据

    • 400 Bad Request:请求失败,表示请求格式不正确或缺少必要参数

    • 401 Unauthorized:请求失败,表示认证失败或缺少授权

    • 403 Forbidden:请求失败,表示没有访问权限

    • 404 Not Found:请求失败,表示请求的资源不存在

    • 500 Internal Server Error:请求失败,表示服务器内部错误

统一返回数据格式
  • 常用的返回数据格式有 JSONXML

  • JSON 是现在比较流行的数据格式,它是简单、轻量、易于解析,并且它有很好的可读性。

  • XML 也是一种常用的数据格式,它的优点是比较灵活,并且支持各种数据类型。

合格美观的 API 文档
  • 项目开发离不开前后端分离,离不开 API,当然也就离不开 API 文档,但是文档的编写又成为程序员觉得麻烦事之一,甚至我还看到有公司的的 API 文档是用 Word 文档手敲的。

  • 市面上有很多可以管理 API 的软件,每个人都有自己的选择,我给大家推荐一款 API 管理神器 Apifox,可以一键生成 API 文档。

  • 不需要你过多的操作,只需要你在可视化的页面添加你的 API 即可生成,现在也支持了多种导航模式亮暗色模式顶部自定义 Icon 、文案可跳转到你的官网等地址


  • 对于独立开发者和团队来说都是一大利好福音,本文就不做过多介绍,感兴趣的可以去试试~

👋🏻 写在最后

  • 总的来说 RESTful 风格的 API 固然很好很规范,但大多数互联网公司并没有按照或者完全按照其规则来设计,因为 REST 是一种风格,而不是一种约束或规则,过于理想的 RESTful API 会付出太多的成本。

  • 如果您正在考虑使用 RESTful API,请确保它符合您的业务需求。例如,如果您的项目需要实现复杂的数据交互,您可能需要考虑使用其他 API 设计方法。

  • 因此,请确保在选择 API 设计方法时充分考虑您的业务需求。此外,您还需要确保 RESTful API 与您的系统架构和技术栈相兼容。通过这些考虑,您可以确保 RESTful API 的正确使用,并且可以实现更高效和可靠的 API

  • 长期来看,API 设计也不只是后端的工作,而是一个产品团队在产品设计上的协调工作,应该整个团队参与。

  • 这次简单分享了 APIRESTful API,在实际运用中,并不是一定要使用这种规范,但是有 RESTful 标准可以参考,是十分有必要的,希望对大家有帮助。

作者:快跑啊小卢_
来源:juejin.cn/post/7196570893152616506

收起阅读 »

我代码就加了一行log日志,结果引发了P1的线上事故

线上事故回顾前段时间新增一个特别简单的功能,晚上上线前review代码时想到公司拼搏进取的价值观临时加一行log日志,觉得就一行简单的日志基本上没啥问题,结果刚上完线后一堆报警,赶紧回滚了代码,找到问题删除了添加日志的代码,重新上线完毕。情景还原定义了一个 C...
继续阅读 »

线上事故回顾

前段时间新增一个特别简单的功能,晚上上线前review代码时想到公司拼搏进取的价值观临时加一行log日志,觉得就一行简单的日志基本上没啥问题,结果刚上完线后一堆报警,赶紧回滚了代码,找到问题删除了添加日志的代码,重新上线完毕。

情景还原

定义了一个 CountryDTO

public class CountryDTO {
   private String country;

   public void setCountry(String country) {
       this.country = country;
  }

   public String getCountry() {
       return this.country;
  }

   public Boolean isChinaName() {
       return this.country.equals("中国");
  }
}
复制代码

定义测试类 FastJonTest

public class FastJonTest {
   @Test
   public void testSerialize() {
       CountryDTO countryDTO = new CountryDTO();
       String str = JSON.toJSONString(countryDTO);
       System.out.println(str);
  }
}

运行时报空指针错误: 通过报错信息可以看出来是 序列化的过程中执行了 isChinaName()方法,这时候this.country变量为空, 那么问题来了:

  • 序列化为什么会执行isChinaName()呢?

  • 引申一下,序列化过程中会执行那些方法呢?

源码分析

通过debug观察调用链路的堆栈信息 调用链中的ASMSerializer_1_CountryDTO.writeFastJson使用asm技术动态生成了一个类ASMSerializer_1_CountryDTO,

asm技术其中一项使用场景就是通过到动态生成类用来代替java反射,从而避免重复执行时的反射开销

JavaBeanSerizlier序列化原理

通过下图看出序列化的过程中,主要是调用JavaBeanSerializer类的write()方法。 JavaBeanSerializer 主要是通过 getObjectWriter()方法获取,通过对getObjectWriter()执行过程的调试,找到比较关键的com.alibaba.fastjson.serializer.SerializeConfig#createJavaBeanSerializer方法,进而找到 com.alibaba.fastjson.util.TypeUtils#computeGetters

public static List<FieldInfo> computeGetters(Class<?> clazz, //
                                                JSONType jsonType, //
                                                Map<String,String> aliasMap, //
                                                Map<String,Field> fieldCacheMap, //
                                                boolean sorted, //
                                                PropertyNamingStrategy propertyNamingStrategy //
  ){
       //省略部分代码....
       Method[] methods = clazz.getMethods();
       for(Method method : methods){
           //省略部分代码...
           if(method.getReturnType().equals(Void.TYPE)){
               continue;
          }
           if(method.getParameterTypes().length != 0){
               continue;
          }
      //省略部分代码...
           JSONField annotation = TypeUtils.getAnnotation(method, JSONField.class);
           //省略部分代码...
           if(annotation != null){
               if(!annotation.serialize()){
                   continue;
              }
               if(annotation.name().length() != 0){
                   //省略部分代码...
              }
          }
           if(methodName.startsWith("get")){
            //省略部分代码...
          }
           if(methodName.startsWith("is")){
            //省略部分代码...
          }
      }
}

从代码中大致分为三种情况:

  • @JSONField(.serialize = false, name = "xxx")注解

  • getXxx() : get开头的方法

  • isXxx():is开头的方法

序列化流程图


示例代码

/**
* case1: @JSONField(serialize = false)
* case2: getXxx()返回值为void
* case3: isXxx()返回值不等于布尔类型
* case4: @JSONType(ignores = "xxx")
*/
@JSONType(ignores = "otherName")
public class CountryDTO {
   private String country;

   public void setCountry(String country) {
       this.country = country;
  }

   public String getCountry() {
       return this.country;
  }

   public static void queryCountryList() {
       System.out.println("queryCountryList()执行!!");
  }

   public Boolean isChinaName() {
       System.out.println("isChinaName()执行!!");
       return true;
  }

   public String getEnglishName() {
       System.out.println("getEnglishName()执行!!");
       return "lucy";
  }

   public String getOtherName() {
       System.out.println("getOtherName()执行!!");
       return "lucy";
  }

   /**
    * case1: @JSONField(serialize = false)
    */
   @JSONField(serialize = false)
   public String getEnglishName2() {
       System.out.println("getEnglishName2()执行!!");
       return "lucy";
  }

   /**
    * case2: getXxx()返回值为void
    */
   public void getEnglishName3() {
       System.out.println("getEnglishName3()执行!!");
  }

   /**
    * case3: isXxx()返回值不等于布尔类型
    */
   public String isChinaName2() {
       System.out.println("isChinaName2()执行!!");
       return "isChinaName2";
  }
}

运行结果为:

isChinaName()执行!!
getEnglishName()执行!!
{"chinaName":true,"englishName":"lucy"}

代码规范

可以看出来序列化的规则还是很多的,比如有时需要关注返回值,有时需要关注参数个数,有时需要关注@JSONType注解,有时需要关注@JSONField注解;当一个事物的判别方式有多种的时候,由于团队人员掌握知识点的程度不一样,这个方差很容易导致代码问题,所以尽量有一种推荐方案。 这里推荐使用@JSONField(serialize = false)来显式的标注方法不参与序列化,下面是使用推荐方案后的代码,是不是一眼就能看出来哪些方法不需要参与序列化了。

public class CountryDTO {
   private String country;

   public void setCountry(String country) {
       this.country = country;
  }

   public String getCountry() {
       return this.country;
  }

   @JSONField(serialize = false)
   public static void queryCountryList() {
       System.out.println("queryCountryList()执行!!");
  }

   public Boolean isChinaName() {
       System.out.println("isChinaName()执行!!");
       return true;
  }

   public String getEnglishName() {
       System.out.println("getEnglishName()执行!!");
       return "lucy";
  }

   @JSONField(serialize = false)
   public String getOtherName() {
       System.out.println("getOtherName()执行!!");
       return "lucy";
  }

   @JSONField(serialize = false)
   public String getEnglishName2() {
       System.out.println("getEnglishName2()执行!!");
       return "lucy";
  }

   @JSONField(serialize = false)
   public void getEnglishName3() {
       System.out.println("getEnglishName3()执行!!");
  }

   @JSONField(serialize = false)
   public String isChinaName2() {
       System.out.println("isChinaName2()执行!!");
       return "isChinaName2";
  }
}

三个频率高的序列化的情况

以上流程基本遵循 发现问题 --> 原理分析 --> 解决问题 --> 升华(编程规范)。

  • 围绕业务上:解决问题 -> 如何选择一种好的额解决方案 -> 好的解决方式如何扩展n个系统应用;

  • 围绕技术上:解决单个问题,顺着单个问题掌握这条线上的原理。

作者:老鹰汤
来源:juejin.cn/post/7156439842958606349

收起阅读 »

一张码如何实现多渠道(微信、支付宝、云闪付...)收款

大家好,我是小悟今天是正月初五,天气超级好,也是迎财神的日子,祝大家顺风顺水,财源滚滚,钱兔似锦。既然要发财,那自然少不了收款咯。如果你是一个商家,肯定是想收款的方式越方便越好,但支付渠道有那么多种,也就意味着顾客的支付选择也是多种。那总不能把所有的渠道收款码...
继续阅读 »


大家好,我是小悟

今天是正月初五,天气超级好,也是迎财神的日子,祝大家顺风顺水,财源滚滚,钱兔似锦。


既然要发财,那自然少不了收款咯。如果你是一个商家,肯定是想收款的方式越方便越好,但支付渠道有那么多种,也就意味着顾客的支付选择也是多种。

那总不能把所有的渠道收款码都贴上吧,那会非常的乱,对顾客来说也极其不方便,一个码能解决的事情,就不要搞复杂化了。那这个是怎么实现的呢?


要实现一码多渠道收款其实也不难,毋庸置疑,现在主流的支付方式就是微信和支付宝,而在微信和支付宝申请的商户相同点是都支持余额、银行卡和信用卡支付,不同点是微信支持云闪付支付,支付宝支持花呗支付。所以只要对接了微信和支付宝,那基本上就够用了。

值得一提的是,随着微信支付生态的发展,现在想实现这样的功能是越来越方便了。借助微信扫普通链接二维码打开小程序的功能,无需判断前端是微信还是支付宝或者其他APP扫码,可以减少很多工作量。


所以重点来了,我们都知道,微信和支付宝根据前端不同而有多种支付方式,比如APP支付,H5支付,小程序支付等。

为了实现更全和更简单的功能,支付宝需要对接H5支付,而微信需要对接的却是小程序支付。说到这里你可能就有疑问了,为啥不都是H5支付或都是小程序支付?


首先对接支付宝H5支付的话,当你使用其他APP比如抖音、快手打开的时候也可以跳转到支付宝完成支付,一劳永逸。再者因为微信小程序支付支持云闪付支付,所以微信对接的是小程序支付。

说到这里不知道你已经想到实现思路了吗?是的,前端需要开发一个简单的页面,但是再简单,起码可以输入金额吧。然后简单做下金额正则校验,因为涉及到H5和小程序,所以可以使用uniapp编写前端页面部署更方便,也就是说支付宝部署的是H5,微信部署的是小程序。

我写的demo是搞了两个,不要学我哦,怎么方便怎么来,左边是支付宝H5扫出来的样子,右边是微信小程序扫出来的样子。


支付做多了服务端其实也不复杂,注意,支付宝对接的是H5支付,微信对接的是小程序支付,简单贴一下代码。需要写回调的话也一定不要忘了哦。

支付宝H5支付

public AjaxResult aliPayH5(PayModel payModel) {
  payModel.setBody("支付宝H5支付").setSubject("支付宝H5支付");
  String outTradeNo = IdUtil.getSnowflake(1,1).nextIdStr();
  payModel.setOutTradeNo(outTradeNo).setPassbackParams(outTradeNo);
  String form = aliPayService.aliPayH5(payModel);
if(StringUtils.isNotBlank(form)) {
      Map<String, Object> result = new HashMap<>(2);
      result.put("form", form);
return AjaxResult.success(result);
  }
return AjaxResult.error("数据准备异常");
}

微信小程序支付

public AjaxResult jsapiMaPayCommon(JsapiOrderParam param, HttpServletRequest request) {
  String openId = param.getOpenId();
  String remoteAddr = IpUtils.getIpAddr(request);
  String outTradeNo = IdUtil.getSnowflake(1,1).nextIdStr();
  BigDecimal decimal100 = new BigDecimal("100");
  BigDecimal orderAmount = new BigDecimal(String.valueOf(param.getAmount()));
  JsapiParam jsapiParam = new JsapiParam();
  jsapiParam.setAppid(wechatProperties.getMaAppId())
          .setMchid(wechatProperties.getMchId())
          .setDescription("微信小程序支付")
          .setOut_trade_no(outTradeNo)
          .setAttach(outTradeNo)
          .setNotify_url(wechatProperties.getNotifyUrlCommon());
  Amount amount = new Amount();
  amount.setTotal(decimal100.multiply(orderAmount).intValue());
  jsapiParam.setAmount(amount);
  Payer payer = new Payer();
  payer.setOpenid(openId);
  jsapiParam.setPayer(payer);
  SceneInfo sceneInfo = new SceneInfo();
  sceneInfo.setDevice_id("POS1:12");
  sceneInfo.setPayer_client_ip(remoteAddr);
  jsapiParam.setScene_info(sceneInfo);
  BaseParam baseParam = new BaseParam();
  baseParam.setAppName(wechatProperties.getAppName())
          .setMchId(wechatProperties.getMchId())
          .setMchSerialNo(wechatProperties.getMchSerialNo())
          .setWechatSerialNo(wechatProperties.getWechatSerialNo())
          .setMchPrivateKeyPath(wechatProperties.getMchPrivateKeyPath())
          .setWechatPubKeyPath(wechatProperties.getWechatPubKeyPath());
  JSONObject result = wechatService.jsapiPay(jsapiParam, baseParam);
  int status = result.getInteger("requestStatus");
if (status == 200) {
      SortedMap<Object, Object> params = new TreeMap<>();
      String timestamp = Long.toString(System.currentTimeMillis() / 1000);
      String nonceStr = UuidUtils.randomUUID();
      String packageParam = "prepay_id=" + result.getString("prepay_id");
      String paySign = SignUtils.paySign(wechatProperties.getMaAppId(), timestamp, nonceStr, packageParam,
              wechatProperties.getMchPrivateKeyPath());
      params.put("appId", wechatProperties.getMaAppId());
      params.put("timeStamp", timestamp);
      params.put("paySign", paySign);
      params.put("signType", "RSA");
      params.put("nonceStr", nonceStr);
      params.put("package", "prepay_id=" + result.getString("prepay_id"));
      logger.info("params:{}",params);
return AjaxResult.success(params);
  } else {
return AjaxResult.error(result.getString("message"), result);
  }
}

部署起来后,支付宝基本就这样了,能支付就行,微信还需要配置一些东西。首先,微信商户号后台,支付方式配置,云闪付需要开启状态。


其次,小程序后台,需要配置扫普通链接二维码打开小程序,将部署的支付宝H5支付链接地址映射到微信小程序的支付页面,测试范围选择线上版,全网发布即可。这样,当使用微信扫描该二维码地址时,就会自动跳转到微信小程序支付页面。


然后使用草料二维码生成器将H5地址塞到二维码里面,就大功告成了,以后使用微信或支付宝,或者其他APP扫码就可以完成支付了。支持微信、云闪付、支付宝、花呗、银行卡、信用卡支付。打完收工。


一码在手,生意你有。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

作者:悟空码字
来源:juejin.cn/post/7192983769618317370

收起阅读 »

微博图床挂了!

一直担心的事情还是发生了。作为hexo多年的使用者,微博图床一直是我的默认选项,hexo+typora+iPic更是我这几年写文章的黄金组合。而图床中,新浪图床一直都是我的默认选项,速度快、稳定同时支持大图片批量上传更是让其成为了众多图床工具的默认选项。虽然今...
继续阅读 »

一直担心的事情还是发生了。

作为hexo多年的使用者,微博图床一直是我的默认选项,hexo+typora+iPic更是我这几年写文章的黄金组合。而图床中,新浪图床一直都是我的默认选项,速度快、稳定同时支持大图片批量上传更是让其成为了众多图床工具的默认选项。虽然今年早些的时候,部分如「ws1、ws2……」的域名就已经无法使用了,但通过某些手段还是可以让其存活的,而最近,所有调用的微博图床图片都无法加载并提示“403 Forbidden”了。


💡Tips:图片中出现的Tengine是淘宝在Nginx的基础上修改后开源的一款Web服务器,基本上,Tengine可以被看作一个更好的Nginx,或者是Nginx的超集,详情可参考👉淘宝Web服务器Tengine正式开源 - The Tengine Web Server

刚得知这个消息的时候,我的第一想法其实是非常生气的,毕竟自己这几年上千张图片都是用的微博图床,如今还没备份就被403了,可仔细一想,说到底还是把东西交在别人手里的下场,微博又不是慈善企业,也要控制成本,一直睁一只眼闭一只眼让大家免费用就算了,出了问题还是不太好怪到微博上来的。

那么有什么比较好的办法解决这个问题呢?

查遍了网上一堆复制/粘贴出来的文章,不是开启反向代理就是更改请求头,真正愿意从根本上解决问题的没几个。

如果不想将自己沉淀的博客、文章托管在印象笔记、notion、语雀这些在线平台的话,想要彻底解决这个问题最好的方式是:自建图床!

为了更好的解决问题,我们先弄明白,403是什么,以及我们存在微博上的图片究竟是如何被403的。

403

百度百科,对于403错误的解释很简单

403错误是一种在网站访问过程中,常见的错误提示,表示资源不可用。服务器理解客户的请求,但拒绝处理它,通常由于服务器上文件或目录的权限设置导致的WEB访问错误。

所以说到底是因为访问者无权访问服务器端所提供的资源。而微博图床出现403的原因主要在于微博开启了防盗链。

防盗链的原理很简单,站点在得知有请求时,会先判断请求头中的信息,如果请求头中有Referer信息,然后根据自己的规则来判断Referer头信息是否符合要求,Referer 信息是请求该图片的来源地址。

如果盗用网站是 https 的 协议,而图片链接是 http 的话,则从 https 向 http 发起的请求会因为安全性的规定,而不带 referer,从而实现防盗链的绕过。官方输出图片的时候,判断了来源(Referer),就是从哪个网站访问这个图片,如果是你的网站去加载这个图片,那么 Referer 就是你的网站地址;你的网址肯定没在官方的白名单内,(当然作为可操作性极强的浏览器来说 referer 是完全可以伪造一个官方的 URL 这样也也就也可以饶过限制🚫)所以就看不到图片了。


解决问题

解释完原理之后我们发现,其实只要想办法在自己的个人站点中设置好referer就可以解决这个问题,但说到底也只是治标不治本,真正解决这个问题就是想办法将图片迁移到自己的个人图床上。

现在的图床工具很多,iPic、uPic、PicGo等一堆工具既免费又开源,问题在于选择什么云存储服务作为自己的图床以及如何替换自己这上千张图片。

  1. 选择什么云存储服务

  2. 如何替换上千张图片

什么是OSS以及如何选择

「OSS」的英文全称是Object Storage Service,翻译成中文就是「对象存储服务」,官方一点解释就是对象存储是一种使用HTTP API存储和检索非结构化数据和元数据对象的工具。

白话文解释就是将系统所要用的文件上传到云硬盘上,该云硬盘提供了文件下载、上传等一列服务,这样的服务以及技术可以统称为OSS,业内提供OSS服务的厂商很多,知名常用且成规模的有阿里云、腾讯云、百度云、七牛云、又拍云等。

对于我们这些个人用户来说,这些云厂商提供的服务都是足够使用的,我们所要关心的便是成本💰。

笔者使用的是七牛云,它提供了10G的免费存储,基本已经够用了。

有人会考虑将GitHub/Gitee作为图床,并且这样的文章在中文互联网里广泛流传,因为很多人的个人站点都是托管在GitHub Pages上的,但是个人建议是不要这么做。

首先GitHub在国内的访问就很受限,很多场景都需要科学上网才能获得完整的浏览体验。再加上GitHub官方也不推荐将Git仓库存储大文件,GitHub建议仓库保持较小,理想情况下小于 1 GB,强烈建议小于 5 GB。

如何替换上千张图片

替换文章中的图片链接和“把大象放进冰箱里”步骤是差不多的

  1. 下载所有的微博图床的图片

  2. 上传所有的图片到自己的图床(xx云)

  3. 对文本文件执行replaceAll操作

考虑到我们需要迁移的文件数量较多,手动操作肯定是不太可行的,因此我们可以采用代码的方式写一个脚本完成上述操作。考虑到自己已经是一个成熟的Java工程师了,这个功能就干脆用Java写了。

为了减少代码量,精简代码结构,我这里引入了几个第三方库,当然不引入也行,如果不引入有一些繁琐而又简单的业务逻辑需要自己实现,有点浪费时间了。

整个脚本逻辑非常简单,流程如下:


获取博客文件夹下的Markdown文件

这里我们直接使用hutool这个三方库,它内置了很多非常实用的工具类,获取所有markdown文件也变得非常容易

/**
* 筛选出所有的markdown文件
*/
public static List<File> listAllMDFile() {
   List<File> files = FileUtil.loopFiles(VAULT_PATH);
   return files.stream()
    .filter(Objects::nonNull)
      .filter(File::isFile)
      .filter(file -> StringUtils.endsWith(file.getName(), ".md"))
      .collect(Collectors.toList());
}

获取文件中的所有包含微博图床的域名

通过Hutools内置的FileReader我们可以直接读取markdown文件的内容,因此我们只需要解析出文章里包含微博图床的链接即可。我们可以借助正则表达式快速获取一段文本内容里的所有url,然后做一下filter即可。

/**
* 获取一段文本内容里的所有url
*
* @param content 文本内容
* @return 所有的url
*/
public static List<String> getAllUrlsFromContent(String content) {
   List<String> urls = new ArrayList<>();
   Pattern pattern = Pattern.compile(
       "\\b(((ht|f)tp(s?)\\:\\/\\/|~\\/|\\/)|www.)" + "(\\w+:\\w+@)?(([-\\w]+\\.)+(com|org|net|gov"
           + "|mil|biz|info|mobi|name|aero|jobs|museum" + "|travel|[a-z]{2}))(:[\\d]{1,5})?"
           + "(((\\/([-\\w~!$+|.,=]|%[a-f\\d]{2})+)+|\\/)+|\\?|#)?" + "((\\?([-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
           + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)" + "(&(?:[-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
           + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)*)*" + "(#([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)?\\b");
   Matcher matcher = pattern.matcher(content);
   while (matcher.find()) {
       urls.add(matcher.group());
  }
   return urls;
}

下载图片

用Java下载文件的代码在互联网上属实是重复率最高的一批检索内容了,这里就直接贴出代码了。

public static void download(String urlString, String fileName) throws IOException {
   File file = new File(fileName);
   if (file.exists()) {
       return;
  }
   URL url = null;
   OutputStream os = null;
   InputStream is = null;
   try {
       url = new URL(urlString);
       URLConnection con = url.openConnection();
       // 输入流
       is = con.getInputStream();
       // 1K的数据缓冲
       byte[] bs = new byte[1024];
       // 读取到的数据长度
       int len;
       // 输出的文件流
       os = Files.newOutputStream(Paths.get(fileName));
       // 开始读取
       while ((len = is.read(bs)) != -1) {
           os.write(bs, 0, len);
      }
  } finally {
       if (os != null) {
           os.close();
      }
       if (is != null) {
           is.close();
      }
  }
}

上传图片

下载完图片后我们便要着手将下载下来的图片上传至我们自己的云存储服务了,这里直接给出七牛云上传图片的文档链接了,文档里写的非常详细,我就不赘述了👇

Java SDK_SDK 下载_对象存储 - 七牛开发者中心

全局处理

通过阅读代码的细节,我们可以发现,我们的方法粒度是单文件的,但事实上,我们可以先将所有的文件遍历一遍,统一进行图片的下载、上传与替换,这样可以节约点时间。

统一替换的逻辑也很简单,我们申明一个全局Map,

private static final Map<String, String> URL_MAP = Maps.newHashMap();

其中,key是旧的新浪图床的链接,value是新的自定义图床的链接。

我们将listAllMDFile这一步中所获取到的所有文件里的所有链接保存于此,下载时只需遍历这个Map的key即可获取到需要下载的图片链接。然后将上传后得到的新链接作为value存在到该Map中即可。

全文替换链接并更新文件

有了上述这些处理步骤,接下来一步就变的异常简单,只需要遍历每个文件,将匹配到全局Map中key的链接替换成Map中的value即可。

/**
* 替换所有的图片链接
*/
private static String replaceUrl(String content, Map<String, String> urlMap) {
   for (Map.Entry<String, String> entry : urlMap.entrySet()) {
       String oldUrl = entry.getKey();
       String newUrl = entry.getValue();
       if (StringUtils.isBlank(newUrl)) {
           continue;
      }
content = RegExUtils.replaceAll(content, oldUrl, newUrl);
  }
   return content;
}

我们借助commons-lang实现字符串匹配替换,借助Hutools实现文件的读取和写入。

files.forEach(file -> {
   try {
       FileReader fileReader = new FileReader(file.getPath());
       String content = fileReader.readString();
       String replaceContent = replaceUrl(content, URL_MAP);
       FileWriter writer = new FileWriter(file.getPath());
       writer.write(replaceContent);
  } catch (Throwable e) {
       log.error("write file error, errorMsg:{}", e.getMessage());
  }
});

为了安全起见,最好把文件放在新的目录中,不要直接替换掉原来的文件,否则程序出现意外就麻烦了。

接下来我们只需要运行程序,静待备份结果跑完即可。

以上就是本文的全部内容了,希望对你有所帮助

作者:插猹的闰土
来源:juejin.cn/post/7189651446306963514

收起阅读 »

如何打造一个优雅的git工作流

Git
在开发中,不论是一个团队一起开发一个项目,还是自己独立开发一个项目。都少不了要和git打交道。面对不同的开发场景,或许每个团队都有自己的git工作流。这里,我想分享一下我的团队目前正在使用的基于gitlab的git工作流。一起交流一下。规范化的git流程能降低...
继续阅读 »

在开发中,不论是一个团队一起开发一个项目,还是自己独立开发一个项目。都少不了要和git打交道。面对不同的开发场景,或许每个团队都有自己的git工作流。这里,我想分享一下我的团队目前正在使用的基于gitlabgit工作流。一起交流一下。

规范化的git流程能降低我们的出错概率,也不会经常遇到git问题,然后去搜一堆git高阶用法。我们的这套git玩法儿,其实只要会基本的git操作就行了,然后规范化操作,基本不会遇到git问题,这样大家就可以将时间用于业务上。最终,希望大家研究git的时候是在感兴趣的时候,而不是遇到问题,紧急去寻找答案的时候

我们的这种git工作流玩儿法呢,主要是分为下面几个分支:

  • master分支 最新的稳定代码

  • vx.x.x分支 版本分支,x.x.x是此次开发的版本号。

  • feat-xxx分支 特性(新的功能)分支

  • fix-xxx分支 修复分支

上面的这些分支呢,就是我们在开发中需要经常去创建并使用的分支。下面详细说说每个分支代表的意思。

master分支代表的是最新的稳定版本的代码,一般是版本分支或者修复分支的代码上线后合并过来的。

feat-xxx分支表示的是为开发某个版本的某个新功能而创建的分支。

vx.x.x代表的是版本分支,这个是我们在每个版本开始前,以此次版本号为名从master创建的分支,比如版本号是 2.0.1,那么版本分支则为 v2.0.1。然后等到该版本的各个新功能在feat-xxx开发完成并冒烟测试通过后,就到gitlab上提一个mr合并到该版本分支上。等到各个环境测试通过后,就将版本分支的代码合并到master上,然后就可以删除本次的版本分支了。

fix-xxx表示的是修复分支,通常在处理线上问题时,创建一个以缺陷名称命名的分支,在缺陷测试通过后,通过mr合并到master分支去

注意:这里有个细节是,在特性分支上开发提交的commit信息,一般认为是无用信息,会在合并给版本分支的时候给合并到一个commit(由于我们是使用gitlab来合并,所以在发起mr请求时勾选squash选项就好了),而在提测后不论是修复测试过程中bug,或者是优化功能的commit则会全部保留,这个目的是一个警示,因为我希望最好的情况是提测即上线,虽然达到这个目标有难度,但是这些留下的commit信息可以帮助我们复盘

各个分支的作用如上面所描述的那样,接着聊聊我们开发的一些经典场景该怎么做:

第一个场景:正常开发迭代

我们以本次需要开发一个 1.0.0版本为例,这个其中有两个功能模块,一个是需要添加一个按钮,一个是需要添加一个表格

masterv1.0.0feat-add-buttonfeat-add-form从master切出 v1.0.0从master切出 feat-add-button从master切出 feat-add-button开发完成开发完成在gitlab发起mr到v1.0.0,并合并所有commit在gitlab发起mr到v1.0.0,并合并所有commit提测修复测试bug将修复的 commit cherry pick到 v1.0.0在gitlab上mr到master,并将合并信息改成 v1.0.0masterv1.0.0feat-add-buttonfeat-add-form

通过上面的时序图,可以看到,我们以我们即将开始的版本命名了一个版本分支 v1.0.0,并且也根据这个版本下面的两个功能创建了两个特性分支 feat-add-buttonfeat-add-form,然后等功能开发完成后再通过gitlab发起mr(注意,这里要把合并commit选项勾选上)合并到 v1.0.0,那么 v1.0.0分支的代码就会从dev环境开始流转,直到生产环境。这其中,如果有需要修复或者优化的地方,也是先修改特性分支,然后再cherry pick到版本分支上面。上线以后删除版本分支以及下面的特性分支。

通过这个流程管理的代码版本非常清晰,这是截取的master的一部分片段


在正常迭代流程还有个场景。那就是在开发过程中,pm突然过来说,因为某种不可抗力,有一个功能需要砍掉。这个时候,如果是代码还没提测,亦或者是功能比较简单,处理起来还不算麻烦。但如果是,你的功能和其他同事的代码已经在测试了,并且也已经修复了一些bug,commit都交叉在一起,特别是那种涉及修改文件还多的需求,这个时候处理起来就很麻烦,不仅要看着别人的代码,还得警惕自己的代码别弄错了。那这个时候,在我们流程里就很简单,直接删除现有的版本分支就好了,再重新将需要上线的特性分支组合在一起就可以了。可以看到,版本分支是由特性分支组合起来的,也就是说,版本分支可以由不同的特性分支随意组合。这样处理起来就比较方便

第二个场景 线上bug修复

我们以线上需要修复一个按钮的点击事件为例

masterfix-button-click从master切出 fix-button-click修复问题并测试从gitlab发起mr合并到mastermasterfix-button-click

其实这里的流程跟上面没多大的区别,但是这里需要注意的是,线上问题修复,一个bug一个commit,合并到master的时候不合并commit。而且需要将合并信息修改为本次的版本号。比如本次则为 v1.0.1

第三个场景 多版本并行开发

这个场景跟正常迭代场景并没啥区别,只是取决于你有多个版本,就创建对应的版本分支就可以了。每个版本分支按照正常迭代流程就可以了。

Q&A

Q:为什么没有使用dev、test等对应环境的分支,这样也好实现push既部署

A:我们这个流程是放弃了使用这些固定的分支的。有几个原因,

  1. 代码提测后从dev到test,甚至再到uat(预发布)环境,如果在不同的环境都有代码的变动,那么为了保持这些分支代码一致的话,就需要将代码同步到各个环境分支,这点儿有些费事儿。而版本分支不存在这个问题,版本分支只有一个,可以对应到各个环境。

  2. 方便多版本并行开发。版本分支可以创建多个,并行开发的时候比较方便部署到不同的测试环境。如果版本之间的模块关联性不大,还可以并行测试。

  3. 语义化。版本分支可以通过分支名称就知道目前有哪些分支正在开发中。

Q: master分支有变动怎么处理

A: master分支有变动的话,及时的合并到自己的功能分支上,以防和其他成员代码有冲突

写在最后

以上就是我的分享了,橘生淮南,适合我的未必适合大家,互相交流罢了

作者:雲天
来源:juejin.cn/post/7186946414620966967

收起阅读 »

大白话DDD(DDD黑话终结者)

一、吐槽的话相信听过DDD的人有很大一部分都不知道这玩意具体是干嘛的,甚至觉得它有那么一些虚无缥缈。原因之一是但凡讲DDD的,都是一堆特别高大上的概念,然后冠之以一堆让人看不懂的解释,。作者曾经在极客时间上买了本DDD实战的电子书,被那些概念一路从头灌到尾,灌...
继续阅读 »

一、吐槽的话

相信听过DDD的人有很大一部分都不知道这玩意具体是干嘛的,甚至觉得它有那么一些虚无缥缈。原因之一是但凡讲DDD的,都是一堆特别高大上的概念,然后冠之以一堆让人看不懂的解释,。作者曾经在极客时间上买了本DDD实战的电子书,被那些概念一路从头灌到尾,灌得作者头昏脑涨,一本电子书那么多文章愣是没有一点点像样的案例,看到最后也 没明白那本电子书的作者究竟想写啥。原因之二是DDD经常出现在互联网黑话中,如果不能稍微了解一下DDD中的名词,我们一般的程序员甚至都不配和那些说这些黑话的人一起共事。

为了帮助大家更好的理解这种虚无缥缈的概念,也为了更好的减少大家在新词频出的IT行业工作的痛苦,作者尝试用人话来解释下DDD,并且最后会举DDD在不同层面上使用的例子,来帮助大家彻底理解这个所谓的“高大上”的概念。

二、核心概念

核心的概念还是必须列的,否则你都不知道DDD的名词有多么恶心,但我会用让你能听懂的话来解释。

1、领域/子域/核心域/支撑域/通用域

领域

DDD中最重要的一个概念,也是黑话中说的最多的,领域指的是特定的业务问题领域,是专门用来确定业务的边界。

子域

有时候一个业务领域可能比较复杂,因此会被分为多个子域,子域分为了如下几种:

  • 核心子域:业务成功的核心竞争力。用人话来说,就是领域中最重要的子域,如果没有它其他的都不成立,比如用户服务这个领域中的用户子域

  • 通用子域:不是核心,但被整个业务系统所使用。在领域这个层面中,这里指的是通用能力,比如通用工具,通用的数据字典、枚举这类(感叹DDD简直恨不得无孔不入)。在整个业务系统这个更高层面上,也会有通用域的存在,指的通用的服务(用户服务、权限服务这类公共服务可以作为通用域)。

  • 支撑子域:不是核心,不被整个系统使用,完成业务的必要能力。

2、通用语言/限界上下文

通用语言

指的是一个领域内,同一个名词必须是同一个意思,即统一交流的术语。比如我们在搞用户中心的时候,用户统一指的就是系统用户,而不能用其他名词来表达,目的是提高沟通的效率以及增加设计的可读性

限界上下文

限界上下文指的是领域的边界,通常来说,在比较高的业务层面上,一个限界上下文之内即一个领域。这里用一张不太好看的图来解释:


3、事件风暴/头脑风暴/领域事件

事件风暴

指的是领域内的业务事件,比如用户中心中,新增用户,授权,用户修改密码等业务事件。

头脑风暴

用最俗的人话解释,就是一堆人坐在一个小会议室中开会,去梳理业务系统都有哪些业务事件。

领域事件

领域内,子域和子域之间交互的事件,如用户服务中用户和角色交互是为用户分配角色,或者是为角色批量绑定用户,这里的领域事件有两个,一个是“为用户分配角色”,另一个是“为角色批量绑定用户”。

4、实体/值对象

实体

这里可以理解为有着唯一标识符的东西,比如用户实体。

值对象

实体的具体化,比如用户实体中的张三和李四。

实体和值对象可以简单的理解成java中类和对象,只不过这里通常需要对应数据实体。

5、聚合/聚合根

聚合

实体和实体之间需要共同协作来让业务运转,比如我们的授权就是给用户分配一个角色,这里涉及到了用户和角色两个实体,这个聚合即是用户和角色的关系。

聚合根

聚合根是聚合的管理者,即一个聚合中必定是有个聚合根的,通常它也是对外的接口。比如说,在给用户分配角色这个事件中涉及两个实体分别是用户和角色,这时候用户就是聚合根。而当这个业务变成给角色批量绑定用户的时候,聚合根就变成了角色。即使没有这样一个名词,我们也会有这样一个标准,让业务按照既定规则来运行,举个上文中的例子,给用户A绑定角色1,用户为聚合根,这样往后去查看用户拥有的角色,也是以用户的唯一标识来查,即访问聚合必须通过聚合根来访问,这个也就是聚合根的作用。

三、用途及案例

目前DDD的应用主要是在战略阶段和战术阶段,这两个名词也是非常的不讲人话,所谓的战略阶段,其实就是前期去规划业务如何拆分服务,服务之间如何交互。战术阶段,就是工程上的应用,用工程化做的比较好的java语言举例子,就是把传统的三层架构变成了四层架构甚至是N层架构而已。

1、微服务的服务领域划分

这是对于DDD在战略阶段做的事情:假如目前我司有个客服系统,内部的客服人员使用这个系统对外上亿的用户提供了形形色色的服务,同时内部人员觉得我们的客服系统也非常好用,老板觉得我们的系统做的非常好,可以拿出去对外售卖以提高公司的利润,那么这时候问题就来了,客服系统需要怎样去改造,才能够支持对外售卖呢?经过激烈的讨论,大致需求如下:

  • 对外售卖的形式有两种,分别是SaaS模式和私有化部署的模式。

  • SaaS模式需要新开发较为复杂的基础设施来支持,比如租户管理,用户管理,基于用户购买的权限系统,能够根据购买情况来给予不同租户不同的权限。而私有化的时候,由于客户是打包购买,这时候权限系统就不需要再根据用户购买来判断。

  • 数据同步能力,很多公司原本已经有一套员工管理系统,通常是HR系统或者是ERP,这时候客服系统也有一套员工管理,需要把公司人员一个一个录入进去,非常麻烦,因此需要和公司原有的数据来进行同步。

  • 老板的野心还比较大,希望造出来的这套基础设施可以为公司其他业务系统赋能,能支持其他业务系统对外售卖

在经过比较细致的梳理(DDD管这个叫事件风暴/头脑风暴)之后,我们整理出了主要的业务事件,大致如下:

1、用户可以自行注册租户,也可以由运营在后台为用户开通租户,每个租户内默认有一个超级管理员,租户开通之后默认有系统一个月的试用期,试用期超级管理员即可在管理端进行用户管理,添加子用户,分配一些基本权限,同时子用户可以使用系统的一些基本功能。

2、高级的功能,比如客服中的机器人功能是属于要花钱买的,试用期不具备此权限,用户必须出钱购买。每次购买之后会生成购买订单,订单对应的商品即为高级功能包。

3、权限系统需要能够根据租户购买的功能以及用户拥有的角色来鉴权,如果是私有化,由于客户此时购买的是完整系统,所以此时权限系统仅仅根据用户角色来鉴权即可。

4、基础设施还需要对其他业务系统赋能。

根据上面的业务流程,我们梳理出了下图中的实体


最后再根据实体和实体之间的交互,划分出了用户中心服务以及计费服务,这两个服务是两个通用能力服务,然后又划分出了基于通用服务的业务层,分别是租户管理端和运营后台以及提供给业务接入的应用中心,架构图如下:


基础设施层即为我们要做的东西,为业务应用层提供通用的用户权限能力、以及售卖的能力,同时构建开发者中心、租户控制台以及运营后台三个基础设施应用。

2、工程层面

这个是对于DDD在战术设计阶段的运用,以java项目来举例子,现在的搞微服务的,都是把工程分为了主要的三层,即控制层->逻辑层->数据层,但是到了DDD这里,则是多了一层,变成了控制层->逻辑层->领域能力层->数据层。这里一层一层来解释下:

分层描述
控制层对外暴漏的接口层,举个例子,java工程的controller
逻辑层主要的业务逻辑层
领域能力层模型层,系统的核心,负责表达业务概念,业务状态信息以及业务规则。即包含了该领域(问题域)所有复杂的业务知识抽象和规则定义。
数据层操作数据,java中主要是dao层

四、总结

在解释完了各种概念以及举例子之后,我们对DDD是什么有了个大概的认知,相信也是有非常多的争议。作者搞微服务已经搞了多年,也曾经在梳理业务的时候被DDD的各种黑话毒打过,也使用过DDD搞过工程。经历了这么多这方面的实践之后觉得DDD最大的价值其实还是在梳理业务的时候划分清楚业务领域的边界,其核心思想其实还是高内聚低耦合而已。至于工程方面,现在微服务的粒度已经足够细,完全没必要再多这么一层。这多出来的这一层,多少有种没事找事的感觉。更可笑的是,这个概念本身在对外普及自己的东西的时候,玩足了文字游戏,让大家学的一头雾水。真正好的东西,是能够解决问题,并且能够很容易的让人学明白,而不是一昧的造新词去迷惑人,也希望以后互联网行业多一些实干,少说一些黑话。

作者:李少博
来源:juejin.cn/post/7184800180984610873

收起阅读 »

Java系列 | 远程热部署在美团的落地实践

1 前言1.1 什么是热部署所谓热部署,就是在应用正在运行时升级软件,却不需要重新启动应用。对于Java应用程序来说,热部署就是在运行时更新Java类文件,同时触发Spring以及其他常用第三方框架的一系列重新加载的过程。在这个过程中不需要重新启动,并且修改的...
继续阅读 »

1 前言

1.1 什么是热部署

所谓热部署,就是在应用正在运行时升级软件,却不需要重新启动应用。对于Java应用程序来说,热部署就是在运行时更新Java类文件,同时触发Spring以及其他常用第三方框架的一系列重新加载的过程。在这个过程中不需要重新启动,并且修改的代码实时生效,好比是战斗机在空中完成加油,不需要战斗机熄火降落,一系列操作都在“运行”状态来完成。

1.2 为什么我们需要热部署

据了解,美团内部很多工程师每天本地重启服务高达5~12次,单次大概3~8分钟,每天向Cargo(美团内部测试环境管理工具)部署3~5次,单次时长20~45分钟,部署频繁频次高、耗时长,严重影响了系统上线的效率。而插件提供的本地和远程热部署功能,可让将代码变更“秒级”生效。一般而言,开发者日常工作主要分为开发自测和联调两个场景,下面将分别介绍热部署在每个场景中发挥的作用。


1.2.1 开发自测场景

一般来讲,在用插件之前,开发者修改完代码还需等待3~8分钟启动时间,然后手动构造请求或协调上游发请求,耗时且费力。在使用完热部署插件后,修改完代码可以一键增量部署,让变更“秒级”生效,能够做到快速自测。而对于那些无法本地启动项目,也可以通过远程热部署功能使代码变更“秒级”生效。


1.2.2 联调场景

通常情况下,在使用插件之前,开发者修改代码经过20~35分钟的漫长部署,需要联系上游联调开发者发起请求,一直要等到远程服务器查看日志,才能确认代码生效。在使用热部署插件之后,开发者修改代码远程热部署能够秒级(2~10s)生效,开发者直接发起服务调用,可以节省大量的碎片化时间(热部署插件还具备流量回放、远程调用、远程反编译等功能,可配合进行使用)。


所以,热部署插件希望解决的痛点是:在可控的条件内,帮助开发者减少频繁编译部署的次数,节省碎片化的时间。最终为开发者每天节约出一定量的编码时间

1.3 热部署难在哪

为什么业界目前没有好用的开源工具?因为热部署不等同于热重启,像Tomcat或者Spring Boot DevTools此类热重启模式需要重新加载项目,性能较差。增量热部署难度较大,需要兼容常用的中间件版本,需要深入启动销毁加载流程。以美团为例,我们需要对JPDA(Java Platform Debugger Architecture)、Java Agent、ASM字节码增强、Classloader、Spring框架、Spring Boot框架、MyBatis框架、Mtthrift(美团RPC框架)、Zebra(美团持久层框架)、Pigeon(美团RPC框架),MDP(美团快速开发框架)、XFrame(美团快速开发脚手架)、Crane(美团分布式任务调度框架)等众多框架和技术原理深入了解才能做到全面的兼容和支持。另外,还需要IDEA插件开发能力,形成整体的产品解决方案闭环,美团的热部署插件Sonic正是在这种背景下应运而生。


1.4 Sonic可以做什么

Sonic是美团内部研发设计的一款IDEA插件,旨在通过低代码开发辅助远程/本地热部署,解决Coding、单测编写执行、自测联调等阶段的效率问题,提高开发者的编码产出效率。数据统计表明,开发者日常大概有35%时间用于编码的产出。如果想提高研发效率,要么扩大编码产出的时间占比,要么提高编码阶段的产出效率,而Sonic则聚焦提高编码阶段的产出效率。

目前,使用Sonic热部署可以解决大部分代码重复构建的问题。Sonic可以使用户在本地编写代码一键部署到远程环境,修改代码、部署、联调请求、查看日志,循环反复。如果不考虑代码修改时间,通常一个循环需要20~35分钟,而使用Sonic可以把整个时长缩短至5~10秒,而且能够给开发者带来高效沉浸式的开发体验。在实际编码工作中,多文件修改是家常便饭,Sonic对多文件的热部署能力尤为突出,它可以通过依赖分析等手段来对多文件批量进行远程热部署,并且支持Spring Bean Class、普通Class、Spring XML、MyBatis XML等多类型文件混合热部署。

那么跟业界现有的产品相比,Sonic有哪些优劣势呢?下面我们尝试给出几种产品的对比,仅供大家参考:

特性JRebelSpring Boot DevToolsIDEA热加载Tomcat热加载Spring LoaderSonic
远程Debug基于Debug协议修改
修改方法体内容✅效率低✅效率低
新增方法体✅效率低✅效率低
Jar包变更✅效率低✅效率低
Spring MVC✅效率低✅效率低
多文件热部署✅效率低✅效率低
新增泛型方法✅效率低✅效率低
新增非静态字段✅效率低✅效率低
新增静态字段✅效率低✅效率低
新增修改继承类✅效率低✅效率低
新增修改接口方法✅效率低✅效率低
新增修改匿名内部类✅效率低✅效率低
增加修改静态块✅效率低✅效率低
FastJson✅效率低✅效率低
Cglib✅效率低✅效率低
MyBatis Annotation✅效率低✅效率低
MyBatis XML✅效率低✅效率低
Gson✅效率低✅效率低
Jackson✅效率低✅效率低
Jdk代理✅效率低✅效率低
Log4j✅效率低✅效率低
Slf4J✅效率低✅效率低
Logback✅效率低✅效率低
Spring Tx✅效率低✅效率低
Spring 新增Xml✅效率低✅效率低
Spring Bean✅效率低✅效率低
Spring Boot✅效率低✅效率低
Spring Validator✅效率低✅效率低
远程热部署配置繁琐
IDEA插件集成

上表未把Sofa-Ark、Osgi、Arthas列举,此类属于插件化、模块化应用框架,以及Java在线诊断工具,核心能力非热部署。值得注意的是,Spring Boot DevTools只能应用在Spring Boot项目中,并且它不是增量热部署,而是通过Classloader迭代的方式重启项目,对大项目而言,性能上是无法接受的。虽然,JRebel支持三方插件较多,生态庞大,但是对于国产的插件不支持,例如FastJson等,同时它还存在远程热部署配置局限,对于公司内部的中间件需要个性化开发,并且是商业软件,整体的使用成本较高。

1.5 Sonic远程热部署落地推广的实践经验

相信大家都知道,对于技术产品的推广,尤其是开发、测试阶段使用的产品,由于远离线上环境,推动力、执行力、产品功能闭环能否做好,是决定着该产品是否能在企业内部落地并得到大多数人认可的重要的一环。此外,因为很多开发者在开发、测试阶段已逐渐形成了“固化动作”,如何改变这些用户的行为,让他们拥抱新产品,也是Sonic面临的艰巨挑战之一。我们从主动沟通、零成本(或极低成本)快速接入、自动化脚本,以及产品自动诊断、收集反馈等方向出发,践行出了四条原则。


2 整体设计方案

2.1 Sonic结构

Sonic插件由4大部分组成,包括脚本端、插件端、Agent端,以及Sonic服务端。脚本端负责自动化构建Sonic启动参数、服务启动等集成工作;IDEA插件端集成环境为开发者提供更便捷的热部署服务;Agent端随项目启动负责热部署的功能实现;服务端则负责收集热部署信息、失败上报等统计工作。如下图所示:


2.2 走进Agent

2.2.1 Instrumentation类常用API

public interface Instrumentation {

   //增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
   void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

   //在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,
   //如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。
   //对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
   void addTransformer(ClassFileTransformer transformer);

   //删除一个类转换器
   boolean removeTransformer(ClassFileTransformer transformer);
   
   //是否允许对class retransform
   boolean isRetransformClassesSupported();

   //在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
   void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
 
   //是否允许对class重新定义
   boolean isRedefineClassesSupported();

   //此方法用于替换类的定义,而不引用现有的类文件字节,就像从源代码重新编译以进行修复和继续调试时所做的那样。
   //在要转换现有类文件字节的地方(例如在字节码插装中),应该使用retransformClasses。
   //该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
   void redefineClasses(ClassDefinition... definitions) throws  ClassNotFoundException, UnmodifiableClassException;

   //获取已经被JVM加载的class,有className可能重复(可能存在多个classloader)
   @SuppressWarnings("rawtypes")
   Class[] getAllLoadedClasses();
}

2.2.2 Instrument简介

Instrument的底层实现依赖于JVMTI(JVM Tool Interface),它是JVM暴露出来的一些供用户扩展的接口集合,JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(如果存在),这些接口可以供开发者去扩展自己的逻辑。

JVMTIAgent是一个利用JVMTI暴露出来的接口提供了代理启动时加载(Agent On Load)、代理通过Attach形式加载(Agent On Attach)和代理卸载(Agent On Unload)功能的动态库。而Instrument Agent可以理解为一类JVMTIAgent动态库,别名是JPLISAgent(Java Programming Language Instrumentation Services Agent),也就是专门为Java语言编写的插桩服务提供支持的代理。

2.2.3 启动时和运行时加载Instrument Agent过程


2.3 那些年JVM和HotSwap之间的“相爱相杀”

围绕着Method Body的HotSwap JVM一直在进行改进。从1.4版本开始,JPDA引入HotSwap机制(JPDA Enhancements),实现Debug时的Method Body的动态性。大家可参考文档:enhancements1.4
1.5版本开始通过JVMTI实现的java.lang.instrument(Java Platform SE 8)的Premain方式,实现Agent方式的动态性(JVM启动时指定Agent)。大家可参考文档:package-summary

1.6版本又增加Agentmain方式,实现运行时动态性(通过The Attach API 绑定到具体VM)。大家可参考文档:package-summary 。基本实现是通过JVMTI的retransformClass/redefineClass进行method、body级的字节码更新,ASM、CGLib基本都是围绕这些在做动态性。但是针对Class的HotSwap一直没有动作(比如Class添加method、添加field、修改继承关系等等),为什么会这样呢?因为复杂度过高,且没有很高的回报。

2.4 Sonic如何解决Instrumentation的局限性

由于JVM限制,JDK 7和JDK 8都不允许改类结构,比如新增字段,新增方法和修改类的父类等,这对于Spring项目来说是致命的。比如开发同学想修改一个Spring Bean,新增一个@Autowired字段,此类场景在实际应用时很多,所以Sonic对此类场景的支持必不可少。

那么,具体是如何做到的呢?这里要提一下“大名鼎鼎”的Dcevm。Dcevm(DynamicCode Evolution Virtual Machine)是Java Hostspot的补丁(严格上来说是修改),允许(并非无限制)在运行环境下修改加载的类文件。当前虚拟机只允许修改方法体(Method,Body),而Decvm可以增加、删除类属性、方法,甚至改变一个类的父类,Dcevm是一个开源项目,遵从GPL 2.0协议。更多关于Dcevm的介绍,大家可以参考:Wuerthinger10a以及GitHub Decvm

值得一提的是,在美团内部,针对Dcevm的安装,Sonic已经打通HULK,集成发布镜像即可完成(本地热部署可结合插件功能实现一键安装热部署环境)。

3 Sonic热部署技术解析

3.1 Sonic整体架构模型

上一章节我们主要介绍了Sonic的组成。下图详细介绍了Sonic在运行期间各个组成部分的工作职责,由它们形成一整套完备的技术产品落地闭环方案:


3.2 Sonic功能流转

Sonic通过NIO监听本地文件变更,触发文件变更事件,例如Class新增、Class修改、Spring Bean重载等事件流程。下图展示了一次热部署单个文件的生命周期:


3.3 文件监听

Sonic首先会在本地和远程预定义两个目录,/var/tmp/sonic/extraClasspath/var/tmp/sonic/classes。extraClasspath为Sonic自定义的拓展Classpath URL,classes为Sonic监听的目录,当有文件变更时,通过IDEA插件来部署到远程/本地,触发Agent的监听目录,来继续下面的热加载逻辑:


为什么Sonic不直接替换用户ClassPath下面的资源文件呢?因为考虑到业务方WAR包的API项目、Spring Boot、Tomcat项目、Jetty项目等,都是以JAR包来启动的,这样是无法直接修改用户的Class文件的。即使是用户项目可以修改,直接操作用户的Class,也会带来一系列的安全问题。

所以,Sonic采用拓展ClassPath URL路径来实现文件的修改和新增。并且存在这么一种场景,多个业务侧的项目引入相同的JAR包,在JAR里面配置MyBatis的XML和注解。在此类情况下,Sonic没有办法直接来修改JAR包中源文件,通过拓展路径的方式可以不需要关注JAR包,来修改JAR包中某一文件和XML。同理,采用此类方法可以进行整个JAR包的热替换。下面我们简单介绍一下Sonic的核心监听器,如下图所示:


3.4 JVM Class Reload

JVM的字节码批量重载逻辑,通过新的字节码二进制流和旧的Class对象生成ClassDefinition定义,instrumentation.redefineClasses(definitions),来触发JVM重载,重载过后将触发初始化时Spring插件注册的Transfrom。接下来,我们简单讲解一下Spring是怎么重载的。

新增class Sonic如何保证可以加载到Classloader上下文中?由于项目在远程执行,所以运行环境复杂,有可能是JAR包方式启动(Spring Boot),也有可能是普通项目,也有可能是War Web项目,针对此类情况Sonic做了一层Classloader URL拓展。


User ClassLoader是框架自定义的ClassLoader统称,例如Jetty项目是WebAppclassLoader。其中Urlclasspath为当前项目的lib文件件下,例如Spring Boot项目也是从当前项目BOOT-INF/lib/路径中加载CLass等等,不同框架的自定义位置稍有不同。所以针对此类情况,Agent必须拿到用户的自定义Classloader,如果是常规方式启动的,比如普通Spring XML项目,借助Plus(美团内部服务发布平台)发布,此类没有自定义Classloader,是默认AppClassLoader,所以Agent在用户项目启动过程中,借助字节码增强的方式来获取到真正的用户Classloader。


找到用户使用的子Classloader之后,通过反射的方式来获取Classloader中的元素Classpath,其中ClassPath中的URL就是当前项目加载Class时需要的所有运行时Class环境,并且包括三方的JAR包依赖等。

Sonic获取到URL数组,把Sonic自定义的拓展Classpath目录加入到URL数组首位,这样当有新增Class时,Sonic只需要将Class文件复制到拓展Classpath对应的包目录下面即可,当有其他Bean依赖新增的Class时,会从当前目录下面查找类文件。

为什么不直接对Appclassloader进行加强?而是对框架的自定义Classloader进行加强?


考虑这样一个场景,框架自定义类加载器中有ClassA,此时用户新增ClassB需要热加载,B Class里面有A的引用关系,如果增强AppClassLoader,初始化B实例时ClassLoader。loadclass首先从UserClassLoader开始加载ClassB的字节码,依靠双亲委派原则,B被Appclassloader加载,因为B依赖类A,所以当前AppClassLoader加载B一定是加载不到的,此时会抛出ClassNotFoundException异常。所以对类加载器拓展,一定要拓展最上层的类加载器,这样才会达到使用者想要的效果。

3.5 Spring Bean重载

Spring Bean Reload过程中,Bean的销毁和重启流程,主要内容如下图展示:


首先当修改Java Class D时,通过Spring ClasspathScan扫描校验当前修改的Bean是否Sprin Bean(注解校验),然后触发销毁流程(BeanDefinitionRegistry.removeBeanDefinition),此方法会将当前Spring上下文中的Bean D和依赖Spring Bean D的Bean C一并销毁,但是作用范围仅仅在当前Spring上下文。如果C被子上下文中的Bean B依赖,就无法更新子上下文中的依赖关系,当有系统请求时,Bean B中关联的Bean C还是热部署之前的对象,所以热部署失败。

因此,在Spring初始化过程中,需要维护父子上下文的对应关系,当子上下文变时若变更范围涉及到Bean B时,需要重新更新子上下文中的依赖关系,当有多上下文关联时需要维护多上下文环境,且当前上下文环境入口需要Reload。这里的入口是指:Spring MVC Controller、Mthrift和Pigeon,对不同的流量入口,采用不同的Reload策略。RPC框架入口主要操作为解绑注册中心、重新注册、重新加载启动流程等等,对Spring MVC Controller,主要是解绑和注册URL Mappping来实现流量入口类的变化切换。

3.6 Spring XML重载

当用户修改/新增Spring XML时,需要对XML中所有Bean进行重载。


重新Reload之后,将Spring销毁后重启。需要注意的是:XML修改方式改动较大,可能涉及到全局的AOP的配置以及前置和后置处理器相关的内容,影响范围为全局,所以目前只放开普通的XML Bean标签的新增/修改,其他能力酌情逐步放开。

3.7 MyBatis 热部署

Spring MyBatis热部署的主要处理流程是在启动期间获取所有Configuration路径,并维护它和Spring Context的对应关系,在热部署Class、XML时去匹配Configuration,从而重新加载Configuration以达到热部署的目的。


4 总结

4.1 热部署功能一览

上一章节主要讲述了Spring Bean、Spring MVC、MyBatis的重载流程,Sonic还支持其它常用的开发框架,丰富的框架支持和兼容能力是Sonic的基石,下面列举一些Sonic支持的常用的第三方框架:


截止目前,Sonic已经支持绝大部分常用第三方框架的热加载,常规业务开发几乎无需重启服务。并且在美团内部的成功率已经高达99.9%以上,真正地让热部署来代替常规部署构建成为一种可能。

4.2 IDE插件集成

Sonic也提供了功能强大的IDEA插件,让用户进行沉浸式开发,远程热部署也变得更加便利。


4.3 推广使用情况

截止到发稿时,Sonic在美团使用人数3000+,应用项目数量2000+。该项目还获得了美团内部2020年下半年到家研发平台“最佳效率团队”奖。

5 作者简介

凯哥、占峰、李晗、龚炎、程骁、玉龙等,均来自美团/到家研发平台。

来源:tech.meituan.com/2022/03/17/java-hotswap-sonic.html

收起阅读 »

2022年年终杂谈,如何成为出色的工程师

重新认识自己我一直对 NodeJS 工具方向比较感兴趣,今年终于有机会在公司内,开始工具项目的研发,调研并设计了整体架构、独立负责开发工作。去年年末时,我刚刚来到字节半年,其实心态还没有完全转换过来,之前在创业公司,涉及了很多不同端的开发工作。所以我对自己的定...
继续阅读 »

重新认识自己

我一直对 NodeJS 工具方向比较感兴趣,今年终于有机会在公司内,开始工具项目的研发,调研并设计了整体架构、独立负责开发工作。

去年年末时,我刚刚来到字节半年,其实心态还没有完全转换过来,之前在创业公司,涉及了很多不同端的开发工作。所以我对自己的定位,还处于一个支持业务开发的状态,对技术渴望度足够,但对于技术路线有些许迷茫。只能看到一些比较聚焦度比较高的技术名词,例如 WASMWebGL,会对这些技术存在追捧,却并没有做到脚踏实地。

相比去年的我,今年我对一些概念又有了许多新的见解,到底什么样才是出色的工程师?

当今国内工程师的问题

按照国内对工程师的区分,在各厂招聘列表中经常出现的是这几类,前端/后端/客户端工程师。

在我的工作中,经常会接触各端 SDK 的开发的同学,接触过程中,有时感觉到大家会存在一些 gap,也就是说 SDK 同学想去做一些事情,但是在他的角度他很明白底层逻辑,但对于其他端(前/后)同学,他们对底层原理其实并不了解。这就造成开发前,需要很长的时间去对齐功能的逻辑,合作同学也很难理解需求的意义与价值。

如果想摆脱这些困惑,那我认为你需要成为一名「全栈工程师」,其实说是全栈,不如是「软件工程师」,作为一名工程师,需要拥有一些闭环整体开发流程的能力。

例如,以 SDK 开发的角度来说,对于 SDK 同学,实际上最终只负责到上报这个动作,至于之后数据的流向,以及数据清洗,并不在掌控的范畴之内。这虽然降低了 SDK 侧上手的门槛,但并不利于长期的维护。

假如你是一个软件工程师,对于以下流程,你都了如指掌:

数据上报 -> 清洗 -> 存储 -> 消费

那你对于系统的整体认知就会提升,从优化上,可以给出更好的建议;在排查问题时,也可以更快速的定位问题。

何为工程师?

下面我详细谈一谈,如何成为一个有宏观视角的工程师。

首先,我目前的关注点主要在前端方向,如果你有仔细观察,你可以看到最终大部分比较厉害的前端,都是具有一些全栈能力的人。

对于服务端层面,我的建议是把 Golang 学好,这是一个还不错的方向。一个技术栈,如果有很多人关注聚焦,广泛地提出问题,那它的发展前景一定是不错的,起码不会垮掉,也就是说,开发生态是健康的。

对于前端层面,如果想去把前端学的很深入的话,那么前端工具以及工程化,必不可少。

在这一年内,我扩展一些自己原本不是很擅长的领域:

  • 产品

    • 竞品调研

    • PRD 撰写

  • 服务端方向

    • MySQL

    • Rust

    • Golang

  • 前端方向

    • 单测/e2e:Jest、@testing-library/react、Cypress

    • 工程化:Rush.js、Pnpm、Webpack、TypeScript

    • 工具:Babel、CLI 相关的 npm 包工具

    • 插件:Chrome、VSCode

  • 设计

    • Figma 学习

  • 英语学习

除了开发角色,一个合格的工程师,还应该掌握技术方案设计的能力,这样可以将整体的开发流程闭环。也就是说一个人扮演,调研、方案设计、编码、测试的工作。从我的 Roadmap 中,你也可以看出来这一点。为什么要闭环呢,当一个需求,有越来越多的角色参与进来的时候,你会发现方案细节的对齐,变成了一个不简单的工作。

有时候我们经常会讲一个词,融会贯通。当你把一整套研发体系都吃下来的时候,你会发现可以顺利地解决掉项目的问题。

我的工作场景

在我的工作中,会涉及到工具链的开发。首先在开始前,需要做一些竞品调研,方案设计的工作。

开发环节,对前端来说,按照目前的趋势,我们更好的方式是以 Monorepo 的形式去做开发。这里 Pnpm 就是一个很不错的选择,但接下来你会遇到一些问题,例如如何去做这些包的发版编排?

由于在 Workspace 中会存在一些包之间的相互引用,在发版时,也要按照拓扑排序的方式进行发版,这时,我们就可以用到 Rush.js 去做拓扑发版,以及自动生成 Changelog

工具链对于质量需要较强的把控,这时我们就要引入 Jest 做单测,但一些场景下,单测是不够的,这时我们需要引入 e2e 测试。

Monorepo 中,不像单仓中,可能只存在一个 tsconfig,这时会存在配置之间 extends 的关系,需要我们对 tsconfig 的配置了如指掌。

对于多种工具消费方式,例如 CLIChrome 插件等,实则需要公用一些方法与配置,这里就需要抽象出公用的 utils 等。

在开发中,可能会关注一些新闻,比如 Vite 4 启用了 SWC 替代 Babel做编译。那你是否有好奇过,为什么 SWC 会更快,这时候如果学过 Rust,就知道 Rust 特有的语言特性。

总结

我想说的是,作为一个工程师,不要去把自己划分为「前端/后端/ PM」这些更加细分的角色。你都可以去学习任何方面的知识。并且你学的一切知识,都是有意义的。虽然学习的道路很长,但只要坚持下去,你就会朝着优秀的工程师进发。

作者:EricLee
来源:juejin.cn/post/7181000277208760378

收起阅读 »

Java中多线程的ABA问题探讨

前言  本文是笔者在日常开发过程中遇到的对 CAS 、 ABA 问题以及 JUC(java.util.concurrent)中 AtomicReference 相关类的设计的一些思考记录。 对需要处理 ABA 问题,或有诸如笔者一样的设计疑问探索好奇心的读者可...
继续阅读 »

前言

  本文是笔者在日常开发过程中遇到的对 CAS 、 ABA 问题以及 JUC(java.util.concurrent)中 AtomicReference 相关类的设计的一些思考记录。 对需要处理 ABA 问题,或有诸如笔者一样的设计疑问探索好奇心的读者可能会带来一些启发。

本文主体由三部分构成:

  1. 首先阐述多线程场景数据同步的常用语言工具

  2. 接着阐述什么是 ABA 问题,以及产生的原因和可能带来的影响

  3. 再探索 JUC 中官方为解决 ABA 问题而做一些工具类设计

文章的最后会对多线程数据同步常用解决方案做了简短地经验性总结与概括。

受限于笔者的理解与知识水平,文章的一些术语表述难免可能会失偏颇,对于有理解歧义或争议的部分,欢迎大家探讨和指正。

一、异步场景常用工具

在Java中的多线程数据同步的场景,常会出现:

  1. 关键字 volatile

  2. 关键字 synchronized

  3. 可重入锁/读写锁 java.util.concurrent.locks.*

  4. 容器同步包装,如 Collections.synchronizedXxx()

  5. 新的线程安全容器,如 CopyOnWriteArrayList/ConcurrentHashMap

  6. 阻塞队列 java.util.concurrent.BlockingQueue

  7. 原子类 java.util.concurrent.atomic.*

  8. 以及 JUC 中其他工具诸如 CountDownLatch/Exchanger/FutureTask 等角色。

  其中 volatile 关键字用于刷新数据缓存,即保证在 A 线程修改某数据后,B 线程中可见,这里面涉及的线程缓存和指令重排因篇幅原因不在本文探讨范围之内。而不论是 synchronized 关键字下的对象锁,还是基于同步器 AbstractQueuedSynchronizerLock 实现者们,它们都属于悲观锁。而在同步容器包装、新的线程程安全容器和阻塞队列中都使用的是悲观锁;只是各类的内部使用不同的 Lock 实现类和 JUC 工具,另外不同容器在加锁粒度和加锁策略上分别做了处理和优化。

  这里值得一说的,也是本文聚焦的重点则是原子类,即 java.util.concurrent.atomic.* 包下的几个类库诸如 AtomicBoolean/AtomicInteger/AtomicReference

二、CAS 与 ABA 问题

  我们知道在使用悲观锁的场景中,如果有有一个线程抢先取得了锁,那么其他想要获得锁的线程就得被阻塞等待,直到占锁线程完成计算释放锁资源。而现代 CPU 提供了硬件级指令来实现同步原语,也就是说可以让线程在运行过程中检测是否有其他线程也在对同一块内存进行读写,基于此 Java 提供了使用忙循环来取代阻塞的系列工具类 AutomicXxx,这属于是一种乐观锁的实现。其常规使用方式形如:

public class Requester {
   private AtomicBoolean isRequesting = new AtomicBoolean(false)

   public void request() {
       // 修改成功时返回true;compareAndSet 方法由 Native 层调硬件指令实现
       if (!isRequesting.compareAndSet(false, true)) {
           return;
      }
       try {
           // do sth...
      } finally {
           isRequesting.set(false)
      }
  }
}

  进入到 JDK11 AtomicBoolean 的源码中,可以看到 compareAndSet 最终调用 Native 层的方式如下。其实在旧的版本中 JDK 是使用 Unsafe 类处理的,在入参数中有传入状态变量的字段偏移值,新版本则将两者封装到 VarHandle 中采用DL方式查找依赖(笔者猜测可能和JDK9模块化改造有关):

// 旧版
public class AtomicBoolean {
   private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
   private static final long VALUE;
   static {
       try {
           VALUE = U.objectFieldOffset
              (AtomicBoolean.class.getDeclaredField("value"));
      } catch (ReflectiveOperationException e) {
           throw new Error(e);
      }
  }

   private volatile int value;

   public final boolean compareAndSet(boolean expect, boolean update) {
       return U.compareAndSwapInt(this, VALUE, (expect ? 1 : 0), (update ? 1 : 0));
  }
}

// 新版
public class AtomicBoolean {
   private static final VarHandle VALUE;
   static {
       try {
           MethodHandles.Lookup l = MethodHandles.lookup();
           VALUE = l.findVarHandle(AtomicBoolean.class, "value", int.class);
      } catch (ReflectiveOperationException e) {
           throw new ExceptionInInitializerError(e);
      }
  }

   private volatile int value;

   public final boolean compareAndSet(boolean expectedValue, boolean newValue) {
       return VALUE.compareAndSet(this, (expectedValue ? 1 : 0), (newValue ? 1 : 0));
  }
}

  犹如入仓有 thisvalue 的偏移值,则 Native 层可根据此二者值定位到某块栈内存,这样对于基本类型没什么问题。原子类型体系中使用 AtomicReference 来引用复合类型实例,但 Java 中 Object 类型在栈中保存的只是堆中对象数据块的地址,其结构形如下图:


  而实际运行过程中,调用 AtomicReference#compareAndSet() 时,Native层只会对比栈中内存的值,而不会关注其指向的堆中数据。这样说可能有点抽象,看一段实验代码:

StringBuilder varA = new StringBuilder("abc");
StringBuilder varB = new StringBuilder("123");

AtomicReference<StringBuilder> ref = new AtomicReference<>(varA);
ref.compareAndSet(varA, varB); // (1)
System.out.println(ref.get()); // (2) varB->123
varB.append('4'); // (3) changed varB->1234
if (ref.compareAndSet(varB, varA)) { // (4)
   System.out.println("CAS succeed"); // (5) CAS succeed
}
System.out.println(ref.get()); // abc

喜欢动手的读者可以尝试自定义一个类,观察下 Compare 过程是否真的没有调用对象的 equals 方法。

  ref 在经过处理后再 (2) 处引用变量B,而在注释 (3) 处将 B 值修改了,但由于原子类不会检查堆中数据,所以还是能通过注释 (4) 处的相等比较走到注释 (5) 。这也就引入了 所谓的 ABA 问题:

  1. 假设,线程 1 的任务希望将变量从 A 变为 C ,但执行到一半被线程 2 抢走 CPU

  2. 线程 2 将变量从 A 改成了 B ,此时 CPU 时间片又被系统分给了线程 3

  3. 线程 3 讲变量从 B 又设置成一个新的 A 。

  4. 线程 1 获取时间片,检查变量发现其仍然是 A(但 A 对象内部的数据已经改变了),检查通过将变量置为 C 。

  若业务场景中,线程 1 不在意变量经过了一轮变化,也不在意 A 中数据是否有变化,则该问题无关痛痒。而若线程 1 对这两个变化敏感,则将变量置为 C 的操作就不符合预期了。用维基百科的例子来表述,其大意是:

你提着有很多现金的包去机场,这时来了个辣妹挑逗你,并趁你不注意时用一个看起来一样的空包换了你的现金包,然后她就走了;此时你检查了下发现你的包还在,于是就匆忙拿着包赶飞机去了。

换个角度看这几个关键字:

  • 有现金的包:指向堆中数据的栈引用

  • 辣妹挑逗:其他线程抢占 CPU

  • 看起来一样空包:其他线程修改堆中数据

  • 发现包还在:仅检查栈中内存的地址值是否一致

三、用 JUC 工具处理 ABA 问题

  为处理 ABA 问题,JDK 提供了另外两个工具类:AtomicMarkableReferenceAtomicStampedReference 他们除了对比栈中对象的引用地址外,另外还保存了一个 booleanint 类型的标记值,用于 CAS 比较。

StringBuilder varA = new StringBuilder("abc");
StringBuilder varB = new StringBuilder("123");

AtomicStampedReference<StringBuilder> ref = new AtomicStampedReference<>(varA, varA.toString().hashCode());
ref.compareAndSet(varA, varB, varA.toString().hashCode(), varB.toString().hashCode());
System.out.println(ref.get(new int[1]));
varB.append('4');
// CAS失败,因为Stamp值对不上
if (ref.compareAndSet(varB, varA, varB.toString().hashCode(), varA.toString().hashCode())) {
   System.out.println("compareAndSet: succeed");
}
System.out.println(ref.get(new int[1]));

:这种设计和为快速判断文件是否相同,而比较文件摘要值(MD5、SHA值)和预期是否一致的思想倒有异曲同工之妙。

总结

  通常在多线程场景中,这些工具的应用场景具有各自的适用特征:

  1. 若各线程读写数据没有竞争关系,则可考虑仅使用 volatile 关键字;

  2. 若各线程对某数据的读写需要去重,则可优先考虑使用乐观锁实现,即用原子类型;

  3. 若各线程有竞争关系且不去重必须按顺序抢占某资源,即必须用锁阻塞,若没有多条件队列的诉求则可先考虑使用 synchronized 添加对象锁(但需注意锁对象的不可变和私有化),否则考虑用 Lock 实现类,但特别的如需读写分锁以实现共享锁则只能用 Lock 了。

  4. 若需使用线程安全容器,出于性能考虑优先考虑 java.util.concurrent.* 类,如 ConcurrentHashMapCopyOnWriteArrayList;再考虑使用容器同步包装 Collections.synchronizedXxx()。而阻塞队列则多用于生产-消费模型中的任务容器,典型如用在线程池中。

作者:Chavin
来源:juejin.cn/post/7181077489211408443

收起阅读 »

你真的了解 RSA 加密算法吗?

沉淀、分享、成长,让自己和他人都能有所收获!😄记得那是我毕业🎓后的第一个秋天,申请了域名,搭建了论坛。可惜好景不长,没多久进入论坛后就出现各种乱七八糟的广告,而这些广告压根都不是我加的。这是怎么回事?后来我才知道,原来我的论坛没有加 HTTPS 也就是没有 S...
继续阅读 »

沉淀、分享、成长,让自己和他人都能有所收获!😄

记得那是我毕业🎓后的第一个秋天,申请了域名,搭建了论坛。可惜好景不长,没多久进入论坛后就出现各种乱七八糟的广告,而这些广告压根都不是我加的。


这是怎么回事?后来我才知道,原来我的论坛没有加 HTTPS 也就是没有 SSL 证书。那这和数学中的素数有啥关系呢?这是因为每一个 SSL 的生成都用到了 RSA 非对称加密,而 RSA 的加解密就是使用了两个互为质数的大素数生成公钥和私钥的。

这就是我们今天要分享的,关于素数在 RSA 算法中的应用。

一、什么是素数

素数(或质数)指的是大于1的且不能通过两个较小的自然数乘积得来的自然数。而大于1的自然数如果不是素数,则称之为合数。例如:7是素数,因为它的乘积只能写成 1 * 7 或者 7 * 1 这样。而像自然数 8 可以写成 2 * 4,因为它是两个较小数字的乘积。

通常在 Java 程序中,我们可以使用下面的代码判断一个数字是否为素数;

boolean isPrime = number > 0;
// 计算number的平方根为k,可以减少一半的计算量
int k = (int) Math.sqrt(number);
for (int i = 2; i <= k; i++) {
   if (number % i == 0) {
       isPrime = false;
       break;
  }
}
return isPrime;

二、对称加密和非对称加密

假如 Alice 时而需要给北漂搬砖的 Bob 发一些信息,为了安全起见两个人相互协商了一个加密的方式。比如 Alice 发送了一个银行卡密码 142857 给 Bob,Alice 会按照与 Bob 的协商方式,把 142857 * 2 = 285714 的结果传递给 Bob,之后 Bob 再通过把信息除以2拿到结果。

但一来二去,Alice 发的密码、生日、衣服尺寸、鞋子大小,都是乘以2的规律被别人发现。这下这个加密方式就不安全了。而如果每次都给不同的信息维护不同的秘钥又十分麻烦,且这样的秘钥为了安全也得线下沟通,人力成本又非常高。

所以有没有另外一种方式,使用不同的秘钥对信息的加密和解密。当 Bob 想从 Alice 那获取信息,那么 Bob 就给 Alice 一个公钥,让她使用公钥对信息进行加密,而加密后的信息只有 Bob 手里有私钥才能解开。那么这样的信息传递就变得非常安全了。如图所示。

对称加密非对称加密


三、算法公式推导


如果 Alice 希望更安全的给 Bob 发送的信息,那么就需要保证经过公钥加密的信息不那么容易被反推出来。所以这里的信息加密,会需用到求模运算。像计算机中的散列算法,伪随机数都是求模运算的典型应用。

例如;5^3 mod 7 = 6 —— 5的3次幂模7余6

  • 5相当于 Alice 要传递给 Bob 的信息

  • 3相当于是秘钥

  • 6相当于是加密后的信息

经过求模计算的结果6,很难被推到出秘钥信息,只能一个个去验证;

5^1 mod 7 = 5
5^2 mod 7 = 3
5^3 mod 7 = 6
5^4 mod 7 = 2
...

但如果求模的值特别大,例如这样:5^3 mod 78913949018093809389018903794894898493... = 6 那么再想一个个计算就有不靠谱了。所以这也是为什么会使用模运算进行加密,因为对于大数来说对模运算求逆根本没法搞。

根据求模的计算方式,我们得到加密和解密公式;—— 关于加密和解密的公式推到,后文中会给出数学计算公式。


对于两个公式我们做一下更简单的转换;


从转换后的公式可以得知,m 的 ed 次幂,除以 N 求求模可以得到 m 本身。那么 ed 就成了计算公钥加密的重要因素。为此这里需要提到数学中一个非常重要的定理,欧拉定理。—— 1763年,欧拉发现。

欧拉定理:m^φ(n) ≡ 1 (mod n) 对于任何一个与 n 互质的正整数 m,的 φ(n) 次幂并除以 n 去模,结果永远等于1。φ(n) 代表着在小于等于 n 的正整数中,有多少个与 n 互质的数。

例如:φ(8) 小于等于8的正整数中 1、2、3、4、5、6、7、8 有 1、3、5、7 与数字 8 互为质数。所以 φ(8) = 4 但如果是 n 是质数,那么 φ(n) = n - 1 比如 φ(7) 与7互为质数有1、2、3、4、5、6 所有 φ(7) = 6

接下来我们对欧拉公式做一些简单的变换,用于看出ed的作用;


经过推导的结果可以看到 ed = kφ(n) + 1,这样只要算出加密秘钥 e 就可以得到一个对应的解密秘钥 d。那么整套这套计算过程,就是 RSA 算法。

四、关于RSA算法

RSA加密算法是一种非对称加密算法,在公开秘钥加密和电子商业中被广泛使用。


于1977年,三位数学家;罗纳德·李维斯特(Ron Rivest)、阿迪·萨莫尔(Adi Shamir)和伦纳德·阿德曼(Leonard Adleman)设计了一种算法,可以实现非对称加密。这种算法用他们三个人的名字命名,叫做RSA算法。

1973年,在英国政府通讯总部工作的数学家克利福德·柯克斯(Clifford Cocks)在一个内部文件中提出了一个与之等效的算法,但该算法被列入机密,直到1997年才得到公开。

RSA 的算法核心在于取了2个素数做乘积求和、欧拉计算等一系列方式算得公钥和私钥,但想通过公钥和加密信息,反推出来私钥就会非常复杂,因为这是相当于对极大整数的因数分解。所以秘钥越长做因数分解越困难,这也就决定了 RSA 算法的可靠性。—— PS:可能以上这段话还不是很好理解,程序员👨🏻‍💻还是要看代码才能悟。接下来我们就来编写一下 RSA 加密代码。

五、实现RSA算法

RSA 的秘钥生成首先需要两个质数p、q,之后根据这两个质数算出公钥和私钥,在根据公钥来对要传递的信息进行加密。接下来我们就要代码实现一下 RSA 算法,读者也可以根据代码的调试去反向理解 RSA 的算法过程,一般这样的学习方式更有抓手的感觉。嘿嘿 抓手

1. 互为质数的p、q

两个互为质数p、q是选择出来的,越大越安全。因为大整数的质因数分解是非常困难的,直到2020年为止,世界上还没有任何可靠的攻击RSA算法的方式。只要其钥匙的长度足够长,用RSA加密的信息实际上是不能被破解的。—— 不知道量子计算机出来以后会不会改变。如果改变,那么程序员又有的忙了。

2. 乘积n

n = p * q 的乘积。

public long n(long p, long q) {
   return p * q;
}

3. 欧拉公式 φ(n)

φ(n) = (p - 1) * (q - 1)

public long euler(long p, long q) {
   return (p - 1) * (q - 1);
}

4. 选取公钥e

e 的值范围在 1 < e < φ(n)

public long e(long euler){
   long e = euler / 10;
   while (gcd(e, euler) != 1){
       e ++;
  }
   return e;
}

5. 选取私钥d

d = (kφ(n) + 1) / e

public long inverse(long e, long euler) {
   return (euler + 1) / e;
}

6. 加密

c = m^e mod n

public long encrypt(long m, long e, long n) {
   BigInteger bM = new BigInteger(String.valueOf(m));
   BigInteger bE = new BigInteger(String.valueOf(e));
   BigInteger bN = new BigInteger(String.valueOf(n));
   return Long.parseLong(bM.modPow(bE, bN).toString());
}

7. 解密

m = c^d mod n

public long decrypt(long c, long d, long n) {
   BigInteger bC = new BigInteger(String.valueOf(c));
   BigInteger bD = new BigInteger(String.valueOf(d));
   BigInteger bN = new BigInteger(String.valueOf(n));
   return Long.parseLong(bC.modPow(bD, bN).toString());
}

8. 测试

@Test
public void test_rsa() {
   RSA rsa = new RSA();
   long p = 3,                         // 选取2个互为质数的p、q
           q = 11,                     // 选取2个互为质数的p、q
           n = rsa.n(p, q),            // n = p * q
           euler = rsa.euler(p, q),    // euler = (p-1)*(q-1)
           e = rsa.e(euler),           // 互为素数的小整数e | 1 < e < euler
           d = rsa.inverse(e, euler),  // ed = φ(n) + 1 | d = (φ(n) + 1)/e
           msg = 5;                    // 传递消息 5
           
   System.out.println("消息:" + msg);
   System.out.println("公钥(n,e):" + "(" + n + "," + e + ")");
   System.out.println("私钥(n,d):" + "(" + n + "," + d + ")");
   
   long encrypt = rsa.encrypt(msg, e, n);
   System.out.println("加密(消息):" + encrypt);
   
   long decrypt = rsa.decrypt(encrypt, d, n);
   System.out.println("解密(消息):" + decrypt);
}

测试结果

消息:5
公钥(n,e)(33,3)
私钥(n,d)(33,7)
加密(消息):26
解密(消息):5
  • 通过选取3、11作为两个互质数,计算出公钥和私钥,分别进行消息的加密和解密。如测试结果消息5的加密后的信息是26,解密后获得原始信息5

六、RSA数学原理

整个 RSA 的加解密是有一套数学基础可以推导验证的,这里小傅哥把学习整理的资料分享给读者,如果感兴趣可以尝试验证。这里的数学公式会涉及到;求模运算、最大公约数、贝祖定理、线性同于方程、中国余数定理、费马小定理。当然还有一些很基础的数论概念;素数、互质数等。以下推理数学内容来自博客:luyuhuang.tech/2019/10/24/…

1. 模运算

1.1 整数除法

定理 1 令 a 为整数, d 为正整数, 则存在唯一的整数 q 和 r, 满足 0⩽r<d, 使得 a=dq+r.

当 r=0 时, 我们称 d 整除 a, 记作 d∣a; 否则称 d 不整除 a, 记作 d∤a

整除有以下基本性质:

定理 2 令 a, b, c 为整数, 其中 a≠0a≠0. 则:

  • 对任意整数 m,n,如果 a∣b 且 a∣c, 则 a∣(mb + nc)

  • 如果 a∣b, 则对于所有整数 c 都有 a∣bc

  • 如果 a∣b 且 b∣c, 则 a∣c

1.2 模算术

在数论中我们特别关心一个整数被一个正整数除时的余数. 我们用 a mod m = b表示整数 a 除以正整数 m 的余数是 b. 为了表示两个整数被一个正整数除时的余数相同, 人们又提出了同余式(congruence).

定义 1 如果 a 和 b 是整数而 m 是正整数, 则当 m 整除 a - b 时称 a 模 m 同余 b. 记作 a ≡ b(mod m)

a ≡ b(mod m) 和 a mod m= b 很相似. 事实上, 如果 a mod m = b, 则 a≡b(mod m). 但他们本质上是两个不同的概念. a mod m = b 表达的是一个函数, 而 a≡b(mod m) 表达的是两个整数之间的关系.

模算术有下列性质:

定理 3 如果 m 是正整数, a, b 是整数, 则有

(a+b)mod m=((a mod m)+(b mod m)) mod m

ab mod m=(a mod m)(b mod m) mod m

根据定理3, 可得以下推论

推论 1 设 m 是正整数, a, b, c 是整数; 如果 a ≡ b(mod m), 则 ac ≡ bc(mod m)

证明 ∵ a ≡ b(mod m), ∴ (a−b) mod m=0 . 那么

(ac−bc) mod m=c(a−b) mod m=(c mod m⋅(a−b) mod m) mod m=0

∴ ac ≡ bc(mod m)

需要注意的是, 推论1反之不成立. 来看推论2:

推论 2 设 m 是正整数, a, b 是整数, c 是不能被 m 整除的整数; 如果 ac ≡ bc(mod m) , 则 a ≡ b(mod m)

证明 ∵ ac ≡ bc(mod m) , 所以有

(ac−bc)mod m=c(a−b)mod m=(c mod m⋅(a−b)mod m) mod m=0

∵ c mod m≠0 ,

∴ (a−b) mod m=0,

∴a ≡ b(mod m) .

2. 最大公约数

如果一个整数 d 能够整除另一个整数 a, 则称 d 是 a 的一个约数(divisor); 如果 d 既能整除 a 又能整除 b, 则称 d 是 a 和 b 的一个公约数(common divisor). 能整除两个整数的最大整数称为这两个整数的最大公约数(greatest common divisor).

定义 2 令 a 和 b 是不全为零的两个整数, 能使 d∣ad∣a 和 d∣bd∣b 的最大整数 d 称为 a 和 b 的最大公约数. 记作 gcd(a,b)

2.1 求最大公约数

如何求两个已知整数的最大公约数呢? 这里我们讨论一个高效的求最大公约数的算法, 称为辗转相除法. 因为这个算法是欧几里得发明的, 所以也称为欧几里得算法. 辗转相除法基于以下定理:

引理 1 令 a=bq+r, 其中 a, b, q 和 r 均为整数. 则有 gcd(a,b)=gcd(b,r)

证明 我们假设 d 是 a 和 b 的公约数, 即 d∣a且 d∣b, 那么根据定理2, d 也能整除 a−bq=r 所以 a 和 b 的任何公约数也是 b 和 r 的公约数;

类似地, 假设 d 是 b 和 r 的公约数, 即 d∣bd∣b 且 d∣rd∣r, 那么根据定理2, d 也能整除 a=bq+r. 所以 b 和 r 的任何公约数也是 a 和 b 的公约数;

因此, a 与 b 和 b 与 r 拥有相同的公约数. 所以 gcd(a,b)=gcd(b,r).

辗转相除法就是利用引理1, 把大数转换成小数. 例如, 求 gcd(287,91) 我们就把用较大的数除以较小的数. 首先用 287 除以 91, 得

287=91⋅3+14

我们有 gcd(287,91)=gcd(91,14) . 问题转换成求 gcd(91,14). 同样地, 用 91 除以 14, 得

91=14⋅6+7

有 gcd(91,14)=gcd(14,7) . 继续用 14 除以 7, 得

14=7⋅2+0

因为 7 整除 14, 所以 gcd(14,7)=7. 所以 gcd(287,91)=gcd(91,14)=gcd(14,7)=7.

我们可以很快写出辗转相除法的代码:

def gcd(a, b):
   if b == 0: return a
   return gcd(b, a % b)

2.2 贝祖定理

现在我们讨论最大公约数的一个重要性质:

定理 4 贝祖定理 如果整数 a, b 不全为零, 则 gcd(a,b)是 a 和 b 的线性组合集 {ax+by∣x,y∈Z}中最小的元素. 这里的 x 和 y 被称为贝祖系数

证明 令 A={ax+by∣x,y∈Z}. 设存在 x0x0, y0y0 使 d0d0 是 A 中的最小正元素, d0=ax0+by0 现在用 d0去除 a, 这就得到唯一的整数 q(商) 和 r(余数) 满足


又 0⩽r<d0, d0 是 A 中最小正元素

∴ r=0 , d0∣a.

同理, 用 d0d0 去除 b, 可得 d0∣b. 所以说 d0 是 a 和 b 的公约数.

设 a 和 b 的最大公约数是 d, 那么 d∣(ax0+by0)即 d∣d0

∴∴ d0 是 a 和 b 的最大公约数.

我们可以对辗转相除法稍作修改, 让它在计算出最大公约数的同时计算出贝祖系数.

def gcd(a, b):
   if b == 0: return a, 1, 0
   d, x, y = gcd(b, a % b)
   return d, y, x - (a / b) * y

3. 线性同余方程

现在我们来讨论求解形如 ax≡b(modm) 的线性同余方程. 求解这样的线性同余方程是数论研究及其应用中的一项基本任务. 如何求解这样的方程呢? 我们要介绍的一个方法是通过求使得方程 ¯aa≡1(mod m) 成立的整数 ¯a. 我们称 ¯a 为 a 模 m 的逆. 下面的定理指出, 当 a 和 m 互素时, a 模 m 的逆必然存在.

定理 5 如果 a 和 m 为互素的整数且 m>1, 则 a 模 m 的逆存在, 并且是唯一的.

证明贝祖定理可知, ∵ gcd(a,m)=1 , ∴ 存在整数 x 和 y 使得 ax+my=1 这蕴含着 ax+my≡1(modm) ∵ my≡0(modm), 所以有 ax≡1(modm)

∴ x 为 a 模 m 的逆.

这样我们就可以调用辗转相除法 gcd(a, m) 求得 a 模 m 的逆.

a 模 m 的逆也被称为 a 在模m乘法群 Z∗m 中的逆元. 这里我并不想引入群论, 有兴趣的同学可参阅算法导论

求得了 a 模 m 的逆 ¯a 现在我们可以来解线性同余方程了. 具体的做法是这样的: 对于方程 ax≡b(modm)a , 我们在方程两边同时乘上 ¯a, 得 ¯aax≡¯ab(modm)

把 ¯aa≡1(modm) 带入上式, 得 x≡¯ab(modm)

x≡¯ab(modm) 就是方程的解. 注意同余方程会有无数个整数解, 所以我们用同余式来表示同余方程的解.


4. 中国余数定理

中国南北朝时期数学著作 孙子算经 中提出了这样一个问题:

有物不知其数,三三数之剩二,五五数之剩三,七七数之剩二。问物几何?

用现代的数学语言表述就是: 下列同余方程组的解释多少?


孙子算经 中首次提到了同余方程组问题及其具体解法. 因此中国剩余定理称为孙子定理.

定理 6 中国余数定理 令 m1,m2,…,mn 为大于 1 且两两互素的正整数, a1,a2,…,an 是任意整数. 则同余方程组


有唯一的模 m=m1m2…mnm=m1m2…mn 的解.

证明 我们使用构造证明法, 构造出这个方程组的解. 首先对于 i=1,2,…,ni=1,2,…,n, 令


即, MiMi 是除了 mimi 之外所有模数的积. ∵∵ m1,m2,…,mn 两两互素, ∴∴ gcd(mi,Mi)=1. 由定理 5 可知, 存在整数 yiyi 是 MiMi 模 mimi 的逆. 即


上式等号两边同时乘 aiai 得


就是第 i 个方程的一个解; 那么怎么构造出方程组的解呢? 我们注意到, 根据 Mi 的定义可得, 对所有的 j≠ij≠i, 都有 aiMiyi≡0(modmj). 因此我们令


就是方程组的解.

有了这个结论, 我们可以解答 孙子算经 中的问题了: 对方程组的每个方程, 求出 MiMi , 然后调用 gcd(M_i, m_i) 求出 yiyi:


最后求出 x=−2⋅35+3⋅21+2⋅15=23≡23(mod105)

5. 费马小定理

现在我们来看数论中另外一个重要的定理, 费马小定理(Fermat's little theorem)

定理 7 费马小定理 如果 a 是一个整数, p 是一个素数, 那么


当 n 不为 p 或 0 时, 由于分子有质数p, 但分母不含p; 故分子的p能保留, 不被约分而除去. 即 p∣(np).

令 b 为任意整数, 根据二项式定理, 我们有


令 a=b+1, 即得 a^p ≡ a(mod p)

当 p 不整除 a 时, 根据推论 2, 有 a^p−1 ≡ 1(mod p)

6. 算法证明

我们终于可以来看 RSA 算法了. 先来看 RSA 算法是怎么运作的:

RSA 算法按照以下过程创建公钥和私钥:

  1. 随机选取两个大素数 p 和 q, p≠qp≠q;

  2. 计算 n=pq

  3. 选取一个与 (p−1)(q−1) 互素的小整数 e;

  4. 求 e 模 (p−1)(q−1) 的逆, 记作 d;

  5. 将 P=(e,n)公开, 是为公钥;

  6. 将 S=(d,n)保密, 是为私钥.


所以 RSA 加密算法是有效的.

(1) 式表明, 不仅可以用公钥加密, 私钥解密, 还可以用私钥加密, 公钥解密. 即加密计算 C=M^d mod n, 解密计算 M=C^e mod n

RSA 算法的安全性基于大整数的质因数分解的困难性. 由于目前没有能在多项式时间内对整数作质因数分解的算法, 因此无法在可行的时间内把 n 分解成 p 和 q 的乘积. 因此就无法求得 e 模 (p−1)(q−1)的逆, 也就无法根据公钥计算出私钥.

七、常见面试题

  • 质数的用途

  • RSA 算法描述

  • RSA 算法加解密的过程

  • RSA 算法使用场景

  • 你了解多少关于 RSA 的数学数论知识


源码:github.com/fuzhengwei/…
作者:小傅哥
来源:juejin.cn/post/7173830290812370958

收起阅读 »

MyBatis-Plus联表查询的短板,终于有一款工具补齐了

mybatis-plus作为mybatis的增强工具,它的出现极大的简化了开发中的数据库操作,但是长久以来,它的联表查询能力一直被大家所诟病。一旦遇到left join或right join的左右连接,你还是得老老实实的打开xml文件,手写上一大段的sql语句...
继续阅读 »

mybatis-plus作为mybatis的增强工具,它的出现极大的简化了开发中的数据库操作,但是长久以来,它的联表查询能力一直被大家所诟病。一旦遇到left joinright join的左右连接,你还是得老老实实的打开xml文件,手写上一大段的sql语句。

直到前几天,偶然碰到了这么一款叫做mybatis-plus-join的工具(后面就简称mpj了),使用了一下,不得不说真香!彻底将我从xml地狱中解放了出来,终于可以以类似mybatis-plusQueryWrapper的方式来进行联表查询了,话不多说,我们下面开始体验。

引入依赖

首先在项目中引入引入依赖坐标,因为mpj中依赖较高版本mybatis-plus中的一些api,所以项目建议直接使用高版本。

<dependency>
   <groupId>com.github.yulichang</groupId>
   <artifactId>mybatis-plus-join</artifactId>
   <version>1.2.4</version>
</dependency>
<dependency>
   <groupId>com.baomidou</groupId>
   <artifactId>mybatis-plus-boot-starter</artifactId>
   <version>3.5.1</version>
</dependency>

引入相关依赖后,在springboot项目中,像往常一样正常配置数据源连接信息就可以了。

数据准备

因为要实现联表查询,所以我们先来建几张表进行测试。

订单表:


用户表,包含用户姓名:


商品表,包含商品名称和单价:


在订单表中,通过用户id和商品id与其他两张表进行关联。

修改Mapper

以往在使用myatis-plus的时候,我们的Mapper层接口都是直接继承的BaseMapper,使用mpj后需要对其进行修改,改为继承MPJBaseMapper接口。

@Mapper
public interface OrderMapper extends MPJBaseMapper<Order> {
}

对其余两个表的Mapper接口也进行相同的改造。此外,我们的service也可以选择继承MPJBaseServiceserviceImpl选择继承MPJBaseServiceImpl,这两者为非必须继承。

查询

Mapper接口改造完成后,我们把它注入到Service中,虽然说我们要完成3张表的联表查询,但是以Order作为主表的话,那么只注入这一个对应的OrderMapper就可以,非常简单。

@Service
@AllArgsConstructor
public class OrderServiceImpl implements OrderService {
   private final OrderMapper orderMapper;
}

MPJLambdaWrapper

接下来,我们体验一下再也不用写sql的联表查询:

public void getOrder() {
   List<OrderDto> list = orderMapper.selectJoinList(OrderDto.class,
    new MPJLambdaWrapper<Order>()
    .selectAll(Order.class)
    .select(Product::getUnitPrice)
    .selectAs(User::getName,OrderDto::getUserName)
    .selectAs(Product::getName,OrderDto::getProductName)
    .leftJoin(User.class, User::getId, Order::getUserId)
    .leftJoin(Product.class, Product::getId, Order::getProductId)
    .eq(Order::getStatus,3));

   list.forEach(System.out::println);
}

不看代码,我们先调用接口来看一下执行结果:


可以看到,成功查询出了关联表中的信息,下面我们一点点介绍上面代码的语义。

首先,调用mapperselectJoinList()方法,进行关联查询,返回多条结果。后面的第一个参数OrderDto.class代表接收返回查询结果的类,作用和我们之前在xml中写的resultType类似。

这个类可以直接继承实体,再添加上需要在关联查询中返回的列即可:

@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class OrderDto extends Order {
   String userName;
   String productName;
   Double unitPrice;
}

接下来的MPJLambdaWrapper就是构建查询条件的核心了,看一下我们在上面用到的几个方法:

  • selectAll():查询指定实体类的全部字段

  • select():查询指定的字段,支持可变长参数同时查询多个字段,但是在同一个select中只能查询相同表的字段,所以如果查询多张表的字段需要分开写

  • selectAs():字段别名查询,用于数据库字段与接收结果的dto中属性名称不一致时转换

  • leftJoin():左连接,其中第一个参数是参与联表的表对应的实体类,第二个参数是这张表联表的ON字段,第三个参数是参与联表的ON的另一个实体类属性

除此之外,还可以正常调用mybatis-plus中的各种原生方法,文档中还提到,默认主表别名是t,其他的表别名以先后调用的顺序使用t1t2t3以此类推。

我们用插件读取日志转化为可读的sql语句,可以看到两条左连接条件都被正确地添加到了sql中:


MPJQueryWrapper

mybatis-plus非常类似,除了LamdaWrapper外还提供了普通QueryWrapper的写法,改造上面的代码:

public void getOrderSimple() {
   List<OrderDto> list = orderMapper.selectJoinList(OrderDto.class,
    new MPJQueryWrapper<Order>()
    .selectAll(Order.class)
    .select("t2.unit_price","t2.name as product_name")
    .select("t1.name as user_name")
    .leftJoin("t_user t1 on t1.id = t.user_id")
    .leftJoin("t_product t2 on t2.id = t.product_id")
    .eq("t.status", "3")
  );

   list.forEach(System.out::println);
}

运行结果与之前完全相同,需要注意的是,这样写时在引用表名时不要使用数据库中的原表名,主表默认使用t,其他表使用join语句中我们为它起的别名,如果使用原表名在运行中会出现报错。

并且,在MPJQueryWrapper中,可以更灵活的支持子查询操作,如果业务比较复杂,那么使用这种方式也是不错的选择。

分页查询

mpj中也能很好的支持列表查询中的分页功能,首先我们要在项目中加入分页拦截器:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
   MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
   interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
   return interceptor;
}

接下来改造上面的代码,调用selectJoinPage()方法:

public void page() {
   IPage<OrderDto> orderPage = orderMapper.selectJoinPage(
     new Page<OrderDto>(2,10),
     OrderDto.class,
     new MPJLambdaWrapper<Order>()
      .selectAll(Order.class)
      .select(Product::getUnitPrice)
      .selectAs(User::getName, OrderDto::getUserName)
      .selectAs(Product::getName, OrderDto::getProductName)
      .leftJoin(User.class, User::getId, Order::getUserId)
      .leftJoin(Product.class, Product::getId, Order::getProductId)
      .orderByAsc(Order::getId));

   orderPage.getRecords().forEach(System.out::println);
}

注意在这里需要添加一个分页参数的Page对象,我们再执行上面的代码,并对日志进行解析,查看sql语句:


可以看到底层通过添加limit进行了分页,同理,MPJQueryWrapper也可以这样进行分页。

最后

经过简单的测试,个人感觉mpj这款工具在联表查询方面还是比较实用的,能更应对项目中不是非常复杂的场景下的sql查询,大大提高我们的生产效率。当然,在项目的issues中也能看到当前版本中也仍然存在一些问题,希望在后续版本迭代中能继续完善。

作者:码农参上
来源:juejin.cn/post/7173493838143553549

收起阅读 »

Spring Boot 分离配置文件的 N 种方式

今天聊一个小伙伴在星球上的提问:问题不难,解决方案也有很多,因此我决定撸一篇文章和大家仔细说说这个问题。1. 配置文件位置首先小伙伴们要明白,Spring Boot 默认加载的配置文件是 application.properties 或者 applicatio...
继续阅读 »

今天聊一个小伙伴在星球上的提问:


问题不难,解决方案也有很多,因此我决定撸一篇文章和大家仔细说说这个问题。

1. 配置文件位置

首先小伙伴们要明白,Spring Boot 默认加载的配置文件是 application.properties 或者 application.yaml,默认的加载位置一共有五个,五个位置可以分为两类:

从 classpath 下加载,这个又细分为两种:

  1. 直接读取 classpath 下的配置文件,对应到 Spring Boot 项目中,就是 resources 目录下的配置。

  2. 读取 classpath:/config/ 目录下的文件,对应到 Spring Boot 项目中就是 resources/config 目录下的配置。

这两种情况如下图:


从项目所在的当前目录下加载,这个又细分为三种情况:

  1. 从项目当前目录下加载配置文件。

  2. 从项目当前目录下的 config 文件夹中加载配置文件。

  3. 从项目当前目录下的 config 文件夹的子文件夹中加载(孙子文件夹不可以)。

这三种情况如下图:


config 目录下的配置文件可以被加载,config/a 目录下的配置文件也可以被加载,但是 config/a/b 目录下的配置文件不会被加载,因为不是直接子文件夹。

配置文件可以放在这么多不同的位置,如果同一个属性在多个配置文件中都写了,那么后面加载的配置会覆盖掉前面的。例如在 classpath:application.yaml 中设置项目端口号是 8080,在 项目当前目录/config/a/application.yaml 中设置项目端口是 8081,那么最终的项目端口号就是 8081。

这是默认的文件位置。

如果你不想让自己的配置文件叫 application.properties 或者 application.yaml,那么也可以自定义配置文件名称,只需要在项目启动的时候指定配置文件名即可,例如我想设置我的配置文件名为 app.yaml,那么我们可以在启动 jar 包的时候按照如下方式配置,此时系统会自动去上面提到的五个位置查找对应的配置文件:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.name=app

如果项目已经打成 jar 包启动了,那么前面所说的目录中,后三个中的项目当前目录就是指 jar 包所在的目录。

如果你不想去这五个位置查找,那么也可以在启动 jar 包的时候明确指定配置文件的位置和名称,如下:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.location=optional:classpath:/app.yaml

注意,我在 classpath 前面加上了 optional: 表示如果这个配置文件不存在,则按照默认的方式启动,而不会报错说找不到这个配置文件。如果不加这个前缀,那么当系统找不到指定的配置文件时,就会抛出 ConfigDataLocationNotFoundException 异常,进而导致应用启动失败。

如果配置文件和 jar 包在相同的目录结构下,如下图:


那么启动脚本如下:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.location=optional:javaboy/app.yaml

如果 spring.config.location 的配置,只是指定了目录,那么必须以 / 结尾,例如上面这个启动脚本,也可以按照如下方式启动:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.location=optional:javaboy/ --spring.config.name=app

通过 spring.config.location 属性锁定配置文件的位置,通过 spring.config.name 属性锁定配置文件的文件名。

2. 额外位置

前面我们关于配置文件位置的设置,都是覆盖掉已有的配置,如果不想覆盖掉 Spring Boot 默认的配置文件查找策略,又想加入自己的,那么可以按照如下方式指定配置文件位置:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.additional-location=optional:javaboy/app.yaml

如果这个额外指定的配置文件和已有的配置文件有冲突,那么还是以后来者为准。

3. 位置通配符

有一种情况,假设我有 redis 和 mysql 的配置,我想将之放在两个不同的文件夹中以便于管理,像下面这样:


那么在项目启动时,可以通过通配符 * 批量扫描相应的文件夹:

java -jar boot_config_file-0.0.1-SNAPSHOT.jar --spring.config.location=optional:config/*/

使用通配符批量扫描 mysql 和 redis 目录时,默认的加载顺序是按照文件夹的字母排序,即先加载 mysql 目录后加载 redis 目录。

需要注意的是,通配符只能用在外部目录中,不可以用在 classpath 中的目录上。另外,包含了通配符的目录,只能有一个通配符 *,不可以有多个,并且还必须是以 */ 结尾,即一个目录的最后部分可以不确定。

4. 导入外部配置

从 Spring Boot2.4 开始,我们也可以使用 spring.config.import 方法来导入配置文件,相比于 additional-location 配置,这个 import 导入更加灵活,可以导入任意名称的配置文件。

spring.config.import=optional:file:./dev.properties

甚至,这个 spring.config.import 还可以导入无扩展名的配置文件,例如我有一个配置文件,是 properties 格式的,但是这个这个配置文件没有扩展名,现在我想将之作为 properties 格式的配置文件导入,方式如下:

spring.config.import=optional:file:/Users/sang/dev[.properties]

好啦,看完上面的内容,文章一开始的问题答案就不用我多说了吧~

作者:江南一点雨
来源:juejin.cn/post/7168285587374342180

收起阅读 »

听说你学过架构设计?来,弄个短链系统

01 引言1)背景这是本人在面试“字节抖音”部门的一道系统设计题,岗位是“后端高级开发工程师”,二面的时候问到的。一开始,面试官笑眯眯地让我做个自我介绍,然后聊了聊项目。当完美无瑕(吞吞吐吐)地聊完项目,并写了一道算法题之后。面试官就开始发问了:小伙子,简历里...
继续阅读 »

01 引言

1)背景

这是本人在面试“字节抖音”部门的一道系统设计题,岗位是“后端高级开发工程师”,二面的时候问到的。一开始,面试官笑眯眯地让我做个自我介绍,然后聊了聊项目。

当完美无瑕(吞吞吐吐)地聊完项目,并写了一道算法题之后。

面试官就开始发问了:小伙子,简历里面写到了熟悉架构设计是吧,那你知道程序设计的‘三高’指什么吗?

我心想,那不是由于程序员的系统不靠谱,领导不当人,天天加班改 BUG,导致年纪轻轻都高血脂、高血压和高血糖嘛!

但是,既然是面试,那领导肯定不愿意听这,于是我回答:程序三高,就是系统设计时需要考虑的高并发、高性能和高可用:

  • 高并发就是在系统开发的过程中,需要保证系统可以同时并行处理很多请求;

  • 高性能是指程序需要尽可能地占用更少的内存和 CPU,并且处理请求的速度要快;

  • 高可用通常描述系统在一段时间内不可服务的时候很短,比如全年停机不超过 31.5 秒,俗称 6 个 9,即保证可用的时间为 99.9999%。

于是,面试官微微点头,心想小伙子还行,既然这难不住你,那我可得出大招了,就来道系统设计题吧!

2)需求说明

众所周知,当业务场景需要给用户发送网络地址或者二维码时,由于地址的长度比较长,通常为了占用更少的资源和提升用户体验。例如,谷歌搜索“计算机”词条地址如下:

https://www.google.com/search?q=%E8%AE%A1%E7%AE%97%E6%9C%BA&ei=KNZ5Y7y4MpiW-AaI4LSACw&ved=0ahUKEwi87MGgnbz7AhUYC94KHQgwDbAQ4dUDCBA&uact=5&oq=%E8%AE%A1%E7%AE%97%E6%9C%BA&gs_lcp=Cgxnd3Mtd2l6LXNlcnAQAzIECAAQQzIFCAAQgAQyBQgAEIAEMgUIABCABDIFCC4QgAQyBQgAEIAEMgUIABCABDIFCAAQgAQyBQgAEIAEMgUIABCABDoKCAAQRxDWBBCwAzoLCC4QgAQQxwEQ0QM6FggAEOoCELQCEIoDELcDENQDEOUCGAE6BwguENQCEENKBAhBGABKBAhGGABQpBZYzSVglydoA3ABeACAAZ0DiAGdD5IBCTAuNy4xLjAuMZgBAKABAbABCsgBCsABAdoBBAgBGAc&sclient=gws-wiz-serp

很明显,如果将这一长串网址发给用户,是十分不“体面”的。而且,遇到一些有字数限制的系统里面,比如微博发帖子就有字数限制,肯定无法发送这样的长链接地址。一般的短信链接中,大多也都是短链接地址:


所以,为了提升用户体验,以及日常业务的需要。我们需要设计一个短链接生成系统,除了业务功能实现以外,我们还得为全国的网络地址服务。在这么大的用户量下,数据该如何存储,高并发如何处理呢?

02 三种链接生成方法

1)需求分析

我心想,这面试官看着“慈眉善目”还笑眯眯的,但出的题目可不简单,这种类型的系统需要考虑的点太多了,绝对不能掉以轻心。

于是,我分别从链接生成、网址访问、缓存优化和高可用四个方面开始着手设计。

首先,生成短链地址,可以考虑用 UUID 或者自增 ID。对于每一个长链接转短链地址时,都必须生成一个全局唯一的短链值,不然就会发生冲突。所以,短链接的特点是:

  • 数据存储量很大,全国的网址每天至少都是百万个短链接地址需要生成;

  • 并发量也不小,遇到同时来访问系统,按一天 3600 秒来算,平均每秒至少上千个请求数;

  • 短链接不可重复,否则会引起数据访问冲突。

2)雪花算法

首先,生成短链接,可以用雪花算法+哈希的方式来实现。


雪花算法是在分布式场景下,根据时间戳、不同的机器 ID 以及序列号生成的唯一数。它的优点在于简单方便,随取随用。

通过雪花算法取到的唯一数,再用哈希映射,将数字转为一个随机的字符串,如果短链字符串比较长,可以直接取前 6 位。但是,由于哈希映射的结果可能会发生冲突,所以对哈希算法的要求比较高。

2)62 进制数生成短链接

除了雪花算法,还可以用 62 进制数(A-Za-z0-9)来生成短链接地址。首先得到一个自增 ID,再将此值转换为 62 进制(a-zA-Z0-9)的字符串,一个亿的数字转换后也就五六位(1亿 -> zAL6e)。

将短链接服务器域名,与这个字符串进行拼接,就能得到短链接的 URL,比如:t.cn/zAL6e。

而生成自增 ID 需要考虑性能影响和并发安全性,所以我们可以通过 Redis 的 incr 命令来做一个发号器,它是一个原子操作,因此我们不必担心数字的安全性。而 Redis 是内存操作,所以效率也挺高。

3)随机数+布隆过滤器

除了自增 ID 以外,我们还可以生成随机数再转 62 进制的方法来生成短链接。但是,由于随机数可能重复,因此我们需要用布隆过滤器来去重。

布隆过滤器是一个巧妙设计的数据结构,它的原理是将一个值多次哈希,映射到不同的 bit 位上并记录下来。当新的值使用时,通过同样的哈希函数,比对各个 bit 位上是否有值:如果这些 bit 位上都没有值,说明这个数是唯一的;否则,就可能不是唯一的。

当然,这可能会产生误判,布隆过滤器一定可以发现重复的值,但 也可能将不重复的值判断为重复值,误判率大概为 0.05%,是可以接受的范围,而且布隆过滤器的效率极高。

因此,通过布隆过滤器,我们能判断生成的随机数是否重复:如果重复,就重新生成一个;如果不重复,就存入布隆过滤器和数据库,从而保证每次取到的随机数都是唯一的。

4)将短链接存到数据库

存库时,可能会因为库存量和技术栈,选用不同的数据库。但由于公司部门用 MySQL 比较多,且当前题目未提及技术选型,所以我们还是选用 MySQL 作为持久化数据库。

每当生成一个短链接后,需要在 MySQL 存储短链接到长链接的映射关系并加上唯一索引,即 zAL6e -> 真实URL。

03 重定向过程

浏览器访问短链接服务时,根据短链地址取到原始 URL,然后进行网址重定向。我们通常有两种重定向方式:

  • 一种是返回给浏览器 301 响应码永久重定向,让其后续直接访问真实的 URL 地址;

  • 一种是 302 临时重定向,让浏览器当前这次访问真实 URL,但后续请求时还是根据短链地址访问。

虽然用 301 浏览器只需一次请求,后续可以直接从浏览器获取长链接,这种方法可以提升访问速度,但是它没法统计短链接的访问次数。

所以根据业务需要,我们一般选用 302 重定向。

04 缓存设计

由于短链接是分发到多个用户手里的,可能在短时间内会多次访问,所以从 MySQL 写入/获取到长链接后可以放入 redis 缓存。

1)加入缓存

并且,短链接和长链接的对应关系一般不会频繁修改,所以数据库和缓存的一致性通过简单的旁路缓存模式来保证:

  • 读(Read)数据时,若缓存未命中,则先读 DB,从 DB 中取出数据,放入缓存,同时返回响应;

  • 写(Write)数据时,先更新 DB,再删除缓存。

当用户需要生成短链接时,先到这个映射表中看一下有没有对应的短链接地址。有就直接返回,并将这个 key-value 的过期时间增加一小时;没有就重新生成,并且将对应关系存入这个映射表中。

缓存的淘汰策略可以选用:

  • LRU:Least Recently Used,最近最少使用算法,最近经常被读写的短链地址作为热点数据可以一直存在于缓存,淘汰那些很久没有访问过的短链 key;

  • LFU:Least Frequently Userd,最近最不频繁使用算法,最近访问频率高的短链地址作为热点数据,淘汰那些访问频率较低的短链 key。

2)缓存穿透

但是,使用缓存也防止不了一些异常情况,比如“缓存穿透”。所谓缓存穿透,就是查询一个缓存和数据库中都不存在的短链接,如果并发量很大,就会导致所有在缓存中不存在的请求都打到 MySQL 服务器上,导致服务器处理不了这么多请求而阻塞,甚至崩溃。

所以,为了防止不法分子通过类似“缓存穿透”的方式来攻击服务器,我们可以采用两种方法来应对:

  • 对不存在的短链地址加缓存,key 为短链接地址,value 值为空,过期时间可以设置得短一点;

  • 采用布隆过滤器将已有的短链接多次哈希后存起来,当有短链接请求时,先通过布隆过滤器判断一下该地址是否存在数据库中;如果不在,则说明数据库中不存在该地址,就直接返回。

05 高可用设计

由于缓存和数据库持久化依赖于 Redis 和 MySQL,因此 MySQL 和 Redis 的高可用性必须要保证。

1)MySQL 高可用

MySQL 数据库采用主从复制,进行读写分离。Master 节点进行写操作,Slave 节点用作读操作,并且可以用 Keepalived 来实现高可用。

Keepalived 的原理是采用虚拟 IP,检测入口的多个节点,选用一台热备服务器作为主服务器,并分配给它一个虚拟 IP,外部请求都通过这个虚拟 IP 来访问数据库。

同时,Keepalived 会实时检测多个节点的可用状态,当发现一台服务器宕机或出现故障时,会从集群中将这台服务器踢除。如果这台服务器是主服务器,keepalived 会触发选举操作,从服务器集群中再选出一个服务器充当 master 并分配给它相同的虚拟 IP,以此完成故障转移。

并且,在 Keepalived 的支持下,这些操作都不需要人工参与,只需修复故障机器即可。

2)Redis 高可用

由于在大数据高并发的场景下,写请求全部落在 Redis 的 master 节点上,压力太大。如果一味地采用增加内存和 CPU 这种纵向扩容的方式,那么一台机器所面临的磁盘 IO,网络等压力逐渐增大,也会影响性能。

所以 Redis 采用集群模式,实现数据分片。并且,加入了哨兵机制来保证集群的高可用。它的基本原理是哨兵节点监控集群中所有的主从节点,当主节点宕机或者发生故障以后,哨兵节点会标记它为主观下线;当足够多的哨兵节点将 Redis 主节点标记为主观下线,就将其状态改为客观下线

此时,哨兵节点们通过选举机制选出一个领头哨兵,对 Redis 主节点进行故障转移操作,以保障 Redis 集群的高可用,这整个流程都不需要人工干预。

3)系统容错

服务在上线之前,需要做好充分的业务量评估,以及性能测试。做好限流、熔断和服务降级的逻辑,比如:采用令牌桶算法实现限流,hystrix 框架来做熔断,并且将常用配置放到可以热更新的配置中心,方便对其实时更改。

当业务量过大时,将同步任务改为异步任务处理。通过这些服务治理方案,让系统更加稳定。

06 后记

当我答完最后一个字的时候,面试官看着我,眼神中充满了“欣赏”与疑惑。我想,他应该是被我这番表现给镇住了,此次面试应该是十拿九稳。

但是,出奇地,面试官没有对刚才的架构设计提出评价,只看了看我说:“那今天的面试就到这里,你有什么想要问的吗?”

这下,轮到我震惊了,那到底过不过呢?倒是给句话呀,于是我问道:“通过这次面试,您觉得我有哪些方面需要提升呢?”

“算法和项目需要再多练练,但是我发现了你一个优点。”面试官笑了笑接着说,“八股文背的倒是挺不错的!”

悬着的心总算放下,我心想:“哦,那稳了~”

作者:xin猿意码
来源:juejin.cn/post/7168090412370886686

收起阅读 »

我说MySQL每张表最好不超过2000万数据,面试官让我回去等通知?

事情是这样的下面是我朋友的面试记录:面试官:讲一下你实习做了什么。朋友:我在实习期间做了一个存储用户操作记录的功能,主要是从MQ获取上游服务发送过来的用户操作信息,然后把这些信息存到MySQL里面,提供给数仓的同事使用。由于数据量比较大,每天大概有四五千多万条...
继续阅读 »

事情是这样的

下面是我朋友的面试记录:

面试官:讲一下你实习做了什么。

朋友:我在实习期间做了一个存储用户操作记录的功能,主要是从MQ获取上游服务发送过来的用户操作信息,然后把这些信息存到MySQL里面,提供给数仓的同事使用。由于数据量比较大,每天大概有四五千多万条,所以我还给它做了分表的操作。每天定时生成3张表,然后将数据取模分别存到这三张表里,防止表内数据过多导致查询速度降低。

这表述,好像没什么问题是吧,别急,接着看:

面试官:那你为什么要分三张表呢,两张表不行吗?四张表不行吗?

朋友:因为MySQL每张表最好不超过2000万条数据,否则会导致查询速度降低,影响性能。我们每天的数据大概是在五千万条左右,所以分成三张表比较稳妥。

面试官:还有吗?

朋友: 没有了…… 你干嘛,哎呦

面试官:那你先回去等通知吧。

🤣🤣🤣讲完了,看出什么了吗,你们觉得我这位朋友回答的有什么问题吗?

前言

一般来说,MySQL每张表最好不要超过2000万条数据,否则就会导致性能下降。阿里的Java开发手册上也提出:单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表。

但实际上,这个2000万或者500万都只是一个大概的数字,并不适用于所有场景,如果盲目的以为表数据只要不超过2000万条就没问题了,很可能会导致系统的性能大幅下降。

实际情况下,每张表由于自身的字段不同、字段所占用的空间不同等原因,它们在最佳性能下可以存放的数据量也就不同。

那么,该如何计算出每张表适合的数据量呢?别急,慢慢往下看。

本文适合的读者

阅读本文你需要有一定的MySQL基础,最好对InnoDB和B+树都有一定的了解,可能需要有一年以上的MySQL学习经验(大概一年?),知道 “InnoDB中B+树的高度一般保持在三层以内会比较好”。

本文主要是针对 “InnoDB中高度为3的B+树最多可以存多少数据” 这一话题进行讲解的。且本文对数据的计算比较严格(至少比网上95%以上的相关博文都要严格),如果你比较在意这些细节并且目前不太清楚的话,请继续往下阅读。

阅读本文你大概需要花费10-20分钟的时间,如果你在阅读的过程中对数据进行验算的话,可能要花费30分钟左右。


基础知识快速回顾

众所周知,MySQL中InnoDB的存储结构是B+树,B+树大家都熟悉吧?特性大概有以下几点,一起快速回顾一下吧!

注:下面这这些内容都是精华,看不懂或者不理解的同学建议先收藏本文,之后有知识基础了再回来看 。🤣🤣

  1. 一张数据表一般对应一颗或多颗树的存储,树的数量与建索引的数量有关,每个索引都会有一颗单独的树。

  2. 聚簇索引和非聚簇索引:

    主键索引也是聚簇索引,非主键索引都是非聚簇索引,两种索引的非叶子节点都是只存索引数据的,比如索引为id,那非叶子节点就只存id的数据。

    叶子节点的区别如下:

    • 聚簇索引的叶子节点存的是这条数据的所有字段信息。所以我们 select * from table where id = 1 的时候,都是要去叶子节点拿数据的。

    • 非聚簇索引的叶子节点存的是这条数据所对应的主键信息。比如这条非聚簇索引是username,然后表的主键是id,那该非聚簇索引的叶子节点存的就是id,而不存其他字段。 相当于是先从非聚簇索引查到主键的值,再根据主键索引去查数据内容,一般情况下要查两次(除非索引覆盖),这也称之为 回表 ,就有点类似于存了个指针,指向了数据存放的真实地址。

  3. B+树的查询是从上往下一层层查询的,一般情况下我们认为B+树的高度保持在3层是比较好的,也就是上两层是索引,最后一层存数据,这样查表的时候只需要进行3次磁盘IO就可以了(实际上会少一次,因为根节点会常驻内存)。

    如果数据量过大,导致B+数变成4层了,则每次查询就需要进行4次磁盘IO了,从而使性能下降。所以我们才会去计算InnoDB的3层B+树最多可以存多少条数据。

  4. MySQL每个节点大小默认为16KB,也就是每个节点最多存16KB的数据,可以修改,最大64KB,最小4KB。

    扩展:那如果某一行的数据特别大,超过了节点的大小怎么办?

    MySQL5.7文档的解释是:

    • 对于 4KB、8KB、16KB 和 32KB设置 ,最大行长度略小于数据库页面的一半 ,例如:对于默认的 16KB页大小,最大行长度略小于 8KB 。

    • 而对于 64KB 页面,最大行则长度略小于 16KB。

    • 如果行超过最大行长度, 则将可变长度列用外部页存储,直到该行符合最大行长度限制。 就是说把varchar、text这种长度可变的存到外部页中,来减小这一行的数据长度。

    image-20221108112456250

    文档地址:MySQL :: MySQL 5.7 Reference Manual :: 14.12.2 File Space Management

  5. MySQL查询速度主要取决于磁盘的读写速度,因为MySQL查询的时候每次只读取一个节点到内存中,通过这个节点的数据找到下一个要读取的节点位置,再读取下一个节点的数据,直到查询到需要的数据或者发现数据不存在。

    肯定有人要问了,每个节点内的数据难道不用查询吗?这里的耗时怎么不计算?

    这是因为读取完整个节点的数据后,会存到内存当中,在内存中查询节点数据的耗时其实是很短的,再配合MySQL的查询方式,时间复杂度差不多为 O(log2N)O(log_2N)O(log2N) ,相比磁盘IO来说,可以忽略不计。


MySQL B+树每个节点都存里些什么?

在Innodb的B+树中,我们常说的节点被称之为 页(page),每个页当中存储了用户数据,所有的页合在一起组成了一颗B+树(当然实际会复杂很多,但我们只是要计算可以存多少条数据,所以姑且可以这么理解😅)。

是InnoDB存储引擎管理数据库的最小磁盘单位,我们常说每个节点16KB,其实就是指每页的大小为16KB。

这16KB的空间,里面需要存储 页格式 信息和 行格式 信息,其中行格式信息当中又包含一些元数据和用户数据。所以我们在计算的时候,要把这些数据的都计算在内。

页格式

每一页的基本格式,也就是每一页都会包含的一些信息,总结表格如下:

名称空间含义和作用等
File Header38字节文件头,用来记录页的一些头信息。 包括校验和、页号、前后节点的两个指针、 页的类型、表空间等。
Page Header56字节页头,用来记录页的状态信息。 包括页目录的槽数、空闲空间的地址、本页的记录数、 已删除的记录所占用的字节数等。
Infimum & supremum26字节用来限定当前页记录的边界值,包含一个最小值和一个最大值。
User Records不固定用户记录,我们插入的数据就存储在这里。
Free Space不固定空闲空间,用户记录增加的时候从这里取空间。
Page Directort不固定页目录,用来存储页当中用户数据的位置信息。 每个槽会放4-8条用户数据的位置,一个槽占用1-2个字节, 当一个槽位超过8条数据的时候会自动分成两个槽。
File Trailer8字节文件结尾信息,主要是用来校验页面完整性的。

示意图:


页格式这块的内容,我在官网翻了好久,硬是没找到🤧。。。。不知道是没写还是我眼瞎,有找到的朋友希望可以在评论区帮我挂出来😋。

所以上面页格式的表格内容主要是基于一些博客中学习总结的。

另外,当新记录插入到 InnoDB 聚集索引中时,InnoDB 会尝试留出 1/16 的页面空闲以供将来插入和更新索引记录。如果按顺序(升序或降序)插入索引记录,则生成的页大约可用 15/16 的空间。如果以随机顺序插入记录,则页大约可用 1/2 到 15/16 的空间。参考文档:MySQL :: MySQL 5.7 Reference Manual :: 14.6.2.2 The Physical Structure of an InnoDB Index

除了 User RecordsFree Space 以外所占用的内存是 38+56+26+8=12838 + 56 + 26 + 8 = 12838+56+26+8=128 字节,每一页留给用户数据的空间就还剩 16×1516×1024−128=1523216 \times \frac{15}{16} \times 1024 - 128 = 1523216×1615×1024−128=15232 字节(保留了1/16)。

当然,这是最小值,因为我们没有考虑页目录。页目录留在后面根据再去考虑,这个得根据表字段来计算。

行格式

首先,我觉得有必要提一嘴,MySQL5.6的默认行格式为COMPACT(紧凑),5.7及以后的默认行格式为DYNAMIC(动态),不同的行格式存储的方式也是有区别的,还有其他的两种行格式,本文后续的内容主要是基于DYNAMIC(动态)进行讲解的。

官方文档链接:MySQL :: MySQL 5.7 参考手册 :: 14.11 InnoDB 行格式(包括下面的行格式内容大都可以在里面找到)



每行记录都包含以下这些信息,其中大都是可以从官方文档当中找到的。我这里写的不是特别详细,仅写了一些能够我们计算空间的知识,更详细内容可以去网上搜索 “MySQL 行格式”。

名称空间含义和作用等
行记录头信息5字节行记录的标头信息 包含了一些标志位、数据类型等信息 如:删除标志、最小记录标志、排序记录、数据类型、 页中下一条记录的位置等
可变长度字段列表不固定来保存那些可变长度的字段占用的字节数,比如varchar、text、blob等。 若变长字段的长度小于 255字节,就用1字节表示; 若大于 255字节,用2字节表示。 表字段中有几个可变长字段该列表中就有几个值,如果没有就不存。
null值列表不固定用来存储可以为null的字段是否为null。 每个可为null的字段在这里占用一个bit,就是bitmap的思想。 该列表占用的空间是以字节为单位增长的,例如,如果有 9 到 16 个 可以为null的列,则使用两个字节,没有占用1.5字节这种情况。
事务ID和指针字段6+7字节了解MVCC的朋友应该都知道,数据行中包含了一个6字节的事务ID和 一个7字节的指针字段。 如果没有定义主键,则还会多一个6字节的行ID字段 当然我们都有主键,所以这个行ID我们不计算。
实际数据不固定这部分就是我们真实的数据了。

示意图:


另外还有几点需要注意:

溢出页(外部页)的存储

注意:这一点是DYNAMIC的特性。

当使用 DYNAMIC 创建表时,InnoDB 会将较长的可变长度列(比如 VARCHAR、VARBINARY、BLOB 和 TEXT 类型)的值剥离出来,存储到一个溢出页上,只在该列上保留一个 20 字节的指针指向溢出页。

而 COMPACT 行格式(MySQL5.6默认格式)则是将前 768 个字节和 20 字节的指针存储在 B+ 树节点的记录中,其余部分存储在溢出页上。

列是否存储在页外取决于页大小和行的总大小。当一行太长时,选择最长的列进行页外存储,直到聚集索引记录适合 B+ 树页(文档里没说具体是多少😅)。小于或等于 40 字节的 TEXT 和 BLOB 直接存储在行内,不会分页。

优点

DYNAMIC 行格式避免了用大量数据填充 B+ 树节点从而导致长列的问题。

DYNAMIC 行格式的想法是,如果长数据值的一部分存储在页外,则通常将整个值存储在页外是最有效的。

使用 DYNAMIC 格式,较短的列会尽可能保留在 B+ 树节点中,从而最大限度地减少给定行所需的溢出页数。

字符编码不同情况下的存储

char 、varchar、text 等需要设置字符编码的类型,在计算所占用空间时,需要考虑不同编码所占用的空间。

varchar、text等类型会有长度字段列表来记录他们所占用的长度,但char是固定长度的类型,情况比较特殊,假设字段 name 的类型为 char(10) ,则有以下情况:

  • 对于长度固定的字符编码(比如ASCII码),字段 name 将以固定长度格式存储,ASCII码每个字符占一个字节,那 name 就是占用 10 个字节。

  • 对于长度不固定的字符编码(比如utf8mb4),至少将为 name 保留 10 个字节。如果可以,InnoDB会通过修剪尾部空格空间的方式来将其存到 10 个字节中。

    如果空格剪完了还存不下,则将尾随空格修剪为 列值字节长度的最小值(一般是 1 字节)。

    列的最大长度为: 字符编码的最大字符长度×N字符编码的最大字符长度 \times N字符编码的最大字符长度×N,比如 name 字段的编码为 utf8mb4,那就是 4×104 \times 104×10。

  • 大于或等于 768 字节的 char 列会被看成是可变长度字段(就像varchar一样),可以跨页存储。例如,utf8mb4 字符集的最大字节长度为 4,则 char(255) 列将可能会超过 768 个字节,进行跨页存储。

说实话对char的这个设计我是不太理解的,尽管看了很久,包括官方文档和一些博客🤧,希望懂的同学可以在评论区解惑:

对于长度不固定的字符编码这块,char是不是有点像是一个长度可变的类型了?我们常用的 utf8mb4,占用为 1 ~ 4 字节,那么 char(10) 所占用的空间就是 10 ~ 40 字节,这个变化还是挺大的啊,但是它并没有留足够的空间给它,也没有使用可变长度字段列表去记录char字段的空间占用情况,就很特殊?


开始计算

好了,我们已经知道每一页当中具体存储的东西了,现在我们已经具备计算能力了。

由于页的剩余空间我已经在上面页格式的地方计算过了,每页会剩余 15232 字节可用,下面我们直接计算行。

非叶子节点计算

单个节点计算

索引页就是存索引的节点,也就是非叶子节点。

每一条索引记录当中都包含了当前索引的值一个 6字节 的指针信息一个 5 字节的行标头,用来指向下一层数据页的指针。

索引记录当中的指针占用空间我没在官方文档里找到😭,这个 6 字节是我参考其他博文的,他们说源码里写的是6字节,但具体在哪一段源码我也不知道😭。

希望知道的同学可以在评论区解惑。

假设我们的主键id为 bigint 型,也就是8个字节,那索引页中每行数据占用的空间就等于 8+6+5=198 + 6 + 5 = 198+6+5=19 字节。每页可以存 15232÷19≈80115232 \div 19 \approx 80115232÷19≈801 条索引数据。

那算上页目录的话,按每个槽平均6条数据计算的话,至少有 801÷6≈134801 \div 6 \approx 134801÷6≈134 个槽,需要占用 268 字节的空间。

把存数据的空间分一点给槽的话,我算出来大约可以存 787 条索引数据。

如果是主键是 int 型的话,那可以存更多,大约有 993 条索引数据。

前两层非叶子节点计算

在 B+ 树当中,当一个节点索引记录为 NNN 条时,它就会有 NNN 个子节点。由于我们 3 层B+树的前两层都是索引记录,第一层根节点有 NNN 条索引记录,那第二层就会有 NNN 个节点,每个节点数据类型与根节点一致,仍然可以再存 NNN 条记录,第三层的节点个数就会等于 N2N^2N2。

则有:

  • 主键为 bigint 的表可以存放 7872=619369787 ^ 2 = 6193697872=619369 个叶子节点

  • 主键为 int 的表可以存放 9932=986049993 ^ 2 = 9860499932=986049 个叶子节点

OK计算完毕。

数据条数计算

最少存放记录数

前面我们提到,最大行长度略小于数据库页面的一半,之所以是略小于一半,是由于每个页面还留了点空间给页格式 的其他内容,所以我们可以认为每个页面最少能放两条数据,每条数据略小于8KB。如果某行的数据长度超过这个值,那InnoDB肯定会分一些数据到 溢出页 当中去了,所以我们不考虑。

那每条数据8KB的话,每个叶子节点就只能存放 2 条数据,这样的一张表,在主键为 bigint 的情况下,只能存放 2×619369=12387382 \times 619369 = 12387382×619369=1238738 条数据,也就是一百二十多万条,这个数据量,没想到吧🤣🤣。

较多的存放记录数

假设我们的表是这样的:

-- 这是一张非常普通的课程安排表,除id外,仅包含了课程id和老师id两个字段
-- 且这几个字段均为 int 型(当然实际生产中不会这么设计表,这里只是举例)。

CREATE TABLE `course_schedule` (
`id` int NOT NULL,
`teacher_id` int NOT NULL,
`course_id` int NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

先来分析一下这张表的行数据:无null值列表,无可变长字段列表,需要算上事务ID和指针字段,需要算上行记录头,那么每行数据所占用的空间就是 4+4+4+6+7+5=304 + 4 + 4 + 6 + 7 + 5 = 304+4+4+6+7+5=30 字节,每个叶子节点可以存放 15232÷30≈50715232 \div 30 \approx 50715232÷30≈507 条数据。

算上页目录的槽位所占空间,每个叶子节点可以存放 502 条数据,那么三层B+树可以存放的最大数据量就是 502×986049=494,996,598502 \times 986049 = 494,996,598502×986049=494,996,598,将近5亿条数据!没想到吧🤡😏。

常规表的存放记录数

大部分情况下我们的表字段都不是上面那样的,所以我选择了一场比较常规的表来进行分析,看看能存放多少数据。表情况如下:

CREATE TABLE `blog` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '博客id',
`author_id` bigint unsigned NOT NULL COMMENT '作者id',
`title` varchar(50) CHARACTER SET utf8mb4 NOT NULL COMMENT '标题',
`description` varchar(250) CHARACTER SET utf8mb4 NOT NULL COMMENT '描述',
`school_code` bigint unsigned DEFAULT NULL COMMENT '院校代码',
`cover_image` char(32) DEFAULT NULL COMMENT '封面图',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`release_time` datetime DEFAULT NULL COMMENT '首次发表时间',
`modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`status` tinyint unsigned NOT NULL COMMENT '发表状态',
`is_delete` tinyint unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `author_id` (`author_id`),
KEY `school_code` (`school_code`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_general_mysql500_ci ROW_FORMAT=DYNAMIC;

这是我的开源项目“校园博客”(GitHub地址:github.com/stick-i/scb…) 中的博客表,用于存放博客的基本数据。

分析一下这张表的行记录:

  1. 行记录头信息:肯定得有,占用5字节。

  2. 可变长度字段列表:表中 title占用1字节,description占用2字节,共3字节。

  3. null值列表:表中仅school_codecover_imagerelease_time3个字段可为null,故仅占用1字节。

  4. 事务ID和指针字段:两个都得有,占用13字节。

  5. 字段内容信息:

    1. id、author_id、school_code 均为bigint型,各占用8字节,共24字节。

    2. create_time、release_time、modified_time 均为datetime类型,各占8字节,共24字节。

    3. status、is_delete 为tinyint类型,各占用1字节,共2字节。

    4. cover_image 为char(32),字符编码为表默认值utf8,由于该字段实际存的内容仅为英文字母(存url的),结合前面讲的字符编码不同情况下的存储 ,故仅占用32字节。

    5. title、description 分别为varchar(50)、varchar(250),这两个应该都不会产生溢出页(不太确定),字符编码均为utf8mb4,实际生产中70%以上都是存的中文(3字节),25%为英文(1字节),还有5%为4字节的表情😁,则存满的情况下将占用 (50+250)×(0.7×3+0.25×1+0.05×4)=765(50 + 250) \times (0.7 \times 3 + 0.25 \times 1 + 0.05 \times 4 ) = 765(50+250)×(0.7×3+0.25×1+0.05×4)=765 字节。

统计上面的所有分析,共占用 869 字节,则每个叶子节点可以存放 15232÷869≈1715232 \div 869 \approx 1715232÷869≈17 条,算上页目录,仍然能放 17 条。

则三层B+树可以存放的最大数据量就是 17×619369=10,529,27317 \times 619369 = 10,529,27317×619369=10,529,273,约一千万条数据,再次没想到吧👴。

数据计算总结

根据上面三种不同情况下的计算,可以看出,InnoDB三层B+树情况下的数据存储量范围为 一百二十多万条将近5亿条,这个跨度还是非常大的,同时我们也计算了一张博客信息表,可以存储 约一千万条 数据。

所以啊,我们在做项目考虑分表的时候还是得多关注一下表的实际情况,而不是盲目的认为两千万数据都是那个临界点。

面试时如果谈到这块的问题,我想面试官也并不是想知道这个数字到底是多少,而是想看你如何分析这个问题,如何得出这个数字的过程。

如果本文中有任何写的不对的地方,欢迎各位朋友在评论区指正🥰。

写在后面的一些话

这篇文章写了整整两周😭😭(虽然第一周在划水),真的超级干货了,前前后后查了好多资料,也看了好多博文,官方文档有些地方写的确实含糊,我看了好久都没看懂😂😂。

作者:阿杆
来源:juejin.cn/post/7165689453124517896

收起阅读 »

B+树的生成过程 怎么去看懂B+树

前提看B+树 看不懂树结构什么意思 这一篇可以帮你理解树结构生成的过程在说B+ 树之前 需要知道 一页的大小是多少show global status like 'innodb_page_size'这个是看出 一页是 16384 也就是 16384/102...
继续阅读 »

前提

看B+树 看不懂树结构什么意思 这一篇可以帮你理解树结构生成的过程

在说B+ 树之前 需要知道 一页的大小是多少

show global status like 'innodb_page_size'


这个是看出 一页是 16384 也就是 16384/1024 = 16kb innodb中一页的大小默认是 16kb

正文

创建表结构 指定引擎为 Innodb

CREATE TABLE tree(
id int PRIMARY key auto_increment,
t_name VARCHAR(20),
t_code int
) ENGINE=INNODB

查看一下当前表的索引情况

show index from tree 

B树和B+树的显示都是BTREE 但是实际使用的B+树 但是B+树也是B树的升级版 称之为B树也是没有问题的


创建数据 这里会有一个小知识点 如果看过上一篇文章的朋友可以明白是为什么

INSERT into tree VALUES(3,"变成派大星",3);
INSERT into tree VALUES(1,"变成派大星",1);
INSERT into tree VALUES(2,"变成派大星",2);
INSERT into tree VALUES(4,"变成派大星",4);
INSERT into tree VALUES(7,"变成派大星",7);
INSERT into tree VALUES(5,"变成派大星",5);
INSERT into tree VALUES(6,"变成派大星",6);
INSERT into tree VALUES(8,"变成派大星",8);


疑问

为什么创建数据的时候数据是乱的但是在创建好数据 被拍好顺序了

基础知识

我们在寻找答案之前 想明白一些基础知识

细心的朋友可以看出来 我们插入Id 时候数据是乱的 插入进去之后 数据就自动帮我通过Id 进行排序了 这是为什么呢?接着往下看

我们如果对于B+树有点了解的话就知道B+树是每页16KB 进行数据储存 在进行数据查询的时候也是一页一页的去查询

相当于下面的数据

首先每一页都有很多数据 就像 我们平常去写分页的时候我们返回给前端的数据也会有很多属性


这个可能比较抽象 我是把他当成平常 分页查询的思想代入进去

我们可以把一页想成是一个对象

@Data
public class page {

List<UserRecords> data;

....省略其余属性
}

我们先看一下 一页数据的图是什么样子 仅仅是进行逻辑思考画的图

这里的Data 就相当于 一页中的数据区域


但是这里是有限制的 上面我们说到 一页的数据只能是16Kb 也就是 一个Page 里面的data只能16Kb 数据 当超过16Kb 就会新开一个对象相当于在进行创建树的时候增加了判断

Java代码思路模拟:


当 Page 对象的大小已经达到16Kb 就算完成这一页 把这一页放到 磁盘中等待使用就行了 到时候进行查询数据的时候会直接返回这一页 里面包含这些数据

我们回到最初的问题 为什么我们在进行插入的时候明明Id 是乱的 等到插入到数据的时候 数据就变成有序的了 我们知道 同时这个数据是根据主键进行排序的 也就Innobd 的数据储存一定是要依赖主键的 有些人会想 我就是不创建主键 他还能排序吗?

疑问二

我们在疑问一的基础上 产生出的疑问 不设置主键 Mysql怎么办

解答

InnoDB对聚簇索引处理如下:

  • 如果定义了主键,那么InnoDB会使用主键作为聚簇索引

  • 如果没有定义主键,那么会使用第一非空的唯一索引(NOT NULL and UNIQUE INDEX)作为聚簇索引

  • 如果既没有主键也找不到合适的非空索引,InnoDB会自动帮你创建一个不可见的、长度为6字节的row_id,而且InnoDB 维护了一个全局的 dictsys.row_id,所以未定义主键的表都共享该row_id,每次插入一条数据,都把全局row_id当成主键id,然后全局row_id加1

很明显,缺少主键的表,InnoDB会内置一列用于聚簇索引来组织数据。而没有建立主键的话就没法通过主键来进行索引,查询的时候都是全表扫描,小数据量没问题,大数据量就会出现性能问题。

但是,问题真的只是查询影响吗?不是的,对于生成的ROW_ID,其自增的实现来源于一个全局的序列,而所以有ROW_ID的表共享该序列,这也意味着插入的时候生成需要共享一个序列,那么高并发插入的时候为了保持唯一性就避免不了锁的竞争,进而影响性能

解答

我们看完疑问二的解答就知道 即便我们不设置主键 数据也会帮我们去生成一个默认的主键 有点像 类默认生成构造器的思想

有了主键之后呢?


为什么会自动排序 大家都知道了 其实在文章之初就会有很多人明白是为什么 大概脑子里会有答案

疑问三

为什么要进行排序

解答

我们都知道 在进行数据查找的时候 比如几个基础的查找算法的 前提都是 先进行排序 再者 List 和 Map 的一些区别肯定都很熟悉了 当然是为了更快 所以无需的Id 会对插入效率造成影响 也就是为什么很多文章说使用自增Id比UUID 或者雪花算效率高的原因 第一个是UUID他们是随机的 每次都要重新排序 甚至可能会因为排序的原因造成页数据的更换 还有就是 UUID 一般都比较长 一页是16Kb 数据越短 一页的数据就会越多 查询的速度也就比较快

这里说完为什么排序 还有一个点就是上面的页目录

疑问三

页目录的作用是什么?

通过上一篇文章可以明白 页目录的作用是减少范围


这里的第三层是数据 上面都是目录 可以增加数据的检索效率


如果没有目录我们需要去直接遍历数据区域 会降低效率 目录能帮我们缩小范围 这里 我们查询 ID = 3 我们可以通过目录知道 1 < 3 < 4 如果 在1 中没有找到对应数据 但是 因为 3 < 4 就不会接着往下查询了 直接返回空结果

当第一页没有的时候去第二页查询 不会直接跳到第二页查询


为了提高效率 当目录数据数量过多时 就会网上延伸一层树 同时可以减少磁盘的IO次数


关于所有叶子节点都处于同一深度是如何实现的?这与B+树具体的插入和删除算法有关。简单解释一下插入时的情况,根据插入值的大小,逐步向下直到对应的叶子节点。如果叶子节点关键字个数小于2t,则直接插入值或者更新卫星数据;如果插入之前叶子节点已经满了,则分裂该叶子节点成两半,并把中间值提上到父节点的关键字中,如果这导致父节点满了的话,则把该父节点分裂,如此递归向上。所以树高是一层层的增加的,叶子节点永远都在同一深度。

小总结

  • 内部节点并不存储真正的信息,而是保存其叶子节点的最小值作为索引。

  • 每次插入删除都进行更新(此时用到parent指针),保持最新状态。

  • B+ 树非叶子节点上是不存储数据的,仅存储键值

  • B+只在底层树储存数据,上层就会存储更多的键值,相应的树的阶数(节点的子节点树)就会更大,树就会更矮更胖,如此一来我们查找数据进行磁盘的 IO 次数又会再次减少,数据查询的效率也会更快。

  • B+ 树的阶数是等于键值的数量的,如果我们的 B+ 树一个节点可以存储 1000 个键值,那么 3 层 B+ 树可以存储 1000×1000×1000=10 亿个数据。

  • 一般根节点是常驻内存的,所以一般我们查找 10 亿数据,只需要 2 次磁盘 IO。

  • 因为 B+ 树索引的所有数据均存储在叶子节点,而且数据是按照顺序排列的。

  • 那么 B+ 树使得范围查找,排序查找,分组查找以及去重查找变得异常简单

  • 有心的读者可能还发现上图 B+ 树中各个页之间是通过双向链表连接的,叶子节点中的数据是通过单向链表连接的。

  • 其实上面的 B 树我们也可以对各个节点加上链表。这些不是它们之前的区别,是因为在 MySQL 的 InnoDB 存储引擎中,索引就是这样存储的。

  • 我们通过数据页之间通过双向链表连接以及叶子节点中数据之间通过单向链表连接的方式可以找到表中所有的数据。

作者:变成派大星
来源:juejin.cn/post/7162856738692005918

收起阅读 »

一个 MySQL 隐式转换的坑,差点把服务器整崩溃了

本来是一个平静而美好的下午,其他部门的同事要一份数据报表临时汇报使用,因为系统目前没有这个维度的功能,所以需要写个SQL马上出一下,一个同事接到这个任务,于是开始在测试环境拼装这条 SQL,刚过了几分钟,同事已经自信的写好了这条SQL,于是拿给DBA,到线上跑...
继续阅读 »

本来是一个平静而美好的下午,其他部门的同事要一份数据报表临时汇报使用,因为系统目前没有这个维度的功能,所以需要写个SQL马上出一下,一个同事接到这个任务,于是开始在测试环境拼装这条 SQL,刚过了几分钟,同事已经自信的写好了这条SQL,于是拿给DBA,到线上跑一下,用客户端工具导出Excel 就好了,毕竟是临时方案嘛。

就在SQL执行了之后,意外发生了,先是等了一下,发现还没执行成功,猜测可能是数据量大的原因,但是随着时间滴滴答答流逝,逐渐意识到情况不对了,一看监控,CPU已经上去了,但是线上数据量虽然不小,也不至于跑成这样吧,眼看着要跑死了,赶紧把这个事务结束掉了。

什么原因呢?查询的条件和 join 连接的字段基本都有索引,按道理不应该这样啊,于是赶紧把SQL拿下来,也没看出什么问题,于是限制查询条数再跑了一次,很快出结果了,但是结果却大跌眼镜,出来的查询结果并不是预期的。


经过一番检查之后,最终发现了问题所在,是 join 连接中有一个字段写错了,因为这两个字段有一部分名称是相同的,于是智能的 SQL 客户端给出了提示,顺手就给敲上去了。但是接下来,更让人迷惑了,因为要连接的字段是 int 类型,而写错的这个字段是 varchar 类型,难道不应该报错吗?怎么还能正常执行,并且还有预期外的查询结果?

难道是 MySQL 有 bug 了,必须要研究一下了。

复现当时的情景

假设有两张表,这两张表的结构和数据是下面这样的。

第一张 user表。

CREATE TABLE `user` (
 `id` int(11NOT NULL AUTO_INCREMENT,
 `name` varchar(50COLLATE utf8_bin DEFAULT NULL,
 `age` int(3DEFAULT NULL,
 `create_time` datetime DEFAULT NULL,
 `update_time` datetime DEFAULT NULL,
 PRIMARY KEY (`id`)
ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;


INSERT INTO `user` VALUES (1'张三'28'2022-09-06 07:40:56''2022-09-06 07:40:59');


第二张 order

CREATE TABLE `order` (
 `id` int(11NOT NULL AUTO_INCREMENT,
 `user_id` int(11DEFAULT NULL,
 `order_code` varchar(64COLLATE utf8_bin DEFAULT NULL,
 `money` decimal(20,0DEFAULT NULL,
 `title` varchar(255COLLATE utf8_bin DEFAULT NULL,
 `create_time` datetime DEFAULT NULL,
 `update_time` datetime DEFAULT NULL,
 PRIMARY KEY (`id`)
ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;


INSERT INTO `order` VALUES (12'1d90530e-6ada-47c1-b2fa-adba4545aabd'100'xxx购买两件商品''2022-09-06 07:42:25''2022-09-06 07:42:27');


目的是查看所有用户的 order 记录,假设数据量比较少,可以直接查,不考虑性能问题。

本来的 SQL 语句应该是这样子的,查询 order表中用户iduser_iduser表的记录。

select o.* from `user` u 
left JOIN `order` o on u.id = o.user_id;

但是呢,因为手抖,将 on 后面的条件写成了 u.id = o.order_code,完全关联错误,这两个字段完全没有联系,而且u.id是 int 类型,o.order_codevarchar类型。

select o.* from `user` u 
left JOIN `order` o on u.id = o.order_code;

这样的话, 当我们执行这条语句的时候,会不会查出数据来呢?

我的第一感觉是,不仅不会查出数据,而且还会报错,因为连接的这两个字段类型都不一样,值更不一样。

结果却被啪啪打脸,不仅没有报错,而且还查出了数据。


可以把这个问题简化一下,简化成下面这条语句,同样也会出现问题。

select * from `order` where order_code = 1;


明明这条记录的 order_code 字段的值是 1d90530e-6ada-47c1-b2fa-adba4545aabd,怎么用 order_code=1的条件就把它给查出来了。

根源所在

相信有的同学已经猜出来了,这里是 MySQL 进行了隐式转换,由于查询条件后面跟的查询值是整型的,所以 MySQL 将 order_code字段进行了字符串到整数类型的转换,而转换后的结果正好是 1

通过 cast函数转换验证一下结果。

select cast('1d90530e-6ada-47c1-b2fa-adba4545aabd' as unsigned);


再用两条 SQL 看一下字符串到整数类型转换的规则。

select cast('223kkk' as unsigned);
select cast('k223kkk' as unsigned);


223kkk转换后的结果是 223,而k223kkk转换后的结果是0。总结一下,转换的规则是:

1、从字符串的左侧开始向右转换,遇到非数字就停止;

2、如果第一个就是非数字,最后的结果就是0;

隐式转换的规则

当操作符与不同类型的操作数一起使用的时候,就会发生隐式转换。

例如算数运算符的前后是不同类型时,会将非数字类型转换为数字,比如 '5a'+2,就会将5a转换为数字类型,然后和2相加,最后的结果就是 7 。


再比如 concat函数是连接两个字符串的,当此函数的参数出现非字符串类型时,就会将其转换为字符串,例如concat(88,'就是发'),最后的结果就是 88就是发


MySQL 官方文档有以下几条关于隐式转换的规则:

1、两个参数至少有一个是 NULL 时,比较的结果也是 NULL,例外是使用 <=> 对两个 NULL 做比较时会返回 1,这两种情况都不需要做类型转换;

也就是两个参数中如果只有一个是NULL,则不管怎么比较结果都是 NULL,而两个 NULL 的值不管是判断大于、小于或等于,其结果都是1。

2、两个参数都是字符串,会按照字符串来比较,不做类型转换;

3、两个参数都是整数,按照整数来比较,不做类型转换;

4、十六进制的值和非数字做比较时,会被当做二进制字符串;

例如下面这条语句,查询 user 表中name字段是 0x61 的记录,0x是16进制写法,其对应的字符串是英文的 'a',也就是它对应的 ASCII 码。

select * from user where name = 0x61;

所以,上面这条语句其实等同于下面这条

select * from user where name = 'a';

可以用 select 0x61;验证一下。

5、有一个参数是 TIMESTAMP 或 DATETIME,并且另外一个参数是常量,常量会被转换为 时间戳;

例如下面这两条SQL,都是将条件后面的值转换为时间戳再比较了,只不过


6、有一个参数是 decimal 类型,如果另外一个参数是 decimal 或者整数,会将整数转换为 decimal 后进行比较,如果另外一个参数是浮点数(一般默认是 double),则会把 decimal 转换为浮点数进行比较;

在不同的数值类型之间,总是会向精度要求更高的那一个类型转换,但是有一点要注意,在MySQL 中浮点数的精度只有53 bit,超过53bit之后的话,如果后面1位是1就进位,如果是0就直接舍弃。所以超大浮点数在比较的时候其实只是取的近似值。

7、所有其他情况下,两个参数都会被转换为浮点数再进行比较;

如果不符合上面6点规则,则统一转成浮点数再进行运算

避免进行隐式转换

我们在平时的开发过程中,尽量要避免隐式转换,因为一旦发生隐式转换除了会降低性能外, 还有很大可能会出现不期望的结果,就像我最开始遇到的那个问题一样。

之所以性能会降低,还有一个原因就是让本来有的索引失效。

select * from `order` where order_code = 1;

order_code 是 varchar 类型,假设我已经在 order_code 上建立了索引,如果是用“=”做查询条件的话,应该直接命中索引才对,查询速度会很快。但是,当查询条件后面的值类型不是 varchar,而是数值类型的话,MySQL 首先要对 order_code 字段做类型转换,转换为数值类型,这时候,之前建的索引也就不会命中,只能走全表扫描,查询性能指数级下降,搞不好,数据库直接查崩了。

作者:古时的风筝
来源:juejin.cn/post/7161991470268825631

收起阅读 »

一台服务器最大能支持多少条TCP连接

一、一台服务器最大能打开的文件数1、限制参数我们知道在Linux中一切皆文件,那么一台服务器最大能打开多少个文件呢?Linux上能打开的最大文件数量受三个参数影响,分别是:fs.file-max (系统级别参数):该参数描述了整个系统可以打开的最大文件数量。但...
继续阅读 »

一、一台服务器最大能打开的文件数

1、限制参数

我们知道在Linux中一切皆文件,那么一台服务器最大能打开多少个文件呢?Linux上能打开的最大文件数量受三个参数影响,分别是:

  • fs.file-max (系统级别参数):该参数描述了整个系统可以打开的最大文件数量。但是root用户不会受该参数限制(比如:现在整个系统打开的文件描述符数量已达到fs.file-max ,此时root用户仍然可以使用ps、kill等命令或打开其他文件描述符)

  • soft nofile(进程级别参数):限制单个进程上可以打开的最大文件数。只能在Linux上配置一次,不能针对不同用户配置不同的值

  • fs.nr_open(进程级别参数):限制单个进程上可以打开的最大文件数。可以针对不同用户配置不同的值

这三个参数之间还有耦合关系,所以配置值的时候还需要注意以下三点:

  1. 如果想加大soft nofile,那么hard nofile参数值也需要一起调整。如果因为hard nofile参数值设置的低,那么soft nofile参数的值设置的再高也没有用,实际生效的值会按照二者最低的来。

  2. 如果增大了hard nofile,那么fs.nr_open也都需要跟着一起调整(fs.nr_open参数值一定要大于hard nofile参数值)。如果不小心把hard nofile的值设置的比fs.nr_open还大,那么后果比较严重。会导致该用户无法登录,如果设置的是*,那么所有用户都无法登录

  3. 如果加大了fs.nr_open,但是是用的echo "xxx" > ../fs/nr_open命令来修改的fs.nr_open的值,那么刚改完可能不会有问题,但是只要机器一重启,那么之前通过echo命令设置的fs.nr_open值便会失效,用户还是无法登录。所以非常不建议使用echo的方式修改内核参数!!!

2、调整服务器能打开的最大文件数示例

假设想让进程可以打开100万个文件描述符,这里用修改conf文件的方式给出一个建议。如果日后工作里有类似的需求可以作为参考。

  • vim /etc/sysctl.conf

fs.file-max=1100000 // 系统级别设置成110万,多留点buffer
fs.nr_open=1100000 // 进程级别也设置成110万,因为要保证比 hard nofile大
  • 使上面的配置生效sysctl -p

  • vim /etc/security/limits.conf

// 用户进程级别都设置成100完
soft nofile 1000000
hard nofile 1000000

二、一台服务器最大能支持多少连接

我们知道TCP连接,从根本上看其实就是client和server端在内存中维护的一组【socket内核对象】(这里也对应着TCP四元组:源IP、源端口、目标IP、目标端口),他们只要能够找到对方,那么就算是一条连接。那么一台服务器最大能建立多少条连接呢?

  • 由于TCP连接本质上可以理解为是client-server端的一对socket内核对象,那么从理论上将应该是【2^32 (ip数) * 2^16 (端口数)】条连接(约等于两百多万亿)

  • 但是实际上由于受其他软硬件的影响,我们一台服务器不可能能建立这么多连接(主要是受CPU和内存限制)。

如果只以ESTABLISH状态的连接来算(这些连接只是建立,但是不收发数据也不处理相关的业务逻辑)那么一台服务器最大能建立多少连接呢?以一台4GB内存的服务器为例!

  • 这种情况下,那么能建立的连接数量主要取决于【内存的大小】(因为如果是)ESTABLISH状态的空闲连接,不会消耗CPU(虽然有TCP保活包传输,但这个影响非常小,可以忽略不计)

  • 我们知道一条ESTABLISH状态的连接大约消耗【3.3KB内存】,那么通过计算得知一台4GB内存的服务器,【可以建立100w+的TCP连接】(当然这里只是计算所有的连接都只建立连接但不发送和处理数据的情况,如果真实场景中有数据往来和处理(数据接收和发送都需要申请内存,数据处理便需要CPU),那便会消耗更高的内存以及占用更多的CPU,并发不可能达到100w+)

上面讨论的都是进建立连接的理想情况,在现实中如果有频繁的数据收发和处理(比如:压缩、加密等),那么一台服务器能支撑1000连接都算好的了,所以一台服务器能支撑多少连接还要结合具体的场景去分析,不能光靠理论值去算。抛开业务逻辑单纯的谈并发没有太大的实际意义

服务器的开销大头往往并不是连接本身,而是每条连接上的数据收发,以及请求业务逻辑处理!!!

三、一台客户端机器最多能发起多少条连接

我们知道客户端每和服务端建立一个连接便会消耗掉client端一个端口。一台机器的端口范围是【0 ~ 65535】,那么是不是说一台client机器最多和一台服务端机器建立65535个连接呢(这65535个端口里还有很多保留端口,可用端口可能只有64000个左右)?

由TCP连接的四元组特性可知,只要四元组里某一个元素不同,那么就认为这是不同的TCP连接。所以需要分情况讨论:

  • 【情况一】、如果一台client仅有一个IP,server端也仅有一个IP并且仅启动一个程序,监听一个端口的情况下,client端和这台server端最大可建立的连接条数就是 65535 个。

    • 因为源IP固定,目标IP和端口固定,四元组中唯一可变化的就是【源端口】,【源端口】的可用范围又是【0 ~ 65535】,所以一台client机器最大能建立65535个连接

  • 【情况二】、如果一台client有多个IP(假设客户端有 n 个IP),server端仅有一个IP并且仅启动一个程序,监听一个端口的情况下,一台client机器最大能建立的连接条数是:n * 65535

    • 因为目标IP和端口固定,有 n 个源IP,四元组中可变化的就是【源端口】+ 【源IP】,【源端口】的可用范围又是【0 ~ 65535】,所以一个IP最大能建立65535个连接,那么n个IP最大就能建立n * 65535个连接了

    • 以现在的技术,给一个client分配多个IP是非常容易的事情,只需要去联系你们网管就可以做到。

  • 【情况三】、如果一台client仅有一个IP,server端也仅有一个IP但是server端启动多个程序,每个程序监听一个端口的情况下(比如server端启动了m个程序,监听了m个不同端口),一台client机器最大能建立的连接数量为:65535 * m

    • 源IP固定,目标IP固定,目标端口数量为m个,可变化的是源端口,而源端口变化范围是【0 ~ 65535】,所以一台client机器最大能建立的TCP连接数量是 65535 * m

  • 其余情况类推,但是客户端的可用端口范围一般达不到65535个,受内核参数net.ipv4.ip_local_port_range限制,如果要修改client所能使用的端口范围,可以修改这个内核参数的值。

  • 所以,不光是一台server端可以接收100w+个TCP连接,一台client照样能发出100w+个连接

四、其他

  • 三次握手里socket的全连接队列长度由参数net.core.somaxconn来控制,默认大小是128,当两台机器离的非常近,但是建立连接的并发又非常高时,可能会导致半连接队列或全连接队列溢出,进而导致server端丢弃握手包。然后造成client超时重传握手包(至少1s以后才会重传),导致三次握手连接建立耗时过长。我们可以调整参数net.core.somaxconn来增加去按连接队列的长度,进而减小丢包的影响

  • 有时候我们通过 ctrl + c方式来终止了某个进程,但是当重启该进程的时候发现报错端口被占用,这种问题是因为【操作系统还没有来得及回收该端口,等一会儿重启应用就好了】

  • client程序在和server端建立连接时,如果client没有调用bind方法传入指定的端口,那么client在和server端建立连接的时候便会自己随机选择一个端口来建立连接。一旦我们client程序调用了bind方法传入了指定的端口,那么client将会使用我们bind里指定的端口来和server建立连接。所以不建议client调用bind方法,bind函数会改变内核选择端口的策略

    public static void main(String[] args) throws IOException {
       SocketChannel sc = SocketChannel.open();
    // 客户端还可以调用bind方法
       sc.bind(new InetSocketAddress("localhost", 9999));
       sc.connect(new InetSocketAddress("localhost", 8080));
       System.out.println("waiting..........");
    }
  • 在Linux一切皆文件,当然也包括之前TCP连接中说的socket。进程打开一个socket的时候需要创建好几个内核对象,换一句直白的话说就是打开文件对象吃内存,所以Linux系统基于安全角度考虑(比如:有用户进程恶意的打开无数的文件描述符,那不得把系统搞奔溃了),在多个位置都限制了可打开的文件描述符的数量

  • 内核是通过【hash表】的方式来管理所有已经建立好连接的socket,以便于有请求到达时快速的通过【TCP四元组】查找到内核中对应的socket对象

    • 在epoll模型中,通过红黑树来管理epoll对象所管理的所有socket,用红黑树结构来平衡快速删除、插入、查找socket的效率

五、相关实际问题

在网络开发中,很多人对一个基础问题始终没有彻底搞明白,那就是一台机器最多能支撑多少条TCP连接。不过由于客户端和服务端对端口使用方式不同,这个问题拆开来理解要容易一些。

注意,这里说的是客户端和服务端都只是角色,并不是指某一台具体的机器。例如对于我们自己开发的应用程序来说,当他响应客户端请求的时候,他就是服务端。当他向MySQL请求数据的时候,他又变成了客户端。

1、"too many open files" 报错是怎么回事,该如何解决

你在线上可能遇到过too many open files这个错误,那么你理解这个报错发生的原理吗?如果让你修复这个错误,应该如何处理呢?

  • 因为每打开一个文件(包括socket),都需要消耗一定的内存资源。为了避免个别进程不受控制的打开了过多文件而让整个服务器奔溃,Linux对打开的文件描述符数量有限制。如果你的进程触发到内核的限制,那么"too many open files" 报错就产生了

  • 可以通过修改fs.file-maxsoft nofilefs.nr_open这三个参数的值来修改进程能打开的最大文件描述符数量

    • 需要注意这三个参数之间的耦合关系!

2、一台服务端机器最大究竟能支持多少条连接

因为这里要考虑的是最大数,因此先不考虑连接上的数据收发和处理,仅考虑ESTABLISH状态的空连接。那么一台服务端机器上最大可以支持多少条TCP连接?这个连接数会受哪些因素的影响?

  • 在不考虑连接上数据的收发和处理的情况下,仅考虑ESTABLISH状态下的空连接情况下,一台服务器上最大可支持的TCP连接数量基本上可以说是由内存大小来决定的。

  • 四元组唯一确定一条连接,但服务端可以接收来自任意客户端的请求,所以根据这个理论计算出来的数字太大,没有实际意义。另外文件描述符限制其实也是内核为了防止某些应用程序不受限制的打开【文件句柄】而添加的限制。这个限制只要修改几个内核参数就可以加大。

  • 一个socket大约消耗3kb左右的内存,这样真正制约服务端机器最大并发数的就是内存,拿一台4GB内存的服务器来说,可以支持的TCP连接数量大约是100w+

3、一条客户端机器最大究竟能支持多少条连接

和服务端不同的是,客户端每次建立一条连接都需要消耗一个端口。在TCP协议中,端口是一个2字节的整数,因此范围只能是0~65535。那么客户单最大只能支持65535条连接吗?有没有办法突破这个限制,有的话有哪些办法?

  • 客户度每次建立一条连接都需要消耗一个端口。从数字上来看,似乎最多只能建立65535条连接。但实际上我们有两种办法破除65535这个限制

    • 方式一,为客户端配置多IP

    • 方式二,分别连接不同的服务端

  • 所以一台client发起百万条连接是没有任何问题的

4、做一个长连接推送产品,支持1亿用户需要多少台机器

假设你是系统架构师,现在老板给你一个需求,让你做一个类似友盟upush这样的产品。要在服务端机器上保持一个和客户端的长连接,绝大部分情况下连接都是空闲的,每天也就顶多推送两三次左右。总用户规模预计是1亿。那么现在请你来评估一下需要多少台服务器可以支撑这1亿条长连接。

  • 对于长连接推送模块这种服务来说,给客户端发送数据只是偶尔的,一般一天也就顶多一两次。绝大部分情况下TCP连接都是空闲的,CPU开销可以忽略

  • 再基于内存来考虑,加色服务器内存是128G的,那么一台服务器可以考虑支持500w条并发。这样会消耗掉大约不到20GB内存用来保存这500w条连接对应的socket。还剩下100GB以上的内存来应对接收、发送缓冲区等其他的开销足够了。所以,一亿用户,仅仅需要20台服务器就差不多够用了!

参考:《深入理解Linux网络》

作者:文攀
来源:juejin.cn/post/7162824884597293086

收起阅读 »

2023 届秋招回顾,寒气逼人。。。

自我介绍我来自杭州的一所双非一本学校,是一名普通的本科生,专业【软件工程】。初学编程事实上,我从高中毕业起就开始思考未来的工作了,一开始网上都是 Python 相关的新闻,因此从高中毕业的暑假就开始学 Python,当时在新华书店,捧着一本入门书天天看;但是看...
继续阅读 »

自我介绍

我来自杭州的一所双非一本学校,是一名普通的本科生,专业【软件工程】。

初学编程

事实上,我从高中毕业起就开始思考未来的工作了,一开始网上都是 Python 相关的新闻,因此从高中毕业的暑假就开始学 Python,当时在新华书店,捧着一本入门书天天看;

但是看了并没有什么用,除了大一的时候吹牛皮,啥都没学到。

然后自 2020 年初(大一寒假) 疫情爆发,学校线上授课;课程中有【面向对象语言】的学习,自此开始正式的跟着视频学习 Java 了。

第一次实习

2021年暑假(大二暑假),我的绩点排名在学校保研线边缘徘徊,但又不愿去刷那些水课的绩点,因此决定考研或者工作,期间比较迷茫。

当时在网上得到一位大数据方向前辈的指点,他说了一句话:“早,就是优势。”

因此,我决定先去实习,当时在杭州人工智能小镇找了家公司实习。

虽说是实习,但其实基本每天上班啥也不干,主管也没分配任务,就是一直在看书,期间看完了周志明老师的 JVM,以及几本讲并发编程的书。

第二次实习

大三上时,眼看着 Java 越来越卷,自己开始学习了大数据相关的组件,像 Hadoop、HBase、Flume 等等组件,一直学到了实时计算之前。

大三下时,我明白自己是一个心态非常不稳定的人,考研对我来说,最后几个月会非常的难熬,并且考研失败的风险也让我望而却步,因此下定决心本科就业!

寒假的时候跟着视频完成了【谷粒商城】那个项目,之后立刻着手准备找实习。

也就是在这第二段实习过程中(2022上半年),我真正的学到了一些实际的开发技巧。

实习期间,看完了几本深入讲中间件 ZK、Redis、Spring源码 和 代码重构的书。

本次实习,让我受益良多,由衷感谢我的 mentor(导师)和主管!

秋招情况

我从 6 月底开始复习准备,因为准备得比较晚,所以基本没参加提前批。

正式批总共投递了近 150 家公司,笔试了 30 家,面试了 15 个公司,除了海康威视,其他基本都意向或排序了。

大致情况如下:

  • offer:兴业数金

  • 意向:猿辅导,Aloudata

  • 排序 / 审批:华为,网易雷火,荣耀,招银网络,古茗奶茶,CVTE,以及一众独角兽公司

  • 面试挂:海康威视

CVTE 提前批面试(已拒)


大应科技(OC)


e签宝 提前批(已拒)


荣耀 Honor(录用决策中)


猿辅导(OC)


趣链科技(流程中)


海康威视(已挂)


SMART(已拒)


寒王厂(泡池子)


网易雷火(排序中)


招银网络(流程中)


古茗奶茶(流程中)


复习方式

关于焦虑

我们先要肯定一点,在复习的时候,【焦虑】是一件必然的事情,我们要正视焦虑。

就拿我自己举例子吧,【双非本科】的学历会把我放到一个最最糟糕的位置。

自开始复习时,我内心就非常非常的焦虑,胸膛经常会像要爆炸一样的沉闷(真的)...

而我的缓解方式主要分为两种吧:

  • 运动

    • 背一会八股或者刷一会题之后就去走走

    • 每天晚上去操场跑步

  • 心理慰藉

    • 面试前,我会像《三傻大闹宝莱坞》里的阿米尔汗一样,拍着自己的胸口对自己说 “Aal izz well”

    • 给自己想好一个下下策,如果秋招真的找不到工作该怎么办?那至少还有春招,对比明年考研失利的同学,我至少积累了经验!

复习流程

我的整体复习流程分为三步:

  • 处理基础知识

  • 看八股

  • 查漏补缺

阶段一:处理基础知识

对于基础知识部分,我自知《计网》和《操作系统》这两门课学的很差,所以一开始就复习这部分知识。

当时先把两门课的教材翻了一遍,然后做了一些摘抄,但说实话基本没用。

这部分知识,我在面试过程中,大概有 50% 的几率会被问到操作系统,但从来没被问到过计网(幸运)。

之后复习《设计模式》,先跟着一个 csdn 上的博客边看别写,之后找了一个很老的(2003年)博客总结,反复背诵,基本能手写大部分的模式实现了。

这部分知识,我在面试过程中,要求写过 单例 、三大工厂 和 发布订阅 的实现,问过项目中和 Spring 以及其它中间件中用到的设计模式。

阶段二:看八股

全面进军 Java 八股文。

我先看了自己在实习前准备的那些文档,之后网上找了 JavaGuide、JavaKeeper 这两份文档作为补充。

因为自己之前有过两段的实习经验,因此背过很多次八股。

但考虑到本次秋招可能会把战线拉得比较长,因此就自己总结了一份脑图。


阶段三:查漏补缺

经过几轮面试,逐渐察觉到了自己的一些不足,之后针对性的去完善了一下。

这里随便列举几个点,供其它同学参考:

  • 为什么说进程切换开销比线程大?

  • NIO到底有没有阻塞,NIO到底能不能提高 IO 效率?

  • Redis分布式锁的限制,RedLock的实现?

  • ZK 明明有了有序的指令队列,为什么还要用 zxid来辅助排序?

  • basic paxos 和 multi paxos 的使用?

  • 为什么拜占庭将军无解?

  • 还有一些业务场景的选择问题。。。

总结

我一直提醒自己:你是一个双非本科生,这个秋招你如果再不拼命,你就要完蛋了。

我想,我是幸运的:

  • 我很幸运 在实习的时候,有一个好的 mentor,带我开发了字节码相关的组件,让我的简历不容易挂;

  • 我很幸运 在复习的时候,有几位好的朋友,分享经验,加油鼓励,让我没有被焦虑击倒;

  • 我很幸运 在面试的时候,有无私的舍友们,能在我需要笔试面试时,把宿舍让给我,让我没有后顾之忧;

当然,也会有遗憾。每个人心中都有着大厂梦,而今年进大厂确实很难:

  • 我从大一开始就非常渴望进入阿里巴巴,实习的时候五面阿里不得,秋招全部简历挂;

  • 百度+度小满,投了 4 个岗位,全部简历挂;

  • 字节,一开始担心算法没敢投,之后担心基础知识也没敢投,也很遗憾了;

人生,有所得就有所失,有所失就有所得。

最后,想给其他明后年参加秋招的同学一些提醒:

  • 一定要早做准备,早点实习,早点刷算法题,早就是优势;

  • 人生无常,意外太多,绝对不要 all in 一家公司;

  • 鞋合不合适只有脚知道,自己总结的八股会更适合自己;

  • 多刷 力扣 Hot 100,或者 Codetop 热门题,反复刷;

  • 选择大于努力;

在寒气逼人的 2022,我们需要抱团取暖...

作者:OliQ
链接:http://www.cnblogs.com/yuanchuziwen/p/16770895.html

收起阅读 »

看完这篇HTTP,跟面试官扯皮就没问题了

web
最初在有网络之前,我们的电脑都是单机的,单机系统是孤立的,我还记得 05 年前那会儿家里有个电脑,想打电脑游戏还得两个人在一个电脑上玩儿,及其不方便。我就想为什么家里人不让上网,我的同学 xxx 家里有网,每次一提这个就落一通批评:xxx上xxx什xxxx么x...
继续阅读 »

我是一名程序员,我的主要编程语言是 Java,我更是一名 Web 开发人员,所以我必须要了解 HTTP,所以本篇文章就来带你从 HTTP 入门到进阶,看完让你有一种恍然大悟、醍醐灌顶的感觉。

最初在有网络之前,我们的电脑都是单机的,单机系统是孤立的,我还记得 05 年前那会儿家里有个电脑,想打电脑游戏还得两个人在一个电脑上玩儿,及其不方便。我就想为什么家里人不让上网,我的同学 xxx 家里有网,每次一提这个就落一通批评:xxx上xxx什xxxx么xxxx网xxxx看xxxx你xxxx考xxxx的xxxx那xxxx点xxxx分。虽然我家里没有上网,但是此时互联网已经在高速发展了,HTTP 就是高速发展的一个产物。

认识 HTTP

首先你听的最多的应该就是 HTTP 是一种 超文本传输协议(Hypertext Transfer Protocol),这你一定能说出来,但是这样还不够,假如你是大厂面试官,这不可能是他想要的最终结果,我们在面试的时候往往把自己知道的尽可能多的说出来,才有和面试官谈价钱的资本。那么什么是超文本传输协议?

超文本传输协议可以进行文字分割:超文本(Hypertext)、传输(Transfer)、协议(Protocol),它们之间的关系如下

img

按照范围的大小 协议 > 传输 > 超文本。下面就分别对这三个名次做一个解释。

什么是超文本

在互联网早期的时候,我们输入的信息只能保存在本地,无法和其他电脑进行交互。我们保存的信息通常都以文本即简单字符的形式存在,文本是一种能够被计算机解析的有意义的二进制数据包。而随着互联网的高速发展,两台电脑之间能够进行数据的传输后,人们不满足只能在两台电脑之间传输文字,还想要传输图片、音频、视频,甚至点击文字或图片能够进行超链接的跳转,那么文本的语义就被扩大了,这种语义扩大后的文本就被称为超文本(Hypertext)

什么是传输

那么我们上面说到,两台计算机之间会形成互联关系进行通信,我们存储的超文本会被解析成为二进制数据包,由传输载体(例如同轴电缆,电话线,光缆)负责把二进制数据包由计算机终端传输到另一个终端的过程(对终端的详细解释可以参考 你说你懂互联网,那这些你知道么?这篇文章)称为传输(transfer)

通常我们把传输数据包的一方称为请求方,把接到二进制数据包的一方称为应答方。请求方和应答方可以进行互换,请求方也可以作为应答方接受数据,应答方也可以作为请求方请求数据,它们之间的关系如下

img

如图所示,A 和 B 是两个不同的端系统,它们之间可以作为信息交换的载体存在,刚开始的时候是 A 作为请求方请求与 B 交换信息,B 作为响应的一方提供信息;随着时间的推移,B 也可以作为请求方请求 A 交换信息,那么 A 也可以作为响应方响应 B 请求的信息。

什么是协议

协议这个名词不仅局限于互联网范畴,也体现在日常生活中,比如情侣双方约定好在哪个地点吃饭,这个约定也是一种协议,比如你应聘成功了,企业会和你签订劳动合同,这种双方的雇佣关系也是一种 协议。注意自己一个人对自己的约定不能成为协议,协议的前提条件必须是多人约定。

那么网络协议是什么呢?

网络协议就是网络中(包括互联网)传递、管理信息的一些规范。如同人与人之间相互交流是需要遵循一定的规矩一样,计算机之间的相互通信需要共同遵守一定的规则,这些规则就称为网络协议。

没有网络协议的互联网是混乱的,就和人类社会一样,人不能想怎么样就怎么样,你的行为约束是受到法律的约束的;那么互联网中的端系统也不能自己想发什么发什么,也是需要受到通信协议约束的。

那么我们就可以总结一下,什么是 HTTP?可以用下面这个经典的总结回答一下: HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范

与 HTTP 有关的组件

随着网络世界演进,HTTP 协议已经几乎成为不可替代的一种协议,在了解了 HTTP 的基本组成后,下面再来带你进一步认识一下 HTTP 协议。

网络模型

网络是一个复杂的系统,不仅包括大量的应用程序、端系统、通信链路、分组交换机等,还有各种各样的协议组成,那么现在我们就来聊一下网络中的协议层次。

为了给网络协议的设计提供一个结构,网络设计者以分层(layer)的方式组织协议,每个协议属于层次模型之一。每一层都是向它的上一层提供服务(service),即所谓的服务模型(service model)。每个分层中所有的协议称为 协议栈(protocol stack)。因特网的协议栈由五个部分组成:物理层、链路层、网络层、运输层和应用层。我们采用自上而下的方法研究其原理,也就是应用层 -> 物理层的方式。

应用层

应用层是网络应用程序和网络协议存放的分层,因特网的应用层包括许多协议,例如我们学 web 离不开的 HTTP,电子邮件传送协议 SMTP、端系统文件上传协议 FTP、还有为我们进行域名解析的 DNS 协议。应用层协议分布在多个端系统上,一个端系统应用程序与另外一个端系统应用程序交换信息分组,我们把位于应用层的信息分组称为 报文(message)

运输层

因特网的运输层在应用程序断点之间传送应用程序报文,在这一层主要有两种传输协议 TCPUDP,利用这两者中的任何一个都能够传输报文,不过这两种协议有巨大的不同。

TCP 向它的应用程序提供了面向连接的服务,它能够控制并确认报文是否到达,并提供了拥塞机制来控制网络传输,因此当网络拥塞时,会抑制其传输速率。

UDP 协议向它的应用程序提供了无连接服务。它不具备可靠性的特征,没有流量控制,也没有拥塞控制。我们把运输层的分组称为 报文段(segment)

网络层

因特网的网络层负责将称为 数据报(datagram) 的网络分层从一台主机移动到另一台主机。网络层一个非常重要的协议是 IP 协议,所有具有网络层的因特网组件都必须运行 IP 协议,IP 协议是一种网际协议,除了 IP 协议外,网络层还包括一些其他网际协议和路由选择协议,一般把网络层就称为 IP 层,由此可知 IP 协议的重要性。

链路层

现在我们有应用程序通信的协议,有了给应用程序提供运输的协议,还有了用于约定发送位置的 IP 协议,那么如何才能真正的发送数据呢?为了将分组从一个节点(主机或路由器)运输到另一个节点,网络层必须依靠链路层提供服务。链路层的例子包括以太网、WiFi 和电缆接入的 DOCSIS 协议,因为数据从源目的地传送通常需要经过几条链路,一个数据包可能被沿途不同的链路层协议处理,我们把链路层的分组称为 帧(frame)

物理层

虽然链路层的作用是将帧从一个端系统运输到另一个端系统,而物理层的作用是将帧中的一个个 比特 从一个节点运输到另一个节点,物理层的协议仍然使用链路层协议,这些协议与实际的物理传输介质有关,例如,以太网有很多物理层协议:关于双绞铜线、关于同轴电缆、关于光纤等等。

五层网络协议的示意图如下

img

OSI 模型

我们上面讨论的计算网络协议模型不是唯一的 协议栈,ISO(国际标准化组织)提出来计算机网络应该按照7层来组织,那么7层网络协议栈与5层的区别在哪里?

img

从图中可以一眼看出,OSI 要比上面的网络模型多了 表示层会话层,其他层基本一致。表示层主要包括数据压缩和数据加密以及数据描述,数据描述使得应用程序不必担心计算机内部存储格式的问题,而会话层提供了数据交换的定界和同步功能,包括建立检查点和恢复方案。

浏览器

就如同各大邮箱使用电子邮件传送协议 SMTP 一样,浏览器是使用 HTTP 协议的主要载体,说到浏览器,你能想起来几种?是的,随着网景大战结束后,浏览器迅速发展,至今已经出现过的浏览器主要有

img

浏览器正式的名字叫做 Web Broser,顾名思义,就是检索、查看互联网上网页资源的应用程序,名字里的 Web,实际上指的就是 World Wide Web,也就是万维网。

我们在地址栏输入URL(即网址),浏览器会向DNS(域名服务器,后面会说)提供网址,由它来完成 URL 到 IP 地址的映射。然后将请求你的请求提交给具体的服务器,在由服务器返回我们要的结果(以HTML编码格式返回给浏览器),浏览器执行HTML编码,将结果显示在浏览器的正文。这就是一个浏览器发起请求和接受响应的过程。

Web 服务器

Web 服务器的正式名称叫做 Web Server,Web 服务器一般指的是网站服务器,上面说到浏览器是 HTTP 请求的发起方,那么 Web 服务器就是 HTTP 请求的应答方,Web 服务器可以向浏览器等 Web 客户端提供文档,也可以放置网站文件,让全世界浏览;可以放置数据文件,让全世界下载。目前最主流的三个Web服务器是Apache、 Nginx 、IIS。

CDN

CDN的全称是Content Delivery Network,即内容分发网络,它应用了 HTTP 协议里的缓存和代理技术,代替源站响应客户端的请求。CDN 是构建在现有网络基础之上的网络,它依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储分发技术

打比方说你要去亚马逊上买书,之前你只能通过购物网站购买后从美国发货过海关等重重关卡送到你的家里,现在在中国建立一个亚马逊分基地,你就不用通过美国进行邮寄,从中国就能把书尽快给你送到。

WAF

WAF 是一种 Web 应用程序防护系统(Web Application Firewall,简称 WAF),它是一种通过执行一系列针对HTTP / HTTPS的安全策略来专门为Web应用提供保护的一款产品,它是应用层面的防火墙,专门检测 HTTP 流量,是防护 Web 应用的安全技术。

WAF 通常位于 Web 服务器之前,可以阻止如 SQL 注入、跨站脚本等攻击,目前应用较多的一个开源项目是 ModSecurity,它能够完全集成进 Apache 或 Nginx。

WebService

WebService 是一种 Web 应用程序,WebService是一种跨编程语言和跨操作系统平台的远程调用技术

Web Service 是一种由 W3C 定义的应用服务开发规范,使用 client-server 主从架构,通常使用 WSDL 定义服务接口,使用 HTTP 协议传输 XML 或 SOAP 消息,它是一个基于 Web(HTTP)的服务架构技术,既可以运行在内网,也可以在适当保护后运行在外网。

HTML

HTML 称为超文本标记语言,是一种标识性的语言。它包括一系列标签.通过这些标签可以将网络上的文档格式统一,使分散的 Internet 资源连接为一个逻辑整体。HTML 文本是由 HTML 命令组成的描述性文本,HTML 命令可以说明文字,图形、动画、声音、表格、链接等。

Web 页面构成

Web 页面(Web page)也叫做文档,是由一个个对象组成的。一个对象(Objecy) 只是一个文件,比如一个 HTML 文件、一个 JPEG 图形、一个 Java 小程序或一个视频片段,它们在网络中可以通过 URL 地址寻址。多数的 Web 页面含有一个 HTML 基本文件 以及几个引用对象。

举个例子,如果一个 Web 页面包含 HTML 文件和5个 JPEG 图形,那么这个 Web 页面就有6个对象:一个 HTML 文件和5个 JPEG 图形。HTML 基本文件通过 URL 地址引用页面中的其他对象。

与 HTTP 有关的协议

在互联网中,任何协议都不会单独的完成信息交换,HTTP 也一样。虽然 HTTP 属于应用层的协议,但是它仍然需要其他层次协议的配合完成信息的交换,那么在完成一次 HTTP 请求和响应的过程中,需要哪些协议的配合呢?一起来看一下

TCP/IP

TCP/IP 协议你一定听过,TCP/IP 我们一般称之为协议簇,什么意思呢?就是 TCP/IP 协议簇中不仅仅只有 TCP 协议和 IP 协议,它是一系列网络通信协议的统称。而其中最核心的两个协议就是 TCP / IP 协议,其他的还有 UDP、ICMP、ARP 等等,共同构成了一个复杂但有层次的协议栈。

TCP 协议的全称是 Transmission Control Protocol 的缩写,意思是传输控制协议,HTTP 使用 TCP 作为通信协议,这是因为 TCP 是一种可靠的协议,而可靠能保证数据不丢失。

IP 协议的全称是 Internet Protocol 的缩写,它主要解决的是通信双方寻址的问题。IP 协议使用 IP 地址 来标识互联网上的每一台计算机,可以把 IP 地址想象成为你手机的电话号码,你要与他人通话必须先要知道他人的手机号码,计算机网络中信息交换必须先要知道对方的 IP 地址。(关于 TCP 和 IP 更多的讨论我们会在后面详解)

DNS

你有没有想过为什么你可以通过键入 http://www.google.com 就能够获取你想要的网站?我们上面说到,计算机网络中的每个端系统都有一个 IP 地址存在,而把 IP 地址转换为便于人类记忆的协议就是 DNS 协议

DNS 的全称是域名系统(Domain Name System,缩写:DNS),它作为将域名和 IP 地址相互映射的一个分布式数据库,能够使人更方便地访问互联网。

URI / URL

我们上面提到,你可以通过输入 http://www.google.com 地址来访问谷歌的官网,那么这个地址有什么规定吗?我怎么输都可以?AAA.BBB.CCC 是不是也行?当然不是的,你输入的地址格式必须要满足 URI 的规范。

URI的全称是(Uniform Resource Identifier),中文名称是统一资源标识符,使用它就能够唯一地标记互联网上资源。

URL的全称是(Uniform Resource Locator),中文名称是统一资源定位符,也就是我们俗称的网址,它实际上是 URI 的一个子集。

URI 不仅包括 URL,还包括 URN(统一资源名称),它们之间的关系如下

img

HTTPS

HTTP 一般是明文传输,很容易被攻击者窃取重要信息,鉴于此,HTTPS 应运而生。HTTPS 的全称为 (Hyper Text Transfer Protocol over SecureSocket Layer),全称有点长,HTTPS 和 HTTP 有很大的不同在于 HTTPS 是以安全为目标的 HTTP 通道,在 HTTP 的基础上通过传输加密和身份认证保证了传输过程的安全性。HTTPS 在 HTTP 的基础上增加了 SSL 层,也就是说 HTTPS = HTTP + SSL。(这块我们后面也会详谈 HTTPS)

HTTP 请求响应过程

你是不是很好奇,当你在浏览器中输入网址后,到底发生了什么事情?你想要的内容是如何展现出来的?让我们通过一个例子来探讨一下,我们假设访问的 URL 地址为 http://www.someSchool.edu/someDepartment/home.index,当我们输入网址并点击回车时,浏览器内部会进行如下操作

  • DNS服务器会首先进行域名的映射,找到访问http://www.someSchool.edu所在的地址,然后HTTP 客户端进程在 80 端口发起一个到服务器 http://www.someSchool.edu 的 TCP 连接(80 端口是 HTTP 的默认端口)。在客户和服务器进程中都会有一个套接字与其相连。

  • HTTP 客户端通过它的套接字向服务器发送一个 HTTP 请求报文。该报文中包含了路径 someDepartment/home.index 的资源,我们后面会详细讨论 HTTP 请求报文。

  • HTTP 服务器通过它的套接字接受该报文,进行请求的解析工作,并从其存储器(RAM 或磁盘)中检索出对象 http://www.someSchool.edu/someDepartment/home.index,然后把检索出来的对象进行封装,封装到 HTTP 响应报文中,并通过套接字向客户进行发送。

  • HTTP 服务器随即通知 TCP 断开 TCP 连接,实际上是需要等到客户接受完响应报文后才会断开 TCP 连接。

  • HTTP 客户端接受完响应报文后,TCP 连接会关闭。HTTP 客户端从响应中提取出报文中是一个 HTML 响应文件,并检查该 HTML 文件,然后循环检查报文中其他内部对象。

  • 检查完成后,HTTP 客户端会把对应的资源通过显示器呈现给用户。

至此,键入网址再按下回车的全过程就结束了。上述过程描述的是一种简单的请求-响应全过程,真实的请求-响应情况可能要比上面描述的过程复杂很多。

HTTP 请求特征

从上面整个过程中我们可以总结出 HTTP 进行分组传输是具有以下特征

  • 支持客户-服务器模式

  • 简单快速:客户向服务器请求服务时,只需传送请求方法和路径。请求方法常用的有 GET、HEAD、POST。每种方法规定了客户与服务器联系的类型不同。由于 HTTP 协议简单,使得 HTTP 服务器的程序规模小,因而通信速度很快。

  • 灵活:HTTP 允许传输任意类型的数据对象。正在传输的类型由 Content-Type 加以标记。

  • 无连接:无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。

  • 无状态:HTTP 协议是无状态协议。无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。另一方面,在服务器不需要先前信息时它的应答就较快。

详解 HTTP 报文

我们上面描述了一下 HTTP 的请求响应过程,流程比较简单,但是凡事就怕认真,你这一认真,就能拓展出很多东西,比如 HTTP 报文是什么样的,它的组成格式是什么? 下面就来探讨一下

HTTP 协议主要由三大部分组成:

  • 起始行(start line):描述请求或响应的基本信息;

  • 头部字段(header):使用 key-value 形式更详细地说明报文;

  • 消息正文(entity):实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据。

其中起始行和头部字段并成为 请求头 或者 响应头,统称为 Header;消息正文也叫做实体,称为 body。HTTP 协议规定每次发送的报文必须要有 Header,但是可以没有 body,也就是说头信息是必须的,实体信息可以没有。而且在 header 和 body 之间必须要有一个空行(CRLF),如果用一幅图来表示一下的话,我觉得应该是下面这样

img

我们使用上面的那个例子来看一下 http 的请求报文

img

如图,这是 http://www.someSchool.edu/someDepartment/home.index 请求的请求头,通过观察这个 HTTP 报文我们就能够学到很多东西,首先,我们看到报文是用普通 ASCII 文本书写的,这样保证人能够可以看懂。然后,我们可以看到每一行和下一行之间都会有换行,而且最后一行(请求头部后)再加上一个回车换行符。

每个报文的起始行都是由三个字段组成:方法、URL 字段和 HTTP 版本字段

img

HTTP 请求方法

HTTP 请求方法一般分为 8 种,它们分别是

  • GET 获取资源,GET 方法用来请求访问已被 URI 识别的资源。指定的资源经服务器端解析后返回响应内容。也就是说,如果请求的资源是文本,那就保持原样返回;

  • POST 传输实体,虽然 GET 方法也可以传输主体信息,但是便于区分,我们一般不用 GET 传输实体信息,反而使用 POST 传输实体信息,

  • PUT 传输文件,PUT 方法用来传输文件。就像 FTP 协议的文件上传一样,要求在请求报文的主体中包含文件内容,然后保存到请求 URI 指定的位置。

    但是,鉴于 HTTP 的 PUT 方法自身不带验证机制,任何人都可以上传文件 , 存在安全性问题,因此一般的 W eb 网站不使用该方法。若配合 W eb 应用程序的验证机制,或架构设计采用REST(REpresentational State Transfer,表征状态转移)标准的同类 Web 网站,就可能会开放使用 PUT 方法。

  • HEAD 获得响应首部,HEAD 方法和 GET 方法一样,只是不返回报文主体部分。用于确认 URI 的有效性及资源更新的日期时间等。

  • DELETE 删除文件,DELETE 方法用来删除文件,是与 PUT 相反的方法。DELETE 方法按请求 URI 删除指定的资源。

  • OPTIONS 询问支持的方法,OPTIONS 方法用来查询针对请求 URI 指定的资源支持的方法。

  • TRACE 追踪路径,TRACE 方法是让 Web 服务器端将之前的请求通信环回给客户端的方法。

  • CONNECT 要求用隧道协议连接代理,CONNECT 方法要求在与代理服务器通信时建立隧道,实现用隧道协议进行 TCP 通信。主要使用 SSL(Secure Sockets Layer,安全套接层)和 TLS(Transport Layer Security,传输层安全)协议把通信内容加 密后经网络隧道传输。

我们一般最常用的方法也就是 GET 方法和 POST 方法,其他方法暂时了解即可。下面是 HTTP1.0 和 HTTP1.1 支持的方法清单

img

HTTP 请求 URL

HTTP 协议使用 URI 定位互联网上的资源。正是因为 URI 的特定功能,在互联网上任意位置的资源都能访问到。URL 带有请求对象的标识符。在上面的例子中,浏览器正在请求对象 /somedir/page.html 的资源。

我们再通过一个完整的域名解析一下 URL

比如 http://www.example.com:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument 这个 URL 比较繁琐了吧,你把这个 URL 搞懂了其他的 URL 也就不成问题了。

首先出场的是 http

img

http://告诉浏览器使用何种协议。对于大部分 Web 资源,通常使用 HTTP 协议或其安全版本,HTTPS 协议。另外,浏览器也知道如何处理其他协议。例如, mailto: 协议指示浏览器打开邮件客户端;ftp:协议指示浏览器处理文件传输。

第二个出场的是 主机

img

http://www.example.com 既是一个域名,也代表管理该域名的机构。它指示了需要向网络上的哪一台主机发起请求。当然,也可以直接向主机的 IP address 地址发起请求。但直接使用 IP 地址的场景并不常见。

第三个出场的是 端口

img

我们前面说到,两个主机之间要发起 TCP 连接需要两个条件,主机 + 端口。它表示用于访问 Web 服务器上资源的入口。如果访问的该 Web 服务器使用HTTP协议的标准端口(HTTP为80,HTTPS为443)授予对其资源的访问权限,则通常省略此部分。否则端口就是 URI 必须的部分。

上面是请求 URL 所必须包含的部分,下面就是 URL 具体请求资源路径

第四个出场的是 路径

img

/path/to/myfile.html 是 Web 服务器上资源的路径。以端口后面的第一个 / 开始,到 ? 号之前结束,中间的 每一个/ 都代表了层级(上下级)关系。这个 URL 的请求资源是一个 html 页面。

紧跟着路径后面的是 查询参数

img

?key1=value1&key2=value2 是提供给 Web 服务器的额外参数。如果是 GET 请求,一般带有请求 URL 参数,如果是 POST 请求,则不会在路径后面直接加参数。这些参数是用 & 符号分隔的键/值对列表。key1 = value1 是第一对,key2 = value2 是第二对参数

紧跟着参数的是锚点

img

#SomewhereInTheDocument 是资源本身的某一部分的一个锚点。锚点代表资源内的一种“书签”,它给予浏览器显示位于该“加书签”点的内容的指示。 例如,在HTML文档上,浏览器将滚动到定义锚点的那个点上;在视频或音频文档上,浏览器将转到锚点代表的那个时间。值得注意的是 # 号后面的部分,也称为片段标识符,永远不会与请求一起发送到服务器。

HTTP 版本

表示报文使用的 HTTP 协议版本。

请求头部

这部分内容只是大致介绍一下,内容较多,后面会再以一篇文章详述

在表述完了起始行之后我们再来看一下请求头部,现在我们向上找,找到http://www.someSchool.edu/someDepartment/home.index,来看一下它的请求头部

Host: http://www.someschool.edu
Connection: close
User-agent: Mozilla/5.0
Accept-language: fr
复制代码

这个请求头信息比较少,首先 Host 表示的是对象所在的主机。你也许认为这个 Host 是不需要的,因为 URL 不是已经指明了请求对象的路径了吗?这个首部行提供的信息是 Web 代理高速缓存所需要的。Connection: close 表示的是浏览器需要告诉服务器使用的是非持久连接。它要求服务器在发送完响应的对象后就关闭连接。User-agent: 这是请求头用来告诉 Web 服务器,浏览器使用的类型是 Mozilla/5.0,即 Firefox 浏览器。Accept-language 告诉 Web 服务器,浏览器想要得到对象的法语版本,前提是服务器需要支持法语类型,否则将会发送服务器的默认版本。下面我们针对主要的实体字段进行介绍(具体的可以参考 developer.mozilla.org/zh-CN/docs/… MDN 官网学习)

HTTP 的请求标头分为四种: 通用标头请求标头响应标头实体标头,依次来进行详解。

通用标头

通用标头主要有三个,分别是 DateCache-ControlConnection

Date

Date 是一个通用标头,它可以出现在请求标头和响应标头中,它的基本表示如下

Date: Wed, 21 Oct 2015 07:28:00 GMT 
复制代码

表示的是格林威治标准时间,这个时间要比北京时间慢八个小时

img

Cache-Control

Cache-Control 是一个通用标头,他可以出现在请求标头和响应标头中,Cache-Control 的种类比较多,虽然说这是一个通用标头,但是又一些特性是请求标头具有的,有一些是响应标头才有的。主要大类有 可缓存性阈值性重新验证并重新加载其他特性

可缓存性是唯一响应标头才具有的特性,我们会在响应标头中详述。

阈值性,这个我翻译可能不准确,它的原英文是 Expiration,我是根据它的值来翻译的,你看到这些值可能会觉得我翻译的有点道理

  • max-age: 资源被认为仍然有效的最长时间,与 Expires 不同,这个请求是相对于 request标头的时间,而 Expires 是相对于响应标头。(请求标头)

  • s-maxage: 重写了 max-age 和 Expires 请求头,仅仅适用于共享缓存,被私有缓存所忽略(这块不理解,看完响应头的 Cache-Control 再进行理解)(请求标头)

  • max-stale:表示客户端将接受的最大响应时间,以秒为单位。(响应标头)

  • min-fresh: 表示客户端希望响应在指定的最小时间内有效。(响应标头)

Connection

Connection 决定当前事务(一次三次握手和四次挥手)完成后,是否会关闭网络连接。Connection 有两种,一种是持久性连接,即一次事务完成后不关闭网络连接

Connection: keep-alive
复制代码

另一种是非持久性连接,即一次事务完成后关闭网络连接

Connection: close
复制代码

HTTP1.1 其他通用标头如下

img

实体标头

实体标头是描述消息正文内容的 HTTP 标头。实体标头用于 HTTP 请求和响应中。头部Content-LengthContent-LanguageContent-Encoding 是实体头。

  • Content-Length 实体报头指示实体主体的大小,以字节为单位,发送到接收方。

  • Content-Language 实体报头描述了客户端或者服务端能够接受的语言,例如

Content-Language: de-DE
Content-Language: en-US
Content-Language: de-DE, en-CA
复制代码
  • Content-Encoding 这又是一个比较麻烦的属性,这个实体报头用来压缩媒体类型。Content-Encoding 指示对实体应用了何种编码。

    常见的内容编码有这几种: gzip、compress、deflate、identity ,这个属性可以应用在请求报文和响应报文中

Accept-Encoding: gzip, deflate //请求头
Content-Encoding: gzip //响应头
复制代码

下面是一些实体标头字段

img

请求标头

上面给出的例子请求报文的属性比较少,下面给出一个 MDN 官网的例子

GET /home.html HTTP/1.1
Host: developer.mozilla.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://developer.mozilla.org/testpage.html
Connection: keep-alive
Upgrade-Insecure-Requests: 1
If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
Cache-Control: max-age=0
复制代码

Host

Host 请求头指明了服务器的域名(对于虚拟主机来说),以及(可选的)服务器监听的TCP端口号。如果没有给定端口号,会自动使用被请求服务的默认端口(比如请求一个 HTTP 的 URL 会自动使用80作为端口)。

Host: developer.mozilla.org
复制代码

上面的 AccpetAccept-LanguageAccept-Encoding 都是属于内容协商的请求标头,我们会在下面说明

Referer

HTTP Referer 属性是请求标头的一部分,当浏览器向 web 服务器发送请求的时候,一般会带上 Referer,告诉服务器该网页是从哪个页面链接过来的,服务器因此可以获得一些信息用于处理。

Referer: https://developer.mozilla.org/testpage.html
复制代码

Upgrade-Insecure-Requests

Upgrade-Insecure-Requests 是一个请求标头,用来向服务器端发送信号,表示客户端优先选择加密及带有身份验证的响应。

Upgrade-Insecure-Requests: 1
复制代码

If-Modified-Since

HTTP 的 If-Modified-Since 使其成为条件请求

  • 返回200,只有在给定日期的最后一次修改资源后,服务器才会以200状态发送回请求的资源。

  • 如果请求从开始以来没有被修改过,响应会返回304并且没有任何响应体

If-Modified-Since 通常会与 If-None-Match 搭配使用,If-Modified-Since 用于确认代理或客户端拥有的本地资源的有效性。获取资源的更新日期时间,可通过确认首部字段 Last-Modified 来确定。

大白话说就是如果在 Last-Modified 之后更新了服务器资源,那么服务器会响应200,如果在 Last-Modified 之后没有更新过资源,则返回 304。

If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
复制代码

If-None-Match

If-None-Match HTTP请求标头使请求成为条件请求。 对于 GET 和 HEAD 方法,仅当服务器没有与给定资源匹配的 ETag 时,服务器才会以200状态发送回请求的资源。 对于其他方法,仅当最终现有资源的ETag与列出的任何值都不匹配时,才会处理请求。

If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
复制代码

ETag 属于响应标头,后面进行介绍。

内容协商

内容协商机制是指客户端和服务器端就响应的资源内容进行交涉,然后提供给客户端最为适合的资源。内容协商会以响应资源的语言、字符集、编码方式等作为判断的标准。

img

内容协商主要有以下3种类型:

  • 服务器驱动协商(Server-driven Negotiation)

这种协商方式是由服务器端进行内容协商。服务器端会根据请求首部字段进行自动处理

  • 客户端驱动协商(Agent-driven Negotiation)

这种协商方式是由客户端来进行内容协商。

  • 透明协商(Transparent Negotiation)

是服务器驱动和客户端驱动的结合体,是由服务器端和客户端各自进行内容协商的一种方法。

内容协商的分类有很多种,主要的几种类型是 Accept、Accept-Charset、Accept-Encoding、Accept-Language、Content-Language

Accept

接受请求 HTTP 标头会通告客户端其能够理解的 MIME 类型

那么什么是 MIME 类型呢?在回答这个问题前你应该先了解一下什么是 MIME

MIME: MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的因特网标准。MIME 消息能包含文本、图像、音频、视频以及其他应用程序专用的数据。

也就是说,MIME 类型其实就是一系列消息内容类型的集合。那么 MIME 类型都有哪些呢?

文本文件: text/html、text/plain、text/css、application/xhtml+xml、application/xml

图片文件: image/jpeg、image/gif、image/png

视频文件: video/mpeg、video/quicktime

应用程序二进制文件: application/octet-stream、application/zip

比如,如果浏览器不支持 PNG 图片的显示,那 Accept 就不指定image/png,而指定可处理的 image/gif 和 image/jpeg 等图片类型。

一般 MIME 类型也会和 q 这个属性一起使用,q 是什么?q 表示的是权重,来看一个例子

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
复制代码

这是什么意思呢?若想要给显示的媒体类型增加优先级,则使用 q= 来额外表示权重值,没有显示权重的时候默认值是1.0 ,我给你列个表格你就明白了

qMIME
1.0text/html
1.0application/xhtml+xml
0.9application/xml
0.8* / *

也就是说,这是一个放置顺序,权重高的在前,低的在后,application/xml;q=0.9 是不可分割的整体。

Accept-Charset

accept-charset 属性规定服务器处理表单数据所接受的字符集。

accept-charset 属性允许您指定一系列字符集,服务器必须支持这些字符集,从而得以正确解释表单中的数据。

该属性的值是用引号包含字符集名称列表。如果可接受字符集与用户所使用的字符即不相匹配的话,浏览器可以选择忽略表单或是将该表单区别对待。

此属性的默认值是 unknown,表示表单的字符集与包含表单的文档的字符集相同。

常用的字符集有: UTF-8 - Unicode 字符编码 ; ISO-8859-1 - 拉丁字母表的字符编码

Accept-Language

首部字段 Accept-Language 用来告知服务器用户代理能够处理的自然语言集(指中文或英文等),以及自然语言集的相对优先级。可一次指定多种自然语言集。 和 Accept 首部字段一样,按权重值 q来表示相对优先级。

Accept-Language: en-US,en;q=0.5
复制代码

请求标头我们大概就介绍这几种,后面会有一篇文章详细深挖所有的响应头的,下面是一个响应头的汇总,基于 HTTP 1.1

img

响应标头

响应标头是可以在 HTTP 响应种使用的 HTTP 标头,这听起来是像一句废话,不过确实是这样解释。并不是所有出现在响应中的标头都是响应标头。还有一些特殊的我们上面说过,有通用标头和实体标头也会出现在响应标头中,比如 Content-Length 就是一个实体标头,但是,在这种情况下,这些实体请求通常称为响应头。下面以一个例子为例和你探讨一下响应头

200 OK
Access-Control-Allow-Origin: *
Connection: Keep-Alive
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
Date: Mon, 18 Jul 2016 16:06:00 GMT
Etag: "c561c68d0ba92bbeb8b0f612a9199f722e3a621a"
Keep-Alive: timeout=5, max=997
Last-Modified: Mon, 18 Jul 2016 02:36:04 GMT
Server: Apache
Set-Cookie: mykey=myvalue; expires=Mon, 17-Jul-2017 16:06:00 GMT; Max-Age=31449600; Path=/; secure
Transfer-Encoding: chunked
Vary: Cookie, Accept-Encoding
x-frame-options: DENY
复制代码

响应状态码

首先出现的应该就是 200 OK,这是 HTTP 响应标头的状态码,它表示着响应成功完成。HTTP 响应标头的状态码有很多,并做了如下规定

2xx 为开头的都表示请求成功响应。

状态码含义
200成功响应
204请求处理成功,但是没有资源可以返回
206对资源某一部分进行响应,由Content-Range 指定范围的实体内容。

3xx 为开头的都表示需要进行附加操作以完成请求

状态码含义
301永久性重定向,该状态码表示请求的资源已经重新分配 URI,以后应该使用资源现有的 URI
302临时性重定向。该状态码表示请求的资源已被分配了新的 URI,希望用户(本次)能使用新的 URI 访问。
303该状态码表示由于请求对应的资源存在着另一个 URI,应使用 GET 方法定向获取请求的资源。
304该状态码表示客户端发送附带条件的请求时,服务器端允许请求访问资源,但未满足条件的情况。
307临时重定向。该状态码与 302 Found 有着相同的含义。

4xx 的响应结果表明客户端是发生错误的原因所在。

状态码含义
400该状态码表示请求报文中存在语法错误。当错误发生时,需修改请求的内容后再次发送请求。
401该状态码表示发送的请求需要有通过 HTTP 认证(BASIC 认证、DIGEST 认证)的认证信息。
403该状态码表明对请求资源的访问被服务器拒绝了。
404该状态码表明服务器上无法找到请求的资源。

5xx 为开头的响应标头都表示服务器本身发生错误

状态码含义
500该状态码表明服务器端在执行请求时发生了错误。
503该状态码表明服务器暂时处于超负载或正在进行停机维护,现在无法处理请求。

Access-Control-Allow-Origin

一个返回的 HTTP 标头可能会具有 Access-Control-Allow-Origin ,Access-Control-Allow-Origin 指定一个来源,它告诉浏览器允许该来源进行资源访问。 否则-对于没有凭据的请求 *通配符,告诉浏览器允许任何源访问资源。例如,要允许源 https://mozilla.org 的代码访问资源,可以指定:

Access-Control-Allow-Origin: https://mozilla.org
Vary: Origin
复制代码

如果服务器指定单个来源而不是 *通配符的话 ,则服务器还应在 Vary 响应标头中包含 Origin ,以向客户端指示 服务器响应将根据原始请求标头的值而有所不同。

Keep-Alive

上面我们提到,HTTP 报文标头会分为四种,这其实是按着上下文来分类的

还有一种分类是根据代理进行分类,根据代理会分为端到端头逐跳标头

而 Keep-Alive 表示的是 Connection 非持续连接的存活时间,如下

Connection: Keep-Alive
Keep-Alive: timeout=5, max=997
复制代码

Keep-Alive 有两个参数,它们是以逗号分隔的参数列表,每个参数由一个标识符和一个由等号 = 分隔的值组成。

timeout:指示空闲连接必须保持打开状态的最短时间(以秒为单位)。

max:指示在关闭连接之前可以在此连接上发送的最大请求数。

上述 HTTP 代码的意思就是限制最大的超时时间是 5s 和 最大的连接请求是 997 个。

Server

服务器标头包含有关原始服务器用来处理请求的软件的信息。

应该避免使用过于冗长和详细的 Server 值,因为它们可能会泄露内部实施细节,这可能会使攻击者容易地发现并利用已知的安全漏洞。例如下面这种写法

Server: Apache/2.4.1 (Unix)
复制代码

Set-Cookie

Cookie 又是另外一个领域的内容了,我们后面文章会说道 Cookie,这里需要记住 Cookie、Set-Cookie 和 Content-Disposition 等在其他 RFC 中定义的首部字段,它们不是属于 HTTP 1.1 的首部字段,但是使用率仍然很高。

Transfer-Encoding

首部字段 Transfer-Encoding 规定了传输报文主体时采用的编码方式。

Transfer-Encoding: chunked
复制代码

HTTP /1.1 的传输编码方式仅对分块传输编码有效。

X-Frame-Options

HTTP 首部字段是可以自行扩展的。所以在 Web 服务器和浏览器的应用上,会出现各种非标准的首部字段。

首部字段 X-Frame-Options 属于 HTTP 响应首部,用于控制网站内容在其他 Web 网站的 Frame 标签内的显示问题。其主要目的是为了防止点击劫持(clickjacking)攻击。

下面是一个响应头的汇总,基于 HTTP 1.1

img

非 HTTP/1.1 首部字段

在 HTTP 协议通信交互中使用到的首部字段,不限于 RFC2616 中定义的 47 种首部字段。还有 Cookie、Set-Cookie 和 Content-Disposition 等在其他 RFC 中定义的首部字段,它们的使用频率也很高。 这些非正式的首部字段统一归纳在 RFC4229 HTTP Header Field Registrations 中。

End-to-end 首部和 Hop-by-hop 首部

HTTP 首部字段将定义成缓存代理和非缓存代理的行为,分成 2 种类型。

一种是 End-to-end 首部 和 Hop-by-hop 首部

End-to-end(端到端) 首部

这些标头必须发送给消息的最终接收者 : 请求的服务器,或响应的客户端。中间代理必须重新传输未经修改的标头,并且缓存必须存储这些信息

Hop-by-hop(逐跳) 首部

分在此类别中的首部只对单次转发有效,会因通过缓存或代理而不再转发。

下面列举了 HTTP/1.1 中的逐跳首部字段。除这 8 个首部字段之外,其他所有字段都属于端到端首部。

Connection、Keep-Alive、Proxy-Authenticate、Proxy-Authorization、Trailer、TE、Transfer-Encoding、Upgrade

HTTP 的优点和缺点

HTTP 的优点

简单灵活易扩展

HTTP 最重要也是最突出的优点是 简单、灵活、易于扩展

HTTP 的协议比较简单,它的主要组成就是 header + body,头部信息也是简单的文本格式,而且 HTTP 的请求报文根据英文也能猜出来个大概的意思,降低学习门槛,能够让更多的人研究和开发 HTTP 应用。

所以,在简单的基础上,HTTP 协议又多了灵活易扩展 的优点。

HTTP 协议里的请求方法、URI、状态码、原因短语、头字段等每一个核心组成要素都没有被制定死,允许开发者任意定制、扩充或解释,给予了浏览器和服务器最大程度的信任和自由。

应用广泛、环境成熟

因为过于简单,普及,因此应用很广泛。因为 HTTP 协议本身不属于一种语言,它并不限定某种编程语言或者操作系统,所以天然具有跨语言、跨平台的优越性。而且,因为本身的简单特性很容易实现,所以几乎所有的编程语言都有 HTTP 调用库和外围的开发测试工具。

随着移动互联网的发展, HTTP 的触角已经延伸到了世界的每一个角落,从简单的 Web 页面到复杂的 JSON、XML 数据,从台式机上的浏览器到手机上的各种 APP、新闻、论坛、购物、手机游戏,你很难找到一个没有使用 HTTP 的地方。

无状态

无状态其实既是优点又是缺点。因为服务器没有记忆能力,所以就不需要额外的资源来记录状态信息,不仅实现上会简单一些,而且还能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。

HTTP 的缺点

无状态

既然服务器没有记忆能力,它就无法支持需要连续多个步骤的事务操作。每次都得问一遍身份信息,不仅麻烦,而且还增加了不必要的数据传输量。由此出现了 Cookie 技术。

明文

HTTP 协议里还有一把优缺点一体的双刃剑,就是明文传输。明文意思就是协议里的报文(准确地说是 header 部分)不使用二进制数据,而是用简单可阅读的文本形式。

对比 TCP、UDP 这样的二进制协议,它的优点显而易见,不需要借助任何外部工具,用浏览器、Wireshark 或者 tcpdump 抓包后,直接用肉眼就可以很容易地查看或者修改,为我们的开发调试工作带来极大的便利。

当然缺点也是显而易见的,就是不安全,可以被监听和被窥探。因为无法判断通信双方的身份,不能判断报文是否被更改过。

性能

HTTP 的性能不算差,但不完全适应现在的互联网,还有很大的提升空间。

img

参考资料:

en.wikipedia.org/wiki/Hypert…

《极客时间》- 透视 HTTP 协议

developer.mozilla.org/en-US/docs/…

baike.baidu.com/item/WEB服务器…

baike.baidu.com/item/内容分发网络…

baike.baidu.com/item/HTML/9…

http://www.jianshu.com/p/3dd8f1879…

《计算机网络-自顶向下方法》

《图解 HTTP》

HTTP协议的内容协商

http://www.w3school.com.cn/tags/att_fo…


作者:程序员cxuan
来源:juejin.cn/post/6844904045572800525

收起阅读 »

为什么B站的弹幕可以不挡人物

那天在B站看视频的时候偶然发现当字幕遇到人物的时候就被裁切了,不会挡住人物,觉得很神奇,于是决定一探究竟。高端的效果,往往只需要采用最朴素的实现方式,忙碌了两个小时,陈师傅打开了F12,豁然开朗。一张图片+一个属性,直接搞定。为了印证我的想法,我决定自己写一个...
继续阅读 »


那天在B站看视频的时候偶然发现当字幕遇到人物的时候就被裁切了,不会挡住人物,觉得很神奇,于是决定一探究竟。

高端的效果,往往只需要采用最朴素的实现方式,忙碌了两个小时,陈师傅打开了F12,豁然开朗。一张图片+一个属性,直接搞定。


为了印证我的想法,我决定自己写一个demo

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.video {
width: 668px;
height: 376px;
position: relative;
-webkit-mask-image: url("mask.svg");
-webkit-mask-size: 668px 376px;
}
.bullet {
position: absolute;
font-size: 20px;
}
</style>
</head>
<body>
<div class="video">
<div class="bullet" style="left: 100px; top: 0;">元芳,你怎么看</div>
<div class="bullet" style="left: 200px; top: 20px;">你难道就是传说中的奶灵</div>
<div class="bullet" style="left: 300px; top: 40px;">你好,我是胖灵</div>
<div class="bullet" style="left: 400px; top: 60px;">这是第一集,还没有舔灵</div>
</div>
</body>
</html>

效果是这样的


加一个红背景,看的清楚一些


至此我们就实现了B站同款的不遮挡人物的弹幕。至于这张图片是怎么来的,肯定是AI识别出来然后生成的,一张图片也就一两K,一次加载很多张也不会造成很大的负担。

最后来看看这个神奇的css属性吧

所以在开发需求的时候可以把它当成一个亮点使用,但是不能强依赖于这个属性做需求。

原文链接:https://juejin.cn/post/7141012605535010823

收起阅读 »