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

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


  • 首页

  • 归档

  • 搜索

如何优雅地记录操作日志?

发表于 2021-09-18

操作日志广泛存在于各个B端和一些C端系统中,比如:客服可以根据工单的操作日志快速知道哪些人对这个工单做了哪些操作,进而快速地定位问题。操作日志和系统日志不一样,操作日志必须要做到简单易懂。所以如何让操作日志不和业务逻辑耦合,如何让操作日志的内容易于理解,让操作日志的接入更加简单?上面这些都是本文要回答的问题,主要围绕着如何“优雅”地记录操作日志展开描述。

  1. 操作日志的使用场景

例子

系统日志和操作日志的区别

系统日志:系统日志主要是为开发排查问题提供依据,一般打印在日志文件中;系统日志的可读性要求没那么高,日志中会包含代码的信息,比如在某个类的某一行打印了一个日志。

操作日志:主要是对某个对象进行新增操作或者修改操作后记录下这个新增或者修改,操作日志要求可读性比较强,因为它主要是给用户看的,比如订单的物流信息,用户需要知道在什么时间发生了什么事情。再比如,客服对工单的处理记录信息。

操作日志的记录格式大概分为下面几种:

  • 单纯的文字记录,比如:2021-09-16 10:00 订单创建。
  • 简单的动态的文本记录,比如:2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号“NO.11089999”。
  • 修改类型的文本,包含修改前和修改后的值,比如:2021-09-16 10:00 用户小明修改了订单的配送地址:从“金灿灿小区”修改到“银盏盏小区” ,其中涉及变量配送的原地址“金灿灿小区”和新地址“银盏盏小区”。
  • 修改表单,一次会修改多个字段。
  1. 实现方式

2.1 使用 Canal 监听数据库记录操作日志

Canal 是一款基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费的开源组件,通过采用监听数据库 Binlog 的方式,这样可以从底层知道是哪些数据做了修改,然后根据更改的数据记录操作日志。

这种方式的优点是和业务逻辑完全分离。缺点也很明显,局限性太高,只能针对数据库的更改做操作日志记录,如果修改涉及到其他团队的 RPC 的调用,就没办法监听数据库了,举个例子:给用户发送通知,通知服务一般都是公司内部的公共组件,这时候只能在调用 RPC 的时候手工记录发送通知的操作日志了。

2.2 通过日志文件的方式记录

1
2
3
c复制代码log.info("订单创建")
log.info("订单已经创建,订单编号:{}", orderNo)
log.info("修改了订单的配送地址:从“{}”修改到“{}”, "金灿灿小区", "银盏盏小区")

这种方式的操作记录需要解决三个问题。

问题一:操作人如何记录

借助 SLF4J 中的 MDC 工具类,把操作人放在日志中,然后在日志中统一打印出来。首先在用户的拦截器中把用户的标识 Put 到 MDC 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Component
public class UserInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取到用户标识
String userNo = getUserNo(request);
//把用户 ID 放到 MDC 上下文中
MDC.put("userId", userNo);
return super.preHandle(request, response, handler);
}

private String getUserNo(HttpServletRequest request) {
// 通过 SSO 或者Cookie 或者 Auth信息获取到 当前登陆的用户信息
return null;
}
}

其次,把 userId 格式化到日志中,使用 %X{userId} 可以取到 MDC 中用户标识。

1
perl复制代码<pattern>"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"</pattern>

问题二:操作日志如何和系统日志区分开

通过配置 Log 的配置文件,把有关操作日志的 Log 单独放到一日志文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
xml复制代码//不同业务日志记录到不同的文件
<appender name="businessLogAppender" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>logs/business.log</File>
<append>true</append>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/业务A.%d.%i.log</fileNamePattern>
<maxHistory>90</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>10MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
<encoder>
<pattern>"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>

<logger name="businessLog" additivity="false" level="INFO">
<appender-ref ref="businessLogAppender"/>
</logger>

然后在 Java 代码中单独的记录业务日志。

1
2
3
4
5
java复制代码//记录特定日志的声明
private final Logger businessLog = LoggerFactory.getLogger("businessLog");

//日志存储
businessLog.info("修改了配送地址");

问题三:如何生成可读懂的日志文案

可以采用 LogUtil 的方式,也可以采用切面的方式生成日志模板,后续内容将会进行介绍。这样就可以把日志单独保存在一个文件中,然后通过日志收集可以把日志保存在 Elasticsearch 或者数据库中,接下来看下如何生成可读的操作日志。

2.3 通过 LogUtil 的方式记录日志

1
2
3
4
java复制代码  LogUtil.log(orderNo, "订单创建", "小明")模板
LogUtil.log(orderNo, "订单创建,订单号"+"NO.11089999", "小明")
String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”"
LogUtil.log(orderNo, String.format(tempalte, "小明", "金灿灿小区", "银盏盏小区"), "小明")

这里解释下为什么记录操作日志的时候都绑定了一个 OrderNo,因为操作日志记录的是:某一个“时间”“谁”对“什么”做了什么“事情”。当查询业务的操作日志的时候,会查询针对这个订单的的所有操作,所以代码中加上了 OrderNo,记录操作日志的时候需要记录下操作人,所以传了操作人“小明”进来。

上面看起来问题并不大,在修改地址的业务逻辑方法中使用一行代码记录了操作日志,接下来再看一个更复杂的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码private OnesIssueDO updateAddress(updateDeliveryRequest request) {
DeliveryOrder deliveryOrder = deliveryQueryService.queryOldAddress(request.getDeliveryOrderNo());
// 更新派送信息,电话,收件人,地址
doUpdate(request);
String logContent = getLogContent(request, deliveryOrder);
LogUtils.logRecord(request.getOrderNo(), logContent, request.getOperator);
return onesIssueDO;
}

private String getLogContent(updateDeliveryRequest request, DeliveryOrder deliveryOrder) {
String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”";
return String.format(tempalte, request.getUserName(), deliveryOrder.getAddress(), request.getAddress);
}

可以看到上面的例子使用了两个方法代码,外加一个 getLogContent 的函数实现了操作日志的记录。当业务变得复杂后,记录操作日志放在业务代码中会导致业务的逻辑比较繁杂,最后导致 LogUtils.logRecord() 方法的调用存在于很多业务的代码中,而且类似 getLogContent() 这样的方法也散落在各个业务类中,对于代码的可读性和可维护性来说是一个灾难。下面介绍下如何避免这个灾难。

2.4 方法注解实现操作日志

为了解决上面问题,一般采用 AOP 的方式记录日志,让操作日志和业务逻辑解耦,接下来看一个简单的 AOP 日志的例子。

1
2
3
4
5
java复制代码@LogRecord(content="修改了配送地址")
public void modifyAddress(updateDeliveryRequest request){
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

我们可以在注解的操作日志上记录固定文案,这样业务逻辑和业务代码可以做到解耦,让我们的业务代码变得纯净起来。可能有同学注意到,上面的方式虽然解耦了操作日志的代码,但是记录的文案并不符合我们的预期,文案是静态的,没有包含动态的文案,因为我们需要记录的操作日志是: 用户%s修改了订单的配送地址,从“%s”修改到“%s”。接下来,我们介绍一下如何优雅地使用 AOP 生成动态的操作日志。

  1. 优雅地支持 AOP 生成动态的操作日志

3.1 动态模板

一提到动态模板,就会涉及到让变量通过占位符的方式解析模板,从而达到通过注解记录操作日志的目的。模板解析的方式有很多种,这里使用了 SpEL(Spring Expression Language,Spring表达式语言)来实现。我们可以先写下期望的记录日志的方式,然后再看下能否实现这样的功能。

1
2
3
4
5
java复制代码@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

通过 SpEL 表达式引用方法上的参数,可以让变量填充到模板中达到动态的操作日志文本内容。
但是现在还有几个问题需要解决:

  • 操作日志需要知道是哪个操作人修改的订单配送地址。
  • 修改订单配送地址的操作日志需要绑定在配送的订单上,从而可以根据配送订单号查询出对这个配送订单的所有操作。
  • 为了在注解上记录之前的配送地址是什么,在方法签名上添加了一个和业务无关的 oldAddress 的变量,这样就不优雅了。

为了解决前两个问题,我们需要把期望的操作日志使用形式改成下面的方式:

1
2
3
4
5
6
7
java复制代码@LogRecord(
content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”",
operator = "#request.userName", bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

修改后的代码在注解上添加两个参数,一个是操作人,一个是操作日志需要绑定的对象。但是,在普通的 Web 应用中用户信息都是保存在一个线程上下文的静态方法中,所以 operator 一般是这样的写法(假定获取当前登陆用户的方式是 UserContext.getCurrentUser())。

1
java复制代码operator = "#{T(com.meituan.user.UserContext).getCurrentUser()}"

这样的话,每个 @LogRecord 的注解上的操作人都是这么长一串。为了避免过多的重复代码,我们可以把注解上的 operator 参数设置为非必填,这样用户可以填写操作人。但是,如果用户不填写我们就取 UserContext 的 user(下文会介绍如何取 user )。最后,最简单的日志变成了下面的形式:

1
2
3
4
5
6
java复制代码@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”", 
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

接下来,我们需要解决第三个问题:为了记录业务操作记录添加了一个 oldAddress 变量,不管怎么样这都不是一个好的实现方式,所以接下来,我们需要把 oldAddress 变量从修改地址的方法签名上去掉。但是操作日志确实需要 oldAddress 变量,怎么办呢?

要么和产品经理 PK 一下,让产品经理把文案从“修改了订单的配送地址:从 xx 修改到 yy” 改为 “修改了订单的配送地址为:yy”。但是从用户体验上来看,第一种文案更人性化一些,显然我们不会 PK 成功的。那么我们就必须要把这个 oldAddress 查询出来然后供操作日志使用了。还有一种解决办法是:把这个参数放到操作日志的线程上下文中,供注解上的模板使用。我们按照这个思路再改下操作日志的实现代码。

1
2
3
4
5
6
7
8
java复制代码@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”",
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
// 查询出原来的地址是什么
LogRecordContext.putVariable("oldAddress", DeliveryService.queryOldAddress(request.getDeliveryOrderNo()));
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

这时候可以看到,LogRecordContext 解决了操作日志模板上使用方法参数以外变量的问题,同时避免了为了记录操作日志修改方法签名的设计。虽然已经比之前的代码好了些,但是依然需要在业务代码里面加了一行业务逻辑无关的代码,如果有“强迫症”的同学还可以继续往下看,接下来我们会讲解自定义函数的解决方案。下面再看另一个例子:

1
2
3
4
5
6
7
8
java复制代码@LogRecord(content = "修改了订单的配送员:从“#oldDeliveryUserId”, 修改到“#request.userId”",
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
// 查询出原来的地址是什么
LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

这个操作日志的模板最后记录的内容是这样的格式:修改了订单的配送员:从 “10090”,修改到 “10099”,显然用户看到这样的操作日志是不明白的。用户对于用户 ID 是 10090 还是 10099 并不了解,用户期望看到的是:修改了订单的配送员:从“张三(18910008888)”,修改到“小明(13910006666)”。用户关心的是配送员的姓名和电话。但是我们方法中传递的参数只有配送员的 ID,没有配送员的姓名可电话。我们可以通过上面的方法,把用户的姓名和电话查询出来,然后通过 LogRecordContext 实现。

但是,“强迫症”是不期望操作日志的代码嵌入在业务逻辑中的。接下来,我们考虑另一种实现方式:自定义函数。如果我们可以通过自定义函数把用户 ID 转换为用户姓名和电话,那么就能解决这一问题,按照这个思路,我们把模板修改为下面的形式:

1
2
3
4
5
6
7
8
java复制代码@LogRecord(content = "修改了订单的配送员:从“{deliveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.userId}}”",
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
// 查询出原来的地址是什么
LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

其中 deliveryUser 是自定义函数,使用大括号把 Spring 的 SpEL 表达式包裹起来,这样做的好处:一是把 SpEL(Spring Expression Language,Spring表达式语言)和自定义函数区分开便于解析;二是如果模板中不需要 SpEL 表达式解析可以容易的识别出来,减少 SpEL 的解析提高性能。这时候我们发现上面代码还可以优化成下面的形式:

1
2
3
4
5
6
java复制代码@LogRecord(content = "修改了订单的配送员:从“{queryOldUser{#request.deliveryOrderNo()}}”, 修改到“{deveryUser{#request.userId}}”",
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

这样就不需要在 modifyAddress 方法中通过 LogRecordContext.putVariable() 设置老的快递员了,通过直接新加一个自定义函数 queryOldUser() 参数把派送订单传递进去,就能查到之前的配送人了,只需要让方法的解析在 modifyAddress() 方法执行之前运行。这样的话,我们让业务代码又变得纯净了起来,同时也让“强迫症”不再感到难受了。

  1. 代码实现解析

4.1 代码结构

上面的操作日志主要是通过一个 AOP 拦截器实现的,整体主要分为 AOP 模块、日志解析模块、日志保存模块、Starter 模块;组件提供了4个扩展点,分别是:自定义函数、默认处理人、业务保存和查询;业务可以根据自己的业务特性定制符合自己业务的逻辑。

4.2 模块介绍

有了上面的分析,已经得出一种我们期望的操作日志记录的方式,那么接下来看看如何实现上面的逻辑。实现主要分为下面几个步骤:

  • AOP 拦截逻辑
  • 解析逻辑
    • 模板解析
    • LogContext 逻辑
    • 默认的 operator 逻辑
    • 自定义函数逻辑
  • 默认的日志持久化逻辑
  • Starter 封装逻辑

4.2.1 AOP 拦截逻辑

这块逻辑主要是一个拦截器,针对 @LogRecord 注解分析出需要记录的操作日志,然后把操作日志持久化,这里把注解命名为 @LogRecordAnnotation。接下来,我们看下注解的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecordAnnotation {
String success();

String fail() default "";

String operator() default "";

String bizNo();

String category() default "";

String detail() default "";

String condition() default "";
}

注解中除了上面提到参数外,还增加了 fail、category、detail、condition 等参数,这几个参数是为了满足特定的场景,后面还会给出具体的例子。

参数名 描述 是否必填
success 操作日志的文本模板 是
fail 操作日志失败的文本版本 否
operator 操作日志的执行人 否
bizNo 操作日志绑定的业务对象标识 是
category 操作日志的种类 否
detail 扩展参数,记录操作日志的修改详情 否
condition 记录日志的条件 否

为了保持简单,组件的必填参数就两个。业务中的 AOP 逻辑大部分是使用 @Aspect 注解实现的,但是基于注解的 AOP 在 Spring boot 1.5 中兼容性是有问题的,组件为了兼容 Spring boot1.5 的版本我们手工实现 Spring 的 AOP 逻辑。

切面选择 AbstractBeanFactoryPointcutAdvisor 实现,切点是通过 StaticMethodMatcherPointcut 匹配包含 LogRecordAnnotation 注解的方法。通过实现 MethodInterceptor 接口实现操作日志的增强逻辑。

下面是拦截器的切点逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public class LogRecordPointcut extends StaticMethodMatcherPointcut implements Serializable {
// LogRecord的解析类
private LogRecordOperationSource logRecordOperationSource;

@Override
public boolean matches(@NonNull Method method, @NonNull Class<?> targetClass) {
// 解析 这个 method 上有没有 @LogRecordAnnotation 注解,有的话会解析出来注解上的各个参数
return !CollectionUtils.isEmpty(logRecordOperationSource.computeLogRecordOperations(method, targetClass));
}

void setLogRecordOperationSource(LogRecordOperationSource logRecordOperationSource) {
this.logRecordOperationSource = logRecordOperationSource;
}
}

切面的增强逻辑主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
java复制代码@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
// 记录日志
return execute(invocation, invocation.getThis(), method, invocation.getArguments());
}

private Object execute(MethodInvocation invoker, Object target, Method method, Object[] args) throws Throwable {
Class<?> targetClass = getTargetClass(target);
Object ret = null;
MethodExecuteResult methodExecuteResult = new MethodExecuteResult(true, null, "");
LogRecordContext.putEmptySpan();
Collection<LogRecordOps> operations = new ArrayList<>();
Map<String, String> functionNameAndReturnMap = new HashMap<>();
try {
operations = logRecordOperationSource.computeLogRecordOperations(method, targetClass);
List<String> spElTemplates = getBeforeExecuteFunctionTemplate(operations);
//业务逻辑执行前的自定义函数解析
functionNameAndReturnMap = processBeforeExecuteFunctionTemplate(spElTemplates, targetClass, method, args);
} catch (Exception e) {
log.error("log record parse before function exception", e);
}
try {
ret = invoker.proceed();
} catch (Exception e) {
methodExecuteResult = new MethodExecuteResult(false, e, e.getMessage());
}
try {
if (!CollectionUtils.isEmpty(operations)) {
recordExecute(ret, method, args, operations, targetClass,
methodExecuteResult.isSuccess(), methodExecuteResult.getErrorMsg(), functionNameAndReturnMap);
}
} catch (Exception t) {
//记录日志错误不要影响业务
log.error("log record parse exception", t);
} finally {
LogRecordContext.clear();
}
if (methodExecuteResult.throwable != null) {
throw methodExecuteResult.throwable;
}
return ret;
}

拦截逻辑的流程:

可以看到,操作日志的记录持久化是在方法执行完之后执行的,当方法抛出异常之后会先捕获异常,等操作日志持久化完成后再抛出异常。在业务的方法执行之前,会对提前解析的自定义函数求值,解决了前面提到的需要查询修改之前的内容。

4.2.2 解析逻辑

模板解析

Spring 3 提供了一个非常强大的功能:Spring EL,SpEL 在 Spring 产品中是作为表达式求值的核心基础模块,它本身是可以脱离 Spring 独立使用的。举个例子:

1
2
3
4
5
6
7
java复制代码public static void main(String[] args) {
SpelExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression("#root.purchaseName");
Order order = new Order();
order.setPurchaseName("张三");
System.out.println(expression.getValue(order));
}

这个方法将打印 “张三”。LogRecord 解析的类图如下:

解析核心类:LogRecordValueParser 里面封装了自定义函数和 SpEL 解析类 LogRecordExpressionEvaluator。

1
2
3
4
5
6
7
8
9
10
java复制代码public class LogRecordExpressionEvaluator extends CachedExpressionEvaluator {

private Map<ExpressionKey, Expression> expressionCache = new ConcurrentHashMap<>(64);

private final Map<AnnotatedElementKey, Method> targetMethodCache = new ConcurrentHashMap<>(64);

public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) {
return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
}
}

LogRecordExpressionEvaluator 继承自 CachedExpressionEvaluator 类,这个类里面有两个 Map,一个是 expressionCache 一个是 targetMethodCache。在上面的例子中可以看到,SpEL 会解析成一个 Expression 表达式,然后根据传入的 Object 获取到对应的值,所以 expressionCache 是为了缓存方法、表达式和 SpEL 的 Expression 的对应关系,让方法注解上添加的 SpEL 表达式只解析一次。 下面的 targetMethodCache 是为了缓存传入到 Expression 表达式的 Object。核心的解析逻辑是上面最后一行代码。

1
java复制代码getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);

getExpression 方法会从 expressionCache 中获取到 @LogRecordAnnotation 注解上的表达式的解析 Expression 的实例,然后调用 getValue 方法,getValue 传入一个 evalContext 就是类似上面例子中的 order 对象。其中 Context 的实现将会在下文介绍。

日志上下文实现

下面的例子把变量放到了 LogRecordContext 中,然后 SpEL 表达式就可以顺利的解析方法上不存在的参数了,通过上面的 SpEL 的例子可以看出,要把方法的参数和 LogRecordContext 中的变量都放到 SpEL 的 getValue 方法的 Object 中才可以顺利的解析表达式的值。下面看下如何实现:

1
2
3
4
5
6
7
8
java复制代码@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
// 查询出原来的地址是什么
LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

在 LogRecordValueParser 中创建了一个 EvaluationContext,用来给 SpEL 解析方法参数和 Context 中的变量。相关代码如下:

1
2
java复制代码
EvaluationContext evaluationContext = expressionEvaluator.createEvaluationContext(method, args, targetClass, ret, errorMsg, beanFactory);

在解析的时候调用 getValue 方法传入的参数 evalContext,就是上面这个 EvaluationContext 对象。下面是 LogRecordEvaluationContext 对象的继承体系:

LogRecordEvaluationContext 做了三个事情:

  • 把方法的参数都放到 SpEL 解析的 RootObject 中。
  • 把 LogRecordContext 中的变量都放到 RootObject 中。
  • 把方法的返回值和 ErrorMsg 都放到 RootObject 中。

LogRecordEvaluationContext 的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public class LogRecordEvaluationContext extends MethodBasedEvaluationContext {

public LogRecordEvaluationContext(Object rootObject, Method method, Object[] arguments,
ParameterNameDiscoverer parameterNameDiscoverer, Object ret, String errorMsg) {
//把方法的参数都放到 SpEL 解析的 RootObject 中
super(rootObject, method, arguments, parameterNameDiscoverer);
//把 LogRecordContext 中的变量都放到 RootObject 中
Map<String, Object> variables = LogRecordContext.getVariables();
if (variables != null && variables.size() > 0) {
for (Map.Entry<String, Object> entry : variables.entrySet()) {
setVariable(entry.getKey(), entry.getValue());
}
}
//把方法的返回值和 ErrorMsg 都放到 RootObject 中
setVariable("_ret", ret);
setVariable("_errorMsg", errorMsg);
}
}

下面是 LogRecordContext 的实现,这个类里面通过一个 ThreadLocal 变量保持了一个栈,栈里面是个 Map,Map 对应了变量的名称和变量的值。

1
2
3
4
5
java复制代码public class LogRecordContext {

private static final InheritableThreadLocal<Stack<Map<String, Object>>> variableMapStack = new InheritableThreadLocal<>();
//其他省略....
}

上面使用了 InheritableThreadLocal,所以在线程池的场景下使用 LogRecordContext 会出现问题,如果支持线程池可以使用阿里巴巴开源的 TTL 框架。那这里为什么不直接设置一个 ThreadLocal<Map<String, Object>> 对象,而是要设置一个 Stack 结构呢?我们看一下这么做的原因是什么。

1
2
3
4
5
6
7
8
java复制代码@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
// 查询出原来的地址是什么
LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 电话,收件人、地址
doUpdate(request);
}

上面代码的执行流程如下:

看起来没有什么问题,但是使用 LogRecordAnnotation 的方法里面嵌套了另一个使用 LogRecordAnnotation 方法的时候,流程就变成下面的形式:

可以看到,当方法二执行了释放变量后,继续执行方法一的 logRecord 逻辑,此时解析的时候 ThreadLocal<Map<String, Object>>的 Map 已经被释放掉,所以方法一就获取不到对应的变量了。方法一和方法二共用一个变量 Map 还有个问题是:如果方法二设置了和方法一相同的变量两个方法的变量就会被相互覆盖。所以最终 LogRecordContext 的变量的生命周期需要是下面的形式:

LogRecordContext 每执行一个方法都会压栈一个 Map,方法执行完之后会 Pop 掉这个 Map,从而避免变量共享和覆盖问题。

默认操作人逻辑

在 LogRecordInterceptor 中 IOperatorGetService 接口,这个接口可以获取到当前的用户。下面是接口的定义:

1
2
3
4
5
6
7
8
9
java复制代码public interface IOperatorGetService {

/**
* 可以在里面外部的获取当前登陆的用户,比如 UserContext.getCurrentUser()
*
* @return 转换成Operator返回
*/
Operator getUser();
}

下面给出了从用户上下文中获取用户的例子:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public class DefaultOperatorGetServiceImpl implements IOperatorGetService {

@Override
public Operator getUser() {
//UserUtils 是获取用户上下文的方法
return Optional.ofNullable(UserUtils.getUser())
.map(a -> new Operator(a.getName(), a.getLogin()))
.orElseThrow(()->new IllegalArgumentException("user is null"));

}
}

组件在解析 operator 的时候,就判断注解上的 operator 是否是空,如果注解上没有指定,我们就从 IOperatorGetService 的 getUser 方法获取了。如果都获取不到,就会报错。

1
2
3
4
5
6
7
8
9
java复制代码String realOperatorId = "";
if (StringUtils.isEmpty(operatorId)) {
if (operatorGetService.getUser() == null || StringUtils.isEmpty(operatorGetService.getUser().getOperatorId())) {
throw new IllegalArgumentException("user is null");
}
realOperatorId = operatorGetService.getUser().getOperatorId();
} else {
spElTemplates = Lists.newArrayList(bizKey, bizNo, action, operatorId, detail);
}

自定义函数逻辑

自定义函数的类图如下:

下面是 IParseFunction 的接口定义:executeBefore 函数代表了自定义函数是否在业务代码执行之前解析,上面提到的查询修改之前的内容。

1
2
3
4
5
6
7
8
9
10
java复制代码public interface IParseFunction {

default boolean executeBefore(){
return false;
}

String functionName();

String apply(String value);
}

ParseFunctionFactory 的代码比较简单,它的功能是把所有的 IParseFunction 注入到函数工厂中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public class ParseFunctionFactory {
private Map<String, IParseFunction> allFunctionMap;

public ParseFunctionFactory(List<IParseFunction> parseFunctions) {
if (CollectionUtils.isEmpty(parseFunctions)) {
return;
}
allFunctionMap = new HashMap<>();
for (IParseFunction parseFunction : parseFunctions) {
if (StringUtils.isEmpty(parseFunction.functionName())) {
continue;
}
allFunctionMap.put(parseFunction.functionName(), parseFunction);
}
}

public IParseFunction getFunction(String functionName) {
return allFunctionMap.get(functionName);
}

public boolean isBeforeFunction(String functionName) {
return allFunctionMap.get(functionName) != null && allFunctionMap.get(functionName).executeBefore();
}
}

DefaultFunctionServiceImpl 的逻辑就是根据传入的函数名称 functionName 找到对应的 IParseFunction,然后把参数传入到 IParseFunction 的 apply 方法上最后返回函数的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public class DefaultFunctionServiceImpl implements IFunctionService {

private final ParseFunctionFactory parseFunctionFactory;

public DefaultFunctionServiceImpl(ParseFunctionFactory parseFunctionFactory) {
this.parseFunctionFactory = parseFunctionFactory;
}

@Override
public String apply(String functionName, String value) {
IParseFunction function = parseFunctionFactory.getFunction(functionName);
if (function == null) {
return value;
}
return function.apply(value);
}

@Override
public boolean beforeFunction(String functionName) {
return parseFunctionFactory.isBeforeFunction(functionName);
}
}

4.2.3 日志持久化逻辑

同样在 LogRecordInterceptor 的代码中引用了 ILogRecordService,这个 Service 主要包含了日志记录的接口。

1
2
3
4
5
6
7
8
9
java复制代码public interface ILogRecordService {
/**
* 保存 log
*
* @param logRecord 日志实体
*/
void record(LogRecord logRecord);

}

业务可以实现这个保存接口,然后把日志保存在任何存储介质上。这里给了一个 2.2 节介绍的通过 log.info 保存在日志文件中的例子,业务可以把保存设置成异步或者同步,可以和业务放在一个事务中保证操作日志和业务的一致性,也可以新开辟一个事务,保证日志的错误不影响业务的事务。业务可以保存在 Elasticsearch、数据库或者文件中,用户可以根据日志结构和日志的存储实现相应的查询逻辑。

1
2
3
4
5
6
7
8
9
java复制代码@Slf4j
public class DefaultLogRecordServiceImpl implements ILogRecordService {

@Override
// @Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(LogRecord logRecord) {
log.info("【logRecord】log={}", logRecord);
}
}

4.2.4 Starter 逻辑封装

上面逻辑代码已经介绍完毕,那么接下来需要把这些组件组装起来,然后让用户去使用。在使用这个组件的时候只需要在 Springboot 的入口上添加一个注解 @EnableLogRecord(tenant = “com.mzt.test”)。其中 tenant 代表租户,是为了多租户使用的。

1
2
3
4
5
6
7
8
9
java复制代码@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableTransactionManagement
@EnableLogRecord(tenant = "com.mzt.test")
public class Main {

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

再看下 EnableLogRecord 的代码,代码中 Import 了 LogRecordConfigureSelector.class,在 LogRecordConfigureSelector 类中暴露了 LogRecordProxyAutoConfiguration 类。

1
2
3
4
5
6
7
8
9
10
java复制代码@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(LogRecordConfigureSelector.class)
public @interface EnableLogRecord {

String tenant();

AdviceMode mode() default AdviceMode.PROXY;
}

LogRecordProxyAutoConfiguration 就是装配上面组件的核心类了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
java复制代码@Configuration
@Slf4j
public class LogRecordProxyAutoConfiguration implements ImportAware {

private AnnotationAttributes enableLogRecord;


@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public LogRecordOperationSource logRecordOperationSource() {
return new LogRecordOperationSource();
}

@Bean
@ConditionalOnMissingBean(IFunctionService.class)
public IFunctionService functionService(ParseFunctionFactory parseFunctionFactory) {
return new DefaultFunctionServiceImpl(parseFunctionFactory);
}

@Bean
public ParseFunctionFactory parseFunctionFactory(@Autowired List<IParseFunction> parseFunctions) {
return new ParseFunctionFactory(parseFunctions);
}

@Bean
@ConditionalOnMissingBean(IParseFunction.class)
public DefaultParseFunction parseFunction() {
return new DefaultParseFunction();
}


@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public BeanFactoryLogRecordAdvisor logRecordAdvisor(IFunctionService functionService) {
BeanFactoryLogRecordAdvisor advisor =
new BeanFactoryLogRecordAdvisor();
advisor.setLogRecordOperationSource(logRecordOperationSource());
advisor.setAdvice(logRecordInterceptor(functionService));
return advisor;
}

@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public LogRecordInterceptor logRecordInterceptor(IFunctionService functionService) {
LogRecordInterceptor interceptor = new LogRecordInterceptor();
interceptor.setLogRecordOperationSource(logRecordOperationSource());
interceptor.setTenant(enableLogRecord.getString("tenant"));
interceptor.setFunctionService(functionService);
return interceptor;
}

@Bean
@ConditionalOnMissingBean(IOperatorGetService.class)
@Role(BeanDefinition.ROLE_APPLICATION)
public IOperatorGetService operatorGetService() {
return new DefaultOperatorGetServiceImpl();
}

@Bean
@ConditionalOnMissingBean(ILogRecordService.class)
@Role(BeanDefinition.ROLE_APPLICATION)
public ILogRecordService recordService() {
return new DefaultLogRecordServiceImpl();
}

@Override
public void setImportMetadata(AnnotationMetadata importMetadata) {
this.enableLogRecord = AnnotationAttributes.fromMap(
importMetadata.getAnnotationAttributes(EnableLogRecord.class.getName(), false));
if (this.enableLogRecord == null) {
log.info("@EnableCaching is not present on importing class");
}
}
}

这个类继承 ImportAware 是为了拿到 EnableLogRecord 上的租户属性,这个类使用变量 logRecordAdvisor 和 logRecordInterceptor 装配了 AOP,同时把自定义函数注入到了 logRecordAdvisor 中。

对外扩展类:分别是IOperatorGetService、ILogRecordService、IParseFunction。业务可以自己实现相应的接口,因为配置了 @ConditionalOnMissingBean,所以用户的实现类会覆盖组件内的默认实现。

  1. 总结

这篇文章介绍了操作日志的常见写法,以及如何让操作日志的实现更加简单、易懂;通过组件的四个模块,介绍了组件的具体实现。对于上面的组件介绍,大家如果有疑问,也欢迎在文末留言,我们会进行答疑。

  1. 作者简介

站通,2020年加入美团,基础研发平台/研发质量及效率部工程师。

  1. 参考资料

  • Canal
  • spring-framework
  • Spring Expression Language (SpEL)
  • ThreadLocal、InheritableThreadLocal、TransmittableThreadLocal三者之间区别
  1. 招聘信息

美团研发质量及效率部 ,致力于建设业界一流的持续交付平台,现招聘基础组件方向相关的工程师,坐标北京/上海。欢迎感兴趣的同学加入。可投递简历至:chao.yu@meituan.com(邮件主题请注明:美团研发质量及效率部)。

阅读美团技术团队更多技术文章合集

前端 | 算法 | 后端 | 数据 | 安全 | 运维 | iOS | Android | 测试

| 在公众号菜单栏对话框回复【2020年货】、【2019年货】、【2018年货】、【2017年货】等关键词,可查看美团技术团队历年技术文章合集。

| 本文系美团技术团队出品,著作权归属美团。欢迎出于分享和交流等非商业目的转载或使用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者使用。任何商用行为,请发送邮件至tech@meituan.com申请授权。

本文转载自: 掘金

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

Spring Boot中有多个Async异步任务时,记得做

发表于 2021-09-18

通过上一篇:配置@Async异步任务的线程池的介绍,你应该已经了解到异步任务的执行背后有一个线程池来管理执行任务。为了控制异步任务的并发不影响到应用的正常运作,我们必须要对线程池做好相应的配置,防止资源的过渡使用。除了默认线程池的配置之外,还有一类场景,也是很常见的,那就是多任务情况下的线程池隔离。

什么是线程池的隔离,为什么要隔离

可能有的小伙伴还不太了解什么是线程池的隔离,为什么要隔离?。所以,我们先来看看下面的场景案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码@RestController
public class HelloController {

@Autowired
private AsyncTasks asyncTasks;

@GetMapping("/api-1")
public String taskOne() {
CompletableFuture<String> task1 = asyncTasks.doTaskOne("1");
CompletableFuture<String> task2 = asyncTasks.doTaskOne("2");
CompletableFuture<String> task3 = asyncTasks.doTaskOne("3");

CompletableFuture.allOf(task1, task2, task3).join();
return "";
}

@GetMapping("/api-2")
public String taskTwo() {
CompletableFuture<String> task1 = asyncTasks.doTaskTwo("1");
CompletableFuture<String> task2 = asyncTasks.doTaskTwo("2");
CompletableFuture<String> task3 = asyncTasks.doTaskTwo("3");

CompletableFuture.allOf(task1, task2, task3).join();
return "";
}

}

上面的代码中,有两个API接口,这两个接口的具体执行逻辑中都会把执行过程拆分为三个异步任务来实现。

好了,思考一分钟,想一下。如果这样实现,会有什么问题吗?


上面这段代码,在API请求并发不高,同时如果每个任务的处理速度也够快的时候,是没有问题的。但如果并发上来或其中某几个处理过程扯后腿了的时候。这两个提供不相干服务的接口可能会互相影响。比如:假设当前线程池配置的最大线程数有2个,这个时候/api-1接口中task1和task2处理速度很慢,阻塞了;那么此时,当用户调用api-2接口的时候,这个服务也会阻塞!

造成这种现场的原因是:默认情况下,所有用@Async创建的异步任务都是共用的一个线程池,所以当有一些异步任务碰到性能问题的时候,是会直接影响其他异步任务的。

为了解决这个问题,我们就需要对异步任务做一定的线程池隔离,让不同的异步任务互不影响。

不同异步任务配置不同线程池

下面,我们就来实际操作一下!

第一步:初始化多个线程池,比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码@EnableAsync
@Configuration
public class TaskPoolConfig {

@Bean
public Executor taskExecutor1() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(10);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("executor-1-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}

@Bean
public Executor taskExecutor2() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(10);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("executor-2-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}

注意:这里特地用executor.setThreadNamePrefix设置了线程名的前缀,这样可以方便观察后面具体执行的顺序。

第二步:创建异步任务,并指定要使用的线程池名称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码@Slf4j
@Component
public class AsyncTasks {

public static Random random = new Random();

@Async("taskExecutor1")
public CompletableFuture<String> doTaskOne(String taskNo) throws Exception {
log.info("开始任务:{}", taskNo);
long start = System.currentTimeMillis();
Thread.sleep(random.nextInt(10000));
long end = System.currentTimeMillis();
log.info("完成任务:{},耗时:{} 毫秒", taskNo, end - start);
return CompletableFuture.completedFuture("任务完成");
}

@Async("taskExecutor2")
public CompletableFuture<String> doTaskTwo(String taskNo) throws Exception {
log.info("开始任务:{}", taskNo);
long start = System.currentTimeMillis();
Thread.sleep(random.nextInt(10000));
long end = System.currentTimeMillis();
log.info("完成任务:{},耗时:{} 毫秒", taskNo, end - start);
return CompletableFuture.completedFuture("任务完成");
}

}

这里@Async注解中定义的taskExecutor1和taskExecutor2就是线程池的名字。由于在第一步中,我们没有具体写两个线程池Bean的名称,所以默认会使用方法名,也就是taskExecutor1和taskExecutor2。

第三步:写个单元测试来验证下,比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码@Slf4j
@SpringBootTest
public class Chapter77ApplicationTests {

@Autowired
private AsyncTasks asyncTasks;

@Test
public void test() throws Exception {
long start = System.currentTimeMillis();

// 线程池1
CompletableFuture<String> task1 = asyncTasks.doTaskOne("1");
CompletableFuture<String> task2 = asyncTasks.doTaskOne("2");
CompletableFuture<String> task3 = asyncTasks.doTaskOne("3");

// 线程池2
CompletableFuture<String> task4 = asyncTasks.doTaskTwo("4");
CompletableFuture<String> task5 = asyncTasks.doTaskTwo("5");
CompletableFuture<String> task6 = asyncTasks.doTaskTwo("6");

// 一起执行
CompletableFuture.allOf(task1, task2, task3, task4, task5, task6).join();

long end = System.currentTimeMillis();

log.info("任务全部完成,总耗时:" + (end - start) + "毫秒");
}

}

在上面的单元测试中,一共启动了6个异步任务,前三个用的是线程池1,后三个用的是线程池2。

先不执行,根据设置的核心线程2和最大线程数2,来分析一下,大概会是怎么样的执行情况?

  1. 线程池1的三个任务,task1和task2会先获得执行线程,然后task3因为没有可分配线程进入缓冲队列
  2. 线程池2的三个任务,task4和task5会先获得执行线程,然后task6因为没有可分配线程进入缓冲队列
  3. 任务task3会在task1或task2完成之后,开始执行
  4. 任务task6会在task4或task5完成之后,开始执行

分析好之后,执行下单元测试,看看是否是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
yaml复制代码2021-09-15 23:45:11.369  INFO 61670 --- [   executor-1-1] com.didispace.chapter77.AsyncTasks       : 开始任务:1
2021-09-15 23:45:11.369 INFO 61670 --- [ executor-2-2] com.didispace.chapter77.AsyncTasks : 开始任务:5
2021-09-15 23:45:11.369 INFO 61670 --- [ executor-2-1] com.didispace.chapter77.AsyncTasks : 开始任务:4
2021-09-15 23:45:11.369 INFO 61670 --- [ executor-1-2] com.didispace.chapter77.AsyncTasks : 开始任务:2
2021-09-15 23:45:15.905 INFO 61670 --- [ executor-2-1] com.didispace.chapter77.AsyncTasks : 完成任务:4,耗时:4532 毫秒
2021-09-15 23:45:15.905 INFO 61670 --- [ executor-2-1] com.didispace.chapter77.AsyncTasks : 开始任务:6
2021-09-15 23:45:18.263 INFO 61670 --- [ executor-1-2] com.didispace.chapter77.AsyncTasks : 完成任务:2,耗时:6890 毫秒
2021-09-15 23:45:18.263 INFO 61670 --- [ executor-1-2] com.didispace.chapter77.AsyncTasks : 开始任务:3
2021-09-15 23:45:18.896 INFO 61670 --- [ executor-2-2] com.didispace.chapter77.AsyncTasks : 完成任务:5,耗时:7523 毫秒
2021-09-15 23:45:19.842 INFO 61670 --- [ executor-1-2] com.didispace.chapter77.AsyncTasks : 完成任务:3,耗时:1579 毫秒
2021-09-15 23:45:20.551 INFO 61670 --- [ executor-1-1] com.didispace.chapter77.AsyncTasks : 完成任务:1,耗时:9178 毫秒
2021-09-15 23:45:24.117 INFO 61670 --- [ executor-2-1] com.didispace.chapter77.AsyncTasks : 完成任务:6,耗时:8212 毫秒
2021-09-15 23:45:24.117 INFO 61670 --- [ main] c.d.chapter77.Chapter77ApplicationTests : 任务全部完成,总耗时:12762毫秒

好了,今天的学习就到这里!如果您学习过程中如遇困难?可以加入我们超高质量的Spring技术交流群,参与交流与讨论,更好的学习与进步!更多Spring Boot教程可以点击直达!,欢迎收藏与转发支持!

代码示例

本文的完整工程可以查看下面仓库中2.x目录下的chapter7-7工程:

  • Github:github.com/dyc87112/Sp…
  • Gitee:gitee.com/didispace/S…

如果您觉得本文不错,欢迎Star支持,您的关注是我坚持的动力!

欢迎关注我的公众号:程序猿DD,分享外面看不到的干货与思考!

本文转载自: 掘金

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

SpringBoot + ShardingSphere-JD

发表于 2021-09-17

引入依赖

我本地用的是 springboot 2 的版本,引用的 ShardingSphere-JDBC 的5.0.0-beta 版本

1
2
3
4
5
xml复制代码 <dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.0.0-beta</version>
</dependency>

修改配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
yaml复制代码spring:
profiles:
include: common-local
shardingsphere:
datasource:
names: write-ds,read-ds-0
write-ds:
jdbcUrl: jdbc:mysql://mysql.local.test.myapp.com:23306/test?allowPublicKeyRetrieval=true&useSSL=false&allowMultiQueries=true&serverTimezone=Asia/Shanghai&useSSL=false&autoReconnect=true&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: nicai
connectionTimeoutMilliseconds: 3000
idleTimeoutMilliseconds: 60000
maxLifetimeMilliseconds: 1800000
maxPoolSize: 50
minPoolSize: 1
maintenanceIntervalMilliseconds: 30000
read-ds-0:
jdbcUrl: jdbc:mysql://mysql.local.test.read1.myall.com:23306/test?allowPublicKeyRetrieval=true&useSSL=false&allowMultiQueries=true&serverTimezone=Asia/Shanghai&useSSL=false&autoReconnect=true&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: nicai
connectionTimeoutMilliseconds: 3000
idleTimeoutMilliseconds: 60000
maxLifetimeMilliseconds: 1800000
maxPoolSize: 50
minPoolSize: 1
maintenanceIntervalMilliseconds: 30000

rules:
readwrite-splitting:
data-sources:
glapp:
write-data-source-name: write-ds
read-data-source-names:
- read-ds-0
load-balancer-name: roundRobin # 负载均衡算法名称
load-balancers:
roundRobin:
type: ROUND_ROBIN # 一共两种一种是 RANDOM(随机),一种是 ROUND_ROBIN(轮询)

这里主要根据官网的 property 配置文件转的 yaml 文件,需要注意几点:

  • type: com.zaxxer.hikari.HikariDataSource 我用的是 Hikari 连接池,根据你的实际情况来
  • driver-class-name: com.mysql.cj.jdbc.Driver 不同 mysql 版本不一样,根据你的实际情况来,我的是 mysql 8.0
  • jdbcUrl ,官网上写的是 url, 不对,要写成 jdbcUrl

遇到的问题

1
2
3
4
5
6
7
8
9
10
11
vbnet复制代码Description:

Configuration property name 'spring.shardingsphere.datasource.write_ds' is not valid:

Invalid characters: '_'
Bean: org.apache.shardingsphere.spring.boot.ShardingSphereAutoConfiguration
Reason: Canonical names should be kebab-case ('-' separated), lowercase alpha-numeric characters and must start with a letter

Action:

Modify 'spring.shardingsphere.datasource.write_ds' so that it conforms to the canonical names requirements.

之前把配置文件中的某些名字配置用下划线写了,不行,得用中线。

测试

所有的改动只有以上这么多,还是比较简单的,以下的读库请求打过来时的监控,证明读请求都过来了,写库没有。

这是写库的:

这是读库的:

参考

  • shardingsphere.apache.org/document/5.…

本文转载自: 掘金

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

小知识 Java中的"魔数"

发表于 2021-09-17
  • 小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

在编程过程中,我们可能经常听到“魔数”这个词,那么这个词到底指的是什么呢?什么数叫做魔数呢?

一、标识文件类型的“魔数”

大多数情况下,我们都是通过扩展名来识别一个文件的类型的,比如我们看到一个.txt类型的文件我们就知道他是一个纯文本文件。但是,扩展名是可以修改的,那一旦一个文件的扩展名被修改过,那么怎么识别一个文件的类型呢。这就用到了我们提到的“魔数”。

很多类型的文件,其起始的几个字节的内容是固定的(或是有意填充,或是本就如此)。因此这几个字节的内容也被称为魔数 (magic number),因为根据这几个字节的内容就可以确定文件类型。有了这些魔术数字,我们就可以很方便的区别不同的文件。

为了方便虚拟机识别一个文件是否是class类型的文件,SUN公司规定每个class文件都必须以一个word(四个字节)作为开始,这个数字就是魔数。魔数是由四个字节的无符号数组成的,而class文件的名字还挺好听的的,其魔数就是0xCAFEBABE

读者可以随便编译一个class文件,然后然后用十六进制编辑器打开编译后的class文件,基本格式如下:

class

如何使用16进制打开class文件:使用 vim test.class ,然后在交互模式下,输入:%!xxd 即可。

二、代码中的魔数

在有些代码中,有一些数字常量或者字符串,他们没有注释,并且从命名上也看不出什么意思,很可能在过一段时间之后谁也不知道这个常量或者字符串代表什么意思。我们就称这个常量或者字符串为魔数。

在《阿里巴巴Java开发手册》中也有关于魔数的要求:

magic

在代码中使用魔数,不仅使代码的可读性大大降低,还可能导致各种问题。所以在代码中,我们要尽量避免产生魔数。

所有需要使用魔数的地方,都可以使用枚举或者静态变量来代替。

譬如一个很简单的根据职位计算薪水的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
csharp复制代码public int getSalary(String title, int grade) {
if ("Programmer".equals(title)){
return grade * 500 + 700;
}

else if ("Tester".equals(title)){
return grade * 500 + 800;
}

else if ("Analyst".equals(title)){
return grade * 800 + 1000;
}
}

在这个方法里面,”Programmer”,”Tester”和”Analyst”是所谓的魔字符串(Magic String),而500, 700,800和1000就是所谓的魔数(Magic Number)了。 咋一看,代码这样写也没有什么问题,但是,仔细思考一下就会发现,如果这种随手捻来的字符串和数字散布于程序当中,随处可见的话,是会有很多弊病的。

如果我们使用常量来代替上面的魔数的话,代码就会清爽很多,而且,下次修改的时候只需要修改常量值就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
csharp复制代码public int getSalary(String title, int grade) {
if (Constants.TITLE_PROGRAMMER.equals(title)){
return grade * Constants.BASE_SALARY_LOW + Constants.ALLOWANCE_LOW;
}
else if (Constants.TITLE_TESTER.equals(title)){
return grade * Constants.BASE_SALARY_LOW + Constants.ALLOWANCE_MEDIUM;
}
else if (Constants.TITLE_ANALYST.equals(title)){
return grade * Constants.BASE_SALARY_HIGH + Constants.ALLOWANCE_HIGH;
}

}

本文转载自: 掘金

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

❤️ 爆赞,基础又全面的Linux命令合集! 🌲 前言 🏆

发表于 2021-09-17

本文正在参与 “走过Linux 三十年”话题征文活动

🌲 前言

为什么要学习 Linux 命令?

目前企业有超过 80% 甚至更多的系统都是 Linux 操作系统,所以不管是做开发还是运维,不会点 Linux 知识肯定是无法进入到企业里工作。而且,很多企业的岗位职责里写要需要精通 Linux 。
在这里插入图片描述
Linux 的从业方向也比较广,主要分为 运维 和 开发 ,细分下来就数不胜数了,基本都会涉及,因此学好 Linux 刻不容缓。
在这里插入图片描述
本文将列出我工作多年所学的 Linux 常用命令的汇总!超全面!超详细!包学包会!

🏆 命令汇总

🍇 文件管理

1️⃣ ls 命令 – 显示指定工作目录下的内容及属性信息

ls命令为英文单词 list 的缩写,正如英文单词 list 的意思,其功能是列出指定目录下的内容及其相关属性信息。

默认状态下,ls命令会列出当前目录的内容。而带上参数后,我们可以用ls做更多的事情。作为最基础同时又是使用频率很高的命令,我们很有必要搞清楚ls命令的用法,那么接下来一起看看吧!

语法:

语法格式: ls [选项] [文件]

常用参数:

参数 描述
-a 显示所有文件及目录 (包括以“.”开头的隐藏文件)
-l 使用长格式列出文件及目录信息
-r 将文件以相反次序显示(默认依英文字母次序)
-t 根据最后的修改时间排序
-A 同 -a ,但不列出 “.” (当前目录) 及 “..” (父目录)
-S 根据文件大小排序
-R 递归列出所有子目录

参考实例:

列出所有文件(包括隐藏文件):

1
bash复制代码ls -a

列出文件的详细信息:

1
bash复制代码ls -l

列出根目录(/)下的所有目录:

1
bash复制代码ls /

列出当前工作目录下所有名称是 “s” 开头的文件(不包含文件夹哦~) :

1
bash复制代码ls -ltr s*

列出 /root 目录下的所有目录及文件的详细信息 :

1
bash复制代码ls -lR /root

列出当前工作目录下所有文件及目录并以文件的大小进行排序 :

1
bash复制代码ls -AS

2️⃣ cp 命令 – 复制文件或目录

cp命令可以理解为英文单词copy的缩写,其功能为复制文件或目录。

cp命令可以将多个文件复制到一个具体的文件名或一个已经存在的目录下,也可以同时复制多个文件到一个指定的目录中。

语法:

语法格式:cp [参数] [文件]

常用参数:

参数 描述
-f 若目标文件已存在,则会直接覆盖原文件
-i 若目标文件已存在,则会询问是否覆盖
-p 保留源文件或目录的所有属性
-r 递归复制文件和目录
-d 当复制符号连接时,把目标文件或目录也建立为符号连接,并指向与源文件或目录连接的原始文件或目录
-l 对源文件建立硬连接,而非复制文件
-s 对源文件建立符号连接,而非复制文件
-b 覆盖已存在的文件目标前将目标文件备份
-v 详细显示cp命令执行的操作过程
-a 等价于“dpr”选项

参考实例:

复制目录:

1
bash复制代码cp -R dir1 dir2/

将文件test1改名为test2:

1
bash复制代码cp -f test1 test2

复制多个文件:

1
bash复制代码cp -r file1 file2 file3 dir

交互式地将目录 /home/lucifer 中的所有.c文件复制到目录 dir 中:

1
bash复制代码cp -r /home/lucifer/*.c dir

3️⃣ mkdir 命令 – 创建目录

mkdir命令是“make directories”的缩写,用来创建目录。

📢 注意: 默认状态下,如果要创建的目录已经存在,则提示已存在,而不会继续创建目录。 所以在创建目录时,应保证新建的目录与它所在目录下的文件没有重名。 mkdir命令还可以同时创建多个目录,是不是很强大呢?

语法:

语法格式 : mkdir [参数] [目录]

常用参数:

参数 描述
-p 递归创建多级目录
-m 建立目录的同时设置目录的权限
-z 设置安全上下文
-v 显示目录的创建过程

参考实例:

在工作目录下,建立一个名为 dir 的子目录:

1
bash复制代码mkdir dir

在目录/home/lucifer下建立子目录dir,并且设置文件属主有读、写和执行权限,其他人无权访问:

1
bash复制代码mkdir -m 700 /home/lucifer/dir

同时创建子目录dir1,dir2,dir3:

1
bash复制代码mkdir dir1 dir2 dir3

递归创建目录:

1
bash复制代码mkdir -p lucifer/dir

4️⃣ mv 命令 – 移动或改名文件

mv命令是“move”单词的缩写,其功能大致和英文含义一样,可以移动文件或对其改名。

这是一个使用频率超高的文件管理命令,我们需要特别留意它与复制的区别:mv与cp的结果不同。mv命令好像文件“搬家”,文件名称发生改变,但个数并未增加。而cp命令是对文件进行复制操作,文件个数是有增加的。

语法:

语法格式:mv [参数]

常用参数:

参数 描述
-i 若存在同名文件,则向用户询问是否覆盖
-f 覆盖已有文件时,不进行任何提示
-b 当文件存在时,覆盖前为其创建一个备份
-u 当源文件比目标文件新,或者目标文件不存在时,才执行移动此操作

参考实例:

将文件file_1重命名为file_2:

1
bash复制代码mv file_1 file_2

将文件file移动到目录dir中 :

1
bash复制代码mv file /dir

将目录dir1移动目录dir2中(前提是目录dir2已存在,若不存在则改名):

1
bash复制代码mv /dir1 /dir2

将目录dir1下的文件移动到当前目录下:

1
bash复制代码mv /dir1/* .

5️⃣ pwd 命令 – 显示当前路径

pwd命令是“print working directory”中每个单词的首字母缩写,其功能正如所示单词一样,为打印工作目录,即显示当前工作目录的绝对路径。

在实际工作中,我们经常会在不同目录之间进行切换,为了防止“迷路”,我们可以使用pwd命令快速查看当前我们所在的目录路径。

语法:

语法格式: pwd [参数]

常用参数:

参数 描述
-L 显示逻辑路径

参考实例:

查看当前工作目录路径:

1
bash复制代码pwd

🍉 文档编辑

1️⃣ cat 命令 – 在终端设备上显示文件内容

cat这个命令也很好记,因为cat在英语中是“猫”的意思,小猫咪是不是给您一种娇小、可爱的感觉呢?

📢 注意: 当文件内容较大时,文本内容会在屏幕上快速闪动(滚屏),用户往往看不清所显示的具体内容。

因此对于较长文件内容可以:

  • 按Ctrl+S键,停止滚屏;
  • 按Ctrl+Q键可以恢复滚屏;
  • 按Ctrl+C(中断)键则可以终止该命令的执行。

或者对于大文件,干脆用 more 命令吧!

语法:

语法格式:cat [参数] [文件]

常用参数:

参数 描述
-n 显示行数(空行也编号)
-s 显示行数(多个空行算一个编号)
-b 显示行数(空行不编号)
-E 每行结束处显示$符号
-T 将TAB字符显示为 ^I符号
-v 使用 ^ 和 M- 引用,除了 LFD 和 TAB 之外
-e 等价于”-vE”组合
-t 等价于”-vT”组合
-A 等价于 -vET组合
–help 显示帮助信息
–version 显示版本信息

参考实例:

查看文件的内容:

1
bash复制代码cat lucifer.log

查看文件的内容,并显示行数编号:

1
bash复制代码cat -n lucifer.log

查看文件的内容,并添加行数编号后输出到另外一个文件中:

1
bash复制代码cat -n lucifer.log > lucifer.txt

清空文件的内容:

1
bash复制代码cat /dev/null > /root/lucifer.txt

持续写入文件内容,碰到EOF符后结束并保存:

1
2
3
4
bash复制代码cat > lucifer.txt <<EOF
Hello, World
Linux!
EOF

将软盘设备制作成镜像文件:

1
bash复制代码cat /dev/fb0 > fdisk.iso

2️⃣ echo 命令 – 输出字符串或提取Shell变量的值

echo命令用于在终端设备上输出字符串或变量提取后的值,这是在Linux系统中最常用的几个命令之一,但操作却非常简单。

人们一般使用在变量前加上符号的方式提取出变量的值,例如:符号的方式提取出变量的值,例如:符号的方式提取出变量的值,例如:PATH,然后再用echo命令予以输出。或者直接使用echo命令输出一段字符串到屏幕上,起到给用户提示的作用。

语法:

语法格式:echo [参数] [字符串]

常用参数:

参数 描述
-n 不输出结尾的换行符
-e “\a” 发出警告音
-e “\b” 删除前面的一个字符
-e “\c” 结尾不加换行符
-e “\f” 换行,光标扔停留在原来的坐标位置
-e “\n” 换行,光标移至行首
-e “\r” 光标移至行首,但不换行
-E 禁止反斜杠转移,与-e参数功能相反
—version 查看版本信息
–help 查看帮助信息

参考实例:

输出一段字符串:

1
bash复制代码echo "Hello Lucifer"

输出变量提取后的值:

1
bash复制代码echo $PATH

对内容进行转义,不让$符号的提取变量值功能生效:

1
bash复制代码echo \$PATH

结合输出重定向符,将字符串信息导入文件中:

1
bash复制代码echo "It is a test" > lucifer

使用反引号符执行命令,并输出其结果到终端:

1
bash复制代码echo `date`

输出带有换行符的内容:

1
bash复制代码echo -e "a\nb\nc"

输出信息中删除某个字符,注意看数字3消失了:

1
bash复制代码echo -e "123\b456"

3️⃣ rm 命令 – 移除文件或目录

rm是常用的命令,该命令的功能为删除一个目录中的一个或多个文件或目录,它也可以将某个目录及其下的所有文件及子目录均删除。对于链接文件,只是删除了链接,原有文件均保持不变。

📢 注意: rm也是一个危险的命令,使用的时候要特别当心,尤其对于新手,否则整个系统就会毁在这个命令(比如在/(根目录)下执行rm * -rf)。

所以,我们在执行rm之前最好先确认一下在哪个目录,到底要删除什么东西,操作时保持高度清醒的头脑。

语法:

语法格式:rm [参数] [文件]

常用参数:

参数 描述
-f 忽略不存在的文件,不会出现警告信息
-i 删除前会询问用户是否操作
-r/R 递归删除
-v 显示指令的详细执行过程

参考实例:

删除前逐一询问确认:

1
bash复制代码rm -i test.txt.bz2

直接删除,不会有任何提示:

1
bash复制代码rm -f test.txt.bz2

递归删除目录及目录下所有文件:

1
2
bash复制代码mkdir /data/log
rm -rf /data/log

删除当前目录下所有文件:

1
bash复制代码rm -rf *

清空系统中所有的文件(谨慎):

1
bash复制代码rm -rf /*

4️⃣ tail 命令 – 查看文件尾部内容

tail用于显示文件尾部的内容,默认在屏幕上显示指定文件的末尾10行。如果给定的文件不止一个,则在显示的每个文件前面加一个文件名标题。如果没有指定文件或者文件名为“-”,则读取标准输入。

语法:

语法格式:tail [参数]

常用参数:

参数 描述
–retry 即是在tail命令启动时,文件不可访问或者文件稍后变得不可访问,都始终尝试打开文件。使用此选项时需要与选项“——follow=name”连用
-c或—bytes= 输出文件尾部的N(N为整数)个字节内容
-f<name/descriptor> –follow:显示文件最新追加的内容
-F 与选项“-follow=name”和“–retry”连用时功能相同
-n或—line= 输出文件的尾部N(N位数字)行内容
–pid=<进程号> 与“-f”选项连用,当指定的进程号的进程终止后,自动退出tail命令
–help 显示指令的帮助信息
–version 显示指令的版本信息

参考实例:

显示文件file的最后10行:

1
bash复制代码tail file

显示文件file的内容,从第20行至文件末尾:

1
bash复制代码tail +20 file

显示文件file的最后10个字符:

1
bash复制代码tail -c 10 file

一直变化的文件总是显示后10行:

1
bash复制代码tail -f 10 file

显示帮助信息:

1
bash复制代码tail --help

5️⃣ rmdir 命令 – 删除空目录

rmdir命令作用是删除空的目录,英文全称:“remove directory”。

注意:rmdir命令只能删除空目录。当要删除非空目录时,就要使用带有“-R”选项的rm命令。

rmdir命令的“-p”参数可以递归删除指定的多级目录,但是要求每个目录也必须是空目录。

语法:

语法格式 : rmdir [参数] [目录名称]

常用参数:

参数 描述
-p 用递归的方式删除指定的目录路径中的所有父级目录,非空则报错
–ignore-fail-on-non-empty 忽略由于删除非空目录时导致命令出错而产生的错误信息
-v 显示命令的详细执行过程
–help 显示命令的帮助信息
–version 显示命令的版本信息

参考实例:

删除空目录:

1
bash复制代码rmdir dir

递归删除指定的目录树:

1
bash复制代码rmdir -p dir/dir_1/dir_2

显示指令详细执行过程:

1
bash复制代码rmdir -v dir

显示命令的版本信息:

1
bash复制代码rmdir --version

🍋 系统管理

1️⃣ rpm 命令 – RPM软件包管理器

rpm命令是Red-Hat Package Manager(RPM软件包管理器)的缩写, 该命令用于管理Linux 下软件包的软件。在 Linux 操作系统下,几乎所有的软件均可以通过RPM 进行安装、卸载及管理等操作。

概括的说,rpm命令包含了五种基本功能:安装、卸载、升级、查询和验证。

语法:

语法格式:rpm [参数] [软件包]

常用参数:

参数 描述
-a 查询所有的软件包
-b或-t 设置包装套件的完成阶段,并指定套件档的文件名称
-c 只列出组态配置文件,本参数需配合”-l”参数使用
-d 只列出文本文件,本参数需配合”-l”参数使用
-e或–erase 卸载软件包
-f 查询文件或命令属于哪个软件包
-h或–hash 安装软件包时列出标记
-i 显示软件包的相关信息
–install 安装软件包
-l 显示软件包的文件列表
-p 查询指定的rpm软件包
-q 查询软件包
-R 显示软件包的依赖关系
-s 显示文件状态,本参数需配合”-l”参数使用
-U或–upgrade 升级软件包
-v 显示命令执行过程
-vv 详细显示指令执行过程

参考实例:

直接安装软件包:

1
bash复制代码rpm -ivh packge.rpm

忽略报错,强制安装:

1
bash复制代码rpm --force -ivh package.rpm

列出所有安装过的包:

1
bash复制代码rpm -qa

查询rpm包中的文件安装的位置:

1
bash复制代码rpm -ql ls

卸载rpm包:

1
bash复制代码rpm -e package.rpm

升级软件包:

1
bash复制代码rpm -U file.rpm

2️⃣ find 命令 – 查找和搜索文件

find命令可以根据给定的路径和表达式查找的文件或目录。find参数选项很多,并且支持正则,功能强大。和管道结合使用可以实现复杂的功能,是系统管理者和普通用户必须掌握的命令。

find如不加任何参数,表示查找当前路径下的所有文件和目录,如果服务器负载比较高尽量不要在高峰期使用find命令,find命令模糊搜索还是比较消耗系统资源的。

语法:

语法格式:find [参数] [路径] [查找和搜索范围]

常用参数:

参数 描述
-name 按名称查找
-size 按大小查找
-user 按属性查找
-type 按类型查找
-iname 忽略大小写

参考实例:

使用-name参数查看/etc目录下面所有的.conf结尾的配置文件:

1
bash复制代码find /etc -name "*.conf

使用-size参数查看/etc目录下面大于1M的文件:

1
bash复制代码find /etc -size +1M

查找当前用户主目录下的所有文件:

1
bash复制代码find $HOME -print

列出当前目录及子目录下所有文件和文件夹:

1
bash复制代码find .

在/home目录下查找以.txt结尾的文件名:

1
bash复制代码find /home -name "*.txt"

在/var/log目录下忽略大小写查找以.log结尾的文件名:

1
bash复制代码find /var/log -iname "*.log"

搜索超过七天内被访问过的所有文件:

1
bash复制代码find . -type f -atime +7

搜索访问时间超过10分钟的所有文件:

1
bash复制代码find . -type f -amin +10

找出/home下不是以.txt结尾的文件:

1
bash复制代码find /home ! -name "*.txt"

3️⃣ startx 命令 – 初始化X-windows

startx命令用来启动X-Window,它负责调用X-Window系统的初始化程序xinit。以完成 X-Window运行所必要的初始化工作,并启动X-Window系统。

语法:

语法格式:startx [参数]

常用参数:

参数 描述
-d 指定在启动过程中传递给客户机的X服务器的显示名称
-m 当未找到启动脚本时,启动窗口管理器
-r 当未找到启动脚本时,装入资源文件
-w 强制启动
-x 使用startup脚本启动X-windows会话

参考实例:

已默认方式启动X-windows系统:

1
bash复制代码startx

以16位颜色深度启动X-windows系统:

1
bash复制代码startx --depth 16

强制启动 X-windows系统:

1
bash复制代码startx -w

4️⃣ uname 命令 – 显示系统信息

uname命令的英文全称即“Unix name”。

用于显示系统相关信息,比如主机名、内核版本号、硬件架构等。

如果未指定任何选项,其效果相当于执行”uname -s”命令,即显示系统内核的名字。

语法:

语法格式:uname [参数]

常用参数:

参数 描述
-a 显示系统所有相关信息
-m 显示计算机硬件架构
-n 显示主机名称
-r 显示内核发行版本号
-s 显示内核名称
-v 显示内核版本
-p 显示主机处理器类型
-o 显示操作系统名称
-i 显示硬件平台

参考实例:

显示系统主机名、内核版本号、CPU类型等信息:

1
bash复制代码uname -a

仅显示系统主机名:

1
bash复制代码uname -n

显示当前系统的内核版本 :

1
bash复制代码uname -r

显示当前系统的硬件架构:

1
bash复制代码uname -i

5️⃣ vmstat 命令 – 显示虚拟内存状态

vmstat命令的含义为显示虚拟内存状态(“Virtual Memory Statistics”),但是它可以报告关于进程、内存、I/O等系统整体运行状态。

语法:

语法格式:vmstat [参数]

常用参数:

参数 描述
-a 显示活动内页
-f 显示启动后创建的进程总数
-m 显示slab信息
-n 头信息仅显示一次
-s 以表格方式显示事件计数器和内存状态
-d 报告磁盘状态
-p 显示指定的硬盘分区状态
-S 输出信息的单位

参考实例:

显示活动内页:

1
bash复制代码vmstat -a

显示启动后创建的进程总数:

1
bash复制代码vmstat -f

显示slab信息:

1
bash复制代码vmstat -m

头信息仅显示一次:

1
bash复制代码vmstat -n

以表格方式显示事件计数器和内存状态:

1
bash复制代码vmstat -s

显示指定的硬盘分区状态:

1
bash复制代码vmstat -p /dev/sda1

指定状态信息刷新的时间间隔为1秒:

1
bash复制代码vmstat 1

🍑 磁盘管理

1️⃣ df 命令 – 显示磁盘空间使用情况

df命令的英文全称即“Disk Free”,顾名思义功能是用于显示系统上可使用的磁盘空间。默认显示单位为KB,建议使用“df -h”的参数组合,根据磁盘容量自动变换合适的单位,更利于阅读。

日常普遍用该命令可以查看磁盘被占用了多少空间、还剩多少空间等信息。

语法:

语法格式: df [参数] [指定文件]

常用参数:

参数 描述
-a 显示所有系统文件
-B <块大小> 指定显示时的块大小
-h 以容易阅读的方式显示
-H 以1000字节为换算单位来显示
-i 显示索引字节信息
-k 指定块大小为1KB
-l 只显示本地文件系统
-t <文件系统类型> 只显示指定类型的文件系统
-T 输出时显示文件系统类型
– -sync 在取得磁盘使用信息前,先执行sync命令

参考实例:

显示磁盘分区使用情况:

1
bash复制代码df

以容易阅读的方式显示磁盘分区使用情况:

1
bash复制代码df -h

显示指定文件所在分区的磁盘使用情况:

1
bash复制代码df /etc/dhcp

显示文件类型为ext4的磁盘使用情况:

1
bash复制代码df -t ext4

2️⃣ fdisk 命令 – 磁盘分区

fdisk命令的英文全称是“Partition table manipulator for Linux”,即作为磁盘的分区工具。进行硬盘分区从实质上说就是对硬盘的一种格式化, 用一个形象的比喻,分区就好比在一张白纸上画一个大方框,而格式化好比在方框里打上格子。

语法:

语法格式:fdisk [参数]

常用参数:

参数 描述
-b 指定每个分区的大小
-l 列出指定的外围设备的分区表状况
-s 将指定的分区大小输出到标准输出上,单位为区块
-u 搭配”-l”参数列表,会用分区数目取代柱面数目,来表示每个分区的起始地址
-v 显示版本信息

参考实例:

查看所有分区情况:

1
bash复制代码fdisk -l

选择分区磁盘:

1
bash复制代码fdisk /dev/sdb

在当前磁盘上建立扩展分区:

1
bash复制代码fdisk /ext

不检查磁盘表面加快分区操作:

1
bash复制代码fdisk /actok

重建主引导记录:

1
bash复制代码fdisk /cmbr

3️⃣ lsblk命令 – 查看系统的磁盘

lsblk命令的英文是“list block”,即用于列出所有可用块设备的信息,而且还能显示他们之间的依赖关系,但是它不会列出RAM盘的信息。

lsblk命令包含在util-linux-ng包中,现在该包改名为util-linux。

语法:

语法格式:lsblk [参数]

常用参数:

参数 描述
-a 显示所有设备
-b 以bytes方式显示设备大小
-d 不显示 slaves 或 holders
-D print discard capabilities
-e 排除设备
-f 显示文件系统信息
-h 显示帮助信息
-i use ascii characters only
-m 显示权限信息
-l 使用列表格式显示
-n 不显示标题
-o 输出列
-P 使用key=”value”格式显示
-r 使用原始格式显示
-t 显示拓扑结构信息

参考实例:

lsblk命令默认情况下将以树状列出所有块设备:

1
bash复制代码lsblk

默认选项不会列出所有空设备:

1
bash复制代码lsblk -a

也可以用于列出一个特定设备的拥有关系,同时也可以列出组和模式:

1
bash复制代码lsblk -m

要获取SCSI设备的列表,你只能使用-S选项,该选项是用来以颠倒的顺序打印依赖的:

1
bash复制代码lsblk -S

例如,你也许想要以列表格式列出设备,而不是默认的树状格式。可以将两个不同的选项组合,以获得期望的输出:

1
bash复制代码lsblk -nl

4️⃣ hdparm命令 – 显示与设定硬盘参数

hdparm命令用于检测,显示与设定IDE或SCSI硬盘的参数。

语法:

语法格式:hdparm [参数]

常用参数:

参数 描述
-a 设定读取文件时,预先存入块区的分区数
-f 将内存缓冲区的数据写入硬盘,并清空缓冲区
-g 显示硬盘的磁轨,磁头,磁区等参数
-I 直接读取硬盘所提供的硬件规格信息
-X 设定硬盘的传输模式

参考实例:

显示硬盘的相关设置:

1
bash复制代码hdparm /dev/sda

显示硬盘的柱面、磁头、扇区数:

1
bash复制代码hdparm -g /dev/sda

评估硬盘的读取效率:

1
bash复制代码hdparm -t /dev/sda

直接读取硬盘所提供的硬件规格信息:

1
bash复制代码hdparm -X /dev/sda

使IDE硬盘进入睡眠模式:

1
bash复制代码hdparm -Y /dev/sda

5️⃣ vgextend命令 – 扩展卷组

vgextend命令用于动态扩展LVM卷组,它通过向卷组中添加物理卷来增加卷组的容量。LVM卷组中的物理卷可以在使用vgcreate命令创建卷组时添加,也可以使用vgextend命令动态的添加。

语法:

语法格式:vgextend [参数]

常用参数:

参数 描述
-d 调试模式
-t 仅测试

参考实例:

将物理卷/dev/sdb1加入卷组vglinuxprobe:

1
bash复制代码vgextend vglinuxprobe /dev/sdb1

🍓 文件传输

1️⃣ tftp 命令 – 上传及下载文件

tftp命令用于传输文件。ftp让用户得以下载存放于远端主机的文件,也能将文件上传到远端主机放置。

tftp是简单的文字模式ftp程序,它所使用的指令和ftp类似。

语法:

语法格式:tftp [参数]

常用参数:

参数 描述
connect 连接到远程tftp服务器
mode 文件传输模式
put 上传文件
get 下载文件
quit 退出
verbose 显示详细的处理信息
trace 显示包路径
status 显示当前状态信息
binary 二进制传输模式
ascii ascii 传送模式
rexmt 设置包传输的超时时间
timeout 设置重传的超时时间
help 帮助信息
? 帮助信息

参考实例:

连接远程服务器”10.211.55.100″:

1
bash复制代码tftp 10.211.55.100

远程下载file文件:

1
bash复制代码tftp> get file

退出tftp:

1
bash复制代码tftp> quit

2️⃣ curl 命令 – 文件传输工具

curl命令是一个利用URL规则在shell终端命令行下工作的文件传输工具;它支持文件的上传和下载,所以是综合传输工具,但按传统,习惯称curl为下载工具。

作为一款强力工具,curl支持包括HTTP、HTTPS、ftp等众多协议,还支持POST、cookies、认证、从指定偏移处下载部分文件、用户代理字符串、限速、文件大小、进度条等特征;做网页处理流程和数据检索自动化。

语法:

语法格式:curl [参数] [网址]

常用参数:

参数 描述
-O 把输出写到该文件中,保留远程文件的文件名
-u 通过服务端配置的用户名和密码授权访问

参考实例:

将下载的数据写入到文件,必须使用文件的绝对地址:

1
bash复制代码curl https://www.baidu.com /root/lucifer.txt --silent -O

访问需要授权的页面时,可通过-u选项提供用户名和密码进行授权:

1
bash复制代码curl -u root https://www.baidu.com/

3️⃣ fsck命令 – 检查并修复Linux文件系统

fsck命令的英文全称是“filesystem check”,即检查文件系统的意思,常用于检查并修复Linux文件系统的一些错误信息,操作文件系统需要先备份重要数据,以防丢失。

Linux fsck命令用于检查并修复Linux文件系统,可以同时检查一个或多个 Linux 文件系统;若系统掉电或磁盘发生问题,可利用fsck命令对文件系统进行检查。

语法:

语法格式:fsck [参数] [文件系统]

常用参数:

参数 描述
-a 自动修复文件系统,不询问任何问题
-A 依照/etc/fstab配置文件的内容,检查文件内所列的全部文件系统
-N 不执行指令,仅列出实际执行会进行的动作
-P 当搭配”-A”参数使用时,则会同时检查所有的文件系统
-r 采用互动模式,在执行修复时询问问题,让用户得以确认并决定处理方式
-R 当搭配”-A”参数使用时,则会略过/目录的文件系统不予检查
-t 指定要检查的文件系统类型
-T 执行fsck指令时,不显示标题信息
-V 显示指令执行过程

参考实例:

修复坏的分区文件系统:

1
bash复制代码fsck -t ext3 -r /usr/local

显示fsck系统安装的版本号:

1
bash复制代码fsck --version

4️⃣ ftpwho命令 – 显示ftp会话信息

ftpwho命令用于显示当前所有以FTP登入的用户会话信息。

执行该命令可得知当前用FTP登入系统的用户有哪些人,以及他们正在进行的操作。

语法:

语法格式:ftpwho [参数]

常用参数:

参数 描述
-h 显示帮助信息
-v 详细模式,输出更多信息

参考实例:

查询当前正在登录FTP 服务器的用户:

1
bash复制代码ftpwho

在详细模式下,查询当前正在登录FTP 服务器的用户:

1
bash复制代码ftpwho -v

显示帮助信息:

1
bash复制代码ftpwho -h

5️⃣ lprm命令 – 删除打印队列中的打印任务

lprm命令的英文全称是“Remove jobs from the print queue”,意为用于删除打印队列中的打印任务。尚未完成的打印机工作会被放在打印机贮列之中,这个命令可用来将未送到打印机的工作取消。

语法:

语法格式:lprm [参数] [任务编号]

常用参数:

参数 描述
-E 与打印服务器连接时强制使用加密
-P 指定接受打印任务的目标打印机
-U 指定可选的用户名

参考实例:

将打印机hpprint中的第102号任务移除:

1
bash复制代码lprm -Phpprint 102

将第101号任务由预设打印机中移除:

1
bash复制代码lprm 101

🌽 网络通讯

1️⃣ ssh 命令 – 安全连接客户端

ssh命令是openssh套件中的客户端连接工具,可以给予ssh加密协议实现安全的远程登录服务器,实现对服务器的远程管理。

语法:

语法格式: ssh [参数] [远程主机]

常用参数:

参数 描述
-1 强制使用ssh协议版本1
-2 强制使用ssh协议版本2
-4 强制使用IPv4地址
-6 强制使用IPv6地址
-A 开启认证代理连接转发功能
-a 关闭认证代理连接转发功能
-b<IP地址> 使用本机指定的地址作为对位连接的源IP地址
-C 请求压缩所有数据
-F<配置文件> 指定ssh指令的配置文件,默认的配置文件为“/etc/ssh/ssh_config”
-f 后台执行ssh指令
-g 允许远程主机连接本机的转发端口
-i<身份文件> 指定身份文件(即私钥文件)
-l<登录名> 指定连接远程服务器的登录用户名
-N 不执行远程指令
-o<选项> 指定配置选项
-p<端口> 指定远程服务器上的端口
-q 静默模式,所有的警告和诊断信息被禁止输出
-X 开启X11转发功能
-x 关闭X11转发功能
-y 开启信任X11转发功能

参考实例:

登录远程服务器:

1
bash复制代码ssh 10.211.55.100

用test用户连接远程服务器:

1
bash复制代码ssh -l test 10.211.55.100

查看分区列表:

1
bash复制代码ssh 10.211.55.100 /sbin/fdisk -l

强制使用ssh协议版本1:

1
bash复制代码ssh -1

开启认证代理连接转发功能:

1
bash复制代码ssh -A

2️⃣ netstat 命令 – 显示网络状态

netstat 命令用于显示各种网络相关信息,如网络连接,路由表,接口状态 (Interface Statistics),masquerade 连接,多播成员 (Multicast Memberships) 等等。

从整体上看,netstat的输出结果可以分为两个部分:一个是Active Internet connections,称为有源TCP连接,其中”Recv-Q”和”Send-Q”指%0A的是接收队列和发送队列。这些数字一般都应该是0。如果不是则表示软件包正在队列中堆积。这种情况只能在非常少的情况见到;另一个是Active UNIX domain sockets,称为有源Unix域套接口(和网络套接字一样,但是只能用于本机通信,性能可以提高一倍)。

语法:

语法格式:netstat [参数]

常用参数:

参数 描述
-a 显示所有连线中的Socket
-p 显示正在使用Socket的程序识别码和程序名称
-u 显示UDP传输协议的连线状况
-i 显示网络界面信息表单
-n 直接使用IP地址,不通过域名服务器

参考实例:

显示详细的网络状况:

1
bash复制代码netstat -a

显示当前户籍UDP连接状况:

1
bash复制代码netstat -nu

显示UDP端口号的使用情况:

1
bash复制代码netstat -apu

显示网卡列表:

1
bash复制代码netstat -i

显示组播组的关系:

1
bash复制代码netstat -g

3️⃣ ping 命令 – 测试主机间网络连通性

ping命令主要用来测试主机之间网络的连通性,也可以用于。执行ping指令会使用ICMP传输协议,发出要求回应的信息,若远端主机的网络功能没有问题,就会回应该信息,因而得知该主机运作正常。

不过值得我们注意的是:Linux系统下的ping命令与Windows系统下的ping命令稍有不同。Windows下运行ping命令一般会发出4个请求就结束运行该命令;而Linux下不会自动终止,此时需要我们按CTR+C终止或者使用-c参数为ping命令指定发送的请求数目。

语法:

语法格式:ping [参数] [目标主机]

常用参数:

参数 描述
-d 使用Socket的SO_DEBUG功能
-c 指定发送报文的次数
-i 指定收发信息的间隔时间
-I 使用指定的网络接口送出数据包
-l 设置在送出要求信息之前,先行发出的数据包
-n 只输出数值
-p 设置填满数据包的范本样式
-q 不显示指令执行过程
-R 记录路由过程
-s 设置数据包的大小
-t 设置存活数值TTL的大小
-v 详细显示指令的执行过程

参考实例:

检测与百度网站的连通性:

1
bash复制代码ping www.baidu.com

连续ping4次:

1
bash复制代码ping -c 4 www.baidu.com

设置次数为4,时间间隔为3秒:

1
bash复制代码ping -c 4 -i 3 www.baidu.com

利用ping命令获取指定网站的IP地址:

1
bash复制代码ping -c 1 baidu.com | grep from | cut -d " " -f 4

4️⃣ dhclient 命令 – 动态获取或释放IP地址

dhclient命令的作用是:使用动态主机配置协议动态的配置网络接口的网络参数,也支持BOOTP协议。

语法:

语法格式:dhclient [参数] [网络接口]

常用参数:

参数 描述
-p 指定dhcp客户端监听的端口号(默认端口号86)
-d 总是以前台方式运行程序
-q 安静模式,不打印任何错误的提示信息
-r 释放ip地址
-n 不配置任何接口
-x 停止正在运行的DHCP客户端,而不释放当前租约,杀死现有的dhclient
-s 在获取ip地址之前指定DHCP服务器
-w 即使没有找到广播接口,也继续运行

参考实例:

在指定网络接口上发出DHCP请求:

1
bash复制代码dhclient eth0

释放IP地址:

1
bash复制代码dhclient -r

从指定的服务器获取ip地址:

1
bash复制代码dhclient -s 10.211.55.100

停止运行dhclient:

1
bash复制代码dhclient -x

5️⃣ ifconfig 命令 – 显示或设置网络设备

ifconfig命令的英文全称是“network interfaces configuring”,即用于配置和显示Linux内核中网络接口的网络参数。用ifconfig命令配置的网卡信息,在网卡重启后机器重启后,配置就不存在。要想将上述的配置信息永远的存的电脑里,那就要修改网卡的配置文件了。

语法:

语法格式:ifconfig [参数]

常用参数:

参数 描述
add<地址> 设置网络设备IPv6的IP地址
del<地址> 删除网络设备IPv6的IP地址
down 关闭指定的网络设备
up 启动指定的网络设备
IP地址 指定网络设备的IP地址

参考实例:

显示网络设备信息:

1
bash复制代码ifconfig

启动关闭指定网卡:

1
2
bash复制代码ifconfig eth0 down
ifconfig eth0 up

为网卡配置和删除IPv6地址:

1
2
bash复制代码ifconfig eth0 add 33ffe:3240:800:1005::2/64
ifconfig eth0 del 33ffe:3240:800:1005::2/64

用ifconfig修改MAC地址:

1
2
3
4
5
bash复制代码ifconfig eth0 down
ifconfig eth0 hw ether 00:AA:BB:CC:DD:EE
ifconfig eth0 up
ifconfig eth1 hw ether 00:1D:1C:1D:1E
ifconfig eth1 up

配置IP地址:

1
2
3
bash复制代码ifconfig eth0 192.168.1.56 
ifconfig eth0 192.168.1.56 netmask 255.255.255.0
ifconfig eth0 192.168.1.56 netmask 255.255.255.0 broadcast 192.168.1.255

🍒 设备管理

1️⃣ mount 命令 – 文件系统挂载

mount命令用于加载文件系统到指定的加载点。此命令的最常用于挂载cdrom,使我们可以访问cdrom中的数据,因为你将光盘插入cdrom中,Linux并不会自动挂载,必须使用Linux mount命令来手动完成挂载。

语法:

语法格式:mount [参数]

常用参数:

参数 描述
-t 指定挂载类型
-l 显示已加载的文件系统列表
-h 显示帮助信息并退出
-V 显示程序版本
-n 加载没有写入文件“/etc/mtab”中的文件系统
-r 将文件系统加载为只读模式
-a 加载文件“/etc/fstab”中描述的所有文件系统

参考实例:

查看版本:

1
bash复制代码mount -V

启动所有挂载:

1
bash复制代码mount -a

挂载 /dev/cdrom 到 /mnt:

1
bash复制代码mount /dev/cdrom /mnt

挂载nfs格式文件系统:

1
bash复制代码mount -t nfs /123 /mnt

挂载第一块盘的第一个分区到/etc目录 :

1
bash复制代码mount -t ext4 -o loop,default /dev/sda1 /etc

2️⃣ MAKEDEV命令 – 建立设备

MAKEDEV是一个脚本程序, 用于在 /dev 目录下建立设备, 通过这些设备文件可以 访问位于内核的驱动程序。

MAKEDEV 脚本创建静态的设备节点,通常位于/dev目录下。

语法:

语法格式:MAKEDEV [参数]

常用参数:

参数 描述
-v 显示出执行的每一个动作
-n 并不做真正的更新, 只是显示一下它的执行效果
-d 删除设备文件

参考实例:

显示出执行的每一个动作:

1
bash复制代码./MAKEDEV -v update

删除设备:

1
bash复制代码./MAKEDEV -d device

3️⃣ lspci命令 – 显示当前设备所有PCI总线信息

lspci命令用于显示当前主机的所有PCI总线信息,以及所有已连接的PCI设备信息。 现在主流设备如网卡储存等都采用PCI总线

语法:

语法格式:lspci [参数]

常用参数:

参数 描述
-n 以数字方式显示PCI厂商和设备代码
-t 以树状结构显示PCI设备的层次关系
-b 以总线为中心的视图
-s 仅显示指定总线插槽的设备和功能块信息
-i 指定PCI编号列表文件,不使用默认文件
-m 以机器可读方式显示PCI设备信息

参考实例:

显示当前主机的所有PCI总线信息:

1
bash复制代码lspci

以树状结构显示PCI设备的层次关系:

1
bash复制代码lspci -t

4️⃣ setleds命令 – 设定键盘上方三个 LED 的状态

setleds即是英文词组“set leds”的合并,翻译为中文就是设置LED灯。setleds命令用来设定键盘上方三个 LED 灯的状态。在 Linux 中,每一个虚拟主控台都有独立的设定。

这是一个十分神奇的命令,竟然可以通过命令来控制键盘的灯的状态。那么下面我一起来学习一下这个命令吧。

语法:

语法格式:setleds [参数]

常用参数:

参数 描述
-F 设定虚拟主控台的状态
-D 改变虚拟主控台的状态和预设的状态
-L 直接改变 LED 显示的状态
+num/-num 将数字键打开或关闭
+caps/-caps 把大小写键打开或关闭
+scroll /-scroll 把选项键打开或关闭

参考实例:

控制键盘灯num灯亮和灯灭:

1
2
bash复制代码setleds +num 
setleds -num

控制键盘的大小写键打开或关闭,键盘指示灯亮与灭:

1
2
bash复制代码setleds +caps 
setleds -caps

控制键盘的选项键打开或关闭,键盘指示灯亮与灭:

1
bash复制代码setleds +scroll

对三灯的亮与灭的情况进行组合,分别设置为数字灯亮,大小写灯灭,选项键scroll灯灭:

1
bash复制代码setleds +num -caps -scroll

5️⃣ sensors命令 – 检测服务器内部温度及电压

sensors命令用于检测服务器内部降温系统是否健康,可以监控主板,CPU的工作电压,风扇转速、温度等数据 。

语法:

语法格式:sensors

参考实例:

检测cpu工作电压,温度等:

1
bash复制代码sensors

🍍 备份压缩

1️⃣ zip 命令 – 压缩文件

zip程序将一个或多个压缩文件与有关文件的信息(名称、路径、日期、上次修改的时间、保护和检查信息以验证文件完整性)一起放入一个压缩存档中。可以使用一个命令将整个目录结构打包到zip存档中。

对于文本文件来说,压缩比为2:1和3:1是常见的。zip只有一种压缩方法(通缩),并且可以在不压缩的情况下存储文件。(如果添加了bzip 2支持,zip也可以使用bzip 2压缩,但这些条目需要一个合理的现代解压缩来解压缩。当选择bzip 2压缩时,它将通货紧缩替换为默认方法。)zip会自动为每个要压缩的文件选择更好的两个文件(通缩或存储,如果选择bzip2,则选择bzip2或Store)。

语法:

语法格式:zip [参数] [文件]

常用参数:

参数 描述
-q 不显示指令执行过程
-r 递归处理,将指定目录下的所有文件和子目录一并处理
-z 替压缩文件加上注释
-v 显示指令执行过程或显示版本信息
-n<字尾字符串> 不压缩具有特定字尾字符串的文件

参考实例:

将 /home/html/ 这个目录下所有文件和文件夹打包为当前目录下的 html.zip:

1
bash复制代码zip -q -r html.zip /home/html

压缩文件 cp.zip 中删除文件 a.c:

1
bash复制代码zip -dv cp.zip a.c

把/home目录下面的mydata目录压缩为mydata.zip:

1
bash复制代码zip -r mydata.zip mydata

把/home目录下面的abc文件夹和123.txt压缩成为abc123.zip:

1
bash复制代码zip -r abc123.zip abc 123.txt

将 logs目录打包成 log.zip:

1
bash复制代码zip -r log.zip ./logs

2️⃣ zipinfo命令 – 查看压缩文件信息

zipinfo命令的全称为“zip information”,该命令用于列出压缩文件信息。执行zipinfo指令可得知zip压缩文件的详细信息。

语法:

语法格式:zipinfo [参数]

常用参数:

参数 描述
-1 只列出文件名称
-2 此参数的效果和指定”-1″参数类似,但可搭配”-h”,”-t”和”-z”参数使用
-h 只列出压缩文件的文件名称
-l 此参数的效果和指定”-m”参数类似,但会列出原始文件的大小而非每个文件的压缩率
-m 此参数的效果和指定”-s”参数类似,但多会列出每个文件的压缩率
-M 若信息内容超过一个画面,则采用类似more指令的方式列出信息
-s 用类似执行”ls -l”指令的效果列出压缩文件内容
-t 只列出压缩文件内所包含的文件数目,压缩前后的文件大小及压缩率
-T 将压缩文件内每个文件的日期时间用年,月,日,时,分,秒的顺序列出
-v 详细显示压缩文件内每一个文件的信息
-x<范本样式> 不列出符合条件的文件的信息
-z 如果压缩文件内含有注释,就将注释显示出来

参考实例:

显示压缩文件信息:

1
bash复制代码zipinfo file.zip

显示压缩文件中每个文件的信息:

1
bash复制代码zipinfo -v file.zip

只显示压缩包大小、文件数目:

1
bash复制代码zipinfo -h file.zip

生成一个基本的、长格式的列表(而不是冗长的),包括标题和总计行:

1
bash复制代码zipinfo -l file

查看存档中最近修改的文件:

1
bash复制代码zipinfo -T file | sort –nr -k 7 | sed 15q

3️⃣ unzip命令 – 解压缩zip文件

unzip命令是用于.zip格式文件的解压缩工具 ,unzip命令将列出、测试或从zip格式存档中提取文件,这些文件通常位于MS-DOS系统上。

默认行为(就是没有选项)是从指定的ZIP存档中提取所有的文件到当前目录(及其下面的子目录)。一个配套程序zip(1L)创建ZIP存档;这两个程序都与PKWARE的PKZIP和PKUNZIP为MS-DOS创建的存档文件兼容,但许多情况下,程序选项或默认行为是不同的。

语法:

语法格式:unzip [参数] [文件]

常用参数:

参数 描述
-l 显示压缩文件内所包含的文件
-v 执行时显示详细的信息
-c 将解压缩的结果显示到屏幕上,并对字符做适当的转换
-n 解压缩时不要覆盖原有的文件
-j 不处理压缩文件中原有的目录路径

参考实例:

把/home目录下面的mydata.zip解压到mydatabak目录里面:

1
bash复制代码unzip mydata.zip -d mydatabak

把/home目录下面的wwwroot.zip直接解压到/home目录里面:

1
bash复制代码unzip wwwroot.zip

把/home目录下面的abc12.zip、abc23.zip、abc34.zip同时解压到/home目录里面:

1
bash复制代码unzip abc\*.zip

查看把/home目录下面的wwwroot.zip里面的内容:

1
bash复制代码unzip -v wwwroot.zip

验证/home目录下面的wwwroot.zip是否完整:

1
bash复制代码unzip -t wwwroot.zip

4️⃣ gzip命令 – 压缩和解压文件

gzip命令的英文是“GNUzip”,是常用来压缩文件的工具,gzip是个使用广泛的压缩程序,文件经它压缩过后,其名称后面会多处“.gz”扩展名。

gzip是在Linux系统中经常使用的一个对文件进行压缩和解压缩的命令,既方便又好用。gzip不仅可以用来压缩大的、较少使用的文件以节省磁盘空间,还可以和tar命令一起构成Linux操作系统中比较流行的压缩文件格式。据统计,gzip命令对文本文件有60%~70%的压缩率。减少文件大小有两个明显的好处,一是可以减少存储空间,二是通过网络传输文件时,可以减少传输的时间。

语法:

语法格式:gzip [参数]

常用参数:

参数 描述
-a 使用ASCII文字模式
-d 解开压缩文件
-f 强行压缩文件
-l 列出压缩文件的相关信息
-c 把压缩后的文件输出到标准输出设备,不去更动原始文件
-r 递归处理,将指定目录下的所有文件及子目录一并处理
-q 不显示警告信息

参考实例:

把rancher-v2.2.0目录下的每个文件压缩成.gz文件:

1
bash复制代码gzip *

把上例中每个压缩的文件解压,并列出详细的信息:

1
bash复制代码gzip -dv *

递归地解压目录:

1
bash复制代码gzip -dr rancher.gz

5️⃣ unarj命令 – 解压.arj文件

unarj命令用于解压缩.arj文件。

语法:

语法格式:unarj [参数] [.arj压缩文件]

常用参数:

参数 描述
-e 解压缩.arj文件
-l 显示压缩文件内所包含的文件
-t 检查压缩文件是否正确
-x 解压缩时保留原有的路径

参考实例:

解压缩.arj文件:

1
bash复制代码unarj -e test.arj

显示压缩文件内所包含的文件:

1
bash复制代码unarj -l test.arj

检查压缩文件是否正确:

1
bash复制代码unarj -t test.arj

解压缩时保留原有的路径:

1
bash复制代码unarj -x test.arj

把文件解压到当前路径:

1
bash复制代码unarj -ex test.arj

🍌 其他命令

1️⃣ hash 命令 – 显示与清除命令运行时查询的哈希表

hash命令负责显示与清除命令运行时系统优先查询的哈希表(hash table)。

当执行hash命令不指定参数或标志时,hash命令向标准输出报告路径名列表的内容。此报告含有先前hash命令调用找到的当前shell环境中命令的路径名。而且还包含通过正常命令搜索进程调用并找到的那些命令。

语法:

语法格式: hash [参数] [目录]

常用参数:

参数 描述
-d 在哈希表中清除记录
-l 显示哈希表中的命令
-p<指令> 将具有完整路径的命令加入到哈希表中
-r 清除哈希表中的记录
-t 显示哈希表中命令的完整路径

参考实例:

显示哈希表中的命令:

1
bash复制代码hash -l

删除哈希表中的命令:

1
bash复制代码hash -r

向哈希表中添加命令:

1
bash复制代码hash -p /usr/sbin/adduser myadduser

在哈希表中清除记录:

1
bash复制代码hash -d

显示哈希表中命令的完整路径:

1
bash复制代码hash -t

2️⃣ grep 命令 – 强大的文本搜索工具

grep是“global search regular expression and print out the line”的简称,意思是全面搜索正则表达式,并将其打印出来。这个命令可以结合正则表达式使用,它也是linux使用最为广泛的命令。

grep命令的选项用于对搜索过程的补充,而其命令的模式十分灵活,可以是变量、字符串、正则表达式。需要注意的是:一当模式中包含了空格,务必要用双引号将其引起来。

linux系统支持三种形式的grep命令,大儿子就是grep,标准,模仿的代表。二儿子兴趣爱好多-egrep,简称扩展grep命令,其实和grep -E等价,支持基本和扩展的正则表达式。小儿子跑的最快-fgrep,简称快速grep命令,其实和grep -F等价,不支持正则表达式,按照字符串表面意思进行匹配。

语法:

语法格式: grep [参数]

常用参数:

参数 描述
-i 搜索时,忽略大小写
-c 只输出匹配行的数量
-l 只列出符合匹配的文件名,不列出具体的匹配行
-n 列出所有的匹配行,显示行号
-h 查询多文件时不显示文件名
-s 不显示不存在、没有匹配文本的错误信息
-v 显示不包含匹配文本的所有行
-w 匹配整词
-x 匹配整行
-r 递归搜索
-q 禁止输出任何结果,已退出状态表示搜索是否成功
-b 打印匹配行距文件头部的偏移量,以字节为单位
-o 与-b结合使用,打印匹配的词据文件头部的偏移量,以字节为单位

参考实例:

支持多文件查询并支持使用通配符:

1
bash复制代码grep zwx file_* /etc/hosts

输出匹配字符串行的数量:

1
bash复制代码grep -c zwx file_*

列出所有的匹配行,并显示行号:

1
bash复制代码grep -n zwx file_*

显示不包含模式的所有行:

1
bash复制代码grep -vc zwx file_*

不再显示文件名:

1
bash复制代码grep -h zwx file_*

只列出符合匹配的文件名,不列出具体匹配的行:

1
bash复制代码grep -l zwx file_*

不显示不存在或无匹配的文本信息:

1
2
bash复制代码grep  -s zwx file1 file_1
grep zwx file1 file_1

递归搜索,不仅搜索当前目录,还搜索子目录:

1
bash复制代码grep -r zwx file_2 *

匹配整词,以字面意思去解释他,相当于精确匹配:

1
2
bash复制代码grep zw* file_1
grep -w zw* file_1

匹配整行,文件中的整行与模式匹配时,才打印出来:

1
bash复制代码grep -x zwx file_*

不输出任何结果,已退出状态表示结果:

1
2
3
4
5
6
bash复制代码grep -q zwx file_1
echo $?
grep -q zwx file_5
echo $?
grep -q zwx file5
echo $?

查找一个文件中的空行和非空行:

1
2
bash复制代码grep -c ^$ file_1
grep -c ^[^$] file_1

匹配任意或重复字符用“.”或“*”符号来实现:

1
2
bash复制代码grep ^z.x file_1
grep ^z* file_6

3️⃣ wait命令 – 等待指令

wait命令用来等待指令的指令,直到其执行完毕后返回终端。该指令常用于shell脚本编程中,待指定的指令执行完成后,才会继续执行后面的任务。该指令等待作业时,在作业标识号前必须添加备份号”%”。

语法:

语法格式:wait [参数]

常用参数:

参数 描述
22 或%1 进程号 或 作业号

参考实例:

等待作业号为1的作业完成后再返回:

1
2
bash复制代码wait %1
find / -name password

4️⃣ bc命令 – 浮点运算

bc的英文全拼为“ Binary Calculator ”,是一种支持任意精度的交互执行的计算器语言。bash内置了对整数四则运算的支持,但是并不支持浮点运算,而bc命令可以很方便的进行浮点运算,当然整数运算也不再话下。

语法:

语法格式:bc [选项]

常用参数:

参数 描述
-i 强制进入交互式模式
-l 定义使用的标准数学库
-w 定义使用的标准数学库
-q 打印正常的GNU bc环境信息

参考实例:

算术操作高级运算bc命令它可以执行浮点运算和一些高级函数:

1
bash复制代码echo "1.212*3" | bc

设定小数精度(数值范围):

1
bash复制代码echo "scale=2;3/8" | bc

计算平方和平方根:

1
2
bash复制代码echo "10^10" | bc
echo "sqrt(100)" | bc

5️⃣ history命令 – 显示与操纵历史命令

history命令用于显示用户以前执行过的历史命令,并且能对历史命令进行追加和删除等操作。

如果你经常使用Linux命令,那么使用history命令可以有效地提升你的效率。

语法:

语法格式: history [参数] [目录]

常用参数:

参数 描述
-a 将当前shell会话的历史命令追加到命令历史文件中,命令历史文件是保存历史命令的配置文件
-c 清空当前历史命令列表
-d 删除历史命令列表中指定序号的命令
-n 从命令历史文件中读取本次Shell会话开始时没有读取的历史命令
-r 读取命令历史文件到当前的Shell历史命令内存缓冲区
-s 将指定的命令作为单独的条目加入命令历史内存缓冲区。在执行添加之前先删除命令历史内存缓冲区中最后一条命令
-w 把当前的shell历史命令内存缓冲区的内容写入命令历史文件

参考实例:

显示最近的10条命令:

1
bash复制代码history 10

将本次登录的命令写入历史文件中:

1
bash复制代码history -w

将命令历史文件中的内容读入到目前shell的history记忆中 :

1
bash复制代码history -r

将当前Shell会话的历史命令追加到命令历史文件中:

1
bash复制代码history -a

清空当前历史命令列表:

1
bash复制代码history -c

本次分享到此结束啦~

如果觉得文章对你有帮助,点赞、收藏、关注、评论,一键四连支持,你的支持就是我创作最大的动力。

❤️ 技术交流可以 关注公众号:Lucifer三思而后行 ❤️

本文转载自: 掘金

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

ForkJoin看这篇就够了!

发表于 2021-09-17

大家好,我是小黑,一个在互联网苟且偷生的农民工。

在JDK1.7中引入了一种新的Fork/Join线程池,它可以将一个大的任务拆分成多个小的任务并行执行并汇总执行结果。

Fork/Join采用的是分而治之的基本思想,分而治之就是将一个复杂的任务,按照规定的阈值划分成多个简单的小任务,然后将这些小任务的结果再进行汇总返回,得到最终的任务。

分治法

分治法是计算机领域常用的算法中的其中一个,主要思想就是将将一个规模为N的问题,分解成K个规模较小的子问题,这些子问题相互独立且与原问题性质相同;求解出子问题的解,合并得到原问题的解。

解决问题的思路

  • 分割原问题;
  • 求解子问题;
  • 合并子问题的解为原问题的解。

使用场景

二分查找,阶乘计算,归并排序,堆排序、快速排序、傅里叶变换都用了分治法的思想。

ForkJoin并行处理框架

在JDK1.7中推出的ForkJoinPool线程池,主要用于ForkJoinTask任务的执行,ForkJoinTask是一个类似线程的实体,但是比普通线程更轻量。

我们来使用ForkJoin框架完成以下1-10亿求和的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
java复制代码public class ForkJoinMain {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ForkJoinPool forkJoinPool = new ForkJoinPool();
ForkJoinTask<Long> rootTask = forkJoinPool.submit(new SumForkJoinTask(1L, 10_0000_0000L));
System.out.println("计算结果:" + rootTask.get());
}
}

class SumForkJoinTask extends RecursiveTask<Long> {
private final Long min;
private final Long max;
private Long threshold = 1000L;

public SumForkJoinTask(Long min, Long max) {
this.min = min;
this.max = max;
}
@Override
protected Long compute() {
// 小于阈值时直接计算
if ((max - min) <= threshold) {
long sum = 0;
for (long i = min; i < max; i++) {
sum = sum + i;
}
return sum;
}
// 拆分成小任务
long middle = (max + min) >>> 1;
SumForkJoinTask leftTask = new SumForkJoinTask(min, middle);
leftTask.fork();
SumForkJoinTask rightTask = new SumForkJoinTask(middle, max);
rightTask.fork();
// 汇总结果
return leftTask.join() + rightTask.join();
}
}

上述代码逻辑可通过下图更加直观的理解。

ForkJoin框架实现

在ForkJoin框架中重要的一些接口和类如下图所示。

ForkJoinPool

ForkJoinPool是用于运行ForkJoinTasks的线程池,实现了Executor接口。

可以通过new ForkJoinPool()直接创建ForkJoinPool对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public ForkJoinPool() {
this(Math.min(MAX_CAP, Runtime.getRuntime().availableProcessors()),
defaultForkJoinWorkerThreadFactory, null, false);
}

public ForkJoinPool(int parallelism,
ForkJoinWorkerThreadFactory factory,
UncaughtExceptionHandler handler,
boolean asyncMode){
this(checkParallelism(parallelism),
checkFactory(factory),
handler,
asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
"ForkJoinPool-" + nextPoolId() + "-worker-");
checkPermission();
}

通过查看构造方法源码我们可以发现,在创建ForkJoinPool时,有以下4个参数:

  • parallelism:期望并发数。默认会使用Runtime.getRuntime().availableProcessors()的值
  • factory:创建ForkJoin工作线程的工厂,默认为defaultForkJoinWorkerThreadFactory
  • handler:执行任务时遇到不可恢复的错误时的处理程序,默认为null
  • asyncMode:工作线程获取任务使用FIFO模式还是LIFO模式,默认为LIFO

ForkJoinTask

ForkJoinTask是一个对于在ForkJoinPool中运行任务的抽象类定义。

可以通过少量的线程处理大量任务和子任务,ForkJoinTask实现了Future接口。主要通过fork()方法安排异步任务执行,通过join()方法等待任务执行的结果。

想要使用ForkJoinTask通过少量的线程处理大量任务,需要接受一些限制。

  • 拆分的任务中避免同步方法或同步代码块;
  • 在细分的任务中避免执行阻塞I/O操作,理想情况下基于完全独立于其他正在运行的任务访问的变量;
  • 不允许在细分任务中抛出受检异常。

因为ForkJoinTask是抽象类不能被实例化,所以在使用时JDK为我们提供了三种特定类型的ForkJoinTask父类供我们自定义时继承使用。

  • RecursiveAction:子任务不返回结果
  • RecursiveTask:子任务返回结果
  • CountedCompleter:在任务完成执行后会触发执行

ForkJoinWorkerThread

ForkJoinPool中用于执行ForkJoinTask的线程。

ForkJoinPool既然实现了Executor接口,那么它和我们常用的ThreadPoolExecutor之前又有什么差异呢?

如果们使用ThreadPoolExecutor来完成分治法的逻辑,那么每个子任务都需要创建一个线程,当子任务的数量很大的情况下,可能会达到上万个,那么使用ThreadPoolExecutor创建出上万个线程,这显然是不可行、不合理的;

而ForkJoinPool在处理任务时,并不会按照任务开启线程,只会按照指定的期望并行数量创建线程。在每个线程工作时,如果需要继续拆分子任务,则会将当前任务放入ForkJoinWorkerThread的任务队列中,递归处理直到最外层的任务。

工作窃取算法

ForkJoinPool的各个工作线程都会维护一个各自的任务队列,减少线程之间对于任务的竞争;

每个线程都会先保证将自己队列中的任务执行完,当自己的任务执行完之后,会去看其他线程的任务队列中是否有未处理完的任务,如果有则会帮助其他线程执行;

为了减少在帮助其他线程执行任务时发生竞争,会使用双端队列来存放任务,被窃取的任务只会从队列的头部获取任务,而正常处理的线程每次都是从队列的尾部获取任务。

优点

充分利用了线程资源,避免资源的浪费,并且减少了线程间的竞争。

缺点

需要给每个线程开辟一个队列空间;在工作队列中只有一个任务时同样会存在线程竞争。

最后

如果觉得文章对你有点帮助,不妨点个赞。我是小黑,下期见~

本文转载自: 掘金

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

面试官:你说说限流的原理? 限流算法 源码举例 总结

发表于 2021-09-17

限流作为现在微服务中常见的稳定性措施,在面试中肯定也是经常会被问到的,我在面试的时候也经常喜欢问一下你对限流算法知道哪一些?有看过源码吗?实现原理是什么?

第一部分先讲讲限流算法,最后再讲讲源码的实现原理。

限流算法

关于限流的算法大体上可以分为四类:固定窗口计数器、滑动窗口计数器、漏桶(也有称漏斗,英文Leaky bucket)、令牌桶(英文Token bucket)。

固定窗口

固定窗口,相比其他的限流算法,这应该是最简单的一种。

它简单地对一个固定的时间窗口内的请求数量进行计数,如果超过请求数量的阈值,将被直接丢弃。

这个简单的限流算法优缺点都很明显。优点的话就是简单,缺点举个例子来说。

比如我们下图中的黄色区域就是固定时间窗口,默认时间范围是60s,限流数量是100。

如图中括号内所示,前面一段时间都没有流量,刚好后面30秒内来了100个请求,此时因为没有超过限流阈值,所以请求全部通过,然后下一个窗口的20秒内同样通过了100个请求。

所以变相的相当于在这个括号的40秒的时间内就通过了200个请求,超过了我们限流的阈值。

限流

限流

滑动窗口

为了优化这个问题,于是有了滑动窗口算法,顾名思义,滑动窗口就是时间窗口在随着时间推移不停地移动。

滑动窗口把一个固定时间窗口再继续拆分成N个小窗口,然后对每个小窗口分别进行计数,所有小窗口请求之和不能超过我们设定的限流阈值。

以下图举例子来说,假设我们的窗口拆分成了3个小窗口,小窗口都是20s,同样基于上面的例子,当在第三个20s的时候来了100个请求,可以通过。

然后时间窗口滑动,下一个20s请求又来了100个请求,此时我们滑动窗口的60s范围内请求数量肯定就超过100了啊,所以请求被拒绝。

漏桶Leaky bucket

漏桶算法,人如其名,他就是一个漏的桶,不管请求的数量有多少,最终都会以固定的出口流量大小匀速流出,如果请求的流量超过漏桶大小,那么超出的流量将会被丢弃。

也就是说流量流入的速度是不定的,但是流出的速度是恒定的。

这个和MQ削峰填谷的思想比较类似,在面对突然激增的流量的时候,通过漏桶算法可以做到匀速排队,固定速度限流。

漏桶算法的优势是匀速,匀速是优点也是缺点,很多人说漏桶不能处理突增流量,这个说法并不准确。

漏桶本来就应该是为了处理间歇性的突增流量,流量一下起来了,然后系统处理不过来,可以在空闲的时候去处理,防止了突增流量导致系统崩溃,保护了系统的稳定性。

但是,换一个思路来想,其实这些突增的流量对于系统来说完全没有压力,你还在慢慢地匀速排队,其实是对系统性能的浪费。

所以,对于这种有场景来说,令牌桶算法比漏桶就更有优势。

令牌桶token bucket

令牌桶算法是指系统以一定地速度往令牌桶里丢令牌,当一个请求过来的时候,会去令牌桶里申请一个令牌,如果能够获取到令牌,那么请求就可以正常进行,反之被丢弃。

现在的令牌桶算法,像Guava和Sentinel的实现都有冷启动/预热的方式,为了避免在流量激增的同时把系统打挂,令牌桶算法会在最开始一段时间内冷启动,随着流量的增加,系统会根据流量大小动态地调整生成令牌的速度,最终直到请求达到系统的阈值。

源码举例

我们以sentinel举例,sentinel中统计用到了滑动窗口算法,然后也有用到漏桶、令牌桶算法。

滑动窗口

sentinel中就使用到了滑动窗口算法来进行统计,不过他的实现和我上面画的图有点不一样,实际上sentinel中的滑动窗口用一个圆形来描述更合理一点。

前期就是创建节点,然后slot串起来就是一个责任链模式,StatisticSlot通过滑动窗口来统计数据,FlowSlot是真正限流的逻辑,还有一些降级、系统保护的措施,最终形成了整个sentinel的限流方式。

就看看官方图吧,这圆形画起来好恶心

就看看官方图吧,这圆形画起来好恶心

滑动窗口的实现主要可以看LeapArray的代码,默认的话定义了时间窗口的相关参数。

对于sentinel来说其实窗口分为秒和分钟两个级别,秒的话窗口数量是2,分钟则是60个窗口,每个窗口的时间长度是1s,总的时间周期就是60s,分成60个窗口,这里我们就以分钟级别的统计来说。

1
2
3
4
5
6
7
8
9
10
11
php复制代码public abstract class LeapArray<T> {
    //窗口时间长度,毫秒数,默认1000ms
    protected int windowLengthInMs;
    //窗口数量,默认60
    protected int sampleCount;
    //毫秒时间周期,默认60*1000
    protected int intervalInMs;
    //秒级时间周期,默认60
    private double intervalInSecond;
    //时间窗口数组
    protected final AtomicReferenceArray<WindowWrap<T>> array;

然后我们要看的就是它是怎么计算出当前窗口的,其实源码里写的听清楚的,但是如果你按照之前想象把他当做一条直线延伸去想的话估计不太好理解。

首先计算数组索引下标和时间窗口时间这个都比较简单,难点应该大部分在于第三点窗口大于old这个是什么鬼,详细说下这几种情况。

  1. 数组中的时间窗口是是空的,这个说明时间走到了我们初始化的时间之后了,此时new一个新的窗口通过CAS的方式去更新,然后返回这个新的窗口就好了。
  2. 第二种情况是刚好时间窗口的时间相等,那么直接返回,没啥好说的
  3. 第三种情况就是比较难以理解的,可以参看两条时间线的图,就比较好理解了,第一次时间窗口走完了达到1200,然后圆形时间窗口开始循环,新的时间起始位置还是1200,然后时间窗口的时间来到1676,B2的位置如果还是老的窗口那么就是600,所以我们要重置之前的时间窗口的时间为当前的时间。
  4. 最后一种一般情况不太可能发生,除非时钟回拨这样子

从这个我们可以发现就是针对每个WindowWrap时间窗口都进行了统计,最后实际上在后面的几个地方都会用到时间窗口统计的QPS结果,这里就不再赘述了,知道即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
csharp复制代码private int calculateTimeIdx(/*@Valid*/ long timeMillis) {
    long timeId = timeMillis / windowLengthInMs;
    // Calculate current index so we can map the timestamp to the leap array.
    return (int) (timeId % array.length());
}

protected long calculateWindowStart(/*@Valid*/ long timeMillis) {
    return timeMillis - timeMillis % windowLengthInMs;
}

public WindowWrap<T> currentWindow(long timeMillis) {
    //当前时间如果小于0,返回空
    if (timeMillis < 0) {
        return null;
    }
    //计算时间窗口的索引
    int idx = calculateTimeIdx(timeMillis);
    // 计算当前时间窗口的开始时间
    long windowStart = calculateWindowStart(timeMillis);

    while (true) {
        //在窗口数组中获得窗口
        WindowWrap<T> old = array.get(idx);
        if (old == null) {
            /*
             *     B0       B1      B2    NULL      B4
             * ||_______|_______|_______|_______|_______||___
             * 200     400     600     800     1000    1200  timestamp
             *                             ^
             *                          time=888
             * 比如当前时间是888,根据计算得到的数组窗口位置是个空,所以直接创建一个新窗口就好了
             */
            WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
            if (array.compareAndSet(idx, null, window)) {
                // Successfully updated, return the created bucket.
                return window;
            } else {
                // Contention failed, the thread will yield its time slice to wait for bucket available.
                Thread.yield();
            }
        } else if (windowStart == old.windowStart()) {
            /*
             *     B0       B1      B2     B3      B4
             * ||_______|_______|_______|_______|_______||___
             * 200     400     600     800     1000    1200  timestamp
             *                             ^
             *                          time=888
             * 这个更好了,刚好等于,直接返回就行
             */
            return old;
        } else if (windowStart > old.windowStart()) {
            /*
             *     B0       B1      B2     B3      B4
             * |_______|_______|_______|_______|_______||___
             * 200     400     600     800     1000    1200  timestamp
             *             B0       B1      B2    NULL      B4
             * |_______||_______|_______|_______|_______|_______||___
             * ...    1200     1400    1600    1800    2000    2200  timestamp
             *                              ^
             *                           time=1676
             * 这个要当成圆形理解就好了,之前如果是1200一个完整的圆形,然后继续从1200开始,如果现在时间是1676,落在在B2的位置,
             * 窗口开始时间是1600,获取到的old时间其实会是600,所以肯定是过期了,直接重置窗口就可以了
             */
            if (updateLock.tryLock()) {
                try {
                    // Successfully get the update lock, now we reset the bucket.
                    return resetWindowTo(old, windowStart);
                } finally {
                    updateLock.unlock();
                }
            } else {
                Thread.yield();
            }
        } else if (windowStart < old.windowStart()) {
            // 这个不太可能出现,嗯。。时钟回拨
            return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
        }
    }
}

漏桶

sentinel主要根据FlowSlot中的流控进行流量控制,其中RateLimiterController就是漏桶算法的实现,这个实现相比其他几个还是简单多了,稍微看一下应该就明白了。

  1. 首先计算出当前请求平摊到1s内的时间花费,然后去计算这一次请求预计时间
  2. 如果小于当前时间的话,那么以当前时间为主,返回即可
  3. 反之如果超过当前时间的话,这时候就要进行排队等待了,等待的时候要判断是否超过当前最大的等待时间,超过就直接丢弃
  4. 没有超过就更新上一次的通过时间,然后再比较一次是否超时,还超时就重置时间,反之在等待时间范围之内的话就等待,如果都不是那就可以通过了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
java复制代码public class RateLimiterController implements TrafficShapingController {
  //最大等待超时时间,默认500ms
  private final int maxQueueingTimeMs;
  //限流数量
  private final double count;
  //上一次的通过时间
  private final AtomicLong latestPassedTime = new AtomicLong(-1);

  @Override
  public boolean canPass(Node node, int acquireCount, boolean prioritized) {
      // Pass when acquire count is less or equal than 0.
      if (acquireCount <= 0) {
          return true;
      }
      // Reject when count is less or equal than 0.
      // Otherwise,the costTime will be max of long and waitTime will overflow in some cases.
      if (count <= 0) {
          return false;
      }

      long currentTime = TimeUtil.currentTimeMillis();
      //时间平摊到1s内的花费
      long costTime = Math.round(1.0 * (acquireCount) / count * 1000); // 1 / 100 * 1000 = 10ms

      //计算这一次请求预计的时间
      long expectedTime = costTime + latestPassedTime.get();

      //花费时间小于当前时间,pass,最后通过时间 = 当前时间
      if (expectedTime <= currentTime) {
          latestPassedTime.set(currentTime);
          return true;
      } else {
          //预计通过的时间超过当前时间,要进行排队等待,重新获取一下,避免出现问题,差额就是需要等待的时间
          long waitTime = costTime + latestPassedTime.get() - TimeUtil.currentTimeMillis();
          //等待时间超过最大等待时间,丢弃
          if (waitTime > maxQueueingTimeMs) {
              return false;
          } else {
              //反之,可以更新最后一次通过时间了
              long oldTime = latestPassedTime.addAndGet(costTime);
              try {
                  waitTime = oldTime - TimeUtil.currentTimeMillis();
                  //更新后再判断,还是超过最大超时时间,那么就丢弃,时间重置
                  if (waitTime > maxQueueingTimeMs) {
                      latestPassedTime.addAndGet(-costTime);
                      return false;
                  }
                  //在时间范围之内的话,就等待
                  if (waitTime > 0) {
                      Thread.sleep(waitTime);
                  }
                  return true;
              } catch (InterruptedException e) {
              }
          }
      }
      return false;
  }

}

令牌桶

最后是令牌桶,这个不在于实现的复制,而是你看源码会发现都算的些啥玩意儿。。。sentinel的令牌桶实现基于Guava,代码在WarmUpController中。

这个算法那些各种计算逻辑其实我们可以不管(因为我也没看懂。。),但是流程上我们是清晰的就可以了。

几个核心的参数看注释,构造方法里那些计算逻辑暂时不管他是怎么算的(我也没整明白,但是不影响我们理解),关键看canPass是怎么做的。

  1. 拿到当前窗口和上一个窗口的QPS
  2. 填充令牌,也就是往桶里丢令牌,然后我们先看填充令牌的逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
java复制代码public class WarmUpController implements TrafficShapingController {
    //限流QPS
    protected double count;
    //冷启动系数,默认=3
    private int coldFactor;
    //警戒的令牌数
    protected int warningToken = 0;
    //最大令牌数
    private int maxToken;
    //斜率,产生令牌的速度
    protected double slope;

    //存储的令牌数量
    protected AtomicLong storedTokens = new AtomicLong(0);
    //最后一次填充令牌时间
    protected AtomicLong lastFilledTime = new AtomicLong(0);

    public WarmUpController(double count, int warmUpPeriodInSec, int coldFactor) {
        construct(count, warmUpPeriodInSec, coldFactor);
    }

    public WarmUpController(double count, int warmUpPeriodInSec) {
        construct(count, warmUpPeriodInSec, 3);
    }

    private void construct(double count, int warmUpPeriodInSec, int coldFactor) {
        if (coldFactor <= 1) {
            throw new IllegalArgumentException("Cold factor should be larger than 1");
        }
        this.count = count;
        this.coldFactor = coldFactor;

        //stableInterval 稳定产生令牌的时间周期,1/QPS
        //warmUpPeriodInSec 预热/冷启动时间 ,默认 10s
        warningToken = (int)(warmUpPeriodInSec * count) / (coldFactor - 1);
        maxToken = warningToken + (int)(2 * warmUpPeriodInSec * count / (1.0 + coldFactor));
    //斜率的计算参考Guava,当做一个固定改的公式
        slope = (coldFactor - 1.0) / count / (maxToken - warningToken);
    }

    @Override
    public boolean canPass(Node node, int acquireCount, boolean prioritized) {
        //当前时间窗口通过的QPS
        long passQps = (long) node.passQps();
        //上一个时间窗口QPS
        long previousQps = (long) node.previousPassQps();
        //填充令牌
        syncToken(previousQps);

        // 开始计算它的斜率
        // 如果进入了警戒线,开始调整他的qps
        long restToken = storedTokens.get();
        if (restToken >= warningToken) {
            //当前的令牌超过警戒线,获得超过警戒线的令牌数
            long aboveToken = restToken - warningToken;
            // 消耗的速度要比warning快,但是要比慢
            // current interval = restToken*slope+1/count
            double warningQps = Math.nextUp(1.0 / (aboveToken * slope + 1.0 / count));
            if (passQps + acquireCount <= warningQps) {
                return true;
            }
        } else {
            if (passQps + acquireCount <= count) {
                return true;
            }
        }

        return false;
    }
}

填充令牌的逻辑如下:

  1. 拿到当前的时间,然后去掉毫秒数,得到的就是秒级时间
  2. 判断时间小于这里就是为了控制每秒丢一次令牌
  3. 然后就是coolDownTokens去计算我们的冷启动/预热是怎么计算填充令牌的
  4. 后面计算当前剩下的令牌数这个就不说了,减去上一次消耗的就是桶里剩下的令牌
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ini复制代码protected void syncToken(long passQps) {
  long currentTime = TimeUtil.currentTimeMillis();
  //去掉当前时间的毫秒
  currentTime = currentTime - currentTime % 1000;
  long oldLastFillTime = lastFilledTime.get();
  //控制每秒填充一次令牌
  if (currentTime <= oldLastFillTime) {
    return;
  }
  //当前的令牌数量
  long oldValue = storedTokens.get();
  //获取新的令牌数量,包含添加令牌的逻辑,这就是预热的逻辑
  long newValue = coolDownTokens(currentTime, passQps);
  if (storedTokens.compareAndSet(oldValue, newValue)) {
    //存储的令牌数量当然要减去上一次消耗的令牌
    long currentValue = storedTokens.addAndGet(0 - passQps);
    if (currentValue < 0) {
      storedTokens.set(0L);
    }
    lastFilledTime.set(currentTime);
  }

}
  1. 最开始的事实因为lastFilledTime和oldValue都是0,所以根据当前时间戳会得到一个非常大的数字,最后和maxToken取小的话就得到了最大的令牌数,所以第一次初始化的时候就会生成maxToken的令牌
  2. 之后我们假设系统的QPS一开始很低,然后突然飙高。所以开始的时候回一直走到高于警戒线的逻辑里去,然后passQps又很低,所以会一直处于把令牌桶填满的状态(currentTime - lastFilledTime.get()会一直都是1000,也就是1秒),所以每次都会填充最大QPScount数量的令牌
  3. 然后突增流量来了,QPS瞬间很高,慢慢地令牌数量就会消耗到警戒线之下,走到我们if的逻辑里去,然后去按照count数量增加令牌
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
csharp复制代码private long coolDownTokens(long currentTime, long passQps) {
  long oldValue = storedTokens.get();
  long newValue = oldValue;

  //水位低于警戒线,就生成令牌
  if (oldValue < warningToken) {
    //如果桶中令牌低于警戒线,根据上一次的时间差,得到新的令牌数,因为去掉了毫秒,1秒生成的令牌就是阈值count
    //第一次都是0的话,会生成count数量的令牌
    newValue = (long)(oldValue + (currentTime - lastFilledTime.get()) * count / 1000);
  } else if (oldValue > warningToken) {
    //反之,如果是高于警戒线,要判断QPS。因为QPS越高,生成令牌就要越慢,QPS低的话生成令牌要越快
    if (passQps < (int)count / coldFactor) {
      newValue = (long)(oldValue + (currentTime - lastFilledTime.get()) * count / 1000);
    }
  }
  //不要超过最大令牌数
  return Math.min(newValue, maxToken);
}

上面的逻辑理顺之后,我们就可以继续看限流的部分逻辑:

  1. 令牌计算的逻辑完成,然后判断是不是超过警戒线,按照上面的说法,低QPS的状态肯定是一直超过的,所以会根据斜率来计算出一个warningQps,因为我们处于冷启动的状态,所以这个阶段就是要根据斜率来计算出一个QPS数量,让流量慢慢地达到系统能承受的峰值。举个例子,如果count是100,那么在QPS很低的情况下,令牌桶一直处于满状态,但是系统会控制QPS,实际通过的QPS就是warningQps,根据算法可能只有10或者20(怎么算的不影响理解)。QPS主键提高的时候,aboveToken再逐渐变小,整个warningQps就在逐渐变大,直到走到警戒线之下,到了else逻辑里。
  2. 流量突增的情况,就是else逻辑里低于警戒线的情况,我们令牌桶在不停地根据count去增加令牌,这时候消耗令牌的速度超过我们生成令牌的速度,可能就会导致一直处于警戒线之下,这时候判断当然就需要根据最高QPS去判断限流了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ini复制代码 long restToken = storedTokens.get();
 if (restToken >= warningToken) {
  //当前的令牌超过警戒线,获得超过警戒线的令牌数
  long aboveToken = restToken - warningToken;
  // 消耗的速度要比warning快,但是要比慢
  // current interval = restToken*slope+1/count
  double warningQps = Math.nextUp(1.0 / (aboveToken * slope + 1.0 / count));
  if (passQps + acquireCount <= warningQps) {
   return true;
  }
 } else {
  if (passQps + acquireCount <= count) {
   return true;
  }
 }

所以,按照低QPS到突增高QPS的流程,来想象一下这个过程:

  1. 刚开始,系统的QPS非常低,初始化我们就直接把令牌桶塞满了
  2. 然后这个低QPS的状态持续了一段时间,因为我们一直会填充最大QPS数量的令牌(因为取最小值,所以其实桶里令牌基本不会有变化),所以令牌桶一直处于满的状态,整个系统的限流也处于一个比较低的水平

这以上的部分一直处于警戒线之上,实际上就是叫做冷启动/预热的过程。

  1. 接着系统的QPS突然激增,令牌消耗速度太快,就算我们每次增加最大QPS数量的令牌任然无法维持消耗,所以桶里的令牌在不断低减少,这个时候,冷启动阶段的限制QPS也在不断地提高,最后直到桶里的令牌低于警戒线
  2. 低于警戒线之后,系统就会按照最高QPS去限流,这个过程就是系统在逐渐达到最高限流的过程

那这样一来,实际就达到了我们处理突增流量的目的,整个系统在漫漫地适应突然飙高的QPS,然后最终达到系统的QPS阈值。

  1. 最后,如果QPS回复正常,那么又会逐渐回到警戒线之上,就回到了最开始的过程。

总结

因为算法如果单独说的话都比较简单,一说大家都可以听明白,不需要几个字就能说明白,所以还是得弄点源码看看别人是怎么玩的,所以尽管我很讨厌放源码,但是还是不得不干。

光靠别人说一点其实有点看不明白,按照顺序读一遍的话心里就有数了。

那源码的话最难以理解的就是令牌桶的实现了,说实话那几个计算的逻辑我看了好几遍不知道他算的什么鬼,但是思想我们理解就行了,其他的逻辑相对来说就比较容易理解。

本文转载自: 掘金

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

netty系列之 在netty中处理CORS 简介 服务端的

发表于 2021-09-17

简介

CORS的全称是跨域资源共享,他是一个基于HTTP-header检测的机制,通过对HTTP-header进行控制,可以实现对跨域资源的权限管理功能。在之前的CORS详解文章中,我们已经对CORS有了基本的解释。

本文将会从netty的实现角度,讲解如何在netty中实现CORS。

服务端的CORS配置

熟悉CORS的朋友应该知道,CORS所有的操作都是在HTTP协议之上通过控制HTTP头来实现的。所以说如果要在服务器端实现CORS的支持,事实上也是对HTTP协议的头进行各种设置完成的。

为了方便大家的使用,netty提供了一个CorsConfig类,来统一CORS的头设置。

先看下CorsConfig类中定义的属性:

1
2
3
4
5
6
7
8
9
10
11
arduino复制代码    private final Set<String> origins;
private final boolean anyOrigin;
private final boolean enabled;
private final Set<String> exposeHeaders;
private final boolean allowCredentials;
private final long maxAge;
private final Set<HttpMethod> allowedRequestMethods;
private final Set<String> allowedRequestHeaders;
private final boolean allowNullOrigin;
private final Map<CharSequence, Callable<?>> preflightHeaders;
private final boolean shortCircuit;

这些属性和CORS的HTTP头设置是一一对应的。比如说origins表示的是允许的源,anyOrigin表示允许所有的源。

是和下面的设置对应的:

1
makefile复制代码Origin: <origin>

exposeHeaders是和Access-Control-Expose-Headers一一对应的,表示服务器端允许客户端获取CORS资源的同时能够访问到的header信息。其格式如下:

1
xml复制代码Access-Control-Expose-Headers: <header-name>[, <header-name>]*

allowCredentials表示是否开启CORS的权限认证。表示服务器端是否接受客户端带有credentials字段的请求。如果用在preflight请求中,则表示后续的真实请求是否支持credentials,其格式如下:

1
yaml复制代码Access-Control-Allow-Credentials: true

allowedRequestMethods表示访问资源允许的方法,主要用在preflight request中。其格式如下:

1
sql复制代码Access-Control-Allow-Methods: <method>[, <method>]*

allowedRequestHeaders用在preflight request中,表示真正能够被用来做请求的header字段,其格式如下:

1
xml复制代码Access-Control-Allow-Headers: <header-name>[, <header-name>]*

当客户端发送OPTIONS方法给服务器的时候,为了安全起见,因为服务器并不一定能够接受这些OPTIONS的方法,所以客户端需要首先发送一个
preflighted requests,等待服务器响应,等服务器确认之后,再发送真实的请求。我们举一个例子。preflightHeaders表示的就是服务器允许额preflight的请求头。

shortCircuit表示请求是否是一个有效的CORS请求,如果请求被拒绝之后,就会返回一个true。

CorsConfigBuilder

CorsConfig使用来表示Cors的配置类,那么怎么去构造这个配置类呢?我们看下CorsConfig的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码    CorsConfig(final CorsConfigBuilder builder) {
origins = new LinkedHashSet<String>(builder.origins);
anyOrigin = builder.anyOrigin;
enabled = builder.enabled;
exposeHeaders = builder.exposeHeaders;
allowCredentials = builder.allowCredentials;
maxAge = builder.maxAge;
allowedRequestMethods = builder.requestMethods;
allowedRequestHeaders = builder.requestHeaders;
allowNullOrigin = builder.allowNullOrigin;
preflightHeaders = builder.preflightHeaders;
shortCircuit = builder.shortCircuit;
}

可以看到CorsConfig是通过CorsConfigBuilder来构造的。通过设置CorsConfigBuilder中的各种属性即可。CorsConfigBuilder中提供了多种设置属性的方法。

可以使用这样的方法来构造CorsConfig如下:

1
scss复制代码CorsConfig corsConfig = CorsConfigBuilder.forAnyOrigin().allowNullOrigin().allowCredentials().build();

CorsHandler

有了corsConfig,我们还需要将这个config配置在netty的handler中,netty提供了一个CorsHandler类来专门处理corsConfig,这个类就叫CorsHandler。

首先看下CorsHandler的构造函数:

1
2
3
4
5
6
7
8
9
arduino复制代码    public CorsHandler(final CorsConfig config) {
this(Collections.singletonList(checkNotNull(config, "config")), config.isShortCircuit());
}

public CorsHandler(final List<CorsConfig> configList, boolean isShortCircuit) {
checkNonEmpty(configList, "configList");
this.configList = configList;
this.isShortCircuit = isShortCircuit;
}

CorsHandler有两个构造函数,一个是传入CorsConfig,一个是传入一个CorsConfig的列表。

CorsHandler的主要工作原理就是在channelRead的时候,对responseHeader进行处理,设置CORS头。

netty对cors的支持

上面我们已经讲过了netty中cors的核心类和方法,最后一步就是把cors的支持类加入到netty的pipeline中,其核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码    public void initChannel(SocketChannel ch) {

ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new HttpResponseEncoder());
pipeline.addLast(new HttpRequestDecoder());
pipeline.addLast(new HttpObjectAggregator(65536));
pipeline.addLast(new ChunkedWriteHandler());

CorsConfig corsConfig = CorsConfigBuilder.forAnyOrigin().allowNullOrigin().allowCredentials().build();
pipeline.addLast(new CorsHandler(corsConfig));

pipeline.addLast(new CustResponseHandler());
}

总结

cors比较简单,netty也为其提供了住够的方法支持。大家可以直接使用。

本文的例子可以参考:learn-netty4

本文已收录于 www.flydean.com/22-netty-co…

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧等你来发现!

欢迎关注我的公众号:「程序那些事」,懂技术,更懂你!

本文转载自: 掘金

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

Unity3D打砖块游戏入门教程

发表于 2021-09-17

今天来实现一个3D打砖块游戏,非常的简单,是正经的入门教程,但是学到的都是Unity游戏开发中重点的内容,下面一起来了解下吧。

新建3D游戏项目

打开Unity Hub软件,我们新建一个3D项目,新建好之后双击打开就好了。

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d28d1f1bdeb047d09e56ae8307bc7758~tplv-k3u1fbpfcp-zoom-1.image

初始化项目目录

在Assets目录下新建以下文件夹:

  • Scenes:存放游戏场景,默认新建项目就有,没有则新建
  • Materals:存放材质,比如一辆车有不同的颜色和款式
  • Prefab:存放预制件,打砖块中砖块和子弹会出现很多,我们可以为其设定预制件
  • Scripts:存放游戏脚本文件,脚本里有游戏的运行逻辑。

创建地面

新建一个叫 Plane 的3D对象,并在Assets/Materals新建一个同名的材质,为我们的地面添加样式,地面太小可以在右侧属性面板中找到Transform组,修改缩放值,X为20,Z为20。
file

创建砖块墙

新建一个 Cube 的3D对象,然后将其设置为一个Prefab,并且添加个物理引擎“刚体”的组件(让其有物理效果,自由落体等)。

复制多个 Cube 的Prefab,使其变为一堵墙,编一个组为 Cubes 。

file

创建子弹

同样,在 prefab 下新建个 Bullet的预置件,并为其设置组件》物理》刚体,让其也有物理效果。

file

我们需求是这样的,在按下鼠标左键时会创建一颗子弹,并执行其动画(移动和砖块撞击),并且是在摄像机的正面方向射出。

为了实现上面需求,新建一个脚本文件 Shoot.cs ,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
csharp复制代码using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Shoot : MonoBehaviour
{

public GameObject bullet;
public float speed = 10;

void Update()
{
//按下鼠标左键
if (Input.GetMouseButtonDown(0))
{
GameObject b = GameObject.Instantiate(bullet, transform.position, transform.rotation);
Rigidbody rgd = b.GetComponent<Rigidbody>();
rgd.velocity = transform.forward * speed;
}
}
}

代码解析:

  • 创建了两个公共属性, bullet是子弹,speed是子弹发射的速度
  • update是帧更新的生命周期,它在游戏运行后每秒后执行60次左右
  • 在 update 方法中,我们监听了鼠标左键,然后获取了子弹的本体,获取它的刚体, 设置它的刚体速度,它的方向是正前方 transform.forward。

现在就可以实现子弹的发射了,不过现在子弹只能在一个方向上发射,下面我们就来解决这个问题。

file

控制摄像机

新建个 MoveCamera.cs 脚本,用来控制摄像机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
csharp复制代码using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MoveCamera : MonoBehaviour
{
public float speed = 10;

void Update()
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");

transform.Translate(new Vector3(h, v, 0) * Time.deltaTime * speed);
}
}

代码解析:

  • 同样,我们 有个 speed 属性,用来控制镜头移动速度
  • 在 update方法中,我们监听了上下左右的按键,然后为摄像机设置位移,改变其镜头方向,从而达到改变小球发射方向。

file

到这里我们的Unity入门教程就结束了,不过,我们要想学好游戏,得举一反三,扩展它的功能。

扩展打砖块功能

  • AI自动生成不同位置,不同大小的墙。(现在是直接写死的)
  • 添加多种样式的子弹,可以是不同颜色,或者不同形状。
  • 添加子弹发射音效,墙壁碰撞音效。
  • 添加游戏分数。
  • 添加多个场景,不同难度的墙。
  • 添加商店,可以购买各种子弹。

这里就写这么多了,扩展功能后期有时间再实现,敬请关注!

总结

这个打砖块小游戏,用到了Unity游戏开发中的一些常用知识点,如生命周期的使用,监听用户输入,获取物体的世界坐标以及改变坐标等等,下面这几个API要重点了解下(很常用):

  • Vector3
  • Input
  • GameObject

扩展阅读

如果你还不知道Unity是什么,请先阅读:

  • Unity游戏引擎中的脚本
  • unity2021游戏引擎安装激活并汉化
  • Unity介绍

完!

我是极客猿小兵,公众号【极客猿】,记录独立开发者学习成长(游戏开发/产品开发/逆向/运营设计)。

本文转载自: 掘金

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

❤️Java17 来了,YYDS!重磅!Oracle 宣布

发表于 2021-09-17

不过,苹果 13 确实不那么 13 香,库克一如既往在挤牙膏式的更新。

对比之下,我觉得还是 JDK 17 比较香,除了新增了不少新特性,Oracle 官方竟然宣布 JDK 17 可以免费商用了!

从官方的声明中可以看得出:Oracle JDK 17 和未来的 JDK 版本是在免费使用许可下提供的,直到下一个 LTS 版本发布整整一年。

LTS 是什么意思呢?就是 Long-Term-Support,长期支持版本,不同于 16、15、14、13、12 这些过渡版本。

生产环境下,最常用的三个版本,就是 JDK 6、JDK 8、JDK 11,JDK 17 会不会是下一个呢?

上面这张图是 Oracle 官方给出的 Oracle JDK 支持的时间线。可以看得到,JDK 17 最多可以支持到 2029 年 9 月份,长达 8 年!

按照技术更新迭代的速度,8 年时间,真不短了!

以 Oracle 的尿性来看,这次免费商用 8 年可谓是良苦用心,为的就是让使用者放心大胆地将 JDK 升级到 JDK 17。

不过,好像 JDK 8 支持的时间更长,可以延长到 2030 年 12 月。似乎我又发现了什么真理:他强任他强,我用 Java 8 !

推荐一下二哥在 GitHub 上开源的《Java 程序员进阶之路》专栏吧!风趣幽默、通俗易懂,对 Java 初学者极度友好和舒适😘,内容包括但不限于 Java 语法、Java 集合框架、Java IO、Java 并发编程、Java 虚拟机等核心知识点。

GitHub 地址:github.com/itwanger/to…

JDK 17 之前,LTS 版本都是 3 年发布一次,11 是 2018 年,8 是 2014 年,7 是 2011 年。

之后呢,Oracle 计划每两年发布一次未来的 LTS 版本,也就是说,下一个 LTS 版本,也就是 JDK 21 将于 2023 年 9 月份发布。

技术更新迭代的速度又快了呀!

这里强调一点哈,非长期支持版本一定不要用于生产环境,不过拿来作为学习的对象还是可以的。

JDK 17 提供了 14 个 JEP(JDK Enhancement Proposal,JDK 增强建议),也就是 14 个新特性,我们来一睹为快!

特性 说明
306:Restore Always-Strict Floating-Point Semantics 恢复始终执行严格模式的浮点定义
356:Enhanced Pseudo-Random Number Generators 增强型伪随机数生成器
382:New macOS Rendering Pipeline 新的 macOS 渲染管道
391:macOS/AArch64 Port macOS AArch64 端口
398:Deprecate the Applet API for Removal 弃用 Applet API
403:Strongly Encapsulate JDK Internals JDK 内部强封装
406:Pattern Matching for switch (Preview) 为 switch 支持模式匹配
407:Remove RMI Activation 移除 RMI 激活
409:Sealed Classes 密封类
410:Remove the Experimental AOT and JIT Compiler 移除实验性的 AOT 和 JIT 编译器
411:Deprecate the Security Manager for Removal 弃用安全管理器
412:Foreign Function & Memory API (Incubator) 外部函数和内存 API(孵化中)
414:Vector API (Second Incubator) 矢量 API(二次孵化中)
415:Context-Specific Deserialization Filters 上下文特定反序列化过滤器

Java 语言增强

JEP 409:密封类,密封的类和接口,可以限制其他类或接口扩展或实现它们。

1
2
3
java复制代码public abstract sealed class Shape permits Circle{

}

类 Shape 被关键字 sealed 修饰,表明它是一个密封类。这个密封类必须要指定它被哪些类继承,比如说 Circle:

1
2
java复制代码public final class Circle extends Shape {
}

Circle 类必须用 final 关键字修饰,表明它不能再被其他类继承了。

这个密封类就有意思了,我只允许谁谁谁继承,就有点指定继承权的内味了。

库的更新和改进

JEP 306:恢复始终执行严格模式的浮点定义。Java 最初只有严格的浮点语义,但从 JDK 1.2 开始,为了适应当时硬件架构的限制,默认情况下允许这些严格语义中的细微变化,而现在这些都没有必要了。

JEP 356:增强型伪随机数生成器。为伪随机数生成器 (PRNG) 提供新的接口类型和实现。

JEP 382:新的 macOS 渲染管道。 使用 Apple Metal API 为 macOS 实现了 Java 2D 渲染管道。新管道减少了 JDK 对已弃用的 Apple OpenGL API 的依赖。

新平台支持

JEP 391:macOS AArch64 端口。该端口允许将 Java 应用程序在新的基于 Arm 64 的 Apple Silicon 计算机上运行。

删除和弃用

JEP 398:弃用 Applet API。Applet 是一种运行在 Web 浏览器内的 Java 程序,早就过时了,删除很有必要。

JEP 407:删除了远程方法调用 (RMI) 激活机制。

JEP 410:删除实验性的 AOT 和 JIT 编译器,这两个实验功能并没有被广泛使用,删了省得维护。

JEP 411:弃用安全管理器。安全管理器可追溯到 Java 1.0,但多年来并没有起到很好的保护作用,删除了省心。

面向未来的 Java 程序

JEP 403:JDK 内部强封装,限制外部对 JDK 内部类进行访问,此更改会使应用程序更安全,并减少对非标准、内部 JDK 实现细节的依赖。

后续 JDK 版本的预览和孵化器

JEP 406 : 为 switch 支持模式匹配。

我们希望将一个变量 o 与多个备选方案进行比较,但之前的 switch 不支持使用 instanceof 的模式匹配,于是我们只能用 if-else 来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码static String formatter(Object o) {
String formatted = "unknown";
if (o instanceof Integer i) {
formatted = String.format("int %d", i);
} else if (o instanceof Long l) {
formatted = String.format("long %d", l);
} else if (o instanceof Double d) {
formatted = String.format("double %f", d);
} else if (o instanceof String s) {
formatted = String.format("String %s", s);
}
return formatted;
}

JDK 17 在模式匹配的基础上提供了 switch 语句的支持:

1
2
3
4
5
6
7
8
9
java复制代码static String formatterPatternSwitch(Object o) {
return switch (o) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
case String s -> String.format("String %s", s);
default -> o.toString();
};
}

这样写是不是就瞬间高大上了许多,舒服。

JEP 412:外部函数和内存 API(孵化器)。通过有效调用外部函数(JVM 外部的代码),并通过安全访问外部内存,这使得 Java 程序能够调用本机库并处理本机数据,而没有 Java 本机接口 (JNI) 的脆弱性和复杂性。

JEP 414:矢量 API(第二孵化器)。Vector API 由JEP 338 提出并作为孵化 API 集成到 Java 16 中。

Vector API 旨在通过提供一种在 Java 中编写复杂矢量算法的方法来提高矢量化计算的可预测和健壮性。许多领域都可以从这个显式向量 API 中受益,包括机器学习、线性代数、密码学、金融和 JDK 本身的代码。

官方链接:www.oracle.com/news/announ…


讲良心话,JDK 更新的频率是比以前更快了,但开发者的习惯仍然停留在 JDK 8 甚至 JDK 6 的层面上。

主动升级到 JDK 11 的并不多,尤其是 Oracle 搞出商业收费后,大家升级的意愿就更淡了。

不知道是不是出于开源或者叫免费的压力,JDK 17 宣布可以免费商用了,并且打算以后的版本也保持这样。

这对使用者来说,无疑是一罐蜜糖,长达 8 年的时间支持,也许大家会愿意升级到 JDK 17 了!

大家觉得呢?

我是二哥呀,没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟。

本文转载自: 掘金

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

1…526527528…956

开发者博客

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