ProGuard 进阶系列(二)配置解析

书接上文,从开源库中把代码下载到本地后,就可以在 IDE 中进行运行了。从 main 方法入手,可以看到 ProGuard 执行的第一步就是去解析参数。本文的内容主要分析源码中我们配置的规则解析的实现。

在上一篇文章末尾,在 IDE 中,添加了 @/Users/xxx/debug_proguard.pro 作为函数运行的入参,将配置文件的路径传递给 ProGuard 使用。先来看一下 Main 函数中的代码:

13b5f2e1a15b545c00663a5769dd61c2.png
ProGuard 的 Main 函数代码

从这几行代码可以看出,ProGuard 的大体运行逻辑。在代码 518 行中,通过入参 args系统属性配置 创建了一个配置解析器 ConfigurationParser ,随后调用其 parse 方法,解析传入的参数,并将结果放到 configuration 中,以供后续混淆逻辑使用。

try-with-resources 语法

在代码 518 行处,创建 ConfigurationParser 时,使用了 Java 1.7 中提供的 try-with-resources 语法。此语法可以帮助我们关闭流。举个例子,我们现在需要从一个文件中读取第一行内容。在 Java 1.7 之前,代码将会如下:

static String readFirstLineFromFile(String path) throws IOException {
    FileReader fr = new FileReader(path);
    BufferedReader br = new BufferedReader(fr);
    try {
        return br.readLine();
    } finally {
        br.close();
        fr.close();
    }
}

从代码中可以看到,在 finally 代码块中,需要手动对 FileReaderBufferedReader 进行关闭。而使用 try-with-resources 语法后,就无需手动调用 close 方法。示例代码如下:

static String readFirstLineFromFile(String path) throws IOException {
    try (FileReader fr = new FileReader(path); BufferedReader br = new BufferedReader(fr)) {
        return br.readLine();
    }
}

这样,代码能精简很多,close 也不会因为开发者的疏忽而被遗漏。

配置读取

为了更好地理解整个读取与解析的内容,我画了一个简单的流程图。在 ProGuard 中读取配置文件的逻辑中,会按照一个个单词 为单位进行读取,根据代码中的流程,绘制如下流程图,能够更好地理解代码内容。

c78ea029a100fcceedb8ee191fada3c0.png
解析流程图

根据上面的流程图,在来看源码实现。首先是 ConfigurationParser 的构造方法,实现代码如下:

public ConfigurationParser(String[] args, Properties properties) throws IOException {
  this(args, null, properties);
}

public ConfigurationParser(String[] args, File baseDir, Properties properties) throws IOException {
 this(new ArgumentWordReader(args, baseDir), properties);
}

public ConfigurationParser(WordReader reader, Properties properties) throws IOException{
  this.reader     = reader;
  this.properties = properties;
 readNextWord();
}

在构造方法中,使用入参中的 args系统属性配置 创建了一个 ArgumentWordReader。顾名思义,它是用来读取运行代码时传入的程序参数的。

9f55d679b05bf9c83772c1b190508b3d.png
WordReader 类图

WordReader 的设计中,内容读取是按行读取的。在 LineWordReaderFileWordReader 中,直接使用 LineNumberReader 按行读取。而对于 ArgumentWordReader,实现逻辑会更简单一些,直接将前面提到的 args 数组中的每一个 String 作为一行字符串处理。

接下来,在看构造方法的最末尾:调用了 readNextWord() 方法,此为流程中开始读取下一个单词,也是为了获取第一个 「单词」。来看一下代码是如何实现的:

private void readNextWord() throws IOException {
  readNextWord(false, false);
}

private void readNextWord(boolean isFileName, boolean expectSingleFile) throws IOException {
  nextWord = reader.nextWord(isFileName, expectSingleFile);
}

代码的逻辑里最终调用了 reader.nextWord,此处的 reader 就是刚才提到的 ArgumentWordReader。运行时会使用它去读取第一个「单词」。讲到这里,不由得让我想起了大学时编译原理中讲的 词法分析器。有感兴趣的同学可以去巩固一下《编译原理》。因为 ProGuard 定义的规则相对简单,所以此处的逻辑比一门编程语言简单许多。在运行代码时,只传了一个参数:@/Users/xxx/debug_proguard.pro。在解析时,它会作为一行直接进行处理。先来看一下代码:

public String nextWord(boolean isFileName, boolean expectSingleFile) throws IOException {
  currentWord = null;
  // 省略部分代码
  while (currentLine == null || currentIndex == currentLineLength) {
    // 读取有效的参数行
    currentLine = nextLine();
  }

  // Find the word starting at the current index.
  int startIndex = currentIndex;
  int endIndex;

  // 取第一个字符
  char startChar = currentLine.charAt(startIndex);

 // 省略部分代码
  
  else if (isDelimiter(startChar)) {
  // 如果是分格符,如 @, {, }, (, )等符号
    endIndex = ++currentIndex;
  }
  else {
    // 其它情况处理逻辑
  }

  // 截取,此处的 currentWord 就是解析出来的 @ 符号
  currentWord = currentLine.substring(startIndex, endIndex);
 
  return currentWord;
}


// 是否为分隔符,如果是,则返回 true
private boolean isDelimiter(char character) {
  return isStartDelimiter(character) || isNonStartDelimiter(character);
}
private boolean isStartDelimiter(char character) {
  return character == '@';
}
private boolean isNonStartDelimiter(char character) {
  return character == '{' ||
          character == '}' ||
          character == '(' ||
          character == ')' ||
          character == ',' ||
          character == ';' ||
          character == File.pathSeparatorChar;
}

在读取的过程中,首先将整行数据存储在 currentLine 中,当前此处为 @/Users/xxx/debug_proguard.pro,紧接着会从 currentLine 中取 第一个 字符,因为 @  是分隔符,因此会将它作为第一个 「单词」。代码执行到这里,构造方法里面涉及的逻辑也执行结束,ConfigurationParser 创建完成。下一步就是调用 parse 方法,去执行解析操作,代码如下:

public void parse(Configuration configuration) throws ParseException, IOException {
  while (nextWord != null) {
    // 是 @ 或者是 -include 执行
   if (ConfigurationConstants.AT_DIRECTIVE.startsWith(nextWord) || ConfigurationConstants.INCLUDE_DIRECTIVE.startsWith(nextWord))
     configuration.lastModified = parseIncludeArgument(configuration.lastModified);
    
    // 省略其它代码
  }
}

parse 方法中,会循环遍历所有的 「单词」,直到所有单词都处理完毕。现在只需要看 @ 的处理逻辑,在代码中,如果当前 「单词」为 @-include 时,会调用 parseIncludeArgument 去实现解析的逻辑。  @ 符号的定义是 以递归的方式从给定的文件中读取配置选项 , 从它的定义就可以看出来, parseIncludeArgument 会去解析 @ 后指的文件名称,并读取文件内容。

private long parseIncludeArgument(long lastModified) throws ParseException, IOException{
  // 读取 @ 后面跟着的文件名
  readNextWord("configuration file name", true, true, false);
  URL url = null;
  try {
    // Check if the file name is a valid URL.
    url = new URL(nextWord);
  } catch (MalformedURLException ex) {
  }
  if (url != null) {
    // 给当前 reader 设置一个 includeWordReader
    reader.includeWordReader(new FileWordReader(url));
  }
 // 省略部分代码
  readNextWord();
  return lastModified;
}

代码中可以看到,在执行时,首先会调用 readNextWord 去获取文件名。与前面 @ 获取类似,从 currentLine 中读取出剩下的部分,作为文件名称。获取到文件名称后,就会直接使用这个名称去创建一个 FileWordReader,用于读取此文件中的内容。当然,这里创建的 FileWordReader 还需要赋值给 ArgumentWordReader 的成员变量 includeWordReader。调用 ArgumentWordReadernextWord 方法时,会先调用 includeWordReader.nextWord(xx, xx) 方法,以此来实现递归读取配置文件,实现 @ 符号所定义的功能,如前面的流程图所示。调用 includeWordReader 去获取下一个「单词」的逻辑如下:

if (includeWordReader != null) {
  // 读取下一个字符
  currentWord = includeWordReader.nextWord(isFileName, expectSingleFile);
  if (currentWord != null) {
   return currentWord;
  }
  // 读取完成后,将 reader 关掉,并且置空
  includeWordReader.close();
  includeWordReader = null;
}

此处的 FileWordReaderArgumentWordReader 的核心逻辑基本相似。在 FileWordReader 中,nextLine 方法从指定的文件中读取真实的数据行,而文件行读取使用的是 JDK 中的 LineNumberReader。逻辑不复杂,有兴趣的朋友可自行查阅原文。

配置解析

从文件中读取到配置信息后,需要解析当前的「单词」,并按照固定的逻辑进行处理。在前面的内容中,已经涉及到了配置解析。在 ProGuard 中,有许多配置和不同的规则,可以通过查看源代码来了解。在 ProGuard 中,配置规则分为多个类别。下面将从 输入/输出选项-keep 选项 这两个部分进行分析,以点带面,了解 ProGuard 中配置解析的逻辑。

输入/输出选项

先来看 -injars-outjars-libraryjars 的实现,当读取到这几个单词时,解析的执行如下:

2f69b4961f1910ac195bef3e42046dcd.png
jars 相关解析

可以看到,这三个参数的解析,都是调用的 parseClassPathArgument  来实现的,且 -injars-outjars 都是放在 configuration.programJars 中的。 以 Android 的项目为例,编译结束时,会生成 R.jar 文件,以及一个 classes 文件夹,因此 -injars 的配置如下:

-injars /project_dir/build/intermediates/compile_r_class_jar/release/R.jar
-injars /project_dir/build/intermediates/javac/release/classes

因此,在解析方法中,按文件路径读取下一个「单词」,然后添加到对应的 classpath 中即可。在源代码中,还会存在文件分割符等逻辑,直接上代码:

private ClassPath parseClassPathArgument(ClassPath classPath, boolean isOutput, boolean allowFeatureName) {
    // 读取第一个文件路径
    readNextWord("jar or directory name", true, false, false);
    while (true) {
        // 创建一个 ClassPathEntry
        ClassPathEntry entry = new ClassPathEntry(file(nextWord), isOutput, featureName);
        // 读取下一个单词,可能是文件分隔符,在 mac os 中为 :
        readNextWord();

        // …… 省略读取 filter 的代码  ……

        // 将 ClassPathEntry 添加到 classpath 中
        classPath.add(entry);
        // 是否已经读取完成了? 如果只有一个文件名,如示例中的,就直接结束了。
        if (configurationEnd()) {
            return classPath;
        }
        // 如果不为 路径分隔符 ,直接抛异常
        if (!nextWord.equals(System.getProperty("path.separator"))) {
            throw new ParseException("Expecting class path separator '" + ConfigurationConstants.JAR_SEPARATOR_KEYWORD + "' before " + reader.locationDescription());
        }
        // 读取下一个文件路径
        readNextWord("jar or directory name", true, false, false);
    }
}

-keep 选项

在写 ProGuard 规则中, keep 的规则是相对比较复杂的,根据个人的理解,将 keep 的解析规则用 EBNF 进行描述,如下所示,能够更好的理解其逻辑。

ffcafbcc6e7f93fcf004370379c8bbf4.png
ProGuard EBNF 描述

解析思路与 输入/输出选项 类似,先根据当前的单词判断是否为 keep_keywords ,代码如下:

ce502b021b2d361a221d1dc7e1738180.png
keep 解析分支代码

从代码中可以看到,所有 keep_keywords 的解析都调用到了 parseKeepClassSpecificationArguments 中,些方法的解析逻辑,与 EBNF 中描述的基本一致,先看代码执行的流程图:

8f40b4202f221fc1d6091f7aa9d3f2c0.png
解析 Keep 后的描述符

代码中实现逻辑与上述流程图一致, 源码如下:

while (true) {
    // 1. 读取 -keep 后的单词,
    // 例如配置规则为: -keep class com.example.MainClass 
    // 则此时读取的单词为 class
    readNextWord("keyword ...", false, false, true);

    // 2. 判断读了的单词是否为 「,」 号,如果是,后面会跟其它命令,
    // 例如配置规则为:
    // -keep, allowobfuscation class Test
    // 此时 nextWord 的值就为 「,」
    if (!ConfigurationConstants.ARGUMENT_SEPARATOR_KEYWORD.equals(nextWord)) {
       // 如果不为 「,」 则直接退出循环
        break;
    }

    // 3. 读取后面的 allowshrinking / allowoptimization / allowobfuscation 等
    readNextWord("keyword '" + ConfigurationConstants.ALLOW_SHRINKING_SUBOPTION + "'");

    // 4. 标记参数
    if (ConfigurationConstants.ALLOW_SHRINKING_SUBOPTION.startsWith(nextWord)) {
        allowShrinking = true;
    } else if (ConfigurationConstants.ALLOW_OPTIMIZATION_SUBOPTION.startsWith(nextWord)) {
        allowOptimization = true;
    } else if (ConfigurationConstants.ALLOW_OBFUSCATION_SUBOPTION.startsWith(nextWord)) {
        allowObfuscation = true;
    } else {
        throw new ParseException("Expecting keyword ...");
    }
}

// 5. 解析配置规则后的 class_specification
ClassSpecification classSpecification = parseClassSpecificationArguments(false, true, false);

有前面的 EBNF 描述以及流程图,代码逻辑看起来就会非常的简单。紧接着是解析 class_specification ,先来看一下它的 EBNF 描述,如下图所示:

56d4b3747e3aeea56b81e28db4094175.png
class EBNF 描述

PS: 在 ProGuard 的使用文档中,也有描述 class_specification 的信息,但是并非是 EBNF 格式,有兴趣的同学可以看看: https://www.guardsquare.com/manual/configuration/usage#classspecification

根据 EBNF 的描述,就可以按照其描述规则进行解析。但上面的描述中,还有 annotation_nameclass_namemethod_namereturn_typeargument_typefield_type 等标识符的描述并没有写出来。这里,写需要对他们进行简单的梳理。因为这个名称都是用来描述 Java 中相应的 类名方法名变量名 的信息,所以:

  1. 这些名称一定是符合 java 标识符的规则,即它们由数字(0~9)、字母(a~z 和 A~Z)以及 $ _ 组成,且第一个符号只能是字母、 $_ 中的一个。

  2. annotation_nameclass_namefield_type 等实际描述的是 Java 类名时,使用的是全路径信息,其中包含包名路径,因此名称会出现 . 这个符号 , 例如: com.example.Testjava.lang.Object

  3. 在描述方法返回值(retrun_type)、方法参数(argument_type)或变量类型(field_type) 时,可能会有数组存在,所以 [] 也可能会出现,例如: public java.lang.Object[] getList();

  4. 在 ProGuard 规则中,名称还能使用通配符,其中包括 *.<n>%

基于这些规则,先来看一下代码实现:

private void checkJavaIdentifier(String expectedDescription, boolean allowGenerics) throws ParseException {
    if (!isJavaIdentifier(nextWord)) {
        throw new ParseException("Expecting ...");
    }

    if (!allowGenerics && containsGenerics(nextWord)) {
        throw new ParseException("Generics are not allowed (erased) in ..."));
    }
}

public boolean isJavaIdentifier(String word) {
    if (word.length() == 0) {
        return false;
    }
    for (int index = 0; index < word.length(); index++) {
        char c = word.charAt(index);
        if (!(Character.isJavaIdentifierPart(c) || c == '.' || 
        c == '[' || c == ']' || c == '<' || c == '>' || c == '-' || 
        c == '!' || c == '*' || c == '?' || c == '%')) {
            return false;
        }
    }

    return true;
}

看完标识符的匹配规则,在来看完整定义的 annotation_nameclass_name 等名称的读取逻辑,在代码中,都会调用到 parseCommaSeparatedList 里面去,顾名思义,此方法会根据 , 解析一个列表出来,直接看代码:

6ea493c497d7e00ba9cf8856b6ef352c.png
annotation type 读取

代码中仅保留了关键代码,从注释中可以看到,拿到「单词」后,会先检查是否为一个合法标识符,如果符合,就添加到列表中去,并读取下一个「单词」,如果是 , 会继续上述逻辑进行添加,反之返回列表。

在回到上层的解析逻辑中来,根据 EBNF 的描述, 需要先判断是否有 annotation_type  和  access_flag, 这一块的逻辑如下:

09f7b97dea6b90d6e78e6ec20310539c.png
annotation type 解析

其中当解析到当前单词为 @ 时,会去解析 annotation_name 的列表, 并且重新用 , 拼接成一个字符串存储起来。 剩下的 access_flag 就简单多了,直接使用一个 int 型的值,按位将存起来就可以了,当然,我们还需要注意 ! ,因此,在存储的时候,会有两个变量:

if (!negated) {
  requiredSetClassAccessFlags |= accessFlag;
} else {
  requiredUnsetClassAccessFlags |= accessFlag;
}

后面的 class_nameextends  等逻辑读取就比较简单了,如下:

7865e1d657a8d43af2220f31c093ab5c.png
解析 ClassName 以及 extends 父类

当父类相关信息解析完成后,下一步便是解析方法和变量相关的信息,也就是 EBNF 中描述 field_specificationmethod_specification 的内容。在 ProGuard 中,这两部份类容统一放到了 parseMemberSpecificationArguments 中去实现了,先来看一下代码逻辑:

a2ddca6ec563fbe1a7e9ac7ddef863e8.png
类成员解析入口

剩下解析类成员的逻辑与前面类解析的逻辑相似,按照 EBNF 格式进行解析即可, 感兴趣的同学可以自行阅读源码。

结语

ProGuard 配置文件解析是非常重要的一部分内容,在 ProGuard 后续的执行逻辑中,会经常使用到本文中解析出来配置信息,可从官方文档详细了解一下各配置选项的作用级使用方法,以便能更好的理解后面的内容。在解析配置文件中,提到的 EBNF 是描述计算机编程语言的上下文无关文法的符号表示法,在编程语言开发中可能会经常遇到,此语法不复杂,可以去百科上读一读,相信你会有很多的收获。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/29857.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

大数据Doris(三十七):Spark Load导入HDFS数据

文章目录 Spark Load导入HDFS数据 一、准备HDFS数据 二、创建Doris表 三、创建Spark Load导入任务

【Reids】搭建主从集群以及主从数据同步原理

目录 一、搭建主从集群 1、介绍 2、搭建 二、数据同步原理 1、全量同步 2、主节点如何判断是不是第一次连接 3、增量同步 4、优化主从数据同步 一、搭建主从集群 1、介绍 单节点的Redis并发能力存在上限&#xff0c;要提高并发能力就需要搭建主从集群&#xff0c;实现…

【LLM GPT】李宏毅大型语言模型课程

目录 1 概述1.1 发展历程1.2 预训练监督学习预训练的好处 1.3 增强式学习1.4 对训练数据的记忆1.5 更新参数1.6 AI内容检测1.7 保护隐私1.8 gpt和bert穷人怎么用gpt 2 生成式模型2.1 生成方式2.1.1 各个击破 Autoregressive2.1.2 一次到位 Non-autoregressive2.1.3 两者结合 2.…

RabbitMQ虚拟主机无法启动的原因和解决方案

RabbitMQ虚拟主机无法启动的原因和解决方案 摘要&#xff1a; RabbitMQ是一个广泛使用的开源消息代理系统&#xff0c;但在使用过程中可能会遇到虚拟主机无法启动的问题。本文将探讨可能导致该问题的原因&#xff0c;并提供相应的解决方案&#xff0c;以帮助读者解决RabbitMQ虚…

第五章 模型篇: 模型保存与加载

参考教程&#xff1a; https://pytorch.org/tutorials/beginner/basics/saveloadrun_tutorial.html 文章目录 pytorch中的保存与加载torch.save()torch.load()代码示例 模型的保存与加载保存 state_dict()nn.Module().load_state_dict()加载模型参数保存模型本身加载模型本身 c…

K8s 中 port, targetPort, NodePort的区别

看1个例子&#xff1a; 我们用下面命令去创建1个pod2&#xff0c; 里面运行的是1个nginx kubectl create deployment pod2 --imagenginx当这个POD被创建后&#xff0c; 其实并不能被外部访问&#xff0c; 因为端口映射并没有完成. 我们用下面这个命令去创建1个svc &#xff…

chatgpt赋能python:Python怎样让画笔变粗

Python怎样让画笔变粗 Python是一门强大的编程语言&#xff0c;不仅适用于数据分析和机器学习等领域&#xff0c;也可以用来进行图像处理。在Python中&#xff0c;我们可以使用Pillow库来进行图像操作。在本篇文章中&#xff0c;我们将介绍如何使用Python和Pillow来让画笔变粗…

vue2_markdown的内容目录生成

文章目录 ⭐前言⭐引入vue-markdown&#x1f496; 全局配置&#x1f496; 渲染选项&#x1f496; 取出markdown的标题层级 ⭐结束 ⭐前言 大家好&#xff01;我是yma16&#xff0c;本文分享在vue2的markdown文本内容渲染和目录生成 背景&#xff1a; 优化个人博客功能&#xf…

delphi的ARM架构支持与System.Win.WinRT库

delphi的ARM架构支持与System.Win.WinRT库 目录 delphi的ARM架构支持与System.Win.WinRT库 一、WinRT 二、delphi的System.Win.WinRT库 2.1、支持ARM芯片指令 2.2、基于WinRT技术的特点 2.3、所以使用默认库而未经转化的服务端应用并不支持ARM架构服务器 2.4、对默认库…

【Linux】初步认识Linux系统

Linux 操作系统 主要作用是管理好硬件设备&#xff0c;并为用户和应用程序提供一个简单的接口&#xff0c;以便于使用。 作为中间人&#xff0c;连接硬件和软件 常见操作系统 桌面操作系统 WindowsmacOsLinux 服务器操作系统 LinuxWindows Server 嵌入式操作系统 Linux …

深度学习图像分类、目标检测、图像分割源码小项目

​demo仓库和视频演示&#xff1a; 银色子弹zg的个人空间-银色子弹zg个人主页-哔哩哔哩视频 卷积网路CNN分类的模型一般使用包括alexnet、DenseNet、DLA、GoogleNet、Mobilenet、ResNet、ResNeXt、ShuffleNet、VGG、EfficientNet和Swin transformer等10多种模型 目标检测包括…

Java关键词synchronized

目录 一、通过卖票系统观察多线程的安全隐患 二、synchronized的基本知识 1.使用synchronized的原因 2.synchronized的作用 3.synchronized的基本格式 a.synchronized加在方法名前 b.synchronized用在方法中 4. Java锁机制 5.synchronized注意事项 三、使用synchronize…

Java Logback日志框架概述及logback.xml详解

日志技术具备的优势 可以将系统执行的信息选择性的记录到指定的位置&#xff08;控制台、文件中、数据库中)。 可以随时以开关的形式控制是否记录日志&#xff0c;无需修改源代码。 日志体系结构 Logback日志框架 Logback是由log4j创始人设计的另一个开源日志组件&#xff0…

MATLAB读取OpenFOAM的二进制文件

OpenFOAM的文件格式 上面是OpenFOAM二进制文件的格式&#xff0c;我们可以看出&#xff0c;前面21行都是无关的说明文件&#xff0c;22开始时除了一个括号之外&#xff0c;其它的都是数据。 读取数据 读取数据的思路非常简单&#xff0c;忽略不需要的&#xff0c;读取需要的。…

Autoware 跑 Demo(踩坑指南)

Autoware 跑 Demo&#xff08;踩坑指南&#xff09; 网上的博客和官方的教程&#xff0c;几乎都是一样的&#xff0c;但实际上跑不起来 Autoware 1.12学习整理–01–运行rosbag示例 Autoware入门学习&#xff08;三&#xff09;——Autoware软件功能使用介绍&#xff08;1/3&a…

【Unity3D】激光雷达特效

1 由深度纹理重构世界坐标 屏幕深度和法线纹理简介中对深度和法线纹理的来源、使用及推导过程进行了讲解&#xff0c;本文将介绍使用深度纹理重构世界坐标的方法&#xff0c;并使用重构后的世界坐标模拟激光雷达特效。 本文完整资源见→Unity3D激光雷达特效。 1&#xff09;重构…

基于51单片机的智能火灾报警系统温度烟雾光

wx供重浩&#xff1a;创享日记 对话框发送&#xff1a;火灾报警 获取完整源码源文件电路图仿真文件论文报告等 功能简介 51单片机MQ-2烟雾传感ADC0832模数转换芯片DS18B20温度传感器数码管显示按键模块声光报警模块 具体功能&#xff1a; 1、实时监测及显示温度值和烟雾浓度…

管理类联考——英语二——技巧篇——写作——B节——议论文——必备替换句型

议论文必备替换句型 (一&#xff09;表示很明显/众所周知的句型 It is obvious thatIt is clear thatIt is apparent thatIt is evident thatlt is self-evident thatIt is manifest thatIt is well-knownIt is known to all thatIt is widely-accepted thatIt is crystal-cl…

蓝牙客户端QBluetoothSocket的使用——Qt For Android

了解蓝牙 经典蓝牙和低功耗蓝牙差异 经典蓝牙&#xff08;Bluetooth Classic&#xff09;&#xff1a;分为基本速率/增强数据速率(BR/EDR)&#xff0c; 79个信道&#xff0c;在2.4GHz的(ISM)频段。支持点对点设备通信&#xff0c;主要用于实现无线音频流传输&#xff0c;已成…

Ceph:关于Ceph 集群管理的一些笔记

写在前面 准备考试&#xff0c;整理ceph 相关笔记博文内容涉及&#xff0c;Ceph 管理工具 cephadm&#xff0c;ceph 编排器&#xff0c;Ceph CLI 和 Dashboard GUI 介绍理解不足小伙伴帮忙指正 对每个人而言&#xff0c;真正的职责只有一个&#xff1a;找到自我。然后在心中坚守…