注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

用了这么久SpringBoot却还不知道的一个小技巧

前言 你可能调第三方接口喜欢启动application,修改,再启动,再修改,顺便还有个不喜欢写JUnitTest的习惯。 你可能有一天想要在SpringBoot启动后,立马想要干一些事情,现在没有可能是你还没遇到。 那么SpringBoot本身提供...
继续阅读 »

前言



你可能调第三方接口喜欢启动application,修改,再启动,再修改,顺便还有个不喜欢写JUnitTest的习惯。




你可能有一天想要在SpringBoot启动后,立马想要干一些事情,现在没有可能是你还没遇到。




那么SpringBoot本身提供了一个小技巧,很多人估计没用过。



正文


1、效果



废话不多说,先写个service和controller展示个效果最实在。




来个简单的service



@Service
public class TestService {

public String test() {

System.err.println("Hello,Java Body ~");
return "Hello,Java Body ~";
}
}


再来个简单的controller



@RestController
@RequestMapping("/api")
@AllArgsConstructor
public class TestController {

private final TestService testService;

@GetMapping("/test")
public ResponseEntity test() {
return ResponseEntity.ok().body(testService.test());
}
}


接下来是不是以为要启动调接口了,No,在SpringBoot的启动类中加这么个玩意儿



@SpringBootApplication
public class JavaAboutApplication {

public static void main(String[] args) {
SpringApplication.run(JavaAboutApplication.class, args);
}

@Bean
CommandLineRunner lookupTestService(TestService testService) {
return args -> {

// 1、test接口
testService.test();

};
}

}


启动看下效果



4.png



可以发现,SpringBoot启动后,自动加载了service的执行程序。




这个小案例是想说明什么呢,其实就是CommandLineRunner这么个东西。



2、它是什么



CommandLineRunner是一个接口,用于在Spring Boot应用程序启动后执行一些特定的任务或代码块。当应用程序启动完成后,Spring Boot会查找并执行实现了CommandLineRunner接口的Bean。




说白了,就是SpringBoot启动后,我立马想干的事,都可以往里写。



3、我用它做过什么



我的话,和很多厂家对接过接口,在前期不会直接开始写业务,而是先调通接口,再接入业务中。




比如webservice这种,我曾经使用CommandLineRunner直接调对方接口来测试,还挺舒适,也节省了IDEA资源,但要注意调试完成后注释掉,本地测试的时候再打开就行。



5.png


4、它还有哪些用途



除了可以拿来调试第三方接口,它还有什么用途吗?




其实开头已经说过,它就是SpringBoot启动后,你立马想干的事,都可以在里面写,所以你完全可以发挥想象去用。




我这里,提供几个思路作为参考。



1)、数据库初始化


你可以使用CommandLineRunner来执行应用程序启动时的数据库初始化操作,例如创建表格、插入初始数据等。



2)、缓存预热


CommandLineRunner在应用程序启动后预热缓存,加载常用的数据到缓存中,提高应用程序的响应速度。



3)、加载外部资源


加载一些外部资源,例如配置文件、静态文件或其他资源。CommandLineRunner可以帮助你在启动时读取这些资源并进行相应的处理。



4)、任务初始化


使用CommandLineRunner来初始化和配置某些定时任务,确保它们在应用程序启动后立即开始运行。



5)、日志记录


SpringBoot启动后记录一些必要的日志信息,如应用程序版本、环境配置、甚至启动时间等等,这个看具体需求。



6)、组件初始化


你可能需要按照特定的顺序初始化一些组件,CommandLineRunner可以帮助你控制初始化顺序,只需要将它们添加到不同的CommandLineRunner实现类中,并使用@Order注解指定它们的执行顺序即可。



总结



其实,能用的地方挺多,我最后再举个例子,netty启动时,往往是绑定了端口并以同步形式启动。




但如果要和SpringBoot整合,我们不可能还那么做,而是交给SpringBoot来控制netty的启动和关闭,当SpringBoot启动后,netty启动,当SpringBoot关闭时,netty自然也关闭了,这样才比较优雅。




那么,我们完全可以将netty的启动执行程序放到CommandLineRunner中,这样就可以达到目的了。




没用过的xdm,今天学会一个新知识点了不,可以自己下去试试哦。


作者:程序员济癫
来源:juejin.cn/post/7273434389404893239
收起阅读 »

如何优雅的将MultipartFile和File互转

我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。 前言 首先来区别一下MultipartFile和File: MultipartFile是 S...
继续阅读 »

我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。


前言


首先来区别一下MultipartFile和File:



  • MultipartFile是 Spring 框架的一部分,File是 Java 标准库的一部分。

  • MultipartFile主要用于接收上传的文件,File主要用于操作系统文件。


MultipartFile转换为File


使用 transferTo


这是一种最简单的方法,使用MultipartFile自带的transferTo 方法将MultipartFile转换为File,这里通过上传表单文件,将MultipartFile转换为File格式,然后输出到特定的路径,具体写法如下。


transferto.png


使用 FileOutputStream


这是最常用的一种方法,使用 FileOutputStream 可以将字节写入文件。具体写法如下。


FileOutputStream.png


使用 Java NIO


Java NIO 提供了文件复制的方法。具体写法如下。


copy.png


File装换为MultipartFile


从File转换为MultipartFile 通常在测试或模拟场景中使用,生产环境一般不这么用,这里只介绍一种最常用的方法。


使用 MockMultipartFile


在转换之前先确保引入了spring-test 依赖(以Maven举例)


<dependency>
<groupId>org.springframeworkgroupId>
<artifactId>spring-testartifactId>
<version>versionversion>
<scope>testscope>
dependency>

通过获得File文件的名称、mime类型以及内容将其转换为MultipartFile格式。具体写法如下。


multi.png


作者:程序员老J
来源:juejin.cn/post/7295559402475667492
收起阅读 »

面试官问我String能存储多少个字符?

首先String的length方法返回是int。所以理论上长度一定不会超过int的最大值。 编译器源码如下,限制了字符串长度大于等于65535就会编译不通过 private void checkStringConstant(DiagnosticPosition...
继续阅读 »

  1. 首先String的length方法返回是int。所以理论上长度一定不会超过int的最大值。

  2. 编译器源码如下,限制了字符串长度大于等于65535就会编译不通过


    private void checkStringConstant(DiagnosticPosition var1, Object var2) {
    if (this.nerrs == 0 && var2 != null && var2 instanceof String && ((String)var2).length() >= 65535) {
    this.log.error(var1, "limit.string", new Object[0]);
    ++this.nerrs;
    }
    }

    Java中的字符常量都是使用UTF8编码的,UTF8编码使用1~4个字节来表示具体的Unicode字符。所以有的字符占用一个字节,而我们平时所用的大部分中文都需要3个字节来存储。


    //65534个字母,编译通过
    String s1 = "dd..d";

    //21845个中文”自“,编译通过
    String s2 = "自自...自";

    //一个英文字母d加上21845个中文”自“,编译失败
    String s3 = "d自自...自";

    对于s1,一个字母d的UTF8编码占用一个字节,65534字母占用65534个字节,长度是65534,长度和存储都没超过限制,所以可以编译通过。


    对于s2,一个中文占用3个字节,21845个正好占用65535个字节,而且字符串长度是21845,长度和存储也都没超过限制,所以可以编译通过。


    对于s3,一个英文字母d加上21845个中文”自“占用65536个字节,超过了存储最大限制,编译失败。


  3. JVM规范对常量池有所限制。量池中的每一种数据项都有自己的类型。Java中的UTF-8编码的Unicode字符串在常量池中以CONSTANTUtf8类型表示。CONSTANTUtf8的数据结构如下:


    CONSTANT_Utf8_info {
    u1 tag;
    u2 length;
    u1 bytes[length];
    }

    我们重点关注下长度为 length 的那个bytes数组,这个数组就是真正存储常量数据的地方,而 length 就是数组可以存储的最大字节数。length 的类型是u2,u2是无符号的16位整数,因此理论上允许的的最大长度是2^16-1=65535。所以上面byte数组的最大长度可以是65535


  4. 运行时限制


    String 运行时的限制主要体现在 String 的构造函数上。下面是 String 的一个构造函数:


    public String(char value[], int offset, int count) {
    ...
    }

    上面的count值就是字符串的最大长度。在Java中,int的最大长度是2^31-1。所以在运行时,String 的最大长度是2^31-1。


    但是这个也是理论上的长度,实际的长度还要看你JVM的内存。我们来看下,最大的字符串会占用多大的内存。


    (2^31-1)*16/8/1024/1024/1024 = 2GB

    所以在最坏的情况下,一个最大的字符串要占用 2GB的内存。如果你的虚拟机不能分配这么多内存的话,会直接报错的。





补充 JDK9以后对String的存储进行了优化。底层不再使用char数组存储字符串,而是使用byte数组。对于LATIN1字符的字符串可以节省一倍的内存空间。


作者:念念清晰
来源:juejin.cn/post/7343883765540831283
收起阅读 »

面试官:为什么忘记密码要重置,而不是告诉我原密码?

Hello,大家好,我是 Sunday。 最近有个同学在面试中遇到了一个很有意思的问题,我相信大多数的同学可能都没有遇到过。 面试官提问说:“为什么很多网站忘记密码需要重置,而不是直接告诉用户原密码?” 很有意思的问题对不对。很多网站中都有“忘记密码”的功能,...
继续阅读 »

Hello,大家好,我是 Sunday。


最近有个同学在面试中遇到了一个很有意思的问题,我相信大多数的同学可能都没有遇到过。


面试官提问说:“为什么很多网站忘记密码需要重置,而不是直接告诉用户原密码?


很有意思的问题对不对。很多网站中都有“忘记密码”的功能,但是为什么当我们点击忘记密码,经过一堆验证之后,网站会让我们重置密码,而不是直接告诉我们原密码呢?


所以,今天咱们就来说一说这个问题。


防止信息泄露



2022年11月1日,Termly 更新了《98个最大的数据泄露、黑客和曝光事件》(98 Biggest Data Breaches, Hacks, and Exposures)。其中包括很多知名网站,比如:Twitter



所以,你保存在网站中的数据可能并没有那么安全。那么这样的数据泄露后会对用户产生什么影响呢?


对大多数人来说最相关的经历(网上看到的)应该是诈骗电话,他们甚至可以很清楚的告诉你你的所有个人信息。那么这些信息是怎么来的呢?


有些同学可能说是因为“网站贩卖了我的个人信息”,其实不是的。相信我 大多数的网站不会做这样的事情


出现这样事情的原因,大部分都是由于数据泄露,导致你所有的个人信息都被别人知道了。


那么,同理。既然他们可以获取到你的私人信息,那么你的账户和密码信息是不是也有可能被盗取?


而对于大多数的同学来说,为了防止密码太多忘记,所以很多时候 大家都会使用统一的密码! 也就是说你的多个账号可能都是同一个密码。所以,一旦密码泄露,那么可能会影响到你的多个账号,甚至是银彳亍卡账号。


因此,对于网站(特别是一些大网站)来说,保护用户数据安全就是至关重要的一件事情。那么他们一般会怎么做呢?


通常的处理方式就是 加密。并且这种加密可能会在多个不同的阶段进行多次。比如常见的:SHA256、加盐、md5、RSA 等等


这样看起来好像是很安全的,但是还有一个问题,开发人员知道如何解密他们。或者有些同学会认为 数据库中依然存在着正确的密码 呀?一旦出现信息泄露,不是依然会有密码泄露的问题吗?


是的,所以为了解决这个问题,网站本身也不知道你的密码是什么。


网站本身也不知道你的密码是什么


对于网站(或者其他应用)来说,它们是 不应该 存储你的原密码的。而是通过一些系列的操作来保存你加密之后的代码。并且这个加密是在前端传输到服务端时就已经进行了,并且是 不可逆 的加密操作,例如:MD5 + 加盐



我们举一个简单的例子:


比如有个用户的密码是 123456,通过 md5 加密之后是:E10ADC3949BA59ABBE56E057F20F883E


md5 理论上是不可逆的,所以从理论上来说这个加密后的代码是不可解析的。但是 md5 有个比较严重的问题就是:同样的字符串加密之后会得到同样的结果


这也就意味着:E10ADC3949BA59ABBE56E057F20F883E 代表的永远都会是 123456


所以,如果有一个很大的 md5 密码库,那么理论上就可以解析出所有的 md5 加密后的字符串。就像下图一样:




因此,在原有的 md5 加密之上,很多网站又增加了 加盐 的操作。所谓加盐指的就是:在原密码的基础上增加一些字符串,然后进行 md5 加密


比如:



  1. 原密码为 123456

  2. 在这个密码基础上增加固定字符“LGD_Sunday!”

  3. 得到的结果就是:“LGD_Sunday!123456”

  4. 然后用该字符进行 md5 加密,结果是:E1FC8CB7B54BED0FDC8711530236BA4D

  5. 此时尝试解密,会发现 解密失败



这样大家是否就可以理解,为什么很多网站在让我们输入密码的时候 ,要求包含 大小写+符号+ 字母 + 数字 了吧。本质上就是为了防止被轻松解密。


而服务端拿到的就是 “E1FC8CB7B54BED0FDC8711530236BA4D” 这样的一个加密后的结果。然后服务端再次对密码进行加密操作,从而得到的是一个 被多次加密 的数据,保存到服务端。


所以说:网站无法告知你密码,因为它也不知道原密码是什么。


目前很多网站或应用为了保证用户安全,都已经采取 扫码登录、验证码登录 等方式进行登录验证,这种无密码的方式,会更大程度的保证你的账号安全。


作者:程序员Sunday
来源:juejin.cn/post/7353580789299281961
收起阅读 »

Mybatis-Plus的insert执行之后,id是怎么获取的?

在日常开发中,会经常使用Mybatis-Plus 当简单的插入一条记录时,使用mapper的insert是比较简洁的写法 @Data public class NoEo { Long id; String no; } NoEo noEo = ...
继续阅读 »

在日常开发中,会经常使用Mybatis-Plus


当简单的插入一条记录时,使用mapper的insert是比较简洁的写法


@Data
public class NoEo {
Long id;
String no;
}

NoEo noEo = new NoEo();
noEo.setNo("321");
noMapper.insert(noEo);
System.out.println(noEo);

这里可以注意到一个细节,就是不管我们使用的是什么类型的id,好像都不需要去setId,也能执行insert语句


不仅不需要setId,在insert语句执行完毕之后,我们还能通过实体类获取到这条insert的记录的id是什么


image.png


image.png


这背后的原理是什么呢?


自增类型ID


刚学Java的时候,插入了一条记录还要再select一次来获取这条记录的id,比较青涩


后面误打误撞才发现可以直接从insert的实体类中拿到这个id


难道框架是自己帮我查了一次嘛


先来看看自增id的情况


首先要先把yml中的mp的id类型设置为auto


mybatis-plus:
global-config:
db-config:
id-type: auto

然后从insert语句开始一直往下跟进


noMapper.insert(noEo);

后面会来到这个方法


// com.baomidou.mybatisplus.core.executor.MybatisSimpleExecutor#doUpdate
@Override
public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
stmt = prepareStatement(handler, ms.getStatementLog(), false);
return stmt == null ? 0 : handler.update(stmt);
} finally {
closeStatement(stmt);
}
}

在执行了下面这个方法之后


handler.update(stmt)

实体类的id就赋值上了


继续往下跟


// org.apache.ibatis.executor.statement.PreparedStatementHandler#update
@Override
public int update(Statement statement) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
int rows = ps.getUpdateCount();
Object parameterObject = boundSql.getParameterObject();
KeyGenerator keyGenerator = mappedStatement.getKeyGenerator();
keyGenerator.processAfter(executor, mappedStatement, ps, parameterObject);
return rows;
}

image.png


最后的赋值在这一行


keyGenerator.processAfter

可以看到会有一个KeyGenerator做一个后置增强,它具体的实现类是Jdbc3KeyGenerator


// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#processAfter
@Override
public void processAfter(Executor executor, MappedStatement ms, Statement stmt, Object parameter) {
processBatch(ms, stmt, parameter);
}

// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#processBatch
public void processBatch(MappedStatement ms, Statement stmt, Object parameter) {
final String[] keyProperties = ms.getKeyProperties();
if (keyProperties == null || keyProperties.length == 0) {
return;
}
try (ResultSet rs = stmt.getGeneratedKeys()) {
final ResultSetMetaData rsmd = rs.getMetaData();
final Configuration configuration = ms.getConfiguration();
if (rsmd.getColumnCount() < keyProperties.length) {
// Error?
} else {
assignKeys(configuration, rs, rsmd, keyProperties, parameter);
}
} catch (Exception e) {
throw new ExecutorException("Error getting generated key or setting result to parameter object. Cause: " + e, e);
}
}

// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#assignKeys
private void assignKeys(Configuration configuration, ResultSet rs, ResultSetMetaData rsmd, String[] keyProperties,
Object parameter)
throws SQLException {
if (parameter instanceof ParamMap || parameter instanceof StrictMap) {
// Multi-param or single param with @Param
assignKeysToParamMap(configuration, rs, rsmd, keyProperties, (Map<String, ?>) parameter);
} else if (parameter instanceof ArrayList && !((ArrayList<?>) parameter).isEmpty()
&& ((ArrayList<?>) parameter).get(0) instanceof ParamMap) {
// Multi-param or single param with @Param in batch operation
assignKeysToParamMapList(configuration, rs, rsmd, keyProperties, (ArrayList<ParamMap<?>>) parameter);
} else {
// Single param without @Param
// 当前case会走这里
assignKeysToParam(configuration, rs, rsmd, keyProperties, parameter);
}
}

// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#assignKeysToParam
private void assignKeysToParam(Configuration configuration, ResultSet rs, ResultSetMetaData rsmd,
String[] keyProperties, Object parameter)
throws SQLException {
Collection<?> params = collectionize(parameter);
if (params.isEmpty()) {
return;
}
List<KeyAssigner> assignerList = new ArrayList<>();
for (int i = 0; i < keyProperties.length; i++) {
assignerList.add(new KeyAssigner(configuration, rsmd, i + 1, null, keyProperties[i]));
}
Iterator<?> iterator = params.iterator();
while (rs.next()) {
if (!iterator.hasNext()) {
throw new ExecutorException(String.format(MSG_TOO_MANY_KEYS, params.size()));
}
Object param = iterator.next();
assignerList.forEach(x -> x.assign(rs, param));
}
}

// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator.KeyAssigner#assign
protected void assign(ResultSet rs, Object param) {
if (paramName != null) {
// If paramName is set, param is ParamMap
param = ((ParamMap<?>) param).get(paramName);
}
MetaObject metaParam = configuration.newMetaObject(param);
try {
if (typeHandler == null) {
if (metaParam.hasSetter(propertyName)) {
// 获取主键的类型
Class<?> propertyType = metaParam.getSetterType(propertyName);
// 获取主键类型处理器
typeHandler = typeHandlerRegistry.getTypeHandler(propertyType,
JdbcType.forCode(rsmd.getColumnType(columnPosition)));
} else {
throw new ExecutorException("No setter found for the keyProperty '" + propertyName + "' in '"
+ metaParam.getOriginalObject().getClass().getName() + "'.");
}
}
if (typeHandler == null) {
// Error?
} else {
// 获取主键的值
Object value = typeHandler.getResult(rs, columnPosition);
// 设置主键值
metaParam.setValue(propertyName, value);
}
} catch (SQLException e) {
throw new ExecutorException("Error getting generated key or setting result to parameter object. Cause: " + e,
e);
}
}

// com.mysql.cj.jdbc.result.ResultSetImpl#getObject(int, java.lang.Class<T>)
@Override
public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
// ...
else if (type.equals(Long.class) || type.equals(Long.TYPE)) {
checkRowPos();
checkColumnBounds(columnIndex);
return (T) this.thisRow.getValue(columnIndex - 1, this.longValueFactory);

}
// ...
}

image.png


最后可以看到这个自增id是在ResultSet的thisRow里面


然后后面的流程就是去解析这个字节数据获取这个long的id


就不往下赘述了


雪花算法ID


yml切换回雪花算法


mybatis-plus:
global-config:
db-config:
id-type: assign_id

在使用雪花算法的时候,也是会走到这个方法


// com.baomidou.mybatisplus.core.executor.MybatisSimpleExecutor#doUpdate
@Override
public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
stmt = prepareStatement(handler, ms.getStatementLog(), false);
return stmt == null ? 0 : handler.update(stmt);
} finally {
closeStatement(stmt);
}
}

但是不同的是,执行完这一行之后,实体类的id字段就已经赋值上了


StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);

image.png


继续往下跟进


// org.apache.ibatis.session.Configuration#newStatementHandler
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
return statementHandler;
}

// org.apache.ibatis.executor.statement.RoutingStatementHandler#RoutingStatementHandler
public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {

switch (ms.getStatementType()) {
// ...
case PREPARED:
delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
// ...
}

}

最后跟进到一个构造器,会有一个processParameter的方法


// com.baomidou.mybatisplus.core.MybatisParameterHandler#MybatisParameterHandler
public MybatisParameterHandler(MappedStatement mappedStatement, Object parameter, BoundSql boundSql) {
this.typeHandlerRegistry = mappedStatement.getConfiguration().getTypeHandlerRegistry();
this.mappedStatement = mappedStatement;
this.boundSql = boundSql;
this.configuration = mappedStatement.getConfiguration();
this.sqlCommandType = mappedStatement.getSqlCommandType();
this.parameterObject = processParameter(parameter);
}

在这个方法里面会去增强参数


// com.baomidou.mybatisplus.core.MybatisParameterHandler#processParameter
public Object processParameter(Object parameter) {
/* 只处理插入或更新操作 */
if (parameter != null
&& (SqlCommandType.INSERT == this.sqlCommandType || SqlCommandType.UPDATE == this.sqlCommandType)) {
//检查 parameterObject
if (ReflectionKit.isPrimitiveOrWrapper(parameter.getClass())
|| parameter.getClass() == String.class) {
return parameter;
}
Collection<Object> parameters = getParameters(parameter);
if (null != parameters) {
parameters.forEach(this::process);
} else {
process(parameter);
}
}
return parameter;
}

// com.baomidou.mybatisplus.core.MybatisParameterHandler#process
private void process(Object parameter) {
if (parameter != null) {
TableInfo tableInfo = null;
Object entity = parameter;
if (parameter instanceof Map) {
Map<?, ?> map = (Map<?, ?>) parameter;
if (map.containsKey(Constants.ENTITY)) {
Object et = map.get(Constants.ENTITY);
if (et != null) {
entity = et;
tableInfo = TableInfoHelper.getTableInfo(entity.getClass());
}
}
} else {
tableInfo = TableInfoHelper.getTableInfo(parameter.getClass());
}
if (tableInfo != null) {
//到这里就应该转换到实体参数对象了,因为填充和ID处理都是争对实体对象处理的,不用传递原参数对象下去.
MetaObject metaObject = this.configuration.newMetaObject(entity);
if (SqlCommandType.INSERT == this.sqlCommandType) {
populateKeys(tableInfo, metaObject, entity);
insertFill(metaObject, tableInfo);
} else {
updateFill(metaObject, tableInfo);
}
}
}
}

最终生成id并赋值的操作是在populateKeys中


// com.baomidou.mybatisplus.core.MybatisParameterHandler#populateKeys
protected void populateKeys(TableInfo tableInfo, MetaObject metaObject, Object entity) {
final IdType idType = tableInfo.getIdType();
final String keyProperty = tableInfo.getKeyProperty();
if (StringUtils.isNotBlank(keyProperty) && null != idType && idType.getKey() >= 3) {
final IdentifierGenerator identifierGenerator = GlobalConfigUtils.getGlobalConfig(this.configuration).getIdentifierGenerator();
Object idValue = metaObject.getValue(keyProperty);
if (StringUtils.checkValNull(idValue)) {
if (idType.getKey() == IdType.ASSIGN_ID.getKey()) {
if (Number.class.isAssignableFrom(tableInfo.getKeyType())) {
metaObject.setValue(keyProperty, identifierGenerator.nextId(entity));
} else {
metaObject.setValue(keyProperty, identifierGenerator.nextId(entity).toString());
}
} else if (idType.getKey() == IdType.ASSIGN_UUID.getKey()) {
metaObject.setValue(keyProperty, identifierGenerator.nextUUID(entity));
}
}
}
}

在tableInfo中可以得知Id的类型


如果是雪花算法类型,那么生成雪花id;UUID同理


image.png


总结


insert之后,id被赋值到实体类的时机要根据具体情况具体讨论:


如果是自增类型的id,那么要在插入数据库完成之后,在ResultSet的ByteArrayRow中获取到这个id


如果是雪花算法id,那么在在插入数据库之前,会通过参数增强的方式,提前生成一个雪花id,然后赋值给实体类


作者:我爱果汁
来源:juejin.cn/post/7319541656399102002
收起阅读 »

当程序员写代码就行了,为什么还要画图

相信很多人选择当程序员除了这个行业起步阶段薪资比其他行业高一些之外,还有一个很大的因素是觉得做研发类的工作只要代码写的好,跟电脑这个“直男”打交道就可以了。 但是还没走出校门呢,毕业这一关就得边做毕业设计边写毕业论文。写毕业论文可不是直接把自己的实现代码从ID...
继续阅读 »

相信很多人选择当程序员除了这个行业起步阶段薪资比其他行业高一些之外,还有一个很大的因素是觉得做研发类的工作只要代码写的好,跟电脑这个“直男”打交道就可以了。


但是还没走出校门呢,毕业这一关就得边做毕业设计边写毕业论文。写毕业论文可不是直接把自己的实现代码从IDE粘贴到Word里,但凡代码多一点就会被老师要求修改,老师会告诉你要把你做的毕业设计的功能、设计思路、关键部分的实现细节用绘图结合文字表达清楚。


这时很多同学就犯难了,匆匆拿出大三的教材《软件程序设计》看看上面的那些的图都怎么画的,再找找网上的例子,模仿着能画出个类似下面的流程图。



上面这个图乍一看还可以,但是网上关于流程图的语法好几个版本,每个人画出来都不一样,而且用这种形式表达大一些的流程,就完全感觉眼花缭乱。


画图的底层逻辑是沟通


其实走到工作岗位上之后我们仍然面临着这样的问题,除了专心写代码外,我们的工作构成中还有大量的需求评审、需求分析、技术评审、系统方案设计这样的需要人和人进行沟通的环节,讲逻辑、讲实现方案、讲设计思路的沟通环节。究其原因就是因为IT行业分工明细,它像其他行业的工作流水线一样涉及大量工种的合作,但又因为交付的软件并不像工业流水线一样是标准件,所以上面列举的每个环节都需要良好的沟通,让各职能人员之间建立共识、建立统一语言后才能完成高效协作交付产品。


既然需要良好的沟通建立统一语言,那么我们在需求分析、技术评审、系统方案设计文档上就不能使用太主观的语言,也不能把实现代码直接往文档上粘,那样的话且不说有的岗位不写代码,即使同岗位的其他同事也没有那个精力一行一行的仔细看完你的代码、理解你的思路。


所以这就需要我们能够简洁、高效地用人们都能看懂的专业图形描述出软件开发这些环节中需要重点沟通的需求逻辑和技术的关键细节。


我们都知道从事理工专业的人,可能对构图、色彩这些不太擅长,那么有没有一种图形不需要美术基础就能掌握,足够专业让图形能突出我们想表达的技术细节,同时还足够简洁即使是不太懂技术的人也能完全看懂呢?在IT领域还真有,那就是UML。


比如同样是表达需求的业务流程,用流程图表达的就是上面图-1的那个样子,但是用表达力更强,更注重语法的UML活动图表达流程的话就是下面这个样子。



关于怎么用活动图分析表达流程,后面会有专门的章节去给大家讲解。


程序员画图难的成因


说到UML,无论是大学里还是市面上讲解UML的书籍中对UML的讲解都太过枯燥了,它们通常都是以技术和软件设计的角度来讲述UML的,通常上来会先讲解一大堆图,哪些是结构建模,哪些是行为建模,紧接着就是各种图的一堆语法(画法),或者是给出的示例太过于技术化,完全脱离日常生活让人无法理解。


这就给我们这样一开始不太懂的人一种UML太过专业太过复杂,不好用的印象。想的那么清楚画出图来,代码早写好了。典型的例子就是如果画类图把类的各个属性和方法都想好画出来也太费时间了,况且需求多变还要经常改,还有就是那些类图表示的类的关系一会儿是箭头、一会儿是虚线、不明白他们都什么区别,看多了就头疼。


其实上面这个现象完全就是误区,UML完全不是必须那么复杂--把所有细节都表示出来才算完事,我们完全可以从需求分析阶段开始就开始使用,在分析的过程中构思业务的结构并画出来它大概的样貌。



后面随着对需求的进一步了解再去补全或者调整其中的内容。写技术文档常用的UML图除了能像上面这样使用类图分析业务的结构,还有活动图、顺序图、状态机图从不同角度分析业务的行为,而且是循序渐进的使用,不是上来把这些都用上。


早期对业务知晓不够透彻时UML图可以画的粗略些,流程分析也只先分析明白大流程即可,随着使用UML分析业务的过程对业务逐渐了解后再逐渐细化以及使用不同的图形从不同角度描述业务。 UML家族里提供的各种图,也不局限于只能用于技术分析,甚至需求用例、系统架构、IT架构方面的需求也能够使用UML进行描述。


掌握UML让自己有更多可能


无论是一线研发,还是已经转型项目经理、产品经理或者团队管理的人员或者是想要上车入行的萌新程序员,本课程都能让你收益颇多,让你掌握产品经理写需求的一些基本技能,也让你轻松应对项目经理参与竞标和项目管理时的文案编写工作。


同时还能让你管理项目质量时找到“抓手”,通过在项目团队建立技术评审、方案设计等相关机制--融合团队成员对UML的使用,让团队成员的思维性创造更容易被周知也让这些内容更容易被Review,从而达到项目开发期间高效的沟通和良好的质量保证。


职场上的“汇报困境”


除了上面讲的这些我们工作中干活需要用到的各种图形外,在职场上班和在学校上学有一个重要的区别就是我们时不时的就要被拿出来评比、通晒、述职,这些场合都会要求我们做汇报。


针对这个程序员在职场中的普遍痛点,推荐一下我用大半年的时间沉淀,汇集了我多年职场经验的画图课,解决程序员普遍只愿意埋头写代码,不会做需求分析、不会做技术评审、不会画架构图、述职汇报做不好,等等这些需要画图和表达能力的事情的时候就犯难的问题,帮助大家摆脱代码的单一维度,从多维度提升自己,建立自信,让你在工作中更游刃有余


课程最后一部分还会扩展一些互联网开发人员在职场中应对各种汇报的策略,讲述一些写汇报PPT的主旨思路,侧重点和注意事项。同时也讲一些使用堆砖块画法(我自己总结的)给汇报PPT进行配图的思路,怎么通过这些图快速抓住听众的眼球建立共识,以及怎么使用一些配图讲解规划给上级“画饼”来获得他们的支持从而进一步获得他们后续在资源上的支持,更好地开展工作,这些技巧我们在课程最后一部分都会讲到。


相关推荐


现有有两种订阅方式


方式1微信专栏:程序员的全能画图课


方式2小报童专栏:程序员的全能画图课


作者:kevinyan
来源:juejin.cn/post/7370615140242472998
收起阅读 »

为什么很多人不推荐你用JWT?

为什么很多人不推荐你用JWT? 如果你经常看一些网上的带你做项目的教程,你就会发现 有很多的项目都用到了JWT。那么他到底安全吗?为什么那么多人不推荐你去使用。这个文章将会从全方面的带你了解JWT 以及他的优缺点。 什么是JWT? 这个是他的官网JSON We...
继续阅读 »

为什么很多人不推荐你用JWT?


如果你经常看一些网上的带你做项目的教程,你就会发现 有很多的项目都用到了JWT。那么他到底安全吗?为什么那么多人不推荐你去使用。这个文章将会从全方面的带你了解JWT 以及他的优缺点。


什么是JWT?


这个是他的官网JSON Web Tokens - jwt.io


这个就是JWT


img


JWT 全称JSON Web Token


如果你还不熟悉JWT,不要惊慌!它们并不那么复杂!


你可以把JWT想象成一些JSON数据,你可以验证这些数据是来自你认识的人。


当然如何实现我们在这里不讲,有兴趣的可以去自己了解。


下面我们来说一下他的流程:



  1. 当你登录到一个网站,网站会生成一个JWT并将其发送给你。

  2. 这个JWT就像是一个包裹,里面装着一些关于你身份的信息,比如你的用户名、角色、权限等。

  3. 然后,你在每次与该网站进行通信时都会携带这个JWT

  4. 每当你访问一个需要验证身份的页面时,你都会把这个JWT带给网站

  5. 网站收到JWT后,会验证它的签名以确保它是由网站签发的,并且检查其中的信息来确认你的身份和权限。

  6. 如果一切都通过了验证,你就可以继续访问受保护的页面了。


JWT Session


为什么说JWT很烂?


首先我们用JWT应该就是去做这些事情:



  • 用户注册网站

  • 用户登录网站

  • 用户点击并执行操作

  • 本网站使用用户信息进行创建、更新和删除 信息


这些事情对于数据库的操作经常是这些方面的



  • 记录用户正在执行的操作

  • 将用户的一些数据添加到数据库中

  • 检查用户的权限,看看他们是否可以执行某些操作


之后我们来逐步说出他的一些缺点


大小


这个方面毋庸置疑。


比如我们需要存储一个用户ID 为xiaou


如果存储到cookie里面,我们的总大小只有5个字节。


如果我们将 ID 存储在 一个 JWT 里。他的大小就会增加大概51倍


image-20240506200449402


这无疑就增大了我们的宽带负担。


冗余签名


JWT的主要卖点之一就是其加密签名。因为JWT被加密签名,接收方可以验证JWT是否有效且可信。


但是,在过去20年里几乎每一个网络框架都可以在使用普通的会话cookie时获得加密签名的好处。


事实上,大多数网络框架会自动为你加密签名(甚至加密!)你的cookie。这意味着你可以获得与使用JWT签名相同的好处,而无需使用JWT本身。


实际上,在大多数网络身份验证情况下,JWT数据都是存储在会话cookie中的,这意味着现在有两个级别的签名。一个在cookie本身上,一个在JWT上。


令牌撤销问题


由于令牌在到期之前一直有效,服务器没有简单的方法来撤销它。


以下是一些可能导致这种情况危险的用例。


注销并不能真正使你注销!


想象一下你在推特上发送推文后注销了登录。你可能会认为自己已经从服务器注销了,但事实并非如此。因为JWT是自包含的,将在到期之前一直有效。这可能是5分钟、30分钟或任何作为令牌一部分设置的持续时间。因此,如果有人在此期间获取了该令牌,他们可以继续访问直到它过期。


可能存在陈旧数据


想象一下用户是管理员,被降级为权限较低的普通用户。同样,这不会立即生效,用户将继续保持管理员身份,直到令牌过期。


JWT通常不加密


因此任何能够执行中间人攻击并嗅探JWT的人都拥有你的身份验证凭据。这变得更容易,因为中间人攻击只需要在服务器和客户端之间的连接上完成


安全问题


对于JWT是否安全。我们可以参考这个文章


JWT (JSON Web Token) (in)security - research.securitum.com


同时我们也可以看到是有专门的如何攻击JWT的教程的


高级漏洞篇之JWT攻击专题 - FreeBuf网络安全行业门户


总结


总的来说,JWT适合作为单次授权令牌,用于在两个实体之间传输声明信息。


但是,JWT不适合作为长期持久数据的存储机制,特别是用于管理用户会话。使用JWT作为会话机制可能会引入一系列严重的安全和实现上的问题,相反,对于长期持久数据的存储,更适合使用传统的会话机制,如会话cookie,以及建立在其上的成熟的实现。


但是写了这么多,我还是想说,如果你作为自己开发学习使用,不考虑安全,不考虑性能的情况下,用JWT是完全没有问题的,但是一旦用到生产环境中,我们就需要避免这些可能存在的问题。


作者:小u
来源:juejin.cn/post/7365533351451672612
收起阅读 »

我是没想到是还可以这样秒出答案 ...

起因 晚上在休闲游戏中,一网友发来信息求问,一道编程题。 咋一看,嘿 2023年1月浙江选考题(信息技术),挺新鲜,那就来看看吧。 聊了一下才知道,这是中考高考(6月28日晚23:05更正)选题。中考高考(6月28日晚23:05更正)就考这样的了吗? ...
继续阅读 »

起因


晚上在休闲游戏中,一网友发来信息求问,一道编程题。



咋一看,嘿 2023年1月浙江选考题(信息技术),挺新鲜,那就来看看吧。
聊了一下才知道,这是中考高考(6月28日晚23:05更正)选题。中考高考(6月28日晚23:05更正)就考这样的了吗?



image.png
image.png


一、题目



image.png



二、解析


因为题解想半天,没看明白要做的,就先直接上手代码去测试实验。通过足够多的次数去请求,就可以知道正确答案了(不符合出现的)。



后面恍然大悟会进一步讲解内容



二、代码测试


把该代码转成 java 对应的代码内容,并进行测试


public static void main(String[] args) {
// 答案记录
Map ansMap = new HashMap<>();
ansMap.put("AB##CD#", 0); // 选项A 答案
ansMap.put("#######", 0); // 选项B 答案
ansMap.put("#B##CDA", 0); // 选项C 答案
ansMap.put("###ABCD", 0); // 选项D 答案
for (int i = 0; i < 100000; i++) { // 10万次执行,看看 ABCD 答案是哪个一直没有出现
String res = runWork(); // 出现的结果
if (ansMap.get(res) == null){ // 出现和选项答案不一致的跳过
continue;
}
// 出现一致的进行+1
ansMap.put(res, ansMap.get(res) + 1);
}
// 输出结果
System.out.println(ansMap.toString());
}

public static String runWork() {
char[] a = {'A', 'B', '#', '#', 'C', 'D', '#'};
char[] stk = new char[a.length];
int top = -1;
Random random = new Random();

for (int i = 0; i < a.length; i++) {
int op = random.nextInt(2);
if (op == 1 && a[i] != '#') {
top++;
stk[top] = a[i];
a[i] = '#';
} else if (op == 0 && top != -1 && a[i] == '#') {
a[i] = stk[top];
top--;
}
}
return String.valueOf(a);
}

三、测试结果



微信图片_20230627210300.png


截图中可以看到,测试中,A、B、C 选项都出现了,不符合的是 D 选项,因此,正确答案是选项 D。

四、恍然大悟(真正解析)


仔细瞧命名, stk ,是栈(stack)的简写!可恶,这道题可以直接利用栈的知识去看选项去解了啊...



原字符数组是 'A', 'B', '#', '#', 'C', 'D', '#'

栈,就是先进后出。



选项内容解析
AAB##CD#对 a 字符数组都不进行拿出拿入,stk 字符数组就是空,
也就是不变,那么结果可以出现
B#######对 a 字符数组的ABCD都拿走,最终 stk 字符数组里就是 DCBA,
那么结果也可以出现
C#B##CDA对 a 字符数组都只拿A,并在最后一个的时候拿出最上层的。
最上层只有一个 A ,那就拿出 A ,
此时 stk 字符数组就为空了,那么结果可以出现
D###ABCD对 a 字符数组先拿A,stk 里就有 A ,但是B也需要拿,
且 A 要放在 B 拿之前的后面,不能实现,那么结果是不可以出现的!


图解:


ans.gif



那么最终,也就能明白这套代码的意思了,就是随机可能去拿去里面的字母,ABCD,放到栈里再实现放到原数组中去。对栈的理解与使用解释了一下。答案选 D ,只有 D 不符合栈的进出。




作者:南方者
来源:juejin.cn/post/7249288803532947517
收起阅读 »

【禁止血压飙升】如何拥有一个优雅的 controller

前言 见过几千行代码的 controller吗?我见过。 见过全是 try catch 的 controller 吗,我见过。 见过全是字段校验的 controller 吗,我见过。 见过全是业务代码的 controller 吗?不好意思,我们公司很多业务写在...
继续阅读 »

前言


见过几千行代码的 controller吗?我见过。


见过全是 try catch 的 controller 吗,我见过。


见过全是字段校验的 controller 吗,我见过。


见过全是业务代码的 controller 吗?不好意思,我们公司很多业务写在 controller 的。


看见这些我真的血压高。


正文


不优雅的 controller



@RestController
@RequestMapping("/user/test")
public class UserController {

private static Logger logger = LoggerFactory.getLogger(UserController.class);

@Autowired
private UserService userService;

@Autowired
private AuthService authService;

@PostMapping
public CommonResult userRegistration(@RequestBody UserVo userVo) {
if (StringUtils.isBlank(userVo.getUsername())){
return CommonResult.error("用户名不能为空");
}
if (StringUtils.isBlank(userVo.getPassword())){
return CommonResult.error("密码不能为空");
}
logger.info("注册用户:{}" , userVo.getUsername());
try {
userService.registerUser(userVo.getUsername());
return CommonResult.ok();
}catch (Exception e){
logger.error("注册用户失败:{}", userVo.getUsername(), e);
return CommonResult.error("注册失败");
}
}

@PostMapping("/login")
@PermitAll
@ApiOperation("使用账号密码登录")
public CommonResult<AuthLoginRespVO> login(@RequestBody AuthLoginReqVO reqVO) {
if (StringUtils.isBlank(reqVO.getUsername())){
return CommonResult.error("用户名不能为空");
}
if (StringUtils.isBlank(reqVO.getPassword())){
return CommonResult.error("密码不能为空");
}
try {
return success(authService.login(reqVO));
}catch (Exception e){
logger.error("注册用户失败:{}", reqVO.getUsername(), e);
return CommonResult.error("注册失败");
}
}

}


优雅的controller


@RestController
@RequestMapping("/user/test")
public class UserController1 {

private static Logger logger = LoggerFactory.getLogger(UserController1.class);

@Autowired
private UserService userService;

@Autowired
private AuthService authService;

@PostMapping("/userRegistration")
public CommonResult userRegistration(@RequestBody @Valid UserVo userVo) {
userService.registerUser(userVo.getUsername());
return CommonResult.ok();
}

@PostMapping("/login")
@PermitAll
@ApiOperation("使用账号密码登录")
public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {
return success(authService.login(reqVO));
}

}


代码量直接减一半呀,这还不算上有些直接把业务逻辑写在 controller 的,看到这些我真的直接吐血



改造流程


校验方式



这个 if 校验看得我哪哪都不爽。好歹给我写一个断言吧。Assert.notNull(userVo.getUsername(), "用户名不能为空");


这不香吗?确实不香。


使用 spring 提供的@Valid




  • 在入参时使用@Valid注解,并且在 vo 中使用校验注解,如AuthLoginReqVO


@ApiModel(value = "管理后台 - 账号密码登录 Request VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuthLoginReqVO {

@ApiModelProperty(value = "账号", required = true, example = "user")
@NotEmpty(message = "登录账号不能为空")
@Length(min = 4, max = 16, message = "账号长度为 4-16 位")
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
private String username;

@ApiModelProperty(value = "密码", required = true, example = "password")
@NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;

}

@Valid


在SpringBoot中,@Valid是一个非常有用的注解,主要用于数据校验。以下是关于@Valid的一些详细信息:



  1. 为什么使用 @Valid 来验证参数:在编写接口时,我们经常需要验证请求参数。通常,我们可能会写大量的 if 和 if else 代码来进行判断。但这样的代码不仅不优雅,而且如果存在大量的验证逻辑,这会使代码看起来混乱,大大降低代码可读性。为了简化这个过程,我们可以使用 @Valid 注解来帮助我们简化验证逻辑。

  2. @Valid 注解的作用:@Valid 的主要作用是用于数据效验,可以在定义的实体中的属性上,添加不同的注解来完成不同的校验规则,而在接口类中的接收数据参数中添加 @valid 注解,这时你的实体将会开启一个校验的功能。

  3. @Valid 的相关注解:在实体类中不同的属性上添加不同的注解,就能实现不同数据的效验功能。

  4. 使用 @Valid 进行参数效验步骤:整个过程如下,用户访问接口,然后进行参数效验,因为 @Valid 不支持平面的参数效验(直接写在参数中字段的效验)所以基于 GET 请求的参数还是按照原先方式进行效验,而 POST 则可以以实体对象为参数,可以使用 @Valid 方式进行效验。如果效验通过,则进入业务逻辑,否则抛出异常,交由全局异常处理器进行处理。

  5. @Validated与@Valid的区别@Validated@Valid 的变体。通过声明实体中属性的 groups ,再搭配使用 @Validated ,就能决定哪些属性需要校验,哪些不需要校验。


全局异常处理



  • 这个全局异常处理,可以根据自己的异常,自定义异常处理,并设置一个兜底的异常处理



@ResponseBody
@RestControllerAdvice
public class ExceptionHandlerAdvice {
protected Logger logger = LoggerFactory.getLogger(getClass());

@ExceptionHandler(MethodArgumentNotValidException.class)
public CommonResult<Object> handleValidationExceptions(MethodArgumentNotValidException ex) {
logger.error("[handleValidationExceptions]", ex);
StringBuilder sb = new StringBuilder();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((org.springframework.validation.FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
sb.append(fieldName).append(":").append(errorMessage).append(";");
});
return CommonResult.error(sb.toString());
}

/**
* 处理系统异常,兜底处理所有的一切
*/

@ExceptionHandler(value = Exception.class)
public CommonResult<?> defaultExceptionHandler(Throwable ex) {
logger.error("[defaultExceptionHandler]", ex);
// 返回 ERROR CommonResult
return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
}

}


就这么多,搞定,这样就拥有了漂流优雅的 controller 了



在日常开发中,还有那些血压飙升瞬间



  • 我拿出下图阁下如何面对


image-20240411185003067.png



  • 这个阁下又如何面对,我不说,你能知道这个什么吗【狗头】


image-20240411185134843.png


总结



  • 不是很明白为什么有些喜欢在 controller 写业务逻辑的,曾经有个同事问我(就是喜欢在 controller 写业务的),你这个接口写在那里,我需要调一下你这个接口。我满脸问号??不是隔壁的模块吗,为什么要调我的接口?直接引用的我的 service 去调方法就好了。

  • 这个就是痛点,各写各的,冗余代码一堆。

  • 曾经看到一个同事写一个保存的方法,虽然逻辑挺多,我滑动了好久都还没有方法还没有结束。一个方法整整几百行……

  • 看过 spring 源码都知道,spring 源码难啃,就是因为 spring 无限往下套娃,基本每个方法干每个方法的事情。比如我保存用户时,就只是保存用户,至于什么校验丢给校验的方法处理,什么发送消息丢给发送消息处理,这些就不能耦合在一起。

  • 对于看到一些 if 下面一丢逻辑,然后 if 再一丢逻辑,看代码时很多情况不需要知道这个逻辑怎么实现的,知道入参出参就大概这里做什么了。即使想知道详细情况点进去就知道了。突出这个当前方法要做的事情就好了。

  • 阿里的开发手册就推荐一个方法不能超过 80 行,超过可以根据业务具体调整一下。


作者:小塵
来源:juejin.cn/post/7357172505961578511
收起阅读 »

来,实现一下这个报表功能,速度要快,要嘎嘎快

我们有一段业务,类似一个报表,就是获取用户的订单汇总,邮费汇总,各种手续费汇总,然后拿时间噶一卡,显示在页面。 但是呢,这几个业务没啥实际关系,数据也是分开的,一个一个获取会有点慢,我开始就是这样写的,老板嫌页面太慢,让我改,可是页面反应慢,关我后端程序什么事...
继续阅读 »

我们有一段业务,类似一个报表,就是获取用户的订单汇总,邮费汇总,各种手续费汇总,然后拿时间噶一卡,显示在页面。


但是呢,这几个业务没啥实际关系,数据也是分开的,一个一个获取会有点慢,我开始就是这样写的,老板嫌页面太慢,让我改,可是页面反应慢,关我后端程序什么事,哥哥别打了,错了错了,我改,我改。那么最好的方案就是多线程分别获取然后汇总到一起返回。


在Java中获取异步线程的结果通常可以使用FutureCallableCompletableFutureFutureTask等类来实现。这些类可以用来提交任务到线程池,并在任务完成后获取结果。这就是我们想要的结果,那么这里来深入研究分析一下这三个方案。


使用FutureCallable


package com.luke.designpatterns.demo;

import java.util.concurrent.*;

public class demo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(new Callable<Integer>() {
public Integer call() throws Exception {
// 获取各种汇总的代码,返回结果
return 42;
}
});
// 获取异步任务的结果
Integer result = future.get();
System.out.println("异步任务的结果是" + result);
executor.shutdown();
}
}

image.png


它们的原理是通过将任务提交到线程池执行,同时返回一个Future对象,该对象可以在未来的某个时刻获取任务的执行结果。



  1. Callable 接口Callable 是一个带泛型的接口,它允许你定义一个返回结果的任务,并且可以抛出异常。这个接口只有一个方法 call(),在该方法中编写具体的任务逻辑。

  2. Future 接口Future 接口代表一个异步计算的结果。它提供了方法来检查计算是否完成、等待计算的完成以及检索计算的结果。Future 提供了一个 get() 方法,它会阻塞当前线程直到计算完成,并返回计算的结果。



Callable 接口本身并不直接启动线程,它只是定义了一个可以返回结果的任务。要启动一个 Callable 实例的任务,通常需要将其提交给 ExecutorService 线程池来执行。



ExecutorService 中,可以使用 submit(Callable<T> task) 方法提交 Callable 任务。这个方法会返回一个 Future 对象,它可以用来获取任务的执行结果。


启动 Callable 任务的原理可以概括为以下几个步骤:



  1. 创建 Callable 实例:首先需要创建一个实现了 Callable 接口的类,并在 call() 方法中定义具体的任务逻辑,包括要执行的代码和返回的结果。

  2. 创建 ExecutorService 线程池:使用 Executors 类的工厂方法之一来创建一个 ExecutorService 线程池,例如 newFixedThreadPool(int nThreads)newCachedThreadPool() 等。

  3. 提交任务:将 Callable 实例通过 ExecutorServicesubmit(Callable<T> task) 方法提交到线程池中执行。线程池会为任务分配一个线程来执行。

  4. 异步执行ExecutorService 线程池会在后台异步执行任务,不会阻塞当前线程,使得主线程可以继续执行其他操作。

  5. 获取结果:通过 Future 对象的 get() 方法获取任务的执行结果。如果任务尚未完成,get() 方法会阻塞当前线程直到任务完成并返回结果。


总的来说,Callable 启动线程的原理是将任务提交给 ExecutorService 线程池,线程池会负责管理线程的执行,执行任务的过程是在独立的线程中进行的,从而实现了异步执行的效果。


使用CompletableFuture


import java.util.concurrent.CompletableFuture;

public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
// 获取各种汇总的代码,返回结果
return 43;
});

// 获取异步任务的结果
Integer result = future.get();

System.out.println("异步任务的结果:" + result);
}
}

image.png


CompletableFuture 是 Java 8 引入的一个类,用于实现异步编程和异步任务的组合。它的原理是基于"Completable"(可以完成的)和"Future"(未来的结果)的概念,提供了一种方便的方式来处理异步任务的执行和结果处理。


CompletableFuture 的原理可以简单概括为以下几点:



  1. 异步执行CompletableFuture 允许你以异步的方式执行任务。你可以使用 supplyAsync()runAsync() 等方法提交一个任务给 CompletableFuture 执行,任务会在一个独立的线程中执行,不会阻塞当前线程。

  2. 回调机制CompletableFuture 提供了一系列的方法来注册回调函数,这些回调函数会在任务执行完成时被调用。例如,thenApply(), thenAccept(), thenRun() 等方法可以分别处理任务的结果、完成时的操作以及任务执行异常时的处理。

  3. 组合多个任务CompletableFuture 支持多个任务的组合,可以使用 thenCombine()thenCompose()thenAcceptBoth() 等方法来组合多个任务,实现任务之间的依赖关系。

  4. 异常处理CompletableFuture 允许你对任务执行过程中抛出的异常进行处理,可以使用 exceptionally()handle() 等方法来处理异常情况。

  5. 等待任务完成:与 Future 类似,CompletableFuture 也提供了 get() 方法来等待任务的完成并获取结果。但与传统的 Future 不同,CompletableFutureget() 方法不会阻塞当前线程,因为任务的执行是异步的。


总的来说,CompletableFuture 的原理是基于回调和异步执行的机制,提供了一种方便的方式来处理异步任务的执行和结果处理,同时支持任务的组合和异常处理。


使用FutureTask


import java.util.concurrent.*;

public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
FutureTask<Integer> futureTask = new FutureTask<>(() -> {
// 获取各种汇总的代码,返回结果
return 44;
});

Thread thread = new Thread(futureTask);
thread.start();

// 获取异步任务的结果
Integer result = futureTask.get();
System.out.println("异步任务的结果:" + result);
}
}

image.png


FutureTask 是 Java 中实现 Future 接口的一个基本实现类,同时也实现了 Runnable 接口,因此可以被用作一个可运行的任务。FutureTask 的原理是将一个可调用的任务(CallableRunnable)封装成一个异步的、可取消的任务,它提供了一个机制来获取任务的执行结果。


FutureTask 的原理可以简要概括如下:



  1. 封装任务FutureTask 接受一个 CallableRunnable 对象作为构造函数的参数,并将其封装成一个异步的任务。

  2. 执行任务FutureTask 实现了 Runnable 接口,因此可以作为一个可运行的任务提交给 Executor(通常是 ExecutorService)来执行。当 FutureTask 被提交到线程池后,线程池会在一个独立的线程中执行该任务。

  3. 获取结果:通过 Future 接口的方法,可以等待任务执行完成并获取其结果。FutureTask 实现了 Future 接口,因此可以调用 get() 方法来获取任务的执行结果。如果任务尚未完成,get() 方法会阻塞当前线程直到任务完成并返回结果。

  4. 取消任务FutureTask 提供了 cancel(boolean mayInterruptIfRunning) 方法来取消任务的执行。可以选择是否中断正在执行的任务。一旦任务被取消,get() 方法会立即抛出 CancellationException 异常。


总的来说,FutureTask 的原理是将一个可调用的任务封装成一个异步的、可取消的任务,并通过 Future 接口来提供获取任务执行结果和取消任务的机制。


这些方法中,get()方法会阻塞当前线程,直到异步任务完成并返回结果。如果任务抛出异常,get()方法会将异常重新抛出。


我们平时常用的方法就是这四种,都能实现我的需求,随便找一个哐哐干上去就好啦。


作者:奔跑的毛球
来源:juejin.cn/post/7350557995895701531
收起阅读 »

连公司WiFi后,无法访问外网,怎么回事,如何解决?

问题描述 从甲方项目组返回公司后,我习惯性连上公司WiFi,准备百度一个bug,突然我发现无打开百度,F5刷新了好几次也没用,浏览器报了下面的错误信息 尝试ping了一下 http://www.badu.com,好家伙,直接丢包 然后运行 ipconfig/...
继续阅读 »

问题描述


从甲方项目组返回公司后,我习惯性连上公司WiFi,准备百度一个bug,突然我发现无打开百度,F5刷新了好几次也没用,浏览器报了下面的错误信息


523d43c309e5d5b786dff74cc54894bf.png


尝试ping了一下 http://www.badu.com,好家伙,直接丢包


然后运行 ipconfig/all 命令看了一下本机的DNSF服务器信息


b75ff8dab79e97b5698daa4e060e24af.png


我的本机DNS地址是192.168.0.1


通常,本机DNS地址若为192.168.0.1,说明所连WiFi的路由器可能被设定为执行DNS转发职责,或者是期望客户端直接使用路由器作为DNS解析的入口点。而192.168.0.1一般是路由器的默认IP地址,并非一个标准的公共DNS服务器地址。在这种情况下,访问不了外网,例如百度,新浪微博等,有可能是路由器的DNS转发功能没有正常工作,或者路由器自身没有被配置正确以访问外部的DNS服务器


最简单直接的解决方法是手动设置主机的DNS地址为公共的DNS服务器地址



  • Google DNS:8.8.8.8 & 8.8.4.4

  • Cloudflare DNS: 1.1.1.1

  • 中国电信:114.114.114.114

  • 中国联通:223.5.5.5


7785e7beed4c08710c07617232dad576.png




OK,可以正常访问百度了



45db3372360d9ecab863ffb1da9e1246.png


这让我产生了非常浓烈的好奇,从浏览器上输入URL到显示页面,中间究竟发生了什么?


image.png


问题探究



这是一道面试题



1716627344750.png


从浏览器中输入URL并按下回车键后,直到网页内容完全显示在屏幕上,这个过程中发生了一系列复杂的步骤,大致可以概括如下:



  1. URL解析:浏览器首先解析输入的URL,提取出协议、域名、路径以及查询字符串等信息。

  2. 检查缓存:在发起网络请求之前,浏览器会检查本地缓存(包括浏览器缓存、系统缓存乃至路由器缓存),看看是否已经存储了该请求的资源。如果有且未过期,则直接使用缓存内容,无需继续下面的步骤。

  3. DNS解析:如果缓存中没有所需资源,浏览器会通过DNS(域名系统)将网址的域名转换为IP地址,因为网络通信是基于IP地址的。这个过程中可能涉及递归查询和迭代查询,直至找到域名对应的IP地址。

  4. TCP连接建立:获得服务器IP后,浏览器使用TCP协议与服务器建立连接。这通常涉及TCP三次握手过程,确保数据传输的可靠性和连接的双方都准备好通信。

  5. 发起HTTP/HTTPS请求:建立连接后,浏览器构造HTTP或HTTPS请求报文,包含请求方法(如GET或POST)、请求头(携带浏览器信息、请求资源的位置等)以及可能的请求体,然后发送给服务器。

  6. 服务器处理请求:服务器接收到请求后,根据请求的内容处理并准备响应,这可能涉及数据库查询、服务器端脚本执行等操作。

  7. 响应浏览器:服务器将处理好的响应数据(包括状态码、响应头、响应体等)封装成HTTP响应报文,发送回浏览器。

  8. 浏览器接收响应:浏览器接收响应数据,如果响应中有新的资源(如CSS、JavaScript、图片等),浏览器会根据需要再次发起请求获取这些资源。

  9. 渲染页面:浏览器开始解析HTML文档,构建DOM(文档对象模型)树,同时解析CSS文件构建CSSOM(CSS对象模型)树,结合这两棵树形成渲染树(Render Tree)。接着进行布局(Layout)和绘制(Painting),即确定每个节点在屏幕上的位置和外观,最终将页面内容呈现给用户。

  10. 执行JavaScript:页面中的JavaScript代码会被解析和执行,它可能修改DOM和CSSOM,导致重新布局和绘制。此外,异步请求如Ajax也可以在这个阶段发起,动态更新页面内容。

  11. 页面交互:页面加载完毕后,用户可以与页面进行交互,触发事件处理程序,进一步的JavaScript执行可能会改变页面状态。

  12. 连接关闭:当所有数据传输完毕,TCP连接会通过四次挥手的过程优雅地关闭。


上述过程中涉及到了多个层次的技术和协议,从应用层的HTTP/HTTPS、运输层的TCP、网络层的IP到链路层的以太网协议等,共同协作完成了从简单的URL输入到复杂页面展示的任务。


cbacfb95186577a2e2d92fe72fa8d0c5.png


基于上述分析,问题发生在第③步(DNS解析)上,要想回答何为DNS解析,就必须弄明白何为DNS。


何为DNS?


DNS,英文全称为Domain Name System,即域名系统。当我们在浏览器输入一个 URL 地址时,浏览器要向这个 URL 的主机名对应的服务器发送请求,就得知道这个服务器对应的 IP地址,而对于浏览器来说,DNS 的作用就是将主机名转换成 IP 地址【正向解析】。以下定义概念摘自《计算机网络:自顶向下方法》:




  1. 一个由分层的 DNS 服务器( DNS server) 实现的分布式数据库

  2. 一个使得主机能够查询分布式数据库的应用层协议



分布式,层次数据库


如何理解分布式?


随着互联网的快速发展,主机日益增多且数量庞大,采用单一DNS服务器上集中响应的设计并不可取,这种设计容易造成单点故障维护困难通信容量受限等问题。


为了应对上述问题和扩展性, DNS 使用了大量的 DNS 服务器并分布在全世界范围内。因为没有一台 DNS 服务器可以存放Internet上所有主机的映射数据, 相反,该映射数据被分布存储在所有的 DNS 服务器上。


如何理解层次?


DNS服务器采用层次组织,大致说来,有3种类型的 DNS 服务器:根 DNS 服务器、 顶级域 (Top- Level Domain , TLD) DNS 服务器和权威 DNS 服务器。它们的层次结构方式如下所示:


1716628019691.png


图片来源:《计算机网络:自顶向下方法》



  • 根DNS服务器


    我们首先要明确根域名是什么,它没有特定的名称,仅由一个点(.)表示。在技术层面上,它是所有域名查询的起点,负责指引域名解析过程中的查询请求到相应的顶级DNS(TLD)服务器,如.com.net.org等。而在实际的网址中,根域名通常隐含而不显示,例如com.baidu.com.,后面的点一般不会显示。


    根DNS服务器是互联网基础设施的关键部分,全球共有13组根DNS服务器,它们存储了顶级DNS服务器的地址信息,从而帮助我们将域名转换为用于网络通信的IP地址。根DNS的管理由国际互联网名称与数字地址分配机构(ICANN)负责。


  • 顶级域服务器


    这些服务器负责顶级域名,如comorgnetedugov,以及所有国家的顶级域名如uk、r、ca和jp。TLD提供了它的下一级,也就是权威 DNS 服务器的 IP 地址。




  • 权威DNS服务器



    在因特网上具有公共可访问主机(如Wb服务器和邮件服务器)的每个组织机构必须提供公共可访问的DNS记录,这些记录将这些主机的名字映射为IP地址。



    以上内容摘自《计算机网络:自顶向下方法》,比较绕口,通俗来讲就是提供最终的主机—IP映射



本地DNS服务器


在上一节的DNS层次结构中,眼尖的小伙伴会发现,并未提及本地DNS服务器,那为什么呢?一个本地DNS服务器,从严格说来,它并不属于上述DNS服务器的层次结构,但它对DNS层次结构0是至关重要的


每个ISP(Internet Service Provider,即网络业务提供商)都有一台本地DNS服务器(也叫默认名字服务器)。当主机与某个ISP连接时,例如一个小区的ISP,一个学校的ISP等,该ISP会提供一台主机的IP地址,该主机具有一台或多台其本地DNS服务器的IP地址,通常主机的本地DNS服务器会临近主机,当主机发出DNS请求时,该请求被发往本地DNS服务器,它起着代理的作用,并将该请求转发到DNS服务器层次结构中。


迭代查询,递归查询


如下图所示,假设主机abc.net想要获取主机xyz.edu的IP地址,大致会进行如下步骤:


1716647441277.png



  1. 主机abc.net首先向它的本地DNS服务器发送一个查询报文,该报文会含有被转换的主机名xyz.edu。

  2. 本地DNS服务器会将该报文转发给根DNS服务器。

  3. 该根DNS服务器注意到其edu前缀并向本地DNS服务器返回负责edu的TLD(顶级域服务器)的IP地址列表。

  4. 该本地DNS服务器则再次向这些TLD 服务器中的其中一台发送查询报文。

  5. 该 TLD 服务器注意到 xyz. edu 前缀,并把权威DNS服务器的IP地址响应给该本地DNS服务器。

  6. 本地 DNS 服务器直接向权威DNS服务器中的其中一台重发查询报文。

  7. 该权威服务器会用xyz.edu的lP地址进行响应。

  8. 本地DNS服务器会将主机xyz.edu及其IP地址的映射数据响应给主机abc.net,主机abc.net拿到它的IP就能给主机xyz.edu发送请求。


在上图例子中,主机abc.net向本地DNS服务器发出的查询是递归查询因为该查询请求是以主机abc.net以自己的名义获得该映射。 而后继的3 个查询是迭代查询,因为所有的回答都是直接返回给本地DNS服务器。 即第①步是递归查询 ,第②,④,⑥步是迭代查询。


那所有的DNS查询都遵循迭代 + 递归的方式吗?


答案并非如此,虽然在理论上,任何DNS查询既可以是迭代的,也能是递归的。


如下图,所有的DNS查询是都是递归的,因为所有的查询请求是以主机abc.net以自己的名义获得该映射。


1716650770678.png


DNS缓存


实际上,为了改善时延性能并减少在Internet上到处传输的 DNS报文数量,DNS 广泛使用了缓存技术。 DNS 缓存的原理非常简单。 在一个请求链中,当某 DNS服务器接收一个 DNS 回答(例如,包含主机名到IP地址的映射)时,它能将该回答中的信息缓存在本地中。 下次查询时便可直接用缓存里的内容。


注意,缓存并不是永久的,每一条映射记录都有一个对应的生存时间,通常设置为两天时间,一旦过了生存时间,这条记录就会从缓存移出。


有了缓存,本地 DNS 服务器可以立即返回所要解析主机的IP地址,而不必查询任何其他DNS服务器。 而本地 DNS服务器也能够缓存TLD服务器的地址,因而经常绕过查询链中的根 DNS服务器。


参考资料


计算机网络:自顶向下方法(原书第8版) (豆瓣) (douban.com)


作者:Jormungand581
来源:juejin.cn/post/7372456890344243215
收起阅读 »

Git提交错了,于是我把锅甩给了新来的baby

又是一遭悲惨的遭遇,git提交了一连串代码之后,发现提交错了。其实是把给老婆发的消息打到了comment里,然后还提交上去了。怎么办,这被看到岂不是要社死了。 一连串的研究之后,找到了几个解决方案。接下来我们一起搞搞这种错误提交的弥补方案。其中最离谱的是第三...
继续阅读 »

又是一遭悲惨的遭遇,git提交了一连串代码之后,发现提交错了。其实是把给老婆发的消息打到了comment里,然后还提交上去了。怎么办,这被看到岂不是要社死了。


image.png


一连串的研究之后,找到了几个解决方案。接下来我们一起搞搞这种错误提交的弥补方案。其中最离谱的是第三个方案。哈哈。


赛前准备


这里模拟一下这个操作,毕竟不能直接看我们的代码记录。我们新建一个项目,新建一个文件,起名001。


image.png


然后依次改为 002 003 004 005,每次都提交一次,在005的时候,执行异常提交。


最终我们得到一个005的文件


image.png


gitee上看是这样的


image.png


对于我们来说,现在是想删除这个异常提交,不仅删除代码,还想删除记录


也就是说,期待的是,文件变为004,而且这个提交记录删除掉。


方案1 交互式 rebase


首先我们尝试一下 git rebase -i HEAD~3,这样会取出最后的三条提交记录供我们编辑。


image.png


我们可以看到顶上有三条记录,这时候,我们删除这个异常的提交5


image.png


保存之后,会返回


git rebase -i HEAD~3
Successfully rebased and updated refs/heads/master.

这时候查看记录


image.png


异常提交已经没有了。


但是若是我们直接git push 会报错


image.png


告诉我们,我们当前的分支的版本是落后于远程分支的,不能提交。


这时候就需要git push --force这个命令,强制推送!!!


需要注意的是,强制推送会覆盖远程仓库中的历史记录,因此请确保你知道这个命令是个啥,并且有必要的话,需要通知团队其他成员协调好操作。


image.png


可以看到,git push --force 是可以成功的,而且再看gitee的记录


image.png


异常提交5已经不见了。并且本地的文件已经变为了004


image.png


其实在git rebase -i HEAD~3这个命令打开的交互框里是可以更改提交的顺序的,但是不能针对同一个文件的同一行,会冲突。


方案2 git reset


git reset 其实之前写文章讲过Git reset到底该如何使用,一文读懂系列 这次我们就直接为达目的,直接使用。
我们在上边的基础上,再提交一个异常提交5,使其恢复最初的情况。


image.png


然后gitee的情况:


image.png


这时候我们执行


git reset --hard HEAD~1

这个命令将删除最近的一个提交,包括提交所做的更改。请注意,这种方法可能会导致丢失未提交的更改,也就是说,本地写的没提交的代码就没了。所以请谨慎使用。


image.png


执行之后,我们可以看到异常提交5不见了


image.png


提交的时候也需要git push --force这个命令,强制推送!!!为啥每次都使用三个!!!呢,我只想告诉你,这个命令很恐怖,一定要慎之又慎。


这时候查看gitee记录


image.png


异常提交5没有了。


使用 git revert


还有小伙伴会说,为啥不用git revert呢,这不是git专门用来回滚代码的吗?


我们恢复异常提交005,再试试


image.png


我们执行 git revert f3d8db 并且 push


image.png


可以看到,文件是从005变为004了。但是从提交记录来看,不仅没有删除记录,还多了一条。其实,除非提交的注释特别社死,不然一般用的就是git revert,因为它不仅可以保存记录,还能确保版本是往前走的。


image.png


方案3 git filter-branch(谨慎使用)


查资料的时候,还看到一个这个命令,可以来一波骚的了。那既然提错了,把这锅甩给新人不就行了,哇咔咔咔咔咔。


git filter-branch --commit-filter '
if git log --format="%B" -n 1 $GIT_COMMIT | grep -q "异常提交"; then
GIT_AUTHOR_NAME="new baby";
GIT_COMMITTER_NAME="new baby";
git commit-tree "$@";
else
git commit-tree "$@";
fi'
-- --all

然后就是这样的


image.png


image.png


可以看到名字变了。当然邮箱也是可以改的。哇咔咔,这异常不就与我没关系了么。。。但是,极其不建议这么瞎折腾哈。


这个命令会根据条件重写整个历史。操作之前备份一下吧,别折腾坏了。而且一定先和其他的小伙伴商量一下,尤其是新人哈。


在此,就研究完毕了。正常来说使用第一种或者第二种方案都是可以的。不怕挨打的话,第三种方案也行。


git rebase 和 git reset 的区别



  • git rebase 命令用于将一个分支的提交移动到另一个分支上,或者重新应用一系列的提交。它的主要作用是改变提交的基础,即重新设置提交的起点。

  • git reset 命令用于修改当前分支的 HEAD 引用,或者用于撤销之前的提交操作。


也就是说git rebase 用于重新整理提交历史,而 git reset 用于调整当前分支的位置或撤销更改。关于这两个详细的使用,git reset已经写过了,有关git rebase的我会新开一篇文章,有关将一个分支的提交移动到另一个分支上这个操作虽不常用,但总有需要用到的时候。


作者:奔跑的毛球
来源:juejin.cn/post/7365414174217355314
收起阅读 »

Spring Boot 3 集成 Jasypt详解

随着信息安全的日益受到重视,加密敏感数据在应用程序中变得越来越重要。Jasypt(Java Simplified Encryption)作为一个简化Java应用程序中数据加密的工具,为开发者提供了一种便捷而灵活的加密解决方案。本文将深入解析Jasypt的工作原...
继续阅读 »

随着信息安全的日益受到重视,加密敏感数据在应用程序中变得越来越重要。Jasypt(Java Simplified Encryption)作为一个简化Java应用程序中数据加密的工具,为开发者提供了一种便捷而灵活的加密解决方案。本文将深入解析Jasypt的工作原理,以及如何在Spring Boot项目中集成和使用Jasypt来保护敏感信息。


springboot-jasypt.jpg


springboot-jasypt.jpg


Jasypt简介


Jasypt(Java Simplified Encryption)是一个专注于简化Java加密操作的工具。它提供了一种简单而强大的方式来处理数据的加密和解密,使开发者能够轻松地保护应用程序中的敏感信息,如数据库密码、API密钥等。


Jasypt的设计理念是简化加密操作,使其对开发者更加友好。它采用密码学强度的加密算法,支持多种加密算法,从而平衡了性能和安全性。其中,Jasypt的核心思想之一是基于密码的加密(Password Based Encryption,PBE),通过用户提供的密码生成加密密钥,然后使用该密钥对数据进行加密和解密。


该工具还引入了盐(Salt)的概念,通过添加随机生成的盐值,提高了加密的安全性,防止相同的原始数据在不同的加密过程中产生相同的结果,有效抵御彩虹表攻击。


Jasypt与Spring Boot天然契合,可以轻松集成到Spring Boot项目中,为开发者提供了更便捷的数据安全解决方案。通过Jasypt,开发者可以在不深入了解底层加密算法的情况下,轻松实现数据的安全保护,使得应用程序更加可靠和安全。


官网地址: http://www.jasypt.org/


github地址: github.com/ulisesbocch…


Spring Boot 3 集成 Jasypt


添加依赖


在pom文件中添加一下依赖


<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot</artifactId>
<version>3.0.5</version>
</dependency>

添加配置文件


未指定前后缀的话默认格式ENC()括号里面是加密后的密文 然后实现自动解密


spring:
# 数据源配置
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.10.106:3306/xj_doc?characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: ENC(BLC3UQBxshlcA9tnMyJL7w==)

# 加密配置
jasypt:
encryptor:
# 指定加密密钥,生产环境请放到启动参数里面
password: 0f7b0a5d-46bc-40fd-b8ed-3181d21d644f
# 指定解密算法,需要和加密时使用的算法一致
algorithm: PBEWithMD5AndDES

iv-generator-classname: org.jasypt.iv.NoIvGenerator

# property:
# # 算法识别的前后缀,默认ENC(),包含在前后缀的加密信息,会使用指定算法解密
# prefix: ENC@[
# suffix: ]

启动类添加注解


在启动类上添加注解@EnableEncryptableProperties注解来开启自动解密


@SpringBootApplication
@MapperScan("cn.xj.xjdoc.**.mapper")
@EnableEncryptableProperties //开启自动解密功能
public class XjdocApplication {
public static void main(String[] args) {
SpringApplication.run(XjdocApplication.class, args);
}
}

测试类


public class JasyptUtil {

public static void main(String[] args){
StandardPBEStringEncryptor standardPBEStringEncryptor =new StandardPBEStringEncryptor();
/*配置文件中配置如下的算法*/
standardPBEStringEncryptor.setAlgorithm("PBEWithMD5AndDES");
/*配置文件中配置的password*/
standardPBEStringEncryptor.setPassword("0f7b0a5d-46bc-40fd-b8ed-3181d21d644f");
//加密
String jasyptPasswordEN =standardPBEStringEncryptor.encrypt("xj2022");
//解密
String jasyptPasswordDE =standardPBEStringEncryptor.decrypt(jasyptPasswordEN);
System.out.println("加密后密码:"+jasyptPasswordEN);
System.out.println("解密后密码:"+jasyptPasswordDE);
}
}

生产环境安全处理


jasypt的password值放在配置文件中在生产环境中是不安全的,我们可以将password值放到启动命令中,删除配置文件中password 的配置行,启动命令如下所示:


java -Djasypt.encryptor.password=password -jar jasypt-spring-boot-demo-0.0.1-SNAPSHOT.jar

或者


java -jar jasypt-spring-boot-demo-0.0.1-SNAPSHOT.jar --jasypt.encryptor.password=password

总结


Jasypt作为一个简单而强大的加密工具,为Java应用程序提供了便捷的数据保护方案。通过与Spring Boot的集成,开发者可以在应用程序中轻松地加密和解密敏感信息。在实际项目中,选择合适的加密方式、安全存储密码以及与Spring Security等安全框架的集成,都是保障应用程序安全的关键步骤。希望本文能够帮助读者更深入地了解Jasypt,并在实际项目中合理地运用加密技术。


作者:修己xj
来源:juejin.cn/post/7318616887415717924
收起阅读 »

Springboot3 + SpringSecurity + JWT + OpenApi3 实现认证授权

Springboot3 + SpringSecurity + JWT + OpenApi3 实现双token 目前全网最新的 Spring Security + JWT 实现双 Token 的案例!收藏就对了,欢迎各位看友学习参考。此项目由作者个人创作,可以供...
继续阅读 »

Springboot3 + SpringSecurity + JWT + OpenApi3 实现双token


目前全网最新的 Spring Security + JWT 实现双 Token 的案例!收藏就对了,欢迎各位看友学习参考。此项目由作者个人创作,可以供大家学习和项目实战使用,创作不易,转载请注明出处!


该项目使用目前最新的 Sprin Boot3 版本,采用目前市面上最主流的 JWT 认证方式,实现双token刷新。



温馨提示:SpringBoot3 版本必须要使用 JDK11 或 JDK19



SpringBoot3 新特性


Spring Boot3 是一个非常重要的版本,将会面临一个新的发展征程!Sprin Boot 3.0 包含了 12 个月以来,151 个人的 5700+ 次 commit 的贡献。这是自 4 年半前发布的 2.0 版本以来的第一次重大修订,这也是第一个支持 Spring Framework 6.0 和 GraaIVM 的 Spring Boot GA 版本。


Spring Boot 3.0 新版本的主要亮点:



  1. 最低要求为 Java 17 ,兼容 Java 19

  2. 支持用 GraalVM 生成原生镜像,代替了 Spring Native

  3. 通过 Micrometer 和 Micrometer 追踪提高应用可观察性

  4. 支持具有 EE 9 baseline 的 Jakarta EE 10


为什么采用双 Token刷新?


**场景假设:**星期四小金上班的时候摸鱼,准备在某APP 上面追剧,已经深深的陷入了角色中无法自拔,此时如果 Token 过期了 ,小金就不得不重新返回登录界面,重新进行登录,那么这样小金的一次完整的追剧体验就被打断了,这种设计带给小金的体验并不好,于是就需要使用双 Token 来解决。


**如何使用:**在小金首次登陆 APP 时,APP 会返回两个 Token 给小金,一个 accessToken,一个 refreshToken,其中 accessToken 的过期时间比较短,refreshToken 的时间比较长。当 accessToken 失效后,会通过 refreshToken 去重新获取 accessToken,这样一来就可以在不被察觉的情况下仍然使小金保持登录状态,让小金误以为自己一直是登录的状态。并且每次使用refreshToken 后会刷新,每一次刷新后的 refreshToken 都是不相同的。


**优势说明:**小金能够有一次完整的追剧体验,除非摸鱼时被老板发现了。accessToken 的存在,保证了登录的正常验证,因为 accessToken 的过期时间比较短,所以也可以保证账号的安全性。refreshToken 的存在,保证了小金无需在短时间内反复的登录来保持 Token 的有效性,同时也保证了活跃用户的登录状态可以一直延续而不需要重新登录,反复刷新也防止了某些不怀好意的人获取 refreshToken 后对用户账号进行不良操作。


一图胜千言:


image-20230604084837740


项目准备


项目采用 Spring Boot 3 + Spring Security + JWT + MyBatis-Plus + Lombok 进行搭建。


创建数据库


user 表


image-20230603220205094


token 表


在实际中应该把 token 信息保存到 redis


image-20230603220333914


创建 Spring Boot 项目


创建一个 Spring Boot 3 项目,一定要选择 Java 17 或者 Java 19


引入依赖


<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
<version>3.0.4version>
dependency>

<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-apiartifactId>
<version>0.11.5version>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-implartifactId>
<version>0.11.5version>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-jacksonartifactId>
<version>0.11.5version>
dependency>

编写配置文件


server:
port: 8417
spring:
application:
name: Spring Boot 3 + Spring Security + JWT + OpenAPI3
datasource:
url: jdbc:mysql://localhost:3306/w_admin
username: root
password: jcjl417
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
table-prefix: t_
id-type: auto
type-aliases-package: com.record.security.entity
mapper-locations: classpath:mapper/*.xml
application:
security:
jwt:
secret-key: VUhJT0pJT0hVWUlHRFVGVFdPSVJISVVHWUZHVkRVR0RISVVIREJZI1VJSEZTVUdZR0ZTVVk=
expiration: 86400000 # 1天
refresh-token:
expiration: 604800000 # 7 天
springdoc:
swagger-ui:
path: /docs.html
tags-sorter: alpha
operations-sorter: alpha
api-docs:
path: /v3/api-docs

项目实现


准备项目所需要的一系列代码,如 entity、controller 、service、mapper 等


系统角色 Role


定义一个角色(Role)枚举,详细代码参考文章结尾处的项目源码


public enum Role {

// 用户
USER(Collections.emptySet()),
// 一线人员
CHASER( ... ),
// 部门主管
SUPERVISOR( ... ),
// 系统管理员
ADMIN( ... ),
;

@Getter
private final Set permissions;

public List getAuthorities() {
var authorities = getPermissions()
.stream()
.map(permission -> new SimpleGrantedAuthority(permission.getPermission()))
.collect(Collectors.toList());
authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name()));
return authorities;
}
}

User 实现 UserDetails


温馨提示:


由于 Spring Security 源码设计的时候 ,将用户名和密码属性定义为 username 和 password,所以我们看到的大部分教程都会遵循源码中的方式,习惯性的将用户名定义为 username,密码定义为 password。


其实我们大可不必遵守这个规则,在我的系统中使用邮箱登录,也即是将邮箱(email)作为 Security 中的用户名(username),那么我必须要将用户输入的 email 作为 username 来存放,这会使我感到非常的不适,因为我的系统中正真的 username 将会 用另外一个单词来命名。


如何避免登录时的字段必须设置为 username 和 password 呢?



重写 getter方法, 只有你的系统中登录的用户名和密码属性不是 username 和 password 的情况下 ,你进行重写才会看到下面红色框中的提示。


202306032035283

重写 username 和 password 的 getter方法


@Override
public String getUsername() {
return email;
}

@Override
public String getPassword() {
return password;
}

Security 配置文件



需要注意的是 WebSecurityConfigurerAdapter 在 Spring Security 中已经被弃用和移除


下面将采用新的配置文件



@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfiguration {

private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
private final LogoutHandler logoutHandler;
private final RestAuthorizationEntryPoint restAuthorizationEntryPoint;
private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.authorizeHttpRequests()
.requestMatchers(
"/api/v1/auth/**",
"/api/v1/test/**",
"/v2/api-docs",
"/v3/api-docs",
"/v3/api-docs/**",
"/swagger-resources",
"/swagger-resources/**",
"/configuration/ui",
"/configuration/security",
"/swagger-ui/**",
"/doc.html",
"/webjars/**",
"/swagger-ui.html",
"/favicon.ico"
).permitAll()
.requestMatchers("/api/v1/supervisor/**").hasAnyRole(SUPERVISOR.name(), ADMIN.name())

.requestMatchers(GET, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_READ.name(), ADMIN_READ.name())
.requestMatchers(POST, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_CREATE.name(), ADMIN_CREATE.name())
.requestMatchers(PUT, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_UPDATE.name(), ADMIN_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_DELETE.name(), ADMIN_DELETE.name())

.requestMatchers("/api/v1/chaser/**").hasRole(CHASER.name())

.requestMatchers(GET, "/api/v1/chaser/**").hasAuthority(CHASER_READ.name())
.requestMatchers(POST, "/api/v1/chaser/**").hasAuthority(CHASER_CREATE.name())
.requestMatchers(PUT, "/api/v1/chaser/**").hasAuthority(CHASER_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/chaser/**").hasAuthority(CHASER_DELETE.name())

.anyRequest()
.authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authenticationProvider(authenticationProvider)
//添加jwt 登录授权过滤器
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.logout()
.logoutUrl("/api/v1/auth/logout")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext())

;
//添加自定义未授权和未登录结果返回
http.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restAuthorizationEntryPoint);

return http.build();
}
}

OpenApi 配置文件


OpenApi 依赖


<dependency>
<groupId>org.springdocgroupId>
<artifactId>springdoc-openapi-starter-webmvc-uiartifactId>
<version>2.1.0version>
dependency>

OpenApiConfig 配置


OpenApi3 生成接口文档,主要配置如下



  • Api Gr0up(分组)

  • Bearer Authorization(认证)

  • Customer(自定义请求头等)


@Configuration
public class OpenApiConfig {

@Bean
public OpenAPI customOpenAPI(){
return new OpenAPI()
.info(info())
.externalDocs(externalDocs())
.components(components())
.addSecurityItem(securityRequirement())
;
}

private Info info(){
return new Info()
.title("京茶吉鹿的 Demo")
.version("v0.0.1")
.description("Spring Boot 3 + Spring Security + JWT + OpenAPI3")
.license(new License()
.name("Apache 2.0") // The Apache License, Version 2.0
.url("https://www.apache.org/licenses/LICENSE-2.0.html"))
.contact(new Contact()
.name("京茶吉鹿")
.url("http://localost:8417")
.email("jc.top@qq.com"))
.termsOfService("http://localhost:8417")
;
}

private ExternalDocumentation externalDocs() {
return new ExternalDocumentation()
.description("京茶吉鹿的开放文档")
.url("http://localhost:8417/docs");
}

private Components components(){
return new Components()
.addSecuritySchemes("Bearer Authorization",
new SecurityScheme()
.name("Bearer 认证")
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
)
.addSecuritySchemes("Basic Authorization",
new SecurityScheme()
.name("Basic 认证")
.type(SecurityScheme.Type.HTTP)
.scheme("basic")
)
;

}

private SecurityRequirement securityRequirement() {
return new SecurityRequirement()
.addList("Bearer Authorization");
}

private List security(Components components) {
return components.getSecuritySchemes()
.keySet()
.stream()
.map(k -> new SecurityRequirement().addList(k))
.collect(Collectors.toList());
}


/**
* 通用接口
*
@return
*/

@Bean
public Gr0upedOpenApi publicApi(){
return Gr0upedOpenApi.builder()
.group("身份认证")
.pathsToMatch("/api/v1/auth/**")
// 为指定组设置请求头
// .addOperationCustomizer(operationCustomizer())
.build();
}

/**
* 一线人员
*
@return
*/

@Bean
public Gr0upedOpenApi chaserApi(){
return Gr0upedOpenApi.builder()
.group("一线人员")
.pathsToMatch("/api/v1/chaser/**",
"/api/v1/experience/search/**",
"/api/v1/log/**",
"/api/v1/contact/**",
"/api/v1/admin/user/update")
.pathsToExclude("/api/v1/experience/search/id")
.build();
}

/**
* 部门主管
*
@return
*/

@Bean
public Gr0upedOpenApi supervisorApi(){
return Gr0upedOpenApi.builder()
.group("部门主管")
.pathsToMatch("/api/v1/supervisor/**",
"/api/v1/experience/**",
"/api/v1/schedule/**",
"/api/v1/contact/**",
"/api/v1/admin/user/update")
.build();
}

/**
* 系统管理员
*
@return
*/

@Bean
public Gr0upedOpenApi adminApi(){
return Gr0upedOpenApi.builder()
.group("系统管理员")
.pathsToMatch("/api/v1/admin/**")
// .addOpenApiCustomiser(openApi -> openApi.info(new Info().title("京茶吉鹿接口—Admin")))
.build();
}
}

image-20230603224928028


Security 接口赋权的方式


hasRole及hasAuthority的区别?



hasAuthority能通过的身份必须与字符串一模一样,而hasRole能通过的身前缀必须带有ROLE_,同时可以通过两种字符串,一是带有前缀ROLE_,二是不带前缀ROLE_



通过配置文件


在配置文件中指明访问路径的权限


.requestMatchers("/api/v1/supervisor/**").hasAnyRole(SUPERVISOR.name(), ADMIN.name())
.requestMatchers(GET, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_READ.name(), ADMIN_READ.name())
.requestMatchers(POST, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_CREATE.name(), ADMIN_CREATE.name())
.requestMatchers(PUT, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_UPDATE.name(), ADMIN_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_DELETE.name(), ADMIN_DELETE.name())


.requestMatchers("/api/v1/chaser/**").hasRole(CHASER.name())
.requestMatchers(GET, "/api/v1/chaser/**").hasAuthority(CHASER_READ.name())
.requestMatchers(POST, "/api/v1/chaser/**").hasAuthority(CHASER_CREATE.name())
.requestMatchers(PUT, "/api/v1/chaser/**").hasAuthority(CHASER_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/chaser/**").hasAuthority(CHASER_DELETE.name())

通过注解


@RestController
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "系统管理员权限测试")
public class AdminController {

@GetMapping
@PreAuthorize("hasAuthority('admin:read')")
public String get() {
return "GET |==| AdminController";
}


@PostMapping
@PreAuthorize("hasAuthority('admin:create')")
public String post() {
return "POST |==| AdminController";
}
}

测试


我们登录认证成功后,系统会为我们返回 access_token 和 refresh_token。


image-20230604082145598




作者:京茶吉鹿
来源:juejin.cn/post/7241399184594993208
收起阅读 »

一个巧妙的分库分表设计:异构索引表

前言 最近计划参与一个换书活动,翻到《企业IT架构转型之道阿里巴巴中台战略思想与架构实战》这本书时,回想起令我印象比较深刻的一个知识点:“异构索引表”,所以在此记录并分享,和大家共同学习交流。 异构索引表的作用 如果《一致性哈希在分库分表的应用》说的是分库分表...
继续阅读 »

前言


最近计划参与一个换书活动,翻到《企业IT架构转型之道阿里巴巴中台战略思想与架构实战》这本书时,回想起令我印象比较深刻的一个知识点:“异构索引表”,所以在此记录并分享,和大家共同学习交流。


异构索引表的作用


如果《一致性哈希在分库分表的应用》说的是分库分表的方法和策略,那么本文所探讨的“异构索引表”,则是在实施分库分表过程中一个非常巧妙的设计,可以有效的解决分库分表的查询问题。


分库分表的查询问题


问题说明


在哈希分库分表时,为了避免分布不均匀造成的“数据倾斜”,通常会选择一些数据唯一的字段进行哈希操作,比如ID。


以订单表为例,通常有(id、uid、status、amount)等字段,通过id进行哈希取模运算分库分表之后,效果如下图


哈希分库分表效果


这样分库分表的方法没有问题,但是,在后期的开发和维护过程中,可能会存在潜在的问题。


举个例子:现在要查询uid为1的记录,应该去哪个表或库去查询?


对于用户来讲,这个场景可以说是非常频繁的。


这个时候就会发现,要想查询uid为1的记录,只能去所有的库或分表上进行查询,也就是所谓的“广播查询”。


整个查询过程大概是这样的


分库分表查询


性能问题


显然,整个查询过程需要进行全库扫描,涉及到多次的网络数据传输,一定会导致查询速度的降低和延迟的增加


数据聚合问题


另外,当这个用户有成千上万条数据时,不得已要在一个节点进行排序、分页、聚合等计算操作,需要消耗大量的计算资源和内存空间。对系统造成的负担也会影响查询性能。


这是一个非常典型的“事务边界大”的案例,即“一条SQL到所有的数据库去执行”。



那么如何解决这一痛点?



解决分库分表的查询问题


本文重点:“异构索引表”是可以解决这个问题的。


引入异构索引表


简单来说,“异构索引表”是一个拿空间换时间的设计。具体如下:


添加订单数据时,除了根据订单ID进行哈希取模运算将订单数据维护到对应的表中,还要对uid进行哈希取模运算,将uid和订单id维护在另一张表中,如图所示。


异构索引表


引入“异构索引表”后,因为同一个uid经过哈希取模运算后得到的结果是一致的,所以,该uid所有的订单id也一定会被分布到同一张user_order表中。


当查询uid为1的订单记录时,就可以有效地解决数据聚合存在的计算资源消耗全库扫描的低效问题了。


接下来,通过查询过程,看看这两个问题是怎么解决的。


引入后的查询过程


引入“异构索引表”后,查询uid为1的订单记录时,具体过程分为以下几步:



  1. 应用向中间件发送select * from order where uid = 1,请求查询uid为1的订单记录。

  2. 中间件根据uid路由到“异构索引表”:user_order,获得该uid相关的订单ID列表(排序、分页可以在此sql操作)。

  3. 中间件根据返回的订单ID,再次准确路由到对应的订单表:order

  4. 中间件将分散的订单数据进行聚合返回给应用。


引入异构索引表查询


看上去引入“异构索引表”之后,多了一个查询步骤,但换来的是:



  1. 根据订单ID准确路由到订单表,避免了全库扫描。

  2. user_order表进行了排序、分页等操作,避免大量数据回到中间件去计算。


异构索引表解决不了的场景


“异构索引表”只适合简单的分库分表查询场景,如果存在复杂的查询场景,还是需要借助搜索引擎来实现。


总结


异构索引表作为一种巧妙的设计,避免了分库分表查询存在的两个问题:全库扫描不必要的计算资源消耗


但是,异构索引表并不适用所有场景,对于复杂的查询场景可能需要结合其他技术或策略来解决问题。


作者:王二蛋呀
来源:juejin.cn/post/7372070947820109851
收起阅读 »

表设计的18条军规

前言 对于后端开发同学来说,访问数据库,是代码中必不可少的一个环节。 系统中收集到用户的核心数据,为了安全性,我们一般会存储到数据库,比如:mysql,oracle等。 后端开发的日常工作,需要不断的建库和建表,来满足业务需求。 通常情况下,建库的频率比建表要...
继续阅读 »

前言


对于后端开发同学来说,访问数据库,是代码中必不可少的一个环节。


系统中收集到用户的核心数据,为了安全性,我们一般会存储到数据库,比如:mysql,oracle等。


后端开发的日常工作,需要不断的建库和建表,来满足业务需求。


通常情况下,建库的频率比建表要低很多,所以,我们这篇文章主要讨论建表相关的内容。


如果我们在建表的时候不注意细节,等后面系统上线之后,表的维护成本变得非常高,而且很容易踩坑。


今天就跟大家一起聊聊,数据库建表的18个小技巧。


文章中介绍的很多细节,我在工作中踩过坑,并且实践过的,非常有借鉴意义,希望对你会有所帮助。


图片


1.名字


建表的时候,给字段索引起个好名字,真的太重要了。


1.1 见名知意


名字就像字段索引的一张脸,可以给人留下第一印象。


好的名字,言简意赅,见名知意,让人心情愉悦,能够提高沟通和维护成本。


坏的名字,模拟两可,不知所云。而且显得杂乱无章,看得让人抓狂。


反例:


用户名称字段定义成:yong_hu_ming、用户_name、name、user_name_123456789

你看了可能会一脸懵逼,这是什么骚操作?


正例:


用户名称字段定义成:user_name


温馨提醒一下,名字也不宜过长,尽量控制在30个字符以内。



1.2 大小写


名字尽量都用小写字母,因为从视觉上,小写字母更容易让人读懂。


反例:


字段名:PRODUCT_NAME、PRODUCT_name

全部大写,看起来有点不太直观。而一部分大写,一部分小写,让人看着更不爽。


正例:


字段名:product_name

名字还是使用全小写字母,看着更舒服。


1.3 分隔符


很多时候,名字为了让人好理解,有可能会包含多个单词。


那么,多个单词间的分隔符该用什么呢?


反例:


字段名:productname、productName、product name、product@name

单词间没有分隔,或者单词间用驼峰标识,或者单词间用空格分隔,或者单词间用@分隔,这几种方式都不太建议。


正例:


字段名:product_name

强烈建议大家在单词间用_分隔。


1.4 表名


对于表名,在言简意赅,见名知意的基础之上,建议带上业务前缀


如果是订单相关的业务表,可以在表名前面加个前缀:order_


例如:order_pay、order_pay_detail等。


如果是商品相关的业务表,可以在表名前面加个前缀:product_


例如:product_spu,product_sku等。


这样做的好处是为了方便归类,把相同业务的表,可以非常快速的聚集到一起。


另外,还有有个好处是,如果哪天有非订单的业务,比如:金融业务,也需要建一个名字叫做pay的表,可以取名:finance_pay,就能非常轻松的区分。


这样就不会出现同名表的情况。


1.5 字段名称


字段名称是开发人员发挥空间最大,但也最容易发生混乱的地方。


比如有些表,使用flag表示状态,另外的表用status表示状态。


可以统一一下,使用status表示状态。


如果一个表使用了另一个表的主键,可以在另一张表的名后面,加_id_sys_no,例如:


在product_sku表中有个字段,是product_spu表的主键,这时候可以取名:product_spu_id或product_spu_sys_no。


还有创建时间,可以统一成:create_time,修改时间统一成:update_time。


删除状态固定为:delete_status。


其实还有很多公共字段,在不同的表之间,可以使用全局统一的命名规则,定义成相同的名称,以便于大家好理解。


1.6 索引名


在数据库中,索引有很多种,包括:主键、普通索引、唯一索引、联合索引等。


每张表的主键只有一个,一般使用:id或者sys_no命名。


普通索引和联合索引,其实是一类。在建立该类索引时,可以加ix_前缀,比如:ix_product_status。


唯一索引,可以加ux_前缀,比如:ux_product_code。


2.字段类型


在设计表时,我们在选择字段类型时,可发挥空间很大。


时间格式的数据有:date、datetime和timestamp等等可以选择。


字符类型的数据有:varchar、char、text等可以选择。


数字类型的数据有:int、bigint、smallint、tinyint等可以选择。


说实话,选择很多,有时候是一件好事,也可能是一件坏事。


如何选择一个合适的字段类型,变成了我们不得不面对的问题。


如果字段类型选大了,比如:原本只有1-10之间的10个数字,结果选了bigint,它占8个字节。


其实,1-10之间的10个数字,每个数字1个字节就能保存,选择tinyint更为合适。


这样会白白浪费7个字节的空间。


如果字段类型择小了,比如:一个18位的id字段,选择了int类型,最终数据会保存失败。


所以选择一个合适的字段类型,还是非常重要的一件事情。


以下原则可以参考一下:



  1. 尽可能选择占用存储空间小的字段类型,在满足正常业务需求的情况下,从小到大,往上选。

  2. 如果字符串长度固定,或者差别不大,可以选择char类型。如果字符串长度差别较大,可以选择varchar类型。

  3. 是否字段,可以选择bit类型。

  4. 枚举字段,可以选择tinyint类型。

  5. 主键字段,可以选择bigint类型。

  6. 金额字段,可以选择decimal类型。

  7. 时间字段,可以选择timestamp或datetime类型。


3.字段长度


前面我们已经定义好了字段名称,选择了合适的字段类型,接下来,需要重点关注的是字段长度了。


比如:varchar(20),biginit(20)等。


那么问题来了,varchar代表的是字节长度,还是字符长度呢?


答:在mysql中除了varcharchar是代表字符长度之外,其余的类型都是代表字节长度。


biginit(n) 这个n表示什么意思呢?


假如我们定义的字段类型和长度是:bigint(4),bigint实际长度是8个字节。


现在有个数据a=1,a显示4个字节,所以在不满4个字节时前面填充0(前提是该字段设置了zerofill属性),比如:0001。


当满了4个字节时,比如现在数据是a=123456,它会按照实际的长度显示,比如:123456。


但需要注意的是,有些mysql客户端即使满了4个字节,也可能只显示4个字节的内容,比如会显示成:1234。


所以bigint(4),这里的4表示显示的长度为4个字节,实际长度还是占8个字节。


4.字段个数


我们在建表的时候,一定要对字段个数做一些限制。


我之前见过有人创建的表,有几十个,甚至上百个字段,表中保存的数据非常大,查询效率很低。


如果真有这种情况,可以将一张大表拆成多张小表,这几张表的主键相同。


建议每表的字段个数,不要超过20个。


5. 主键


在创建表时,一定要创建主键


因为主键自带了主键索引,相比于其他索引,主键索引的查询效率最高,因为它不需要回表。


此外,主键还是天然的唯一索引,可以根据它来判重。


单个数据库中,主键可以通过AUTO_INCREMENT,设置成自动增长的。


但在分布式数据库中,特别是做了分库分表的业务库中,主键最好由外部算法(比如:雪花算法)生成,它能够保证生成的id是全局唯一的。


除此之外,主键建议保存跟业务无关的值,减少业务耦合性,方便今后的扩展。


不过我也见过,有些一对一的表关系,比如:用户表和用户扩展表,在保存数据时是一对一的关系。


这样,用户扩展表的主键,可以直接保存用户表的主键。


6.存储引擎


mysql8以前的版本,默认的存储引擎是myisam,而mysql8以后的版本,默认的存储引擎变成了innodb


之前我们还在创建表时,还一直纠结要选哪种存储引擎?


myisam的索引和数据分开存储,而有利于查询,但它不支持事务和外键等功能。


innodb虽说查询性能,稍微弱一点,但它支持事务和外键等,功能更强大一些。


以前的建议是:读多写少的表,用myisam存储引擎。而写多读多的表,用innodb。


但虽说mysql对innodb存储引擎性能的不断优化,现在myisam和innodb查询性能相差已经越来越小。


所以,建议我们在使用mysql8以后的版本时,直接使用默认的innodb存储引擎即可,无需额外修改存储引擎。


7. NOT NULL


在创建字段时,需要选择该字段是否允许为NULL


我们在定义字段时,应该尽可能明确该字段NOT NULL


为什么呢?


我们主要以innodb存储引擎为例,myisam存储引擎没啥好说的。


主要有以下原因:



  1. 在innodb中,需要额外的空间存储null值,需要占用更多的空间。

  2. null值可能会导致索引失效。

  3. null值只能用is null或者is not null判断,用=号判断永远返回false。


因此,建议我们在定义字段时,能定义成NOT NULL,就定义成NOT NULL。


但如果某个字段直接定义成NOT NULL,万一有些地方忘了给该字段写值,就会insert不了数据。


这也算合理的情况。


但有一种情况是,系统有新功能上线,新增了字段。上线时一般会先执行sql脚本,再部署代码。


由于老代码中,不会给新字段赋值,则insert数据时,也会报错。


由此,非常有必要给NOT NULL的字段设置默认值,特别是后面新增的字段。


例如:


alter table product_sku add column  brand_id int(10not null default 0;

8.外键


在mysql中,是存在外键的。


外键存在的主要作用是:保证数据的一致性完整性


例如:


create table class (
  id int(10primary key auto_increment,
  cname varchar(15)
);

有个班级表class。


然后有个student表:


create table student(
  id int(10primary key auto_increment,
  name varchar(15not null,
  gender varchar(10not null,
  cid int,
  foreign key(cid) references class(id)
);

其中student表中的cid字段,保存的class表的id,这时通过foreign key增加了一个外键。


这时,如果你直接通过student表的id删除数据,会报异常:


a foreign key constraint fails

必须要先删除class表对于的cid那条数据,再删除student表的数据才行,这样能够保证数据的一致性和完整性。



顺便说一句:只有存储引擎是innodb时,才能使用外键。



如果只有两张表的关联还好,但如果有十几张表都建了外键关联,每删除一次主表,都需要同步删除十几张子表,很显然性能会非常差。


因此,互联网系统中,一般建议不使用外键。因为这类系统更多的是为了性能考虑,宁可牺牲一点数据一致性和完整性。


除了外键之外,存储过程触发器也不太建议使用,他们都会影响性能。


9. 索引


在建表时,除了指定主键索引之外,还需要创建一些普通索引


例如:


create table product_sku(
  id int(10primary key auto_increment,
  spu_id int(10not null,
  brand_id int(10not null,
  name varchar(15not null
);

在创建商品表时,使用spu_id(商品组表)和brand_id(品牌表)的id。


像这类保存其他表id的情况,可以增加普通索引:


create table product_sku (
  id int(10primary key auto_increment,
  spu_id int(10not null,
  brand_id int(10not null,
  name varchar(15not null,
  KEY `ix_spu_id` (`spu_id`USING BTREE,
  KEY `ix_brand_id` (`brand_id`USING BTREE
);

后面查表的时候,效率更高。


但索引字段也不能建的太多,可能会影响保存数据的效率,因为索引需要额外的存储空间。


建议单表的索引个数不要超过:5个。


如果在建表时,发现索引个数超过5个了,可以删除部分普通索引,改成联合索引


顺便说一句:在创建联合索引的时候,需要使用注意最左匹配原则,不然,建的联合索引效率可能不高。


对于数据重复率非常高的字段,比如:状态,不建议单独创建普通索引。因为即使加了索引,如果mysql发现全表扫描效率更高,可能会导致索引失效。


如果你对索引失效问题比较感兴趣,可以看看我的另一篇文章《聊聊索引失效的10种场景,太坑了》,里面有非常详细的介绍。


10.时间字段


时间字段的类型,我们可以选择的范围还是比较多的,目前mysql支持:date、datetime、timestamp、varchar等。


varchar类型可能是为了跟接口保持一致,接口中的时间类型是String。


但如果哪天我们要通过时间范围查询数据,效率会非常低,因为这种情况没法走索引。


date类型主要是为了保存日期,比如:2020-08-20,不适合保存日期和时间,比如:2020-08-20 12:12:20。


datetimetimestamp类型更适合我们保存日期和时间


但它们有略微区别。



  • timestamp:用4个字节来保存数据,它的取值范围为1970-01-01 00:00:01 UTC ~ 2038-01-19 03:14:07。此外,它还跟时区有关。

  • datetime:用8个字节来保存数据,它的取值范围为1000-01-01 00:00:00 ~ 9999-12-31 23:59:59。它跟时区无关。


优先推荐使用datetime类型保存日期和时间,可以保存的时间范围更大一些。



温馨提醒一下,在给时间字段设置默认值是,建议不要设置成:0000-00-00 00:00:00,不然查询表时可能会因为转换不了,而直接报错。



11.金额字段


mysql中有多个字段可以表示浮点数:float、double、decimal等。


floatdouble可能会丢失精度,因此推荐大家使用decimal类型保存金额。


一般我们是这样定义浮点数的:decimal(m,n)。


其中n是指小数的长度,而m是指整数加小数的总长度。


假如我们定义的金额类型是这样的:decimal(10,2),则表示整数长度是8位,并且保留2位小数。


12. json字段


我们在设计表结构时,经常会遇到某个字段保存的数据值不固定的需求。


举个例子,比如:做异步excel导出功能时,需要在异步任务表中加一个字段,保存用户通过前端页面选择的查询条件,每个用户的查询条件可能都不一样。


这种业务场景,使用传统的数据库字段,不太好实现。


这时候就可以使用MySQL的json字段类型了,可以保存json格式的结构化数据。


保存和查询数据都是非常方便的。


MySQL还支持按字段名称或者字段值,查询json中的数据。


13.唯一索引


唯一索引在我们实际工作中,使用频率相当高。


你可以给单个字段,加唯一索引,比如:组织机构code。


也可以给多个字段,加一个联合的唯一索引,比如:分类编号、单位、规格等。


单个的唯一索引还好,但如果是联合的唯一索引,字段值出现null时,则唯一性约束可能会失效。


关于唯一索引失效的问题,感兴趣的小伙伴可以看看我的另一篇文章《明明加了唯一索引,为什么还是产生重复数据?》。



创建唯一索引时,相关字段一定不能包含null值,否则唯一性会失效。



14.字符集


mysql中支持的字符集有很多,常用的有:latin1、utf-8、utf8mb4、GBK等。


这4种字符集情况如下:图片


latin1容易出现乱码问题,在实际项目中使用比较少。


GBK支持中文,但不支持国际通用字符,在实际项目中使用也不多。


从目前来看,mysql的字符集使用最多的还是:utf-8utf8mb4


其中utf-8占用3个字节,比utf8mb4的4个字节,占用更小的存储空间。


但utf-8有个问题:即无法存储emoji表情,因为emoji表情一般需要4个字节。


由此,使用utf-8字符集,保存emoji表情时,数据库会直接报错。


所以,建议在建表时字符集设置成:utf8mb4,会省去很多不必要的麻烦。


15. 排序规则


不知道,你关注过没,在mysql中创建表时,有个COLLATE参数可以设置。


例如:


CREATE TABLE `order` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `code` varchar(20COLLATE utf8mb4_bin NOT NULL,
  `name` varchar(30COLLATE utf8mb4_bin NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `un_code` (`code`),
  KEY `un_code_name` (`code`,`name`USING BTREE,
  KEY `idx_name` (`name`)
ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin

它是用来设置排序规则的。


字符排序规则跟字符集有关,比如:字符集如果是utf8mb4,则字符排序规则也是以:utf8mb4_开头的,常用的有:utf8mb4_general_ciutf8mb4_bin等。


其中utf8mb4_general_ci排序规则,对字母的大小写不敏感。说得更直白一点,就是不区分大小写。


而utf8mb4_bin排序规则,对字符大小写敏感,也就是区分大小写。


说实话,这一点还是非常重要的。


假如order表中现在有一条记录,name的值是大写的YOYO,但我们用小写的yoyo去查,例如:


select * from order where name='yoyo';

如果字符排序规则是utf8mb4_general_ci,则可以查出大写的YOYO的那条数据。


如果字符排序规则是utf8mb4_bin,则查不出来。


由此,字符排序规则一定要根据实际的业务场景选择,否则容易出现问题。


最近就业形式比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。


你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。


image.png


进群方式


添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。


16.大字段


我们在创建表时,对一些特殊字段,要额外关注,比如:大字段,即占用较多存储空间的字段。


比如:用户的评论,这就属于一个大字段,但这个字段可长可短。


但一般会对评论的总长度做限制,比如:最多允许输入500个字符。


如果直接定义成text类型,可能会浪费存储空间,所以建议将这类字段定义成varchar类型的存储效率更高。


当然,我还见过更大的字段,即该字段直接保存合同数据。


一个合同可能会占几Mb


在mysql中保存这种数据,从系统设计的角度来说,本身就不太合理。


像合同这种非常大的数据,可以保存到mongodb中,然后在mysql的业务表中,保存mongodb表的id。


17.冗余字段


我们在设计表的时候,为了性能考虑,提升查询速度,有时可以冗余一些字段。


举个例子,比如:订单表中一般会有userId字段,用来记录用户的唯一标识。


但很多订单的查询页面,或者订单的明细页面,除了需要显示订单信息之外,还需要显示用户ID和用户名称。


如果订单表和用户表的数据量不多,我们可以直接用userId,将这两张表join起来,查询出用户名称。


但如果订单表和用户表的数据量都非常多,这样join是比较消耗查询性能的。


这时候我们可以通过冗余字段的方案,来解决性能问题。


我们可以在订单表中,可以再加一个userName字段,在系统创建订单时,将userId和userName同时写值。


当然订单表中历史数据的userName是空的,可以刷一下历史数据。


这样调整之后,后面只需要查询订单表,即可查询出我们所需要的数据。


不过冗余字段的方案,有利也有弊。


对查询性能有利。


但需要额外的存储空间,还可能会有数据不一致的情况,比如用户名称修改了。


我们在实际业务场景中,需要综合评估,冗余字段方案不适用于所有业务场景。


18.注释


我们在做表设计的时候,一定要把表和相关字段的注释加好。


例如下面这样的:


CREATE TABLE `sys_dept` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `name` varchar(30NOT NULL COMMENT '名称',
  `pid` bigint NOT NULL COMMENT '上级部门',
  `valid_status` tinyint(1NOT NULL DEFAULT 1 COMMENT '有效状态 1:有效 0:无效',
  `create_user_id` bigint NOT NULL COMMENT '创建人ID',
  `create_user_name` varchar(30NOT NULL COMMENT '创建人名称',
  `create_time` datetime(3DEFAULT NULL COMMENT '创建日期',
  `update_user_id` bigint DEFAULT NULL COMMENT '修改人ID',
  `update_user_name` varchar(30)  DEFAULT NULL COMMENT '修改人名称',
  `update_time` datetime(3DEFAULT NULL COMMENT '修改时间',
  `is_del` tinyint(1DEFAULT '0' COMMENT '是否删除 1:已删除 0:未删除',
  PRIMARY KEY (`id`USING BTREE,
  KEY `index_pid` (`pid`USING BTREE
ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='部门';

表和字段的注释,都列举的非常详细。


特别是有些状态类型的字段,比如:valid_status字段,该字段表示有效状态, 1:有效 0:无效。


让人可以一目了然,表和字段是干什么用的,字段的值可能有哪些。


最怕的情况是,你在表中创建了很多status字段,每个字段都有1、2、3、4、5、6、7、8、9等多个值。


没有写什么注释。


谁都不知道1代表什么含义,2代表什么含义,3代表什么含义。


可能刚开始你还记得。


但系统上线使用一年半载之后,可能连你自己也忘记了这些status字段,每个值的具体含义了,埋下了一个巨坑。


由此,我们在做表设计时,一定要写好相关的注释,并且经常需要更新这些注释。




作者:苏三说技术
来源:juejin.cn/post/7352789840352690185
收起阅读 »

接口设计的18条军规

大家好,我是苏三,又跟大家见面了。 前言 之前写过一篇文章《表设计的18条军规》,发表之前,在全网广受好评。 今天延续设计的话题,给大家总结了接口设计的18条军规,希望对你会有所帮助。 1. 签名 为了防止API接口中的数据被篡改,很多时候我们需要对API接...
继续阅读 »

大家好,我是苏三,又跟大家见面了。


前言


之前写过一篇文章《表设计的18条军规》,发表之前,在全网广受好评。


今天延续设计的话题,给大家总结了接口设计的18条军规,希望对你会有所帮助。


图片


1. 签名


为了防止API接口中的数据被篡改,很多时候我们需要对API接口做签名


接口请求方将请求参数 + 时间戳 + 密钥拼接成一个字符串,然后通过md5等hash算法,生成一个前面sign。


然后在请求参数或者请求头中,增加sign参数,传递给API接口。


API接口的网关服务,获取到该sign值,然后用相同的请求参数 + 时间戳 + 密钥拼接成一个字符串,用相同的m5算法生成另外一个sign,对比两个sign值是否相等。


如果两个sign相等,则认为是有效请求,API接口的网关服务会将给请求转发给相应的业务系统。


如果两个sign不相等,则API接口的网关服务会直接返回签名错误。


问题来了:签名中为什么要加时间戳?


答:为了安全性考虑,防止同一次请求被反复利用,增加了密钥没破解的可能性,我们必须要对每次请求都设置一个合理的过期时间,比如:15分钟。


这样一次请求,在15分钟之内是有效的,超过15分钟,API接口的网关服务会返回超过有效期的异常提示。


目前生成签名中的密钥有两种形式:


一种是双方约定一个固定值privateKey。


另一种是API接口提供方给出AK/SK两个值,双方约定用SK作为签名中的密钥。AK接口调用方作为header中的accessKey传递给API接口提供方,这样API接口提供方可以根据AK获取到SK,而生成新的sgin。


2. 加密


有些时候,我们的API接口直接传递的非常重要的数据,比如:用户的登录密码、银彳亍卡号、转账金额、用户身-份-证等,如果将这些参数,直接明文,暴露到公网上是非常危险的事情。


由此,我们需要对数据进行加密


比如:用户注册接口,用户输入了用户名和密码之后,需要将密码加密。


我们可以使用AES对称加密算法。


在前端使用公钥对用户密码加密。


然后注册接口中,可以使用密钥解密,做一些业务需求校验。然后再换成其他的加密方式加密,保存到数据库当中。


3. ip白名单


为了进一步加强API接口的安全性,防止接口的签名或者加密被破解了,攻击者可以在自己的服务器上请求该接口。


需求限制请求ip,增加ip白名单


只有在白名单中的ip地址,才能成功请求API接口,否则直接返回无访问权限。


ip白名单也可以加在API网关服务上。


但也要防止公司的内部应用服务器被攻破,这种情况也可以从内部服务器上发起API接口的请求。


这时候就需要增加web防火墙了,比如:ModSecurity等。


4. 限流


如果你的API接口被第三方平台调用了,这就意味着着,调用频率是没法控制的。


第三方平台调用你的API接口时,如果并发量一下子太高,可能会导致你的API服务不可用,接口直接挂掉。


由此,必须要对API接口做限流


限流方法有三种:



  1. 对请求ip做限流:比如同一个ip,在一分钟内,对API接口总的请求次数,不能超过10000次。

  2. 对请求接口做限流:比如同一个ip,在一分钟内,对指定的API接口,请求次数不能超过2000次。

  3. 对请求用户做限流:比如同一个AK/SK用户,在一分钟内,对API接口总的请求次数,不能超过10000次。


我们在实际工作中,可以通过nginxredis或者gateway实现限流的功能。


5. 参数校验


我们需要对API接口做参数校验,比如:校验必填字段是否为空,校验字段类型,校验字段长度,校验枚举值等等。


这样做可以拦截一些无效的请求。


比如在新增数据时,字段长度超过了数据字段的最大长度,数据库会直接报错。


但这种异常的请求,我们完全可以在API接口的前期进行识别,没有必要走到数据库保存数据那一步,浪费系统资源。


有些金额字段,本来是正数,但如果用户传入了负数,万一接口没做校验,可能会导致一些没必要的损失。


还有些状态字段,如果不做校验,用户如果传入了系统中不存在的枚举值,就会导致保存的数据异常。


由此可见,做参数校验是非常有必要的。


在Java中校验数据使用最多的是hiberateValidator框架,它里面包含了@Null、@NotEmpty、@Size、@Max、@Min等注解。


用它们校验数据非常方便。


当然有些日期字段和枚举字段,可能需要通过自定义注解的方式实现参数校验。


6. 统一返回值


我之前调用过别人的API接口,正常返回数据是一种json格式,比如:


{
    "code":0,
    "message":null,
    "data":[{"id":123,"name":"abc"}]
},

签名错误返回的json格式:


{
    "code":1001,
    "message":"签名错误",
    "data":null
}

没有数据权限返回的json格式:


{
    "rt":10,
    "errorMgt":"没有权限",
    "result":null
}

这种是比较坑的做法,返回值中有多种不同格式的返回数据,这样会导致对接方很难理解。


出现这种情况,可能是API网关定义了一直返回值结构,业务系统定义了另外一种返回值结构。如果是网关异常,则返回网关定义的返回值结构,如果是业务系统异常,则返回业务系统的返回值结构。


但这样会导致API接口出现不同的异常时,返回不同的返回值结构,非常不利于接口的维护。


其实这个问题我们可以在设计API网关时解决。


业务系统在出现异常时,抛出业务异常的RuntimeException,其中有个message字段定义异常信息。


所有的API接口都必须经过API网关,API网关捕获该业务异常,然后转换成统一的异常结构返回,这样能统一返回值结构。


7. 统一封装异常


我们的API接口需要对异常进行统一处理。


不知道你有没有遇到过这种场景:有时候在API接口中,需要访问数据库,但表不存在,或者sql语句异常,就会直接把sql信息在API接口中直接返回。


返回值中包含了异常堆栈信息数据库信息错误代码和行数等信息。


如果直接把这些内容暴露给第三方平台,是很危险的事情。


有些不法分子,利用接口返回值中的这些信息,有可能会进行sql注入或者直接脱库,而对我们系统造成一定的损失。


因此非常有必要对API接口中的异常做统一处理,把异常转换成这样:


{
    "code":500,
    "message":"服务器内部错误",
    "data":null
}

返回码code500,返回信息message服务器内部异常


这样第三方平台就知道是API接口出现了内部问题,但不知道具体原因,他们可以找我们排查问题。


我们可以在内部的日志文件中,把堆栈信息、数据库信息、错误代码行数等信息,打印出来。


我们可以在gateway中对异常进行拦截,做统一封装,然后给第三方平台的是处理后没有敏感信息的错误信息。


8. 请求日志


在第三方平台请求你的API接口时,接口的请求日志非常重要,通过它可以快速的分析和定位问题。


我们需要把API接口的请求url、请求参数、请求头、请求方式、响应数据和响应时间等,记录到日志文件中。


最好有traceId,可以通过它串联整个请求的日志,过滤多余的日志。


当然有些时候,请求日志不光是你们公司开发人员需要查看,第三方平台的用户也需要能查看接口的请求日志。


这时就需要把日志落地到数据库,比如:mongodb或者elastic search,然后做一个UI页面,给第三方平台的用户开通查看权限。这样他们就能在外网查看请求日志了,他们自己也能定位一部分问题。


9. 幂等设计


第三方平台极有可能在极短的时间内,请求我们接口多次,比如:在1秒内请求两次。有可能是他们业务系统有bug,或者在做接口调用失败重试,因此我们的API接口需要做幂等设计


也就是说要支持在极短的时间内,第三方平台用相同的参数请求API接口多次,第一次请求数据库会新增数据,但第二次请求以后就不会新增数据,但也会返回成功。


这样做的目的是不会产生错误数据。


我们在日常工作中,可以通过在数据库中增加唯一索引,或者在redis保存requestId和请求参来保证接口幂等性。


对接口幂等性感兴趣的小伙伴,可以看看我的另一篇文章《高并发下如何保证接口的幂等性?》,里面有非常详细的介绍。


10. 限制记录条数


对于对我提供的批量接口,一定要限制请求的记录条数


如果请求的数据太多,很容易造成API接口超时等问题,让API接口变得不稳定。


通常情况下,建议一次请求中的参数,最多支持传入500条记录。


如果用户传入多余500条记录,则接口直接给出提示。


建议这个参数做成可配置的,并且要事先跟第三方平台协商好,避免上线后产生不必要的问题。


对于一次性查询的数据太多的情况,我们需要将接口设计成分页查询返回的。


11. 压测


上线前我们务必要对API接口做一下压力测试,知道各个接口的qps情况。


以便于我们能够更好的预估,需要部署多少服务器节点,对于API接口的稳定性至关重要。


之前虽说对API接口做了限流,但是实际上API接口是否能够达到限制的阀值,这是一个问号,如果不做压力测试,是有很大风险的。


比如:你API接口限流1秒只允许50次请求,但实际API接口只能处理30次请求,这样你的API接口也会处理不过来。


我们在工作中可以用jmeter或者apache benc对API接口做压力测试。


12. 异步处理


一般的API接口的逻辑都是同步处理的,请求完之后立刻返回结果。


但有时候,我们的API接口里面的业务逻辑非常复杂,特别是有些批量接口,如果同步处理业务,耗时会非常长。


这种情况下,为了提升API接口的性能,我们可以改成异步处理


在API接口中可以发送一条mq消息,然后直接返回成功。之后,有个专门的mq消费者去异步消费该消息,做业务逻辑处理。


直接异步处理的接口,第三方平台有两种方式获取到。


第一种方式是:我们回调第三方平台的接口,告知他们API接口的处理结果,很多支付接口就是这么玩的。


第二种方式是:第三方平台通过轮询调用我们另外一个查询状态的API接口,每隔一段时间查询一次状态,传入的参数是之前的那个API接口中的id集合。


13. 数据脱敏


有时候第三方平台调用我们API接口时,获取的数据中有一部分是敏感数据,比如:用户手机号、银彳亍卡号等等。


这样信息如果通过API接口直接保留到外网,是非常不安全的,很容易造成用户隐私数据泄露的问题。


这就需要对部分数据做数据脱敏了。


我们可以在返回的数据中,部分内容用星号代替。


已用户手机号为例:182****887


这样即使数据被泄露了,也只泄露了一部分,不法分子拿到这份数据也没啥用。


14. 完整的接口文档


说实话,一份完整的API接口文档,在双方做接口对接时,可以减少很多沟通成本,让对方少走很多弯路。


接口文档中需要包含如下信息:



  1. 接口地址

  2. 请求方式,比如:post或get

  3. 请求参数和字段介绍

  4. 返回值和字段介绍

  5. 返回码和错误信息

  6. 加密或签名示例

  7. 完整的请求demo

  8. 额外的说明,比如:开通ip白名单。


接口文档中最好能够统一接口和字段名称的命名风格,比如都用驼峰标识命名。


统一字段的类型和长度,比如:id字段用Long类型,长度规定20。status字段用int类型,长度固定2等。


统一时间格式字段,比如:time用String类型,格式为:yyyy-MM-dd HH:mm:ss。


接口文档中写明AK/SK和域名,找某某单独提供等。


最近建了一些高质量的粉丝群,里面可以交流技术,有工作内推,有粉丝福利。


进群方式


添加苏三的私人微信:su_san_java,备注:掘金+粉丝,即可加入。


15. 请求方式


接口支持的请求方式有很多,比如:GET、POST、PUT、DELETE等等。


我们在设计接口的时候,要根据实际情况选择使用哪种请求方式。


实际工作中使用最多的是:GETPOST,这两种请求方式。


如果没有输入参数的接口,可以使用GET请求方式,问题不大。


如果有输入参数的接口,推荐使用POST请求方式,坑更少。


主要原因有下面两点:



  1. POST请求方式更容易扩展参数,特别是在Fegin调用接口的场景下,比如:增加一个参数,调用方可以不用修改代码。而GET请求方式,需要修改代码,否则编译会出错。

  2. GET请求方式的参数,有长度限制,最长是5000个字符,而POST请求方式对参数的长度没有做限制,可以传入更长的参数值。


16. 请求头


对于一些公共的功能,比如:接口的权限验证,或者接口的traceId参数。


我们在设计接口的时候,不用把所有的参数,都放入接口的请求参数中。


有些参数可以放到Header请求头中。


比如:我们需求记录每个请求的traceId,不用在所有接口中都加traceId字段。


而改成让用户在header中传入traceId,在服务端使用统一的拦截器解析header,即可获取该traceId了。


17. 批量


我们在设计接口的时候,无论是查询数据、添加数据、修改数据,还是删除的场景,都应该考虑一下能否设计成批量的。


很多时候,需要通过id查询数据详情,比如:通过订单id,查询订单详情。


如果你的接口只支持,通过一个id,查询一个订单的详情。


那么,后面需要通过多个id,查询多个订单详情的时候,就需要额外增加接口了。


如果你添加数据的接口,只支持一条数据一条数据的添加。


后面,有个job需要一次性添加1000条数据的时候,这时在代码中循环1000次,一个个添加,这种做法效率比较低。


为了让你的接口设计的更加通用,满足更多的业务场景,能设计成批量的,尽量别设计成单个的。


18. 职责单一


我之前见过有些小伙伴设计的接口,在入参中各种条件都支持,在Service层有N多的if...else判断。


而且返回的实体类中,包含了各种场景下的返回值字段,字段很多很全。


接口上线一年之后,自己可能都忘了,在哪些业务场景下,要传入哪些字段,返回值是哪些字段。


这类接口的维护成本非常高,而且又不敢轻易重构,怕改了A业务场景,影响B业务场景的功能,这种接口让人非常痛苦的。


好的接口设计原则是:职责单一


比如用户下单的场景,有web端和移动端。


而每个端都有普通下单和快速下单,两种不同的业务场景。


我们在设计接口的时候,可以将web端和移动端的接口在controller层完全分开。


/web/v1/order
/mobile/v1/order

并且将普通下单和快速下单也分开:


/web/v1/order/create
/web/v1/order/fastCreate
/mobile/v1/order/create
/mobile/v1/order/fastCreate

这样可以设计成4个接口。


业务逻辑更清晰一些,方便后面维护。




作者:苏三说技术
来源:juejin.cn/post/7372094258793414710
收起阅读 »

好烦啊,我真的不想写增删改查了!

大家好,我是程序员鱼皮。 很想吐槽:我真的不想写增删改查这种重复代码了! 大学刚做项目的时候,就在写增删改查,万万没想到 7 年后,还在和增删改查打交道。因为增删改查是任何项目的基础功能,每次带朋友们做新项目时,为了照顾更多同学,我都会带大家把增删改查的代码再...
继续阅读 »

大家好,我是程序员鱼皮。


很想吐槽:我真的不想写增删改查这种重复代码了!


大学刚做项目的时候,就在写增删改查,万万没想到 7 年后,还在和增删改查打交道。因为增删改查是任何项目的基础功能,每次带朋友们做新项目时,为了照顾更多同学,我都会带大家把增删改查的代码再编写并讲解一遍。


不开玩笑地说,我绝对有资格在简历上写 “自己精通增删改查的编写” 了!



相信很多已经在工作中的小伙伴,80% 甚至更多的时间也在天天写增删改查这种重复代码,也会因此感到烦恼。那大家有没有思考过:如何提高写增删改查的效率?让自己有更多时间进步(愉快摸鱼)呢?


其实有很多种方法,鱼皮分享下自己的提效小操作,看看朋友们有没有实践过~


如何提高增删改查的编写效率?


方法 1、复制粘贴


复制粘贴前人的代码是一种最简单直接的方法,估计大多数开发者在实际工作中都是这么干的。



但这种方式存在的问题也很明显,如果对复制的代码本身不够理解,很有可能出现细节错误。而且不同数据表的字段和校验规则是不同的,往往复制后的代码还要经过大量的人工修改。


还有很多 “小迷糊”,经常复制完代码后忘了修改一些变量名称和注释,出现类似下面代码的名场面:


// 帖子接口
class UserController {
}

方法 2、使用模板


一般新项目都是要基于模板开发的,而不是每次都重复编写一大堆的通用代码。比如我之前给编程导航同学编写的 Spring Boot 后端万用模板,内置了用户注册、账号密码登录、公众号登录等通用能力。基于这种模板二次开发,能够大大提高开发效率,也有助于开发同学遵循一致的规范。


模板支持的功能


然而,使用模板也存在一些风险,如果模板本身有功能存在漏洞,那么所有基于这个模板开发的项目可能都会存在风险。而且别人的模板也不是万能的,建议还是根据自己的开发经验,自己沉淀和维护一套模板。对团队来说,沉淀模板是必须要做的事。


方法 3、AI 工具


利用 AI 工具来生成增删改查的代码是一种新兴的方法。只需要甩给 AI 要生成代码的表结构,然后精准地编写要生成的代码要求,就可以让 AI 快速生成了。



这种方式的优点是非常灵活,能帮开发者提供一些灵感;缺点就是对编写 prompt(提示词)的要求会比较高,而且生成后的代码还是得仔细检查一遍的。


方法 4、超级抽象


这是一种更高级别的代码复用方法。通过设计 通用的 数据模型和操作接口,实现用一套代码满足多种不同业务场景下的增删改查操作。


举个例子,如果有帖子、评论、回答等多个资源需要支持用户收藏功能,系统规模不大的情况下,不需要编写 3 张不同的收藏表、并分别编写增删改查代码。而是可以设计 1 张通用的收藏表,通过 type 字段来区分不同类型的资源,从而实现统一的收藏操作。


像点赞、消息通知、日志、数据收集等业务场景,都可以采用这种方式,通过极致的复用完成快速开发。


但也要注意,千万不要把区别较大的功能强行合并到一起,反而会增加开发者的理解成本;而且如果系统数据量较大,分开维护表更有利于系统的性能和稳定性。


方法 5、代码生成器


这也是非常典型的一种提高增删改查效率的方法。后端可以使用 MyBatis X 插件来生成数据模型和数据访问层的 Mapper 代码,前端可以用 OpenAPI 工具生成请求函数和 TS 类型代码等。


不过用别人的生成器难免会出现无法满足需求的情况,生成后的代码一般还是要自己再修改一下的。


所以,我建议可以使用模板引擎技术,自己开发一套更灵活、更适合自己业务的代码生成器。


比如鱼皮给后端万用模板补充了代码生成器功能,使用 FreeMarker 模板引擎技术实现,定制了 Controller、Service、数据包装类的代码模板。用户只需要指定几个参数,就可以在指定位置生成代码了~ 昨天 AI 答题应用平台的开发中,就是用了这个代码生成器,几分钟写好一套功能。



可以在代码小抄阅读生成器的核心实现代码:http://www.codecopy.cn/post/edkpo4 。之前我从 0 到 1 直播带大家开发过一个代码生成器共享平台,感兴趣的同学也可以学习下,保证能把代码生成玩得很熟练~


方法 6、云服务


这种方式也比较新颖了,利用某些云服务提供的现成的数据库和操作数据库的接口,都不需要自己去编写增删改查了!


比如我之前用过的腾讯云开发 Cloudbase,开通服务后,只要在平台上建号数据表,就能自动得到数据管理页面,可以直接通过 HTTP 请求或 SDK 实现增删改查,尤其适合前端同学使用。


但这种方式的缺点也很明显,灵活性相对差了一些,而且会产生一些额外的费用。


所以还是那句话,没有最好的技术,只有最适合自身需求和业务场景的技术。




作者:程序员鱼皮
来源:juejin.cn/post/7369094945154711578
收起阅读 »

😰我被恐吓了,对方扬言要压测我的网站

大家好我是聪,昨天真是水逆,在技术群里交流问题,竟然被人身攻击了!骂的话太难听具体就不加讨论了,人身攻击我可以接受,我接受不了他竟然说要刷我接口!!!!这下激发我的灵感来写一篇如何抵御黑子的压测攻击,还真得要谢谢他。 🔥本次的自动加入黑名单拦截代码已经上传到...
继续阅读 »

大家好我是聪,昨天真是水逆,在技术群里交流问题,竟然被人身攻击了!骂的话太难听具体就不加讨论了,人身攻击我可以接受,我接受不了他竟然说要刷我接口!!!!这下激发我的灵感来写一篇如何抵御黑子的压测攻击,还真得要谢谢他。


image-20240523081706355.png


🔥本次的自动加入黑名单拦截代码已经上传到短链狗,想学习如何生成一个短链可以去我的 Github 上面查看哦,项目地址:github.com/lhccong/sho…


思维发散


如果有人要攻击我的网站,我应该从哪些方面开始预防呢,我想到了以下几点,如何还有其他的思路欢迎大家补充:



  1. 从前端开始预防!


    聪 A🧑:确实是一种办法,给前端 ➕ 验证码、短信验证,或者加上谷歌认证(用户说:我谢谢你哈,消防栓)。


    聪 B🧑:再次思考下还是算了,这次不想动我的前端加上如何短信验证还消耗我的💴,本来就是一个练手项目,打住❌。


  2. 人工干预!


    聪 A🧑:哇!人工干预很累的欸,拜托。


    聪 B🧑:那如果是定时人工检查进行干预处理,辅助其他检测手段呢,是不是感觉还行!


  3. 使用网关给他预防!


    聪 A🧑:网关!好像听起来不错。


    聪 B🧑:不行!我项目都没有网关,单单为了黑子增加一个网关,否决❌。


  4. 日志监控!


    聪 A🧑:日志监控好像还不错欸,可以让系统日志的输出到时候统一监控,然后发短信告诉我们。


    聪 B🧑:日志监控确实可以,发短信还是算了,拒绝一切花销哈❌。


  5. 我想到了!后端 AOP 拦截访问限流,通过自动检测将 IP + 用户ID 加入黑名单,让黑子无所遁形。


    聪 A🧑:我觉得可以我们来试试?


    聪 B🧑:还等什么!来试试吧!



功能实现


设置 AOP 注解


1)获取拦截对象的标识,这个标识可以是用户 ID 或者是其他。


2)限制频率。举个例子:如果每秒超过 10 次就直接给他禁止访问 1 分钟或者 5 分钟。


3)加入黑名单。举个例子:当他多次触发禁止访问机制,就证明他还不死心还在刷,直接给他加入黑名单,可以是永久黑名单或者 1 天就又给他放出来。


4)获取后面回调的方法,会用反射来实现接口的调用。


有了以上几点属性,那么注解设置如下:


/**
* 黑名单拦截器
*
*
@author cong
*
@date 2024/05/23
*/

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface BlacklistInterceptor {

   /**
    * 拦截字段的标识符
    *
    *
@return {@link String }
    */

   String key() default "default";;

   /**
    * 频率限制 每秒请求次数
    *
    *
@return double
    */

   double rageLimit() default 10;

   /**
    * 保护限制 命中频率次数后触发保护,默认触发限制就保护进入黑名单
    *
    *
@return int
    */

   int protectLimit() default 1;

   /**
    * 回调方法
    *
    *
@return {@link String }
    */

   String fallbackMethod();
}

设置切面具体实现


@Aspect
@Component
@Slf4j
public class RageLimitInterceptor {
   private final Redisson redisson;

   private RMapCache blacklist;
   // 用来存储用户ID与对应的RateLimiter对象
   private final Cache userRateLimiters = CacheBuilder.newBuilder()
          .expireAfterWrite(1, TimeUnit.MINUTES)
          .build();

   public RageLimitInterceptor(Redisson redisson) {
       this.redisson = redisson;
       if (redisson != null) {
           log.info("Redisson object is not null, using Redisson...");
           // 使用 Redisson 对象执行相关操作
           // 个人限频黑名单24h
           blacklist = redisson.getMapCache("blacklist");
           blacklist.expire(24, TimeUnit.HOURS);// 设置过期时间
      } else {
           log.error("Redisson object is null!");
      }
  }


   @Pointcut("@annotation(com.cong.shortlink.annotation.BlacklistInterceptor)")
   public void aopPoint() {
  }

   @Around("aopPoint() && @annotation(blacklistInterceptor)")
   public Object doRouter(ProceedingJoinPoint jp, BlacklistInterceptor blacklistInterceptor) throws Throwable {
       String key = blacklistInterceptor.key();

       // 获取请求路径
       RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
       HttpServletRequest httpServletRequest = ((ServletRequestAttributes) requestAttributes).getRequest();
       //获取 IP
       String remoteHost = httpServletRequest.getRemoteHost();
       if (StringUtils.isBlank(key)) {
           throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "拦截的 key 不能为空");
      }
       // 获取拦截字段
       String keyAttr;
       if (key.equals("default")) {
           keyAttr = "SystemUid" + StpUtil.getLoginId().toString();
      } else {
           keyAttr = getAttrValue(key, jp.getArgs());
      }

       log.info("aop attr {}", keyAttr);

       // 黑名单拦截
       if (blacklistInterceptor.protectLimit() != 0 && null != blacklist.getOrDefault(keyAttr, null) && (blacklist.getOrDefault(keyAttr, 0L) > blacklistInterceptor.protectLimit()
               ||blacklist.getOrDefault(remoteHost, 0L) > blacklistInterceptor.protectLimit())) {
           log.info("有小黑子被我抓住了!给他 24 小时封禁套餐吧:{}", keyAttr);
           return fallbackMethodResult(jp, blacklistInterceptor.fallbackMethod());
      }

       // 获取限流
       RRateLimiter rateLimiter;
       if (!userRateLimiters.asMap().containsKey(keyAttr)) {
           rateLimiter = redisson.getRateLimiter(keyAttr);
           // 设置RateLimiter的速率,每秒发放10个令牌
           rateLimiter.trySetRate(RateType.OVERALL, blacklistInterceptor.rageLimit(), 1, RateIntervalUnit.SECONDS);
           userRateLimiters.put(keyAttr, rateLimiter);
      } else {
           rateLimiter = userRateLimiters.getIfPresent(keyAttr);
      }

       // 限流拦截
       if (rateLimiter != null && !rateLimiter.tryAcquire()) {
           if (blacklistInterceptor.protectLimit() != 0) {
               //封标识
               blacklist.put(keyAttr, blacklist.getOrDefault(keyAttr, 0L) + 1L);
               //封 IP
               blacklist.put(remoteHost, blacklist.getOrDefault(remoteHost, 0L) + 1L);
          }
           log.info("你刷这么快干嘛黑子:{}", keyAttr);
           return fallbackMethodResult(jp, blacklistInterceptor.fallbackMethod());
      }

       // 返回结果
       return jp.proceed();
  }

   private Object fallbackMethodResult(JoinPoint jp, String fallbackMethod) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
       Signature sig = jp.getSignature();
       MethodSignature methodSignature = (MethodSignature) sig;
       Method method = jp.getTarget().getClass().getMethod(fallbackMethod, methodSignature.getParameterTypes());
       return method.invoke(jp.getThis(), jp.getArgs());
  }

   /**
    * 实际根据自身业务调整,主要是为了获取通过某个值做拦截
    */

   public String getAttrValue(String attr, Object[] args) {
       if (args[0] instanceof String) {
           return args[0].toString();
      }
       String filedValue = null;
       for (Object arg : args) {
           try {
               if (StringUtils.isNotBlank(filedValue)) {
                   break;
              }
               filedValue = String.valueOf(this.getValueByName(arg, attr));
          } catch (Exception e) {
               log.error("获取路由属性值失败 attr:{}", attr, e);
          }
      }
       return filedValue;
  }

   /**
    * 获取对象的特定属性值
    *
    *
@param item 对象
    *
@param name 属性名
    *
@return 属性值
    *
@author tang
    */

   private Object getValueByName(Object item, String name) {
       try {
           Field field = getFieldByName(item, name);
           if (field == null) {
               return null;
          }
           field.setAccessible(true);
           Object o = field.get(item);
           field.setAccessible(false);
           return o;
      } catch (IllegalAccessException e) {
           return null;
      }
  }

   /**
    * 根据名称获取方法,该方法同时兼顾继承类获取父类的属性
    *
    *
@param item 对象
    *
@param name 属性名
    *
@return 该属性对应方法
    *
@author tang
    */

   private Field getFieldByName(Object item, String name) {
       try {
           Field field;
           try {
               field = item.getClass().getDeclaredField(name);
          } catch (NoSuchFieldException e) {
               field = item.getClass().getSuperclass().getDeclaredField(name);
          }
           return field;
      } catch (NoSuchFieldException e) {
           return null;
      }
  }


}

这段代码主要实现了几个方面:



  • 获取限流对象的唯一标识。如用户 Id 或者其他。

  • 将标识来获取是否触发限流 + 黑名单 如果是这两种的一种,直接触发预先设置的回调(入参要跟原本接口一致喔)。

  • 通过反射来获取回调的属性以及方法名称,触发方法调用。

  • 封禁 标识 、IP 。


代码测试


@BlacklistInterceptor(key = "title", fallbackMethod = "loginErr", rageLimit = 1L, protectLimit = 10)
   @PostMapping("/login")
   public String login(@RequestBody UrlRelateAddRequest urlRelateAddRequest) {
       log.info("模拟登录 title:{}", urlRelateAddRequest.getTitle());
       return "模拟登录:登录成功 " + urlRelateAddRequest.getTitle();
  }

   public String loginErr(UrlRelateAddRequest urlRelateAddRequest) {
       return "小黑子!你没有权限访问该接口!";
  }


  • key:需要拦截的标识,用来判断请求对象。

  • fallbackMethod:回调的方法名称(这里需要注意的是入参要跟原本接口保持一致)。

  • rageLimit:每秒限制的访问次数。

  • protectLimit:超过每秒访问次数+1,当请求超过 protectLimit 值时,进入黑名单封禁 24 小时。


以下是具体操作截图:


Snipaste_2024-05-23_11-28-41.png


到这里这个黑名单的拦截基本就实现啦,大家还有什么具体的补充点都可以提出来,一起学习一下,经过这次”恐吓风波“,让我知道互联网上的人戾气还是很重的,只要坚持好做自己,管他别人什么看法!!


作者:cong_
来源:juejin.cn/post/7371761447696121866
收起阅读 »

一条SQL差点引发离职

文章首发于微信公众号:云舒编程 关注公众号获取: 1、大厂项目分享 2、各种技术原理分享 3、部门内推 背景        最近组里的小伙伴在开发一个更新功能时踩了MySQL的一个类型转换的坑,差点造成线上故障。 本来是一个很简单的逻辑,就是根据唯一的id去...
继续阅读 »

文章首发于微信公众号:云舒编程

关注公众号获取:
1、大厂项目分享
2、各种技术原理分享
3、部门内推



背景


       最近组里的小伙伴在开发一个更新功能时踩了MySQL的一个类型转换的坑,差点造成线上故障。

本来是一个很简单的逻辑,就是根据唯一的id去更新对应的MySQL数据,代码简化后如下:


var updates []*model.Goods
for id, newGoods := range update {
 if err := model.GetDB().Model(&model.Goods{}).Where("id = ?", id).Updates(map[string]interface{}{
  "selling_price":  newGoods.SellingPrice,
  "sell_type":      newGoods.SellType,
  "status":         newGoods.Status,
  "category_id":    newGoods.CategoryID,
 }).Error; err != nil {
  return nil, err
 }
}

很明显,updates[]model.Goods\color{red}{updates []*model.Goods}本来应该是想声明为 map[string]model.Goods\color{red}{map[string]*model.Goods}类型的,然后key是唯一id。这样下面的更新逻辑才是对的,否则拿到的id其实是数组的下标。

但是code review由于跟着一堆代码一起评审了,并且这段更新很简单,同时测试的时候也测试过了(能测试通过也是“机缘巧合”),所以没有发现这段异常。

发到线上后,进行了灰度集群的测试,这个时候发现只要调用了这个接口,灰度集群的数据全部都变成了一样,回滚后正常。


分析


       回滚后在本地进行复现,由于本地环境是开启了SQL打印的,于是看到了这么一条SQL:很明显是拿数组的下标去比较了


update db_name set selling_price = xx,sell_type = xx where id = 0;

       由于我们的id是全部是通过uuid生成的,所以下意识的认为这条sql应该啥也不会更新才对,但是本地的确只执行了这条sql,没有别的sql,并且db中的数据全部都被修改了。

这个时候想起福尔摩斯的名言“排除一切不可能的,剩下的即使再不可能,那也是真相”\color{blue}{“排除一切不可能的,剩下的即使再不可能,那也是真相”} ,于是抱着试一试的心态直接拿这条sql去db控制台执行了一遍,发现果然所有的数据又都被修改了。

也就是 whereid=0\color{red}{where id = 0}  这个条件对于所有的记录都是恒为true,就会导致所有记录都被更新。在这个时候,想起曾经看到过MySQL对于不同类型的比较会有 【隐式转换】\color{red}{【隐式转换】},难道是这个原因导致的?


隐式转换规则


在MySQL官网找到了不同类型比较的规则:



最后一段的意思是:对于其他情况,将按照浮点(双精度)数进行比较。例如,字符串和数字的比较就按照浮点数规则进行比较。

也就是id会首先被转换成浮点数,然后再跟0进行比较。


MySQL字符转为浮点数时会按照如下规则进行:


1.如果字符串的第一个字符就是非数字的字符,那么转换结果就是0;

2.如果字符串以数字开头:

(1)如果字符串都是数字,转换结果就是整个字符串对应的数字;

(2)如果字符串中存在非数字,转换结果就是开头的那些数字对应的值;

举例说明:

"test" -> 0

"1test" -> 1

"12test12" -> 12

由于我们生成的uuid没有数字开头的字符串,于是都会转变成0。那么这条SQL就变成了:


update db_name set selling_price = xx,sell_type = xx where 0 = 0;

就恒为true了。

修复就很简单了,把取id的逻辑改成正确的就行。


为什么测试环境没有发现


       前面有提到这段代码在测试环境是测试通过了的,这是因为开发和测试同学的环境里都只有一条记录,每次更新他发现都能正常更新就认为是正常的了。同时由于逻辑太简单了,所以都没有重视这块的回归测试。

幸好在灰度集群就发现了这个问题,及时进行了回滚,如果发到了线上影响了用户数据,可能就一年白干了。


最后


代码无小事,事事需谨慎啊。一般致命问题往往是一行小小的修改导致的。


作者:云舒编程
来源:juejin.cn/post/7275550679790960640
收起阅读 »

系统干崩了,只认代码不认人

各位朋友听我一句劝,写代码提供方法给别人调用时,不管是内部系统调用,还是外部系统调用,还是被动触发调用(比如MQ消费、回调执行等),一定要加上必要的条件校验。千万别信某些同事说的这个条件肯定会传、肯定有值、肯定不为空等等。这不,临过年了我就被坑了一波,弄了个生...
继续阅读 »

各位朋友听我一句劝,写代码提供方法给别人调用时,不管是内部系统调用,还是外部系统调用,还是被动触发调用(比如MQ消费、回调执行等),一定要加上必要的条件校验。千万别信某些同事说的这个条件肯定会传、肯定有值、肯定不为空等等。这不,临过年了我就被坑了一波,弄了个生产事故,年终奖基本是凉了半截。


为了保障系统的高可用和稳定,我发誓以后只认代码不认人。文末总结了几个小教训,希望对你有帮助。


一、事发经过


我的业务场景是:业务A有改动时,发送MQ,然后应用自身接受到MQ后,再组合一些数据写入到Elasticsearch。以下是事发经过:



  1. 收到一个业务A的异常告警,当时的告警如下:



  2. 咋一看觉得有点奇怪,怎么会是Redis异常呢?然后自己连了下Redis没有问题,又看了下Redis集群,一切正常。所以就放过了,以为是偶然出现的网络问题。

  3. 然后技术问题群里 客服 反馈有部分用户使用异常,我警觉性的感觉到是系统出问题了。赶紧打开了系统,确实有偶发性的问题。

  4. 于是我习惯性的看了几个核心部件:



    1. 网关情况、核心业务Pod的负载情况、用户中心Pod的负载情况。

    2. Mysql的情况:内存、CPU、慢SQL、死锁、连接数等。



  5. 果然发现了慢SQL和元数据锁时间过长的情况。找到了一张大表的全表查询,数据太大,执行太慢,从而导致元数据锁持续时间太长,最终数据库连接数快被耗尽。


SELECT xxx,xxx,xxx,xxx FROM 一张大表


  1. 立马Kill掉几个慢会话之后,发现系统仍然没有完全恢复,为啥呢?现在数据库已经正常了,怎么还没完全恢复呢?又继续看了应用监控,发现用户中心的10个Pod里有2个Pod异常了,CPU和内存都爆了。难怪使用时出现偶发性的异常呢。于是赶紧重启Pod,先把应用恢复。

  2. 问题找到了,接下来就继续排查为什么用户中心的Pod挂掉了。从以下几个怀疑点开始分析:



    1. 同步数据到Elasticsearch的代码是不是有问题,怎么会出现连不上Redis的情况呢?

    2. 会不会是异常过多,导致发送异常告警消息的线程池队列满了,然后就OOM?

    3. 哪里会对那张业务A的大表做不带条件的全表查询呢?



  3. 继续排查怀疑点a,刚开始以为:是拿不到Redis链接,导致异常进到了线程池队列,然后队列撑爆,导致OOM了。按照这个设想,修改了代码,升级,继续观察,依旧出现同样的慢SQL 和 用户中心被干爆的情况。因为没有异常了,所以怀疑点b也可以被排除了。

  4. 此时基本可以肯定是怀疑点c了,是哪里调用了业务A的大表的全表查询,然后导致用户中心的内存过大,JVM来不及回收,然后直接干爆了CPU。同时也是因为全表数据太大,导致查询时的元数据锁时间过长造成了连接不能够及时释放,最终几乎被耗尽。

  5. 于是修改了查询业务A的大表必要校验条件,重新部署上线观察。最终定位出了问题。


二、问题的原因


因为在变更业务B表时,需要发送MQ消息( 同步业务A表的数据到ES),接受到MQ消息后,查询业务A表相关连的数据,然后同步数据到Elasticsearch。


但是变更业务B表时,没有传业务A表需要的必要条件,同时我也没有校验必要条件,从而导致了对业务A的大表的全表扫描。因为:


某些同事说,“这个条件肯定会传、肯定有值、肯定不为空...”,结果我真信了他!!!

由于业务B表当时变更频繁,发出和消费的MQ消息较多,触发了更多的业务A的大表全表扫描,进而导致了更多的Mysql元数据锁时间过长,最终连接数消耗过多。


同时每次都是把业务A的大表查询的结果返回到用户中心的内存中,从而触发了JVM垃圾回收,但是又回收不了,最终内存和CPU都被干爆了。


至于Redis拿不到连接的异常也只是个烟雾弹,因为发送和消费的MQ事件太多,瞬时间有少部分线程确实拿不到Redis连接。


最终我在消费MQ事件处的代码里增加了条件校验,同时也在查询业务A表处也增加了的必要条件校验,重新部署上线,问题解决。


三、总结教训


经过此事,我也总结了一些教训,与君共勉:



  1. 时刻警惕线上问题,一旦出现问题,千万不能放过,赶紧排查。不要再去怀疑网络抖动问题,大部分的问题,都跟网络无关。

  2. 业务大表自身要做好保护意识,查询处一定要增加必须条件校验。

  3. 消费MQ消息时,一定要做必要条件校验,不要相信任何信息来源。

  4. 千万别信某些同事说,“这个条件肯定会传、肯定有值、肯定不为空”等等。为了保障系统的高可用和稳定,咱们只认代码不认人

  5. 一般出现问题时的排查顺序:



    1. 数据库的CPU、死锁、慢SQL。

    2. 应用的网关和核心部件的CPU、内存、日志。



  6. 业务的可观测性和告警必不可少,而且必须要全面,这样才能更快的发现问题和解决问题。




作者:程序员半支烟
来源:mp.weixin.qq.com/s/TvIpTZq0XO8v9ccYSsM37Q
收起阅读 »

MQ消息积压,把我整吐血了

大家好,我是苏三,又跟大家见面了。 前言 我之前在一家餐饮公司待过两年,每天中午和晚上用餐高峰期,系统的并发量不容小觑。为了保险起见,公司规定各部门都要在吃饭的时间轮流值班,防止出现线上问题时能够及时处理。 我当时在后厨显示系统团队,该系统属于订单的下游业务...
继续阅读 »

大家好,我是苏三,又跟大家见面了。



前言


我之前在一家餐饮公司待过两年,每天中午和晚上用餐高峰期,系统的并发量不容小觑。为了保险起见,公司规定各部门都要在吃饭的时间轮流值班,防止出现线上问题时能够及时处理。


我当时在后厨显示系统团队,该系统属于订单的下游业务。


用户点完菜下单后,订单系统会通过发kafka消息给我们系统,系统读取消息后,做业务逻辑处理,持久化订单和菜品数据,然后展示到划菜客户端。


这样厨师就知道哪个订单要做哪些菜,有些菜做好了,就可以通过该系统出菜。系统自动通知服务员上菜,如果服务员上完菜,修改菜品上菜状态,用户就知道哪些菜已经上了,哪些还没有上。这个系统可以大大提高后厨到用户的效率。图片


这一切的关键是消息中间件:kafka,如果它出现问题,将会直接影响到后厨显示系统的用户功能使用。


这篇文章跟大家一起聊聊,我们当时出现过的消息积压问题,希望对你会有所帮助。


1 第一次消息积压


刚开始我们的用户量比较少,上线一段时间,mq的消息通信都没啥问题。


随着用户量逐步增多,每个商家每天都会产生大量的订单数据,每个订单都有多个菜品,这样导致我们划菜系统的划菜表的数据越来越多。


在某一天中午,收到商家投诉说用户下单之后,在平板上出现的菜品列表有延迟。


厨房几分钟之后才能看到菜品。


我们马上开始查原因。


出现这种菜品延迟的问题,必定跟kafka有关,因此,我们先查看kafka。


果然出现了消息积压


通常情况下,出现消息积压的原因有:



  1. mq消费者挂了。

  2. mq生产者生产消息的速度,大于mq消费者消费消息的速度。


我查了一下监控,发现我们的mq消费者,服务在正常运行,没有异常。


剩下的原因可能是:mq消费者消费消息的速度变慢了。


接下来,我查了一下划菜表,目前不太多只有几十万的数据。


看来需要优化mq消费者的处理逻辑了。


我在代码中增加了一些日志,把mq消息者中各个关键节点的耗时都打印出来了。


发现有两个地方耗时比较长:



  1. 有个代码是一个for循环中,一个个查询数据库处理数据的。

  2. 有个多条件查询数据的代码。


于是,我做了有针对性的优化。


将在for循环中一个个查询数据库的代码,改成通过参数集合,批量查询数据。


有时候,我们需要从指定的用户集合中,查询出有哪些是在数据库中已经存在的。


实现代码可以这样写:


public List<User> queryUser(List<User> searchList) {
    if (CollectionUtils.isEmpty(searchList)) {
        return Collections.emptyList();
    }

    List<User> result = Lists.newArrayList();
    searchList.forEach(user -> result.add(userMapper.getUserById(user.getId())));
    return result;
}

这里如果有50个用户,则需要循环50次,去查询数据库。我们都知道,每查询一次数据库,就是一次远程调用。


如果查询50次数据库,就有50次远程调用,这是非常耗时的操作。


那么,我们如何优化呢?


具体代码如下:


public List<User> queryUser(List<User> searchList) {
    if (CollectionUtils.isEmpty(searchList)) {
        return Collections.emptyList();
    }
    List<Long> ids = searchList.stream().map(User::getId).collect(Collectors.toList());
    return userMapper.getUserByIds(ids);
}

提供一个根据用户id集合批量查询用户的接口,只远程调用一次,就能查询出所有的数据。


多条件查询数据的地方,增加了一个联合索引,解决了问题。


这样优化之后, mq消费者处理消息的速度提升了很多,消息积压问题被解决了。


2 第二次消息积压


没想到,过了几个月之后,又开始出现消息积压的问题了。


但这次是偶尔会积压,大部分情况不会。


这几天消息的积压时间不长,对用户影响比较小,没有引起商家的投诉。


我查了一下划菜表的数据只有几百万。


但通过一些监控,和DBA每天发的慢查询邮件,自己发现了异常。


我发现有些sql语句,执行的where条件是一模一样的,只有条件后面的参数值不一样,导致该sql语句走的索引不一样。


比如:order_id=123走了索引a,而order_id=124走了索引b。


有张表查询的场景有很多,当时为了满足不同业务场景,加了多个联合索引。


MySQL会根据下面几个因素选择索引:



  1. 通过采样数据来估算需要扫描的行数,如果扫描的行数多那可能io次数会更多,对cpu的消耗也更大。

  2. 是否会使用临时表,如果使用临时表也会影响查询速度;

  3. 是否需要排序,如果需要排序则也会影响查询速度。


综合1、2、3以及其它的一些因素,MySql优化器会选出它自己认为最合适的索引。


MySQL优化器是通过采样来预估要扫描的行数的,所谓采样就是选择一些数据页来进行统计预估,这个会有一定的误差。


由于MVCC会有多个版本的数据页,比如删除一些数据,但是这些数据由于还在其它的事务中可能会被看到,索引不是真正的删除,这种情况也会导致统计不准确,从而影响优化器的判断。


上面这两个原因导致MySQL在执行SQL语句时,会选错索引


明明使用索引a的时候,执行效率更高,但实际情况却使用了索引b。


为了解决MySQL选错索引的问题,我们使用了关键字force index,来强制查询sql走索引a。


这样优化之后,这次小范围的消息积压问题被解决了。


3 第三次消息积压


过了半年之后,在某个晚上6点多钟。


有几个商家投诉过来,说划菜系统有延迟,下单之后,几分钟才能看到菜品。


我查看了一下监控,发现kafka消息又出现了积压的情况。


查了一下MySQL的索引,该走的索引都走了,但数据查询还是有些慢。


此时,我再次查了一下划菜表,惊奇的发现,短短半年表中有3千万的数据了。


通常情况下,单表的数据太多,无论是查询,还是写入的性能,都会下降。


这次出现查询慢的原因是数据太多了。


为了解决这个问题,我们必须:



  1. 做分库分表

  2. 将历史数据备份


由于现阶段做分库分表的代价太大了,我们的商户数量还没有走到这一步。


因此,我们当时果断选择了将历史数据做备份的方案。


当时我跟产品和DBA讨论了一下,划菜表只保留最近30天的数据,超过几天的数据写入到历史表中。


这样优化之后,划菜表30天只会产生几百万的数据,对性能影响不大。


消息积压的问题被解决了。


最近就业形式比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。


你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。


进群方式


添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。


4 第四次消息积压


通过上面这几次优化之后,很长一段时间,系统都没有出现消息积压的问题。


但在一年之后的某一天下午,又有一些商家投诉过来了。


此时,我查看公司邮箱,发现kafka消息积压的监控报警邮件一大堆。


但由于刚刚一直在开会,没有看到。


这次的时间点就有些特殊。


一般情况下,并发量大的时候,是中午或者晚上的用餐高峰期,而这次出现消息积压问题的时间是下午


这就有点奇怪了。


刚开始查询这个问题一点头绪都没有。


我问了一下订单组的同事,下午有没有发版,或者执行什么功能?


因为我们的划菜系统,是他们的下游系统,跟他们有直接的关系。


某位同事说,他们半小时之前,执行了一个批量修改订单状态的job,一次性修改了几万个订单的状态。


而修改了订单状态,会自动发送mq消息。


这样导致,他们的程序在极短的时间内,产生了大量的mq消息。


而我们的mq消费者根本无法处理这些消息,所以才会产生消息积压的问题。


我们当时一起查了kafka消息的积压情况,发现当时积压了几十万条消息。


要想快速提升mq消费者的处理速度,我们当时想到了两个方案:



  1. 增加partion数量。

  2. 使用线程池处理消息。


但考虑到,当时消息已经积压到几个已有的partion中了,再新增partion意义不大。


于是,我们只能改造代码,使用线程池处理消息了。


为了开始消费积压的消息,我们将线程池的核心线程最大线程数量调大到了50。


这两个参数是可以动态配置的。


这样调整之后,积压了几十万的mq消息,在20分钟左右被消费完了。


这次突然产生的消息积压问题被解决了。


解决完这次的问题之后,我们还是保留的线程池消费消息的逻辑,将核心线程数调到8,最大线程数调到10


当后面出现消息积压问题,可以及时通过调整线程数量,先临时解决问题,而不会对用户造成太大的影响。



注意:使用线程池消费mq消息不是万能的。该方案也有一些弊端,它有消息顺序的问题,也可能会导致服务器的CPU使用率飙升。此外,如果在多线程中调用了第三方接口,可能会导致该第三方接口的压力太大,而直接挂掉。



总之,MQ的消息积压问题,不是一个简单的问题。


虽说产生的根本原因是:MQ生产者生产消息的速度,大于MQ消费者消费消息的速度,但产生的具体原因有多种。


我们在实际工作中,需要针对不同的业务场景,做不同的优化。


我们需要对MQ队列中的消息积压情况,进行监控和预警,至少能够及时发现问题。


没有最好的方案,只有最合适当前业务场景的方案。


最后说一句(求关注,别白嫖我)


如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。


求一键三连:点赞、转发、在看。


关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。


作者:苏三说技术
来源:juejin.cn/post/7368308963128000512
收起阅读 »

面试官:“你知道什么情况下 HTTPS 不安全么”

面试官:“HTTPS的加密过程你知道么?”我:“那肯定知道啊。”面试官:“那你知道什么情况下 HTTPS 不安全么”我:“这....”越面觉得自己越菜,继续努力学习!!!什麽是中间人攻击?中间人攻击(MITM)在密码学和计算机安全领域中是指攻击者与通讯的两端分...
继续阅读 »

面试官:“HTTPS的加密过程你知道么?”

我:“那肯定知道啊。”

面试官:“那你知道什么情况下 HTTPS 不安全么”

我:“这....”

越面觉得自己越菜,继续努力学习!!!


什麽是中间人攻击?

中间人攻击MITM)在密码学计算机安全领域中是指攻击者与通讯的两端分别创建独立的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过一个私密的连接与对方直接对话,但事实上整个会话都被攻击者完全控制[1]。在中间人攻击中,攻击者可以拦截通讯双方的通话并插入新的内容。在许多情况下这是很简单的(例如,在一个未加密的Wi-Fi 无线接入点的接受范围内的中间人攻击者,可以将自己作为一个中间人插入这个网络)。

一个中间人攻击能成功的前提条件是攻击者能将自己伪装成每一个参与会话的终端,并且不被其他终端识破。中间人攻击是一个(缺乏)相互认证的攻击。大多数的加密协议都专门加入了一些特殊的认证方法以阻止中间人攻击。例如,SSL协议可以验证参与通讯的一方或双方使用的证书是否是由权威的受信任的数字证书认证机构颁发,并且能执行双向身份认证。

以上定义来自维基百科,我们来举一个通俗的例子来理解中间人攻击:

image.png

  1. A发送给B一条消息,却被C截获:

A: “嗨,B,我是A。给我你的公钥”

  1. C将这条截获的消息转送给B;此时B并无法分辨这条消息是否从真的A那里发来的:

C: “嗨,B,我是A。给我你的公钥”

  1. B回应A的消息,并附上了他的公钥:

B -> B 的公钥 -> C

  1. C用自己的密钥替换了消息中B的密钥,并将消息转发给A,声称这是B的公钥:

C -> C 的公钥 -> A

  1. A 用它以为是 B的公钥,加密了以为只有 B 能看到的消息

A -> xxx -> C

  1. C 用 B 的密钥进行修改

C -> zzz -> B

这就是整个中间人攻击的流程。

中间人攻击怎么作用到 HTTPS 中?

首先让我来回顾一下 HTTPS 的整个流程:

回顾 HTTPS 过程

image.png

这是 HTTPS 原本的流程,但是当我们有了 中间人服务器之后,整个流程就变成了下面这个样子。

这个流程建议动手画个图,便于理解

  1. 客户端向服务器发送 HTTPS 建立连接请求,被中间人服务器截获。
  2. 中间人服务器向服务器发送 HTTPS 建立连接请求
  3. 服务器向客户端发送公钥证书,被中间人服务器截获
  4. 中间人服务器验证证书的合法性,从证书拿到公钥
  5. 中间人服务器向客户端发送自己的公钥证书

注意!在这个时候 HTTPS 就可能出现问题了。客户端会询问你:“此网站的证书存在问题,你确定要信任这个证书么。”所以从这个角度来说,其实 HTTPS 的整个流程还是没有什么问题,主要问题还是客户端不够安全。

  1. 客户端验证证书的合法性,从证书拿到公钥
  2. 客户端生成一个随机数,用公钥加密,发送给服务器,中间人服务器截获
  3. 中间人服务器用私钥加密后,得到随机数,然后用随机数根据算法,生成堆成加密密钥,客户端和中间人服务器根据对称加密密钥进行加密。
  4. 中间人服务器用服务端给的证书公钥加密,在发送给服务器时
  5. 服务器得到信息,进行解密,然后用随机数根据算法,生成对称加密算法

如何预防?

刚才我们说到这里的问题主要在于客户端选择信任了,所以主要是使用者要放亮眼睛,保持警惕

参考文章:


作者:阳树阳树
来源:juejin.cn/post/7238619890993643575
收起阅读 »

如此丝滑的API设计,用起来真香

分享是最有效的学习方式。 博客:blog.ktdaddy.com/ 故事 工位上,小猫一边撸着代码,一边吐槽着前人设计的接口。 如下: “我艹,货架模型明明和商品SKU模型是一对多的关系,接口入参的时候偏偏要以最小粒度的SKU将重复入参进行平铺”。 “一个...
继续阅读 »

分享是最有效的学习方式。


博客:blog.ktdaddy.com/





故事


工位上,小猫一边撸着代码,一边吐槽着前人设计的接口。


如下:


“我艹,货架模型明明和商品SKU模型是一对多的关系,接口入参的时候偏偏要以最小粒度的SKU将重复入参进行平铺”。


“一个接口居然做了多件事情,传入参数复杂异常,不是一块业务类型的东西,非得全部揉在一起”。


“如此长的业务流程,接口能快起来么,难怪天天收到接口慢的告警”。


00.png


“这都啥啊,这名字怎么能这么取呢,这也太随意了吧....”


......


小猫一边写着V2版本的新接口,一边骂着现状接口。


聊聊APi设计


在日常开发过程中,相信大家在维护老代码的时候也多多少少会像小猫一样吐槽现有接口设计。很多项目经过历史沉淀以及业务验证,接口设计问题就慢慢放大暴露出来了。具体原因是这样的:


第一种情况可能是业务发展的必然趋势:不同技术人员对业务的看法和理解不同,一个接口可能经过多人的维护开发迭代,很多时候,新增功能也只是在原有的接口上直接拓展,当业务需求比较紧急的时候,大部分的研发一般都会选择快速去实现,而不会太过去考虑现有接口拓展的合规性。


第二种情况可能是本身开发人员自身能力问题,对业务的把控以及评估不合理导致的最终接口设计缺陷问题。


在系统软件开发过程中,一个好的UI设计可以让用户更好地使用一款产品。那么深入一层,一个好的API设计则可以让开发者高效地使用一个系统的能力,尤其是现在很多大型微服务项目中,API设计更加重要,因为此时的API调用方不仅仅是前端,甚至直接是其他服务。


那么接下来,老猫会和大家从下面的几个方面探讨一下,日常开发中我们应该如何去设计API。


0.png


API设计需要明确边界


在实际职场中,部门与部门之间、管理员与管理员之间容易出现扯皮、推诿现象。当然在系统和系统之间API的交互中其实往往也存在这样的情况。打个比方客户端的交互细节让后端代码通过接口来兜,你觉得合理不?


所以这就要求我们遵循下面两个点,咱们分别中两个维度来看,一个是“面向于服务和服务之间的API”,另一个是“面向客户端和服务之间的API”。


1、我们在设计API的过程中应该聚焦软件系统需要提供的服务或者能力。API是系统和外部交互的接口,至于外部如何使用,通过什么途径使用并不是重点。


2、对于面向UI的API设计中,我们更应该避免去过多关注UI的交互细节。交互属于客户端范畴,不同的终端设备,其交互必然也是不一样的。


API设计思路尽量面向结果设计而不是面向过程设计


相信大家应该都知道面向对象编程和面向过程编程吧。


老猫虽说的这里的面向结果设计其实和面向对象的概念有点类似。这种情况下的API应该是根据对象的行为来封装具体的业务逻辑,调用方直接发起请求需要什么就能给出一个最终的结果性质的东西,而不是中间过程中某个状态性质的东西。上层业务无需多次调用底层接口进行组装才能获取最终结果。


如下图:


面向执行过程API设计


面向最终结果API设计


举个例子。


银行提现逻辑中,


如果面向执行过程设计的API应该是这样的,先查询出余额,然后再进行扣减。于是有了下面这样的伪代码。


public interface BankService {
AccountInfo getAccountByUserName(String userName);
void updateAccount(AccountInfoReq accountInfoReq);
}

如果是面向结果设计,那么应该就是这样的伪代码。


public interface BankService {
AccountInfo withdraw(String userName,Long amount);
}

API设计需要尽量保证职责单一


在设计API的时候,应该尽力要求一个API只做一件事情,职责单一的API可以让API的外观更加稳定,没有歧义。并且上层调用层也是一目了然,简单易用。


对于一个API如果符合下面条件的时候,咱们就可以考虑对其进行拆分了。


1、一个API内部完成了多件事情。例如:一个API既可以发布新商品信息,又能更新商品的价格、标题、规格信息、库存等等。如果这些行为在一个接口进行调用,接口复杂度可想而知。
另外的接口的性能也是需要考虑的一部分,再者如果后续涉及权限粒度拆分,其实这种设计就不便于权限管控了。


2、一个API用于处理不同类型对象的业务。例如:一个API编辑不同的商品类型,由于不同类型的商品对应的模型通常是不同的(例如出行类的商品以及卡券类的商品差别就很大),
如果放在一个API中,API的输入和输出参数会非常复杂,使用和维护成本就很高。


其实关于API单一职责相关的话题,老猫在之前的文章中也有提及过,有兴趣的小伙伴可以戳【忍不了,客户让我在一个接口里兼容多种业务功能


API不应该基于实现去设计


在API设计过程中,我们应该避免实现细节。一个API有多种实现,在API层面不应该暴露实现细节,从而误导用户。


例如生成token是最为常见的,生成token的方式也会有很多种。可以通过各种算法生成token,
有的是根据用户信息的hash算法生成,或者也可以用base64生成,甚至雪花算法直接生成。如果对外暴露更多实现细节,其实内部实现的可拓展性就会相当差。
我们来看一下下面的代码。


//反例:暴露实现细节
public interface tokenService {
TokenInfo generateHashTokenByUserName(String userName);
}
//正例:足够抽象、便于拓展
public interface tokenService {
TokenInfo generateToken(Object key);
}

API的命名相当重要


一个好的API名字无疑是相当重要的,使用者一看API的命名就能知道如何使用,可以大大降低调用方的使用成本。所以我们在设计API的时候需要注意下面几个方面。


1、API的名字可以自解释,一个好的API的名称可以清晰准确概括出API本身提供的能力。


2、保持对称性。例如read/write,get/set。


3、基本的API的拼写务必准确。API一旦发布之后,只能增加新的API去订正,旧API完全没有请求量之后才能废弃,错误的API的拼写可能会带给调用方理解上的歧义。


API设计需要避免标志性质的参数


所谓标志性的参数,就是一个接口为了兼容不同的逻辑分支,增加参数让调用方去抉择。这块其实和上述提及的API设计保证职责单一有点重复,但是老猫觉得很重要,所以还是
单独领出来细说一下。举个例子,上述提及的发布商品,在发布商品中既有更新的原有商品信息的功能在,又有新增商品的功能在。于是就有了这样错误的设计,如下:


public class PublishProductReq {
private String title;
private String headPicUrl;
private List<Sku> skuList;

//是否为更新动作,isModify就是所说的标志性质的参数
private Boolean isModify;
.....
}

那么对应的原始的发布接口为:


//反例:内部入参通过isModify抉择区分不同的逻辑
public interface PublishService {
PublishResult publishProduct(PublishProductReq req);
}

比较好的逻辑应将其区分开来,移除原来的isModify标志位:


public interface PublishService {
PublishResult addProduct(PublishProductReq req);
PublishResult editProduct(PublishProductReq req);
}

API设计出入参需要保证风格一致


这里所说的出入参的风格一致主要指的是字段的定义需要保持一个,例如对外的订单编号,一会叫做outerNo,一会叫做outerOrderNo。相关的用户在调用的时候八成是会骂娘的。


老猫最近其实在对接供应商的相关API,调用对方创建发货订单之后返回的订单编号是orderNo,后来用户侧完成订单需要通知供应商,入参是outerNo。老猫此时是懵逼的,都不知道这个
outerNo又是个什么,后来找到对面的研发沟通了一轮才知道原来outerNo就是之前返回的orderNo。


于是“我艹,坑笔啊”收尾.....


API设计的时候考虑性能


最后再聊聊API性能,维护了很多的项目,发现很多小伙伴在设计接口的时候并不会考虑接口性能。或者说当时那么设计确实不会存在接口的性能问题,可是随着业务的增长,数据量的增长,
接口性能问题就暴露出来了。就像上面小猫吐槽的,接口又又又慢了,又在报接口慢警告了。


举个例子,查询API,当数据量少的情况下,一个List作为最终返回搞定没有问题的。但是随着时间的推移,数据量越来越大,List能够cover吗?显然是不行的,此时就要考虑是否需要通过分页去做。
所以原来的List的接口就必须要改造成分页接口。


当然关于API性能的优化提升,老猫整理了如下提升方式。


1、缓存:CRUD的读写性能毕竟是有限的。所以对某些数据进行频繁的读取,这时候,可以考虑将这些数据缓存起来,下次读取时,直接从缓存中读取,减少对数据库的访问,提升API性能。


2、索引优化:很多时候接口慢是由于数据库性能瓶颈,如果不用上述提及的缓存,那么我们就需要看一下接口究竟是慢在哪个环节,可能是某个查询,可能是更新,所以我们就要分析
执行的SQL情况去添加一些索引。当然这里涉及如何进行MYSQL索引优化的知识点了,老猫在此不展开。


3、分页读取:如上述老猫举的例子中,针对的是那种随着数据量增长暴露出来的,那么我们就要对这些数据进行分页读取处理。


4、异步操作:在一个请求中开启多任务模式。


异步操作模式


举个例子:订单支付中,支付是核心链路,支付后邮件通知是非核心链路,因此,可以把这些非核心链路的操作,改成异步实现,
这样就可以提升API的性能。常用的异步方式有:线程池,消息队列,事件总线等。当然自从Java8之后还有比较好用的CompletableFuture。


5、Json序列化:JSON可以将复杂的数据结构或对象转换为简单的字符串,以便在网络传输、存储或与其他程序交互时进行数据交换。
优化JSON序列化过程可以提高API性能。使用高效的序列化库,减少不必要的数据字段,以及采用更紧凑的数据格式,都可以减少响应体的大小,从而加快数据传输速度和解析时间。


6、其他提升性能方案:例如运维侧提升带宽以及网速等等


上述罗列了相关API性能提升的一些措施,如果大家还有其他不错的方法,也欢迎留言。


总结


谈及软件中的设计,无论是架构设计还是程序设计还是说API设计,
原则其实都差不多,要能够松耦合、易扩展、注意性能。遵循上述这些API的设计规则,
相信大家都能设计出比较丝滑的API。当然如果还有其他的API设计中的注意点也欢迎在评论区留言。


作者:程序员老猫
来源:juejin.cn/post/7369783680427409418
收起阅读 »

请大家一定不要像我们公司这样打印log日志

前言 最近接手了公司另一个项目,熟悉业务和代码苦不堪言。 我接手一个新项目,有个习惯,就是看结构,看数据库,搜代码。 其中搜代码是我个人这些年不知不觉形成的癖好,我下面给大家展示下这个小癖好。 正文 我面对一个到手的新项目,会主动去搜索一些关键词...
继续阅读 »

前言



最近接手了公司另一个项目,熟悉业务和代码苦不堪言。




我接手一个新项目,有个习惯,就是看结构,看数据库,搜代码。




其中搜代码是我个人这些年不知不觉形成的癖好,我下面给大家展示下这个小癖好。



正文



我面对一个到手的新项目,会主动去搜索一些关键词,让我对这个项目有个整体健康的认识。



1、直接打印堆栈



比如搜索了printStackTrace(),目的是为了看这个项目中有多少地方直接打印了堆栈。




不搜还好,一搜,沃日,这滚动条,是奏响我悲痛的序章,竟然到处都是这种打印,而且是release分支。



1.png



我抽点了一些,看看具体是怎么写的,比如下面这样。



2.png



再比如下面这样,我反正长见识了,也可能只是我不会。



3.png


2、堆栈+log



比较典型的可能是下面这样,我以前就见过不少次,堆栈和log混合双打。



4.png



还无意间发现了这样的打印方式,log、堆栈、throw,纵享丝滑,一气呵成,让我们一起摇摆,哎,一起摇摆哎~



5.png


3、log+Json



最后这种,我怀疑是正在看文章的很多人就干过的,入参打印JSON,舒爽的做法,极致的坑爹。




我公司这个更酸爽,用的还是FastJson。



6.png


4、小插曲



写到这里,我可以告诉大家我写这篇文章的初衷不是我想教大家学习,因为这就是常识的东西。




我是因为今天的一件事感到意外。




我同组的工作了12年的Java工程师,做过非常多的项目,也确实很有经验且有责任心的同事。




他也写过这样的代码,因为我用IDEA查看了提交人,其中就有他的贡献。




另外,我有把上面log+堆栈+throw的写法给他看看,他的回答非常理所当然。




“这有问题吗,没报错啊”




我当场石化了,然后尴尬的笑笑就聊别的话题了。




讲这个小插曲的原因是什么,一叶知秋,从他身上我能断定,这样的工程师比比皆是。




干了这么多年,连个基本的日志规范都没有概念,哪怕不看什么阿里编码规范,至少对基础性的东西有个了解吧。



5、日志规范


所以,我专程又把以前分享过给大家的阿里巴巴《Java开发手册(黄山版)》掏出来,找出了里面日志规范着重说明的这部分。



正确的打印日志方式如下:



7.png



再看这个,第8条,禁止直接打印堆栈。




第9条,正确的打印异常日志的规范,我本人也一直都是第9条这种方式打印的。




另外,第10条说的很清楚,为什么不要在log里面用JSON转换工具,说简单点就是可能会报错,然后导致业务不走了。




一个日志打印本来是辅助排查问题用的,结果影响了正常业务流程,你说这是不是隐患。



8.png



而且,还告诉你了要如何打印入参,就是用toString()方法就行。




看看,写得多好,但是有多少人真的看了,都像你买的网课一样存在那里摆烂了吧。



总结



希望大家认真看一看,虽然简单,可很多程序员就差这么点意思,还是要养成好习惯哦。




作者:程序员济癫
来源:juejin.cn/post/7275974397005201449
收起阅读 »

解决LiveData数据倒灌的新思路

⏰ : 全文字数:5500+ 🥅 : 内容关键字:LiveData数据倒灌 数据倒灌现象 对于LiveData“数据倒灌”的问题,我相信很多人已经都了解了,这里提一下。所谓的“数据倒灌”:其实是类似粘性广播那样,当新的观察者开始注册观察时,会把上次发的最后一...
继续阅读 »

⏰ : 全文字数:5500+

🥅 : 内容关键字:LiveData数据倒灌



数据倒灌现象


对于LiveData“数据倒灌”的问题,我相信很多人已经都了解了,这里提一下。所谓的“数据倒灌”:其实是类似粘性广播那样,当新的观察者开始注册观察时,会把上次发的最后一次的历史数据传递给当前注册的观察者


比如在在下面的例子代码中:


val testViewModel = ViewModelProvider(this)[TestViewModel::class.java]
testViewModel.updateData("第一次发送数据")
testViewModel.testLiveData.observe(this,object :Observer{
override fun onChanged(value: String) {
println("==============$value")
}
})

updateData方法发送了一次数据,当下面调用LiveData的observe方法时,会立即打印==============第一次发送数据,这就是上面说的“数据倒灌”现象。


发生原因


原因其实也很简单,其实就是 LiveData内部有一个mVersion字段,记录版本,其初始的 mVersion 是-1,当我们调用了其 setValue 或者 postValue,其 mVersion+1;对于每一个观察者的封装 ObserverWrapper,其初始 mLastVersion 也为-1,也就是说,每一个新注册的观察者,其 mLastVersion 为-1;当 LiveData 设置这个 ObserverWrapper 的时候,如果 LiveDatamVersion 大于 ObserverWrappermLastVersionLiveData 就会强制把当前 value 推送给 Observer


也就是下面这段代码


    private void considerNotify(ObserverWrapper observer) {
if (!observer.mActive) {
return;
}

if (!observer.shouldBeActive()) {
observer.activeStateChanged(false);
return;
}
// 判断observer的版本是否大于LiveData的版本mVersion
if (observer.mLastVersion >= mVersion) {
return;
}
observer.mLastVersion = mVersion;
observer.mObserver.onChanged((T) mData);
}

所以要解决这个问题,思路上有两种方式:



  • 通过改变每个ObserverWrapper的版本号的值

  • 通过某种方式,保证第一次分发不响应


解决方法


目前网络上可以看到有三种解决方式


每次只响应一次


public class SingleLiveData<T> extends MutableLiveData<T> {
private final AtomicBoolean mPending = new AtomicBoolean(false);

public SingleLiveData() {
}

public void observe(@NonNull LifecycleOwner owner, @NonNull Observersuper T> observer) {
super.observe(owner, (t) -> {
if (this.mPending.compareAndSet(true, false)) {
observer.onChanged(t);
}

});
}

@MainThread
public void setValue(@Nullable T t) {
this.mPending.set(true);
super.setValue(t);
}

@MainThread
public void call() {
this.setValue((Object)null);
}
}

这个方法能解决历史数据往回发的问题,但是对于多Observe监听就不行了,只能单个监听,如果是多个监听,只有一个能正常收到,其他的就无法正常工作


反射


这种方式就是每次注册观察者时,通过反射获取LiveData的版本号,然后又通过反射修改当前Observer的版本号值。这种方式的优点是:



  • 能够多 Observer 监听

  • 解决粘性问题


但是也有缺点:



  • 每次注册 observer 的时候,都需要反射更新版本,耗时有性能问题


UnPeekLiveData


public class UnPeekLiveData extends LiveData {

protected boolean isAllowNullValue;

private final HashMap observers = new HashMap();

public void observeInActivity(@NonNull AppCompatActivity activity, @NonNull Observer super T> observer) {
LifecycleOwner owner = activity;
Integer storeId = System.identityHashCode(observer);
observe(storeId, owner, observer);
}

private void observe(@NonNull Integer storeId,
@NonNull LifecycleOwner owner,
@NonNull Observer super T> observer) {

if (observers.get(storeId) == null) {
observers.put(storeId, true);
}

super.observe(owner, t -> {
if (!observers.get(storeId)) {
observers.put(storeId, true);
if (t != null || isAllowNullValue) {
observer.onChanged(t);
}
}
});
}

@Override
protected void setValue(T value) {
if (value != null || isAllowNullValue) {
for (Map.Entry entry : observers.entrySet()) {
entry.setValue(false);
}
super.setValue(value);
}
}

protected void clear() {
super.setValue(null);
}
}

这个其实就是上面 SingleLiveData 的升级版,SingleLiveData 是用一个变量控制所有的 Observer,而上面采用的每个 Observer 都采用一个控制标识进行控制。
每次 setValue 的时候,就打开所有 Observer 的开关,表示可以接受分发。分发后,关闭当前执行的 Observer 开关,即不能对其第二次执行了,除非你重新 setValue
这种方式基本上是比价完美了,除了内部多一个用HashMap存放每个Observer的标识,如果Observer比较多的话,会有一定的内存消耗。


新的思路


我们先看下LiveData获取版本号方法:


int getVersion() {
return mVersion;
}

这个方法是一个包访问权限的方法,如果我新建一个和LiveData同包名的类,是不是就可以不需要反射就能获取这个值呢?其实这是可行的


// 跟LiveData同包名
package androidx.lifecycle

open class SafeLiveData<T> : MutableLiveData<T>() {

override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
// 直接可以通过this.version获取到版本号
val pictorialObserver = PictorialObserver(observer, this.version > START_VERSION)
super.observe(owner, pictorialObserver)
}

class PictorialObserver<T>(private val realObserver: Observer<in T>, private var preventDispatch: Boolean = false) :
Observer {

override fun onChanged(value: T) {
// 如果版本有差异,第一次不处理
if (preventDispatch) {
preventDispatch = false
return
}
realObserver.onChanged(value)
}

}
}

这种取巧的方式的思路就是:



  • 利用同包名访问权限可以获取版本号,不需要通过反射获取

  • 判断LiveDataObserver是否有版本差异,如果有,第一次不响应,否则就响应


我个人是偏向这种方式,也应用到了实际的开发中。这种方式的优点是:改动小,不需要反射,也不需要用HashMap存储等,缺点是:有一定的侵入性,假如后面这个方法的访问权限修改或者包名变动,就无效了,但是我认为这种可能性是比较小,毕竟androidx库迭代了这么多版本,算是比较稳定了。



作者:卒子行
来源:juejin.cn/post/7268622342728171572
收起阅读 »

“WWW” 仍然属于 URL 吗?它可以消失吗?

多年来,我们的地址栏上一直在进行着一场小小的较真战。也就是Google、Instagram和Facebook 等品牌。该群组已选择重定向 example.com 至 http://www.example.com。相反:GitHub...
继续阅读 »

多年来,我们的地址栏上一直在进行着一场小小的较真战。也就是GoogleInstagramFacebook 等品牌。该群组已选择重定向 example.com 至 http://www.example.com。相反:GitHubDuckDuckGoDiscord。该组织选择执行相反的操作并重定向 http://www.example.com 到 example.com



“WWW”属于 URL 吗?一些开发人员对此主题持有强烈的意见。在了解了一些历史之后,我们将探讨支持和反对它的论据。


WWW是什么?


WWW代表"World Wide Web",是上世纪80年代晚期的一个发明,引入了浏览器和网站。使用"WWW"的习惯源于给子域名命名的传统:



如果没有WWW会发生什么问题?


1. 向子域名泄露cookies


反对"没有WWW"的域名的批评者指出,在某些情况下,subdomain.example.com可以读取example.com设置的cookies。如果你是一个允许客户在你的域名上运营子域名的Web托管提供商,这可能是不希望看到的。


然而,这种行为只存在于Internet Explorer中。


RFC 6265标准化了浏览器对cookies的处理,并明确指出这种行为是错误的。


另一个潜在的泄露源是example.com设置的cookies的Domain值。如果Domain值明确设置为example.com,那么这些cookies也将被其子域名所访问。


Cookie 值暴露于 example.com暴露于 subdomain.example.com
secret=data
secret=data; Domain=example.com

总之,只要你不明确设置Domain值,而且你的用户不使用Internet Explorer,就不会发生cookie泄露。


2. DNS的困扰


有时,"没有WWW"的域名可能会使你的域名系统(DNS)设置复杂化。


当用户在浏览器的地址栏中输入example.com时,浏览器需要知道他们想访问的Web服务器的Internet协议(IP)地址。浏览器通过你的域名的域名服务器向其DNS服务器(通常间接通过用户的互联网服务提供商(ISP)的DNS服务器)请求IP地址。如果你的域名服务器配置为响应包含IP地址的A记录,那么"没有WWW"的域名将正常工作。


在某些情况下,你可能希望使用规范名称(CNAME)记录来代替为你的网站设置。这样的记录可以声明http://www.example.comexample123.somecdnprovider.com的别名,这会告诉用户的浏览器去查找example123.somecdnprovider.com的IP地址,并将HTTP请求发送到那里。


请注意,上面的示例使用了一个WWW子域名。对于example.com,不可能定义一个CNAME记录。根据RFC 1912,CNAME记录不能与其他记录共存。如果你尝试为example.com定义CNAME记录,example.com上的MX(邮件交换)记录将无法存在。因此,就不可能在@example.com上接收邮件


一些DNS提供商可以让你绕过这个限制。Cloudflare称其解决方案为CNAME解析。通过这种技术,域名管理员配置一个CNAME记录,但他们的域名服务器将暴露一个A记录。


例如,如果管理员为example.com配置了指向example123.somecdnprovider.com的CNAME记录,并且存在一个指向1.2.3.4example123.somecdnprovider.com的A记录,那么Cloudflare就会暴露一个指向1.2.3.4的example.com的A记录。


总之,虽然这个问题对希望使用CNAME记录的域名所有者来说是有效的,但现在有一些DNS提供商提供了合适的解决办法。


没有WWW的好处


大部分反对WWW的论点是实用性或外观方面的。"无WWW"的支持者认为example.comhttp://www.example.com更容易说和输入(对于不那么精通技术的用户可能更不容易混淆)。


反对WWW子域名的人还指出,去掉它会带来一种谦虚的性能优势。网站所有者可以通过这样做每个HTTP请求节省4个字节。虽然这些节省对于像Facebook这样的高流量网站可能会累积起来,但带宽通常并不是一种紧缺的资源。


有"WWW"的好处


支持WWW的一个实际论点适用于使用较新顶级域的情况。例如,http://www.example.miamiexample.miami无法立即被识别为Web地址。对于具有诸如.com这样的可识别顶级域的网站,这不是一个太大的问题。


对搜索引擎排名的影响


目前的共识是你的选择不会影响你的搜索引擎表现。如果你希望从一个URL迁移到另一个URL,你需要配置永久重定向(HTTP 301)而不是临时重定向(HTTP 302)。永久重定向确保你旧的URL的SEO价值转移到新的URL。


同时支持两者的技巧


网站通常会选择example.comhttp://www.example.com作为官方网站,并为另一个配置HTTP 301重定向。理论上,可以支持http://www.example.com和example.com两者。但实际上,成本可能会超过效益。


从技术角度来看,你需要验证你的技术栈是否能够处理。你的内容管理系统(CMS)或静态生成的网站需要将内部链接输出为相对URL以保留访问者的首选主机名。除非你可以将主机名配置为别名,否则你的分析工具可能会将流量分别记录在两个主机名上。


最后,你需要采取额外的措施来保护你的搜索引擎表现。谷歌将把URL的"WWW""非WWW"版本视为重复内容。为了在其搜索索引中去重复内容,谷歌将显示它认为用户更喜欢的那个版本——不论是好是坏。


为了在谷歌中保持对自己的控制,建议插入规范链接标签。首先,决定哪个主机名将成为官方(规范)主机名。


例如,如果你选择了www.example.com,则必须在 https://example.com/my-article里的  上的标记 中插入以下代码段:


    <link href="https://www.example.com/my-article" rel="canonical"> 

这个代码片段告诉谷歌"无WWW"变体代表着相同的内容。通常情况下,谷歌会在搜索结果中偏好你标记为规范的版本,也就是在这个例子中的"WWW"变体。


总结


对于是否在URL中加入"WWW",人们有不同的观点。下面是支持和反对的论点:


支持"WWW"的论点:



  1. 存在子域名的安全性问题:某些情况下,子域名可以读取主域名设置的cookies。虽然这个问题只存在于Internet Explorer浏览器中,并且已经被RFC 6265标准化修复,但仍有人认为使用"WWW"可以避免潜在的安全风险。

  2. DNS配置的复杂性:如果你的域名系统(DNS)配置为响应包含IP地址的A记录,那么"没有WWW"的域名将正常工作。但如果你想使用CNAME记录来设置规范名称,那么"没有WWW"的域名可能会导致一些限制,例如无法同时定义CNAME记录和MX(邮件交换)记录。

  3. 对搜索引擎排名的影响:对于使用较新顶级域的网站,使用"WWW"可以帮助识别网址,而不是依赖可识别的顶级域名。然而,目前的共识是选择是否使用"WWW"对搜索引擎表现没有直接影响。


支持去除"WWW"的论点:



  1. 实用性和外观:去除"WWW"可以使域名更简洁和易于输入,减少了用户可能混淆的机会。

  2. 节省字节:去除"WWW"可以每个HTTP请求节省4个字节。虽然这对于高流量网站来说可能是一个可累积的优势,但对于大多数网站来说,带宽通常不是一个紧缺的资源。


最佳实践:
一般来说,网站会选择将example.com或www.example.com作为官方网址,并对另一个进行重定向。你可以通过使用HTTP 301永久重定向来确保旧URL的SEO价值转移到新URL。同时,你还可以在页面的标签中插入规范链接标签,告诉搜索引擎两个URL代表相同的内容,以避免重复内容问题。


需要注意的是,在做决策时要考虑到技术栈的支持能力、DNS配置的限制和谷歌对搜索排名的处理方式。


作者:狗头大军之江苏分军
来源:juejin.cn/post/7263274550074507321
收起阅读 »

OPPO率先适配Android 15,首批机型名单公布

北京时间5月15日,代号为「Vanilla Ice Cream(香草冰淇淋)」的 Android 15 在2024谷歌 I/O 大会正式亮相。作为全球智能手机市场领先品牌,OPPO 作为连续六年首批适配 Android 新系统的厂商,此次不仅率先发布了基于 A...
继续阅读 »

北京时间5月15日,代号为「Vanilla Ice Cream(香草冰淇淋)」的 Android 15 在2024谷歌 I/O 大会正式亮相。作为全球智能手机市场领先品牌,OPPO 作为连续六年首批适配 Android 新系统的厂商,此次不仅率先发布了基于 Android 15 的开发者预览版助力开发者抢先适配,更以全流程、全方位的适配保障服务,持续为广大开发者保驾护航。

图1.png

OPPO 公布 Android 15 适配指南,督促开发者升级64位架构

据了解,Android 15 带来了一系列令人瞩目的新功能和改进,包括全新设计的兼容性调试工具、安全与隐私相关的强化措施、系统优化和新的API支持。这些功能使开发者能够更轻松地构建和维护应用,为用户提供更好的性能和体验。

基于 Android 15 Beta 版本,OPPO 推出 ColorOS 开发者预览版,OPPO Find X7、一加12首批支持。值得注意的是, OPPO 这次特别督促开发者对64位架构的全面升级,确保所有软件和应用在新系统中都能实现最佳性能。据了解,Android 15 系统升级 Vendor 的手机均不支持32位应用,若不进行64位升级将导致应用后续无法下载使用。64位架构优势显著,更快的处理速度、更大的内存支持以及更高的效率,使开发者能够充分利用 Android 15 的潜力,为用户提供更加流畅和丰富的应用体验。开发者可前往OPPO开放平台官网,抢先下载并体验开发者预览版。

图2.png

全方位服务保障,助力开发者推进系统适配

为了助力开发者高效适配 Android 15 系统,OPPO 提供了包括适配文档、适配工具、适配资讯以及专家交流等在内的全面支持和服务。

全面清晰的指导文档帮助各类型 APP 开发者迅速找到所需的适配方案;云测服务提供实时在线的远程调试功能,支持开发者随时接入,帮助开发者快速验证适配结果;OPPO开放平台官网适配支持专区实时更新 Android 15 最新动态,开发者可随时获取第一手适配资讯。此外,OPPO 还提供了7*24小时在线答疑服务,专人协助解决适配技术难题,进一步提升适配效率。技术指引也将实时更新,集中解答开发者提出的高频问题。

海量全面的文档支持,贯穿全程的指导升级服务,让每一个开发者都不掉队。

图3.png

据悉,5月22日,OPPO 将联合知名技术社区 51CTO 举办「OTalk | Android 15 适配开发者交流专场」线上直播活动,与行业开发者深入交流对话。届时将特别邀请 OPPO 高级工程师带来 Android 15 新特性的深度解读及适配建议,分享 OPPO 适配支持服务,解答开发者常见问题,助力开发者高效适配新版本。

图4.png

作为 Android 生态系统的关键参与者,OPPO 连续6年首批适配 Android 新版本,持续为开发者提供全流程适配支持和服务,携手开发者高效完成版本迭代优化与应用兼容性测试,共同将更安全、更流畅的系统体验带给用户。

接下来,OPPO 将持续提供关于 Android 15 适配的最新进展,广大开发者可关注「OPPO开放平台」后续公告,以获取更多详细信息和支持资源。

收起阅读 »

在线人数统计功能怎么实现?

一、前言 大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。 在线人数统计这个功能相信大家一眼就明白是啥,这个功能不难做,实现的方式也很多,这里说一下我常使用的方...
继续阅读 »

一、前言


大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。


在线人数统计这个功能相信大家一眼就明白是啥,这个功能不难做,实现的方式也很多,这里说一下我常使用的方式:使用Redis的有序集合(zset)实现。
核心方法是这四个:zaddzrangeByScorezremrangeByScorezrem


二、实现步骤


1. 如何认定用户是否在线?


认定用户在线的条件一般跟网站有关,如果网站需要登录才能进入,那么这种网站就是根据用户的token令牌有效性判断是否在线;
如果网站是公开的,是那种不需要登录就可以浏览的,那么这种网站一般就需要自定一个规则来识别用户,也有很多方式实现如IPdeviceId浏览器指纹,推荐使用浏览器指纹的方式实现。


浏览器指纹可能包括以下信息的组合:用户代理字符串 (User-Agent string)、HTTP请求头信息、屏幕分辨率和颜色深度、时区和语言设置、浏览器插件详情等。现成的JavaScript库,像 FingerprintJSClientJS,可以帮助简化这个过程,因为它们已经实现了收集上述信息并生成唯一标识的算法。


使用起来也很简单,如下:


// 安装:npm install @fingerprintjs/fingerprintjs

// 使用示例:
import FingerprintJS from '@fingerprintjs/fingerprintjs';

// 初始化指纹JS Library
FingerprintJS.load().then(fp => {
// 获取访客ID
fp.get().then(result => {
const visitorId = result.visitorId;
console.log(visitorId);
});
});


这样就可以获取一个访问公开网站的用户的唯一ID了,当用户访问网站的时候,将这个ID放到访问链接的Cookie或者header中传到后台,后端服务根据这个ID标示用户。


2. zadd命令添加在线用户


(1)zadd命令介绍
zadd命令有三个参数



key:有序集合的名称。
score1、score2 等:分数值,可以是整数值或双精度浮点数。
member1、member2 等:要添加到有序集合的成员。
例子:向名为 myzset 的有序集合中添加一个成员:ZADD myzset 1 "one"



(2)添加在线用户标识到有序集合中


// expireTime给用户令牌设置了一个过期时间
LocalDateTime expireTime = LocalDateTime.now().plusSeconds(expireTimeout);
String expireTimeStr = DateUtil.formatFullTime(expireTime);
// 添加用户token到有序集合中
redisService.zadd("user.active", Double.parseDouble(expireTimeStr), userToken);


由于一个用户可能户会重复登录,这就导致userToken也会重复,但为了不重复计算这个用户的访问次数,zadd命令的第二个参数很好的解决了这个问题。
我这里的逻辑是:每次添加一个在线用户时,利用当前时间加上过期时间计算出一个分数,可以有效保证当前用户只会存在一个最新的登录态。



3. zrangeByScore命令查询在线人数


(1)zrangeByScore命令介绍



key:指定的有序集合的名字。
min 和 max:定义了查询的分数范围,也可以是 -inf 和 +inf(分别表示“负无穷大”和“正无穷大”)。
例子:查询分数在 1 到 3之间的所有成员:ZRANGEBYSCORE myzset 1 3



(2)查询当前所有的在线用户


// 获取当前的日期
String now = DateUtil.formatFullTime(LocalDateTime.now());
// 查询当前日期到"+inf"之间所有的用户
Set userOnlineStringSet = redisService.zrangeByScore("user.active", now, "+inf");


利用zrangeByScore方法可以查询这个有序集合指定范围内的用户,这个userOnlineStringSet也就是在线用户集,它的size就是在线人数了。



4. zremrangeByScore命令定时清除在线用户


(1)zremrangeByScore命令介绍



key:指定的有序集合的名字。
min 和 max:定义了查询的分数范围,也可以是 -inf 和 +inf(分别表示“负无穷大”和“正无穷大”)。
例子:删除分数在 1 到 3之间的所有成员:ZREMRANGEBYSCORE myzset 1 3



(2)定时清除在线用户


// 获取当前的日期
String now = DateUtil.formatFullTime(LocalDateTime.now());
// 清除当前日期到"-inf"之间所有的用户
redisService.zremrangeByScore(""user.active"","-inf", now);


由于有序集合不会自动清理下线的用户,所以这里我们需要写一个定时任务去定时删除下线的用户。



5. zrem命令用户退出登录时删除成员


(1)zrem命令介绍



key:指定的有序集合的名字。
members:需要删除的成员
例子:删除名为xxx的成员:ZREM myzset "xxx"



(2)定时清除在线用户


// 删除名为xxx的成员
redisService.zrem("user.active", "xxx");


删除 zset中的记录,确保主动退出的用户下线。



三、小结一下


这种方案的核心逻辑就是,创建一个在线用户身份集合为key,利用用户身份为member,利用过期时间为score,然后对这个集合进行增删改查,实现起来还是比较巧妙和简单的,大家有兴趣可以试试看。


作者:summo
来源:juejin.cn/post/7356065093060427816
收起阅读 »

为什么网站要使用HTTPS?

现在HTTPS基本上已经是网站的标配了,很少会遇到单纯使用HTTP的网站。但是十年前这还是另一番景象,当时只有几家大型互联网公司的网站会使用HTTPS,大部分使用的都还是简单的HTTP,这一切是怎么发生的呢?为什么要把网站升级到HTTPS?若干年前,公司开发了...
继续阅读 »

现在HTTPS基本上已经是网站的标配了,很少会遇到单纯使用HTTP的网站。但是十年前这还是另一番景象,当时只有几家大型互联网公司的网站会使用HTTPS,大部分使用的都还是简单的HTTP,这一切是怎么发生的呢?

为什么要把网站升级到HTTPS?

若干年前,公司开发了一款APP,其中的某些页面是用H5实现的,有一天用户向我们反馈,页面中弹出了一个广告窗口,这让当时身为开发小白的我感觉很懵逼,后来经过经验丰富的老程序员点拨,才知道这是被电信运营商劫持了,运营商拦截了服务器对用户的HTTP响应,并在中间夹带了一些私货。

一些网龄比较大的同学可能还有这样的记忆:网站页面找不到的时候,浏览器会跳转到一个运营商或者路由器厂商的网址导航页面;家里的宽带到期的时候,浏览器网页右下角会弹出续费通知。

这都是HTTP响应被劫持的表现,HTTP本身没什么安全机制,HTTP传输的数据很容易被窃取和篡改,这也是我们将网站升级到HTTPS的根本动机。

使用HTTPS有很多好处,这里稍微展开介绍一下:

  • 数据加密:HTTPS通过SSL/TLS协议为数据传输过程提供了加密,即便数据在传输过程中被截获,没有密钥也无法解读数据内容。这就像是特工使用密文发送电报,即使电报内容被别人截获,没有密码表也无法解读其中的内容。
  • 身份验证:使用HTTPS的网站会获得权威认证机构颁发的证书,这就像是一个“身-份-证”,让访问者能够确认自己访问的是官方合法的网站,有效防止钓鱼网站的风险。
  • 数据完整性:因为数据传输的中间人接触不到密钥,不仅不能解密,而且也无法对数据进行加密,这就保证了数据在传输过程中不被篡改、伪造。
  • 增强用户信任:由于浏览器会对HTTPS网站显示锁标志,这有助于增强访问者对网站的信任。就像是看到家门口安装了高级安全锁,人们会自然而然地觉得这家人对安全非常重视,从而更加放心。
  • SEO优势:谷歌等搜索引擎已经明确表示,HTTPS是搜索排名算法的一个信号。这意味着使用HTTPS的网站在搜索结果中可能会获得更高的排名,具备更大的竞争优势。

HTTPS的发展趋势

大约从2010年开始,大型网站和安全专家开始倡导使用HTTPS,也就是在HTTP上加上SSL/TLS协议进行加密。

根据互联网安全研究机构的报告,目前超过80%的网站已经使用HTTPS。特别是那些大型电商平台和社交媒体网站,几乎100%都已经完成了从HTTP到HTTPS的升级。

不仅是企业和网站管理员在推动HTTPS的普及,各国政府和互联网安全组织也在积极推荐使用HTTPS。例如,各种浏览器都会对那些仍然使用HTTP的网站标记为“不安全”。

随着人们对网络安全意识的增强,大家也更加偏好那些使用HTTPS的网站。就像是在选择酒店的时候,你可能会更倾向于选择那些看起来保卫严密的酒店。

HTTPS的技术原理

加密技术

HTTPS 安全通信的核心在于加密技术。这里面主要涉及两种加密方式:对称加密和非对称加密。

  • 对称加密:就像是你和朋友使用同一把钥匙来锁和解一个箱子。信息的发送方和接收方使用同一个密钥进行数据的加密和解密。这种方式的优点是加解密速度快,通信成本低,但缺点在于如果密钥被中间截获或者泄漏,通信就不安全了。
  • 非对称加密:就像是用一个钥匙锁箱子(公钥),另一个钥匙来开箱子(私钥)。发送方使用接收方的公钥进行加密,而只有接收方的私钥能解开。这样即便公钥被公开,没有私钥也无法解密信息,从而保证了传输数据的安全。

在实际应用中,HTTPS 通常采用混合加密机制。在连接建立初期使用非对称加密交换对称加密的密钥,一旦密钥交换完成,之后的通信就切换到效率更高的对称加密。就像是先通过一个安全的箱子(非对称加密)把家门钥匙(对称加密的密钥)安全送到朋友手中,之后就可以放心地使用这把钥匙进行通信了。

SSL/TLS协议

HTTPS 实际上是 HTTP 协议跑在 TLS 协议之上,TLS的全称是 Transport Layer Security,从字面上理解就是传输层安全,保护数据传输的安全。有时候我们还会看到 SSL 这个词,SSL 其实是 TLS 的前身,它的全称是 Secure Sockets Layer,Socket 就是是TCP/UDP编程中经常接触的套接字概念,也是传输层的一个组件。

可以理解为,SSL/TLS就像是一个提供安全保护的信封,确保了信件(数据)在寄送过程中的安全。

让我们来详细探查下 HTTPS 的工作流程:

1、开始握手:当浏览器尝试与服务器建立HTTPS连接时,它首先会发送一个“Hello”消息给服务器,这个消息里包含了浏览器支持的加密方法(包括对称加密和非对称加密等)等信息。

2、服务器回应:服务器收到客户端的“Hello”之后,会选择一组客户端和服务器都支持的加密方法,然后用自己的私钥对信息进行签名,把这个签名连同服务器的SSL证书一起发送到客户端,SSL证书里包含了服务器的公钥。

3、验证证书:客户端收到服务器发过来的证书后,会首先验证证书的合法性,确保证书是可信任的CA颁发,且未被篡改。这个验证会使用浏览器或者操作系统内置的安全根证书,验证从服务器证书到根证书的所有认证链上的签名都是可信任的。

4、生成临时密钥:一旦证书验证通过,客户端就会生成一串随机密钥(也就是对称密钥)。然后,客户端会用服务器的公钥对这串随机密钥进行加密,再发送给服务器。

5、服务器解密获取对称密钥:服务器收到加密后的数据,会用自己的私钥对其解密,获取到其中的对称密钥。到这里,客户端和服务器双方就都拥有了这个对称密钥,后续的通信就可以使用这个对称密钥进行加密了。

这里我们介绍的密钥交换方式是RSA,其实TLS支持多种密钥交换机制,除了RSA,还包括Diffie-Hellman密钥交换(简称DH)、椭圆曲线Diffie-Hellman(简称ECDH)密钥交换等,或者RSA和DH的结合。DH密钥交换不需要在通信双方之间直接发送对称密钥,同时即使证书的私钥被泄露,之前的会话密钥也不能被推导出来,之前的通信也就无法被解密,这样更加安全。有兴趣的同学可以去搜索了解一下。

证书和认证机构(CA)

为了保证网站的身份真实性,HTTPS还涉及到了证书(SSL证书)的使用。这个证书由认证机构(CA)颁发,包含了网站公钥、网站身份信息等。浏览器或操作系统内置了这些认证机构的信任列表,能自动验证证书的真实性。

证书认证机构会在颁发证书前确认网站的身份,这有点像买火车票之前,需要先通过身份认证来确认你的身份。根据验证的深度和范围,证书可以分为以下几种类型:

  1. 域名验证(DV)证书

这种证书只验证网站拥有者对域名的控制权。CA会通过Url文件验证或DNS记录验证等方式来确认申请者是否控制该域名。DV证书的发放速度快,成本低,但它只证明域名的控制权,不会验证组织的真实身份。

  1. 组织验证(OV)证书

OV证书不仅验证域名的控制权,还要验证申请证书的组织是真实、合法且正式注册的。这就像提交某些申请时,除了要上传身-份-证,还要上传企业的营业执照,确认你是某个公司的员工。OV证书提供了更高级别的信任,适用于商业网站。

  1. 扩展验证(EV)证书

EV证书提供了最高级别的验证。在这个过程中,CA会进行更为严格和全面的审查,包括确认申请组织的法律、运营和物理存在。这就像不仅检查身-份-证和营业执照,还要确认你的实际居住地址、实际办公地点等信息。EV证书为用户提供了最高水平的信任,但它的发放流程最为复杂,成本也最高。

配置HTTPS的步骤

1. 获取SSL/TLS证书

可以从阿里云、腾讯云等这些大的云计算平台申请你需要的证书,也可以从专门的证书颁发机构获取。

证书可以只针对单个域名,比如www.juejin.cn,那只能 http://www.juejin.cn 使用这个证书,www2.juejin.cn 不能使用这个证书;也可以配置为泛域名,比如 *.juejin.cn,那么 http://www.juejin.cn 和 www2.juejin.cn 都可以使用这个证书。

申请证书时会验证你的身份,比如对于DV证书,需要你在DNS中配置一个特殊TXT解析、或者在网站中放置一个特别的验证文件,证书颁发机构能够通过网络进行验证。

验证通过后,证书颁发结构会给你发放证书,包括公钥和私钥。

证书有免费版和收费版。免费版一般只针对单个域名,仅颁发DV证书,证书的有效期一般是3-12个月。普通用户为了节约成本,可以使用免费版本,通过一些程序脚本实现证书的到期自动更新。

2. 配置Web服务器

拿到证书后,需要在你的Web服务器上配置它,具体步骤取决于你使用的服务器软件(如Apache、Nginx等)。

注意HTTPS默认的监听端口是443,使用这个端口,用户访问时可以不输入端口号。

3. 强制使用HTTPS

为了确保所有数据都是安全传输的,我们可以使用重定向让用户始终访问HTTPS地址。

在Web服务器上设置,将所有HTTP请求重定向到HTTPS,用户使用HTTP时都会自动跳转到HTTPS,比如访问 juejin.cn 会自动跳转到 juejin.cn。

4. 维护和更新

证书都是有保质期的,需要在证书到期前进行续期。有时候我们还需要根据安全威胁报告,及时更新SSL/TLS的加密设置,确保它们符合最新的安全标准。

HTTPS的安全问题

HTTPS虽然大大提高了网站的安全性,但它也不是万无一失的。

1、弱加密算法

如果使用过时或不安全的加密算法,加密的数据可能会被破解。

在Web服务器配置中禁用已知不安全的SSL/TLS版本(如TLS 1.0和1.1)和弱加密套件,选择使用强加密算法,如AES_GCM或ChaCha20。

2、钓鱼网站

即使是使用HTTPS的网站,也可能是钓鱼网站,比如DV证书只验证网站的域名归属,不确认网站具体是干什么的。这就像强盗穿上快递员的制服,你很难一眼识破。

对于关键的服务,比如在线购物、上传个人信息,用户需要提高警惕,检查网站的URL,确保是访问的正确网站。

我们也可以使用浏览器提供的安全插件或服务来识别和阻止访问已知的恶意网站。

3、中间人攻击

即使使用了HTTPS,如果攻击者能够在通信双方之间插入自己,就能够监听、修改传输的数据。如果你使用过Fiddler 这种抓包程序做过前端通信调试,就很容易理解这个问题。这就像快递途中有个假冒的收发室,所有包裹都得先经过它。

要防范这个问题比较困难,用户尽量不要在公共的WiFi网络进行敏感操作,不随便下载安装可疑的文件或程序,网站运营者要确保网站的TLS配置是安全的,使用强加密算法和协议。

4、审核不严的证书

证书颁发机构审核不严或者胡乱颁发证书,比如别有用心的人通过特殊手段就能申请到google.com的证书。而且历史上也确实发生过。

2011年,荷兰证书颁发机构(CA)DigiNotar因被黑客入侵并滥发了大量伪造的SSL/TLS证书,包括对Google域名的证书,最终导致DigiNotar破产。

2016年,中国CA机构WoSign及旗下子公司StartCom被曝出多种违规操作,导致主流浏览器厂商逐步撤销对这两家CA的信任。

解决这个问题主要依赖证书颁发机构和监管机构的安全机制,浏览器和操作系统厂商也可以在问题发生后通过紧急更新来避免风险的进一步扩大,使用证书的用户如果有能力,可以通过监控CA机构发布的证书颁发日志来探查是否有未经授权的证书颁发给你的域名。


以上就是本文的主要内容,希望此文能让你对Https有了一个系统全面的了解,更好的保护Http通信安全。


作者:萤火架构
来源:juejin.cn/post/7366053684154777626
收起阅读 »

困扰我 1 小时的 404 错误 别人 1 分钟解决了

上周五遇到了一个 Bug,没有任何异常,困扰了我一个小时,差点连周末都过不好了。最后没办法,请教了组内的同学,没想到竟被他 1 分钟解决了! 事情的起因,只是因为我把一个接口从 @GetMapping 改成了 @PostMapping ,然后接口就报以下的错误...
继续阅读 »

上周五遇到了一个 Bug,没有任何异常,困扰了我一个小时,差点连周末都过不好了。最后没办法,请教了组内的同学,没想到竟被他 1 分钟解决了!


事情的起因,只是因为我把一个接口从 @GetMapping 改成了 @PostMapping ,然后接口就报以下的错误:


image.png
没有任何的 WARN 或者 ERROR 日志!

网上搜了一下,也没有什么有效的信息,万能的 AI 给出了下面这样的回答:


image.png
404 的错误太常见了,有很多原因造成这一结果。


但可以确定的是,我的请求路径和控制器配置都是没有问题的,因为只要要把 @PostMapping 改回 @GetMapping ,一切都运行正常。


在这种情况下,搜索引擎和AI,除了给我造成干扰误导排查方向外,不能起到什么实质性的作用。


无奈,我只能硬着头皮打开 DEBUG 日志,尝试对照源码,去解决问题了。


不幸的是,DEBUG 日志实在太多了,里面也没有任何异常。我刚学 SpringBoot 不久,这些碎片化的日志,不能引起我的任何联想,因此,实质上也起不到辅助排查的作用。


折腾了一个多小时,还是没有什么头绪,明天就周末了,带着这个 Bug,周末恐怕都休息不好。于是,我就硬着头皮找了组内一个比较有经验的同学帮忙看一下。


他过来翻了翻日志,查看了一下配置类,淡淡地说到,你打开了 Csrf 验证,但是请求却没带 Token。说罢,指导我加上了一行代码:


.csrf().disable()

然后,再次访问,竟然就真的可以了!整个过程也就 1 分钟左右!


我这个小弱鸡的心灵着实有些触动。于是追问到,大神你是怎么看出来的呀。


“没什么,就是经验多了。日志里面有些信息,比如 token 相关的, 其实已经提示了你答案。不过,需要你对框架比较了解,才能 get 到这些信息。新手遇到这种没有明显异常的问题,确实会比较费劲。”


“那有没有什么办法,可以快速搞懂这种框架问题啊,每次遇到都挺烦躁的,不仅影响研发进度,也影响心情” , 上进的我还是想从大神这里获取更多的经验。


“额….我想想”,大神迟疑了一会儿,“你可以试试这个 XCodeMap 插件“,”它可以提供更丰富的信息,图形化的形式,可以较为容易看出可能存在的问题。实在看不出,你也可以基于这些信息再去问搜索引擎或者AI”。


“感谢大神,我去试一下”。


试用了一下,这个工具画出了下面的序列图:


image.png
我虽然不懂什么 Csrf 的原理,但是这个图已经可以清晰地表达出问题了,在 SpringBoot 的FilterChain 中,走到 CsrfFilter 就终止了,并且调用了一个 AccessDeniedHandler。


看起来,这个序列图是实时动态采集的,而且做了很多剪枝,把一些关键调用给标记了出来。对于 SpringBoot 系列,其会把各种 Filter 的调用情况展示出来,可以让人一眼看出来是哪个 Filter 出了问题。


点击 CsrfFilter 的 doFilter 方法,可以看到以下代码:


image.png
这个代码可以看出来,Csrf 的原理(以CookieCsrfToken为例)就是取两个token进行比对。其中一个从请求的 Header 或者 Parameter 中读出。另外一个,从 Cookie 中读出。


image.png
由于浏览器的同源策略,攻击网站无法获取本网站的Cookie,也即其无法完成下面这样的JS操作:


image.png
但是本网站可以通过上面的操作,把 Cookie 中的token设置到 Header 中,这样就达到了避免 CSRF 攻击的效果。


不过,这里还有一个小插曲,csrf 验证失败,本意应该是报 403 错误码,然后转发到 “/403” 页面,只是因为我没有配置 “/403” 页面,最终才报了404 错误。


image.png


这次由 Csrf 引起的 404 错误,就到此为止了。


我独自完成了后面的排查,还是很开心的。我没有大神那样丰富的经验,可以凭借只言片语的日志信息,就可以推断出问题所在。但我借助 XCodeMap 绘制的动态序列图,按图索骥,搞清楚了问题的来龙去脉。这让我想起了下面的一句话:


人类有了弓箭,拳头就不再是绝对硬实力了。好的工具,可以削平人与人的差距!


感觉自己与大神接近了不少!


参考资料:



作者:摸鱼总工
来源:juejin.cn/post/7362722064069427237
收起阅读 »

关于“明显没有bug的代码”的一些拙见

以前听过过一个有趣的说法:不要编写没有明显bug的代码,要去编写明显没有bug的代码。这里提到的两个概念:“没有明显bug的代码”和“明显没有bug的代码”。同样的文字,只是调换了下顺序,表达的就是完全不同的概念了。前者“没有明显bug的代码”大概是最常见的代...
继续阅读 »

以前听过过一个有趣的说法:不要编写没有明显bug的代码,要去编写明显没有bug的代码。这里提到的两个概念:“没有明显bug的代码”和“明显没有bug的代码”。同样的文字,只是调换了下顺序,表达的就是完全不同的概念了。前者“没有明显bug的代码”大概是最常见的代码了,特征就是:



  1. 每段程序看起来合理,但结果就是不对

  2. 程序看起来复杂、奇怪,但就是可以正常运行

  3. 天书一般的程序

  4. 待补充


平时工作中到处缝缝补补的代码大概就是这种代码吧。背后的原因一般比较复杂,有时还不可追溯,项目工期紧,人员交接等等都有可能。因此,与其思考“如何避免没有明显bug的代码”,还不如思考“如何写出明显没有bug的代码”。本文就何为“明显没有bug的代码”总结一些个人的思的胡思乱想,阐述这类代码的几个特征。


特征1:代码简短


“明显没有xx”意味着一眼能看出来,而“一眼”这个条件就有很大的限制。如果给我一个函数,包含1000多行代码,我鼠标滚轮要滚好久,才能过完一遍代码,那么这种代码一定不是“明显没有bug的代码”。那么,反过来说,“明显没有bug的代码”一定是短小的代码。比如,Java中的Objects.equals方法:


public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}

这一段代码简短到,代码跟功能定义的文字篇幅差不多,连写文档注释的必要都没有了。还有一个更极端的例子是Java的Objects.isNull方法:


public static boolean isNull(Object obj) {
return obj == null;
}

简直就是一段“废话”。


特征2:功能完整且连贯


“一眼能看出”还意味着功能不能太分散,如果一个功能,分散在十多个函数或文件中,那么看这段功能就得在很多代码片段中跳来跳去,这个就需要开发者来阅读代码时充当一个“人肉解释器”的角色,在大脑中把各个代码片段组合起来才能明白整个流程和细节,这无疑降低阅读代码的效率,bug也容易隐藏在各个代码片段的“缝隙”中。举个常见的例子:在图形界面应用中,用户登录后,弹出登录成功的提示,然后关闭登录页面。一种普通的实现是:


//1. 在登录按钮触发登录操作
loginButton.setOnClickListener(v -> controller.login(username, poassword))

//2. 在登录成功的回调中展示弹窗
public void onLoginSuccess(User user){
LoginSuccessDialog.show("login success")
}

//3. 在失败的回调中展示错误信息
public void onLoginFailed(String errorMsg){
MessageDialog.showMessage(errorMsg);
}

//4. 在LoginSuccessDialog确认后关闭页面
public void onLoginDialogConfirmed(){
loginPage.close();
}

看上去好像解耦很不错,但功能都变得七零八落。要拼凑出完整的功能大概得仔细阅读整段代码,更别提“一眼看出”了。那么一眼能看出的代码大概长啥样呢?我想,大概是这样:


loginButton.setOnClickListener(v -> {
controller.login(username, poassword)
.onSuccess(user ->
LoginSuccessDialog.总而言之,解耦需谨慎,不要因过度解耦而牺牲了内聚性和连贯性。show("login success")
.onConfirmed(() -> loginPage.close()))
.onFailed(errorMsg ->
MessageDialog.showMessage(errorMsg));
});

这是对该视图流程的一个连贯的描述,而且篇幅更短。至于获取到用户数据存储到本地数据库、通知其他页面更新等操作,跟当前视图没有关系,也就不需要放在这段代码里。总而言之,解耦需谨慎,不要因过度解耦而牺牲了内聚性和连贯性。


特征3:良好的表达


代码的篇幅得到控制后,要让人一眼看懂,还需要容易理解才行。设计心理学提出“设计传达所有必要的信息,创造一个良好的系统概念模型,引导用户理解系统状态,带来掌控感”。程序设计也是如此,代码是程序功能的文本表达,需要传达对应信息来让人产生该功能正确的概念模型。


以一个常见的上传图片的弹窗为例,思考一个菜单弹窗,包含取消和两个功能按钮:从相册选择和拍照上传,例如下图这样。


在这里插入图片描述


那么对应的代码可以表达为:


MenuDialog.create()
.withAction("拍照上传", dialog -> {
takePhoto();
})
.withAction("本地上传", dialog -> {
chooseFromGallery();
})
.onCancel(() -> {
//do something
})
.show();

或者


MenuDialog.create()
.withAction("拍照上传", controller::takePhoto)
.withAction("本地上传", controller::chooseFromGallery)
.onCancel(() -> {
//do something
})
.show();

没有多余的代码,该有的信息都表达到位,而且和实际功能有良好的对应关系。


4. 特征4:可验证正确性


代码可以让人一眼看懂之后,那么判断其有没有bug,还有一个重要前提:这个代码是有正确性可言的,可以被验证。


例如,来看下面这段随意的代码:


int type;
boolean isClosed;

void doSomething(String text) {
if (type == 0) {
if (isClosed) {
println(text);
} else {
error("something wrong");
}
} else if (type == 1) {
//do something
}
}

这段程序简短、易读,但是doSomething函数的行为依赖两个外部变量,而这两个外部变量又容易被其他地方随意改动。比如,type的定义域为1、2、3,但如果type新增类型4的时候或者被错误地赋值为-1的时候,这个doSomething函数的行为还是正确的吗?doSomething函数的正确性依赖于type变量的正确性,那么又依赖于读写type变量的程序的正确性,这样的程序是难以验证的。而且,对上下文依赖越多的程序,越难以产生明确的定义,因为这个定义也依赖上下文的定义。定义不明确,更难以验证内容的正确性。


相比之下,Objects.equalsObjects.isNull方法有着明确的定义,而且不受上下文影响,可以一眼就看出对错。而下面这段代码:


MenuDialog.create()
.withAction("拍照上传", controller::takePhoto)
.withAction("本地上传", controller::chooseFromGallery)
.onCancel(() -> {
//do something
})
.show();

表达明确,可以快速判断出程序行为是否正确、符合期望。即便MenuDialog出现异常,或者takePhotochooseFromGallery出了什么问题,也不需要来修改这段程序。


不过,程序验证是一个有点高深的科研方向,要严格验证一个程序的正确性是很困难的一件事,不过我们仍然可以试着去编写一些“看起来”正确的程序。(利用函数式编程思想写出来的代码通常容易验证一些)


作者:乐征skyline
来源:juejin.cn/post/7236010330051887164
收起阅读 »

室友打一把王者就学会了Java多线程

大家好,我是二哥呀。 对于 Java 初学者来说,多线程的很多概念听起来就很难理解。比方说: 进程,是对运行时程序的封装,是系统进行资源调度和分配的基本单位,实现了操作系统的并发。 线程,是进程的子任务,是 CPU 调度和分派的基本单位,实现了进程内部的并发...
继续阅读 »


大家好,我是二哥呀。


对于 Java 初学者来说,多线程的很多概念听起来就很难理解。比方说:



  • 进程,是对运行时程序的封装,是系统进行资源调度和分配的基本单位,实现了操作系统的并发。

  • 线程,是进程的子任务,是 CPU 调度和分派的基本单位,实现了进程内部的并发。


很抽象,对不对?打个比喻,你在打一把王者(其实我不会玩哈 doge):



  • 进程可以比作是你开的这一把游戏

  • 线程可以比作是你所选的英雄或者是游戏中的水晶野怪等之类的。


带着这个比喻来理解进程和线程的一些关系,一个进程可以有多个线程就叫多线程。是不是感觉非常好理解了?


进程和线程


❤1、线程在进程下进行


(单独的英雄角色、野怪、小兵肯定不能运行)


❤2、进程之间不会相互影响,主线程结束将会导致整个进程结束


(两把游戏之间不会有联系和影响。你的水晶被推掉,你这把游戏就结束了)


❤3、不同的进程数据很难共享


(两把游戏之间很难有联系,有联系的情况比如上把的敌人这把又匹配到了)


❤4、同进程下的不同线程之间数据很容易共享


(你开的那一把游戏,你可以看到每个玩家的状态——生死,也可以看到每个玩家的出装等等)


❤5、进程使用内存地址可以限定使用量


(开的房间模式,决定了你可以设置有多少人进,当房间满了后,其他人就进不去了,除非有人退出房间,其他人才能进)


创建线程的三种方式


搞清楚上面这些概念之后,我们来看一下多线程创建的三种方式:


继承 Thread 类


♠①:创建一个类继承 Thread 类,并重写 run 方法。


public class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(getName() + ":打了" + i + "个小兵");
}
}
}

我们来写个测试方法验证下:


//创建MyThread对象
MyThread t1=new MyThread();
MyThread t2=new MyThread();
MyThread t3=new MyThread();
//设置线程的名字
t1.setName("鲁班");
t2.setName("刘备");
t3.setName("亚瑟");
//启动线程
t1.start();
t2.start();
t3.start();

来看一下执行后的结果:



实现 Runnable 接口


♠②:创建一个类实现 Runnable 接口,并重写 run 方法。


public class MyRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {//sleep会发生异常要显示处理
Thread.sleep(20);//暂停20毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "打了:" + i + "个小兵");
}
}
}

我们来写个测试方法验证下:


//创建MyRunnable类
MyRunnable mr = new MyRunnable();
//创建Thread类的有参构造,并设置线程名
Thread t1 = new Thread(mr, "张飞");
Thread t2 = new Thread(mr, "貂蝉");
Thread t3 = new Thread(mr, "吕布");
//启动线程
t1.start();
t2.start();
t3.start();

来看一下执行后的结果:



实现 Callable 接口


♠③:实现 Callable 接口,重写 call 方法,这种方式可以通过 FutureTask 获取任务执行的返回值。


public class CallerTask implements Callable<String> {
public String call() throws Exception {
return "Hello,i am running!";
}

public static void main(String[] args) {
//创建异步任务
FutureTask<String> task=new FutureTask<String>(new CallerTask());
//启动线程
new Thread(task).start();
try {
//等待执行完成,并获取返回结果
String result=task.get();
System.out.println(result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}

关于线程的一些疑问


❤1、为什么要重写 run 方法?


这是因为默认的run()方法不会做任何事情。


为了让线程执行一些实际的任务,我们需要提供自己的run()方法实现,这就需要重写run()方法。


public class MyThread extends Thread {
public void run() {
System.out.println("MyThread running");
}
}

在这个例子中,我们重写了run()方法,使其打印出一条消息。当我们创建并启动这个线程的实例时,它就会打印出这条消息。


❤2、run 方法和 start 方法有什么区别?



  • run():封装线程执行的代码,直接调用相当于调用普通方法。

  • start():启动线程,然后由 JVM 调用此线程的 run() 方法。


❤3、通过继承 Thread 的方法和实现 Runnable 接口的方式创建多线程,哪个好?


实现 Runable 接口好,原因有两个:



  • ♠①、避免了 Java 单继承的局限性,Java 不支持多重继承,因此如果我们的类已经继承了另一个类,就不能再继承 Thread 类了。

  • ♠②、适合多个相同的程序代码去处理同一资源的情况,把线程、代码和数据有效的分离,更符合面向对象的设计思想。Callable 接口与 Runnable 非常相似,但可以返回一个结果。


控制线程的其他方法


针对线程控制,大家还会遇到 3 个常见的方法,我们来一一介绍下。


1)sleep()


使当前正在执行的线程暂停指定的毫秒数,也就是进入休眠的状态。


需要注意的是,sleep 的时候要对异常进行处理。


try {//sleep会发生异常要显示处理
Thread.sleep(20);//暂停20毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}

2)join()


等待这个线程执行完才会轮到后续线程得到 cpu 的执行权,使用这个也要捕获异常。


//创建MyRunnable类
MyRunnable mr = new MyRunnable();
//创建Thread类的有参构造,并设置线程名
Thread t1 = new Thread(mr, "张飞");
Thread t2 = new Thread(mr, "貂蝉");
Thread t3 = new Thread(mr, "吕布");
//启动线程
t1.start();
try {
t1.join(); //等待t1执行完才会轮到t2,t3抢
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.start();
t3.start();

来看一下执行后的结果:



3)setDaemon()


将此线程标记为守护线程,准确来说,就是服务其他的线程,像 Java 中的垃圾回收线程,就是典型的守护线程。


//创建MyRunnable类
MyRunnable mr = new MyRunnable();
//创建Thread类的有参构造,并设置线程名
Thread t1 = new Thread(mr, "张飞");
Thread t2 = new Thread(mr, "貂蝉");
Thread t3 = new Thread(mr, "吕布");

t1.setDaemon(true);
t2.setDaemon(true);

//启动线程
t1.start();
t2.start();
t3.start();

如果其他线程都执行完毕,main 方法(主线程)也执行完毕,JVM 就会退出,也就是停止运行。如果 JVM 都停止运行了,守护线程自然也就停止了。


小结


本文主要介绍了 Java 多线程的创建方式,以及线程的一些常用方法。最后再来看一下线程的生命周期吧,一图胜千言。



好了,如果你想学好 Java,GitHub 上标星 10000+ 的《二哥的 Java 进阶之路》不容错过,据说每一个优秀的 Java 程序员都喜欢她,风趣幽默、通俗易懂。内容包括 Java 基础、Java 并发编程、Java 虚拟机、Java 企业级开发(Git、Nginx、Maven、Intellij IDEA、Spring、Spring Boot、Redis、MySql 等等)、Java 面试等核心知识点。学 Java,就认准二哥的 Java 进阶之路😄。


Github 仓库:github.com/itwanger/to…


码云仓库(国内访问更快):gitee.com/itwanger/to…


star 了这个仓库就等于你拥有了成为了一名优秀 Java 工程师的潜力。



把二哥的座右铭送给你:没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟


作者:沉默王二
来源:juejin.cn/post/7329413905028186124
收起阅读 »

眼看他搭中台,眼看他又拆了

曾几何时,中台一度被当做“变革灵药”,嫁接在“前台作战单元”和“后台资源部门”之间,实现企业各业务线的“打通”和全域业务能力集成,提高开发和服务效率。但在中台如火如荼之际,我们可以发现各大企业又在反其道而行,纷纷不断进行“拆中台”,那么中台对于企业而言,究竟发...
继续阅读 »

曾几何时,中台一度被当做“变革灵药”,嫁接在“前台作战单元”和“后台资源部门”之间,实现企业各业务线的“打通”和全域业务能力集成,提高开发和服务效率。但在中台如火如荼之际,我们可以发现各大企业又在反其道而行,纷纷不断进行“拆中台”,那么中台对于企业而言,究竟发挥了哪些作用,当前又出现了哪些问题?今天,我们特邀了高级研发管理专家、腾讯云 TVP 程超老师,他将从搭中台到拆中台的风向转变,探讨企业软件架构的底层逻辑。



中台都在忽悠吗?都被忽悠瘸了?我们都在悄悄淘汰中台,你们还在建?最近网上充斥大量文章和观点,都在说中台过时。为什么会这样说?是因为成本与复杂性?技术限制与业务变化?还是因为组织变化?为什么会这样呢?且听我一一分析。


众所周知,中台是指企业内部的中间层平台,负责连接上下游系统,提供数据和功能服务。而在过去几年中台概念曾经风靡一时,甚至被认为是企业数字化转型的关键。然而,近年来,一些企业确实出现了对中台战略的重新评估,不再像之前那样盲目地追求中台建设。其实,中台的概念兴起于企业数字化转型的浪潮中,企业开始意识到传统的前台系统(如客户端应用)与后台系统(如企业资源规划系统)之间的断层,而中台则被认为是弥合这种断层的理想方式。


值得一提的是,关于中台的定义,业内大佬也曾经发表过一些观点:


提炼各个业务条线的共性需求,并将这些打造成组件化的资源/能力包,然后以接口的形式提供给前台各业务部门使用,这样就可以最大限度地避免“重复造轮子”的问题,也让每一个新的前台业务创新能够真正意义上“站在巨人的肩膀上”,而不用每次开辟一个新业务都像新建一家创业公司那么艰难,甚或更为艰难。——某企业资深架构师 钟华


总结而言,中台的核心点主要有以下三个:



  • 中台是为前台而生。

  • 提炼各业务条线的共性需求。

  • 减少“重复造轮子”的时间与资源浪费。


01四大层面解读中台备受追捧原因


2015年,业界首次提出“大中台、小前台”战略,是想打造统一技术架构、产品支撑体系、数据共享平台、安全体系等等,把整个组织“横”过来,支撑多种多样的业务形态。中台似乎已经成为行业标配,稍有规模的公司都建设了自己的中台,掀起了一股强劲的中台风。


中台能够解决哪些问题呢?在我看来,主要有以下四种:



  • 项目重复造轮子严重,无法形成抽象共用


中台提供了一种在企业内部建立统一的技术平台或者服务平台的模式。这个平台可以被不同部门或者项目共享和复用,从而减少了重复开发的情况。随着新业务的不断接入,共享服务也从仅能提供单一的业务功能,不断的自我进化成更健壮更强大的服务,不断适应各种业务线的新需求。同时在数据积累方面,通过数据中台将各业务的数据都沉淀下来,不断地积累数据,发挥数据的最大威力。



  • 业务变化快,缓慢的研发流程难以迅速响应


很多企业开发响应慢,其实大部分都是因为数据问题,没有做到实时、准确和统一。比如一家公司的订单,分为 C 端订单,B 端订单,共享单车订单等等,这些订单分管在不同部门中,想要做订单统计、预测等就比较困难,各类型订单彼此割裂,而如果企业只有一个订单中心的话,数据就能够在不同场景下感知到业务的变化和联动。



  • 提高资源利用率和研发效率



说起如何提高资源利用率和研发效率,我总结为中台建设五步法:插件化、服务化、配置化、异步化和数据化。这五步环环相扣,其中插件化就是提高研发效率的关键点,我们将对核心交易流程进行抽象建模设计,并通过流程引擎的改造,实现增加多个插件和扩展点。这样,不同的业务场景可以根据需求自定义其个性化逻辑,将整个交易环节抽象为一个流程框架,并在其基础上引入一系列业务扩展。这种设计使得各业务间互不干扰,更灵活地满足各自需求。


提高资源利用率,这也是必然的,服务、数据、组件等形成统一复用,各资源也不再分散,只需通过一套服务来做支撑,并且可以通过各业务线的忙闲情况,做资源的调控、比如某个业务线使用交易中台服务,高峰时期是在早上8点到晚上12点,凌晨以后基本没有业务量,则可以考虑把针对这个业务线的资源配置降低,从而实现降本增效。



  • 提高系统稳定性和可靠性


一般来说系统的故障由三个方面引起,系统 bug、变更配置、并发流量变化。而技术中台避免了各个部门为解决自身技术问题而随意修改系统设置和配置的情况,这样做有助于防止整个系统因为随意修改而出现不稳定和安全问题。


02拆分中台并非全盘否定中台


前面我主要介绍了中台能解决哪些问题,但其实很多企业在实际引入中台的过程中,也遇到了很多问题:



  • 中台与前台的边界模糊


很多前台的业务让中台接管开发,到底是接还是不接?中台的角色和范围缺乏明确界定,导致中台与业务之间的责任划分模糊不清,引发了重复建设、资源浪费和沟通成本等问题。



  • 稳定性与灵活性的冲突


稳定与灵活一直是个矛盾体,中台接入的业务线非常多,一旦出问题影响面巨大,代码质量如何把控、上线流程如何稳定、业务如何做好隔离,都需要考虑清楚。



  • 沟通障碍与目标差异


协调中台团队和业务团队之间的沟通和合作,平衡双方的需求和利益,以及处理中台和业务之间的依赖和变更,都是一项复杂的管理任务。



  • 中台规划与业务需求之间的平衡


中台的服务需求和响应之间存在不匹配,这导致中台无法满足业务的多样化和个性化需求。有时中台过度迎合业务的短期需求,却牺牲了其长期规划和可持续发展。



  • 利益分配


距离业务近的地方,比距离业务远的地方更能得到公司增长的成果,中台看似业务,其实只是沉淀,追求的是稳定和灵活。还有业务下沉的时候,会涉及到与中台的业务交接,前台业务必定会减少。如果是部门划到中台,是否会有人员变动?当中台的服务价值和收益缺乏清晰界定,将难以有效衡量自身的贡献和影响。


综上,中台看似很美好,但很多企业在实际落地的时候却因为遇到这些问题,导致陷入困境,中台建设越建越复杂,甚至有些企业对中台也逐渐失去了信心,反而成了阻碍企业发展的瓶颈。


近两年业界开始风行“拆中台”策略——将中台变“薄”,拆分到多个独立的业务单元。这使得很多企业又开始认为中台已成明日黄花,引进中台并不是一个好选择,甚至有些企业将自身发展不顺的原因也归在了中台上面,一时间中台被全盘否定了。


我个人则认为拆分中台并非全盘否定中台,而是基于自身发展阶段和市场环境的变化进行战略调整和优化。“天下大事,合久必分,分久必合”,这就意味着在中台的管理和战略中,必须根据具体情况来做出分合的决策。有时候,将中台进行分散管理或者分解成更小的部分可能更为合适,因为这样有助于更好地满足各个业务单位的需求,提高灵活性和适应性。互联网大厂们将庞大而僵化的共享中台重新组织为灵活的业务域中台,可以更好适应具体业务场景和用户需求,既能保留中台提供通用能力和协同效率的优势,又能增加中台的灵活性和个性化。


03企业应该因地制宜选择是否需要中台


首先,我想强调的是,“中台”本身并不是一个新的架构思想,这个架构思想早在若干年以前就已经有了,很多企业已经是这么做了,就像面向对象编程语言中(Java)高内聚,低耦合,便是这种思想。


当企业处在初创期,随着业务发展产生多条业务线或产品线的时候,就会面临协同方面的挑战,如果每条业务线都要自己成立技术、运维、数据等部门,这样显然是非常浪费人力和资源的。为了适应快速发展的业务,就需要成立中台部门,来抽取、复用共性的东西,形成统一,这样既能满足“小前台,大中台”策略,让业务快跑抢占市场,中台提供稳定的炮火支援,又能提高协同和研发效率。参考示意图如下:



当企业已经渡过初创期,发展已经具有较大规模时,各条业务线人员和业务场景也比初创时更加庞大和复杂,企业了将面临更加多样化的市场,以及强大的响应能力,甚至每条业务线都要独立去创新,这样统一的中台部门就会变成瓶颈,人员、响应时间、需求变化和沟通等都会成为阻碍多样化需求的绊脚石。这时候企业就需要根据市场需要,将庞大而僵化的大共享中台,拆分到各业务单元中,将中台下沉到各业务单元中,这样既能保留中台的通用和协同能力,又能针对具体业务和场景不断增加灵活性和定制性。参考示意图如下:



总而言之,中台不是一直不变的,它需要根据市场需求不断进化,演变成能够满足当前企业市场需要的形态。中台不是万能的,它只是企业数字化转型的一种重要实现路径,我们不能对中台有过高的期望,而是应该理性地回归到企业数字化转型的价值上来。


作者简介


程超,腾讯云 TVP,高级研发管理专家,14年 Java 研发经验,8年技术管理和架构经验,曾任京东架构师,易宝支付和松果出行架构技术负责人,熟悉支付和电商领域,擅长微服务生态建设和运维监控,对 Dubbo、Spring Cloud 和 gRPC 等微服务框架有深入研究,并应用于项目,帮助过多家公司进行过微服务建设和改造,目前正在建设业务中台。 合著作品《深入分布式缓存》和《高可用可伸缩微服务架构》,极客时间每日一课讲师和出品人,CSDN 博主专家。


作者:腾讯云开发者
来源:juejin.cn/post/7366175769602932755
收起阅读 »

如果按代码量算工资,也许应该这样写

前言 假如有一天我们要按代码量来算工资,那怎样才能写出一手漂亮的代码,同时兼顾代码行数和实际意义呢? 要在增加代码量的同时提高代码质量和可维护性,能否做到呢? 答案当然是可以,这可难不倒我们这种摸鱼高手。 耐心看完,你一定有所收获。 正文 1. 实现更多的...
继续阅读 »

前言


假如有一天我们要按代码量来算工资,那怎样才能写出一手漂亮的代码,同时兼顾代码行数和实际意义呢?


要在增加代码量的同时提高代码质量和可维护性,能否做到呢?


答案当然是可以,这可难不倒我们这种摸鱼高手。


耐心看完,你一定有所收获。


giphy.gif


正文


1. 实现更多的接口:


给每一个方法都实现各种“无关痛痒”的接口,比如SerializableCloneable等,真正做到不影响使用的同时增加了相当数量的代码。


为了这些代码量,其中带来的性能损耗当然是可以忽略的。


public class ExampleClass implements Serializable, Comparable<ExampleClass>, Cloneable, AutoCloseable {

@Override
public int compareTo(ExampleClass other) {
// 比较逻辑
return 0;
}

// 实现 Serializable 接口的方法
private void writeObject(ObjectOutputStream out) throws IOException {
// 序列化逻辑
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
// 反序列化逻辑
}

// 实现 Cloneable 接口的方法
@Override
public ExampleClass clone() throws CloneNotSupportedException {
// 复制对象逻辑
return (ExampleClass) super.clone();
}

// 实现 AutoCloseable 接口的方法
@Override
public void close() throws Exception {
// 关闭资源逻辑
}

}


除了示例中的SerializableComparableCloneableAutoCloseable,还有Iterable


2. 重写 equals 和 hashcode 方法


重写 equalshashCode 方法绝对是上上策,不仅增加了代码量,还为了让对象在相等性判断和散列存储时能更完美的工作,确保代码在处理对象相等性时更准确、更符合业务逻辑。


public class ExampleClass {
private String name;
private int age;

// 重写 equals 方法
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}

if (obj == null || getClass() != obj.getClass()) {
return false;
}

ExampleClass other = (ExampleClass) obj;
return this.age == other.age && Objects.equals(this.name, other.name);
}

// 重写 hashCode 方法
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}


giphy (2).gif


3. 增加配置项和参数:


不要管能不能用上,梭哈就完了,问就是为了健壮性和拓展性。


public class AppConfig {
private int maxConnections;
private String serverUrl;
private boolean enableFeatureX;

// 新增配置项
private String emailTemplate;
private int maxRetries;
private boolean enableFeatureY;

// 写上构造函数和getter/setter
}

4. 增加监听回调:


给业务代码增加监听回调,比如执行前、执行中、执行后等各种Event,这里举个完整的例子。


比如创建个 EventListener ,负责监听特定类型的事件,事件源则是产生事件的对象。通过EventListener 在代码中增加执行前、执行中和执行后的事件。


首先,我们定义一个简单的事件类 Event


public class Event {
private String name;

public Event(String name) {
this.name = name;
}

public String getName() {
return name;
}
}

然后,我们定义一个监听器接口 EventListener


public interface EventListener {
void onEventStart(Event event);

void onEventInProgress(Event event);

void onEventEnd(Event event);
}

接下来,我们定义一个事件源类 EventSource,在执行某个业务方法时,触发事件通知:


public class EventSource {
private List<EventListener> listeners = new ArrayList<>();

public void addEventListener(EventListener listener) {
listeners.add(listener);
}

public void removeEventListener(EventListener listener) {
listeners.remove(listener);
}

public void businessMethod() {
Event event = new Event("BusinessEvent");

// 通知监听器:执行前事件
for (EventListener listener : listeners) {
listener.onEventStart(event);
}

// 模拟执行业务逻辑
System.out.println("Executing business method...");

// 通知监听器:执行中事件
for (EventListener listener : listeners) {
listener.onEventInProgress(event);
}

// 模拟执行业务逻辑
System.out.println("Continuing business method...");

// 通知监听器:执行后事件
for (EventListener listener : listeners) {
listener.onEventEnd(event);
}
}
}

现在,我们可以实现具体的监听器类,比如 BusinessEventListener,并在其中定义事件处理逻辑:


public class BusinessEventListener implements EventListener {
@Override
public void onEventStart(Event event) {
System.out.println("Event Start: " + event.getName());
}

@Override
public void onEventInProgress(Event event) {
System.out.println("Event In Progress: " + event.getName());
}

@Override
public void onEventEnd(Event event) {
System.out.println("Event End: " + event.getName());
}
}

最后,我们写个main函数来演示监听事件:


public class Main {
public static void main(String[] args) {
EventSource eventSource = new EventSource();
eventSource.addEventListener(new BusinessEventListener());

// 执行业务代码,并触发事件通知
eventSource.businessMethod();

// 移除监听器
eventSource.removeEventListener(businessEventListener);
}
}

如此这般那般,代码量猛增,还顺带实现了业务代码的流程监听。当然这只是最简陋的实现,真实环境肯定要比这个复杂的多。


5. 构建通用工具类:


同样的,甭管用不用的上,定义更多的方法,都是为了健壮性。


比如下面这个StringUtils,可以从ApacheCommons、SpringBoot的StringUtil或HuTool的StrUtil中拷贝更多的代码过来,美其名曰内部工具类。


public class StringUtils {
public static boolean isEmpty(String str) {
return str == null || str.trim().isEmpty();
}

public static boolean isBlank(String str) {
return str == null || str.trim().isEmpty();
}

// 新增方法:将字符串反转
public static String reverse(String str) {
if (str == null) {
return null;
}
return new StringBuilder(str).reverse().toString();
}

// 新增方法:判断字符串是否为整数
public static boolean isInteger(String str) {
try {
Integer.parseInt(str);
return true;
} catch (NumberFormatException e) {
return false;
}
}
}

6. 添加新的异常类型:


添加更多异常类型,对不同的业务抛出不同的异常,每种异常都要单独去处理


public class CustomException extends RuntimeException {
// 构造函数
public CustomException(String message) {
super(message);
}

// 新增异常类型
public static class NotFoundException extends CustomException {
public NotFoundException(String message) {
super(message);
}
}

public static class ValidationException extends CustomException {
public ValidationException(String message) {
super(message);
}
}
}

// 示例:添加不同类型的异常处理
public class ExceptionHandling {
public void process(int value) {
try {
if (value < 0) {
throw new IllegalArgumentException("Value cannot be negative");
} else if (value == 0) {
throw new ArithmeticException("Value cannot be zero");
} else {
// 正常处理逻辑
}
} catch (IllegalArgumentException e) {
// 异常处理逻辑
} catch (ArithmeticException e) {
// 异常处理逻辑
}
}
}


7. 实现更多设计模式:


在项目中运用更多设计模式,也不失为一种合理的方式,比如单例模式、工厂模式、策略模式、适配器模式等各种常用的设计模式。


比如下面这个单例,大大节省了内存空间,虽然它存在线程不安全等问题。


public class SingletonPattern {
// 单例模式
private static SingletonPattern instance;

private SingletonPattern() {
// 私有构造函数
}

public static SingletonPattern getInstance() {
if (instance == null) {
instance = new SingletonPattern();
}
return instance;
}

}

还有下面这个策略模式,能避免过多的if-else条件判断,降低代码的耦合性,代码的扩展和维护也变得更加容易。


// 策略接口
interface Strategy {
void doOperation(int num1, int num2);
}

// 具体策略实现类
class AdditionStrategy implements Strategy {
@Override
public void doOperation(int num1, int num2) {
int result = num1 + num2;
System.out.println("Addition result: " + result);
}
}

class SubtractionStrategy implements Strategy {
@Override
public void doOperation(int num1, int num2) {
int result = num1 - num2;
System.out.println("Subtraction result: " + result);
}
}

// 上下文类
class Context {
private Strategy strategy;

public Context(Strategy strategy) {
this.strategy = strategy;
}

public void executeStrategy(int num1, int num2) {
strategy.doOperation(num1, num2);
}
}

// 测试类
public class StrategyPattern {
public static void main(String[] args) {
int num1 = 10;
int num2 = 5;

// 使用加法策略
Context context = new Context(new AdditionStrategy());
context.executeStrategy(num1, num2);

// 使用减法策略
context = new Context(new SubtractionStrategy());
context.executeStrategy(num1, num2);
}
}

对比下面这段条件判断,高下立判。


public class Calculator {
public static void main(String[] args) {
int num1 = 10;
int num2 = 5;
String operation = "addition"; // 可以根据业务需求动态设置运算方式

if (operation.equals("addition")) {
int result = num1 + num2;
System.out.println("Addition result: " + result);
} else if (operation.equals("subtraction")) {
int result = num1 - num2;
System.out.println("Subtraction result: " + result);
} else if (operation.equals("multiplication")) {
int result = num1 * num2;
System.out.println("Multiplication result: " + result);
} else if (operation.equals("division")) {
int result = num1 / num2;
System.out.println("Division result: " + result);
} else {
System.out.println("Invalid operation");
}
}
}


8. 扩展注释和文档:


如果要增加代码量,写更多更全面的注释也不失为一种方式。


/**
* 这是一个示例类,用于展示增加代码数量的技巧和示例。
* 该类包含一个示例变量 value 和示例构造函数 ExampleClass(int value)。
* 通过示例方法 getValue() 和 setValue(int newValue),可以获取和设置 value 的值。
* 这些方法用于展示如何增加代码数量,但实际上并未实现实际的业务逻辑。
*/

public class ExampleClass {

// 示例变量
private int value;

/**
* 构造函数
*/

public ExampleClass(int value) {
this.value = value;
}

/**
* 获取示例变量 value 的值。
* @return 示范变量 value 的值
*/

public int getValue() {
return value;
}

/**
* 设置示例变量 value 的值。
* @param newValue 新的值,用于设置 value 的值。
*/

public void setValue(int newValue) {
this.value = newValue;
}
}

结语


哪怕是以代码量算工资,咱也得写出高质量的代码,合理合法合情的赚票子。


giphy (1).gif


作者:一只叫煤球的猫
来源:juejin.cn/post/7263760831052906552
收起阅读 »

基于SSE的实时消息推送

背景 小盟 AI 助手项目中需要服务端把 AI 模型回调回来内容,实时推送到客户端,展示给用户;整个流程需要一个能够快速支持上线的服务端推送方案。经过对现有的一些服务端推送方案进行调研,并结合项目的周期、实现成本、用户体验等多方综合考量,最终选择了 ...
继续阅读 »



背景


小盟 AI 助手项目中需要服务端把 AI 模型回调回来内容,实时推送到客户端,展示给用户;整个流程需要一个能够快速支持上线的服务端推送方案。经过对现有的一些服务端推送方案进行调研,并结合项目的周期、实现成本、用户体验等多方综合考量,最终选择了 Server-Sent Events(SSE)方案进行实践。


首先服务端推送,是一种允许应用服务器主动将信息发送到客户端的能力,为客户端提供了实时的信息更新和通知,增强了用户体验。


服务端推送主要基于以下几个诉求:


(1)实时通知:在很多情况下,用户期望实时接收到应用的通知,如新消息提醒、活动提醒等。


(2)节省资源:如果没有服务端推送,客户端需要通过轮询的方式来获取新信息,会造成客户端、服务端的资源损耗。通过服务端推送,客户端只需要在收到通知时做出响应,大大减少了资源的消耗。


(3)增强用户体验:通过服务端推送,应用可以针对特定用户或用户群发送有针对性的内容,如优惠活动、个性化推荐等。这有助于提高用户对应用的满意度和黏性。


方案对比



轮询: 是一种较为传统的方式,客户端定时地向服务端发送请求,询问是否有新数据。服务端只需要检查数据状态,然后将结果返回给客户端。轮询的优点是实现简单,兼容性好;缺点是可能产生较大的延迟,且对服务端资源消耗较高。


长轮询(Long Polling): 轮询的改进版。客户端向服务器发送请求,服务器收到请求后,如果有新的数据,立即返回给客户端;如果没有新数据,服务器会等待一定时间(比如30秒超时时间),在这段时间内,如果有新数据,就返回给客户端,否则返回空数据。客户端处理完服务器返回的响应后,再次发起新的请求,如此反复。长轮询相较于传统的轮询方式减少了请求次数,但仍然存在一定的延迟。   


WebSocket: 一种双向通信协议,同时支持服务端和客户端之间的实时交互。WebSocket 是基于 TCP 的长连接,和HTTP 协议相比,它能实现轻量级的、低延迟的数据传输,非常适合实时通信场景,主要用于交互性强的双向通信。


SSE: 是一种基于 HTTP 协议的推送技术。服务端可以使用 SSE 来向客户端推送数据,但客户端不能通过 SSE 向服务端发送数据。相较于 WebSocket,SSE 更简单、更轻量级,但只能实现单向通信。


图片


图片


小盟 AI 助手项目需要快速上线且保证要用户较好的使用体验。鉴于 SSE 技术的轻量、实现简单、不增加额外的资源成本;当前业务场景也只需要服务端到用户端的单向的字符推送。非常适合项目需要。所以决定使用 SSE 来实现内容推送。****


深入 SSE



SSE 服务端推送,它基于 HTTP 协议,易于实现和部署,特别适合那些需要服务器主动推送信息、客户端只需接收数据的场景。有以下特点:


1、简单:基于 HTTP,无须额外的协议或者类库支持。主流浏览器都支持。


2、事件流:使用"事件流"(Event Stream)将数据从服务器发送到客户端。每个事件都可以包含一个事件标识符、事件类型和数据字段。客户端可以根据这些信息来解析和处理接收到的数据。


3、自动重连:意外断开时会自动尝试重新连接。可以确保了在网络故障或连接中断后能够及时恢复通信,为用户提供连续的数据流。重连时会在 HTTP 头中的Last_Event_ID 带上上一次的数据 ID,便于服务端返回后续数据。


4、单向推送:只能从服务端推送数据到客户端。


图片


SSE 消息体介绍:


图片


SSE消息体示例:


图片


服务端主要使用 Spring,其对 SSE 主要提供了两种支持:



  • Spring WebMVC:传统的基于 Servlet 的同步阻塞编程模型,即 同步模型 Web 框架。

  • Spring WebFlux:异步非阻塞的响应式编程模型,即 异步模型 Web 框架。          


项目基于springboot,所以选择使用前者实现。SseEmitter emitter = new SseEmitter(); 一句代码就可以建立一个 SSE 连接。


实践



后端实现


建立一个SseEmitterManger,统一管理当前服务 SSE 连接的创建、释放以及数据推送。结合 Redis 缓存可实现集群环境 SSE 连接的管理。


核心逻辑如下:



  • 连接池维护,设定一个上限,避免过大,导致内存问题。


static final Map<String, SseEmitter> sseCache =     new ConcurrentHashMap<>(300)          


  • 建立SSE连接,为每个连接建立一个唯一的MsgId,用来维护SSE连接与客户端的关系了;在 Redis 缓存中存入MsgId和当前机器节点的IP和Port,这样可以找到SSE 连接所在的服务结点,然后通过 HTTP 请求转发需要发送的数据到对应的服务节点上进行处理。


sse = new SseEmitter()sseId = "sse_xxx";redisKey= "aisse:" + bosId + "_" + wid ipPort = "10.10.10.10:8080"redis.hset(redisKey, msgId, ipPort)sseCache.put(msgId, sseEmitter);


  • 获取持有连接的 pod ipPort;根据 IP 发起请求。


ipPort = redisUtil.hashGet(redisKey, msgId)


  • 获取当前服务结点的SSE连接,发送数据。


sseEmitter = sseCache.get(msgId)sseEmitter.send(msgJson)          


  • 释放SSE连接


SseEmitter sseEmitter = sseCache.get(msgId);sseEmitter.complete();sseCache.remove(msgId);redisUtil.hashDel(redisKey, msgId);

**核心流程图如下: **  


图片


需要注意的是开启 SSE 连接接口的整个链路都要支持长连接。例如使用 Nginx 则要开启长连接的配置:



  • keepalive 用于控制可连接整个 upstream servers 的 HTTP 长连接的个数,即控制总数。

  • proxy_http_verion 用于控制代理后端链接时使用的 HTTP 版本,默认为 1.0。要想使用长连接,必须配置为 1.1。

  • proxy_set_header 需要设置为 Connection "",否则则发往 upstream servers 的请求中,Connection header 的值将为close,导致无法建立长连接。   


http {        upstream keepAliveService {            server 10.10.131.149:8080;            keepalive 20;        }            server {            listen 80;            server_name keepAliveService;            location /keep-alive/hello {                proxy_http_version 1.1;                proxy_set_header Connection "";                proxy_pass http://keepAliveService;            }        }}

**前端实现 **  


前端可以使用组件 @microsoft/fetch-event-source 来实现。


npm i @microsoft/fetch-event-source
import { fetchEventSource } from '@microsoft/fetch-event-source';let controller = new AbortController(); let eventSource = fetchEventSource('apiUrl', { method: 'POST', headers: { 'Content-Type': 'application/json', 'token': '....' }, signal: controller.signal, body: JSON.stringify({ ... // 传参 }), onopen() { // 建立连接 }, onmessage(event) { // 接收信息 // 成功之后满足某些条件可以使用AbortController关闭连接 controller.abort() eventSource?.close && eventSource.close(); }, onerror() { // 服务异常 controller.abort() eventSource?.close && eventSource.close(); }, onclose() { // 服务关闭 },})

总结



SSE 轻量级的服务端单向推送技术;具有支持跨域、使用简单、支持自动重连等特点。相对于 WebSocket 更加轻量级,如果需求场景客户端和服务端单向通信,那么 SSE 是一个不错的选择。


作者:微盟技术中心
来源:juejin.cn/post/7317325043541032970
收起阅读 »

从密码到无密码:账号安全进化史(科普向)

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究! 本文是一篇科普文,五一结束了,大家看点轻松的~ 不知道大家在过去半年有没有发现 Github 强制开启了 2FA,而且还不可以关闭的,每次你打开 github 都会提...
继续阅读 »

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!




本文是一篇科普文,五一结束了,大家看点轻松的~


不知道大家在过去半年有没有发现 Github 强制开启了 2FA,而且还不可以关闭的,每次你打开 github 都会提醒你的验证:


Image.png


简单的说,就是打开 Github 进行验证时,只依靠密码验证已经不被允许,你必须打开你手机上的验证软件,把里面随机码输入到 Github 才能完成身份验证,类似于十年前国内 QQ 安全中心的验证。


这是一种双重验证的手段,用于更好的保证我们的账号安全,今天就以此为引,给大家讲讲账号安全相关发展的历史。


第一幕:密码的独角戏 - 脆弱的防线


在互联网的蛮荒时代,密码就像原始人手中的木棍和石头,是守护账号安全的唯一屏障。然而,这道防线却是脆弱不堪的,面对黑客的攻击,如同纸糊的老虎,一戳就破。暴-力-破-解、字-典-攻-击、社会-工程-学手段,都足以让密码这道防线形同虚设。


1. 暴-力-破-解:暴-力-破-解就是通过遍历的方式尝试出你的密码组合,比如银行的六位取款密码实际上只有 46656种组合,利用现在任何一台电脑或者手机的算力都能瞬间算出来,为了对应这种情况,现在几乎所有网站都有密码输入次数限制。


2. 字-典-攻-击:字典-攻击就是利用常用密码来攻破你的密码,比暴力破解效率更好,比如 123456 这个密码就有很多人使用。


3. 社会-工程-学:社会-工程-学说人话就是套你的话,或者调查你的信息,比如在和你沟通的过程中知道了你的手机号、身-份-证号码、生日信息等,因为有大量的人用手机号后六位、身-份-证号后六位或者生日当作自己的密码,所以这种手段的成功率一般会更高。


在当前这个时代,由于互联网各种 App 的涌入,每个人都拥有大量的账号,如何记忆他们成为了一个难题,大量的人选择对所有网站使用同一个密码,这就又造成了账号安全问题。


重复使用密码就像是使用同一把钥匙开启不同的门,一旦一把钥匙被复制,所有的门都将面临危险。


每年,全球都会发生无数起数据泄露事件,大量的用户名和密码被公开曝光。这些泄露的密码成为了黑客攻击的利器,他们可以利用这些密码进行撞库攻击,尝试登录其他网站。


会在不同的网站使用相同的密码将会导致“一损俱损”的局面。一旦一个网站发生数据泄露,黑客便可以利用泄露的密码尝试登录其他网站,从而获取更多个人信息,造成更大的损失。


第二幕:多因素认证 (MFA) 登场 - 多重关卡,层层设防


所以,为了弥补密码的不足,MFA 应运而生,为账号安全加装了多重门锁。除了密码这把“钥匙”,你还需要其他的“通关密语”才能进入:



  • 验证码: 这是国内最常用的方式,甚至几乎所有 App 都已经不需要你记忆账号密码,只需要一个手机验证码即可,国外使用手机验证码的很少,因为他们更多使用邮箱来注册账号,比如我现在在使用的编辑软件Craft 在登录时就要求你提供邮箱验证码,它甚至不能设置密码。

  • 指纹识别: 你的指纹独一无二,所以它就像是你的专属的“魔法印记”,轻轻一按,就能保证你是你。

  • 面部识别: 对着摄像头眨眨眼,你的面容信息也是你的专属印记,苹果手机上甚至使用了虹膜识别来检测你是你,而不是别人。

  • 安全令牌: 一个小巧的硬件设备,可以生成一次性密码,就像古代的“虎符”一样,只有拥有它才能调兵遣将。令牌可以有软件和硬件两种方式,软件就是 Google Auth 这种软件,而硬件则是我们早古互联网时代网上购物常用的网银 U 盾形式。


在开头的时候,我曾提到了 2FA,它和本节的 MFA 听名字非常相似,实际上说的也几乎是一个东西。


2FA 是指:需要两种验证,才能完成整个验证,一般是密码和动态安全令牌。


MFA 是指:需要两种或以上验证方式,才能完成整个验证,一般也是密码和动态安全令牌。


所以在大多数语境下,这俩说的其实是一个东西,有些验证方式将两个验证方式合而为一,比如手机/邮箱验证码。


因为多因子验证的核心是:一个你知道的凭证 和 一个你刚刚才知道的凭证。


我们一开始就知道的凭证往往是邮箱 + 密码,一个刚刚才会知道的凭证往往就是动态安全令牌码了,所以手机验证码登录的方式也是 2FA,还是属于比较方便的那种。


注:我这里说的手机验证码登录是真的发给你验证码,而不是国内的那种手机号一键登录。


第三幕:单点登录 (SSO) 崛起 - 统一管理的钥匙


其实随着 MFA 的出现,安全问题已经不需要太担心了,所以接下来账号安全开始朝着:安全 + 高效的方向开始发展,所以开始出现了 SSO。


SSO 的第一个阶段是内部互信,它的概念最早可以追溯到 1990 年代,随着企业内部网络的发展而兴起。


后来随着互联网的发展,一个公司往往同时拥有多个业务,比如十年前还是百度的天下的时候,我们会同时使用百度知道、百度贴吧、百度网盘这些产品。


你只需要在某一个百度旗下的产品登录一次,打开其他产品的时候往往也会自动识别到你的账号。


比如你在百度贴吧登录了,此时你打开网页版的百度网盘你自动就是已登录状态。


不要以为这是一个 So easy 的操作,它的原理其实是使用你存储在同一个主域名下的 cookie 实现的。


比如百度贴吧的域名是:tieba.baidu.com/,而百度网盘的域名是:pan.baidu.com/,它俩都属于主域名 baidu.com,所以通过携带同域名下cookie 的方式,让同域名下的其他服务也能正确识别当前账号。


具体识别方案一般有两种:



  1. 通过共享 session + cookie 的方式做验证。

  2. 通过获取 cookie 内部跳转到 SSO 做验证。


无论使用哪种方案,携带 cookie 这个操作必不可少,所以这一阶段的 SSO 是基于 Cookie 的。


可能还有一个词大家比较常见:SAML,SAML标准也是用于内部系统互信,做的事和基于 Cookie 的 SSO 都是一样的,所以这里我不再赘述。


第四幕:OAuth 协议的诞生 - 授权管理的桥梁


经历完 SSO 的第一个阶段之后,我们就来到了 SSO 的第二阶段:外部互信


由于 Web 互联网的兴起,这一阶段也被称为基于 Web 的 SSO,这一阶段的代表是OAuth。


你有没有想过,如果我们在所有平台都使用同一个账号多好,就不用在记忆那么多的应用账号密码,减少心智负担。


在国内互联圈地的情况下,这种情况并没有实现,也可以说通过手机号实现了。


但是在国外,Google 账号体系几乎就是事实上的一号通行,你注册一个 Google 账号之后,几乎可以通过这个账号登录所有的网站,这就是 OAtuh 的作用。


想象一下,你拥有许多宝藏,分别存放于不同的宝库中。比如,你在 Facebook 上存储着你的社交关系,在 Google 上存储着你的邮件和文件,在 Spotify 上存储着你的音乐喜好。


现在,你想要使用一个新的游戏应用,而这个应用需要访问你在 Facebook 上的好友列表,以便你能够邀请好友一起玩游戏。


这时,你面临一个两难的选择:



  • 分享密码: 将你的 Facebook 密码告诉游戏应用,让它直接访问你的好友列表。但这存在着巨大的安全风险,一旦游戏应用泄露你的密码,你的所有 Facebook 数据都将暴露无遗。

  • 放弃使用: 由于担心安全问题,你放弃使用这个游戏应用,从而错过了与好友一起游戏的乐趣。


为了解决上面这种问题,Google 等公司在 2010 年发布了 OAuth1.0,由于它存在许多问题,所以又在 2012 年发布了 OAuth2.0。


所以在现如今,几乎所有公司都接入了 Google 的 OAuth 登录,当你在第三方平台想使用 Google 账号登录时,OAuth 协议会引导你到 Google 进行授权。


平台会询问你是否同意授权第三方应用访问你的部分数据 (例如好友列表),如果你同意,平台就会发放一个临时的“通行证”给第三方应用,让它可以访问你的数据,但不会泄露你的密码。


所以 OAuth 的核心是授权而非共享。


第五幕:无密码时代的曙光 - 告别繁琐的密码


我相信当大家看到第四节的时候,大家就会觉得应该就这些了,没有别的新意了,恰恰相反,为了彻底摆脱密码的束缚,世界巨头们正在探索新的“魔法”,那就是无密码


在 2019 年,WebAuthn 标准被 W3C 以建议的形式发布,它是 FIDO 联盟下 FIDO2 的核心组件,旨在减少人们对于密码的依赖。


它带了以下三个好处:



  • 消除密码依赖: 通过使用更加安全的认证方式,例如生物识别技术 (指纹、面部识别) 或安全密钥,消除用户对密码的依赖,降低密码泄露和网络钓鱼攻击的风险。

  • 提升用户体验: 简化登录流程,无需记忆和输入复杂的密码,只需轻触指纹或插入安全密钥,即可完成身份验证。

  • 增强安全性: 使用公钥加密技术,确保用户的认证信息不会被窃取或伪造,有效抵御网络攻击。


如果大家有在 Mac 上的 Safari 浏览器登录苹果账号的经历,就会发现它不需要你输入密码,只需要一次简单的指纹验证:


Image.png


这时你通过验证你的指纹就可以顺利登录成功,这就是基于 WebAuthn 标准的 Passkeys。


目前苹果、谷歌、微软等几乎所有大厂都支持了Passkeys,,由于它也是一个 W3C 标准,所以你可以通过这个网站查看支持列表。


看起来指纹验证就像开头我们说过的 MFA,但是它比 MFA 多了一个东西就是设备,通过生物信息 + 受信设备的方式完成了它的整个认证流程,它拥有两个比较大的特点:



  • 提供了一套标准化的用户界面和用户体验,简化了无密码登录的操作流程。

  • 将用户的登录凭证 (私钥) 存储在用户的设备 (例如手机、电脑) 中,并通过云端服务进行同步,方便用户在不同设备上登录。


说回我们开头的 Github 的 2FA,其实 Github 也接入了它,如果你完成 2FA 之后,之后就可以在浏览器中通过指纹验证登录。


身份认证的未来已来,无密码的出现,为我们在登录授权流程中带来了许多方便~




好了,以上就是本篇文章的全部内容了,希望大家多多点赞支持,我将更快提供更好更优质的内容。


注:本文小标题是借助 AI 能力起的,部分描述也借助了 AI 美化,AI 美化生成内容不会超过 300 字(本文 4000 字),请大家放心食用。


作者:和耳朵
来源:juejin.cn/post/7364764922339065890
收起阅读 »

JSON慢地要命: 看看有啥比它快!

是的, 你没听错! 网络开发中无处不在的数据交换格式JSON, 可能会拖慢你的应用程序. 在这个速度和响应速度至上的世界里, 检查 JSON 的性能影响至关重要, 而我们对此却常常忽略. 在本博客中, 我们将深入探讨 JSON 成为应用程序瓶颈的原因, 并探索...
继续阅读 »


是的, 你没听错! 网络开发中无处不在的数据交换格式JSON, 可能会拖慢你的应用程序. 在这个速度和响应速度至上的世界里, 检查 JSON 的性能影响至关重要, 而我们对此却常常忽略. 在本博客中, 我们将深入探讨 JSON 成为应用程序瓶颈的原因, 并探索更快的替代方案和优化技术, 以确保你的应用程序以最佳状态运行.


JSON 是什么? 为何我要关注这个问题?



JSON 教程 | w3resource


JSON是JavaScript Object Notation的缩写, 是一种轻量级数据交换格式, 已成为Web应用中传输和存储数据的首选. 它的简洁性和人类可读格式使人类和机器都能轻松使用. 但是, 为什么要在Web开发项目中关注 JSON 呢?


JSON 是应用中数据的粘合剂. 它是服务器和客户端之间进行数据通信的语言, 也是数据库和配置文件中存储数据的格式.


JSON 的流行以及人们使用它的原因…


JSON 在Web开发领域的受欢迎程度怎么强调都不为过. 它已成为数据交换的事实标准, 这其中有几个令人信服的原因:


它易于使用!



  1. 人类可读格式: JSON 使用简单明了, 基于文本的结构, 开发人员和非开发人员都能轻松阅读和理解. 这种人类可读的格式增强了协作, 简化了调试.

  2. 语言无关性: JSON 与任何特定的编程语言无关. 它是一种通用的数据格式, 几乎所有现代编程语言都能对其进行解析和生成, 因此它具有很强的通用性.

  3. 数据结构一致性: JSON 使用键值对, 数组和嵌套对象来实现数据结构的一致性. 这种一致性使其具有可预测性, 便于在各种编程场景中使用.

  4. 支持浏览器: 网络浏览器原生支持 JSON, 允许Web应用与服务器进行无缝通信. 这种本地支持极大地促进了 JSON 在Web开发中的应用.

  5. JSON API: 许多网络服务和应用接口默认以 JSON 格式提供数据. 这进一步巩固了 JSON 在Web开发中作为数据交换首选的地位.

  6. JSON Schema: 开发人员可以使用 JSON 模式来定义和验证 JSON 数据的结构, 从而为应用增加了一层额外的清晰度和可靠性.


鉴于这些优势, 难怪全球的开发人员都依赖 JSON 来满足他们的数据交换需求. 然而, 随着我们在本博客的深入探讨, 我们将发现与 JSON 相关的潜在性能挑战, 以及如何有效解决这些挑战.


速度需求


🚀🚀🚀


应用的速度和响应的重要性


在当今快节奏的数字环境中, 应用的速度和响应能力是不可或缺的. 用户希望在Web和移动应用中即时获取信息, 快速交互和无缝体验. 对速度的这种要求是由以下几个因素驱动的:



  1. 用户期望: 用户已习惯于从数字互动中获得闪电般快速的响应. 他们不想等待网页的加载或应用的响应. 哪怕是几秒钟的延迟, 都会导致用户产生挫败感并放弃使用.

  2. 竞争优势: 速度可以成为重要的竞争优势. 反应迅速的应用往往比反应迟缓的应用更能吸引和留住用户.

  3. 搜索引擎排名: 谷歌等搜索引擎将网页速度视为排名因素. 加载速度更快的网站往往在搜索结果中排名靠前, 从而提高知名度和流量.

  4. 转化率: 电子商务网站尤其清楚速度对转化率的影响. 网站速度越快, 转换率越高, 从而增加收入.

  5. 移动性能: 随着移动设备的普及, 对速度的需求变得更加重要. 移动用户的带宽和处理能力往往有限, 因此快速的应用性能是必要的.


JSON 会拖慢我们的应用吗?


现在, 让我们来讨论核心问题: JSON 是否会拖慢我们的应用?


如前所述, JSON 是一种非常流行的数据交换格式. 它灵活, 易用, 并得到广泛支持. 然而, 这种广泛的应用并不意味着它不会面临性能挑战.


某些情况下, JSON 可能是导致应用慢的罪魁祸首. 解析 JSON 数据的过程, 尤其是在处理大型或复杂结构时, 可能会耗费宝贵的毫秒时间. 此外, 低效的序列化和反序列化也会影响应用的整体性能.


在接下来的内容中, 我们将探讨 JSON 成为应用瓶颈的具体原因, 更重要的是, 探讨如何缓解这些问题. 在深入探讨的过程中, 请记住我们的目标不是诋毁 JSON, 而是了解其局限性并发现优化其性能的策略, 以追求更快, 反应更灵敏的应用.



LinkedIn将Protocal Buffers与Rest.li集成以提高微服务性能| LinkedIn工程


JSON 为什么会变慢


尽管 JSON 被广泛使用, 但它也难逃性能挑战. 让我们来探究 JSON 可能会变慢的原因, 并理解为什么 JSON 并不总是数据交换的最佳选择.


1. 解析带来的开销


当 JSON 数据到达应用时, 它必须经过解析过程才能转换成可用的数据结构. 解析过程可能相对较慢, 尤其是在处理大量或深度嵌套的 JSON 数据时.


2. 序列化和反序列化


JSON 要求在从客户端向服务器发送数据时进行序列化(将对象编码为字符串), 并在接收数据时进行反序列化(将字符串转换回可用对象). 这些步骤会带来开销, 影响应用的整体速度.


微服务架构的世界里, JSON 通常用于在服务之间传递消息. 但是, 很关键的是, 我们必须认识到, JSON 消息需要序列化和反序列化, 这两个过程会带来巨大的开销.



在有大量微服务不断通信的场景中, 这种开销可能会增加, 并有可能使应用变慢, 以至于影响用户体验.




我们面临的第二个挑战是, 由于 JSON 的文本性质, 序列化和反序列化的延迟和吞吐量都不理想.
— LinkedIn



1_74sQfiW0SjeFfcTcgNKupw.webp
序列化和反序列化


3. 字符串操作


JSON 基于文本, 在连接和解析等操作中严重依赖字符串操作. 与处理二进制数据相比, 处理字符串的速度会慢一些.


4. 缺乏数据类型


JSON 的数据类型(如字符串, 数字, 布尔值)非常有限. 复杂的数据结构可能需要效率较低的表示法, 从而导致内存使用量增加和处理速度减慢.



5. 冗余


JSON 的人类可读性设计可能会导致冗余. 不需要的键和重复的结构增加了有效载荷的大小, 导致数据传输时间延长.



第一个挑战是 JSON 是一种文本格式, 往往比较冗余. 这导致网络带宽使用量增加, 更高的延迟, 效果并不理想.
— LinkedIn



6. 不支持二进制


JSON 缺乏对二进制数据的本地支持. 在处理二进制数据时, 开发人员通常需要将其编解码为文本, 而这可能会降低效率.


7. 深度嵌套


在某些情况下, JSON 数据可能是深嵌套的, 需要递归解析和遍历. 这种计算复杂性会降低应用的运行速度, 尤其是在没有优化的情况下.


JSON的替代方案


虽然 JSON 是一种通用的数据交换格式, 但由于其在某些情况下的性能限制, 人们开始探索更快的替代格式. 让我们深入探讨其中的一些替代方案, 了解何时以及为何选择它们:


1. Protocol Buffers(protobuf)


Protocal Buffers通常被称为protobuf, 是由谷歌开发的一种二进制序列化格式. 它的设计宗旨是高效, 紧凑和快速. Protobuf 的二进制性质使其在序列化和反序列化方面的速度明显快于 JSON.



  • 何时选择: 当你需要高性能的数据交换时, 尤其是在微服务架构, 物联网应用或网络带宽有限的情况下, 请考虑使用Protobuf.


GitHub - vaishnav-mk/protobuf-example


2. MessagePack


MessagePack 是另一种二进制序列化格式, 以速度快, 结构紧凑而著称. 它比 JSON 更有效率, 同时与各种编程语言保持兼容.



  • 何时选择: 当你需要在速度和跨语言兼容性之间取得平衡时, MessagePack 是一个不错的选择. 它适用于实时应用和对减少数据大小至关重要的情况.


3. BSON (二进制 JSON)


BSON 或二进制 JSON 是一种从 JSON 衍生出来的二进制编码格式. 它保留了 JSON 的灵活性, 同时通过二进制编码提高了性能. BSON 常用于 MongoDB 等数据库.



  • 何时选择: 如果你正在使用 MongoDB, 或者需要一种格式来弥补 JSON 和二进制效率之间的差距, 那么 BSON 是一个很有价值的选择.


4. Apache Avro


Apache Avro 是一个数据序列化框架, 专注于提供一种紧凑的二进制格式. 它基于schema, 可实现高效的数据编解码.



  • 何时选择: Avro 适用于schema演进非常重要的情况, 如数据存储, 以及需要在速度和数据结构灵活性之间取得平衡的情况.


与 JSON 相比, 这些替代方案提供了不同程度的性能改进, 具体选择取决于你的具体使用情况. 通过考虑这些替代方案, 你可以优化应用的数据交换流程, 确保将速度和效率放在开发工作的首位.



JSON, Protobufs, MessagePack, BSON 和 Avro 之间的差异


每个字节都很重要: 优化数据格式


在效率和速度至上的数据交换世界中, 数据格式的选择会产生天壤之别. 本节将探讨从简单的 JSON 数据表示到更高效的二进制格式(如 Protocol Buffers, MessagePack, BSON 和 Avro)的过程. 我们将深入探讨每种格式的细微差别, 并展示为什么每个字节都很重要.


开始: JSON 数据


我们从简单明了的 JSON 数据结构开始. 下面是我们的 JSON 数据示例片段:


{
"id": 1, // 14 bytes
"name": "John Doe", // 20 bytes
"email": "johndoe@example.com", // 31 bytes
"age": 30, // 9 bytes
"isSubscribed": true, // 13 bytes
"orders": [ // 11 bytes
{ // 2 bytes
"orderId": "A123", // 18 bytes
"totalAmount": 100.50 // 20 bytes
}, // 1 byte
{ // 2 bytes
"orderId": "B456", // 18 bytes
"totalAmount": 75.25 // 19 bytes
} // 1 byte
] // 1 byte
} // 1 byte

JSON 总大小: ~ 139 字节


虽然 JSON 用途广泛且易于使用, 但它也有一个缺点, 那就是它的文本性质. 每个字符, 每个空格和每个引号都很重要. 在数据大小和传输速度至关重要的情况下, 这些看似微不足道的字符可能会产生重大影响.


效率挑战: 使用二进制格式减小尺寸



现在, 让我们提供其他格式的数据表示并比较它们的大小:


Protocol Buffers (protobuf):


syntax = "proto3";

message User {
int32 id = 1;
string name = 2;
string email = 3;
int32 age = 4;
bool is_subscribed = 5;
repeated Order orders = 6;

message Order {
string order_id = 1;
float total_amount = 2;
}
}

0A 0E 4A 6F 68 6E 20 44 6F 65 0C 4A 6F 68 6E 20 44 6F 65 65 78 61 6D 70 6C 65 2E 63 6F 6D 04 21 00 00 00 05 01 12 41 31 32 33 03 42 DC CC CC 3F 05 30 31 31 32 34 34 35 36 25 02 9A 99 99 3F 0D 31 02 42 34 35 36 25 02 9A 99 99 3F

Protocol Buffers 总大小: ~ 38 bytes


MessagePack:


(注意:MessagePack 是一种二进制格式, 此处的表示法非人工可读.)


二进制表示(十六进制):


a36a6964000000000a4a6f686e20446f650c6a6f686e646f65406578616d706c652e636f6d042100000005011241313302bdcccc3f0530112434353625029a99993f


MessagePack 总大小: ~34 字节


BSON (二进制 JSON):


(注意:BSON 是一种二进制格式, 此处的表示法非人工可读.)


二进制表示法 (十六进制):


3e0000001069640031000a4a6f686e20446f6502656d61696c006a6f686e646f65406578616d706c652e636f6d1000000022616765001f04370e4940

BSON 总大小: ~ 43 字节


Avro:


(注: Avro使用schema, 因此数据与schema信息一起编码.)


二进制表示法 (十六进制):


0e120a4a6f686e20446f650c6a6f686e646f65406578616d706c652e636f6d049a999940040a020b4108312e3525312e323538323539

Avro 总大小: ~ 32 字节



(这些替代方案的实际字节数可能会有所不同, 提供这些数字只是为了让大家有个大致的了解.)


现在你可能会感到奇怪, 为什么我们的程序会有这么多的字节数?


现在你可能想知道为什么有些格式输出的是二进制, 但它们的大小却各不相同. Avro, MessagePack 和 BSON 等二进制格式具有不同的内部结构和编码机制, 这可能导致二进制表示法的差异, 即使它们最终表示的是相同的数据. 下面简要介绍一下这些差异是如何产生的:


1. Avro:



  • Avro 使用schema对数据进行编码, 二进制表示法中通常包含该schema.

  • Avro 基于schema的编码可提前指定数据结构, 从而实现高效的数据序列化和反序列化.

  • Avro 的二进制格式设计为自描述格式, 这意味着schema信息包含在编码数据中. 这种自描述性使 Avro 能够保持不同版本数据模式之间的兼容性.


2. MessagePack:



  • MessagePack 是一种二进制序列化格式, 直接对数据进行编码, 不包含schema信息.

  • 它使用长度可变的整数和长度可变的字符串的紧凑二进制表示法, 以尽量减少空间使用.

  • MessagePack 不包含schema信息, 因此更适用于schema已预先知道并在发送方和接收方之间共享的情况.


3. BSON:



  • BSON 是 JSON 数据的二进制编码, 包括每个值的类型信息.

  • BSON 的设计与 JSON 紧密相连, 但它增加了二进制数据类型, 如 JSON 缺乏的日期和二进制数据.

  • 与 MessagePack 一样, BSON 不包含schema信息.


这些设计和编码上的差异导致了二进制表示法的不同:



  • Avro 包含模式信息并具有自描述性, 这导致二进制大小稍大, 但提供了schema兼容性.

  • MessagePack 因其可变长度编码而高度紧凑, 但缺乏模式信息, 因此适用于已知模式的情况.

  • BSON 与 JSON 关系密切, 包含类型信息, 与 MessagePack 等纯二进制格式相比, 会增加大小.


总之, 这些差异源于每种格式的设计目标和功能. Avro 优先考虑schema兼容性, MessagePack 注重紧凑性, 而 BSON 则在保持类似 JSON 结构的同时增加了二进制类型. 格式的选择取决于具体的使用情况和要求, 如schema兼容性, 数据大小和易用性.


优化 JSON 性能


JSON 虽然用途广泛, 在Web开发中被广泛采用, 但在速度方面也存在挑战. 这种格式的人类可读性会导致数据负载较大, 处理时间较慢. 因此, 问题出现了: 我们能够怎样优化JSON以使得它更快更高效? 在本文中, 我们将探讨可用于提高 JSON 性能的实用策略和优化方法, 以确保 JSON 在提供应用所需的速度和效率的同时, 仍然是现代 Web 开发中的重要工具.


以下是一些优化 JSON 性能的实用技巧以及代码示例和最佳实践:


1. 最小化数据大小:



  • 使用简短, 描述性的键名: 选择简洁但有意义的键名, 以减小 JSON 对象的大小.


// Inefficient
{
"customer_name_with_spaces": "John Doe"
}

// Efficient
{
"customerName": "John Doe"
}


  • 尽可能缩写:  在不影响清晰度的情况下, 考虑对键或值使用缩写.


// Inefficient
{
"transaction_type": "purchase"
}

// Efficient
{
"txnType": "purchase"
}

2. 明智地使用数组:



  • 最小化嵌套: 避免深度嵌套数组, 因为它们会增加解析和遍历 JSON 的复杂性.


// Inefficient
{
"order": {
"items": {
"item1": "Product A",
"item2": "Product B"
}
}
}

// Efficient
{
"orderItems": ["Product A", "Product B"]
}

3. 优化数字表示:



  • 尽可能使用整数:  如果数值可以用整数表示, 请使用整数而不是浮点数.


// Inefficient
{
"quantity": 1.0
}

// Efficient
{
"quantity": 1
}

4. 消除冗余:



  • 避免重复数据: 通过引用共享值来消除冗余数据.


// Inefficient
{
"product1": {
"name": "Product A",
"price": 10
},
"product2": {
"name": "Product A",
"price": 10
}
}

// Efficient
{
"products": [
{
"name": "Product A",
"price": 10
},
{
"name": "Product B",
"price": 15
}
]
}

5. 使用压缩:



  • 使用压缩算法:  如何可行的话, 使用压缩算法, 比如Gzip 或者Brotli, 以在传输过程中减少JSON负载大小.


// Node.js example using zlib for Gzip compression
const zlib = require('zlib');

const jsonData = {
// Your JSON data here
};

zlib.gzip(JSON.stringify(jsonData), (err, compressedData) => {
if (!err) {
// Send compressedData over the network
}
});

6. 采用服务器端缓存:



  • 缓存 JSON 响应:  实施服务器端缓存, 以便高效地存储和提供 JSON 响应, 减少重复数据处理的需要.


7. 剖析与优化:



  • 剖析性能:  使用剖析工具找出 JSON 处理代码中的瓶颈, 然后优化这些部分.



请记住, 你实施的具体优化措施应符合应用的要求和限制.



真实世界的优化: 在实践中加速


在这一部分, 我们将深入探讨现实世界中遇到 JSON 性能瓶颈并成功解决的应用和项目. 我们将探讨企业如何解决 JSON 的局限性, 以及这些优化为其应用带来的切实好处. 从 LinkedIn 和 Auth0 这样的知名平台到 Uber 这样的颠覆性科技巨头*, 这些示例为我们提供了宝贵的见解, 让我们了解在尽可能利用 JSON 的多功能性的同时提高速度和响应能力的策略.


1. LinkedIn集成Protocol Buffers:


挑战: LinkedIn 面临的挑战是 JSON 的冗长以及由此导致的网络带宽使用量增加, 从而导致延迟增加.
解决方案: 他们在微服务通信中采用了二进制序列化格式 Protocol Buffers 来取代 JSON.
影响: 这一优化将延迟降低了60%, 提高了 LinkedIn 服务的速度和响应能力.


2. Uber的H3地理索引:



  • 挑战: Uber 使用 JSON 表示各种地理空间数据, 但解析大型数据集的 JSON 会降低其算法的速度.

  • 解决方法 他们引入了H3地理索引, 这是一种用于地理空间数据的高效六边形网格系统, 可减少 JSON 解析开销.

  • 影响: 这一优化大大加快了地理空间操作, 增强了 Uber 的叫车和地图服务.


3. Slack的消息格式优化:



  • 挑战: Slack 需要在实时聊天中传输和呈现大量 JSON 格式的消息, 这导致了性能瓶颈.

  • 解决方法 他们优化了 JSON 结构, 减少了不必要的数据, 只在每条信息中包含必要的信息.

  • 影响: 这一优化提高了消息渲染速度, 改善了 Slack 用户的整体聊天性能.


4. Auth0的Protocal Buffers实现:



  • 挑战: Auth0 是一个流行的身份和访问管理平台, 在处理身份验证和授权数据时面临着 JSON 的性能挑战.

  • 解决方案: 他们采用Protocal Buffers来替代 JSON, 以编解码与身份验证相关的数据.

  • 影响: 这一优化大大提高了数据序列化和反序列化的速度, 从而加快了身份验证流程, 并增强了 Auth0 服务的整体性能.


这些真实案例表明, 通过优化策略解决 JSON 的性能难题, 可对应用的速度, 响应和用户体验产生重大积极影响. 它们强调了在各种应用场景中考虑使用替代数据格式和高效数据结构来克服 JSON 相关缓慢的问题的重要性.


总结一下


在开发领域, JSON 是数据交换不可或缺的通用工具. 其人类可读格式和跨语言兼容性使其成为现代应用的基石. 然而, 正如我们在本文中所探讨的, JSON 的广泛应用并不能使其免于性能挑战.


我们在优化 JSON 性能的过程中获得的主要启示是显而易见的:



  • 性能至关重要: 在当今的数字环境中, 速度和响应速度至关重要. 用户希望应用能够快如闪电, 即使是微小的延迟也会导致不满和机会的丧失.

  • 尺寸至关重要: 数据有效载荷的大小会直接影响网络带宽的使用和响应时间. 减少数据大小通常是优化 JSON 性能的第一步.

  • 替代格式: 当效率和速度至关重要时, 探索其他数据序列化格式, 如Protocal Buffers, MessagePack, BSON 或 Avro.

  • 真实世界案例: 从企业成功解决 JSON 速度变慢问题的实际案例中学习. 这些案例表明, 优化工作可以大幅提高应用的性能.


在继续构建和增强Web应用时, 请记住要考虑 JSON 对性能的影响. 仔细设计数据结构, 选择有意义的键名, 并在必要时探索其他序列化格式. 这样, 你就能确保你的应用在速度和效率方面不仅能满足用户的期望, 而且还能超越用户的期望.


在不断变化的Web开发环境中, 优化 JSON 性能是一项宝贵的技能, 它能让你的项目与众不同, 并确保你的应用在即时数字体验时代茁壮成长.


作者:bytebeats
来源:juejin.cn/post/7299353265099423753
收起阅读 »

告别轮询,SSE 流式传输可太香了!

今天想和大家分享的一个技术是 SSE 流式传输 。如标题所言,通过 SSE 流式传输的方式可以让我们不再通过轮询的方式获取服务端返回的结果,进而提升前端页面的性能。 对于需要轮询的业务场景来说,采用 SSE 确实是一个更好的技术方案。 接下来,我将从 SSE ...
继续阅读 »

今天想和大家分享的一个技术是 SSE 流式传输 。如标题所言,通过 SSE 流式传输的方式可以让我们不再通过轮询的方式获取服务端返回的结果,进而提升前端页面的性能。


对于需要轮询的业务场景来说,采用 SSE 确实是一个更好的技术方案。


接下来,我将从 SSE 的概念、与 Websocket 对比、SSE 应用场景多个方面介绍 SSE 流式传输,感兴趣的同学一起来了解下吧!


什么是 SSE 流式传输


SSE 全称为 Server-sent events , 是一种基于 HTTP 协议的通信技术,允许服务器主动向客户端(通常是Web浏览器)发送更新。


它是 HTML5 标准的一部分,设计初衷是用来建立一个单向的服务器到客户端连接,使得服务器可以实时地向客户端发送数据。


这种服务端实时向客户端发送数据的传输方式,其实就是流式传输。


我们在与 ChatGPT 交互时,可以发现 ChatGPT 的响应总是间断完成。细扒 ChatGPT 的网络传输模式,可以发现,用的也是流式传输。


图片


SSE 流式传输的好处


在 SSE 技术出现之前,我们习惯把需要等待服务端返回的过程称为长轮询。


长轮询的实现其实也是借助 http 请求来完成,一个完整的长轮询过程如下图所示:


图片


从图中可以发现,长轮询最大的弊端是当服务端响应请求之前,客户端发送的所有请求都不会被受理。并且服务端发送响应的前提是客户端发起请求。


前后端通信过程中,我们常采用 ajax 、axios 来异步获取结果,这个过程,其实也是长轮询的过程。


而同为采用 http 协议通信方式的 SSE 流式传输,相比于长轮询模式来说,优势在于可以在不需要客户端介入的情况下,多次向客户端发送响应,直至客户端关闭连接。


这对于需要服务端实时推送内容至客户端的场景可方便太多了!


SSE 技术原理


1. 参数设置

前文说到,SSE 本质是一个基于 http 协议的通信技术。


因此想要使用 SSE 技术构建需要服务器实时推送信息到客户端的连接,只需要将传统的 http 响应头的 contentType 设置为 text/event-stream 。


并且为了保证客户端展示的是最新数据,需要将 Cache-Control 设置为 no-cache 。


在此基础上,SSE 本质是一个 TCP 连接,因此为了保证 SSE 的持续开启,需要将 Connection 设置为 keep-alive 。


Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

完成了上述响应头的设置后,我们可以编写一个基于 SSE 流式传输的简单 Demo 。


2. SSE Demo

服务端代码:


const express = require('express');
const app = express();
const PORT = 3000;

app.use(express.static('public'));

app.get('/events'function(req, res) {
    res.setHeader('Content-Type''text/event-stream');
    res.setHeader('Cache-Control''no-cache');
    res.setHeader('Connection''keep-alive');

    let startTime = Date.now();

    const sendEvent = () => {
        // 检查是否已经发送了10秒
        if (Date.now() - startTime >= 10000) {
            res.write('event: close\ndata: {}\n\n'); // 发送一个特殊事件通知客户端关闭
            res.end(); // 关闭连接
            return;
        }

        const data = { message'Hello World'timestampnew Date() };
        res.write(`data: ${JSON.stringify(data)}\n\n`);

        // 每隔2秒发送一次消息
        setTimeout(sendEvent, 2000);
    };

    sendEvent();
});

app.listen(PORT() => {
    console.log(`Server running on http://localhost:${PORT}`);
});

客户端代码:


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

<head>
    <meta charset="UTF-8">
    <title>SSE Example</title>
</head>

<body>
    <h1>Server-Sent Events Example</h1>
    <div id="messages"></div>

    <script>
        const evtSource = new EventSource('/events');
        const messages = document.getElementById('messages');

        evtSource.onmessage = function(event) {
            const newElement = document.createElement("p");
            const eventObject = JSON.parse(event.data);
            newElement.textContent = "Message: " + eventObject.message + " at " + eventObject.timestamp;
            messages.appendChild(newElement);
        };
    
</script>
</body>
</html>

当我们在浏览器中访问运行在 localhost: 3000 端口的客户端页面时,页面将会以 流式模式 逐步渲染服务端返回的结果:


图片


需要注意的是,为了保证使用 SSE 通信协议传输的数据能被客户端正确的接收,服务端和客户端在发送数据和接收数据应该遵循以下规范:


服务端基本响应格式

SSE 响应主要由一系列以两个换行符分隔的事件组成。每个事件可以包含以下字段:


data:事件的数据。如果数据跨越多行,每行都应该以data:开始。
id:事件的唯一标识符。客户端可以使用这个ID来恢复事件流。
event:自定义事件类型。客户端可以根据不同的事件类型来执行不同的操作。
retry:建议的重新连接时间(毫秒)。如果连接中断,客户端将等待这段时间后尝试重新连接。

字段之间用单个换行符分隔,而事件之间用两个换行符分隔。


客户端处理格式

客户端使用 EventSource 接口监听 SSE 消息:


const evtSource = new EventSource('path/to/sse');
evtSource.onmessage = function(event) {
    console.log(event.data); // 处理收到的数据
};

SSE 应用场景


SSE 作为基于 http 协议由服务端向客户端单向推送消息的通信技术,对于需要服务端主动推送消息的场景来说,是非常适合的:


图片


SSE 兼容性


图片


可以发现,除了 IE 和低版本的主流浏览器,目前市面上绝大多数浏览器都支持 SSE 通信。


SSE 与 WebSocket 对比


看完 SSE 的使用方式后,细心的同学应该发现了:


SSE 的通信方式和 WebSocket 很像啊,而且 WebSocket 还支持双向通信,为什么不直接使用 WebSocket ?


下表展示了两者之间的对比:


特性/因素SSEWebSockets
协议基于HTTP,使用标准HTTP连接单独的协议(ws:// 或 wss://),需要握手升级
通信方式单向通信(服务器到客户端)全双工通信
数据格式文本(UTF-8编码)文本或二进制
重连机制浏览器自动重连需要手动实现重连机制
实时性高(适合频繁更新的场景)非常高(适合高度交互的实时应用)
浏览器支持良好(大多数现代浏览器支持)非常好(几乎所有现代浏览器支持)
适用场景实时通知、新闻feed、股票价格等需要从服务器推送到客户端的场景在线游戏、聊天应用、实时交互应用
复杂性较低,易于实现和维护较高,需要处理连接的建立、维护和断开
兼容性和可用性基于HTTP,更容易通过各种中间件和防火墙可能需要配置服务器和网络设备以支持WebSocket
服务器负载适合较低频率的数据更新适合高频率消息和高度交互的场景

可以发现,SSE 与 WebSocket 各有优缺点,对于需要客户端与服务端高频交互的场景,WebSocket 确实更适合;但对于只需要服务端单向数据传输的场景,SSE 确实能耗更低,且不需要客户端感知


参考文档


developer.mozilla.org/zh-CN/docs/…


作者:veneno
来源:juejin.cn/post/7355666189475954725
收起阅读 »

三方接口不动声色将http改为了https,于是开启了我痛苦的一天

早上刚来,就看到仓库那边不停发消息说,我们的某个功能用不了了。赶紧放下早餐加紧看。 原来是调的一个三方接口报错了: javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorExc...
继续阅读 »

早上刚来,就看到仓库那边不停发消息说,我们的某个功能用不了了。赶紧放下早餐加紧看。


原来是调的一个三方接口报错了:


javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
at sun.security.ssl.Alert.createSSLException(Alert.java:131)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:353)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:296)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:291)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:652)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:471)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:367)
at sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:376)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:444)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:422)
at sun.security.ssl.TransportContext.dispatch(TransportContext.java:183)
at sun.security.ssl.SSLTransport.decode(SSLTransport.java:154)
at sun.security.ssl.SSLSocketImpl.decode(SSLSocketImpl.java:1279)
at sun.security.ssl.SSLSocketImpl.readHandshakeRecord(SSLSocketImpl.java:1188)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:401)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:373)

查看原因:



由于JVM默认信任证书不包含该目标网站的SSL证书,导致无法建立有效的信任链接。



奥...原来是他们把接口从http改为了https,导致我们获取数据报错了。再看看他们的证书,奥...新的。


image.png


好了,看看我们的逻辑,这其实是一个获取对方生成的PDF文件的接口


PdfReader pdfReader = new PdfReader(url);

url就是他们给的链接,是这行代码报的错。这时候,开始研究,在网上扒拉,找到了初版方案


尝试1


写一个程序专门获取安全证书,这代码有点长,全贴出来影响阅读。我给扔我hithub上了github.com/lukezhao6/I… 将这个文件贴到本地,执行javac InstallCert.java将其进行编译


image.png


编译完长这样:


image.png
然后再执行java InstallCert http://www.baidu.com (这里我们用百度举例子,实际填写的就是你想要获取证书的目标网站)


image.png
报错不用怕,因为它会去检查目标服务器的证书,如果出现了SSLException,表示证书可能存在问题,这时候会把异常信息打印出来。


在生成的时候需要输入一个1


image.png
这样,我们需要的证书文件就生成好了


image.png


这时候,将它放入我们本地的 jdk的lib\security文件夹内就行了


image.png


重启,这时候访问是没有问题了。阶段性胜利。


但是,但是。一顿操作下来,对于测试环境的docker,还有生产环境貌似不能这么操作。 放这个证书文件比较费事。


那就只能另辟蹊径了。


尝试2


搜到了,还有两种方案。



1.通过System.setProperty("javax.net.ssl.trustStore", "你的jssecacerts证书路径");


2.程序启动命令-Djavax.net.ssl.trustStore=你的jssecacerts证书路径 -Djavax.net.ssl.trustStorePassword=changeit



我尝试了第一种,System.setProperty可以成功,但是读不到文件,权限什么的都是ok的。
检查了蛮多地方



  • 路径格式问题

  • 文件是否存在

  • 文件权限

  • 信任库密码

  • 系统属性优先级


貌似都是没问题的,但肯定又是有问题的,因为没起作用。但是想着这样的接口有4个,万一哪天其他三个也改了,我又得来一遍。所以就算研究出来了,还是不能稳坐钓鱼台。有没有一了百了的方法嘞。


尝试3


还真找到了:这个错是因为对方网站的证书不被java信任么,那咱不校验了,直接全部信任。这样就算其他接口改了,咱也不愁。而且这个就是获取pdf,貌似安全性没那么重。那就开搞。


代码贴在了下方,上边的大概都能看懂吧,下方的我加了注释。


URL console = new URL(url);
HttpURLConnection conn = (HttpURLConnection) console.openConnection();
if (conn instanceof HttpsURLConnection) {
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, new TrustManager[]{new TrustAnyTrustManager()}, new java.security.SecureRandom());
((HttpsURLConnection) conn).setSSLSocketFactory(sc.getSocketFactory());
((HttpsURLConnection) conn).setHostnameVerifier(new TrustAnyHostnameVerifier());
}
conn.connect();
InputStream inputStream = conn.getInputStream();
PdfReader pdfReader = new PdfReader(inputStream);
inputStream.close();
conn.disconnect();

private static class TrustAnyTrustManager implements X509TrustManager {
//这个方法用于验证客户端的证书。在这里,方法体为空,表示不对客户端提供的证书进行任何验证。
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法用于验证服务器的证书。同样,方法体为空,表示不对服务器提供的证书进行任何验证。
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法返回一个信任的证书数组。在这里,返回空数组,表示不信任任何证书,也就是对所有证书都不做任何信任验证。
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
//这个方法用于验证主机名是否可信。在这里,无论传入的主机名是什么,方法始终返回 true,表示信任任何主机名。这就意味着对于 SSL 连接,不会对主机名进行真实的验证,而是始终接受所有主机名。
private static class TrustAnyHostnameVerifier implements HostnameVerifier {
public boolean verify(String hostname, SSLSession session) {
return true;
}
}

解决了解决了,这样改算是个比较不错的方案了吧。


作者:奔跑的毛球
来源:juejin.cn/post/7362587412066893834
收起阅读 »

我们开源啦!一键部署免费使用!Kubernetes上直接运行大数据平台!

导语:市场上首个 K8s 上的大数据平台,开源了!智领云自主研发的首个完全基于Kubernetes的容器化大数据平台Kubernetes Data Platform (简称KDP)开源啦!开发者只要准备好命令行工具,一键部署Hadoop,Hive,Spark,...
继续阅读 »

导语:市场上首个 K8s 上的大数据平台,开源了!

智领云自主研发的首个完全基于Kubernetes的容器化大数据平台

Kubernetes Data Platform (简称KDP)

开源啦!

开发者只要准备好命令行工具,一键部署

Hadoop,Hive,Spark,Kafka, Flink, MinIO ...

就可以创建以前要花几十万甚至几百万才可以买到的大数据平台

无需再花大量的时间和经费去做重复的研发

高度集成,单机即可体验大数据平台

在高级安装模式下

用户可在现有的K8s集群上集成运行大数据组件

不用额外单独建设大数据集群

项目地址:

https://github.com/linktimecloud/kubernetes-data-platform

辛辛苦苦研究出来的成果,为什么要开源?

这波格局开大,老板有话说

问题1:我们为什么要开源?

我们的产品一直是基于大数据开源生态体系建设的。之前就一直有开源回馈社区的计划,但是因为之前Kubernetes对于大数据组件的支持还不够成熟,我们也一直在迭代与Kubernetes的适配。现在我们的企业版已经在很多头部客户落地并且在生产环境下高效运行,觉得这个版本已经可以达到大部分生产级项目的需求,集成度以及可用性是能够帮到有类似需求的用户的,希望这次开源能够降低在Kubernetes上集成大数据组件的门槛,让更多Kuberenetes和big data社区的同行们可以使用。

问题2:开源版本的KDP,能干啥?

KDP可以很方便的在Kubenetes上安装和管理常用的大数据组件,Hadoop,Hive,Spark,Kafka, Flink, MinIO 等等,不需要自己一个一个去适配,可以直接开始使用。然后KDP也提供集成的运维管理界面,用户可以从界面管理所有组件的安装配置,运行状况,资源使用情况,修改配置。而且KDP会将一个大数据组件的所有负载(容器,pod)作为一个整体管理,用户不需要在Kubernetes的控制平面上去管理单独的负载。

问题3:最大的亮点是?

只要你已经在使用Kubernetes,那么在现有集群上十几分钟就可以启动一个完整的大数据集群,马上开始使用,极大的降低了大数据平台的使用门槛。因为我们这个流程是高度集成的,整个安装过程在一个单机环境下也都能启动(例如使用单机kind虚拟集群都可以),所以在测试和实验环境下都可以高效使用。当然,启动之后Day 2的很多好处,例如资源的高效利用和集成的运维管理,也是KDP提供的重要功能。

KDP,即在Kubernetes上使用原生的分布式功能搭建及管理大数据平台。

将多套大数据组件集成在Kubernetes之上,同时提供一个整体的管理及运维工具体系,形成一个完全基于Kubernetes的大数据平台。企业级KDP更是支持在同一个Kubernetes集群中同时运行多个大数据平台以及多租户管理的能力,充分发挥Kubernetes云原生体系的优势。

KDP,通过对开源大数据组件的扩展和集成,实现了传统大数据平台到K8s大数据平台的平稳迁移。

作为市场上首个可完全在Kubernetes上部署的容器化云原生大数据平台,智领云自主研发的KDP,深度整合云原生架构优势,将大数据组件、数据应用及资源调度混排,纳入Kubernetes管理体系,从而带你真正玩转云原生!

总体框架

简单来讲,KDP可以允许客户在Kubernetes上运行它所有的大数据组件,并把它们作为一个整体管理起来。

在Kubernetes上运行大数据平台有三个好处:

第一,更高效的大数据组件集成:KDP提供标准化自动化的大数据组件部署和配置,极大地缩短了大数据项目开发和上线时间;

第二,更高效的大数据集群运管:KDP通过大数据组件与K8s的集成,在K8s之上搭建了一个大数据组件管理抽象层,标准化大数据组件生命周期管理,并提供UI界面进一步提升了部署、升级等操作的效率;

第三,更高的集群资源利用率:利用K8s的资源管理和配额机制,与其它系统共享K8s资源池,精细化资源管理,对比传统大数据平台约30%左右的资源利用率,KDP可大幅提升至60%以上。

社区

我们期待您的贡献和建议!最简单的贡献方式是参与Github议题/讨论的讨论。 如果您有任何问题,请与我们联系,我们将确保尽快为您解答。

微信群:添加小助手微信拉您进入交流群

钉钉群:搜索公开群组号 82250000662

贡献

参考开发者指南,了解如何开发及贡献 KDP。

https://linktimecloud.github.io/kubernetes-data-platform/docs/zh/developer-guide/developer-guide.html

收起阅读 »

状态🐔到底如何优雅的实现

状态机的组成 状态机是一种抽象的数学模型,描述了对象或系统在特定时间点可能处于的各种状态以及状态之间的转换规则。它由一组状态、事件、转移和动作组成,用于模拟对象在不同条件下的行为和状态变化。 状态机包括以下基本组成部分: 状态(State):表示对象或系统...
继续阅读 »

状态机的组成


状态机是一种抽象的数学模型,描述了对象或系统在特定时间点可能处于的各种状态以及状态之间的转换规则。它由一组状态、事件、转移和动作组成,用于模拟对象在不同条件下的行为和状态变化。


image-20240423094124100

状态机包括以下基本组成部分:



  • 状态(State):表示对象或系统当前的状态,例如开、关、就绪等。

  • 事件(Event):触发状态转换的动作或条件,例如按钮点击、消息到达等。

  • 转移(Transition):定义了从一个状态到另一个状态的转换规则,通常与特定事件相关联。

  • 动作(Action):在状态转换过程中执行的操作或行为,例如更新状态、记录日志等。


状态机,也就是 State Machine ,不是指一台实际机器,而是指一个数学模型。说白了,一般就是指一张状态转换图。例如,根据自动门的运行规则,我们可以抽象出下面这么一个图。


image-20240423095540911


简单实现


在计算机中,状态机通常用编程语言来实现。在 C、C++、Java、Python 等编程语言中,可以通过使用 switch-case 语句、if-else 语句、状态转移表等来实现状态机。在下面还有更加优雅的方式,使用 Spring 状态机 来实现。


if-else 实现状态机


在上面的示例中,我们使用 if-else 结构根据当前活动来控制音乐的播放状态,并执行相应的行为。代码如下:


public class BasketballMusicStateMachineUsingIfElse {
private boolean isPlayingMusic;

public BasketballMusicStateMachineUsingIfElse() {
this.isPlayingMusic = false; // 初始状态为音乐未播放
}

public void playMusic() {
if (!isPlayingMusic) {
System.out.println("Music starts playing...");
isPlayingMusic = true;
}
}

public void stopMusic() {
if (isPlayingMusic) {
System.out.println("Music stops playing...");
isPlayingMusic = false;
}
}

public void performActivity(String activity) {
if ("basketball".equals(activity)) {
System.out.println("Music~");
playMusic(); // 打篮球时播放音乐
} else if ("sing_rap".equals(activity)) {
System.out.println("哎哟你干嘛!");
stopMusic(); // 唱跳Rap时停止音乐
} else {
System.out.println("Invalid activity!");
}
}

public static void main(String[] args) {
BasketballMusicStateMachineUsingIfElse stateMachine = new BasketballMusicStateMachineUsingIfElse();

// 测试状态机
stateMachine.performActivity("basketball"); // 打篮球,音乐开始播放
stateMachine.performActivity("sing_rap"); // 唱跳Rap,音乐停止播放
stateMachine.performActivity("basketball"); // 再次打篮球,音乐重新开始播放
}
}

switch-case 实现状态机


在这个示例中,我们使用 switch-case 结构根据不同的活动来控制音乐的播放状态,并执行相应的行为。代码如下:


public class BasketballMusicStateMachineUsingSwitchCase {
private boolean isPlayingMusic;

public BasketballMusicStateMachineUsingSwitchCase() {
this.isPlayingMusic = false; // 初始状态为音乐未播放
}

public void playMusic() {
if (!isPlayingMusic) {
System.out.println("Music starts playing...");
isPlayingMusic = true;
}
}

public void stopMusic() {
if (isPlayingMusic) {
System.out.println("Music stops playing...");
isPlayingMusic = false;
}
}

public void performActivity(String activity) {
switch (activity) {
case "basketball":
System.out.println("Music ~");
playMusic(); // 打篮球时播放音乐
break;
case "sing_rap":
System.out.println("哎哟 你干嘛 ~");
stopMusic(); // 唱跳Rap时停止音乐
break;
default:
System.out.println("Invalid activity!");
}
}

public static void main(String[] args) {
BasketballMusicStateMachineUsingSwitchCase stateMachine = new BasketballMusicStateMachineUsingSwitchCase();

// 测试状态机
stateMachine.performActivity("basketball"); // 打篮球,音乐开始播放
stateMachine.performActivity("sing_rap"); // 唱跳Rap,音乐停止播放
stateMachine.performActivity("basketball"); // 再次打篮球,音乐重新开始播放
}
}


是不是感觉状态机其实经常在我们的日常使用中捏~,接下来带大家使用更优雅的状态机 Spring 状态机。


image-20240423100302874

使用 Spring 状态机


1)引入依赖


<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-core</artifactId>
<version>2.0.1.RELEASE</version>
</dependency>

2)定义状态和事件的枚举


代码如下:


public enum States {
IDLE, // 空闲状态
PLAYING_BB, // 打篮球状态
SINGING // 唱跳Rap状态
}
public enum Event {
START_BB_MUSIC, // 开始播放篮球音乐事件
STOP_BB_MUSIC // 停止篮球音乐事件
}

3)配置状态机


代码如下:


@Configuration
@EnableStateMachine
public class BasketballMusicStateMachineConfig extends EnumStateMachineConfigurerAdapter<States, Event> {

@Autowired
private BasketballMusicStateMachineEventListener eventListener;

@Override
public void configure(StateMachineConfigurationConfigurer<States, Event> config) throws Exception {
config
.withConfiguration()
.autoStartup(true)
.listener(eventListener); // 设置状态机事件监听器
}

@Override
public void configure(StateMachineStateConfigurer<States, Event> states) throws Exception {
states
.withStates()
.initial(States.IDLE)
.states(EnumSet.allOf(States.class));
}

@Override
public void configure(StateMachineTransitionConfigurer<States, Event> transitions) throws Exception {
transitions
.withExternal()
.source(States.IDLE).target(States.PLAYING_BB).event(Event.START_BB_MUSIC)
.and()
.withExternal()
.source(States.PLAYING_BB).target(States.SINGING).event(Event.STOP_BB_MUSIC)
.and()
.withExternal()
.source(States.SINGING).target(States.PLAYING_BB).event(Event.START_BB_MUSIC);
}
}

4)定义状态机事件监听器


代码如下:


@Component
public class BasketballMusicStateMachineEventListener extends StateMachineListenerAdapter<States, Event> {

@Override
public void stateChanged(State<States, Event> from, State<States, Event> to) {
if (from.getId() == States.IDLE && to.getId() == States.PLAYING_BB) {
System.out.println("开始打篮球,music 起");
} else if (from.getId() == States.PLAYING_BB && to.getId() == States.SINGING) {
System.out.println("唱跳,你干嘛");
} else if (from.getId() == States.SINGING && to.getId() == States.PLAYING_BB) {
System.out.println("继续打篮球,music 继续");
}
}
}

5)编写单元测试


@SpringBootTest
class ChatApplicationTests {
@Resource
private StateMachine<States, Event> stateMachine;

@Test
void contextLoads() {
//开始打球,music 起
stateMachine.sendEvent(Event.START_BB_MUSIC);
//开始唱跳,你干嘛
stateMachine.sendEvent(Event.STOP_BB_MUSIC);
//继续打球,music 继续
stateMachine.sendEvent(Event.START_BB_MUSIC);

}
}

效果如下:


image-20240423103523546


在上面的示例中,我们定义了一个状态机,用于控制在打篮球时音乐的播放和唱跳 Rap 的行为。通过触发事件来执行状态转移,并通过事件监听器监听状态变化并执行相应的操作。


image-20240423103604502


作者:cong_
来源:juejin.cn/post/7360647839448088613
收起阅读 »

数据连接已满,导致新连接无法成功

个人项目中经常会把每个项目的平台部署成开发、测试环境,而数据库就有可能是多个平台共用一个了,现在基本上都是用的微服务架构,那么数据库连接就不够用了。 我们用的是MySQL数据库,最近遇到了这个尴尬的问题,本地修改了代码启动的时候经常会连不上数据库既然连接太多,...
继续阅读 »

个人项目中经常会把每个项目的平台部署成开发、测试环境,而数据库就有可能是多个平台共用一个了,现在基本上都是用的微服务架构,那么数据库连接就不够用了。


我们用的是MySQL数据库,最近遇到了这个尴尬的问题,本地修改了代码启动的时候经常会连不上数据库既然连接太多,要么减少连接,要么扩大最大可连接数,


经过查询,MySQL 数据库 的默认最大连接数是经常设置在151的默认值,而最大连接数可以达到16384个


对应报错信息一般为:


com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure


too many connections



  1. 查看数据库当前连接信息,可以看到连接数据库的进程id,ip,用户名,连接的数据库,连接状态,连接时长等如果发现有大量的sleep状态的连接进程,则说明该参数设置的过大,可以进行适当的调整小些。




    1. SHOW FULL processlist;




    2. 在MySQL中,使用 SHOW FULL PROCESSLIST; 命令可以显示系统中所有当前运行的线程,包括每个线程的状态、是否锁定等信息。这个命令输出的表格中包含的字段具有以下含义:



      1. Id: 线程的唯一标识符。这个ID可以用来引用特定的线程,例如,在需要终止一个特定的线程时可以使用 KILL 命令。

      2. User: 启动线程的MySQL用户。

      3. Host: 用户连接到MySQL服务器的主机名和端口号,显示格式通常是 host_name:port

      4. db: 当前线程所在的数据库。如果线程没有使用任何数据库,这一列可能显示为NULL。

      5. Command: 线程正在执行的命令类型,例如 QuerySleepConnect 等。

      6. Time: 命令执行的时间,以秒为单位。对于 Sleep 状态,这表示线程处于空闲状态的时长。

      7. State: 线程的当前状态,提供了正在执行的命令的额外信息。这可以是 Sending datasorting resultLocked 等。

      8. Info: 如果线程正在执行查询,则这一列显示具体的SQL语句。对于其他类型的命令,这一列可能为空或显示为NULL。


      Command 列显示为 Sleep 时,这意味着该线程当前没有执行任何查询,只是在连接池中等待下一个查询命令。通常,应用程序执行完一个查询后,连接可能会保持打开状态而不是立即关闭,以便可以重用该连接执行后续的查询。在这种状态下,线程不会使用服务器资源来处理任何数据,但仍占用一个连接槽。如果看到很多线程处于 Sleep 状态且持续时间较长,这可能是一个优化点,例如,通过调整应用逻辑或连接池设置来减少空闲连接的数量。






  2. 查询当前最大连接数和超时时间




    1. # 查看最大连接数
      show variables like '%max_connections%';
      # 查看非交互式超时时间 单位秒
      show variables like 'wait_timeout';
      # 查看交互式叫号时间 单位秒
      show variables like 'interactive_timeout';





      1. max_connections:



        1. max_connections 参数定义了数据库服务器能够同时接受的最大客户端连接数。当达到这个限制时,任何新的尝试连接的客户端将会收到一个错误,通常是“Too many connections”。

        2. 默认值通常基于系统的能力和配置,但经常设置在151的默认值。这个值可以根据服务器的硬件资源(如CPU和内存)和负载要求进行调整。



      2. mysqlx_max_connections:



        1. mysqlx_max_connections 参数是专门为MySQL的X协议(一种扩展的协议,支持更复杂的操作,如CRUD操作和实时通知)设定的最大连接数。X协议使得开发者能够使用NoSQL风格的接口与数据库交互。

        2. 默认值通常较小,因为X协议的使用还不如传统SQL协议普遍。这个参数允许你独立于max_connections控制通过X协议可能的连接数。



      3. wait_timeout:



        1. wait_timeout 设置的是非交互式(非控制台)客户端连接在变成非活动状态后,在被自动关闭之前等待的秒数。非交互式连接通常指的是通过网络或API等进行的数据库连接,如应用程序服务器到数据库的连接。

        2. 默认值通常较长,如8小时(28800秒),但这可以根据需要进行调整,特别是在连接数资源受限的环境中。



      4. interactive_timeout:



        1. interactive_timeout 适用于MySQL服务器与客户端进行交互式会话时的连接超时设置。交互式会话通常是指用户通过MySQL命令行客户端或类似工具直接连接并操作数据库。

        2. 这个超时值只会在MySQL服务器识别连接为交互式时应用。它的默认值也通常是8小时。








  3. 修改最大连接数和超时时间



    1. SQL直接改




      1. # 重启后失效 这里直接设置1000
        SET GLOBAL max_connections = 1000;
        # 设置全局 非交互连接 超时时间 单位秒
        SET GLOBAL wait_timeout = 300;




    2. 配置文件改



      1.     MySQL的配置文件通常是 my.cnf(在Linux系统中)或 my.ini(在Windows系统中)。你应该在 [mysqld] 部分中设置这些参数

      2.     在Linux系统上,MySQL的配置文件一般位于以下几个路径之一:

      3. /etc/my.cnf

      4. /etc/mysql/my.cnf

      5. /var/lib/mysql/my.cnf

      6.     具体位置可能会根据不同的Linux发行版和MySQL安装方式有所不同。你可以使用 find 命令来搜索这个文件,例如:


      7. sudo find / -name my.cnf




    3.   找到到文件后,将这些值修改为下列的值 这里直接设置1000


    4. [mysqld]
      max_connections = 1000

      wait_timeout = 300


    5. docker情况



      1. 使用Docker命令行参数

      2.     你可以在运行MySQL容器时通过Docker命令行直接设置配置参数,例如:


      3. docker run -d \
        -p 3306:3306 \
        --name mysql \
        -e MYSQL_ROOT_PASSWORD=my-secret-pw \
        -e MYSQL_DATABASE=mydatabase \
        mysql:tag --max-connections=1000 --wait-timeout=300


      4.     在这个例子中,--max-connections=1000 是作为命令行参数传递给MySQL服务器的。

      5. 修改配置文件并挂载

      6.     如果你需要修改多个配置项或者希望使用配置文件来管理设置,可以创建一个自定义的 my.cnf 文件,然后在启动容器时将它挂载到容器中适当的位置。例如:


      7. docker run -d \
        -p 3306:3306 \
        --name mysql \
        -e MYSQL_ROOT_PASSWORD=my-secret-pw \
        -v /path/to/your/custom/my.cnf:/etc/mysql/my.cnf \
        mysql:tag






  4. 修改完后再查一遍看看有没有改成功




    1. # 查看最大连接数
      show variables like '%max_connections%';
      # 查看非交互式超时时间
      SHOW GLOBAL VARIABLES LIKE 'wait_timeout';




  5. 拓展



    1. wait_timeout 变量分为全局级别和会话级别



      •     执行 SET GLOBAL wait_timeout = 300;后,

      •     使用执行 show variables like 'wait_timeout'; 发现并没有改变

      •     是因为在MySQL中,当你执行 SET GLOBAL wait_timeout = 300; 这条命令时,理论上应该是会设置全局的 wait_timeout 值为300秒。在查询 wait_timeout 时,没有指定是查询全局变量,可能会返回会话级的值。会话级的 wait_timeout 并没有被改变。尝试使用以下命令来查看全局设置:


      • # 查看全局变量
        SHOW GLOBAL VARIABLES LIKE 'wait_timeout';
        # 与之对应的,查看会话级别的变量可以使用:
        SHOW SESSION VARIABLES LIKE 'wait_timeout';




    2. 全局变量和会话变量的区别



      • 全局变量:



        1. 全局变量对服务器上所有当前会话和未来会话都有效。

        2. 当你设置一个全局变量时,它的值会影响所有新建的连接。然而,对于已经存在的连接,全局变量的更改通常不会影响它们,除非这些连接被重新启动或者明确地重新读取全局设置。

        3. 通过 SET GLOBAL 命令修改全局变量,或者在服务器的配置文件中设置并重新启动服务器。



      • 会话变量:



        1. 会话变量只对当前连接的会话有效,并且当会话结束时,会话变量的设置就会失效。

        2. 修改会话变量的命令是 SET SESSION 或者简单的 SET,它不会影响其他会话或连接。

        3. 每个新的会话都会从当前的全局设置中继承变量的值,但在会话中对这些变量的修改不会影响到其他会话。



      • 关于是否改变全局变量,这取决于你试图解决的具体问题:



        • 如果你需要修改的设置应该对所有新的连接生效,例如,修改 wait_timeout 来减少空闲连接超时,那么修改全局变量是合适的。这样,所有新建立的连接都会采用新的超时设置。

        • 然而,如果你需要立即影响当前活动的会话,你必须在每个会话中单独设置会话变量。这在某些操作中可能是必需的,比如调整当前事务的隔离级别或者调试中动态改变某些性能调优参数。

        • 因此,如果改变是为了长期或持久的配置调整,修改全局变量通常是正确的做法。但如果需要对当前会话立即生效的改变,应该使用会话变量。








作者:不惊夜
来源:juejin.cn/post/7361056871673446437
收起阅读 »

一个小小的批量插入,被面试官追问了6次

嗨,你好呀,我是哪吒。 面试经常被问到“MyBatis批量入库时,xml的foreach和java的foreach,性能上有什么区别?”。 首先需要明确一点,优先使用批量插入,而不是在Java中通过循环单条插入。 很多小伙伴都知道这个结论,但是,为啥?很少有人...
继续阅读 »

嗨,你好呀,我是哪吒。


面试经常被问到“MyBatis批量入库时,xml的foreach和java的foreach,性能上有什么区别?”。


首先需要明确一点,优先使用批量插入,而不是在Java中通过循环单条插入。


很多小伙伴都知道这个结论,但是,为啥?很少有人能说出个所以然来。


就算我不知道,你也不能反反复复问我“同一个问题”吧?


1、MyBatis批量入库时,xml的foreach和java的foreach,性能上有什么区别?


批量入库时,如果通过Java循环语句一条一条入库,每一条SQL都需要涉及到一次数据库的操作,包括网络IO以及磁盘IO,可想而知,这个效率是非常低下的。


xml中使用foreach的方式会一次性发送给数据库执行,只需要进行一次网络IO,提高了效率。


但是,xml中的foreach可能会导致内存溢出OOM问题,因为它会一次性将所有数据加载到内存中。而java中的foreach可以有效避免这个问题,因为它会分批次处理数据,每次只处理一部分数据,从而减少内存的使用。


如果操作比较复杂,例如需要进行复杂的计算或者转换,那么使用java中的foreach可能会更快,因为它可以直接利用java的强大功能,而不需要通过xml进行转换。


孰重孰轻,就需要面试官自己拿捏了~


2、在MyBatis中,对于<foreach>标签的使用,通常有几种常见的优化方法?


比如避免一次性传递过大的数据集合到foreach中,可以通过分批次处理数据或者在业务层先进行数据过滤和筛选。


预编译SQL语句、优化SQL语句,减少foreach编译的工作量。


对于重复执行的SQL语句,可以利用mybatis的缓存机制来减少数据库的访问次数。


对于关联查询,可以考虑使用mybatis的懒加载特性,延迟加载关联数据,减少一次性加载的数据量。


3、MyBatis foreach批量插入会有什么问题?


foreach在处理大量数据时会消耗大量内存。因为foreach需要将所有要插入的数据加载到内存中,如果数据量过大,可能会导致内存溢出。


有些数据库对单条SQL语句中可以插入的数据量有限制。如果超过这个限制,foreach生成的批量插入语句将无法执行。


使用foreach进行批量插入时,需要注意事务的管理。如果部分插入失败,可能需要进行回滚操作。


foreach会使SQL语句变得复杂,可能影响代码的可读性和可维护性。


4、当使用foreach进行批量插入时,如何处理可能出现的事务问题?内存不足怎么办?


本质上这两个是一个问题,就是SQL执行慢,一次性执行SQL数量大的问题。


大多数数据库都提供了事务管理功能,可以确保一组操作要么全部成功,要么全部失败。在执行批量插入操作前,开始一个数据库事务,如果所有插入操作都成功,则提交事务;如果有任何一条插入操作失败,则回滚事务。


如果一次插入大量数据,可以考虑分批插入。这样,即使某一批插入失败,也不会影响到其他批次的插入。


优化foreach生成的SQL语句,避免因SQL语句过长或过于复杂而导致的问题。


比如MySQL的INSERT INTO ... VALUES语法 通常比使用foreach进行批量插入更高效,也更可靠。


5、MyBati foreach批量插入时如何处理死锁问题?


当使用MyBatis的foreach进行批量插入时,可能会遇到死锁问题。这主要是因为多个事务同时尝试获取相同的资源(如数据库的行或表),并且每个事务都在等待其他事务释放资源,从而导致了死锁。


(1)优化SQL语句


确保SQL语句尽可能高效,避免不必要的全表扫描或复杂的联接操作,这可以减少事务持有锁的时间,从而降低死锁的可能性。


不管遇到什么问题,你就回答优化SQL,基本上都没毛病。


(2)设置锁超时


为事务设置一个合理的锁超时时间,这样即使发生死锁,也不会导致系统长时间无响应。


(3)使用乐观锁


乐观锁是一种非阻塞性锁,它假设多个事务在同一时间不会冲突,因此不会像悲观锁那样在每次访问数据时都加锁。乐观锁通常用于读取频繁、写入较少的场景。


(4)分批插入


如果一次插入大量数据,可以考虑分批插入。这样,即使某一批插入失败,也不会影响到其他批次的插入。


(5)调整事务隔离级别


较低的隔离级别(如READ UNCOMMITTED)可能会减少死锁的发生,但可能会导致其他问题,如脏读或不可重复读。


6、mybatis foreach批量插入时如果数据库连接池耗尽,如何处理?


(1)增加最大连接数


数据库连接池耗尽了,增加最大连接数,这个回答,没毛病。


(2)优化SQL语句


减少每个连接的使用时间,从而减少连接池耗尽的可能性。


万变不离其宗,优化SQL,没毛病。


(3)分批插入


避免一次性占用过多的连接,从而减少连接池耗尽的可能性。


(4)调整事务隔离级别


降低事务隔离级别可以减少每个事务持有连接的时间,从而减少连接池耗尽的可能性。但需要注意,较低的事务隔离级别可能会导致其他问题,如脏读或不可重复读。


(5)使用更高效的批量插入方法


比如MySQL的INSERT INTO ... VALUES语法。这些方法通常比使用foreach进行批量插入更高效,也更节省连接资源。


感觉每道题的答案都是一样呢?这就对喽,数据库连接池耗尽,本质问题不就是入库的速度太慢了嘛。


(6)定期检查并关闭空闲时间过长的连接,以释放连接资源。


就前面的几个问题,做一个小总结,你会发现,它们的回答大差不差。


通过现象看本质,批量插入会有什么问题?事务问题?内存不足怎么办?如何处理死锁问题?数据库连接池耗尽,如何处理?


这些问题的本质都是因为SQL执行慢,一次性SQL数据量太大,事务提交太慢导致的。


回答的核心都是:如何降低单次事务时间?



  1. 优化SQL语句

  2. 分批插入

  3. 调整事务隔离级别

  4. 使用更高效的批量插入方法


作者:哪吒编程
来源:juejin.cn/post/7359900973991362597
收起阅读 »

我早就看现在的工作流不爽了!- 前端使用 Jenkins

背景目前笔者所在的小公司的前端项目还是推送到git仓库后由另一名后端拉取代码到他电脑上再build,然后再手动同步到服务器上,比较麻烦,而且出现一个bug就要立即修复,笔者一天要说100次“哥,代码更新了,打包上传下吧,球球了”,终于我实在受不了了(上传代码的...
继续阅读 »


背景

目前笔者所在的小公司的前端项目还是推送到git仓库后由另一名后端拉取代码到他电脑上再build,然后再手动同步到服务器上,比较麻烦,而且出现一个bug就要立即修复,笔者一天要说100次“哥,代码更新了,打包上传下吧,球球了”,终于我实在受不了了(上传代码的这位哥也受不了了),于是想通过 Jenkins 实现简单的前端项目自动打包部署。

通过 docker 安装 Jenkins

通过 ssh 连接上局域网服务器 192.168.36.2,在 home 目录下新建了一个 Jenkins 文件夹,后续我们的配置文件就放在其中。

 cd
 # 将 Jenkins 相关的文件都放在这里
 mkdir jenkins
 cd jenkins
 
 # 创建 Jenkins 配置文件存放的地址,并赋予权限
 mkdir jenkins_home
 chmod -R 777 jenkins_home
 
 pwd
 # /root/jenkins

创建docker-compose.yml

 touch docker-compose.yml
 vim docker-compose.yml
 version: '3'
 services:
  jenkins:
    image: jenkins/jenkins:latest
    container_name: 'jenkins'
    restart: always
    ports:
      - "8999:8080"
    volumes:
      - /root/jenkins/jenkins_home:/var/jenkins_home

Jenkins 启动后会挂在8080端口上,本文笔者将其映射到8999端口,读者可以自行更改。

关键在于将容器中的/var/jenkins_home目录映射到宿主机的/root/jenkins/jenkins_home目录,这一步相当于将 Jenkins 的所有配置都存放在宿主机而不是容器中,这样做的好处在于,后续容器升级、删除、崩溃等情况下,不需要再重新配置 Jenkins。

使用:wq保存后可以开始构建了:

 docker compose up -d

这一步会构建容器并启动,看到如下信息就说明成功了:

 [+] Running 1/1
  Container Jenkins   Started           1.3s

查看一下容器是否在运行:

 docker ps

image-20240403133238265

这个时候通过http://192.168.36.2:8999就可以访问 Jenkins 了。

Jenkins 初次配置向导

解锁

image-20240403133538015

第一次打开会出现向导,需要填入管理员密码,获取密码有三种方式:

  1. 通过宿主机

     cat /root/jenkins/jenkins_home/secrets/initialAdminPassword
     
     # 2bf4ca040f624716befd5ea137b70560
  2. 通过 docker 进入容器

     docker exec -it jenkins /bin/bash
     
     #进入了docker
     jenkins@1c151dfc2482:/$ cat /var/jenkins_home/secrets/initialAdminPassword
     
     # 2bf4ca040f624716befd5ea137b70560

    与方法一类似,因为目录映射,这两个目录其实是同一个。

  3. 通过查看 docker log

     docker logs jenkins

    会出现一大串,最后能找到密码:

    image-20240403134001532

填入密码,点击继续。

安装插件

image-20240403134122512

选择安装推荐插件即可。

安装插件可能会非常慢,可以选择换源。

更换 Jenkins 插件源(可选)

有两种方法:

  1. 直接输入地址:

    http://192.168.36.2:8999/manage/pluginManager/advanced,在Update Site中填入清华源地址:

     https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json

    点击Submit提交保存,并重启容器。

  2. 直接更改配置文件:

    宿主机中操作:

     cd /root/jenkins/jenkins_home
     vim hudson.model.UpdateCenter.xml

    替换其中的地址,然后重启容器即可。

    image-20240403135010339

创建用户

这一步建议用户名不为 admin ,不然会出现奇怪的问题,比如密码登录不上,需要用上一部的初始密码(2bf4ca040f624716befd5ea137b70560)才能登录。

我这里创建了一个 root 用户(只是名字叫 root,防止用户名太多记不住而已)。

image-20240403135909136

点击保存并完成。

实例配置按需调整即可,直接下一步,Jenkins 就准备就绪了。

image-20240403140101678

至此 Jenkins 安装就算完成了。

安装插件

笔者是一名前端,因此以前端项目为例。

前端项目的打包需要 node 环境,打包完成后通过 ssh 部署到服务器上,并且构建结果通过钉钉机器人推送到群里,因此需要三个插件。

  1. NodeJS
  2. Publish Over SSH
  3. DingTalk(可选)

在 系统管理 -> 插件管理 -> Available plugins 中搜索并安装。

image-20240403140852383

image-20240403140907525

勾选安装后重启,让插件生效。

插件配置

我们安装了三个插件,分别进行配置。

NodeJS

这个插件可以在不同的项目中使用不同的 node 环境,例如 A项目 使用 node14,B项目 使用 node20 这样。

进入 系统管理 -> 全局工具配置 -> NodeJS 安装 (在最下面)

点击新增:

image-20240403142305544

默认的这个使用的是 nodejs.org 的官方源,虽然现在 nodejs.org 的官方源国内访问也还可以,但为了保险起见,笔者还是换成阿里巴巴源。

image-20240403142424488

点击红框里的 X 删除当前安装,在点击新增安装,选择 Install from nodejs.org mirror

image-20240403142605321

镜像地址填入https://mirrors.aliyun.com/nodejs-release/,版本按需选择,笔者这里选择的是 node20-lts,并且安装了包管理工具 pnpm,如果读者的项目需要别的全局安装的包,也可以写在 Global npm packages to install ,比如 yarncnpm 之类的。

记得起一个别名:

image-20240403153355639

配置好后点击保存。

一般来说,在使用 npm 时,需要更改 npm 的源,同样在 Jenkins 中也是可以的。

安装完 NodeJS 插件后,系统设置中会多一项 Managed files

image-20240403143048480

进入后选择左侧的Add a new Config,然后选择 Npm config file,然后点击 Next

image-20240403143327739

image-20240403143449389

新增一个 NPM Registry,填入阿里巴巴镜像源:http://registry.npmmirror.com

至此 NodeJS 相关的配置就完成了。

SSH Server

打包后需要通过 SSH 部署到服务器上,因此需要先配置好 SSH 服务器。

打开 系统管理 -> 系统配置 -> Publish over SSH (在最下面):

image-20240403143805027

然后根据实际情况进行填写:

image-20240403144201158

字段解释
Name显示在 Jenkins 中的名称,可随意填写
Hostname服务器地址,ip 或 域名
UsernameSSH 登录的用户名
Remote DirectorySSH 登录后进入的目录,必须是服务器中已经存在的目录,设置好之后所有通过 SSH 上传的文件只能放在这个目录下

这里笔者使用用户名-密码的方式登录 SSH,如果要通过 SSH Key 的方式的话,需要在字段 Path to key 填入 key 文件的地址,或者直接将 key 的内容填入 Key 字段中:

image-20240403144737759

设置好可以通过Test Configuration,测试 SSH 连通性:

image-20240403144822057

出现 Success 代表 SSH 配置成功。

钉钉通知(可选)

如果不需要通过钉钉通知,可以不装 DingTalk 插件,并跳过本节内容。

钉钉部分设置

该功能需要一个钉钉群,并打开钉钉群机器人:

image-20240403145500789

点击添加机器人,选择自定义:

image-20240403145604092

这里笔者的安全设置选择了加签:

image-20240403145717147

将签名保存下来备用。

点击完成后,出现了钉钉机器人的 Webhook 地址。

image-20240403145823192

将地址保存下来备用。

至此钉钉部分的设置就结束了。

Jenkins 部分

打开 系统设置 -> 钉钉 (在最下面的未分类中):

image-20240403145150439

根据需要配置通知时机:

image-20240403145231249

然后点击机器人-新增:

image-20240403145303034

将刚刚的钉钉机器人的签名和 Webhook 地址填入对应的地方,并点击测试:

image-20240403150049799

此时钉钉机器人也在群中发了消息:

image-20240403150138516

至此钉钉机器人配置完毕。

创建任务(job)

本文中,笔者将以存储在 Git 仓库中的项目为例。

Github 项目

注意,如果想让 Github 项目全自动构建的话,需要你的 Jenkins 能被公网访问到,例如部署在云服务器上,像笔者这样部署在本地局域网中,是无法实现“提交代码 -> 自动构建 -> 自动部署”的,只能实现“提交代码 -> 手动点击开始构建 -> 自动部署”

如果在 Jenkins 新手向导里选择了 安装推荐插件,那么现在就不需要额外安装 Github 相关的插件了,否则的话需要手动安装 Github 相关的插件:

image-20240403151242880

创建项目

选择 Dashboard -> 新建任务:

image-20240403151424735

选择构建一个自由风格的软件项目,点击确定。

General

这部分可以添加钉钉机器人:

image-20240403151545166

源码管理

这里选择 Git:

输入仓库地址:https://github.com/baIder/homepage.git

image-20240403151822101

由于笔者这是一个私有仓库,因此会报错。

在下面的Credentials中,添加一个。

image-20240403151941812

image-20240403152135370

注意,这里的用户名是 Github 用户名,但是密码不是你的 Github 密码,而是你的 Github Access Token!!!

image-20240403152324115

image-20240403152429183

可以在这里创建 Token,需要勾选 admin:repo_hook 、repo 权限。

image-20240403152535951

image-20240403152729685

这里的报错是网络问题,连接 Github 懂得都懂。

image-20240403152824725

分支可以根据实际情况选择。

构建触发器

勾选GitHub hook trigger for GITScm polling,这样在 Git 仓库产生提交时,就会触发构建,属于是真正的核心。

image-20240403153134664

构建环境

勾选 Provide Node & npm bin/ folder to Path

image-20240403153444910

Build Steps

到这里,可以理解为 Jenkins 已经将仓库克隆到本地,并且已经安装好了nodenpmpnpm,接下来就是执行命令:

image-20240403153625159

我们需要执行命令:

 node -v
 pnpm -v
 
 rm -rf node_modules
 rm -rf dist
 
 pnpm install
 pnpm build

这里的pnpm build需要按情况更换为package.json中设定的命令。

image-20240403153850007

image-20240403153750787

构建后操作

经过所有的流程到这里,项目应该已经打包在dist目录下了。现在可以通过 SSH 将打包好的产物上传到服务器上了:

image-20240403154044658

image-20240403155757484

这里的 Source files 字段一定要写成dist/**/**,如果写成dist/*,则只会将第一层的文件上传。

Remove prefix 需要填写,否则会将dist这个目录也上传到服务器上。

Remote directory 是相对于配置 SSH Server 时的 Remote directory 的,本例中就是 /data/sites/homepage 。

Exec command 是文件上传后执行的命令,可以是任何命令,可以是让nginx有权限访问这些数据,重启nginx等等,根据服务器实际情况更改。

当然也可以在 Build Steps 中 build 完成后将 dist 目录打包,然后在通过 SSH 将压缩包上传到服务器,然后在 Exec command 中解压。

至此所有的配置已经完成,保存。

测试

点击左侧的 立即构建:

image-20240403154858929

image-20240403154950197

第一次构建会比较慢,因为需要下载node,安装依赖等等,可以从控制台看到,命令都如期执行了:

image-20240403155359524

构建成功,钉钉机器人也提示了(因为 Github 访问失败的原因,多试了几次):

image-20240403155855959

笔者已经配置好了nginx,因此可以直接访问网页,查看效果:

image-20240403160008179

通过 Git 提交触发构建

目前虽然构建成功了,但是需要手动点击构建,接下来实现如何将代码提交 Git 后自动触发构建。

打开仓库设置 -> Webhooks 添加一个:

image-20240403160353025

这里的 Payload URL 就是 Jenkins 地址 + /github-webhook,例如笔者的就如图所示。

但是由于笔者的 Jenkins 部署在本地局域网,因此是不行的,Github 肯定是无法访问到笔者的局域网的,有公网地址的读者可以试试,在笔者的阿里云服务器上是没有问题的。所以目前如果是 Github 项目的话,笔者需要提交代码后手动点击 立即构建:

image-20240403161026497

Gitlab 项目

实际上笔者所在公司是在局域网中部署了 Gitlab 的,因此针对 Gitlab 项目的自动化才是核心。

安装 Gitlab 插件:

image-20240403161442736

安装完毕后重启 Jenkins。

获取 Gitlab token

与 Github 的流程类似,也需要在 Gitlab 中创建一个 token:

image-20240403161807711

创建好之后保存 token 备用。

在 Jenkins 中配置 Gitlab

打开 Jenkins -> 系统管理 -> 系统配置 -> Gitlab

image-20240403162301361

这里需要新建一个Credentials,点击下方的添加:

类型选择GitLab API token,将刚刚保存的 token 填入到 API token 字段中。

image-20240403162144399

点击Test Connection

image-20240403162651637

出现Success说明配置成功。

创建项目

大多数过程与 Github 项目雷同。

General

会多出一个选项,选择刚刚添加的:

image-20240403163406501

源码管理

Git 仓库地址填 Gitlab 仓库地址,同样会报错,添加一个Credentials便可解决:

image-20240403163538105

用户名密码填登录 Gitlab 的用户名密码即可。

构建触发器

按需选择触发条件,这里笔者仅选择了提交代码:

这里红框中的 url 需要记下,后面要用。

image-20240403164252265

其他配置

与 Github 项目相同。

测试构建

点击立即构建,查看是否能构建成功:

image-20240403163945821

构建成功:

image-20240403164002293

提交代码自动构建

进入 Gitlab 仓库 -> 设置 -> 集成:

这里的 url 填入刚刚 Jenkins 构建触发器 中红框内的 url 地址。

image-20240403164203308

看情况是否开启 SSL verification。

点击 Add webhook:

image-20240403164449921

测试一下:

image-20240403164513668

可以看到 Jenkins 那边已经开始构建了:

image-20240403164551282

构建成功:

image-20240403164606737

测试 Git 提交触发构建

目前页面:

image-20240403164712339

我们将v2.0-f改成v2.0-g

image-20240403164817371

提交代码,Jenkins 开始了自动构建:

image-20240403164852625

构建成功,页面也发生了变化:

image-20240403164912343

至此,Gitlab 提交代码后自动打包并部署至服务器的流水线就完成了。

后记

本文实现了从提交代码到部署上线的自动化工作流,适合小公司的小型项目或自己的演示项目,大公司一定会有更规范更细节的流程,笔者也是从实际需求出发,希望本文能帮助到各位,由于笔者也是第一次使用 Jenkins,如有不足或错误之处,请读者批评指正。


作者:bald3r
来源:juejin.cn/post/7354406980784504870

收起阅读 »

Redis stream 用做消息队列完美吗 ?

Redis Stream 是 Redis 5.0 版本中引入的一种新的数据结构,它用于实现简单但功能强大的消息传递模式。 这篇文章,我们聊聊 Redis Stream 基本用法 ,以及如何在 SpringBoot 项目中应用 Redis Stream 。 1...
继续阅读 »

Redis Stream 是 Redis 5.0 版本中引入的一种新的数据结构,它用于实现简单但功能强大的消息传递模式。


这篇文章,我们聊聊 Redis Stream 基本用法 ,以及如何在 SpringBoot 项目中应用 Redis Stream 。



1 基础知识


Redis Stream 的结构如下图所示,它是一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。



每个 Redis Stream 都有唯一的名称 ,对应唯一的 Redis Key 。


同一个 Stream 可以挂载多个消费组 ConsumerGr0up , 消费组不能自动创建,需要使用 XGR0UP CREATE 命令创建


每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动 ,标识当前消费组消费到哪条消息了。


消费组 ConsumerGr0up 同样可以挂载多个消费者 Consumer , 每个 Consumer 并行的读取消息,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。


消费者内部有一个属性 pending_ids , 记录了当前消费者读取但没有回复 ACK 的消息 ID 列表 。


2 核心命令


01 XADD 向 Stream 末尾添加消息


使用 XADD 向队列添加消息,如果指定的队列不存在,则创建一个队列。基础语法格式:


XADD key ID field value [field value ...]


  • key :队列名称,如果不存在就创建

  • ID :消息 id,我们使用 * 表示由 redis 生成,可以自定义,但是要自己保证递增性。

  • field value : 记录。


127.0.0.1:6379> XADD mystream * name1 value1 name2 value2
"1712473185388-0"
127.0.0.1:6379> XLEN mystream
(integer) 1
127.0.0.1:6379> XADD mystream * name2 value2 name3 value3
"1712473231761-0"

消息 ID 使用 * 表示由 redis 生成,同时也可以自定义,但是自定义时要保证递增性。



消息 ID 的格式: 毫秒级时间戳 + 序号 , 例如:1712473185388-5 , 它表示当前消息在毫秒时间戳 1712473185388 产生 ,并且该毫秒内产生到了第5条消息。



在添加队列消息时,也可以指定队列的长度


127.0.0.1:6379> XADD mystream MAXLEN 100 * name value1 age 30
"1713082205042-0"

使用 XADD 命令向 mystream 的 stream 中添加了一条消息,并且指定了最大长度为 100。消息的 ID 由 Redis 自动生成,消息包含两个字段 nameage,分别对应的值是 value130


02 XRANGE 获取消息列表


使用 XRANGE 获取消息列表,会自动过滤已经删除的消息。语法格式:


XRANGE key start end [COUNT count]


  • key :队列名

  • start :开始值, - 表示最小值

  • end :结束值, + 表示最大值

  • count :数量


127.0.0.1:6379> XRANGE mystream - + COUNT 2
1) 1) "1712473185388-0"
  2) 1) "name1"
     2) "value1"
     3) "name2"
     4) "value2"
2) 1) "1712473231761-0"
  2) 1) "name2"
     2) "value2"
     3) "name3"
     4) "value3"

我们得到两条消息,第一层是消息 ID ,第二层是消息内容 ,消息内容是 Hash 数据结构 。


03 XREAD 以阻塞/非阻塞方式获取消息列表


使用 XREAD 以阻塞或非阻塞方式获取消息列表 ,语法格式:


XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]


  • count :数量

  • milliseconds :可选,阻塞毫秒数,没有设置就是非阻塞模式

  • key :队列名

  • id :消息 ID


127.0.0.1:6379> XREAD streams mystream 0-0
1) 1) "mystream"
  2) 1) 1) "1712473185388-0"
        2) 1) "name1"
           2) "value1"
           3) "name2"
           4) "value2"
     2) 1) "1712473231761-0"
        2) 1) "name2"
           2) "value2"
           3) "name3"
           4) "value3"

XRED 读消息时分为阻塞非阻塞模式,使用 BLOCK 选项可以表示阻塞模式,需要设置阻塞时长。非阻塞模式下,读取完毕(即使没有任何消息)立即返回,而在阻塞模式下,若读取不到内容,则阻塞等待。


127.0.0.1:6379> XREAD block 1000 streams mystream $
(nil)
(1.07s)

使用 Block 模式,配合 $ 作为 ID ,表示读取最新的消息,若没有消息,命令阻塞!等待过程中,其他客户端向队列追加消息,则会立即读取到。


因此,典型的队列就是 XADD 配合 XREAD Block 完成。XADD 负责生成消息,XREAD 负责消费消息。


04 XGR0UP CREATE 创建消费者组


使用 XGR0UP CREATE 创建消费者组,分两种情况:



  • 从头开始消费:


XGR0UP CREATE mystream consumer-group-name 0-0  


  • 从尾部开始消费:


XGR0UP CREATE mystream consumer-group-name $

执行效果如下:


127.0.0.1:6379> XGR0UP CREATE mystream mygroup 0-0
OK

05 XREADGR0UP GR0UP 读取消费组中的消息


使用 XREADGR0UP GR0UP 读取消费组中的消息,语法格式:


XREADGR0UP GR0UP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]


  • group :消费组名

  • consumer :消费者名。

  • count : 读取数量。

  • milliseconds : 阻塞毫秒数。

  • key : 队列名。

  • ID : 消息 ID。


示例:


127.0.0.1:6379>  XREADGR0UP group mygroup consumerA count 1 streams mystream >
1) 1) "mystream"
  2) 1) 1) "1712473185388-0"
        2) 1) "name1"
           2) "value1"
           3) "name2"
           4) "value2"

消费者组 mygroup 中的消费者 consumerA ,从 名为 mystream 的 Stream 中读取消息。



  • COUNT 1 表示一次最多读取一条消息

  • > 表示消息的起始位置是当前可用消息的 ID,即从当前未读取的最早消息开始读取。


06 XACK 消息消费确认


接收到消息之后,我们要手动确认一下(ack),语法格式:


xack key group-key ID [ID ...]

示例:


127.0.0.1:6379> XACK mystream mygroup 1713089061658-0
(integer) 1

消费确认增加了消息的可靠性,一般在业务处理完成之后,需要执行 ack 确认消息已经被消费完成,整个流程的执行如下图所示:



我们可以使用 xpending 命令查看消费者未确认的消息ID


127.0.0.1:6379> xpending mystream mygroup
1) (integer) 1
2) "1713091227595-0"
3) "1713091227595-0"
4) 1) 1) "consumerA"
     2) "1"

07 XTRIM 限制 Stream 长度


我们使用 XTRIM 对流进行修剪,限制长度, 语法格式:


127.0.0.1:6379> XADD mystream * field1 A field2 B field3 C field4 D
"1712535017402-0"
127.0.0.1:6379> XTRIM mystream MAXLEN 2
(integer) 4
127.0.0.1:6379> XRANGE mystream - +
1) 1) "1712498239430-0"
  2) 1) "name"
     2) "zhangyogn"
2) 1) "1712535017402-0"
  2) 1) "field1"
     2) "A"
     3) "field2"
     4) "B"
     5) "field3"
     6) "C"
     7) "field4"
     8) "D"

3 SpringBoot Redis Stream 实战


1、添加 SpringBoot Redis 依赖


<dependency>
   <groupId>org.springframework.bootgroupId>
   <artifactId>spring-boot-starter-data-redisartifactId>
dependency>

2、yaml 文件配置



3、RedisTemplate 配置



4、定义stream监听器



5、定义streamcontainer 并启动



6、发送消息



执行完成之后,消费者就可以打印如下日志:



演示代码地址:



github.com/makemyownli…



4 Redis stream 用做消息队列完美吗


笔者认为 Redis stream 用于消息队列最大的进步在于:实现了发布订阅模型


发布订阅模型具有如下特点:



  • 消费独立


    相比队列模型的匿名消费方式,发布订阅模型中消费方都会具备的身份,一般叫做订阅组(订阅关系),不同订阅组之间相互独立不会相互影响。


  • 一对多通信


    基于独立身份的设计,同一个主题内的消息可以被多个订阅组处理,每个订阅组都可以拿到全量消息。因此发布订阅模型可以实现一对多通信。



细品 Redis stream 的设计,我们发现它和 Kafka 非常相似,比如说消费者组,消费进度偏移量等。


我们曾经诟病 Redis List 数据结构用做队列时,因为消费时没有 Ack 机制,应用异常挂掉导致消息偶发丢失的情况,Redis Stream 已经完美的解决了。


因为消费者内部有一个属性 pending_ids , 记录了当前消费者读取但没有回复 ACK 的消息 ID 列表 。当消费者重新上线,这些消息可以重新被消费。


但 Redis stream 用做消息队列完美吗 ?


这个真没有!


1、Redis 本身定位是内存数据库,它的设计之初都是为缓存准备的,并不具备消息堆积的能力。而专业消息队列一个非常重要的功能是数据中转枢纽,Redis 的定位很难满足,所以使用起来要非常小心。


2、Redis 的高可用方案可能丢失消息(AOF 持久化 和 主从复制都是异步 ),而专业消息队列可以针对不同的场景选择不同的高可用策略。


所以,笔者认为 Redis 非常适合轻量级消息队列解决方案,轻量级意味着:数据量可控 + 业务模型简单 。




参考文章:



redis.io/docs/data-t…


http://www.runoob.com/redis/redis…


pdai.tech/md/db/nosql…





作者:勇哥Java实战
来源:juejin.cn/post/7357301805569687563
收起阅读 »

争论不休的一个话题:金额到底是用Long还是BigDecimal?

在网上一直流传着一个争论不休的话题:金额到底是用Long还是用BigDecimal?这个话题一出在哪都会引起异常无比激烈的讨论。。。。 比如说这个观点:算钱用BigDecimal是常识 有支持用Long的,将金额的单位设计为分,然后乘以100,使用Long...
继续阅读 »

在网上一直流传着一个争论不休的话题:金额到底是用Long还是用BigDecimal?这个话题一出在哪都会引起异常无比激烈的讨论。。。。 比如说这个观点:算钱用BigDecimal是常识


image.png


image.png


有支持用Long的,将金额的单位设计为分,然后乘以100,使用Long进行存储以及计算,这样不用担心小数点问题。


image.png


并且一些银行系统就会选择用Long


image.png


还有,最最最牛逼的万能大法:用String


image.png


成年人不做选择题,Long跟BigDecimal都用。。。


image.png


image.png


还有一种就是封装一个金额的基类,对金额进行统一处理。


image.png


排除float和double


当然,对于金额,首先我们要排除的就是float和double。它们不适合用于精确的金融计算,因为floatdouble是基于IEEE 754标准的浮点数表示,它们无法精确地表示所有的十进制小数。这会导致在进行财务计算时出现舍入误差,这些误差可能会累积并导致不可预测的结果。



关于带精度的计算,我们不推荐使用float以及double,推荐使用BigDecimal,具体原因请参考:聊一聊BigDecimal使用时的陷阱



选择Long


Long类型在Java中用于存储64位整数。它的主要优点是速度快,因为整数运算在CPU层面是非常高效的。另外,Long类型也占用较少的内存,并且整数类型(BIGINT)在数据库中占用较少的存储空间。


但是Long类型在处理金额时有几个明显的缺点:



  1. 1. 精度问题Long只能存储整数,无法直接表示小数。使用Long来表示以分为单位的金额(例如,100表示1元),此时就会失去小数的精度。即使使用某种方式来表示小数(例如,乘以100或10000),也会遇到舍入误差的问题。并且这种计算方式也会增加计算的复杂度。

  2. 2. 浮点数问题:虽然这不是直接使用Long的问题,但如果你尝试将Long与浮点数(如doublefloat)进行转换以进行计算(比如汇率计算等),还是会遇到浮点数精度问题,这可能导致在财务计算中出现不可接受的误差。


在阿里巴巴的开发手册中建议使用Long。


image.png


但是在一些金融系统当中,对小数位要求比较高的,比如精确到小数点后6位,那么我们使用Long进行存储,每次在计算时都要除以或者乘以1000000,那么计算的开销就很大了。


并且,如果在需求确认时,我们无法知道金额要求的小数位,那我们使用Long也是不行的,我们并不知道需要乘以或者除以多少个0。


选择BigDecimal


BigDecimal是Java提供的一个类,用于任意精度的算术运算。它的主要优点是提供了高精度的计算,这对于金融和货币计算来说是非常重要的。BigDecimal可以表示任意大小的正数、负数或零,并可以精确控制舍入行为。并且在数据库中存储时也有对应的类型进行匹配,比如MySQL的DECIMAL类型提供了精确的数值存储,可以匹配BigDecimal的精度。


但是BigDecimal也有一些缺点:



  1. 1. 性能:与Long相比,BigDecimal的性能较差。因为它的运算需要更多的内存和CPU时间。

  2. 2. 复杂性:使用BigDecimal进行运算比使用Long或基本数据类型更复杂。你需要考虑舍入模式、精度等因素。

  3. 3. 在数据库中需要更多的存储空间来存储小数部分。


而在Mysql的开发手册中,建议金额需要进行小数位计算时,存储要使用Decimal,否则我们要将金额乘以对应小数位的倍数变成BIGINT进行存储。


image.png


总结


基于上述对LongBigDecimal的优缺点分析,我们可以得出以下结论:


在金额计算层面,即代码实现中,推荐使用BigDecimal进行所有与金额相关的计算。BigDecimal提供了高精度的数值运算,能够确保金额计算的精确性,避免了因浮点数精度问题导致的财务误差。使用BigDecimal可以简化代码逻辑,减少因处理精度问题而引入的复杂性。


而在数据库存储方面,我们需要根据具体需求进行权衡。如果业务需求已经明确金额只需精确到分(如某些国家/地区的货币最小单位为分),并且我们确信不会涉及到需要更高精度的小数计算,那么可以使用Long类型进行存储,将金额转换为最小货币单位(如分)进行存储。这样可以节省存储空间并提高查询性能。


但是如果业务需求中金额的小数位数不确定,或者可能涉及多位小数的计算(如国际货币交易等),那么最好使用DECIMALNUMERIC类型进行存储。这些类型提供了精确的数值存储,可以确保数据库中的数据与应用程序中的BigDecimal对象保持一致,避免数据转换过程中可能引入的精度损失。




作者:码农Academy
来源:juejin.cn/post/7358670107902984229
收起阅读 »

公司新来一个架构师, 将消费金融系统重构了

1、背景 1.2 业务重组与合并 随着需求不断迭代,转转消费分期整体出现了一些调整,并提出了新的产品方向,在此背景下,对于经历了久经沧桑的历史服务,已经逐渐不适合未来的产品规划。面对新的业务整合和重组,急需新的架构和思想来承载未来的业务。 1.2 解决...
继续阅读 »

1、背景


1.2 业务重组与合并


随着需求不断迭代,转转消费分期整体出现了一些调整,并提出了新的产品方向,在此背景下,对于经历了久经沧桑的历史服务,已经逐渐不适合未来的产品规划。面对新的业务整合和重组,急需新的架构和思想来承载未来的业务。


1.2 解决技术债务


现阶段存在的主要问题:



  1. 代码模块之间边界感不强,需要通过模块拆分、服务拆分来区分业务边界。

  2. 代码实现缺少层次感,设计模式单一,一层到底的冗长代码。

    此前,微服务拆分原则是按消费分期、合作方分期产品等维度进行整体拆分的,优点是明确了项目职责,简单的从需求维度进行服务拆分,确实是一种行之有效的方式,缺点是没有对基础功能进行剥离,以至于很多场景需要维护重复的代码,增加了项目的维护成本。


1.3 影响开发效率


即使我们接手项目已经有一段时间,并对项目足够了解时,但排查问题起来依然费力费时,而且系统内部代码错综复杂,调用链路交错,难以正常维护,从长远的开发效率考虑,尽快提出新型方案来代替现有结构。


1.4 监控体系不够完善


线上异常机制不够敏感,缺少关键业务指标的告警看板,作为一个业务开发,应保持对核心指标数据的敏感性。


2、重构目标



  1. 不影响业务的正常运转和迭代;

  2. 改善现有代码结构设计,让代码易于扩展,提升开发效率;

  3. 采用新工程逐步替代原有接口,旧工程逐渐废弃。


3、设计


3.1 调研


开始重构之前,调研了互联网消金通用的架构解决方案:


通用方案


由于是外部调研的通用架构设计,所以并非完全契合转转消费分期产品,但可以借鉴其分层架构设计的思想,在代码设计阶段,可以先对核心模块进行拆解和规划。


3.2 规划


前端页面与后端重构计划分两次迭代进行,分阶段进行,可以有效分摊并降低项目上线风险,第一次迭代围绕后端主要模块进行剥离重新设计并上线;第二次重构目的是解决产品需求,对接前端新页面。


3.3 修缮者模式


作为一个一线的业务开发,需要开展重构工作的同时还得保证产品需求的正常迭代,修缮者模式无疑是最佳选择。
第一次迭代历程,对于历史工程边缘逻辑保留并隔离,只对核心代码进行重构后转移到到新工程,新工程逐步接手老旧逻辑,并对老工程提供RPC接口,逐渐取代。此方案整体风险最低,同时能兼顾到正常的需求迭代。

第二次迭代历程,经历了第一次迭代后,新系统运行稳定,同时也具备接手新产品的能力,新工程开始与前端对接、联调,在此之后,V2版本也正式上线。


3.4 领域设计(横向拆分)


模块拆分



  1. 聚合业务:涵盖了消费分期主要业务,根据其各自产品需求特点,作为上层业务代码,对前端、收银台提供聚合接口。

  2. 基础服务:用户信贷所产生的数据、或依托合作方数据,围绕金融分期服务提供的数据支持。

  3. 三方对接:基于转转标准API下的逻辑实现,同时具备灵活接入合作方接口的能力。


3.5 模块设计(纵向拆分)


基于以往项目存在的问题,再结合消费分期的特点,我们对分期购买到账单还款结清的整个流程进行拆解:用户主动填写申请信息,提交授信申请并获额,挑选商品分期下单,生成还款计划,提供绑卡、账单还款等功能。以上就是一个简单的分期购物流程,基于以上流程,我们把消费分期所包含的公共模块,如授信前获额、用信、账单还款,这些富有金融服务属性的功能进行剥离。消费分期作为转转的产品原型,在聚合层中各自维护,互不影响。

设计原则:在不改变原有代码逻辑的情况下,根据单一职责和依赖倒置原则的思想:对系统进行模块拆分与合并,以明确项目职责降低耦合度;对包进行重新规划,划分包与包之间的边界,进一步减少代码间的耦合。


3.6 代码设计


好的代码重构一定离不开设计模式,基于原有单一的策略模式,我们把合作方对接模块与基础服务模块进行了拆解,采用双层模板、策略、工厂模式的组合,分别对授信、用信、贷后几个模块单独设计接口,维护好对合作方通用标准API接口,同时具备灵活接入的特点,举个例子,以下为授信模块主要代码类图:


第一层作为基础服务的策略模式;

第二层作为合作方对接的策略模式。

主要类图设计:
类图设计


在定义接口与实现类后,形成了对合作方对接层依赖,同时对订单、用信、授信等核心数据进行落地,对消费分期提供数据支撑,举个例子,以下为授信模块主要代码:



  1. 基础服务接口定义


/**
* 授信接口定义
**/

public interface ICreditService {

/**
* appId,资方定义的一个唯一ID
*/

String getAppId();

/**
* app名称
*
*
@return zz or zlj
*/

String getAppName();

/**
* 获取授信结果
*
*
@return result
*/

CreditResult creditResult(String logStr, Long uid);
}




  1. 标准流程抽象


/**
* 标准API对接实现
*
**/

public abstract class AbstractCreditService implements ICreditService {

/**
* 标准API对接
*
*
@return IBaseApiService
*/

protected abstract IBaseApiService getApiThirdService();

@Override
public AppConfig getPartner() {
return commonConfigUtil.getAppConfig(getAppId());
}

@Override
public CreditResult creditResult(String logStr, Long uid) {
CreditResultInput input = new CreditResultInput();
input.setUid(uid);
ResponseProtocol output = getApiThirdService().creditResult(logStr, input);
String creditStatus = TransformUtil.approvalStatusTransform(output.getData());
return CreditResult.builder().result(creditStatus).build();
}
}

/**
* 合作方差异化接入
*/

@Service
@Slf4j
public class ZZABCCreditServiceImpl extends AbstractABCCreditService {

@Resource
ZZABCThirdServiceImpl abcThirdService;

@Override
public String getAppId() {
return PartnerEnum.ABC_ZZ_API.getAppId();
}
@Override
public String getAppName() {
return AppNameEnum.ZZ.getValue();
}
@Override
protected IABCThirdService getABCThirdService() {
return abcThirdService;
}
}



  1. 标准API对接


/**
* 标准API对接
*
*
@author Rouse
*
@date 2022/4/24 13:57
*/

public interface IBaseApiService {
/**
* 标准API,获取appId
*
*
@return appId
*/

String getAppId();
/**
* 获取授信结果
*/

ResponseProtocol creditResult(CreditResultInput input);
}



  1. 内部标准API实现


/**
* 合作方,标准API对接实现
*
*
@author Rouse
*
@date 2022/4/24 14:04
*/

@Slf4j
public abstract class AbstractBaseApiService implements IBaseApiService {
@Override
public ResponseProtocol creditResult(CreditResultInput input) {
// 通用加解密
return getDataResponse(logStr, getAppConf().getUrl4CreditResult(), input, CreditResultOutput.class);
}
}


  1. 差异化合作方接入



/**
* ABC合作方接口封装
**/

public interface IABCThirdService extends IBaseApiService {
/**
* 标准API,获取appId
*
*
@return appId
*/

String getAppId();
/**
* 获取授信结果
*/

ResponseProtocol creditResult(ABCCreditResultInput input);
}

/**
* 合作方抽象方法封装
**/

@Slf4j
public abstract class AbstractABCThirdService extends AbstractBaseApiService implements IABCThirdService {
@Override
public ResponseProtocol creditResult(ABCCreditResultInput input) {
// 加解密差异化实现
return getDataResponse(logStr, getAppConf().getUrl4CreditResult(), input, ABCCreditResultOutput.class);
}
}


/**
* ABC合作方对接
*
*/

@Service
@Slf4j
public class ZZABCThirdServiceImpl extends AbstractABCThirdService{

@Override
public String getAppId() {
return PartnerEnum.ABC_API_ZZ.getAppId();
}

@Override
public String getAppName() {
return AppNameEnum.ZZ.getValue();
}
}


4、上线过程


对于老系统的重构,新系统上线过度期也至关重要,因为采用了新的表结构进行重新设计,涉及到数据的同步,我们采用单向数据同步,逐渐弃用老系统数据,如果灰度期间需要回滚,首先对数据进行回滚,优先保证线上服务稳定。

以下是经历两次重构迭代的过程:
迁移过程


5、监控


监控面板

告警通知



  1. 项目重构监控先行,这次我们采用了转转告警机制和Prometheus线上监控,另外搭建了一套线上看板,及时发现各个模块的潜在隐患。

  2. 日志,一个完美的系统离不开合理的日志,日志往往是定位问题最便捷的工具。


6、总结


通过此次技术重构,我们不仅解决了过去存在的技术债务问题,还提升了服务的稳定性和用户体验,也提升产品交付效率。

技术重构并非一蹴而就,但只要我们有坚定的信念和不懈的努力,终将取得成功。引用一句名言:”不要因为懒惰而拒绝重构,不要因为无暇重构而成为你拖延的理由 。” 是的,重构是持续优化代码质量和可维护性的过程,需要我们时刻关注并付诸行动。

我认为,重构的另一种价值:一个重构好的系统、往往具备通用性,可移植性。简单说就是我们重构后的系统以最小的改动且能在同行中快速复用,因为你创造了一个稳定可靠的“轮子”,如果做到这点,无非你是这个行业技术解决方案的专家。


关于作者


罗思,金融技术部后端研发工程师。转转消费分期业务开发。


作者:转转技术团队
来源:juejin.cn/post/7356550566535495732
收起阅读 »

JAVA 一个简单查重的实现

JAVA 一个简单查重的实现 1. 前言 最近在做一个教育网站时,有一个考试的模块,其中学生编写的文章需要有一个查重的功能。到网上找了下,感觉这方面的资料还是比较少,大部分都需要收费,由于公司家境贫寒(不愿意花钱),且需求不是特别难,只需要一个建议版本的功能。...
继续阅读 »

JAVA 一个简单查重的实现


1. 前言


最近在做一个教育网站时,有一个考试的模块,其中学生编写的文章需要有一个查重的功能。到网上找了下,感觉这方面的资料还是比较少,大部分都需要收费,由于公司家境贫寒(不愿意花钱),且需求不是特别难,只需要一个建议版本的功能。于是只能亲自动手做一个 simple 版本的。


2. 实现思路


思路的话比较简单,想法是利用双指针的模式,找出两个文本中相似的文本。不过这样算法复杂度是 O(n2) ,不过由于我们是小网站,文章本来也不多。


image.png
image.png


核心就是有一个双层的循环做遍历,然后判断最小字符串是否相同,如果不相同,则指针B递增直到找到相同的文本


image.png


如果指针位置找到了相同文本,则增加最小字符串长度,直到找到最大的匹配的文本。


3. 代码实现



public static class SameResult {
// 存储相似文本关键词
private String keyword;
// 存储与关键词详细信息
private String detail;

public String getKeyword() {
return keyword;
}

public void setKeyword(String keyword) {
this.keyword = keyword;
}

public String getDetail() {
return detail;
}

public void setDetail(String detail) {
this.detail = detail;
}
}

/**
* 获取两个字符串中的相似文本片段
* @param a 文本a
* @param b 文本b
* @param minSize 最小相似字符数
* @return 返回相似文本片段的列表
*/

public static List<SameResult> getSameTextList(String a, String b, Integer minSize) {

List<SameResult> result = new ArrayList<>();
Map<String, String> stash = new HashMap<>();
if (a == null || b == null) {
return result;
}
if (a.length() < minSize || b.length() < minSize) {
return result;
}
int i = 0;
while (i <= a.length() - minSize) {
// 初始化窗口大小为最小相似字符数
int nowWindowSize = minSize;
// 遍历文本b,寻找与文本a当前片段相似的片段
int j = 0;
String nowMate = null; // 存储当前相似片段
String nowDetail = null; // 存储当前相似片段的详细信息
SameResult sameResult = new SameResult();
Boolean isMate = false; // 标记是否找到相似片段
while (j <= b.length() - minSize) {
// 如果文本a和文本b的当前片段相等
if (a.substring(i, nowWindowSize + i).equals(b.substring(j, nowWindowSize + j))) {
// 记录相似片段
nowMate = a.substring(i, nowWindowSize + i);
// 记录详细信息, 这里的5表示详细信息取前五个和后五个字符
nowDetail = b.substring(Math.max(j - 5, 0), Math.min(nowWindowSize + j + 5, b.length()));
sameResult.setKeyword(nowMate);
sameResult.setDetail(nowDetail);
// 设置找到相似片段的标记
isMate = true;
// 增加窗口大小
nowWindowSize++;
// 继续在文本b中寻找更长的相似片段
while (j <= b.length() - nowWindowSize) {
String ma1 = a.substring(i, nowWindowSize + i);
String ma2 = b.substring(j, nowWindowSize + j);
// 如果找到更长的相似片段
if (ma1.equals(ma2)) {
nowMate = a.substring(i, nowWindowSize + i);
nowDetail = b.substring(Math.max(j - 5, 0), Math.min(nowWindowSize + j + 5, b.length()));
sameResult.setKeyword(nowMate);
sameResult.setDetail(nowDetail);
nowWindowSize++;
} else {
// 如果不再相似,退出循环
break;
}
}
// 找到相似片段后,退出内部循环
break;
} else {
// 如果不相似,继续在文本b中寻找
j++;
}
}
// 如果找到相似片段,将其存储到映射中
if (isMate) {
// 移动文本a的索引
i += nowWindowSize - 1;
stash.put(sameResult.getKeyword(), sameResult.getDetail());
} else {
// 如果没有找到相似片段,移动文本a的索引
i++;
}
}
for (String key : stash.keySet()) {
SameResult sameResult = new SameResult();
sameResult.setKeyword(key);
sameResult.setDetail(stash.get(key));
result.add(sameResult);
}
return result;
}

public static void main(String[] args) {
// 调用getSameTextList方法,并打印结果
System.out.println(getSameTextList("test1", "test2", 10));
}

代码总体比较简单,就是获取到所有最小长度文本长度的所有相似文本,并放到一个 List 中,以便后续的业务处理。


最后可以整理为一个类似下面的表格


原文相似内容
脸哭声更为响亮。我问他是谁的悲他把他脸哭声更为响亮。我问他是谁使的打成这
情往往只是作为情的友爱和险情往往只是作为情可来及,正
着茂盛树叶的树下节了一棵已着茂盛树叶的树下,走的女棉花
再说我爹年轻时也我端人的子。再说我爹年轻时也好些一手,

4. 结尾


一般会用到查重的业务场景可能并不多,大部分都是学校、政府等才需要进行查重,本文算是抛砖引玉吧,只是为需要做查重内容展示时为大家提供一点点思路。


作者:码头的薯条
来源:juejin.cn/post/7355347789677035571
收起阅读 »

更适合年轻人体质的 git 工作流

关于如何使用 git,相信大家都见过下面这张图: 很多人都学习过这张图上的流程并应用在实际工作中,但是慢慢就发现,用起来好像有点不对劲:令人困惑的合并冲突,每次发版前都需要找哪些 commit 需要发布等等。然后突然发现,诶这套流程好像用起来也不太爽,不知道...
继续阅读 »

关于如何使用 git,相信大家都见过下面这张图:


image.png


很多人都学习过这张图上的流程并应用在实际工作中,但是慢慢就发现,用起来好像有点不对劲:令人困惑的合并冲突,每次发版前都需要找哪些 commit 需要发布等等。然后突然发现,诶这套流程好像用起来也不太爽,不知道有没有更好的流程可以用。本篇文章就来聊一聊这个问题。


现有 git flow 存在的问题


首先我们来分析一下上面这个流程中都存在哪些问题:


feature 分支要从 dev 分支创建,怎么保证代码是干净的?


举个例子,你要开发一个新功能,从 dev 切出一个新分支后之后发现怎么都跑不起来,群里问了一圈发现有人提交到 dev 的代码有问题,于是你就等到他重新提交了一个 commit 之后,你拉了下代码,这才开始正常开发。


发版时需要从 dev 分支创建 release 分支,怎么保证代码都是干净的?


再举个例子,本轮迭代共提交了 20 个 commit,其中 16 个 commit 需要发布,剩下 4 个 commit 因为还没测试完、bug 没改完不能发。这时候你能准确的把要发布的 commit 检出来么?


如果可以的话,咱们更进一步,本轮迭代由五位同事提交了 40 个 commit,在发版的时候其中两个请假了,这时候你能准确的知道哪些 commit 是要发布的,并准确将其检出来么?


如果还可以的话,那就更更进一步,你检出来之后,发布到 uat 环境,发现代码跑不起来了,结果发现,有个同事偷懒了,某个 commit 因为功能没开发完所以没有检进来,但是恰好这个 commit 里又包含了一些非常关键的代码,没有就跑不起来,这时候你会怎么做?


需要保持 dev 分支和 master 分支的同步,不同步的话可能导致合并冲突


回忆一下,你之前有没有处理过这种合并冲突:冲突的两方代码是完全一样的,但就是冲突了。


这种就是使用了 rebase(非 fast-forward)或者 cherry-pick 后导致的,因为这两种方法会产生代码完全一样,但是 id 不同的新 commit。就导致了 git 产生了混乱。


一个常见场景就是 hotfix 分支的 commit,合并到 master 之后又 cherry-pick 到了 dev 分支。这样下次再从 dev 往 master 合的时候就会出现这种问题。




不知道你什么感受,反正我是已经开始汗流浃背了,那么有没有更简单、更高效、心智负担更低的 git 工作流能解决这些问题呢?当然是有的。


正式介绍一下新的 git flow


首先我们还是以流程图的形式展示一下新的 flow:


image.png


和原本 git flow 的区别在于:



  • feature 分支不再从 dev 创建,而是从最稳定的 master 分支创建

  • dev 分支的代码不再向 release 分支合并,由 feature 直接发起到 release 的合并。

  • 当 release 分支测试完成要发版的时候,直接 fast-forward 到 master

  • 定期删除 dev 和 release,然后从 master 创建新的(例如每轮迭代结束之后)


那么这套工作流能解决刚才提到的问题么?答案是肯定的,老的工作流中存在的问题主要就是:


dev 分支过于重要


dev 需要接受来自多个 feat 以及 hotfix、master 的合并,并合并到 release 分支,这就会导致 dev 分支出现冲突的概率是成倍增加的。开发人员越多,其中存在的脏代码就越多,分支就越不稳定,冲突的情况就越多。


而这套新流程中 dev 的职责被弱化了,变得更加纯粹,即只对接测试环境的发布,其他的工作一概不管。也就是说 dev 本身就是合并路径的终点,从而消除了合并 commit 的回环,干掉了很多可能会产生迷惑冲突的场景。


从普通开发人员的视角看一下


现在我们从头开始,以普通开发的身份来走一遍这套流程,看会有什么效果:



  • 昨天版本发布了,master 代码上有了新的 commit,于是你执行了 git fetch会把远程的代码都同步到本地,比如远程的 master 分支同步到本地的 origin/master

  • 早上开会的时候给你安排了功能 a 和功能 b,你决定先做 a,于是你执行了 git checkout -b feat/a origin/master从刚才拉下来的 origin/master 分支创建了一个新分支

  • 你开始开发,随着开发进度的增加,中间可能执行了多次 git addgit commit

  • 几个小时后终于把功能做好了,自测也没问题,你决定发到测试环境让 QA 同事看一下,于是你执行了 git push 并且在远程仓库里提交了 feat/a 到 dev 分支的 pr,合并完成后流水线自动把代码发布到了测试环境。

  • 通知了 QA 之后,你决定开始开发 b 功能,于是你执行了 git checkout -b feat/b origin/master,然后开始开发。

  • 突然 QA 通知你功能 a 有 bug 需要修复,于是你执行了 git stash 把当前手头的工作暂存了起来,然后 git checkout feat/a 开始解决 bug。

  • 解决完了之后,你重新 git commitgit push 到了 dev 分支,QA 开始继续测试,你也切回了 feat/b 分支并 git stash pop 开始继续开发。

  • 过了一会,QA 通知你功能 a 测试没问题了,于是你在远程仓库里找到 feat/a 分支,并直接发起了一个到 release 分支的 pr。此时 release 分支触发了流水线,将功能 a 的代码更新到了预发环境。

  • 搞完之后,你切回 feat/b 分支继续开始功能 b 的开发...


故事到这里就结束了,你可能会好奇:版本发布的时候呢?不需要执行什么操作?


是的不需要。这套流程中发布生产环境极其简单。因为功能测试完成后会直接推到 release 分支。也就是说,只要和 release 分支绑定的环境(例如 uat)测试没问题,那么发布的时候只需要把 release 合并到 master 就行了。不会出现之前那种要在发版前检查很久要发布哪些 commit 的情况。


一些疑问解答


在实践过程中也有很多同事对这套流程产生了或多或少的疑问,这里就记录一下,希望对大家有帮助:


1、代码提交到 release 分支后出现 bug 怎么办?


切换到对应的分支(例如 feat/c),提交新的 commit 之后从 feat/c 合并到 dev,dev 测试没问题后从 feat/c 合并到 release 分支。


2、feat 分支合并到 dev 分支的时候代码冲突了怎么办?



首先,代码冲突很正常,没有任何一个工作流能完全避免代码冲突。我们应该尽力避免因工作流本身的问题产生的“令人困惑”的代码冲突。



比较正规的做法是:从最新的 dev 创建一个新分支,例如 dev-feat/a,然后把你的 feat/a 本地合并到 dev-feat/a 并解决冲突,然后 git push dev-feat/a 并在远程仓库发起 dev-feat/a 到 dev 的 pr。


比较随性的做法是:本地切到 dev 分支,git pull --rebase 拉取最新代码,然后直接 git rebase feat/a 解决冲突后直接 git push 到远程仓库的 dev 分支。


有些人可能会有疑问:"直接 push 到这种环境分支没问题么,之前我们这种分支都是写保护的,只能接受 pr"。


确实,老的工作流对环境分支的保护都是比较严格的,但是这一套工作流没有这些限制,因为最遭的情况也就是你把 dev 分支搞崩了。那直接把远程 dev 分支删掉再从 master 或者 release 分支拉一个就完事了嘛,反正大家的功能都在各自的 feat 分支上。再极端一点,只要你不搞坏其他人的代码,你就算直接 git push --force 强制推送到 dev 分支都没问题。


3、同事 A 和 B 的新功能要基于同事 C 的新代码,这时候怎么办?


假设同事 C 开发的功能在 feat/c,那么同事 A 和 B 的分支就应该从 feat/c 创建并继续开发。而不是等同事 C 合并到 dev 之后再从 dev 创建。


4、既然是 feat 直接合并到指定分支,那么为什么最后一步不是 feat 分支合并到 master 分支呢?


因为这套流程里,最重要的就是保证 master 分支的稳定性。所以 master 分支上的代码必须是经过严格验证的。


并且如果 feat 直接合到 master 的话还会导致一些其他的问题:



  • 有一个同事比较粗心,在提交 pr 的时候本来该合到 dev 分支,结果一不小心点到了 master,审核的人有不注意直接点了同意,这时候 master 就被污染了。

  • 合并到 dev 时如果出现合并冲突的话,那么合并到 release 分支大概率也会再出现一遍,你总不会想合到 master 的时候去解决第三遍吧,而且也无法保证冲突的解决一定是不会出问题的。


所以说,最稳妥,最省心的做法就是直接把 release 分支的代码合并到 master。


5、hotfix master 怎么办?


git flow 里 hotfix 分支中的 commit 一方面要合并到 master,另一方面要同步到 dev。但是由于后续 dev 也要再次更新到 master,这个 hotfix 的 commit 就可能会导致困惑冲突。


但是这套新流程里就不会出现冲突,因为 dev 分支自己就已经是终点了,不会合并到其他分支。所以 hotfix 里的提交无论怎么合并到 dev,不管是 merge、rebase 还是 cherry-pick,都是可以的。甚至不用管也没关系,因为只要是新 feat 合并到 dev,这个 hotfix commit 就被自动携带过来了。


总结


其实这一套工作流其实是 gitlab flow + git flow 的一个调优,使其在保证效率的同时更贴近 git 新手的心理认知。总结一下就是 dev 分支并不会“晋升”到 release 分支。而是由 feat 分支发起到 release 分支的合并,同时 master 只接受来自 release 的合并,由此减少了很多需要遵守的规则和发生冲突的情况。


参考



作者:HOHO
来源:juejin.cn/post/7355845860683202595
收起阅读 »