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

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


  • 首页

  • 归档

  • 搜索

深度探索 Gradle 自动化构建技术(一、Gradle 核

发表于 2020-04-13

前言

成为一名优秀的Android开发,需要一份完备的 知识体系,在这里,让我们一起成长为自己所想的那样~。

一、重识 Gradle

工程构建工具从古老的 mk、make、cmake、qmake, 再到成熟的 ant、maven、ivy,最后到如今互联网时代的 sbt、gradle,经历了长久的历史演化与变迁。

Gradle 作为一款新生代的构建工具无疑是有它自身的巨大优势的,因此,掌握好 Gradle 构建工具的各种使用姿势与使用场景其重要性不言而喻。

此外,Gradle 已经成为 高级 Android 知识体系 必不可少的一部分。因此,掌握 Gradle,提升自身 自动化构建技术的深度, 能让我们更加地 如虎添翼。

1、Gradle 是什么?

  • 1)、它是一款强大的构建工具,而不是语⾔。
  • 2)、它使用了 Groovy 这个语言,创造了一种 DSL,但它本身不是语⾔。

2、为什么使用 Gradle?

主要基于如下 三点 原因:

  • 1)、它是一款最新的,功能最强大的构建工具,使用它我们能做很多事情。
  • 2)、使用程序替代传统的 XML 配置,使得项目构建更加灵活。
  • 3)、丰富的第三方插件,可以让我们随心所欲地使用。

3、Gradle 的构建流程

通常来说,Gradle 一次完整的构建过程通常分成如下 三个部分:

  • 初始化阶段:首先,在初始化阶段 Gradle 会决定哪些项目模块要参与构建,并且为每个项目模块创建一个与之对应的 Project 实例。
  • 配置阶段:然后,配置工程中每个项目的模块,并执行包含其中的配置脚本。
  • 任务执行:最后,执行每个参与构建过程的 Gradle task。

二、打包提速

掌握 Gradle 构建提速的技巧能够帮助我们节省大量的编译构建时间,并且,依赖模块越多且越大的项目节省出来的时间越多,因此是一件投入产出比相当大的事情。

1、升级最新的 Gradle 版本

将 Gradle 和 Android Gradle Plugin 的版本升至最新,所带来的的构建速度的提升效果是显而易见的,特别是当之前你所使用的版本很低的时候。

2、开启离线模式

打开 Android Studio 的离线模式后,所有的编译操作都会走本地缓存,毫无疑问,这将会极大地缩短编译时间。

3、配置 AS 的最大堆内存

在默认情况下, AS 的最大堆内存为 1960MB,我们可以选择 Help => Edit Custom VM Options,此时,会打开一个 studio.vmoptions 文件,我们将第二行的 -Xmx1960m 改为 -Xmx3g 即可将可用内存提升到 3GB。

4、删除不必要的 Moudle 或合并部分 Module

过多的 Moudle 会使项目中 Module 的依赖关系变得复杂,Gradle 在编译构建的时候会去检测各个 Module 之间的依赖关系,然后,它会花费大量的构建时间帮我们梳理这些 Module 之间的依赖关系,以避免 Module 之间相互引用而带来的各种问题。除了删除不必要的 Moudle 或合并部分 Module 的方式外,我们也可以将稳定的底层 Module 打包成 aar,上传到公司的本地 Maven 仓库,通过远程方式依赖。

5、删除Module中的无用文件

  • 1)、如果我们不需要写单元测试代码,可以直接删除 test 目录。
  • 2)、如果我们不需要写 UI 测试代码,也可以直接删除 androidTest 目录。
  • 3)、此外,如果 Moudle 中只有纯代码,可以直接删除 res 目录。

6、去除项目中的无用资源

在 Android Studio 中提供了供了自动检测失效文件和删除的功能,即 Remove Unused Resource 功能,操作路径如下所示:

右键 => 选中 Refactor => 选中Remove Unused Resource => 直接点击REFACTOR

需要注意的是,这里不需要将 Delete unused @id declarations too 选中,如果你使用了 databinding 的话,可能会编译失败。

7、优化第三方库的使用

一般的优化步骤有如下 三步:

1)、使用更小的库去替换现有的同类型的三方库。

2)、使用 exclude 来排除三方库中某些不需要或者是重复的依赖。

例如,我在 Awesome-WanAndroid 项目中就使用到了这种技巧,在依赖 LeakCanary 时,发现它包含有 support 包,因此,我们可以使用 exclude 将它排除掉,代码如下所示:

1
2
3
4
5
6
7
8
9
java复制代码   debugImplementation (rootProject.ext.dependencies["leakcanary-android"]) {
exclude group: 'com.android.support'
}
releaseImplementation (rootProject.ext.dependencies["leakcanary-android-no-op"]) {
exclude group: 'com.android.support'
}
testImplementation (rootProject.ext.dependencies["leakcanary-android-no-op"]) {
exclude group: 'com.android.support'
}

3)、使用 debugImplementation 来依赖仅在 debug 期间才会使用的库,如一些线下的性能检测工具。如下是一个示例代码:

1
2
java复制代码// 仅在debug包启用BlockCanary进行卡顿监控和提示的话,可以这么用
debugImplementation 'com.github.markzhai:blockcanary-android:1.5.0'

8、利用公司 Maven 仓库的本地缓存

当第一个开发引入了新库或者更新版本之后,公司的 Maven 仓库中就会缓存对应的库版本,通过这样的方式,其他开发同事就能够在项目构建时直接从公司的 Maven 仓库中拿到缓存。

9、Debug 构建时设置 minSdkVersion 为 21

这样,我们就可以避免因使用 MutliDex 而拖慢 build 速度。在主 Moudle 中的 build.gradle 中加入如下代码:

1
2
3
4
5
java复制代码    productFlavors {
speed {
minSdkVersion 21
}
}

同步项目之后,我们在Android Studio右侧的 Build Variants 中选中 speedDebug 选项即可,如下图所示:

需要注意的是,要注意我们当前项目的实际最低版本,比如它为 18,现在我们开启了 speedDebug,项目编写时就会以 21 为标准,此时,就 需要注意 18 ~ 21 之间的 API,例如我在布局中使用了 21 版本新出的 Material Design 的控件,此时就是没问题的,但实际我们需要对 21 版本以下的对应布局做相应的适配。

此外,我们也可以定义不同的 productFlavors,并且在 src 目录下新建对应的 flavor 名称标识的目录资源文件,以此实现在不同的渠道 APK 中采用不同的资源文件。

10、配置 gradle.properties

通用的配置项如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码    // 构建初始化需要执行许多任务,例如java虚拟机的启动,加载虚拟机环境,加载class文件等等,配置此项可以开启线程守护,并且仅仅第一次编译时会开启线程(Gradle 3.0版本以后默认支持)
org.gradle.daemon=true

// 配置编译时的虚拟机大小
org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8

// 开启并行编译,相当使用了多线程,仅仅适用于模块化项目(存在多个 Library 库工程依赖主工程)
org.gradle.parallel=true

// 最大的优势在于帮助多 Moudle 的工程提速,在编译多个 Module 相互依赖的项目时,Gradle 会按需选择进行编译,即仅仅编译相关的 Module
org.gradle.configureondemand=true

// 开启构建缓存,Gradle 3.5新的缓存机制,可以缓存所有任务的输出,
// 不同于buildCache仅仅缓存dex的外部libs,它可以复用
// 任何时候的构建缓存,设置包括其它分支的构建缓存
org.gradle.caching=true

这里效果比较好一点的配置项就是 配置编译时的虚拟机大小 这项,我们来详细分析下其中参数的含义,如下所示:

  • -Xmx2048m:指定 JVM 最大允许分配的堆内存为 2048MB,它会采用按需分配的方式。
  • -XX:MaxPermSize=512m:指定 JVM 最大允许分配的非堆内存为 512MB,同上堆内存一样也是按需分配的。

11、配置 DexOptions

我们可以将 dexOptions 配置项中的 maxProcessCount 设定为 8,这样编译时并行的最大进程数数目就可以提升到 8 个。

12、使用 walle 提升打多渠道包的效率

walle 是 Android Signature V2 Scheme 签名下的新一代渠道包打包神器,它在 Apk 中的 APK Signature Block 区块添加了自定义的渠道信息以生成渠道包,因而提高了渠道包的生成效率。此外,它也可以作为单机工具来使用,也可以部署在 HTTP 服务器上来实时处理渠道包 Apk 的升级网络请求,有需要的同学可以参考美团的 walle。

13、设置应用支持的语言

如果应用没有做国际化,我们可以让应用仅仅支持 中文的资源配置,即将 resConfigs 设置为 “zh”。如下所示:

1
2
3
4
5
groovy复制代码    android {
defaultConfig {
resConfigs "zh"
}
}

14、使用增量编译

Gradle 的构建方式通常来说细分为以下 三种:

  • 1)、Full Build:全量构建,即从0开始构建。
  • 2)、Incremental build java change:增量构建Java改变,修改源代码后的构建,且之前构建过。
  • 3)、Incremental build resource change:修改资源文件后的构建,且之前构建过。

在 Gradle 4.10 版本之后便默认使用了增量编译,它会测试自上次构建以来是否已更改任何 gradle task 任务输入或输出。如果还没有,Gradle 会将该任务认为是最新的,因此跳过执行其动作。由于 Gradle 可以将项目的依赖关系分析精确到类级别,因此,此时仅会重新编译受影响的类。如果在更老的版本需要启动增量编译,可以使用如下配置:

1
2
3
groovy复制代码    tasks.withType(JavaCompile) {
options.incremental = true
}

15、使用循环进行依赖优化(🔥)

在 Awesome-WanAndroid 项目的 app moudle 的 build.gradle 中,有将近几百行的依赖代码,如下所示:

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
groovy复制代码    dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')

// 启动器
api files('libs/launchstarter-release-1.0.0.aar')

//base
implementation rootProject.ext.dependencies["appcompat-v7"]
implementation rootProject.ext.dependencies["cardview-v7"]
implementation rootProject.ext.dependencies["design"]
implementation rootProject.ext.dependencies["constraint-layout"]

annotationProcessor rootProject.ext.dependencies["glide_compiler"]

//canary
debugImplementation (rootProject.ext.dependencies["leakcanary-android"]) {
exclude group: 'com.android.support'
}
releaseImplementation (rootProject.ext.dependencies["leakcanary-android-no-op"]) {
exclude group: 'com.android.support'
}
testImplementation (rootProject.ext.dependencies["leakcanary-android-no-op"]) {
exclude group: 'com.android.support'
}

...

有没有一种好的方式不在 build.gradle 中写这么多的依赖配置?

有,就是 使用循环遍历依赖。答案似乎很简单,但是要想处理在依赖时遇到的所有情况,并不简单。下面,我直接给出相应的适配代码,大家可以直接使用。

首先,在 app 下的 build.gradle 的依赖配置如下所示:

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
groovy复制代码    // 处理所有的 aar 依赖
apiFileDependencies.each { k, v -> api files(v)}

// 处理所有的 xxximplementation 依赖
implementationDependencies.each { k, v -> implementation v }
debugImplementationDependencies.each { k, v -> debugImplementation v }
releaseImplementationDependencies.each { k, v -> releaseImplementation v }
androidTestImplementationDependencies.each { k, v -> androidTestImplementation v }
testImplementationDependencies.each { k, v -> testImplementation v }
debugApiDependencies.each { k, v -> debugApi v }
releaseApiDependencies.each { k, v -> releaseApi v }
compileOnlyDependencies.each { k, v -> compileOnly v }

// 处理 annotationProcessor 依赖
processors.each { k, v -> annotationProcessor v }

// 处理所有包含 exclude 的依赖
implementationExcludes.each { entry ->
implementation(entry.key) {
entry.value.each { childEntry ->
exclude(group: childEntry)
}
}
}
debugImplementationExcludes.each { entry ->
debugImplementation(entry.key) {
entry.value.each { childEntry ->
exclude(group: childEntry.key, module: childEntry.value)
}
}
}
releaseImplementationExcludes.each { entry ->
releaseImplementation(entry.key) {
entry.value.each { childEntry ->
exclude(group: childEntry.key, module: childEntry.value)
}
}
}
testImplementationExclude.each { entry ->
testImplementation(entry.key) {
entry.value.each { childEntry ->
exclude(group: childEntry.key, module: childEntry.value)
}
}
}
androidTestImplementationExcludes.each { entry ->
androidTestImplementation(entry.key) {
entry.value.each { childEntry ->
exclude(group: childEntry.key, module: childEntry.value)
}
}
}

然后,在 config.gradle 全局依赖管理文件中配置好对应名称的依赖数组即可。代码如下所示:

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
groovy复制代码    dependencies = [
// base
"appcompat-v7" : "com.android.support:appcompat-v7:${version["supportLibraryVersion"]}",
...
]

annotationProcessor = [
"glide_compiler" : "com.github.bumptech.glide:compiler:${version["glideVersion"]}",
...
]

apiFileDependencies = [
"launchstarter" :"libs/launchstarter-release-1.0.0.aar"
]

debugImplementationDependencies = [
"MethodTraceMan" : "com.github.zhengcx:MethodTraceMan:1.0.7"
]

...

implementationExcludes = [
"com.android.support.test.espresso:espresso-idling-resource:3.0.2" : [
'com.android.support' : 'support-annotations'
]
]

...

具体的代码示例可以在 Awesome-WanAndroid 的 build.gradle 和 config.gradle 上进行查看。

三、Gradle 常用命令

1、Gradle 查询命令

1)、查看主要任务

1
gradle复制代码    ./gradlew tasks

2)、查看所有任务,包括缓存任务等等

1
gradle复制代码    ./gradlew tasks --all

2、Gradle 执行命令

1)、对某个module [moduleName] 的某个任务[TaskName] 运行

1
gradle复制代码    ./gradlew :moduleName:taskName

3、Gradle 快速构建命令

Gradle 提供了一系列的快速构建命令来替代 IDE 的可视化构建操作,如我们最常用的 clean、build 等等。需要注意的是,build 命令会把 debug、release 环境的包都构建出来。

1)、查看构建版本

1
gradle复制代码    ./gradlew -v

2)、清除 build 文件夹

1
gradle复制代码    ./gradlew clean

3)、检查依赖并编译打包

1
gradle复制代码    ./gradlew build

4)、编译并安装 debug 包

1
gradle复制代码    ./gradlew installDebug

5)、编译并打印日志

1
gradle复制代码    ./gradlew build --info

6)、编译并输出性能报告,性能报告一般在构建工程根目录 build/reports/profile 下

1
gradle复制代码    ./gradlew build --profile

7)、调试模式构建并打印堆栈日志

1
gradle复制代码    ./gradlew build --info --debug --stacktrace

8)、强制更新最新依赖,清除构建后再构建

1
gradle复制代码    ./gradlew clean build --refresh-dependencies

9)、编译并打 Debug 包

1
2
3
gradle复制代码    ./gradlew assembleDebug
# 简化版命令,取各个单词的首字母
./gradlew aD

10)、编译并打 Release 的包

1
2
3
gradle复制代码    ./gradlew assembleRelease
# 简化版命令,取各个单词的首字母
./gradlew aR

4、Gradle 构建并安装命令

1)、Release 模式打包并安装

1
gradle复制代码    ./gradlew installRelease

2)、卸载 Release 模式包

1
gradle复制代码    ./gradlew uninstallRelease

3)、debug release 模式全部渠道打包

1
gradle复制代码    ./gradlew assemble

5、Gradle 查看包依赖命令

1)、查看项目根目录下的依赖

1
gradle复制代码    ./gradlew dependencies

2)、查看 app 模块下的依赖

1
gradle复制代码    ./gradlew app:dependencies

3)、查看 app 模块下包含 implementation 关键字的依赖项目

1
gradle复制代码    ./gradlew app:dependencies --configuration implementation

四、使用 Build Scan 诊断应用的构建过程

在了解 Build Scan 之前,我们需要先来一起学习下旧时代的 Gradle build 诊断工具 Profile report。

1、Profile report

通常情况下,我们一般会使用如下命令来生成一份本地的构建分析报告:

1
gradle复制代码    ./gradlew assembleDebug --profile

这里,我们在 Awesome-WanAndroid App的根目录下运行这个命令,可以得到四块视图。下面,我们来了解下。

1)、Summary

Gradle 构建信息的概览界面,用于 查看 Total Build Time、初始化(包含 Startup、Settings and BuildSrc、Loading Projects 三部分)、配置、任务执行的时间。如下图所示:

image

2)、Configuaration

Gradle 配置各个工程所花费的时间,我们可以看到 All projects、app 模块以及其它模块单个的配置时间。如下图所示:

image

3)、Dependency Resolution

Gradle 在对各个 task 进行依赖关系解析时所花费的时间。如下图所示:

image

4)、Task Execution

Gradle 在执行各个 Gradle task 所花费的时间。如下图所示:

image

需要注意的是,Task Execution 的时间是所有 gradle task 执行时间的总和,实际上 多模块的任务是并行执行的。

2、Build Scan

Build Scan 是官方推出的用于诊断应用构建过程的性能检测工具,它能分析出导致应用构建速度慢的一些问题。在项目下使用如下命令即可开启 Build Scan 诊断:

1
gradle复制代码    ./gradlew build --scan

如果你使用的是 Mac,使用上述命令时出现

1
gradle复制代码    zsh: permission denied: ./gradlew

可以加入下面的命给 gradlew 分配执行权限:

1
gradle复制代码    chmod +x gradlew

执行完 build –scan 命令之后,在命令的最后我们可以看到如下信息:

image

可以看到,在 Publishing build scan 点击下面的链接就可以跳转到 Build Scan 的诊断页面。

需要注意的是,如果你是第一次使用 Build Scan,首先需要使用自己的邮箱激活 Build Scan。如下图界面所示:

image

这里,我输入了我的邮箱 chao.qu521@gmail.com,点击 Go!之后,我们就可以登录我们的邮箱去确认授权即可。如下图所示:

image

直接点击 Discover your build 即可。

授权成功后,我们就可以看到 Build Scan 的诊断页面了。如下图所示:

image

可以看到,在界面的右边有一系列的功能 tab 可供我们选择查看,这里默认是 Summary 总览界面,我们的目的是要查看 应用的构建性能,所以点击右侧的 Performance tab 即可看到如下图所示的构建分析界面:

image

从上图可以看到,Performance 界面中除了 Build、Configuration、Dependency resolution、Task execution 这四项外,还有 Daemon、Network activity、Settings and suggestions。

在 Build 界面中,共有三个子项目,即 Total build time、Total garbage collection time、Peak heap memory usage,Total build time 里面的配置项前面我们已经分析过了,这里我们看看其余两项的含义,如下所示:

  • Total garbage collection time:总的垃圾回收时间。
  • Peak heap memory usage:最大堆内存使用。

对于 Peak heap memory usage 这一项来说,还有三个子项,其含义如下:

  • 1)、PS Eden Space:Young Generation 的 Eden(伊甸园)物理内存区域。程序中生成的大部分新的对象都在 Eden 区中。
  • 2)、PS Survivor Space:Young Generation 的 Eden 的 两个Survivor(幸存者)物理内存区域。当 Eden 区满时,还存活的对象将被复制到其中一个 Survivor 区,当此 Survivor 区满时,此区存活的对象又被复制到另一个 Survivor 区,当这个 Survivor 区也满时,会将其中存活的对象复制到年老代。
  • 3)、PS Old Gen:Old Generation,一般情况下,年老代中的对象生命周期都比较长。

由于我们的目的是关注项目的 build 时间,所以,我们直接关注到 Task execution 这一项。如下图所示:

image

可以看到,Awesome-WanAndroid 项目中所有的 task 都是 Not cacheable 的。此时,我们往下滑动界面,可以看到所有 task 的构建时间。如下所示:

image

如果,我们想查看一个 tinyPicPluginSpeedRelease 这一个 task 的执行详细,可以点击 :app:tinyPicPluginSpeedRelease 这一项,然后,就会跳转到 Timeline 界面,显示出 tinyPicPluginSpeedRelease 相应的执行信息。如下图所示:

image

此外,这里我们点击弹出框右上方的第一个图标:Focus on task in timeline 即可看到该 task 在整个 Gradle build 时间线上的精确位置,如下图所示:

image

至此,我们可以看到 Build Scan 的功能要比 Profile report 强大不少,所以我强烈建议优先使用它进行 Gradle 构建时间的诊断与优化。

五、总结

Gradle 每次构建的运行时间会随着项目编译次数越来少,因此为了准确评估 Gradle 构建提速的优化效果,我们可以在优化前后分别执行以下命令进行对比分析,如下所示:

1
java复制代码    gradlew --profile --recompile-scripts --offline --rerun-tasks assembleDebug

参数含义如下:

  • profile:开启性能检测。
  • recompile-scripts:不使用缓存,直接重新编译脚本。
  • offline:启用离线编译模式。
  • return-task:运行所有 gradle task 并忽略所有优化。

此外,Facebook 的 Buck 以及 Google 的 Bazel 都是优秀的编译工具,那么他们为什么没有使用开源的构建工具呢,主要有如下 三点原因:

  • 1)、统一编译工具:内部的所有项目都使用同一套构建工具,包括 Android、Java、iOS、Go、C++ 等。编译工具的统一优化会使所有项目受益。
  • 2)、代码组织管理架构:Facebook 和 Google 的所有项目都放到同一个仓库里面,因此整个仓库非常庞大,并且,他们也不会使用 Git。目前 Google 使用的是Piper,Facebook 是基于HG修改的,也是一种基于分布式的文件系统。
  • 3)、极致的性能追求:Buck 和 Bazel 的性能的确比 Gradle 更好,内部包含它们的各种编译优化。但是它们的定制型太强,而且对 Maven、JCenter 这样的外部依赖支持也不好。

但是,Buck 和 Bazel 编译构建工具内部的优化思路 还是很值得我们学习和参考的,有兴趣的同学可以去研究下。下一篇文章,我们将一起来学习 Gradle 中的必备基础 — groovy,这将会给我们后续的 Gradle 学习打下坚实的基础,敬请期待。

公钟号同名,欢迎关注,关注后回复 Framework,我将分享给你一份我这两年持续总结、细化、沉淀出来的 Framework 体系化精品面试题,里面很多的核心题答案在面试的压力下,经过了反复的校正与升华,含金量极高~

参考链接:


1、Gradle Github 地址

2、Gradle配置最佳实践

3、提升 50% 的编译速度!阿里零售通 App 工程提效实践

4、Gradle 提速:每天为你省下一杯喝咖啡的时间

5、[大餐]加快gradle构建速度

6、Gradle模块化配置:让你的gradle代码控制在100行以内

7、Gradle Android-build 常用命令参数及解释

8、Android打包提速实践

9、GRADLE构建最佳实践

本文转载自: 掘金

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

手撸的一个快递查询系统,竟然阅读量过18w

发表于 2020-04-13

一、目的

做这个项目的初衷是因为我去年在微信卖老家水果,好多朋友下单后都问我快递单号,每天发货后我都要挨个甄别这个人是哪个快递信息,很麻烦一部小心就搞错了。基于这件小事我有了自助快递查询的这个想法。将发货的快递信息导入到我的系统里,用户访问我的系统,通过输入手机号就可以查看自己的快递物流信息。

项目是去年8月写的,一直搁浅在哪,最近无意间翻看我发的那篇文章自助快递单号查询阅读量竟然都1.8w了,有图有真相。

这着实让我很震惊,看来自助快递查询这块确实是个热点。今天我就讲一下我手撸的快递查询系统。
二、开发


项目地址:github.com/hellowHuaai…
有兴趣的可以直接下载源码,觉得项目不错的伙伴记得点个star,谢谢啦!

2.1技术栈

项目涉及到的技术栈有:

  • SpringBoot: 一款 Java 微服务框架。Spring boot 是 Spring 家族中的一个新框架,它用来简化 Spring 应用程序的创建和开发。
  • Mybitas: 一款ORM框架,即对象关系映射。ORM框架的作用是把持久化对象的保存、修改、删除等操作,转换成对数据库的操作。
  • Jquery:一个轻量级的写的少,做的多的 JavaScript 函数库。
  • Bootstrap:Bootstrap 是一个用于快速开发 Web 应用程序和网站的前端框架。Bootstrap 是基于 HTML、CSS、JAVASCRIPT 的。

2.2后端开发

创建entity

创建快递单实体类,属性包括id,用户名(userName),电话(phone),快递单号(kuaidiNo),快递公司(company),数据创建时间(createTime)。代码如下:

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
复制代码@Data
@Builder
public class KuaiDi {
private Integer id;
/* 收件人姓名 */
private String userName;
/**收件人电话*/
private String phone;
/* 快递单号*/
private String kuaidiNo;
/*快递公司名称(拼音)*/
private String company;
/*订单创建时间*/
private Date createTime;

public KuaiDi(Integer id, String userName, String phone, String kuaidiNo, String company, Date createTime) {
this.id = id;
this.userName = userName;
this.phone = phone;
this.kuaidiNo = kuaidiNo;
this.company = company;
this.createTime = createTime;
}
public KuaiDi(Integer id, String userName, String phone, String kuaidiNo, String company) {
this.id = id;
this.userName = userName;
this.phone = phone;
this.kuaidiNo = kuaidiNo;
this.company = company;
}
}

service,mapper是常规的增删查改操作,就是保存快递的基本信息在数据库中,并可以对数据进行简单的维护功能。详细可参考项目源码。接下来看核心代码。

查询快递信息

快递的基本信息存入数据库,然后就是通过这些信息查询快递的详细物流信息。这里我做过很多尝试,想直接调用一些快递公司的快递信息查询接口,但是都发现接口都有session,当session失效后就无法查询到数据或者就查询到的数据不正确。最终在网上找到一种付费的方案,使用快递100接口。www.kuaidi100.com/
查询快递的demo代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
复制代码public class SynQueryDemo {

/**
* 实时查询请求地址
*/
private static final String SYNQUERY_URL = "http://poll.kuaidi100.com/poll/query.do";

private String key; //授权key
private String customer; //实时查询公司编号

public SynQueryDemo(String key, String customer) {
this.key = key;
this.customer = customer;
}

/**
* 实时查询快递单号
* @param com 快递公司编码
* @param num 快递单号
* @param phone 手机号
* @param from 出发地城市
* @param to 目的地城市
* @param resultv2 开通区域解析功能:0-关闭;1-开通
* @return
*/
public String synQueryData(String com, String num, String phone, String from, String to, int resultv2) {

StringBuilder param = new StringBuilder("{");
param.append("\"com\":\"").append(com).append("\"");
param.append(",\"num\":\"").append(num).append("\"");
param.append(",\"phone\":\"").append(phone).append("\"");
param.append(",\"from\":\"").append(from).append("\"");
param.append(",\"to\":\"").append(to).append("\"");
if(1 == resultv2) {
param.append(",\"resultv2\":1");
} else {
param.append(",\"resultv2\":0");
}
param.append("}");

Map<String, String> params = new HashMap<String, String>();
params.put("customer", this.customer);
String sign = MD5Utils.encode(param + this.key + this.customer);
params.put("sign", sign);
params.put("param", param.toString());

return this.post(params);
}

/**
* 发送post请求
*/
public String post(Map<String, String> params) {
StringBuffer response = new StringBuffer("");

BufferedReader reader = null;
try {
StringBuilder builder = new StringBuilder();
for (Map.Entry<String, String> param : params.entrySet()) {
if (builder.length() > 0) {
builder.append('&');
}
builder.append(URLEncoder.encode(param.getKey(), "UTF-8"));
builder.append('=');
builder.append(URLEncoder.encode(String.valueOf(param.getValue()), "UTF-8"));
}
byte[] bytes = builder.toString().getBytes("UTF-8");

URL url = new URL(SYNQUERY_URL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(3000);
conn.setReadTimeout(3000);
conn.setRequestMethod("POST");
conn.setRequestProperty("accept", "*/*");
conn.setRequestProperty("connection", "Keep-Alive");
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
conn.setRequestProperty("Content-Length", String.valueOf(bytes.length));
conn.setDoOutput(true);
conn.getOutputStream().write(bytes);

reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"));

String line = "";
while ((line = reader.readLine()) != null) {
response.append(line);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != reader) {
reader.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}

return response.toString();
}
}

上面的代码就是通过java代码调用kuaidi100的查询接口,这个查询接口会通过快递单号自动识别快递是属于哪个快递公司,然后调用对应快递公司接口获取响应数据。付费购买接口使用权其实就是生成一个授权key和实时查询公司编号customer,在线调用会做身份认证。这样就可以获取快递信息的json数据了。我已经购买了100块大洋的接口使用权,大家可直接调用快递查询接口。

controller代码

快递信息增删查改的controller就不在列了,这里主要看下我对查询快递的接口进行了一次包装处理。代码如下:

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
复制代码@RestController
public class KuaiDiQueryController {

@Autowired
private KuaiDiService kuaiDiService;
@Autowired
private KuaiDiQueryService kuaiDiQueryService;

/**
* 返回json数据
* @param com
* @param no
* @return
*/
@GetMapping("/getKuaiDiInfoByJson")
@ResponseBody
public String queryKuadiInfoByJson(String com, String no) {
return kuaiDiQueryService.synQueryData(com, no,"", "", "", 0);
}

@GetMapping("/getKuaiDiInfoByPhone")
@ResponseBody
public Response queryKuaidiByPhone(String phone){
Response response = new Response();
if(StringUtils.isNotEmpty(phone)){
List<ResponseData> responseDataList = new ArrayList<>();
// 1.通过手机号查询下面的所有订单号
List<KuaiDi> kuaiDiList = kuaiDiService.getList("", phone);
if(!CollectionUtils.isEmpty(kuaiDiList)){
kuaiDiList.forEach(kuaiDi -> {
// 2.依次查出所有的订单号
String responseDataStr = kuaiDiQueryService.synQueryData(kuaiDi.getCompany(), kuaiDi.getKuaidiNo(),"", "", "", 0);
ResponseData responseData = CommonUtils.convertJsonStr2Object(responseDataStr);
responseDataList.add(responseData);
});
}
// 3.组装数据返回给前台
response.setDataList(responseDataList);
}
return response;
}
}

2.3前端开发

前端展示主要包括两个页面,管理员页面和客户页面。管理员页面功能包括快递信息的新增,修改,删除,分页查询,在线快递物流信息接口。客户页面包括快递信息的分页查询和在线快递物流信息接口。所以主要看一下管理员页面。

html页面

html页面引入了jQuery和Bootstrap,jQuery已经过时了,但是使用起来还是很方便的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
复制代码<html>
<head>
<title>快递单号查询</title>
<script src="https://cdn.bootcss.com/jquery/3.2.1/jquery.min.js"></script>
<link href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.bootcss.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script src="https://cdn.bootcss.com/bootbox.js/4.4.0/bootbox.min.js"></script>
<link href="https://cdn.bootcss.com/bootstrap-table/1.11.1/bootstrap-table.min.css" rel="stylesheet">
<script src="https://cdn.bootcss.com/bootstrap-table/1.11.1/bootstrap-table.min.js"></script>
<script src="https://cdn.bootcss.com/bootstrap-table/1.11.1/locale/bootstrap-table-zh-CN.min.js"></script>
...
</head>
<body>
<div class="container-fluid">

<div class="row">
<nav class="navbar navbar-inverse navbar-fixed-top">
<a class="navbar-brand" href="http://mhtclub.com">我的个人主页</a>
<button class="navbar-toggle" data-toggle="collapse" data-target="#collapseMenu">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<div class="collapse navbar-collapse" id="collapseMenu">
<ul class="nav navbar-nav">
<li class="nav-li">
<a href="https://github.com/hellowHuaairen/kuaidi" target="_blank">本项目github</a>
</li>
</ul>
</div>
</nav>
</div>

<h1 class="page-header">
快递单号自助查询
</h1>

<!-- 查询工具栏 -->
<div class="form-inline">
<div class="form-group">
<label for="queryNameText">收件人姓名:</label>
<input id="queryNameText" class="form-control input-sm">
</div>
<div class="form-group">
<label for="queryPhoneText">收件人电话:</label>
<input id="queryPhoneText" class="form-control input-sm">
</div>
<button class="btn btn-primary btn-sm" id="queryBtn">查询</button>
<button class="btn btn-primary btn-sm" id="resetBtn">重置</button>
<button class="btn btn-primary btn-sm" id="addBtn">新增</button>
</div>
<hr>

<table id="testTable"></table>

<!-- 查看订单信息模态窗 -->
<div class="modal fade" id="viewModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">×</button>
<h4 class="modal-title">订单信息</h4>
</div>
<div class="modal-body" id="viewDataList"></div>
<div class="modal-footer">
<button class="btn btn-default" data-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<!-- 新增模态窗 -->
<div class="modal fade" id="addModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">×</button>
<h4 class="modal-title">新增信息</h4>
</div>
<div class="modal-body">
<div class="form-inline">

</div>
<div class="form-group">
<label for="addNameText">收件人姓名:</label>
<input id="addNameText" class="form-control input-sm">
</div>
<div class="form-group">
<label for="addPhoneText">收件人电话:</label>
<input id="addPhoneText" class="form-control input-sm">
</div>
<div class="form-group">
<label for="addKuaiDiNoText">快递单号:</label>
<input id="addKuaiDiNoText" class="form-control input-sm">
</div>
<div class="form-group">
<label for="addCompanyText">快递公司(拼音):</label>
<input id="addCompanyText" class="form-control input-sm">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-default" data-dismiss="modal">关闭</button>
<button class="btn btn-primary" id="saveAdd">保存</button>
</div>
</div>
</div>
</div>

<!-- 修改模态窗 -->
<div class="modal fade" id="modifyModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">×</button>
<h4 class="modal-title">修改信息</h4>
</div>
<div class="modal-body">
<div class="form-inline">

</div>
<div class="form-group">
<label for="modifyNameText">收件人姓名:</label>
<input id="modifyNameText" class="form-control input-sm">
</div>
<div class="form-group">
<label for="modifyPhoneText">收件人电话:</label>
<input id="modifyPhoneText" class="form-control input-sm">
</div>
<div class="form-group">
<label for="modifyKuaiDiNoText">快递单号:</label>
<input id="modifyKuaiDiNoText" class="form-control input-sm">
</div>
<div class="form-group">
<label for="modifyCompanyText">快递公司(拼音):</label>
<input id="modifyCompanyText" class="form-control input-sm">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-default" data-dismiss="modal">关闭</button>
<button class="btn btn-primary" id="saveModify">保存</button>
</div>
</div>
</div>
</div>
</div> <!-- container-fluid -->

<script type="text/javascript" src="js/admin.js"></script>
</body>
</html>

admin.js

这里说明一下前端我引入的jQuery,包括新增,修改,删除,查询的功能,查询事件添加了对电话号码的必填校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
复制代码var $testTable = $('#testTable');
$testTable.bootstrapTable({
url: 'getList',
queryParams: function (params) {
return {
offset: params.offset,
limit: params.limit,
userName: $('#queryNameText').val(),
phone: $('#queryPhoneText').val()
}
},
columns: [{
field: 'id',
title: '编号'
}, {
field: 'userName',
title: '收件人姓名'
}, {
field: 'phone',
title: '收件人电话'
}, {
field: 'company',
title: '快递公司'
},{
field: 'kuaidiNo',
title: '快递单号',
formatter: function (value, row, index) {
return [
'<a onclick="kuaidiRecordInfo(' + "'" + row.kuaidiNo + "','" + row.company + "')" + '">' + row.kuaidiNo +'</a>',
].join('');
},
}, {
formatter: function (value, row, index) {
return [
'<a href="javascript:modifyKuaiDi(' + row.id + ",'" + row.userName + "'," + row.phone + ",'" + row.kuaidiNo + "'" + ')">' +
'<i class="glyphicon glyphicon-pencil"></i>修改' +
'</a>',
'<a href="javascript:delKuaiDi(' + row.id + ')">' +
'<i class="glyphicon glyphicon-remove"></i>删除' +
'</a>'
].join('');
},
title: '操作'
}],
striped: true,
pagination: true,
sidePagination: 'server',
pageSize: 10,
pageList: [5, 10, 25, 50, 100],
rowStyle: function (row, index) {
var ageClass = '';
if (row.age < 18) {
ageClass = 'text-danger';
}
return {classes: ageClass}
},
});

// 设置bootbox中文
bootbox.setLocale('zh_CN');

var titleTip = '提示';

function kuaidiRecordInfo(no, company) {
$('#viewModal').modal('show');
$.ajax({
type:'get',
url:'getKuaiDiInfoByJson?com='+ company +'&no=' + no,
cache:false,
dataType:'json',
success:function(result){
// 显示详细信息 发送请求通过单号
$("#viewDataList").empty();
console.log(result.data);
var dataList = result.data;
if(null != dataList){
$("#viewDataList").append('<li class="accordion-navigation"><a href="#kuaidi'+ '">快递单号:'+ result.nu +'</a><div id="kuaidi'+ '" class="content"></div></li>');
$("#kuaidi").append('<section class="result-box"><div id="resultTop" class="flex result-top"><time class="up">时间</time><span>地点和跟踪进度</span></div><ul id="reordList'+'" class="result-list sortup"></ul></section>');
for(var i=0;i<dataList.length; i++){
var kuaiRecodList = dataList[i];
if( i == 0){
$("#reordList").append('<li class="last finish"><div class="time"> '+ kuaiRecodList.ftime + '</div><div class="dot"></div><div class="text"> '+ kuaiRecodList.context +'</div></li>');
}else{
$("#reordList").append('<li class=""><div class="time"> '+ kuaiRecodList.ftime + '</div><div class="dot"></div><div class="text"> '+ kuaiRecodList.context +'</div></li>');
}
}
}
}
});
}

// 验证姓名和地址是否为空
function verifyNameAndAddress(name, address) {
if (name != '' && address != '') {
return true;
}
return false;
}

function nullAlert() {
bootbox.alert({
title: titleTip,
message: '所有项均为必填!'
});
}

// 点击查询按钮
$('#queryBtn').click(function () {
var age = $('#queryAgeText').val();
// 刷新并跳转到第一页
$testTable.bootstrapTable('selectPage', 1);

});

// 点击重置按钮,清空查询条件并跳转回第一页
$('#resetBtn').click(function() {
$('.form-group :text').val('');
$testTable.bootstrapTable('selectPage', 1);
});

// 用于修改服务器资源
function exchangeData(path, id, userName, phone, kuaiDiNo, company) {
$.ajax({
url: path,
type: 'post',
data : {
id: id,
userName: userName,
phone: phone,
kuaiDiNo: kuaiDiNo,
company: company
},
success: function(res) {
bootbox.alert({
title: titleTip,
message: res.message
});
// 在每次提交操作后返回首页
$testTable.bootstrapTable('selectPage', 1);
}
});
}

// 新增
$('#addBtn').click(function() {
$('#addNameText').val('');
$('#addPhoneText').val('');
$('#addKuaiDiNoText').val('');
$('#addCompanyText').val('');
$('#addModal').modal('show');
});

$('#saveAdd').click(function() {
$('#addModal').modal('hide');
bootbox.confirm({
title: titleTip,
message: '确认增加?',
callback: function (flag) {
if (flag) {
var userName = $('#addNameText').val();
var phone = $('#addPhoneText').val();
var kuaiDiNo = $('#addKuaiDiNoText').val();
var company = $('#addCompanyText').val();
if (verifyNameAndAddress(userName, kuaiDiNo)) {
exchangeData('addKuaiDi', null, userName, phone, kuaiDiNo, company);
} else {
nullAlert();
}
}
}
});
});

var mid;

// 修改
function modifyKuaiDi(id, name, age, address) {
mid = id;
$('#modifyNameText').val(name);
$('#modifyPhoneText').val(age);
$('#modifyKuaiDiNoText').val(address);
$('#modifyCompanyText').val(address);
$('#modifyModal').modal('show');
}

$('#saveModify').click(function() {
$('#modifyModal').modal('hide');
bootbox.confirm({
title: titleTip,
message: '确认修改?',
callback: function (flag) {
if (flag) {
var userName = $('#modifyNameText').val();
var phone = $('#modifyPhoneText').val();
var kuaiDiNo = $('#modifyKuaiDiNoText').val();
var company = $('#modifyCompanyText').val();
if (verifyNameAndAddress(userName, phone)) {
exchangeData('modifyKuaiDi', mid, userName, phone, kuaiDiNo, company);
} else {
nullAlert();
}
}
}
});
});

// 删除
function delKuaiDi(id) {
bootbox.confirm({
title: titleTip,
message: '确认删除?',
callback: function(flag) {
if (flag) {
exchangeData("delKuaiDi", id);
}
}
});
}

2.4运行项目

修改配置文件

项目配置文件src/resources/application.properties,根据实际情况修改对应的数据库连接信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码#MySQL配置
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.url=jdbc:mysql://localhost:3306/kuaidi?useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root #数据库账号
spring.datasource.password=root #数据库密码
#MyBatis日志配置
mybatis.mapperLocations=classpath:mapper/*.xml
mybatis.config-location=classpath:/config/mybatis-config.xml
#端口配置
server.port=8082

# 定位模板的目录
spring.mvc.view.prefix=classpath:/templates/
# 给返回的页面添加后缀名
spring.mvc.view.suffix=.html

创建数据库表

表结构如下:

1
2
3
4
5
6
7
8
9
10
复制代码DROP TABLE IF EXISTS `kuaidi`;
CREATE TABLE `kuaidi` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '收件人姓名',
`phone` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '收件人电话',
`kuaidi_no` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '快递单号',
`company` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '快递公司名称拼音',
`create_time` datetime(0) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 10 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

运行

将项目导入Idea工具,找到com.wangzg.kuaidi.KuaiDiApplication文件,执行main方法即可,如下图:

三、部署

3.1 jar部署

上传安装包

在服务器创建/usr/myworkspace,执行下面命令可直接创建:

1
复制代码mkdir -p /usr/myworkspace

下载相关文件,上传到服务器/usr/myworkspace。下载地址:github.com/hellowHuaai…

文件主要包括:

  • application.properties 说明:项目配置文件,可能会涉及到修改服务器端口,数据库访问、端口、账号、密码等。
  • kuaidi.jar 说明:后端服务的可执行jar文件。
  • kuaidi.sql 说明:数据库初始化脚本。
  • start.sh 说明: 启动服务器shell脚本。
  • stop.sh 说明: 停止服务器shell脚本。

初始化数据库

打开Navicat工具,选中数据库,右键选择运行SQL文件...,具体操作,这样数据库就初始化完成。

运行项目

在服务器/usr/myworkspace目录下,执行如下命令,即可运行项目:

1
2
复制代码chmod +x *.sh #给所有 .sh文件添加执行权限
./start.sh

3.2 Docker部署

Docker 容器化部署项目,需要创建一个 mysql 的容器,创建kuaidi的容器,再初始化一下数据库。

创建数据库容器

代码如下:

1
复制代码docker run -d --name mysql5.7 -e MYSQL_ROOT_PASSWORD=root -it -p 3306:3306 daocloud.io/library/mysql:5.7.7-rc

导入数据库脚本

数据库脚本kuaidi.sql内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码create DATABASE kuaidi;
use kuaidi;

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

DROP TABLE IF EXISTS `kuaidi`;
CREATE TABLE `kuaidi` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '收件人姓名',
`phone` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '收件人电话',
`kuaidi_no` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '快递单号',
`company` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '快递公司名称拼音',
`create_time` datetime(0) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 10 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

然后执行下面命令,就可以导入kuaidi.sql脚本:

1
复制代码docker exec -i mysql5.7 mysql -uroot -proot mysql < kuaidi.sql

创建kuaidi容器

执行下面命令就可以创建容器:

1
复制代码docker run -d -p 9082:8082 -v application.properties:/home/conf/application.properties --name kuaidi1 huaairen/kuaidi:latest

注:application.properties文件为项目的配置文件,在src/main/resources目录下;huaairen/kuaidi:latest是我打包好的镜像,直接下载就可以。

四、最后

项目功能还特别简陋,很多功能需要开发和完善。如果你也遇到类似的问题我们可以一起讨论,合作共赢哦!

不安分的猿人
孜孜不断的技术分享!

本文转载自: 掘金

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

0xA05 Android 10 源码分析:Dialog加载

发表于 2020-04-13

引言

  • 这是 Android 10 源码分析系列的第 5 篇
  • 分支:android-10.0.0_r14
  • 全文阅读大概 10 分钟

通过这篇文章你将学习到以下内容,将在文末总结部分会给出相应的答案

  • Dialog的的创建流程?
  • Dialog的视图怎么与Window做关联了?
  • 自定义CustomDialog的view的是如何绑定的?
  • 如何使用Kotlin具名可选参数构造类,实现构建者模式?
  • 相比于Java的构建者模式,通过具名可选参数构造类具有以下优点?
  • 如何在Dialog中使用DataBinding?

阅读本文之前,如果之前没有看过 Apk加载流程之资源加载一 和 Apk加载流程之资源加载二 点击下方链接前去查看,这几篇文章都是互相关联的

  • 0xA03 Android 10 源码分析:Apk加载流程之资源加载(一)
  • 0xA04 Android 10 源码分析:Apk加载流程之资源加载(二)

本文主要来主要围绕以下几个方面来分析:

  • Dialog加载绘制流程
  • 如何使用Kotlin具名可选参数构造类,实现构建者模式
  • 如何在Dialog中使用DataBinding

源码分析

在开始分析Dialog的源码之前,需要了解一下Dialog加载绘制流程,涉及到的数据结构与职能

在包 android.app 下:

  • Dialog:Dialog是窗口的父类,主要实现Window对象的初始化和一些共有逻辑
    • AlertDialog:继承自Dialog,是具体的Dialog的操作实现类
    • AlertDialog.Builder:是AlertDialog的内部类,主要用于构造AlertDialog
  • AlertController:是AlertDialog的控制类
    • AlertController.AlertParams:是AlertController的内部类,负责AlertDialog的初始化参数

了解完相关的数据结构与职能,接下来回顾一下Dialog的创建流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setIcon(R.mipmap.ic_launcher);
builder.setMessage("Message部分");
builder.setTitle("Title部分");
builder.setView(R.layout.activity_main);

builder.setPositiveButton("确定", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
alertDialog.dismiss();
}
});
builder.setNegativeButton("取消", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
alertDialog.dismiss();
}
});
alertDialog = builder.create();
alertDialog.show();

上面代码都不会很陌生,主要使用了设计模式当中-构建者模式,

  1. 构建AlertDialog.Builder对象
  2. builder.setXXX 系列方法完成Dialog的初始化
  3. 调用builder.create()方法创建AlertDialog
  4. 调用AlertDialog的show()完成View的绘制并显示AlertDialog

主要通过上面四步完成Dialog的创建和显示,接下来根据源码来分析每个方法的具体实现,以及Dialog的视图怎么与Window做关联

1 构建AlertDialog.Builder对象

1
ini复制代码AlertDialog.Builder builder = new AlertDialog.Builder(this);

AlertDialog.Builder是AlertDialog的内部类,用于封装AlertDialog的构造过程,看一下Builder的构造方法
frameworks/base/core/java/android/app/AlertDialog.java

1
2
3
4
5
6
7
8
9
10
11
java复制代码// AlertController.AlertParams类型的成员变量
private final AlertController.AlertParams P;
public Builder(Context context) {
this(context, resolveDialogTheme(context, Resources.ID_NULL));
}
public Builder(Context context, int themeResId) {
// 构造ContextThemeWrapper,ContextThemeWrapper 是 Context的子类,主要用来处理和主题相关的
// 初始化成为变量 P
P = new AlertController.AlertParams(new ContextThemeWrapper(
context, resolveDialogTheme(context, themeResId)));
}
  • ContextThemeWrapper 继承自ContextWrapper,Application、Service继承自ContextWrapper,Activity继承自ContextThemeWrapper

context3

  • P是AlertDialog.Builder中的AlertController.AlertParams类型的成员变量
  • AlertParams中包含了与AlertDialog视图中对应的成员变量,调用builder.setXXX系列方法之后,我们传递的参数就保存在P中了

1.1 AlertParams封装了初始化参数

AlertController.AlertParams 是AlertController的内部类,负责AlertDialog的初始化参数
frameworks/base/core/java/com/android/internal/app/AlertController.java

1
2
3
4
5
6
7
ini复制代码public AlertParams(Context context) {
mContext = context;
// mCancelable 用来控制点击外部是否可取消,默认可以取消
mCancelable = true;
// LayoutInflater 主要来解析layout.xml文件
mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
}
  • 主要执行了AlertController.AlertParams的初始化操作,初始化了一些成员变量,LayoutInflater 主要来解析layout.xml文件,关于LayoutInflater可以参考之前的文章0xA04 Android 10 源码分析:Apk加载流程之资源加载(二)
  • 初始化完成AlertParams之后,就完成了AlertDialog.Builder的构建

2 调用AlertDialog.Builder的setXXX系列方法

AlertDialog.Builder初始化完成之后,调用它的builder.setXXX 系列方法完成Dialog的初始化
frameworks/base/core/java/android/app/AlertDialog.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
less复制代码// ... 省略了很多builder.setXXX方法
public Builder setTitle(@StringRes int titleId) {
P.mTitle = P.mContext.getText(titleId);
return this;
}
public Builder setMessage(@StringRes int messageId) {
P.mMessage = P.mContext.getText(messageId);
return this;
}
public Builder setPositiveButton(@StringRes int textId, final OnClickListener listener) {
P.mPositiveButtonText = P.mContext.getText(textId);
P.mPositiveButtonListener = listener;
return this;
}
public Builder setPositiveButton(CharSequence text, final OnClickListener listener) {
P.mPositiveButtonText = text;
P.mPositiveButtonListener = listener;
return this;
}
// ... 省略了很多builder.setXXX方法

上面所有setXXX方法都是给Builder的成员变量P赋值,并且他们的返回值都是Builder类型,因此可以通过消息琏的方式调用

1
scss复制代码builder.setTitle().setMessage().setPositiveButton()...

PS: 在Kotlin应该尽量避免使用构建者模式,使用Kotlin中的具名可选参数,实现构建者模式,代码更加简洁,为了不影响阅读的流畅性,将这部分内容放到了文末扩展阅读部分

3 builder.create方法

builder.setXXX 系列方法之后调用builder.create方法完成AlertDialog构建,接下来看一下create方法
frameworks/base/core/java/android/app/AlertDialog.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
scss复制代码public AlertDialog create() {
// P.mContext 是ContextWrappedTheme 的实例
final AlertDialog dialog = new AlertDialog(P.mContext, 0, false);
// Dialog的参数其实保存在P这个类里面
// mAler是AlertController的实例,通过这个方法把P中的变量传给AlertController.AlertParams
P.apply(dialog.mAlert);
// 用来控制点击外部是否可取消,mCancelable 默认为true
dialog.setCancelable(P.mCancelable);
// 如果可以取消设置回调监听
if (P.mCancelable) {
dialog.setCanceledOnTouchOutside(true);
}
// 设置一系列监听
dialog.setOnCancelListener(P.mOnCancelListener);
dialog.setOnDismissListener(P.mOnDismissListener);
if (P.mOnKeyListener != null) {
dialog.setOnKeyListener(P.mOnKeyListener);
}
// 返回 AlertDialog 对象
return dialog;
}
  • 根据P.mContex 构建了一个AlertDialog
  • mAler是AlertController的实例,调用apply方法把P中的变量传给AlertController.AlertParams
  • 设置是否可以点击外部取消,默认可以取消,同时设置回调监听
  • 最后返回AlertDialog对象

3.1 如何构建AlertDialog

我们来分析一下AlertDialog是如何构建的,来看一下它的造方法具体实现
frameworks/base/core/java/android/app/AlertDialog.java

1
2
3
4
5
6
7
8
9
10
less复制代码AlertDialog(Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
super(context, createContextThemeWrapper ? resolveDialogTheme(context, themeResId) : 0,
createContextThemeWrapper);

mWindow.alwaysReadCloseOnTouchAttr();
// getContext() 返回的是ContextWrapperTheme
// getWindow() 返回的是 PhoneWindow
// mAlert 是AlertController的实例
mAlert = AlertController.create(getContext(), this, getWindow());
}

PhoneWindows是什么时候创建的?AlertDialog继承自Dialog,首先调用了super的构造方法,来看一下Dialog的构造方法
frameworks/base/core/java/android/app/Dialog.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
less复制代码Dialog(@NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
...

// 获取WindowManager对象
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
// 构建PhoneWindow
final Window w = new PhoneWindow(mContext);
// mWindow 是PhoneWindow实例
mWindow = w;
w.setCallback(this);
w.setOnWindowDismissedCallback(this);
w.setOnWindowSwipeDismissedCallback(() -> {
if (mCancelable) {
cancel();
}
});
w.setWindowManager(mWindowManager, null, null);
w.setGravity(Gravity.CENTER);
// 继承 Handler
mListenersHandler = new ListenersHandler(this);
}
  • 获取WindowManager对象,构建了PhoneWindow,到这里我们知道了PhoneWindow是在Dialog构造方法创建的
  • 初始化了Dialog的成员变量mWindow,mWindow 是PhoneWindow的实例
  • 初始化了Dialog的成员变量mListenersHandler,mListenersHandler继承Handler

我们回到AlertDialog构造方法,在AlertDialog构造方法内,调用了 AlertController.create方法,来看一下这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public static final AlertController create(Context context, DialogInterface di, Window window) {
final TypedArray a = context.obtainStyledAttributes(
null, R.styleable.AlertDialog, R.attr.alertDialogStyle,
R.style.Theme_DeviceDefault_Settings);
int controllerType = a.getInt(R.styleable.AlertDialog_controllerType, 0);
a.recycle();
// 根据controllerType 使用不同的AlertController
switch (controllerType) {
case MICRO:
// MicroAlertController 是matrix风格 继承自AlertController
return new MicroAlertController(context, di, window);
default:
return new AlertController(context, di, window);
}
}

根据controllerType 返回不同的AlertController,到这里分析完了AlertDialog是如何构建的

4 调用Dialog的show方法显示Dialog

调用AlertDialog.Builder的create方法之后返回了AlertDialog的实例,最后调用了AlertDialog的show方法显示dialog,但是AlertDialog是继承自Dialog的,实际上调用的是Dialog的show方法
frameworks/base/core/java/android/app/Dialog.java

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
ini复制代码public void show() {
// mShowing变量用于表示当前dialog是否正在显示
if (mShowing) {
if (mDecor != null) {
if (mWindow.hasFeature(Window.FEATURE_ACTION_BAR)) {
mWindow.invalidatePanelMenu(Window.FEATURE_ACTION_BAR);
}
mDecor.setVisibility(View.VISIBLE);
}
return;
}

mCanceled = false;

// mCreated这个变量控制dispatchOnCreate方法只被执行一次
if (!mCreated) {
dispatchOnCreate(null);
} else {
// Fill the DecorView in on any configuration changes that
// may have occured while it was removed from the WindowManager.
final Configuration config = mContext.getResources().getConfiguration();
mWindow.getDecorView().dispatchConfigurationChanged(config);
}

// 用于设置ActionBar
onStart();
// 获取DecorView
mDecor = mWindow.getDecorView();

if (mActionBar == null && mWindow.hasFeature(Window.FEATURE_ACTION_BAR)) {
final ApplicationInfo info = mContext.getApplicationInfo();
mWindow.setDefaultIcon(info.icon);
mWindow.setDefaultLogo(info.logo);
mActionBar = new WindowDecorActionBar(this);
}
// 获取布局参数
WindowManager.LayoutParams l = mWindow.getAttributes();
boolean restoreSoftInputMode = false;
if ((l.softInputMode
& WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION) == 0) {
l.softInputMode |=
WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION;
restoreSoftInputMode = true;
}
// 将DecorView和布局参数添加到WindowManager中,完成view的绘制
mWindowManager.addView(mDecor, l);
if (restoreSoftInputMode) {
l.softInputMode &=
~WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION;
}

mShowing = true;
// 向Handler发送一个Dialog的消息,从而显示AlertDialog
sendShowMessage();
}
  • 判断dialog是否已经显示,如果显示了直接返回
  • 判断dispatchOnCreate方法是否已经调用,如果没有则调用dispatchOnCreate方法
  • 获取布局参数添加到WindowManager,调用addView方法完成view的绘制
  • 向Handler发送一个Dialog的消息,从而显示AlertDialog

4.1 dispatchOnCreate

在上面代码中,根据mCreated变量,判断dispatchOnCreate方法是否已经调用,如果没有则调用dispatchOnCreate方法
frameworks/base/core/java/android/app/Dialog.java

1
2
3
4
5
6
7
scss复制代码void dispatchOnCreate(Bundle savedInstanceState) {
if (!mCreated) {
// 调用 onCreate 方法
onCreate(savedInstanceState);
mCreated = true;
}
}

在dispatchOnCreate方法中主要调用Dialog的onCreate方法, Dialog的onCreate方法是个空方法,由于我们创建的是AlertDialog对象,AlertDialog继承于Dialog,所以调用的是AlertDialog的onCreate方法
frameworks/base/core/java/android/app/AlertDialog.java

1
2
3
4
scss复制代码protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mAlert.installContent();
}

在这方法里面调用了AlertController的installContent方法,来看一下具体的实现逻辑
frameworks/base/core/java/com/android/internal/app/AlertController.java

1
2
3
4
5
6
7
8
scss复制代码public void installContent() {
// 获取相应的Dialog布局文件
int contentView = selectContentView();
// 调用setContentView方法解析布局文件
mWindow.setContentView(contentView);
// 初始化布局文件中的组件
setupView();
}
  • 调用selectContentView方法获取布局文件,来看一下具体的实现
  • frameworks/base/core/java/com/android/internal/app/AlertController.java*
1
2
3
4
5
6
7
8
9
csharp复制代码private int selectContentView() {
if (mButtonPanelSideLayout == 0) {
return mAlertDialogLayout;
}
if (mButtonPanelLayoutHint == AlertDialog.LAYOUT_HINT_SIDE) {
return mButtonPanelSideLayout;
}
return mAlertDialogLayout;
}

返回的布局是mAlertDialogLayout,布局文件是在AlertController的构造方法初始化的
frameworks/base/core/java/com/android/internal/app/AlertController.java

1
2
ini复制代码mAlertDialogLayout = a.getResourceId(
R.styleable.AlertDialog_layout, R.layout.alert_dialog);
  • 调用Window.setContentView方法解析布局文件,Activity的setContentView最后也是调用了Window.setContentView这个方法,具体的解析流程,可以参考之前的文章Activity布局加载流程 0xA03 Android 10 源码分析:Apk加载流程之资源加载
  • 调用setupView方法初始化布局文件中的组件, 到这里dispatchOnCreate方法分析结束

4.2 调用mWindowManager.addView完成View的绘制

回到我们的Dialog的show方法,在执行了dispatchOnCreate方法之后,又调用了onStart方法,这个方法主要用于设置ActionBar,然后初始化WindowManager.LayoutParams对象,最后调用mWindowManager.addView()方法完成界面的绘制,绘制完成之后调用sendShowMessage方法
frameworks/base/core/java/android/app/Dialog.java

1
2
3
4
5
6
csharp复制代码private void sendShowMessage() {
if (mShowMessage != null) {
// Obtain a new message so this dialog can be re-used
Message.obtain(mShowMessage).sendToTarget();
}
}

向Handler发送一个Dialog的消息,从而显示AlertDialog,该消息最终会在ListenersHandler中的handleMessage方法中被执行,ListenersHandler是Dialog的内部类,继承Handler
frameworks/base/core/java/android/app/Dialog.java

1
2
3
4
5
6
7
8
9
10
11
12
13
csharp复制代码public void handleMessage(Message msg) {
switch (msg.what) {
case DISMISS:
((OnDismissListener) msg.obj).onDismiss(mDialog.get());
break;
case CANCEL:
((OnCancelListener) msg.obj).onCancel(mDialog.get());
break;
case SHOW:
((OnShowListener) msg.obj).onShow(mDialog.get());
break;
}
}

如果msg.what = SHOW,会执行OnShowListener.onShow方法,msg.what的值和OnShowListener调用setOnShowListener方法赋值的
frameworks/base/core/java/android/app/Dialog.java

1
2
3
4
5
6
7
less复制代码public void setOnShowListener(@Nullable OnShowListener listener) {
if (listener != null) {
mShowMessage = mListenersHandler.obtainMessage(SHOW, listener);
} else {
mShowMessage = null;
}
}

mListenersHandler构造了Message对象,当我们在Dialog中发送showMessage的时候,被mListenersHandler所接收

4.3 自定义Dialog的view的是如何绑定的

在上文分析中根据mCreated变量,判断dispatchOnCreate方法是否已经调用,如果没有则调用dispatchOnCreate方法,在dispatchOnCreate方法中主要调用Dialog的onCreate方法,由于创建的是AlertDialog对象,AlertDialog继承于Dialog,所以实际调用的是AlertDialog的onCreate方法,来完成布局文件的解析,和布局文件中控件的初始化

同理我们自定义CustomDialog继承自Dialog,所以调用的是自定义CustomDialog的onCreate方法,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
scala复制代码public class CustomDialog extends Dialog {
Context mContext;
// ... 省略构造方法
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

LayoutInflater inflater = (LayoutInflater) mContext
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View view = inflater.inflate(R.layout.custom_dialog, null);
setContentView(view);
}

}

在onCreate方法中调用了 Dialog的setContentView 方法, 来分析setContentView方法
frameworks/base/core/java/android/app/Dialog.java

1
2
3
less复制代码public void setContentView(@NonNull View view) {
mWindow.setContentView(view);
}

mWindow是PhoneWindow的实例,最后调用的是PhoneWindow的setContentView解析布局文件,Activity的setContentView最后也是调用了PhoneWindow的setContentView方法,具体的解析流程,可以参考之前的文章Activity布局加载流程 0xA03 Android 10 源码分析:Apk加载流程之资源加载

总结

Dialog和Activity的显示逻辑是相似的都是内部管理这一个Window对象,用WIndow对象实现界面的加载与显示逻辑

Dialog的的创建流程?

  1. 构建AlertDialog.Builder对象
  2. builder.setXXX 系列方法完成Dialog的初始化
  3. 调用builder.create()方法创建AlertDialog
  4. 调用AlertDialog的show()初始化Dialog的布局文件,Window对象等,然后执行mWindowManager.addView方法,开始执行绘制View的操作,最终将Dialog显示出来

Dialog的视图怎么与Window做关联了?

  • 在Dialog的构造方法中初始化了Window对象
1
2
3
4
5
6
7
8
9
10
less复制代码Dialog(@NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
...
// 获取WindowManager对象
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
// 构建PhoneWindow
final Window w = new PhoneWindow(mContext);
// mWindow 是PhoneWindow实例
mWindow = w;
...
}
  • 调用Dialog的show方法,完成view的绘制和Dialog的显示
1
2
3
4
5
6
7
8
scss复制代码public void show() {
// 获取DecorView
mDecor = mWindow.getDecorView();
// 获取布局参数
WindowManager.LayoutParams l = mWindow.getAttributes();
// 将DecorView和布局参数添加到WindowManager中
mWindowManager.addView(mDecor, l);
}

最终会通过WindowManager将DecorView添加到Window之中,用WIndow对象实现界面的加载与显示逻辑

自定义CustomDialog的view的是如何绑定的?

  • 调用Dialog的show方法,在该方法内部会根据mCreated变量,判断dispatchOnCreate方法是否已经调用,如果没有则调用dispatchOnCreate方法,在dispatchOnCreate方法中主要调用Dialog的onCreate方法
  • 自定义CustomDialog继承自Dialog,所以调用的是自定义CustomDialog的onCreate方法,在CustomDialog的onCreate方法中调用setContentView方法,最后调用的是PhoneWindow的setContentView解析布局文件,解析流程参考0xA03 Android 10 源码分析:Apk加载流程之资源加载

如何使用Kotlin具名可选参数构造类,实现构建者模式?

这部分内容参考扩展阅读部分

相比于Java的构建者模式,通过具名可选参数构造类具有以下优点?

  • 代码非常的简洁
  • 每个参数名都可以显示的,声明对象时无须按照顺序书写,非常的灵活
  • 构造函数中每个参数都是val声明的,在多线程并发业务场景中更加的安全
  • Kotlin的require方法,让我们在参数约束上更加的友好

如何在Dialog中使用DataBinding?

这部分内容参考扩展阅读部分

扩展阅读

1. Kotlin实现构建者模式

刚才在上文中提到了,在Kotlin中应该尽量避免使用构建者模式,使用Kotlin的具名可选参数构造类,实现构建者模式,代码更加简洁

在 “Effective Java” 书中介绍构建者模式时,是这样子描述它的:本质上builder模式模拟了具名的可算参数,就像Ada和Python中的一样

关于Java用构建者模式实现自定义dialog,这里就不展示了,可以百度、Google搜索一下,代码显得很长……..幸运的是,Kotlin是一门拥有具名可选参数的变成语言,Kotlin中的函数和构造器都支持这一特性,接下里我们使用具名可选参数构造类,实现构建者模式,点击JDataBinding前往查看,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码class AppDialog(
context: Context,
val title: String? = null,
val message: String? = null,
val yes: AppDialog.() -> Unit
) : DataBindingDialog(context, R.style.AppDialog) {

init {
requireNotNull(message) { "message must be not null" }
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)

setContentView(root)
display.text = message
btnNo.setOnClickListener { dismiss() }
btnYes.setOnClickListener { yes() }
}
}

调用方式也更加的简单

1
2
3
4
5
6
less复制代码AppDialog(
context = this@MainActivity,
message = msg,
yes = {
// do something
}).show()

相比于Java的构建者模式,通过具名可选参数构造类具有以下优点:

  • 代码非常的简洁
  • 每个参数名都可以显示的,声明对象时无须按照顺序书写,非常的灵活
  • 构造函数中每个参数都是val声明的,在多线程并发业务场景中更加的安全
  • Kotlin的require方法,让我们在参数约束上更加的友好

2. 如何在Dialog中使用DataBinding

DataBinding是什么?查看Google官网,会有更详细的介绍

DataBinding 是 Google 在 Jetpack 中推出的一款数据绑定的支持库,利用该库可以实现在页面组件中直接绑定应用程序的数据源

在使用Kotlin的具名可选参数构造类实现Dailog构建者模式的基础上,用DataBinding进行二次封装,加上DataBinding数据绑定的特性,使Dialog变得更加简洁、易用

Step1: 定义一个基类DataBindingDialog

1
2
3
4
5
6
7
8
9
10
11
less复制代码abstract class DataBindingDialog(@NonNull context: Context, @StyleRes themeResId: Int) :
Dialog(context, themeResId) {

protected inline fun <reified T : ViewDataBinding> binding(@LayoutRes resId: Int): Lazy<T> =
lazy {
requireNotNull(
DataBindingUtil.bind<T>(LayoutInflater.from(context).inflate(resId, null))
) { "cannot find the matched view to layout." }
}

}

Step2: 改造AppDialog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
kotlin复制代码class AppDialog(
context: Context,
val title: String? = null,
val message: String? = null,
val yes: AppDialog.() -> Unit
) : DataBindingDialog(context, R.style.AppDialog) {
private val mBinding: DialogAppBinding by binding(R.layout.dialog_app)

init {
requireNotNull(message) { "message must be not null" }
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)

mBinding.apply {
setContentView(root)
display.text = message
btnNo.setOnClickListener { dismiss() }
btnYes.setOnClickListener { yes() }
}

}
}

同理DataBinding在Activity、Fragment、Adapter中的使用也是一样的,利用Kotlin的inline、reified、DSL等等语法,可以设计出更加简洁并利于维护的代码

关于基于DataBinding封装的DataBindingActivity、DataBindingFragment、DataBindingDialog基础库相关代码,后续也会陆续完善基础库,点击JDataBinding前往查看,欢迎start

结语

致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,正在努力写出更好的文章,如果这篇文章对你有帮助给个 star,文章中有什么没有写明白的地方,或者有什么更好的建议欢迎留言,欢迎一起来学习,在技术的道路上一起前进。

计划建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,正在逐渐增加 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请帮我点个赞,我会陆续完成更多 Jetpack 新成员的项目实践。

算法

由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序。

  • 数据结构: 数组、栈、队列、字符串、链表、树……
  • 算法: 查找算法、搜索算法、位运算、排序、数学、……

每道题目都会用 Java 和 kotlin 去实现,并且每道题目都有解题思路、时间复杂度和空间复杂度,如果你同我一样喜欢算法、LeetCode,可以关注我 GitHub 上的 LeetCode 题解:Leetcode-Solutions-with-Java-And-Kotlin,一起来学习,期待与你一起成长。

Android 10 源码系列

正在写一系列的 Android 10 源码分析的文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,如果你同我一样喜欢研究 Android 源码,可以关注我 GitHub 上的 Android10-Source-Analysis,文章都会同步到这个仓库。

  • 0xA01 Android 10 源码分析:APK 是如何生成的
  • 0xA02 Android 10 源码分析:APK 的安装流程
  • 0xA03 Android 10 源码分析:APK 加载流程之资源加载
  • 0xA04 Android 10 源码分析:APK 加载流程之资源加载(二)
  • 0xA05 Android 10 源码分析:Dialog 加载绘制流程以及在 Kotlin、DataBinding 中的使用
  • 更多……

工具系列

  • 为数不多的人知道的 AndroidStudio 快捷键(一)
  • 为数不多的人知道的 AndroidStudio 快捷键(二)
  • 关于 adb 命令你所需要知道的
  • 如何高效获取视频截图
  • 10分钟入门 Shell 脚本编程
  • 如何在项目中封装 Kotlin + Android Databinding

逆向系列

  • 基于 Smali 文件 Android Studio 动态调试 APP
  • 解决在 Android Studio 3.2 找不到 Android Device Monitor 工具

本文转载自: 掘金

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

线程池源码精讲

发表于 2020-04-12

为什么要使用线程池

  1. 降低创建线程和销毁线程的性能开销。
  2. 提高响应速度,当有新任务需要执行是不需要等待线程创建就可以立马执行。
  3. 合理的设置线程池大小可以避免因为线程数过多消耗CPU资源。

我们来看阿里巴巴的代码规范,在项目中创建线程必须要使用线程池创建,原因也是我说的以上三点。

线程池的使用

首先我们来看下UML类图

  • Executor:可以看到最顶层是 Executor 的接口。这个接口很简单,只有一个 execute 方法。此接口的目的是为了把任务提交和任务执行解耦。
  • ExecutorService:这还是一个接口,继承自 Executor,它扩展了 Executor 接口,定义了更多线程池相关的操作。
  • AbstractExecutorService:提供了 ExecutorService 的部分默认实现。
  • ThreadPoolExecutor:实际上我们使用的线程池的实现是 ThreadPoolExecutor。它实现了线程池工作的完整机制。也是我们接下来分析的重点对象。
  • ForkJoinPool:和ThreadPoolExecutor都继承自AbstractExecutorService,适合用于分而治之,递归计算的算法
  • ScheduledExecutorService:这个接口扩展了ExecutorService,定义个延迟执行和周期性执行任务的方法。
  • ScheduledThreadPoolExecutor:此接口则是在继承 ThreadPoolExecutor 的基础上实现 ScheduledExecutorService 接口,提供定时和周期执行任务的特性。

搞清楚上面的结构很重要,Executors是一个工具类,然后看创建线程的两种方式,第一种是通过Executors提供的工厂方法来实现,有下面四种方式:

1
2
3
4
java复制代码        Executor executor1 = Executors.newFixedThreadPool(10);
Executor executor2 = Executors.newSingleThreadExecutor();
Executor executor3 = Executors.newCachedThreadPool();
Executor executor4 = Executors.newScheduledThreadPool(10);

第二种是通过构造方法来实现

1
2
3
4
5
6
java复制代码        ExecutorService executor5 = new ThreadPoolExecutor(1,
1,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<Runnable>(2), Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());

其实查看第一种方式创建的源码就会发现:

1
2
3
4
5
java复制代码    public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue());
}

它们的底层还是通过调用ThreadPoolExecutor的构造方法,创建时传入不同参数,所以本质上还是只有一种创建线程池的方式,就是用构造方法,这里我不想讲用Executors的工厂方法具体帮我们创建了怎样的线程池,让我们再来看一条阿里巴巴规范。


看到这里大家都明白了吧,正是因为封装性太强了,反而小伙们会不知道怎么用,乱用,滥用,有可能会导致OOM,除非你对创建的这四个线程池了如指掌,所以我介绍了也是白介绍,因为就不让用,接下来我们重点看下ThreadPoolExecutor构造方法里各个参数的含义。

1
2
3
4
5
6
7
java复制代码public ThreadPoolExecutor(int corePoolSize, //核心线程数量
int maximumPoolSize, //最大线程数
long keepAliveTime, //超时时间,超出核心线程数量以外的线程空余存活时间
TimeUnit unit, //存活时间单位
BlockingQueue<Runnable> workQueue, //保存执行任务的队列
ThreadFactory threadFactory,//创建新线程使用的工厂
RejectedExecutionHandler handler //当任务无法执行的时候的处理方式)
  • corePoolSize:即线程池的核心线程数量,其实也是最小线程数量。不设置allowCoreThreadTimeOut 的情况下,核心线程数量范围内的线程一直存活。线程不会自行销毁,而是以挂起的状态返回到线程池,直到应用程序再次向线程池发出请求时,线程池里挂起的线程就会再度激活执行任务。
  • maximumPoolSize:即线程池的最大线程数量
  • keepAliveTime和unit:超出核心线程数后的存活时间和存活单位
  • workQueue:是一个阻塞的 queue,用来保存线程池要执行的所有任务。通常可以取下面三种类型:
1
2
3
ini复制代码1)ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小;  
2)LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;
3)SynchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务。
  • ThreadFactory:我们一般用Executors.defaultThreadFactory()默认工厂,为什么要用工厂呢,其实就是规范了生成的Thread。避免调用new Thread创建,导致创建出来的Thread可能存在差异
  • handler:当队列和最大线程池都满了之后的拒绝策略。
1
2
3
4
5
6
复制代码1、AbortPolicy:直接抛出异常,默认策略;
2、CallerRunsPolicy:用调用者所在的线程来执行任务;
3、DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
4、DiscardPolicy:直接丢弃任务;
当然也可以根据应用场景实现 RejectedExecutionHandler 接口,自定义饱和策略,如记录
日志或持久化存储不能处理的任务

创建完线程池后使用也很简单,带返回值和不带返回值,传入对应传入Runnable或者Callable接口的实现

1
2
3
4
java复制代码//无返回值
executor5.execute(() -> System.out.println("jack xushuaige"));
//带返回值
String message = executor5.submit(() -> { return "jack xushuaige"; }).get();

源码分析

execute方法

我们先从execute方法开始看

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复制代码public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();

int c = ctl.get();
//判断当前工作线程数是否小于核心线程数(延迟初始化)
if (workerCountOf(c) < corePoolSize) {
//添加工作线程的同时,执行command
if (addWorker(command, true))
return;
c = ctl.get();
}
//添加到阻塞队列
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
//线程池停掉拒绝加入阻塞队列
if (! isRunning(recheck) && remove(command))
//拒绝策略
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//阻塞队列满了,则添加工作线程(扩容的线程)
else if (!addWorker(command, false))
//拒绝策略
reject(command);
}

分三步做处理:

  1. 如果运行的线程数量小于 corePoolSize,那么尝试创建新的线程,并把传入的 command 作为它的第一个 task 来执行。调用 addWorker 会自动检查 runState 和 workCount,以此来防止在不应该添加线程时添加线程的错误警告;
  2. 即使 task 可以被成功加入队列,我们仍旧需要再次确认我们是否应该添加 thread(因为最后一次检查之后可能有线程已经死掉了)还是线程池在进入此方法后已经停掉了。所以我们会再次检查状态,如果有必要的话,可以回滚队列。或者当没有线程时,开启新的 thread;
  3. 如果无法将 task 加入 queue,那么可以尝试添加新的 thread。如果添加失败,这是因为线程池被关闭或者已经饱和了,所以拒绝这个 task。
    下面用流程图演示一下,更加直观清楚

然后介绍一下源码中ctl是干什么的,点进去查看源码


我们发现它是一个原子类,主要作用是用来保存线程数量和线程池的状态,他用到了位运算,
一个int数值是32个 bit 位,这里采用高 3 位来保存运行状态,低 29 位来保存线程数量。

我们来计算一下ctlOf(RUNNING, 0)方法,其中 RUNNING =-1 << COUNT_BITS ; -1 左移 29 位,-1 的二进制是32个1(1111 1111 1111 1111 1111 1111 1111 1111),左移29位后得到(1110 0000 0000 0000 0000 0000 0000 0000),然后111| 0还是111,同理可得其他状态的 bit 位。这个位运算很有意思,hashmap源码中也用到了位运算,小伙们在平时开发中也可以尝试用下,这样运算速度会快,而且能够装b,介绍下这五种线程池的状态

  • RUNNING:接收新任务,并执行队列中的任务
  • SHUTDOWN:不接收新任务,但是执行队列中的任务
  • STOP:不接收新任务,不执行队列中的任务,中断正在执行中的任务
  • TIDYING:所有的任务都已结束,
    线程数量为 0,处于该状态的线程池即将调用 terminated()方法
  • TERMINATED:terminated()方法执行完成

他们的转换关系如下:

addWorker方法

我们看到execute流程的核心方法为addWorker,我们继续分析,其实就做了两件事,拆分一下

第一步:通过原子操作增加线程数量:

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
java复制代码retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
//通过原子操作增加线程数量
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

retry是一个标记,和循环配合使用,continue retry 的时候,会跳到 retry 的地方再次执行。如果 break retry,则跳出整个循环体。源码先获取到 ctl,然后检查状态,然后根据创建线程类型的不同,进行数量的校验。在通过CAS方式更新 ctl状态,成功的话则跳出循环。否则再次取得线程池状态,如果和最初已经不一致,那么从头开始执行。如果状态并未改变则继续更新worker的数量。流程图如下:


第二步:添加 worker 到 workers 的 set 中。并且启动 worker 中持有的线程。代码如下:

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复制代码boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
//构建一个工作线程,此时还没有启动
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
//添加到一个容器中
workers.add(w);
int s = workers.size();
//重新更新largestPoolSize
if (s > largestPoolSize)
largestPoolSize = s;
//添加成功
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
//启动线程
t.start();
workerStarted = true;
}
}
} finally {//失败回滚
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;

可以看到添加 work 时需要先获得锁,这样确保多线程并发安全。如果添加 worker 成功,那么调用 worker 中线程的 start 方法启动线程。如果启动失败则调用 addWorkerFailed 方法进行回滚。看到这里小伙们会发现

1、ThreadPoolExecutor在初始化后并没有启动和创建任何线程,在调用 execute方法时才会调用 addWorker创建线程

2、addWorker方法中会创建新的worker,并启动其持有的线程来执行任务。

上文提到如果线程数量已经达到corePoolSize,则只会把command 加入到 workQueue中,那么加入到 workQueue中的command是如何被执行的呢?下面我们来分析 Worker 的源代码。

Worker类

Worker封装了线程,是executor中的工作单元。worker继承自AbstractQueuedSynchronizer,并实现 Runnable。 Worker 简单理解其实就是一个线程,里面重新了 run 方法,我们来看他的构造方法:

1
2
3
4
5
java复制代码        Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}

再来看下这两个重要的属性

1
2
3
4
java复制代码        /** Thread this worker is running in.  Null if factory fails. */
final Thread thread;
/** Initial task to run. Possibly null. */
Runnable firstTask;

firstTask 用它来保存传入的任务;thread 是在调用构造方法时通过 ThreadFactory 来创建的线程,是用来处理任务的线程,这里用的是 ThreadFactory 创建线程,并没有直接 new,原因上文也提到过,这里看下 newThread 传入的是 this,因为 Worker 本身继承了 Runnable 接口,所以 addWork 中调用的 t.start(),实际上运行的是 t 所属 worker 的 run 方法。worker 的 run 方法如下:

1
2
3
java复制代码public void run() {
runWorker(this);
}

runWorker源码再如下:

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
java复制代码    final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
//while循环保证当前线程不结束,直到task为null
while (task != null || (task = getTask()) != null) {
//表示当前线程正在运行一个任务,如果其他地方要shutdown(),你必须等我执行完成
w.lock();//worker继承了AQS->实现了互斥锁
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();//是否应该触发中断
try {
beforeExecute(wt, task);//空的实现
Throwable thrown = null;
try {
task.run();//执行task.run
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);//空的实现
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}

getTask()

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
java复制代码private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?
//cas自旋
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

//如果线程池已经结束状态,直接返回null,需要清理掉所有的工作线程
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}

int wc = workerCountOf(c);

//是否允许超时
//allowCoreThreadTimeOut为true
//如果当前的工作线程数量大于核心线程数
// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
//cas减少工作线程数量
if (compareAndDecrementWorkerCount(c))
//表示要销毁当前工作线程
return null;
continue;
}
//获取任务的过程
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
//如果阻塞队列没有任务,当前工作线程就会阻塞在这里
workQueue.take();
if (r != null)
return r;
timedOut = true;
//中断异常
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

简单分析一下

  1. 先取出 worker 中的 firstTask,并清空;
  2. 如果没有 firstTask,则调用 getTask 方法,从 workQueue 中获取task;
  3. 获取锁;
  4. 执行 beforeExecute。这里是空方法,如有需要在子类实现;
  5. 执行 task.run;
  6. 执行 afterExecute。这里是空方法,如有需要在子类实现;
  7. 清空 task,completedTasks++,释放锁;
  8. 当有异常或者没有 task 可执行时,会进入外层 finnaly 代码块。调用 processWorkerExit 退出当前 worker。从 works 中移除本 worker 后,如果 worker 数量小于 corePoolSize,则创建新的 worker,以维持 corePoolSize 大小的线程数。

这行代码 while (task != null || (task = getTask()) != null) ,确保了 worker 不停地从 workQueue 中取得 task 执行。getTask 方法会从 BlockingQueue workQueue 中 poll 或者 take 其中的 task 出来。

后面还有shutdown()、shutdownNow()等其他方法留给小伙们自行去观察研究哈。

如何合理配置线程池的大小

线程池大小不是靠猜,也不是说越多越好,最好的方式还是根据实际情况测试得出最佳配置。

  • CPU 密集型:主要是执行计算任务,响应时间很快,CPU 一直在运行,这种任务 CPU 利用率很高,会增加上下文切换,应当分配较少的线程,比如 CPU core+1。
  • IO 密集型:主要是进行 IO 操作,执行 IO 操作的时间较长,由于线程并不是一直在运行,这时 CPU 利用率不高,可以增加线程池的大小,比如 CPU 2*core+1。

线程池的监控

如果在项目中大规模的使用了线程池,那么必须要有一套监控体系,来指导当前线程池的状
态,当出现问题的时候可以快速定位到问题。我们通过重写线程池的 beforeExecute、afterExecute 和 shutdown 等方式就可以实现对线程的监控


看这些名称和定义都知道,这是让子类来实现的,可以在线程执行前、后、终止状态执行自定义逻辑。

总结

线程池这东西说简单也简单,说难也难,简单是因为用起来简单,难是难在要知道它的底层的源码,它是如何调度线程的,说两点吧,第一是本文中用了大量的流程图,当我们在阅读源码或者做复杂业务开发的时候,一定要静下心来先画个图,否则会被绕晕或者被别人打断后,又得从头到尾的看一边,第二是阅读源码,刚毕业的小伙伴可能只要会用行了,但是如果你工作五年了,还是只会用,那你比刚毕业的优势在哪里,凭什么工资要的高。感谢大家观看~

本文转载自: 掘金

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

手把手教你分析Mysql死锁问题

发表于 2020-04-12

前言

发生死锁了,如何排查和解决呢?本文将跟你一起探讨这个问题

  • 准备好数据环境
  • 模拟死锁案发
  • 分析死锁日志
  • 分析死锁结果

环境准备

数据库隔离级别:

1
2
3
4
5
6
7
复制代码mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)

自动提交关闭:

1
2
3
4
5
6
7
8
9
10
复制代码mysql> set autocommit=0;
Query OK, 0 rows affected (0.00 sec)

mysql> select @@autocommit;
+--------------+
| @@autocommit |
+--------------+
| 0 |
+--------------+
1 row in set (0.00 sec)

表结构:

1
2
3
4
5
6
7
8
复制代码//id是自增主键,name是非唯一索引,balance普通字段
CREATE TABLE `account` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`balance` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;

表中的数据:

模拟并发

开启两个终端模拟事务并发情况,执行顺序以及实验现象如下:

1)事务A执行更新操作,更新成功

1
2
复制代码mysql> update  account  set balance =1000 where name ='Wei';
Query OK, 1 row affected (0.01 sec)

2)事务B执行更新操作,更新成功

1
2
复制代码mysql> update  account  set balance =1000 where name ='Eason';
Query OK, 1 row affected (0.01 sec)

3)事务A执行插入操作,陷入阻塞~

1
复制代码mysql> insert into account values(null,'Jay',100);

这时候可以用select * from information_schema.innodb_locks;查看锁情况:

4)事务B执行插入操作,插入成功,同时事务A的插入由阻塞变为死锁error。

1
2
复制代码mysql> insert into account values(null,'Yan',100);
Query OK, 1 row affected (0.01 sec)

锁介绍

在分析死锁日志前,先做一下锁介绍,哈哈~

主要介绍一下兼容性以及锁模式类型的锁:

共享锁与排他锁

InnoDB 实现了标准的行级锁,包括两种:共享锁(简称 s 锁)、排它锁(简称 x 锁)。

  • 共享锁(S锁):允许持锁事务读取一行。
  • 排他锁(X锁):允许持锁事务更新或者删除一行。

如果事务 T1 持有行 r 的 s 锁,那么另一个事务 T2 请求 r 的锁时,会做如下处理:

  • T2 请求 s 锁立即被允许,结果 T1 T2 都持有 r 行的 s 锁
  • T2 请求 x 锁不能被立即允许

如果 T1 持有 r 的 x 锁,那么 T2 请求 r 的 x、s 锁都不能被立即允许,T2 必须等待T1释放 x 锁才可以,因为X锁与任何的锁都不兼容。

意向锁

  • 意向共享锁( IS 锁):事务想要获得一张表中某几行的共享锁
  • 意向排他锁( IX 锁): 事务想要获得一张表中某几行的排他锁

比如:事务1在表1上加了S锁后,事务2想要更改某行记录,需要添加IX锁,由于不兼容,所以需要等待S锁释放;如果事务1在表1上加了IS锁,事务2添加的IX锁与IS锁兼容,就可以操作,这就实现了更细粒度的加锁。

InnoDB存储引擎中锁的兼容性如下表:

记录锁(Record Locks)

  • 记录锁是最简单的行锁,仅仅锁住一行。如:SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE
  • 记录锁永远都是加在索引上的,即使一个表没有索引,InnoDB也会隐式的创建一个索引,并使用这个索引实施记录锁。
  • 会阻塞其他事务对其插入、更新、删除

记录锁的事务数据(关键词:lock_mode X locks rec but not gap),记录如下:

1
2
3
4
5
6
复制代码RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t` 
trx id 10078 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000274f; asc 'O;;
2: len 7; hex b60000019d0110; asc ;;

间隙锁(Gap Locks)

  • 间隙锁是一种加在两个索引之间的锁,或者加在第一个索引之前,或最后一个索引之后的间隙。
  • 使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。
  • 间隙锁只阻止其他事务插入到间隙中,他们不阻止其他事务在同一个间隙上获得间隙锁,所以 gap x lock 和 gap s lock 有相同的作用。

间隙锁的事务数据(关键词:gap before rec),记录如下:

1
2
3
4
5
复制代码RECORD LOCKS space id 177 page no 4 n bits 80 index idx_name of table `test2`.`account` 
trx id 38049 lock_mode X locks gap before rec
Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 3; hex 576569; asc Wei;;
1: len 4; hex 80000002; asc ;;

Next-Key Locks

  • Next-key锁是记录锁和间隙锁的组合,它指的是加在某条记录以及这条记录前面间隙上的锁。

插入意向锁(Insert Intention)

  • 插入意向锁是在插入一行记录操作之前设置的一种间隙锁,这个锁释放了一种插入方式的信号,亦即多个事务在相同的索引间隙插入时如果不是插入间隙中相同的位置就不需要互相等待。
  • 假设有索引值4、7,几个不同的事务准备插入5、6,每个锁都在获得插入行的独占锁之前用插入意向锁各自锁住了4、7之间的间隙,但是不阻塞对方因为插入行不冲突。

事务数据类似于下面:

1
2
3
4
5
6
复制代码RECORD LOCKS space id 31 page no 3 n bits 72 index `PRIMARY` of table `test`.`child`
trx id 8731 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 80000066; asc f;;
1: len 6; hex 000000002215; asc " ;;
2: len 7; hex 9000000172011c; asc r ;;...

锁模式兼容矩阵(横向是已持有锁,纵向是正在请求的锁):

如何读懂死锁日志?

show engine innodb status

可以用show engine innodb status,查看最近一次死锁日志哈~,执行后,死锁日志如下:

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
复制代码2020-04-11 00:35:55 0x243c
*** (1) TRANSACTION:
TRANSACTION 38048, ACTIVE 92 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 4 row lock(s), undo log entries 2
MySQL thread id 53, OS thread handle 2300, query id 2362 localhost ::1 root update
insert into account values(null,'Jay',100)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 177 page no 4 n bits 80 index idx_name of table `test2`.`account`
trx id 38048 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 3; hex 576569; asc Wei;;
1: len 4; hex 80000002; asc ;;

*** (2) TRANSACTION:
TRANSACTION 38049, ACTIVE 72 sec inserting, thread declared inside InnoDB 5000
mysql tables in use 1, locked 1
5 lock struct(s), heap size 1136, 4 row lock(s), undo log entries 2
MySQL thread id 52, OS thread handle 9276, query id 2363 localhost ::1 root update
insert into account values(null,'Yan',100)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 177 page no 4 n bits 80 index idx_name of table `test2`.`account`
trx id 38049 lock_mode X locks gap before rec
Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 3; hex 576569; asc Wei;;
1: len 4; hex 80000002; asc ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 177 page no 4 n bits 80 index idx_name of table `test2`.`account`
trx id 38049 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

*** WE ROLL BACK TRANSACTION (1)

我们如何分析以上死锁日志呢?

第一部分

1)找到关键词TRANSACTION,事务38048

2)查看正在执行的SQL

1
复制代码insert into account values(null,'Jay',100)

3)正在等待锁释放(WAITING FOR THIS LOCK TO BE GRANTED),插入意向排他锁(lock_mode X locks gap before rec insert intention waiting),普通索引(idx_name),物理记录(PHYSICAL RECORD),间隙区间(未知,Wei);

第二部分

1)找到关键词TRANSACTION,事务38049

2)查看正在执行的SQL

1
复制代码insert into account  values(null,'Yan',100)

3)持有锁(HOLDS THE LOCK),间隙锁(lock_mode X locks gap before rec),普通索引(index idx_name),物理记录(physical record),区间(未知,Wei);

4)正在等待锁释放(waiting for this lock to be granted),插入意向锁(lock_mode X insert intention waiting),普通索引上(index idx_name),物理记录(physical record),间隙区间(未知,+∞);

5)事务1回滚(we roll back transaction 1);

查看日志结果

查看日志可得:

  • 事务A正在等待的插入意向排他锁(事务A即日志的事务1,根据insert语句来对号入座的哈),正在事务B的怀里~
  • 事务B持有间隙锁,正在等待插入意向排它锁

这里面,有些朋友可能有疑惑,

  • 事务A持有什么锁呢?日志根本看不出来。它又想拿什么样的插入意向排他锁呢?
  • 事务B拿了具体什么的间隙锁呢?它为什么也要拿插入意向锁?
  • 死锁的死循环是怎么形成的?目前日志看不出死循环构成呢?

我们接下来一小节详细分析一波,一个一个问题来~

死锁分析

死锁死循环四要素

  • 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
  • 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
  • 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

事务A持有什么锁呢?它又想拿什么样的插入意向排他锁呢?

为了方便记录,例子用W表示Wei,J表示Jay,E表示Eason哈~

我们先来分析事务A中update语句的加锁情况~

1
复制代码update  account  set balance =1000 where name ='Wei';

间隙锁:

  • Update语句会在非唯一索引的name加上左区间的间隙锁,右区间的间隙锁(因为目前表中只有name=’Wei’的一条记录,所以没有中间的间隙锁~),即(E,W) 和(W,+∞)
  • 为什么存在间隙锁?因为这是RR的数据库隔离级别,用来解决幻读问题用的~

记录锁

  • 因为name是索引,所以该update语句肯定会加上W的记录锁

Next-Key锁

  • Next-Key锁=记录锁+间隙锁,所以该update语句就有了(E,W]的 Next-Key锁

综上所述,事务A执行完update更新语句,会持有锁:

  • Next-key Lock:(E,W]
  • Gap Lock :(W,+∞)

我们再来分析一波事务A中insert语句的加锁情况

1
复制代码insert into account values(null,'Jay',100);

间隙锁:

  • 因为Jay(J在E和W之间),所以需要请求加(E,W)的间隙锁

插入意向锁(Insert Intention)

  • 插入意向锁是在插入一行记录操作之前设置的一种间隙锁,这个锁释放了一种插入方式的信号,即事务A需要插入意向锁(E,W)

因此,事务A的update语句和insert语句执行完,它是持有了 (E,W]的 Next-Key锁,(W,+∞)的Gap锁,想拿到 (E,W)的插入意向排它锁,等待的锁跟死锁日志是对上的,哈哈~

事务B拥有了什么间隙锁?它为什么也要拿插入意向锁?

同理,我们再来分析一波事务B,update语句的加锁分析:

1
复制代码update  account  set balance =1000 where name ='Eason';

间隙锁:

  • Update语句会在非唯一索引的name加上左区间的间隙锁,右区间的间隙锁(因为目前表中只有name=’Eason’的一条记录,所以没有中间的间隙锁~),即(-∞,E)和(E,W)

记录锁

  • 因为name是索引,所以该update语句肯定会加上E的记录锁

Next-Key锁

  • Next-Key锁=记录锁+间隙锁,所以该Update语句就有了(-∞,E]的 Next-Key锁

综上所述,事务B执行完update更新语句,会持有锁:

  • Next-key Lock:(-∞,E]
  • Gap Lock :(E,W)

我们再来分析一波B中insert语句的加锁情况

1
复制代码insert into account  values(null,'Yan',100);

间隙锁:

  • 因为Yan(Y在W之后),所以需要请求加(W,+∞)的间隙锁

插入意向锁(Insert Intention)

  • 插入意向锁是在插入一行记录操作之前设置的一种间隙锁,这个锁释放了一种插入方式的信号,即事务A需要插入意向锁(W,+∞)

所以,事务B的update语句和insert语句执行完,它是持有了 (-∞,E]的 Next-Key锁,(E,W)的Gap锁,想拿到 (W,+∞)的间隙锁,即插入意向排它锁,加锁情况跟死锁日志也是对上的~

死锁真相还原

接下来呢,让我们一起还原死锁真相吧哈哈

  • 事务A执行完Update Wei的语句,持有(E,W]的Next-key Lock,(W,+∞)的Gap Lock ,插入成功~
  • 事务B执行完Update Eason语句,持有(-∞,E]的 Next-Key Lock,(E,W)的Gap Lock,插入成功~
  • 事务A执行Insert Jay的语句时,因为需要(E,W)的插入意向锁,但是(E,W)在事务B怀里,所以它陷入心塞~
  • 事务B执行Insert Yan的语句时,因为需要(W,+∞) 的插入意向锁,但是(W,+∞) 在事务A怀里,所以它也陷入心塞。
  • 事务A持有(W,+∞)的Gap Lock,在等待(E,W)的插入意向锁,事务B持有(E,W)的Gap锁,在等待(W,+∞) 的插入意向锁,所以形成了死锁的闭环(Gap锁与插入意向锁会冲突的,可以看回锁介绍的锁模式兼容矩阵哈)
  • 事务A,B形成了死锁闭环后,因为Innodb的底层机制,它会让其中一个事务让出资源,另外的事务执行成功,这就是为什么你最后看到事务B插入成功了,但是事务A的插入显示了Deadlock found ~

总结

最后,遇到死锁问题,我们应该怎么分析呢?

  • 模拟死锁场景
  • show engine innodb status;查看死锁日志
  • 找出死锁SQL
  • SQL加锁分析,这个可以去官网看哈
  • 分析死锁日志(持有什么锁,等待什么锁)
  • 熟悉锁模式兼容矩阵,InnoDB存储引擎中锁的兼容性矩阵。

个人公众号

  • 觉得写得好的小伙伴给个点赞+关注啦,谢谢~
  • 如果有写得不正确的地方,麻烦指出,感激不尽。
  • 同时非常期待小伙伴们能够关注我公众号,后面慢慢推出更好的干货~嘻嘻

本文转载自: 掘金

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

Spring Boot 2X 实战--消息队列(Rocke

发表于 2020-04-12

作者:小先

源代码仓库:github.com/zhshuixian/…

在上一小节《实战 SQL 数据库(MyBatis)》中,主要介绍了 MyBatis 如何连接数据库,实现数据的增删改查等操作。这一小节,将实战 Spring Boot 整合 RocketMQ。消息中间件是现代分布式系统的重要组件,RocketMQ 是一款开源的分布式消息中间件,具有低延迟,高性能、高可用、可伸缩的消息发布与订阅服务,支持万亿级容量。

RocketMQ 是由阿里巴巴团队采用 Java 语言开发,在 2016 年的时候贡献给 Apache 基金会,是 Apache 的顶级项目。

1)RocketMQ 的安装和运行

安装和运行 RocketMQ 需要的先决条件:

  • 64 bit 的系统,推荐 Linux/Unix/Mac,Windows 系统也可以运行
  • 64 bit 的 JDK 1.8+
  • 4G 的空闲磁盘

下载 RocketMQ 4.6.1 ,打开 RocketMQ 官网 rocketmq.apache.org/release_not… ,选择二进制文件:

下载 RocketMQ
下载完成后,解压到安装目录,打开终端进入安装目录 ROCKETMQ_HOME,运行如下的命令:

设置 JVM 的最小内存和最大内存

1
2
3
复制代码# 打开 runbroker.sh 或者 runbroker.cmd(Windows)
# 根据电脑内存情况设置 JVM 最大内存和最小内存
JAVA_OPT="${JAVA_OPT} -server -Xms8g -Xmx8g -Xmn4g"

运行 Name Server

1
2
3
4
复制代码> nohup sh bin/mqnamesrv &
> tail -f ~/logs/rocketmqlogs/namesrv.log
# 如果成功的话可以看到这样的内容
The Name Server boot success. serializeType=JSON

运行 Broker

1
2
3
复制代码> nohup sh bin/mqbroker -n localhost:9876 &
> tail -f ~/logs/rocketmqlogs/broker.log
The broker[..., ...] boot success. serializeType=JSON and name server is localhost:9876

关闭 Server

1
2
复制代码 > sh bin/mqshutdown broker
> sh bin/mqshutdown namesrv

Windows 系统

1
2
3
4
5
6
7
8
9
10
复制代码# Windows 系统需要设置环境变量 %ROCKETMQ_HOME%
> cd %ROCKETMQ_HOME%\bin
> .\mqnamesrv
# 成功后会在终端看到这样子的输出
The Name Server boot success. serializeType=JSON
# 重新打开一个终端
> cd %ROCKETMQ_HOME%\bin
> .\mqbroker.cmd -n localhost:9876
The broker[..., ...] boot success. serializeType=JSON and name server is localhost:9876
# 在 Windows 关闭 Server 通过关闭终端或者 Ctrl + C 终止任务吧

2)开始使用

rocketmq-spring-boot-starter 是 Spring Boot 快速与 RocketMQ 集成的启动器(Starter),需要 Spring Boot 2.0 及更高版本。

实战 Spring Boot 整合 RocketMQ,实现写入消息(Producer)和消费消息(Consumer)。

2.1)新建项目和共同配置

这里将新建两个项目,04-rocketmq-producer 和 04-rocketmq-consumer,分别生产信息和消费信息。Spring Boot 选择 2.1.13,依赖选择 Spring Web,其除了项目名称以外,其它配置基本相同。


添加 rocketmq-spring-boot-starter:

1
2
3
复制代码// Gradle
// https://mvnrepository.com/artifact/org.apache.rocketmq/rocketmq-spring-boot-starter
compile group: 'org.apache.rocketmq', name: 'rocketmq-spring-boot-starter', version: '2.1.0'
1
2
3
4
5
6
7
复制代码<!-- Maven -->
<!-- https://mvnrepository.com/artifact/org.apache.rocketmq/rocketmq-spring-boot-starter -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>

配置 application.properties

1
2
3
4
5
6
7
8
9
10
11
12
复制代码# 04-rocketmq-producer 不需要设置 spring.main.web-application-type
# none 表示不启动 Web 容器
spring.main.web-application-type=none
spring.application.name=rocketmq-consumer
# RocketMQ Name Server (替换为 RocketMQ 的 IP 地址和端口号)
rocketmq.name-server=192.168.128.10:9876
# 兹定于 Name Server
boot.rocketmq.NameServer=192.168.128.10:9876
# 程序用使用到的属性配置 (替换为 RocketMQ 的 IP 地址和端口号)
boot.rocketmq.topic=string-topic
boot.rocketmq.topic.user=user-topic
boot.rocketmq.tag=tagA

在两个项目中新建 User 类:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public class User {
private String userName;
private Byte userAge;
// 省略 Getter Setter
@Override
public String toString() {
return "User{" +
"userName='" + userName + '\'' +
", userAge=" + userAge +
'}';
}
}

2.2)Producer 实现消息的写入

项目名称 04-rocketmq-producer 。实现从 RESTful API 接收的消息写入 RocketMQ。

新建 ProducerService.class:

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
复制代码@Service
public class ProducerService {
private Logger logger = LoggerFactory.getLogger(getClass());

@Resource
private RocketMQTemplate mqTemplate;

@Value(value = "${boot.rocketmq.topic}")
private String springTopic;

@Value(value = "${boot.rocketmq.topic.user}")
private String userTopic;

@Value(value = "${boot.rocketmq.tag}")
private String tag;

public SendResult sendString(String message) {
// 发送 String 类型的消息
// 调用 RocketMQTemplate 的 syncSend 方法
SendResult sendResult = mqTemplate.syncSend(springTopic + ":" + tag, message);
logger.info("syncSend String to topic {} sendResult={} \n", springTopic, sendResult);
return sendResult;
}

public SendResult sendUser(User user) {
// 发送 User
SendResult sendResult = mqTemplate.syncSend(userTopic, user);
logger.info("syncSend User to topic {} sendResult= {} \n", userTopic, sendResult);
return sendResult;
}
}

代码解析:

@Value(value = “${boot.rocketmq.topic}”):将 application.properties 文件中定义的 boot.rocketmq.topic 值自动注入到 springTopic 变量。

新建 RESTful API ,ProducerController.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码@RestController
@RequestMapping("/producer")
public class ProducerController {
@Resource ProducerService producerService;

@PostMapping("/string")
public SendResult sendString(@RequestBody String message){
return producerService.sendString(message);
}

@PostMapping("/user")
public SendResult sendUser(@RequestBody User user){
return producerService.sendUser(user);
}
}

2.2)Consumer 消费信息

项目名称 04-rocketmq-consumer ,实现 RocketMQ 中消息的读取与消费。注意 这个项目不需要启动 Web 容器。

StringConsumer.class 消费 String 类型的消息。

1
2
3
4
5
6
7
8
9
10
11
12
复制代码@Service
@RocketMQMessageListener(topic = "${boot.rocketmq.topic}", consumerGroup = "string_consumer", selectorExpression = "${boot.rocketmq.tag}")
public class StringConsumer implements RocketMQListener<String> {
private Logger logger = LoggerFactory.getLogger(getClass());

@Override
public void onMessage(String message) {
// 重写消息处理方法
logger.info("------- StringConsumer received:{} \n", message);
// TODO 对消息进行处理,比如写入数据
}
}

UserConsumer.class 消费 User 类型的消息

1
2
3
4
5
6
7
8
9
10
复制代码@Service
@RocketMQMessageListener(nameServer = "${boot.rocketmq.NameServer}", topic = "${boot.rocketmq.topic.user}", consumerGroup = "user_consumer")
public class UserConsumer implements RocketMQListener<User> {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public void onMessage(User user) {
logger.info("######## user_consumer received: {} ; age: {} ; name: {} \n", user,user.getUserAge(),user.getUserName());
// TODO 对消息进行处理User
}
}

代码解析:

@RocketMQMessageListener:指定监听的 topic,consumerGroup,selectorExpression等;

topic:消息主题,表示一类的消息,比如上文的 string-topic 、user-topic,topic = “string-topic” 表示值消费 string-topic 主题的消息;

consumerGroup:消费组,同一个消费组一般情况消费相同的消息;

selectorExpression*:选择 tag,selectorExpression=”tagA”,只消费 tag 为 tagA 的消息;默认 “\“,即所有的 tag;

RocketMQListener : 实现 RocketMQListener,我么只需要重写消息处理方法即可;

3)运行项目

启动 RocketMQ,分别启动 04-rocketmq-producer 和 04-rocketmq-consumer。

Producer 运行的 Web 端口是 8080,Consumer 没有启动 Web 容器。

启动 Consumer 可以看到如下日志输出:

1
2
3
4
复制代码DefaultRocketMQListenerContainer{consumerGroup='user_consumer', nameServer='192.168.128.10:9876', topic='user-topic', consumeMode=CONCURRENTLY, selectorType=TAG, selectorExpression='*', messageModel=CLUSTERING}
2020-03-16 23:11:19.636 INFO 16092 --- [ main] o.a.r.s.a.ListenerContainerConfiguration : Register the listener to container, listenerBeanName:userConsumer, containerBeanName:org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer_1
2020-03-16 23:11:19.924 INFO 16092 --- [ main] a.r.s.s.DefaultRocketMQListenerContainer : running container: DefaultRocketMQListenerContainer{consumerGroup='string_consumer', nameServer='192.168.128.10:9876', topic='string-topic', consumeMode=CONCURRENTLY, selectorType=TAG, selectorExpression='tagA', messageModel=CLUSTERING}
2020-03-16 23:11:19.924 INFO 16092 --- [ main] o.a.r.s.a.ListenerContainerConfiguration : Register the listener to container, listenerBeanName:stringConsumer, containerBeanName:org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer_2

打开 Postname,测试 String 类型的消息,访问 http://localhost:8080/producer/string

image-20200316231442381
Producer 日志输出:

1
2
复制代码2020-03-16 23:14:21.681  INFO 16776 --- [nio-8080-exec-2] org.xian.producer.ProducerService        : 
syncSend String to topic string-topic sendResult=SendResult [sendStatus=SEND_OK, msgId=0000000000000000000000000000000100001F89AB83523BF6E30000, offsetMsgId=C0A8800A00002A9F000000000003ADF2, messageQueue=MessageQueue [topic=string-topic, brokerName=master, queueId=2], queueOffset=4]

Consumer 日志输出:

1
2
复制代码2020-03-16 23:14:21.983  INFO 16092 --- [MessageThread_1] org.xian.consumer.StringConsumer         : 
------- StringConsumer received:Hello RocketMQ By Spring Boot 0

测试 User 类型的消息,访问 http://localhost:8080/producer/user

image-20200316231826149
Producer 日志输出:

1
2
复制代码2020-03-16 23:18:11.548  INFO 16776 --- [nio-8080-exec-5] org.xian.producer.ProducerService        : 
syncSend User to topic user-topic sendResult= SendResult [sendStatus=SEND_OK, msgId=0000000000000000000000000000000100001F89AB83523F79590003, offsetMsgId=C0A8800A00002A9F000000000003B11F, messageQueue=MessageQueue [topic=user-topic, brokerName=master, queueId=3], queueOffset=2]

Consumer 日志输出:

1
2
复制代码2020-03-16 23:18:11.591  INFO 16092 --- [MessageThread_1] org.xian.consumer.UserConsumer           : 
######## user_consumer received: User{userName='RocketMQ With Spring Boot', userAge=4} ; age: 4 ; name: RocketMQ With Spring Boot

参考和扩展阅读:

  • 消息队列扫盲 github.com/Snailclimb/…
  • RocketMQ-Spring github.com/apache/rock…

本章节主要介绍了如何单机运行 RocketMQ、 Spring Boot 如何整合 RocketMQ 并实现消息的产生和消费,对于如何集群运行 RocketMQ,RocketMQ 高级使用方法,这里暂不做介绍。下一章节,将开始实战 Spring 的安全框架,主要包括:

  • Spring Security
  • Spring Security 整合 JJWT 实现 Token 登录和验证
  • 整合 Shiro (Token)
  • 实现微信登录 (Token)

欢迎关注《编程技术进阶》或者小先的博客。

本文转载自: 掘金

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

一个跨专业选手的后端java实习面经 已拿阿里美团字节off

发表于 2020-04-09

说在前面

先说下自己基本情况,我是本科土木, 保研本校计算机, 保研的时候因为跨专业所以被调成了专硕, 总共两年, 所以只读了半年就要准备找工作. [跨保相关的经验, 我最后有链接] 我们实验室基本都是做的纵向课题, 并且专硕学硕的培养计划是一致的, 接触横向的机会并不多. 考虑到做算法两年时间(实际上只有一年)很难有竞争力, 并且这两年算法的内卷比较严重的情况, 我开始了自己的自学研发的道路.

在整个学习和准备找实习的过程中, 掘金和牛客真的给了我很大帮助, 所以在我上岸之后, 在学长的建议下对我自己的经历进行一个总结, 回馈大家, 大家如果有什么问题欢迎留言和小窗.

4月8号收到阿里钉钉实习的口头offer,我的春招实习基本进入尾声,面的岗位都是后端java(字节要转go),以美团字节阿里的oc收官。总体结果挺出乎我的意料的,因为我的简历上没有别人那么漂亮的java项目(两个项目一个是我毕设的车辆路径算法设计cpp,一个是做的横向课题web开发python), 也没有大厂的实习经历, 我觉得简历能看的主要就是加权和数模一等奖这些. 我自己从一月底开始琢磨实习的事情,过完大年初三开始复习准备,总体时间甚至有些仓促,而且作为一个开始准备的时候,用java写leetcode都要偶尔翻doc查api的菜鸡,我都有点意外我可以走到现在. 回顾整个过程, 我觉得一定是我做对了什么事,所以把自己的经验梳理一下,给大家作为参考。

要不要投实习

实习招聘战场上,通常有两类人,一类是已经准备的差不多的大佬,在我都不敢投大厂的时候,oc已经拿到手软。一类是啥都没准备,突然意识到秋招前我是不是该先试试水的菜鸡,比如我。

所以这里第一个问题, 要不要投实习? 实验室不放的话投实习有什么意义? 实习面试太菜会不会影响秋招?

首先是要去投, 就算没有准备好, 也要边准备边投. 实习面试的经历非常重要, 甚至对于没有准备好的同学更重要, 没有准备好通常的状态, 比如我手上没有实习经历, 没有见过大厂的真题, 没有感受过大厂面试的过程. 这种情况, 春天的实习招聘就是你的复活甲, 投不了吃亏投不了上当, 血赚不亏. 如果这个时候不投, 秋招的时候就真的裸奔上战场了, 实习0offer可怕还是秋招0offer可怕, 应该大家都有衡量.

比如我们学校很多实验室是不放实习的, 我和朋友聊的过程也经常遇到这种情况, 我们实验室也是理论上不放实习, 但我觉得实习固然重要, 对于没有实习经历的同学, 实习面试的经历也很重要, 像阿里这种来不了实习也可以直通终面的情况,真的非常友好. 因此, 即使实验室不放, 我也强烈建议要去投一投, 把面试经验沉淀一下. 如果过了, 接到oc去不了拒掉的话,好好和hr沟通,据说人家秋招还会来问你意向(不是鸽offer,鸽offer可能被拉黑)

然后面试被刷影不影响秋招, 我面过的大厂, 都是回复的有通过记录的秋招有优势, 没有通过也不会影响. 我甚至之前看到过大佬的分享, 说进阿里的童鞋有很多是有不止一次面试记录, 是否能过与能力, 部门情况, 运气, 临场表现息息相关, 过了不代表强, 不过也不能代表一无是处.

总结就是, 实习阶段的面试是很低成本的试错机会, 大家要抓住这样的稳赚不赔的机会.

面试准备

接下来说下我的面试的准备,两个月的时间边准备边面,比较仓促, 但也很适合目前还没有准备好的同学。

计算机基础,操作系统,计网和数据结构。我在面试前都把教材过了一遍,并且要总结,有的我用了我以前的笔记,有的搬了些博客和github的内容. 这部分复习花了大概15天, 期间也每天在刷算法题. 有了自己的知识点总结, 之后是一个查漏补缺的过程,把牛客上见到的题不会的,找到答案补充进去,高频的问题,拎出来答案准备好,每次面试前过一遍。参考我的github,note_md高频部分, 另外, 推荐大家可以借鉴github上cyc2018的这种总结方式, 对知识点进行梳理. 上面提到了我自己的项目, 但是做的的确不如cyc好, 链接在文末会给出.

计算机基础是大厂面试的重中之重,并且千万不要背答案, 背不完也没有用. 阿里的面试为例, 操作系统和算法的部分, 有的地方我记不住的,面试官提示一下,我给面试官展示了把完整的知识点推出来的思路, 我觉得这个过程其实比直接给答案更加分。

算法题要刷,我的顺序是两遍剑指,挑出还不会的最优解的, 再过一遍. 然后cyc2018的leetcode题库,先刷完数据结构部分,再刷算法部分。面试手撕原题命中率很高。刷题时,遇到不太熟的,比如我是dp和trie字典树不熟的,系统性的翻教材学一遍做好笔记,针对性的做点算法题巩固。算法题啊,没必要死磕,刚开始刷的时候20分钟完全没头绪很正常,看一遍答案,明天回头自己写一遍。 我剑指上都有三刷记不住最优解的,记不住的別强求。每天一个半小时起步吧,开始的时候每天花半天时间。

再是java基础,这个部分比较杂,我建议找个比较全的java知识点思维导图,掌握的勾掉,不熟的先从高频到低频准备,然后对导图上的知识一个个补洞, 准备的过程并不是找到高频题答案就行了, 最好能读一读源码(时间不够可以看源码分析的博客, 然后和自己学的操作系统/计网的知识点串一串)。学习资料和顺序,JavaSE我推荐《core java 第一卷》, 比较厚, 前六章一定要看, 后面的按需跳着看, 最好能和《java编程思想》比较着看,我觉得后者讲的更清楚。 jvm只要看最经典的《深入java虚拟机》那本,我自己对java基础的复习主要是看博客+源码,并发这部分有看《java concurrency in practice》 的中文版,但是直接看博客+源码也还行。时间不够的话不要太留恋难点,我到现在AQS都没完整读透,准备的过程要写demo,跑通过的东西记忆会深刻很多, 同时要写博客或做笔记, 以后这就是你知识体系的外存 。

然后再回到计算机基本功,问的高频题和简历延伸到东西都是比较容易准备的。 难的是开放题,没法准备只能靠自己的计算机基础去尝试给答案,不过也可能是我自己基础不扎实或者见识有限积累不够。但我的总体建议是, 联系你学过的操作系统,计网中学过的解决问题的方法进行迁移。这部分真的靠日常积累。

怎么准备简历

首先是策略和态度, 你要对自己简历的每个字负责。如果认真准备它们就是你的亮点, 否则就是面试官抓到你的漏洞。简历是你最能控住全场的部分,别的问题没法准备, 但是简历上的东西是可以提前准备的。每个字都要想面试官会拿着这个怎么问,自己按照背景,亮点,不足,可能可以改进的方向准备好回答,然后积累不同面试过程中面试官对你项目问的问题,简历相关的东西面几次就脱口而出了。

简历没有java项目怎么办,我觉得面试Java用java相关当然最好,但是我真的没有完整的好看的Java项目,这是我的短板,被阿里hr吊着打,差点挂我。但是没有的话临时凑的不如一个不是Java但是非常完整的,完整到你复盘过,和更好的比较过,最好比较完回头改进过,如果做到最后一步这个项目就比较完整了,我只做到找优秀的相似项目比较,但是也因此得到过好评。 Java项目可以反映你对这门语言和其生态的熟悉程度, 但是一个不完整的项目也会让你在面试时漏洞百出。

自己的项目,用过的框架相关的高频面试题一定要熟,源码有余力最好能读一读,读不完没关系, 从来没看过就是态度问题。 问到源码没有看过的部分, 可以说那部分没看过,但是正在看这个部分,就扯回你熟悉的领域了。我自己会暗搓搓搜一些Spring面试高频知识点,但是主要是为了应急啦,我现在不急了还是每天啃一点spring源码。

面试套路

首先说一下我最近面了这么多的一个感想,实习不一定要有好看的项目,但是基础一定要扎实,同时要带着面试官往你擅长的地方走,自己的战线不要打得太开。举个反例,一下说自己熟悉mysql,一下说自己Spring用的6,再往zk,dubbo一扯,面试官对你的期望很高,一问都没看过源码,然后很容易凉凉。

相反,开始不要给面试官太高期待,比如我,我知道我菜, 所以自我介绍的时候就说熟悉javaSE,用过mysql,redis,Spring还在学,源码这些可能不太熟。 然后面试官在你圈的范围开始问了之后,一旦问到了你精心准备的部分可以给他设个连环套,面试官可能会对你刮目相看。比如hashmap套concurrentHashMap套volatile, 他如果顺着准备的这条问,就这三条我聊源码可以说40分钟不止,类似的在mysql,redis,以及其他的JavaEE框架这些技术上也有对常问的点准备到侃侃而谈,不常问的点不至于一句都说不上来,一个面试的基本盘就搭起来了。面试官他有可能会跳出你画的圈,对我来说比如问到javaEE相关的,如果正好你也有准备,同样是回答一个知识点,面试官在不同期待值下的感受应该是有区别的。

另外,遇到不会的知识点,但是你有自己的推测的,可以说,这部分我不太熟,如果我来做可能会xxx,因为我觉得(推测原因),我下去会了解下这一部分。在我有限的尝试中,这里面试官点头的情况还蛮多的,主要是我瞎猜的准? 对于那种要么是要么不是或者你就算猜都编不出理由的,坦诚地说不好意思这部分有点糊。一方面,你需要展示出你遇到问题时,是比较主动的想解决方案的姿态,另一方面也要坦诚,但是如果你这不会那不会说的太多,估计要凉。比如我的蘑菇街,我说最近自己写demo做了一个比较简陋的web服务器,多嘴说了句参考了tomcat源码,然后被抓住问了一堆tomcat我不太熟的,就没了。

策略和心态

我是从牛客的贴意识到去面实习的重要性,也是在牛客和掘金上找的大部分实习,以及面经。看大家都有offer,我那时每天过的都非常难受,不敢刷牛客掘金,感觉人和人的差别真的太大了,又对自己没有什么信心,面了的都要么凉要么没有消息,每天晚上一两点不敢停下学习,不敢睡觉,躺床上也会怀疑人生。

我自己准备过程的心态的确不够好,非常焦虑,但是我觉得策略上还是有可取之处。面试准备上点面结合, 高频题是点, 计算机基础是面; 选择公司时, 没有面试经验的时候,适当多投一些,自己想去的公司往后排一点。再重复一遍, 过了不管一面还是两面都是有积极作用的,没过的话,不会影响秋招, 白嫖一个心仪厂的试错机会。像我这种面着面着就进去了的也是可能的,我是真的菜鸡。

btw。 选择计算机行业, 意味着在一定程度上以技术安身立命, 投机取巧一定是不牢靠的,要做好长期规划和持续集成,比如我缺的分布式相关的知识和项目练习,我也会在近期尽快补上,努力成为一个真大佬。

然后打个广告,阿里钉钉急缺简历,其他bu挂了有机会捞,java后端和前端,base杭州/北京, 球球了,我也有找简历kpi,目前还有三十多个hc, 大家走过路过,投个简历,我还可以在线卖艺帮忙改简历,还有阿里学长帮忙改和提建议,及时跟进我每轮面完最多一天就知道结果,一周走完流程,人超级好。

我上岸真的有很大一部分的运气和学长的帮助, 以及各轮面试的面试官都非常平等的和我交流技术探讨问题, 没有因为我没有好看的Java项目放弃我, 很多问题考得很活有难度但也很过瘾.

对我们感兴趣的或者求捞的, 可以私信把『姓名+电话+邮箱+院校+投递岗位』发给我,后续会给这些同学们发内推邮件,然后填邮件完成投递。也可以扫二维码投递, 记得和我私信说一下, 我这边帮你对接.

以上是我暂时想到的东西,然后摆一波资源

书单(也是我从别的大佬找来的通过我自己验证的优先看的章节, 其他章节也要慢慢看完)

  • 剑指offer
  • 深入理解Java虚拟机 第二章 第三章 第七章 第十二章 第十三章
  • mysql必知必会 + leetcode-database题目
  • 高性能mysql 第一章 第五章和第六章
  • redis设计与实现 字典 跳跃表 过期机制 持久化 事件 复制 Sentinel 集群 发布与订阅 事务

项目资源

  • 大名鼎鼎的cyc2018
  • 菜鸡本鸡的知识点整理,继续更新

java练手项目
-秒杀系统
-购物系统集合
-购物系统

这些练手项目, 如果是自己如果想不到做什么可以模仿的项目, 但是我依然建议大家自己去想一想项目, 比如模仿spring写个IoC容器, 模仿tomcat实现个小web服务器, 这些都是我这样的菜鸡的选择, 大佬绕道.

另外,我挺推荐java的一个付费课(我真的没收钱, 人家讲的不错)

  • 极客时间 - Java核心技术面试精讲

我自己博客, 我最近欠了好多草稿写了一半, dbq我太菜了
blog.csdn.net/m0_37407587…

以前写的跨保的经历 不同学校的保研政策可能有差异, 供参考 www.zhihu.com/question/65…

本文转载自: 掘金

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

在 Android 开发中使用协程 背景介绍

发表于 2020-04-08

本文是介绍 Android 协程系列中的第一部分,主要会介绍协程是如何工作的,它们主要解决什么问题。
协程用来解决什么问题?


Kotlin 中的协程提供了一种全新处理并发的方式,您可以在 Android 平台上使用它来简化异步执行的代码。协程是从 Kotlin 1.3 版本开始引入,但这一概念在编程世界诞生的黎明之际就有了,最早使用协程的编程语言可以追溯到 1967 年的 Simula 语言。

在过去几年间,协程这个概念发展势头迅猛,现已经被诸多主流编程语言采用,比如 Javascript、C#、Python、Ruby 以及 Go 等。Kotlin 的协程是基于来自其他语言的既定概念。

在 Android 平台上,协程主要用来解决两个问题:

  1. 处理耗时任务 (Long running tasks),这种任务常常会阻塞住主线程;
  2. 保证主线程安全 (Main-safety) ,即确保安全地从主线程调用任何 suspend 函数。

让我们来深入上述问题,看看该如何将协程运用到我们代码中。

处理耗时任务

获取网页内容或与远程 API 交互都会涉及到发送网络请求,从数据库里获取数据或者从磁盘中读取图片资源涉及到文件的读取操作。通常我们把这类操作归类为耗时任务 —— 应用会停下并等待它们处理完成,这会耗费大量时间。

当今手机处理代码的速度要远快于处理网络请求的速度。以 Pixel 2 为例,单个 CPU 周期耗时低于 0.0000000004 秒,这个数字很难用人类语言来表述,然而,如果将网络请求以 “眨眼间” 来表述,大概是 400 毫秒 (0.4 秒),则更容易理解 CPU 运行速度之快。仅仅是一眨眼的功夫内,或是一个速度比较慢的网络请求处理完的时间内,CPU 就已完成了超过 10 亿次的时钟周期了。

Android 中的每个应用都会运行一个主线程,它主要是用来处理 UI (比如进行界面的绘制) 和协调用户交互。如果主线程上需要处理的任务太多,应用运行会变慢,看上去就像是 “卡” 住了,这样是很影响用户体验的。所以想让应用运行上不 “卡”、做到动画能够流畅运行或者能够快速响应用户点击事件,就得让那些耗时的任务不阻塞主线程的运行。

要做到处理网络请求不会阻塞主线程,一个常用的做法就是使用回调。回调就是在之后的某段时间去执行您的回调代码,使用这种方式,请求 developer.android.google.cn 的网站数据的代码就会类似于下面这样:

1
2
3
4
5
6
7
复制代码class ViewModel: ViewModel() {
fun fetchDocs() {
get("developer.android.google.cn") { result ->
show(result)
}
}
}

在上面示例中,即使 get 是在主线程中调用的,但是它会使用另外一个线程来执行网络请求。一旦网络请求返回结果,result 可用后,回调代码就会被主线程调用。这是一个处理耗时任务的好方法,类似于 Retrofit 这样的库就是采用这种方式帮您处理网络请求,并不会阻塞主线程的执行。

使用协程来处理协程任务

使用协程可以简化您的代码来处理类似 fetchDocs 这样的耗时任务。我们先用协程的方法来重写上面的代码,以此来讲解协程是如何处理耗时任务,从而使代码更清晰简洁的。

1
2
3
4
5
6
7
8
9
10
复制代码
// Dispatchers.Main
suspend fun fetchDocs() {
// Dispatchers.Main
val result = get("developer.android.google.cn")
// Dispatchers.Main
show(result)
}
// 在接下来的章节中查看这段代码
suspend fun get(url: String) = withContext(Dispatchers.IO){/*...*/}

在上面的示例中,您可能会有很多疑问,难道它不会阻塞主线程吗?get 方法是如何做到不等待网络请求和线程阻塞而返回结果的?其实,是 Kotlin 中的协程提供了这种执行代码而不阻塞主线程的方法。

协程在常规函数的基础上新增了两项操作。在invoke (或 call) 和 return 之外,协程新增了 suspend 和 resume:

  • suspend — 也称挂起或暂停,用于暂停执行当前协程,并保存所有局部变量;
  • resume — 用于让已暂停的协程从其暂停处继续执行。

Kotlin 通过新增 suspend 关键词来实现上面这些功能。您只能够在 suspend 函数中调用另外的 suspend 函数,或者通过协程构造器 (如 launch) 来启动新的协程。

搭配使用 suspend 和 resume 来替代回调的使用。

在上面的示例中,get 仍在主线程上运行,但它会在启动网络请求之前暂停协程。当网络请求完成时,get 会恢复已暂停的协程,而不是使用回调来通知主线程。

上述动画展示了 Kotlin 如何使用 suspend 和 resume 来代替回调

观察上图中 fetchDocs 的执行,就能明白** suspend** 是如何工作的。Kotlin 使用堆栈帧来管理要运行哪个函数以及所有局部变量。暂停协程时,会复制并保存当前的堆栈帧以供稍后使用。恢复协程时,会将堆栈帧从其保存位置复制回来,然后函数再次开始运行。在上面的动画中,当主线程下所有的协程都被暂停,主线程处理屏幕绘制和点击事件时就会毫无压力。所以用上述的 suspend 和 resume 的操作来代替回调看起来十分的清爽。
当主线程下所有的协程都被暂停,主线程处理别的事件时就会毫无压力。

即使代码可能看起来像普通的顺序阻塞请求,协程也能确保网络请求避免阻塞主线程。

接下来,让我们来看一下协程是如何保证主线程安全 (main-safety),并来探讨一下调度器。

使用协程保证主线程安全

在 Kotlin 的协程中,主线程调用编写良好的 suspend 函数通常是安全的。不管那些 suspend 函数是做什么的,它们都应该允许任何线程调用它们。

但是在我们的 Android 应用中有很多的事情处理起来太慢,是不应该放在主线程上去做的,比如网络请求、解析 JSON 数据、从数据库中进行读写操作,甚至是遍历比较大的数组。这些会导致执行时间长从而让用户感觉很 “卡” 的操作都不应该放在主线程上执行。

使用 suspend 并不意味着告诉 Kotlin 要在后台线程上执行一个函数,这里要强调的是,协程会在主线程上运行。事实上,当要响应一个 UI 事件从而启动一个协程时,使用 Dispatchers.Main.immediate 是一个非常好的选择,这样的话哪怕是最终没有执行需要保证主线程安全的耗时任务,也可以在下一帧中给用户提供可用的执行结果。

协程会在主线程中运行,suspend 并不代表后台执行。

如果需要处理一个函数,且这个函数在主线程上执行太耗时,但是又要保证这个函数是主线程安全的,那么您可以让 Kotlin 协程在 Default 或 IO 调度器上执行工作。在 Kotlin 中,所有协程都必须在调度器中运行,即使它们是在主线程上运行也是如此。协程可以自行暂停,而调度器负责将其恢复。

Kotlin 提供了三个调度器,您可以使用它们来指定应在何处运行协程:

  • 如果您在 Room 中使用了 suspend 函数、RxJava 或者 LiveData,Room 会自动保障主线程安全。
  • 类似于 Retrofit 和 Volley 这样的网络库会管理它们自身所使用的线程,所以当您在 Kotlin 协程中调用这些库的代码时不需要专门来处理主线程安全这一问题。

接着前面的示例来讲,您可以使用调度器来重新定义 get 函数。在 get 的主体内,调用 withContext(Dispatchers.IO) 来创建一个在 IO 线程池中运行的块。您放在该块内的任何代码都始终通过 IO 调度器执行。由于 withContext 本身就是一个 suspend 函数,它会使用协程来保证主线程安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码
// Dispatchers.Main
suspend fun fetchDocs() {
// Dispatchers.Main
val result = get("developer.android.google.cn")
// Dispatchers.Main
show(result)
}
// Dispatchers.Main
suspend fun get(url: String) =
// Dispatchers.Main
withContext(Dispatchers.IO) {
// Dispatchers.IO
}
// Dispatchers.Main

借助协程,您可以通过精细控制来调度线程。由于 withContext 可让您在不引入回调的情况下控制任何代码行的线程池,因此您可以将其应用于非常小的函数,如从数据库中读取数据或执行网络请求。一种不错的做法是使用 withContext 来确保每个函数都是主线程安全的,这意味着,您可以从主线程调用每个函数。这样,调用方就无需再考虑应该使用哪个线程来执行函数了。

在这个示例中,fetchDocs 会在主线程中执行,不过,它可以安全地调用 get 来在后台执行网络请求。因为协程支持 suspend 和 resume,所以一旦 withContext 块完成后,主线程上的协程就会恢复继续执行。

主线程调用编写良好的 suspend 函数通常是安全的。

确保每个 suspend 函数都是主线程安全的是很有用的。如果某个任务是需要接触到磁盘、网络,甚至只是占用过多的 CPU,那应该使用 withContext 来确保可以安全地从主线程进行调用。这也是类似于 Retrofit 和 Room 这样的代码库所遵循的原则。如果您在写代码的过程中也遵循这一点,那么您的代码将会变得非常简单,并且不会将线程问题与应用逻辑混杂在一起。同时,协程在这个原则下也可以被主线程自由调用,网络请求或数据库操作代码也变得非常简洁,还能确保用户在使用应用的过程中不会觉得 “卡”。

withContext 的性能

withContext 同回调或者是提供主线程安全特性的 RxJava 相比的话,性能是差不多的。在某些情况下,甚至还可以优化 withContext 调用,让它的性能超越基于回调的等效实现。如果某个函数需要对数据库进行 10 次调用,您可以使用外部 withContext 来让 Kotlin 只切换一次线程。这样一来,即使数据库的代码库会不断调用 withContext,它也会留在同一调度器并跟随快速路径,以此来保证性能。此外,在 Dispatchers.Default 和 Dispatchers.IO 中进行切换也得到了优化,以尽可能避免了线程切换所带来的性能损失。

下一步

本篇文章介绍了使用协程来解决什么样的问题。协程是一个计算机编程语言领域比较古老的概念,但因为它们能够让网络请求的代码比较简洁,从而又开始流行起来。

在 Android 平台上,您可以使用协程来处理两个常见问题:

  1. 似于网络请求、磁盘读取甚至是较大 JSON 数据解析这样的耗时任务;
  2. 线程安全,这样可以在不增加代码复杂度和保证代码可读性的前提下做到不会阻塞主线程的执行。

接下来的文章中我们将继续探讨协程在 Android 中是如何使用的,感兴趣的读者请继续关注。

点击这里利用 Kotlin 协程提升应用性能

本文转载自: 掘金

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

Golang入门(2):一天学完GO的基本语法

发表于 2020-04-07
  • Golang入门(1):安装与配置环境变量的意义
  • Golang入门(2):一天学完GO的基本语法
  • Golang入门(3):一天学完GO的进阶语法
  • Golang入门(4):并发

摘要

在配置好环境之后,要研究的就是这个语言的语法了。在这篇文章中,作者希望可以简单的介绍一下Golang的各种语法,并与C和Java作一些简单的对比以加深记忆。因为这篇文章只是入门Golang的第二篇文章,所以本文并不会对一些指令进行深挖,仅仅只是停留在“怎么用”的程度,至于“为什么是这样”,则涉及到了具体的应用场景和汇编指令,作者将会在以后的文章中进行介绍。

1 导包

总所周知,“Hello World”是程序员的一种仪式感。

而这一行“Hello World”,一定会涉及到输入输出相关的方法。所以,如何导入包,是我们需要研究的第一步。

在C语言中,我们使用include,在Java中,我们使用了import。在Golang中也一样,我们使用import引入其他的包。在上一篇文章中,我们已经提到了对于导入的包,编译器会首先在GOROOT中寻找,随后会在项目所对应的GOPATH中寻找,最后才是在全局GOPATH中寻找,如果都无法找到,编译器将会报错。

注意,在Golang中和Java有一点很大的区别,就是在Golang中,import导入的是目录,而不是包名。而且,Golang没有强制要求包名和目录名需要一致。

下面举一些例子来说明在Golang中包名和目录的关系,先来看看目录结构:

项目目录结构

可以看出,我们在src下面设置了两个文件夹,在第二个文件夹下面设置了两个go文件。

来看看这两个文件的代码,test1.go如下:

1
2
3
4
5
复制代码package pktest

func Func1() {
println("这是第一个函数")
}

test2.go如下:

1
2
3
4
5
复制代码package pktest

func Func2() {
println("这是第二个函数")
}

然后我们再来看看testmain.go下面的内容:

1
2
3
4
5
6
7
复制代码package main

import "package1/package2"

func main() {
pktest.Func1()
}

注意到了吗,我们在调用Func1这个函数的时候,使用的是pktest,而不是我们认为的package1/package2中的package2。

按照我们在Java中的思想,我们应该是使用package2.Func1的调用方法或者说是使用test1.Func1这样的方法。

这是因为在Golang中,没有强制要求包名和目录名称一致。也就是说,在上面的例子中,我们引用路径中的文件夹名称是package2,而在这个文件夹下面的两个文件,他们的包名,却被设置成了pktest。而在Golang的引用中,我们需要填写的是源文件所在的相对路径。

也就是说,我们可以理解为,包名和路径其实是两个概念,文件名在Golang中不会被显式的引用,通常的引用格式是packageName.FunctionName。

结论如下:

  • import导入的是源文件的相对路径,而不是包名。
  • 在习惯上将包名和目录名保证一致,但这并不是强制规定(但不建议这么做,这样容易造成调用这个包的人,无法快速知道这个包的名称是什么)
  • 在代码中引用包内的成员时,使用包名而不是目录名。
  • 在一个文件夹内,只能存在一种包名,源文件的名称也没有其他的限制。
  • 如果多个文件夹下有相同名字的package,它们其实是彼此无关的package。

以上部分内容摘自于这篇文章

2 声明

看完了导包方面的内容,我们再来看看如何声明一个变量。在声明变量这一部分,和C以及Java也有较大的区别。

2.1 变量的定义

我们先定义一些变量看看:

1
2
3
4
5
复制代码var a int
var b float32
var c, d float64
e, f := 9, 10
var g = "Ricardo"

我们可以看到,在Golang中定义一个变量,需要使用var关键字,而与C或者Java不同的是,我们需要将这个变量的类型写在变量名的后面。不仅如此,在Golang中,允许我们一次性定义多个变量并同时赋值。

还有另外的一种做法,是使用:=这个符号。使用了这个符号之后,开发者不再需要写var关键字,只需要定义变量名,并在后面进行赋值即可。并且,Golang编译器会根据后面的值的类型,自动推导出变量的类型。

在变量的定义过程中,如果定义的时候就赋予了变量的初始值,是不需要再声明变量的类型的,如变量g。

注意,Golang是强类型的一种语言,所有的变量必须拥有类型,并且变量仅仅可以存储特定类型的数据。

2.2 匿名变量

标识符为_(下划线)的变量,是系统保留的匿名变量,在赋值后,会被立即释放,称之为匿名变量。其作用是变量占位符,对其变量赋值结构。通常会在批量赋值时使用。

例如,函数返回多个值,我们仅仅需要其中部分,则不需要的使用_来占位

1
2
3
4
5
6
7
8
9
10
复制代码func main() {
// 调用函数,仅仅需要第二个返回值,第一,三使用匿名变量占位
_, v, _ := getData()
fmt.Println(v)
}
// 返回两个值的函数
func getData() (int, int, int) {
// 返回3个值
return 2, 4, 8
}

如上述代码所示,如果我仅仅需要一个变量的值,就不需要去额外定义一些没有意义的变量名了,仅仅只是需要使用占位符这种“用后即焚”的匿名变量。

2.3 常量

在Golang的常量定义中,使用const关键字,并且不能使用:=标识符。

3 判断

我们在使用Java或者C的时候,写判断语句是这样的:

1
2
3
复制代码if(condition){
...
}

在Golang中,唯一的不同是不需要小括号,但是大括号还是必须的。如下:

1
2
3
4
5
6
复制代码func pow(x, n, lim float64) float64 {
if v := math.Pow(x, n); v < lim {
return v
}
return lim
}

除去不需要写小括号以外,Golang还允许在判断条件之前执行一个简单的语句,并用一个分号;隔开。

4 循环

在Golang中,只有一种循环,for循环。

和判断语句一样,在Golang中也是没有小括号的。

1
2
3
4
5
6
7
复制代码func main() {
sum := 0
for i := 0; i < 10; i++ {
sum += i
}
fmt.Println(sum)
}

此外,在循环条件中,初始化语句和后置语句是可选的,这个时候把分号去掉,for循环就变成了while循环。

1
2
3
4
5
6
7
复制代码func main() {
sum := 1
for sum < 1000 {
sum += sum
}
fmt.Println(sum)
}

不仅如此,如果省略循环条件,该循环就不会结束,因此无限循环可以写得很紧凑,这个时候,和while(true)的效果是一样的。

1
2
3
4
5
复制代码func main() {
for {
...
}
}

5 函数

5.1 函数的定义

在Golang的函数定义中,所有的函数都以func开头,并且Golang命名推荐使用驼峰命名法。

注意,在Golang的函数中,如果首字母是小写,则只能在包内使用;如果首字母是大写,则可以在包外被引入使用。可以理解为,使用小写的函数,是private的,使用大写的函数,是public的。

在Golang的函数定义中,一样可以不接受参数,或者接受多个参数。而在参数的定义过程中,也是按照定义变量的格式,先定义变量名,再声明变量类型。对于函数的返回类型,也是按照这样的格式,先写函数名,再写返回类型:

1
2
3
4
5
6
7
复制代码func add(x int, y int) int {
return x + y
}

func main() {
fmt.Println(add(42, 13))
}

并且,对于相同类型的两个参数,参数类型可以只写一个,用法如下:

1
2
3
复制代码func add(x, y int) int {
return x + y
}

在Golang中,对于函数的返回值,和C以及Java是不一样的。

Golang中的函数可以返回任意多个返回值。

例如下面的小李子,

1
2
3
4
5
6
7
8
复制代码func swap(x, y string) (string, string) {
return y, x
}

func main() {
a, b := swap("hello", "world")
fmt.Println(a, b)
}

其次,函数的返回值是可以被命名的:

1
2
3
4
5
复制代码func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return
}

在这里,我们可以理解为在函数的顶部预先定义了这些变量值,而空的return语句则默认返回所有已经定义的返回变量。

5.2defer

在Golang中,有一个关键字叫defer。

defer 语句会将函数推迟到外层函数返回之后执行。
推迟调用的函数其参数会立即求值,但直到外层函数返回前该函数都不会被调用。

1
2
3
4
5
复制代码func main() {
defer fmt.Println("world")

fmt.Println("hello")
}

在这段代码中,本来的执行路径是从上往下,也就是先输出“world”,然后再输出“hello”。但是因为defer这个关键字的存在,这行语句将在最后才执行,所以产生了先打印“hello”然后再打印“world”的效果。

注意,defer后面必须是函数调用语句,不能是其他语句,否则编译器会报错。

可以考虑到的场景是,文件的关闭,或数据库连接的释放等,这样打开和关闭的代码写在一起,既可以使得代码更加的整洁,也可以防止出现开发者在写了长长的业务代码后,忘记关闭的情况。

至于defer的底层实现,本文不进行详细的解释,简单来讲就是将defer语句后面的函数调用的地址压进一个栈中,在当前的函数执行完毕,CPU即将执行函数外的下一行代码之前,先把栈中的指令地址弹出给CPU执行,直到栈为空,才结束这个函数,继续执行后面的代码。

从上文刚刚的表述中也可以推断出,如果有多条refer语句,将会从下往上依次执行。

因为本文只是对各种指令简单的进行对比,所以对于refer的详细解释,将在以后的文章中详细说明。

6 指针

对于指针,如果是C或者C++开发者,一定很熟悉;而对于Java开发者,指针是对开发者透明的一个东西,一个对象会在堆中占据一定的内存空间,而在当前的栈桢中,有一个局部变量,他的值就是那个对象的首地址,这也是一个指针。

可以说,指针就是开发者访问内存的一种途径,只不过是由控制权交给了开发者还是虚拟机。

在Golang中,指针的用法和 C 是一样的。同样是用&取地址,用*取地址中的值。

但是,与 C 不同,Golang没有指针运算。

7 数组

在Golang中,数组的定义是这样的:

1
复制代码var a [10]int

这样做会将变量 a 声明为拥有 10 个整数的数组。

注意,在Golang中,数组的大小也同样和 C 语言一样不能改变。

7.1切片

数组的切片,顾名思义,就是将一个数组按需切出自己所需的部分。

每个数组的大小都是固定的。而切片则为数组元素提供动态大小的、灵活的视角。在实践中,切片比数组更常用。

切片通过两个下标来界定,即一个上界和一个下界,二者以冒号分隔:

1
复制代码a[low : high]

它会选择一个半开区间,包括第一个元素,但排除最后一个元素。

以下表达式创建了一个切片,它包含 a 中下标从 1 到 3 的元素:

1
复制代码a[1:4]

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码func main() {
str := [4]string{
"aaa",
"bbb",
"ccc",
"ddd",
}
fmt.Println(str)

a := str[0:2]
b := str[1:3]
fmt.Println(a, b)

b[0] = "XXX"
fmt.Println(a, b)
fmt.Println(str)
}

我们定义了一个数组,里面含有”aaa”,”bbb”,”ccc”,”ddd”四个元素。然后我们定义了两个切片,a和b,根据定义可以知道,a为”aaa”和”bbb”,b为”bbb”和”ccc”。

这个时候,我们把b[0]改成了”XXX”,那么b变成了”XXX”和”ccc”,这是毋庸置疑的。但是与直觉相违背的是,这个时候的数组str,也变成了”aaa”,”XXX”,”ccc”,”ddd”。

这是因为,Golang中的切片,不是拷贝,而是定义了新的指针,指向了原来数组所在的内存空间。所以,修改了切片数组的值,也就相应的修改了原数组的值了。

此外,切片可以用append增加元素。但是,如果此时底层数组容量不够,此时切片将会指向一个重新分配空间后进行拷贝的数组。

因此可以得出结论:

  • 切片并不存储任何数据,它只是描述了底层数组中的一段。
  • 更改切片的元素会修改其底层数组中对应的元素。
  • 与它共享底层数组的切片都会观测到这些修改。

7.2 make

切片可以用内建函数 make 来创建,这也是你创建动态数组的方式。

1
2
3
4
复制代码在此之前需要解释两个定义,len(长度)和cap(容量)。
len是数组的长度,指的是这个数组在定义的时候,所约定的长度。
cap是数组的容量,指的是底层数组的长度,也可以说是原数组在内存中的长度。
在前文中所提到的切片,如果我定义了一个str[0,0]的切片,此时的长度为0,但是容量依旧还是5。

make 函数会分配一个元素为零值的数组并返回一个引用了它的切片:

1
复制代码a := make([]int, 5)  // len(a)=5

要指定它的容量,需向 make 传入第三个参数:

1
2
3
4
复制代码b := make([]int, 0, 5) // len(b)=0, cap(b)=5

b = b[:cap(b)] // len(b)=5, cap(b)=5
b = b[1:] // len(b)=4, cap(b)=4

也就是说,make函数可以自定义切片的大小。用Java的话来说,他可以被重载。

有两种形式,如果只有两个参数,第一个参数是数组内元素的类型,第二个参数是数组的长度(此时长度和容量都为5)。

而如果有第三个参数,那么第三个参数可以指定数组的容量,即可以指定这个数组在内存中分配多大的空间。

写在最后

首先,谢谢你能看到这里。

如果这篇文章对你能起到哪怕一点点的帮助,作者都会很开心!

其次要说明的是,作者也是刚开始接触Golang,写这篇文章的目的是起到一个笔记的效果,能够去比较一些C,Java,Golang中的语法区别,也一定会有不少的认知错误。如果在这篇文章中你看到了任何与你的认识有差距的地方,请一定指出作者的错误。如果本文有哪些地方是作者讲的不够明白的,或者是你不理解的,也同样欢迎留言,一起交流学习进步。

而且在本文中,很多地方没有进行深入挖掘,这些作者都有记录,并且打算在之后的文章中,也会从源码的角度出发,分析这些原因。在这篇文章中,就只是单纯的学会怎么用,就达到目的了。

那么在最后,再次感谢~

PS:如果有其他的问题,也可以在公众号找到作者。并且,所有文章第一时间会在公众号更新,欢迎来找作者玩~

本文转载自: 掘金

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

PostConstruct注解,你该好好看看

发表于 2020-04-07

在最近的工作中,get到一个很实用的注解,分享给诸位。

痛点

做过微信或支付宝支付的童鞋,可能遇到过这种问题,就是填写支付结果回调,就是在支付成功之后,支付宝要根据我们给的地址给我们进行通知,通知我们用户是否支付成功,如果成功我们就要去处理下面相应的业务逻辑,如果在测试服务,那么这个回调地址我们就需要填写测试服务的,如果发布到线上那么我们就需要改成线上的地址。

针对上面的场景,我们一般都会通过如下的方式,进行一个动态配置,不需要每次去改,防止出现问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码public class PayTest {

@Value("${spring.profiles.active}")
private String environment;

public Object notify(HttpServletRequest request) {

if ("prod".equals(environment)) {
// 正式环境
} else if ("test".equals(environment)) {

// 测试环境
}
return "SUCCESS";
}
}

上面的代码看起来没有一点问题,但是身为搬砖的我们咋可能这样搬,姿势不对呀!

问题:

扩展性太差,如果这个参数我们还需要在别的地方用到,那么我们是不是还要使用@Value的注解获取一遍,假如有天我们的leader突然说吗,test这个单词看着太low了,换个高端一点的,换成dev,那么我们是不是要把项目中所有的test都要改过来,如果少还好,要是很多,那我们怕不是凉了。

所以我们能不能将这些配置参数搞成一个全局的静态变量,这样的话我们直接饮用就好了,哪怕到时候真的要改,那我也只需要改动一处就好了。

注意大坑

有的朋友可能就比较自信了,那我直接加个static修饰下不就好了,如果你真是打算这样做,那你就准备卷好铺盖走人吧。直接加static获取到的值其实是一个null,至于原因,大家复习下类以及静态变量变量的加载顺序。

@PostConstruct注解

那么既然说出了问题,肯定就有解决方法,不然你以为我跟你玩呢。

首先这个注解是由Java提供的,它用来修饰一个非静态的void方法。它会在服务器加载Servlet的时候运行,并且只运行一次。

改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码@Component
public class SystemConstant {

public static String surroundings;

@Value("${spring.profiles.active}")
public String environment;

@PostConstruct
public void initialize() {
System.out.println("初始化环境...");
surroundings = this.environment;
}
}

结果:

我们可以看到在项目启动的时候进行了初始化

到这里我们已经可以拿到当前运行的环境是测试还是正式,这样就可以做到动态配置

最后想说

其实这个注解远不止这点用处,像我之前写的Redis工具类,我使用的是RedisTemplate操作Redis,导致写出来的方法没办法用static修饰,每次使用Redis工具类只能先注入到容器然后再调用,使用了这个注解就可以完美的解决这种尴尬的问题。代码如下。

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
复制代码import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

/**
* @ClassName RedisUtil
* @Description TODO
* @Auther bingfeng
* @Date 2019/7/4/004 17:14
* @Version 1.0
*/
@Component
public class RedisUtil {

private static RedisTemplate<Object, Object> redisTemplates;

@Autowired
private RedisTemplate<Object, Object> redisTemplate;

@PostConstruct
public void initialize() {
redisTemplates = this.redisTemplate;
}

/**
* 添加元素
*
* @param key
* @param value
*/
public static void set(Object key, Object value) {

if (key == null || value == null) {
return;
}
redisTemplates.opsForValue().set(key, value);
}
}

本文转载自: 掘金

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

1…821822823…956

开发者博客

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