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

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


  • 首页

  • 归档

  • 搜索

Jetpack 成员 Paging3 数据库实践以及源码分析

发表于 2020-06-17

前言

前几天 Google 更新了几个 Jetpack 新成员 Hilt、Paging 3、App Startup 等等,在之前的文章里面分了 App Startup 是什么、App Startup 为我们解决了什么问题,如果之前没有看过可以点击下面连接前往查看文章和代码。

  • Jetpack 最新成员 AndroidX App Startup 实践以及原理分析
  • AppStartup 代码地址:https://github.com/hi-dhl/AndroidX-Jetpack-Practice

今天这边文章主要来分析 Paging3,Paging3 会分为三篇文章,详细的分析其原理,每篇文章都有完整的项目示例。

  • Jetpack 成员 Paging3 数据库实践以及源码分析(一)
  • Jetpack 成员 Paging3 网络实践及原理分析(二)
  • Jetpack 成员 Paging3 使用 RemoteMediator 实现加载网络分页数据并更新到数据库中(三)

通过这篇文章你将学习到以下内容:

  • Paging3 是什么?
  • Paging3 在项目中的架构以及类的职能源码分析?
  • 如何在项目中正确使用 Paging3?
  • 数据映射(Data Mapper)是什么?
  • Kotlin Flow 是什么?

在分析之前我们先来了解一下本文实战项目中用到的技术:

  • 使用 Koin 作为依赖注入,可以看我之前写的篇文章:[译][2.4K Star] 放弃 Dagger 拥抱 Koin。
  • 使用 Composing builds 作为依赖库的版本管理,可以看我之前写篇文章:再见吧 buildSrc, 拥抱 Composing builds 提升 Android 编译速度。
  • JDataBinding 是我基于 DataBinding 封装的库,可以看我之前写篇文章:项目中封装 Kotlin + Android Databinding。
  • 数据映射(Data Mapper): 将数据源的实体,转换为上层用到的 model,在项目中起到了很大重要,我看了很多项目的,这个概念很少被提及到,看国外的大牛的写的文章时,它们提及到了这个概念,后面会对它详细的分析。
  • 项目中用到了一些 Kotlin 技巧,可以查看我另外一篇文章:为数不多的人知道的 Kotlin 技巧以及 原理解析。
  • 还有 Paging 3、Room、Anko、Repository 设计模式、MVVM 架构等等。

Paging3 是什么?

Paging 是一个分页库,它可以帮助您从本地存储或通过网络加载显示数据。这种方法使你的 App 更有效地使用网络带宽和系统资源。

Paging3 是使用 Kotlin 协程完全重写的库,经历了从 Paging1x 到 Paging2x 在到现在的 Paging3,深刻领悟到 Paging3 比 Paging1 和 Paging2 真的方便了很多。

Google 推荐使用 Paging 作为 App 架构的一部分,它可以很方便的和 Jetpack 组件集成,Paging3 包含了以下功能:

  • 在内存中缓存分页数据,确保您的 App 在使用分页数据时有效地使用系统资源。
  • 内置删除重复数据的请求,确保您的 App 有效地使用网络带宽和系统资源。
  • 可配置 RecyclerView 的 adapters,当用户滚动到加载数据的末尾时自动请求数据。
  • 支持 Kotlin 协程和 Flow, 以及 LiveData 和 RxJava。
  • 内置的错误处理支持,包括刷新和重试等功能。

Paging3 的架构以及类的职能源码分析

Google 推荐我们使用 Paging3 时,在应用程序的三层中操作,以及它们如何协同工作加载和显示分页数据,如下图所示:

但是我个人认为应该在增加一层 Data Mapper (下面会有详细的介绍),如下图所示:

数据映射(Data Mapper)将数据源的实体,转换为上层用到的 model,往往会被我们忽略掉,但是在项目中起到了很大重要,我看了很多项目的,这个概念很少被提及到,我只在国外的大牛的写的文章中,它们提及到了这个概念。关于数据映射(Data Mapper) 后面会单独写一篇文章,配合 Demo 去验证,这里只是简单提及一下。

Data Mapper

在一个快速开发的项目中,为了越快完成第一个版本交付,下意识的将数据源和 UI 绑定到一起,当业务逐渐增多,数据源变化了,上层也要一起变化,导致后期的重构工作量很大,核心的原因耦合性太强了。

使用数据映射(Data Mapper)优点如下:

  • 数据源的更改不会影响上层的业务。
  • 糟糕的后端实现不会影响上层的业务 (想象一下,如果你被迫执行2个网络请求,因为后端不能在一个请求中提供你需要的所有信息,你会让这个问题影响你的整个代码吗)。
  • Data Mapper 便于做单元测试,确保不会因为数据源的变化,而影响上层的业务。
  • 在本文案例项目 Paging3Simple 中会用到 Data Mapper 作为数据映射,在代码中有详细的注释。

Repository layer

在 Repository layer 中的主要使用 Paging3 组件中的 PagingSource,每个 PagingSource 对象定义一个数据源以及如何从该数据源查找数据, PagingSource 对象可以从任何一个数据源加载数据,包括网络数据和本地数据。

PagingSource 是一个抽象类,其中有两个重要的方法 load 和 和 getRefreshKey,load 方法如下所示:

1
kotlin复制代码abstract suspend fun load(params: LoadParams<Key>): LoadResult<Key, Value>

这是一个挂起函数,实现这个方法来触发异步加载,另外一个 getRefreshKey 方法

1
kotlin复制代码open fun getRefreshKey(state: PagingState<Key, Value>): Key? = null

该方法只在初始加载成功且加载页面的列表不为空的情况下被调用。

在这一层中还有另外一个 Paging3 的组件 RemoteMediator,RemoteMediator 对象处理来自分层数据源的分页,例如具有本地数据库缓存的网络数据源。

ViewModel layer

在 ViewModel layer 层主要用到了 Paging3 的组件 Pager,Pager 是主要的入口页面,在其构造方法中接受 PagingConfig、initialKey、remoteMediator、pagingSourceFactory,代码如下所示:

1
2
3
4
5
6
7
8
less复制代码class Pager<Key : Any, Value : Any>
@JvmOverloads constructor(
config: PagingConfig,
initialKey: Key? = null,
@OptIn(ExperimentalPagingApi::class)
remoteMediator: RemoteMediator<Key, Value>? = null,
pagingSourceFactory: () -> PagingSource<Key, Value>
)

今天这篇文章和项目主要用到了 PagingConfig 和 PagingSource,PagingSource 上面已经说过了,所以我们主要来分一下 PagingConfig。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ini复制代码val pagingConfig = PagingConfig(
// 每页显示的数据的大小
pageSize = 60,

// 开启占位符
enablePlaceholders = true,

// 预刷新的距离,距离最后一个 item 多远时加载数据
prefetchDistance = 3,

/**
* 初始化加载数量,默认为 pageSize * 3
*
* internal const val DEFAULT_INITIAL_PAGE_MULTIPLIER = 3
* val initialLoadSize: Int = pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER
*/
initialLoadSize = 60,

/**
* 一次应在内存中保存的最大数据
* 这个数字将会触发,滑动加载更多的数据
*/
maxSize = 200
)

将 ViewModel 层连接到 UI 层用到了 Paging3 的组件 PagingData,PagingData 对象是分页数据的容器,它查询一个 PagingSource 对象并存储结果。

Google 推荐我们将组件 Pager 放到 ViewModel layer,但是我更喜欢放到 Repository layer,详见下文。

UI layer

在 UI layer 中的主要到了 Paging3 的组件 PagingDataAdapter,PagingDataAdapter 是一个处理分页数据的可回收视图适配器,您可以使用 AsyncPagingDataDiffer 组件来构建自己的自定义适配器,本文中用到是 PagingDataAdapter。

Paging 3 如何在项目中使用

在 App 模块中的 build.gradle 文件中添加以下代码:

1
2
3
4
5
bash复制代码dependencies {
def paging_version = "3.0.0-alpha01"

implementation "androidx.paging:paging-runtime:$paging_version"
}

接下来我将按照上面说的每层去实现,首先我们先来看一下项目的结构。

  • bean: 存放上层需要的 model,会和 RecyclerView 的 Adapter 绑定在一起。
  • loca: 存放和本地数据库相关的操作。
  • mapper: 数据映射,主要将数据源的实体 转成上层的 model。
  • repository:主要来处理和数据源相关的操作(本地、网络、内存中缓存等等)。
  • di: 和依赖注入相关。
  • ui:数据的展示。

数据库部分

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码@Dao
interface PersonDao {

@Query("SELECT * FROM PersonEntity order by updateTime desc")
fun queryAllData(): PagingSource<Int, PersonEntity>

@Insert
fun insert(personEntity: List<PersonEntity>)

@Delete
fun delete(personEntity: PersonEntity)
}

关于 Dao 这里需要解释一下, queryAllData 方法返回了一个 PagingSource,后面会通过 Pager 转换成 flow<PagingData<Value>>。

Repository 部分

通过 Koin 注入 RepositoryFactory,通过 RepositoryFactory 管理相关的 Repository,RepositoryFactory 代码如下:

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
ini复制代码class RepositoryFactory(val appDataBase: AppDataBase) {
// 传递 PagingConfig 和 Data Mapper
fun makeLocalRepository(): Repository =
PersonRepositoryImpl(appDataBase, pagingConfig,Person2PersonEntityMapper(), PersonEntity2PersonMapper())

val pagingConfig = PagingConfig(
// 每页显示的数据的大小
pageSize = 60,

// 开启占位符
enablePlaceholders = true,

// 预刷新的距离,距离最后一个 item 多远时加载数据
prefetchDistance = 3,

/**
* 初始化加载数量,默认为 pageSize * 3
*
* internal const val DEFAULT_INITIAL_PAGE_MULTIPLIER = 3
* val initialLoadSize: Int = pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER
*/
initialLoadSize = 60,

/**
* 一次应在内存中保存的最大数据
* 这个数字将会触发,滑动加载更多的数据
*/
maxSize = 200
)

}

这里主要是生成 PagingConfig 和 Data Mapper 然后传递给 PersonRepositoryImpl,我们来看一下 PersonRepositoryImpl 相关代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码class PersonRepositoryImpl(
val db: AppDataBase,
val pageConfig: PagingConfig,
val mapper2PersonEntity: Mapper<Person, PersonEntity>,
val mapper2Person: Mapper<PersonEntity, Person>
) : Repository {

private val mPersonDao by lazy { db.personDao() }

override fun postOfData(): Flow<PagingData<Person>> {
return Pager(pageConfig) {
// 加载数据库的数据
mPersonDao.queryAllData()
}.flow.map { pagingData ->

// 数据映射,数据库实体 PersonEntity ——> 上层用到的实体 Person
pagingData.map { mapper2Person.map(it) }
}
}
}

Pager 是主要的入口页面,在其构造方法中接受 PagingConfig、pagingSourceFactory。

1
vbnet复制代码pagingSourceFactory: () -> PagingSource<Key, Value>

pagingSourceFactory 是一个 lambda 表达式,在 Kotlin 中可以直接用花括号表示,在花括号内,执行加载数据库的数据的请求。

最后调用 flow 返回 Flow<PagingData<Value>>,然后通过 Flow 的 map 将数据库实体 PersonEntity 转换成上层用到的实体 Person。

Flow 库是在 Kotlin Coroutines 1.3.2 发布之后新增的库,也叫做异步流,类似 RxJava 的 Observable,本文主要用到了 Flow 当中的 map 方法进行数据转换,简单实例如下所示:

1
2
3
4
5
6
7
scss复制代码flow{
for (i in 1..4) {
emit(i)
}
}.map {
it * it
}

到这里我们在回过去看,项目中 pagingData.map { mapper2Person.map(it) } 这行代码,其中 mapper2Person 是我们自己实现的 Data Mapper,代码如下所示:

1
2
3
kotlin复制代码class PersonEntity2PersonMapper : Mapper<PersonEntity, Person> {
override fun map(input: PersonEntity): Person = Person(input.id, input.name, input.updateTime)
}

数据库实体 PersonEntity 转换为 上层用到的实体 Person。

UI 部分

通过 koin 依赖注入 MainViewModel,并传递参数 Repository。

1
2
3
4
5
css复制代码class MainViewModel(val repository: Repository) : ViewModel() {

// 调用 Flow 的 asLiveData 方法转为 LiveData
val pageDataLiveData3: LiveData<PagingData<Person>> = repository.postOfData().asLiveData()
}

在 Activity 当中注册 observe,并将数据绑定给 Adapter,如下所示:

1
2
3
kotlin复制代码mMainViewModel.pageDataLiveData3.observe(this, Observer { data ->
mAdapter.submitData(lifecycle, data)
})

知识扩充

刚才我们调用了 asLiveData 方法转为 LiveData,其实还有两种方法(作为了解即可)。

方法一

在 LifeCycle 2.2.0 之前使用的方法,使用两个 LiveData,一个是可变的,一个是不可变的,如下所示:

1
2
3
4
5
6
7
8
swift复制代码// 私有的 MutableLiveData 可变的,对内访问
private val _pageDataLiveData: MutableLiveData<Flow<PagingData<Person>>>
by lazy { MutableLiveData<Flow<PagingData<Person>>>() }

// 对外暴露不可变的 LiveData,只能查询
val pageDataLiveData: LiveData<Flow<PagingData<Person>>> = _pageDataLiveData

_pageDataLiveData.postValue(repository.postOfData())
  • 准备一私有的 MutableLiveData,只对内访问。
  • 对外暴露不可变的 LiveData。
  • 将值赋值给 _pageDataLiveData。

方法二

在 LifeCycle 2.2.0 之后,可以用更精简的方法来完成,使用 LiveData 协程构造方法 (coroutine builder)。

1
2
3
ini复制代码val pageDataLiveData2 = liveData {
emit(repository.postOfData())
}

liveData 协程构造方法提供了一个协程代码块,产生的是一个不可变的 LiveData,emit() 方法则用来更新 LiveData 的数据。

最后添加左右滑动删除功能

调用 recyclerview 封装好的 ItemTouchHelper 实现 左右滑动删除 item 功能。

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复制代码private fun initSwipeToDelete() {

/**
* 位于 [androidx.recyclerview.widget] 包下,已经封装好的控件
*/
ItemTouchHelper(object : ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int =
makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT)

override fun onMove(
recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean = false

override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
(viewHolder as PersonViewHolder).mBinding.person?.let {
// 当 item 左滑 或者 右滑 的时候删除 item
mMainViewModel.remove(it)
}
}
}).attachToRecyclerView(rvList)
}

关于 Paging 加载本地数据到这里就结束了,我们将在下一篇文章讲解如何加载网络数据,最后上一个效果图。

wca41-qu1r

总结

这篇文章主要介绍了以下内容:

Paging3 是什么以及它的优点

Paging 是一个分页库,它可以帮助您从本地存储或通过网络加载和显示数据。这种方法使你的 App 更有效地使用网络带宽和系统资源,而 Paging3 是使用 Kotlin 协程完全重写的库:

  • 在内存中缓存分页数据,确保您的 App 在使用分页数据时有效地使用系统资源。
  • 内置删除重复数据的请求,确保您的 App 有效地使用网络带宽和系统资源。
  • 可配置 RecyclerView 的 adapters,当用户滚动到加载数据的末尾时自动请求数据。
  • 支持 Kotlin 协程和 Flow, 以及 LiveData 和 RxJava。
  • 内置的错误处理支持,包括刷新和重试功能。

Paging3 的架构以及类的职能源码分析

  • PagingSource:每个 PagingSource 对象定义一个数据源以及如何从该数据源查找数据。
  • RemoteMediator:RemoteMediator 对象处理来自分层数据源的分页,例如具有本地数据库缓存的网络数据源。
  • Pager:是主要的入口页面,在其构造方法中接受 PagingConfig、initialKey、remoteMediator、pagingSourceFactory。
  • PagingDataAdapter:是一个处理分页数据的可回收视图适配器,您可以使用 AsyncPagingDataDiffer 组件来构建自己的自定义适配器。

数据映射(Data Mapper)

数据映射(Data Mapper)将数据源的实体,转换为上层用到的 model,往往会被我们忽略掉的,但是在项目中起到了很大重要,使用 数据映射(Data Mapper)优点如下:

  • 数据源的更改不会影响上层的业务。
  • 糟糕的后端实现不会影响上层的业务 (想象一下,如果你被迫执行2个网络请求,因为后端不能在一个请求中提供你需要的所有信息,你会让这个问题影响你的整个代码吗)。
  • Data Mapper 便于做单元测试,确保不会因为数据源的变化,而影响上层的业务。
  • 在本文案例项目 Paging3Simple 中会用到 Data Mapper 作为数据映射。

Kotlin Flow

Flow 库是在 Kotlin Coroutines 1.3.2 发布之后新增的库,也叫做异步流,类似 RxJava 的 Observable,本文主要用到了 flow 当中的 map 方法进行数据转换,如下面的例子所示:

1
2
3
4
5
6
7
scss复制代码flow{
for (i in 1..4) {
emit(i)
}
}.map {
it * it
}

到这里我相信应该理解了,项目中 pagingData.map { mapper2Person.map(it) } 这行代码的意思了。

GitHub 地址:https://github.com/hi-dhl/AndroidX-Jetpack-Practice

正在建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请仓库右上角帮我点个赞。

结语

致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,可以关注我,如果这篇文章对你有帮助给个 star,正在努力写出更好的文章,一起来学习,期待与你一起成长。

算法

由于 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 中的使用
  • 0xA06 Android 10 源码分析:WindowManager 视图绑定以及体系结构
  • 0xA07 Android 10 源码分析:Window 的类型 以及 三维视图层级分析
  • 更多……

Android 应用系列

  • 如何高效获取视频截图
  • 如何在项目中封装 Kotlin + Android Databinding
  • 再见吧 buildSrc, 拥抱 Composing builds 提升 Android 编译速度
  • 为数不多的人知道的 Kotlin 技巧以及 原理解析
  • Jetpack 最新成员 AndroidX App Startup 实践以及原理分析

精选译文

目前正在整理和翻译一系列精选国外的技术文章,不仅仅是翻译,很多优秀的英文技术文章提供了很好思路和方法,每篇文章都会有译者思考部分,对原文的更加深入的解读,可以关注我 GitHub 上的 Technical-Article-Translation,文章都会同步到这个仓库。

  • [译][Google工程师] 刚刚发布了 Fragment 的新特性 “Fragment 间传递数据的新方式” 以及源码分析
  • [译][Google工程师] 详解 FragmentFactory 如何优雅使用 Koin 以及部分源码分析
  • [译][2.4K Start] 放弃 Dagger 拥抱 Koin
  • [译][5k+] Kotlin 的性能优化那些事
  • [译] 解密 RxJava 的异常处理机制
  • [译][1.4K+ Star] Kotlin 新秀 Coil VS Glide and Picasso
  • 更多……

工具系列

  • 为数不多的人知道的 AndroidStudio 快捷键(一)
  • 为数不多的人知道的 AndroidStudio 快捷键(二)
  • 关于 adb 命令你所需要知道的
  • 10分钟入门 Shell 脚本编程

逆向系列

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

本文转载自: 掘金

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

深入理解MySQL索引 深入浅出MySQL索引

发表于 2020-06-16

深入浅出MySQL索引

1、索引的基本概念

索引是数据库中一个很重要的概念,那么什么是索引呢,通俗的讲,索引是存储引擎用于快速找到记录的一种数据结构,就如同书的目录,当要查找某一行记录时,可以在索引中快速定位所在的位置信息,然后就可直接获取目标行的记录。

既然索引的出现是为了提高查找效率,那么肯定会存在不同的索引结构(模型),不同的索引模型肯定有其适应的场景,在下面文章中,我们将重点讲解常见的索引模型及其特点。

2、常见的索引模型

用于提高读写效率的数据结构有很多,我们常见的有哈希表、数组和搜索树。

2.1哈希索引

哈希表是以键值对(key-value)存储的数据结构,根据key查找value,只有Memory引擎支持哈希索引,它根据哈希函数将列值key转换成实际物理位置,然后将value存放在该数组中。

但是,多个key经过可以计算后可能会出现同一个位置的情况,这种情况被称为哈希冲突,我们可以用链地址法来解决,其思路是在有冲突的数组索引位置拉出一个链表

哈希索引使用的是散列算法,所以存储的位置很分散,因此该索引适用于等值查询的场景中,不适用于范围查询。

2.2 有序数组索引

数组索引的思路很简单,它可以根据索引的位置快速找到存储的元素,又因为数组是有序的,所以该索引在范围查询中效果较好。

如果仅仅是查询,采用有序数组的索引肯定是非常合适的,但是当向数组中插入数据时,就需要在插入的位置后的元素都向后移,效率很低,因此有序数组索引仅仅适用于查询操作。

2.3 树索引

树也是一种数据结构,它综合数组和链表的特点,在保证检索速度的基础上,同时也保证了数据的插入、删除和修改的速度。

我们以二叉搜索树为例,我们看一下二叉搜索树结构

二叉所搜树定义了在非叶子结点中,左子结点的值小于结点值,右子结点的值大于等于结点值,这样的二叉树称为二叉排序树。加入我们要查找2,它走的路径为7–>3–>1–>2,查询的时间复杂度为O(log(N)),但是这个时间复杂度依赖于这棵树为平衡二叉树。

树是可以存在多叉的,即多叉树中的每个结点存在多个子结点,子结点的数据大小要保障从左到右是递增的。二叉树是搜索效率最高的,但是在数据库中并不适用二叉树,因为索引不仅存在内存中,还要写到磁盘中,为了让一个查询尽可能减少读磁盘次数,就必须让查询访问尽量少的数据块,因此使用最多的是多叉树。

3、InnoDB索引模型分析

通过上面的内容,了解了多叉树由于读写性能的优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。实际上,InnoDB使用的树模型是B+树,数据库表是根据主键的顺序以索引的形式存放的,每一个索引都对应着一个B+树,在学习B+树之前,我们先了解下B树,最后对比说明为什么数据库索引选择B+树而不选择B树。

3.1B树索引

B树是一种多叉自平衡搜索树,它与普通二叉树的区别在于允许每个结点有更多的子结点。它设计思想是将更多相关的数据尽量集中在一起,以便一次读取多个数据,减少磁盘操作的次数,它能够最大化的优化大块数据的读和写操作,加快了了存取速度。

B树有以下特点:

  • 关键字分布在整个整棵树中
  • 任何一个关键字只会出现在树中的某一个节点
  • 搜索效率等价于二分查找

它的优点是可以在内部结点同时存储键和值,因此,将频繁访问的数据存放在靠近根结点的地方将会提高热点数据的查询效率,这种特性使得B树在特定数据重复多次查询的场景中非常的高校。

3.2B+树索引

B+树是对B树的进一步优化,B+树图如下

B+树的特点

  • B+树的内部结点只存放键,不存放值,因此可以在内存页中获取更多的键,这有利于更快的缩小查询范围
  • B+树的叶子结点是由一条链来连接的,因此,当需要进行一次全部数据遍历的时候,B+树只需要耗费O(logN)的时间就可以找到最小的一个结点,然后通过链进行O(N)的顺序遍历即可。而B树则需要对树的每一层进行遍历,这会需要更多的内存置换次数,因此也就需要花费更多的时间

3.3对比

在InnoDB中使用的索引是B+树,那么为什么B+树比B树更适合做数据库的索引呢

  • B+树的磁盘读写代价更低

B+树中的内部结点没有存放数据,所以其内部结点与B树相比较也就越小,如果把所有同一内部节点的关键字都存放在同一盘块中,该盘块容纳的关键字数量就会越多,查询数据时读入的内存的关键字机会越多,读写IO的次数就会降低

  • B+树的查询效率更稳定

B+树的非叶子结点并不是指向文件内容的结点,仅仅是叶子结点中的关键字索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。

  • B+树实现了范围查询

B树在提高了IO性能的同时并没有解决元素遍历的我效率低下的问题,正是为了解决这个问题,B+树应用而生。B+树只需要去遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低。

4、索引的分类

在B+树中,根据叶子结点的内容,可以将索引类型分为主键索引和非主键索引

  • 主键索引

主键索引的叶子结点存放的是所查字段的整行数据,在InnoDB里,主键索引也被称为聚簇索引,它的索引和数据是存入同一个.idb文件中的,因此它的索引结构是在同一个树节点中同时存放索引和数据

  • 非主键索引(普通索引)

非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引。

我们了解了主键索引和普通索引之后,那么它们之间有什么区别呢?

  • 如果语句是 select * from T where ID=XXX,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
  • 如果语句是 select * from T where k=XXX,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 值,再到 ID 索引树搜索一次。这个过程称为回表。

也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。

那么有没有可能存在这样一种情况,仅仅使用普通索引而不需要回表就可以拿到所需的数据呢?答案是可以的,覆盖索引就可以满足这样的要求。

  • 覆盖索引

覆盖索引就是select的数据列只用从索引中就能够取得,不必从数据表中读取,换句话说查询列要被所使用的索引覆盖。即普通索引中除了包含指向的ID之外,也可以存放数据。

因为覆盖索引可以减少树的搜索次数,显著提升查询性能,所有覆盖索引是一个常用的性能优化手段。

  • 联合索引

联合索引又可称为复合索引,对于这种类型的索引,MySQL从左到右的使用索引中的字段,一次查询只能使用索引中的一部分,并且是做左侧部分,满足最左前缀原则。

最左前缀原则可以这样理解,例如索引key index(a,b,c),它就支持索引(a),(a,b),(a,b,c)3种类型的组合进行查找,不支持索引b、c查找。

利用符合索引中的附加列,可以缩小搜索的范围,但使用一个具有两列的索引 不同于使用两个单独的索引。复合索引的结构与电话簿类似,人名由姓和名构成,电话簿首先按姓氏对进行排序,然后按名字对有相同姓氏的人进行排序。如果您知道姓,电话簿将非常有用;如果您知道姓和名,电话簿则更为有用,但如果您只知道名不姓,电话簿将没有用处。

  • 唯一索引

唯一索引是索引列中的元素是不可重复的,只能出现一次,它与普通索引有如下的区别

查询操作

普通索引:查找到满足条件的第一个记录后,需要查找下一个记录,直到碰到第一个不满足条件的记录

唯一索引:由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索

因此,普通索引相对于唯一索引要多一些操作。但是,他们之间对于性能的差距却是微乎其微

更新操作

普通索引:当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InnoDB会将这些更新操作缓存在change buffer中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行change buffer中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。

唯一索引:所有的更新操作都要先判断这个操作是否违反唯一性约束,而这必须要将数据页读入内存才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用change buffer了。

总结

在查询操作中,因为俩者区别不大,选择任何一种都是可以的。在更新操作中,唯一索引相较于普通索引更加消耗IO资源,所以选择普通索引。综合分析来看,选择普通索引。

5、索引下推

简单来说,索引下推是数据库检索数据过程中为减少回表次数而做的优化。

案例

有一个联合索引(name,age),现在的需求是检索出表中的名字的第一个字是郎,而且年龄是23岁的男生,SQL可以这样写

1
2
3
> 复制代码mysql> select * from tuser where name like '郎%' and age=10 and ismale=1;
>
>

MySQL5.6之前,

无索引下推

在最左前缀前提下,只能利用最左边的索引“郎”,找到第一个满足条件的ID,然后进行回表,到主键索引上找出数据行,再对比字段值。

有索引下推

有索引下推可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。即InnoDB 在 (name,age) 索引内部就判断了 age 是否等于 10,对于不等于 10 的记录,直接判断并跳过。在我们的这个例子中,只需要对 ID4、ID5 这两条记录回表取数据判断,就只需要回表 2 次。

参考文章

[1]林晓斌.《MySQL实战45讲》

[2]https://www.jianshu.com/p/0371c9569736


关注公众号:10分钟编程

公众回复success领取学习资源

本文转载自: 掘金

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

RabbitMQ面试题必知必会29道(附答案)

发表于 2020-06-16

❝
消息队列也叫 MQ(Message Queue)。RabbitMQ作为消息队列中的优秀平台且开源,被很多公司使用。RabbitMQ服务器是用Erlang语言编写的,基于AMQP,本篇给大家总结了29道RabbitMQ知识点或者说面试题,可以收藏一波了,持续更新中。。。

❞

1.RabbitMQ是什么?

RabbitMQ是实现了高级消息队列协议(AMQP)的开源消息代理软件(亦称面向消息的中间件)。RabbitMQ服务器是用Erlang语言编写的,而群集和故障转移是构建在开放电信平台框架上的。所有主要的编程语言均有与代理接口通讯的客户端库。

2.RabbitMQ特点?

可靠性: RabbitMQ使用一些机制来保证可靠性, 如持久化、传输确认及发布确认等。

灵活的路由 : 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能, RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个 交换器绑定在一起, 也可以通过插件机制来实现自己的交换器。

扩展性: 多个RabbitMQ节点可以组成一个集群,也可以根据实际业务情况动态地扩展 集群中节点。

高可用性 : 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队 列仍然可用。

多种协议: RabbitMQ除了原生支持AMQP协议,还支持STOMP, MQTT等多种消息 中间件协议。

多语言客户端 :RabbitMQ 几乎支持所有常用语言,比如 Java、 Python、 Ruby、 PHP、 C#、 JavaScript 等。

管理界面 : RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集 群中的节点等。

令插件机制: RabbitMQ 提供了许多插件 , 以实现从多方面进行扩展,当然也可以编写自 己的插件。

3.AMQP是什么?

RabbitMQ就是 AMQP 协议的 Erlang 的实现(当然 RabbitMQ 还支持 STOMP2、 MQTT3 等协议 ) AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。

RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。目前 RabbitMQ 最新版本默认支持的是 AMQP 0-9-1。

4.AMQP协议3层?

Module Layer:协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。

Session Layer:中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。

TransportLayer:最底层,主要传输二进制数据流,提供帧的处理、信道服用、错误检测和数据表示等。

5.AMQP模型的几大组件?

  • 交换器 (Exchange):消息代理服务器中用于把消息路由到队列的组件。
  • 队列 (Queue):用来存储消息的数据结构,位于硬盘或内存中。
  • 绑定 (Binding): 一套规则,告知交换器消息应该将消息投递给哪个队列。

6.生产者Producer?

消息生产者,就是投递消息的一方。

消息一般包含两个部分:消息体(payload)和标签(Label)。

7.消费者Consumer?

消费消息,也就是接收消息的一方。

消费者连接到RabbitMQ服务器,并订阅到队列上。消费消息时只消费消息体,丢弃标签。

8.Broker服务节点?

Broker可以看做RabbitMQ的服务节点。一般请下一个Broker可以看做一个RabbitMQ服务器。

9.Queue队列?

Queue:RabbitMQ的内部对象,用于存储消息。多个消费者可以订阅同一队列,这时队列中的消息会被平摊(轮询)给多个消费者进行处理。

10.Exchange交换器?

Exchange:生产者将消息发送到交换器,有交换器将消息路由到一个或者多个队列中。当路由不到时,或返回给生产者或直接丢弃。

11.RoutingKey路由键?

生产者将消息发送给交换器的时候,会指定一个RoutingKey,用来指定这个消息的路由规则,这个RoutingKey需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。

12.Binding绑定?

通过绑定将交换器和队列关联起来,一般会指定一个BindingKey,这样RabbitMq就知道如何正确路由消息到队列了。

13.交换器4种类型?

主要有以下4种。

fanout:把所有发送到该交换器的消息路由到所有与该交换器绑定的队列中。

direct:把消息路由到BindingKey和RoutingKey完全匹配的队列中。

topic:

匹配规则:

RoutingKey 为一个 点号’.’: 分隔的字符串。 比如: java.xiaoka.show

BindingKey和RoutingKey一样也是点号“.“分隔的字符串。

BindingKey可使用 * 和 # 用于做模糊匹配,*匹配一个单词,#匹配多个或者0个

headers:不依赖路由键匹配规则路由消息。是根据发送消息内容中的headers属性进行匹配。性能差,基本用不到。

14.生产者消息运转?

1.Producer先连接到Broker,建立连接Connection,开启一个信道(Channel)。

2.Producer声明一个交换器并设置好相关属性。

3.Producer声明一个队列并设置好相关属性。

4.Producer通过路由键将交换器和队列绑定起来。

5.Producer发送消息到Broker,其中包含路由键、交换器等信息。

6.相应的交换器根据接收到的路由键查找匹配的队列。

7.如果找到,将消息存入对应的队列,如果没有找到,会根据生产者的配置丢弃或者退回给生产者。

8.关闭信道。

9.管理连接。

15.消费者接收消息过程?

1.Producer先连接到Broker,建立连接Connection,开启一个信道(Channel)。

2.向Broker请求消费响应的队列中消息,可能会设置响应的回调函数。

3.等待Broker回应并投递相应队列中的消息,接收消息。

4.消费者确认收到的消息,ack。

5.RabbitMq从队列中删除已经确定的消息。

6.关闭信道。

7.关闭连接。

16.交换器无法根据自身类型和路由键找到符合条件队列时,有哪些处理?

mandatory :true 返回消息给生产者。

mandatory: false 直接丢弃。

17.死信队列?

DLX,全称为 Dead-Letter-Exchange,死信交换器,死信邮箱。当消息在一个队列中变成死信 (dead message) 之后,它能被重新被发送到另一个交换器中,这个交换器就是 DLX,绑定 DLX 的队列就称之为死信队列。

18.导致的死信的几种原因?

  • 消息被拒(Basic.Reject /Basic.Nack) 且 requeue = false。
  • 消息TTL过期。
  • 队列满了,无法再添加。

19.延迟队列?

存储对应的延迟消息,指当消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。

20.优先级队列?

优先级高的队列会先被消费。

可以通过x-max-priority参数来实现。

当消费速度大于生产速度且Broker没有堆积的情况下,优先级显得没有意义。

21.事务机制?

RabbitMQ 客户端中与事务机制相关的方法有三个:

channel.txSelect 用于将当前的信道设置成事务模式。

channel . txCommit 用于提交事务 。

channel . txRollback 用于事务回滚,如果在事务提交执行之前由于 RabbitMQ 异常崩溃或者其他原因抛出异常,通过txRollback来回滚。

22.发送确认机制?

生产者把信道设置为confirm确认模式,设置后,所有再改信道发布的消息都会被指定一个唯一的ID,一旦消息被投递到所有匹配的队列之后,RabbitMQ就会发送一个确认(Basic.Ack)给生产者(包含消息的唯一ID),这样生产者就知道消息到达对应的目的地了。

23.消费者获取消息的方式?

  • 推
  • 拉

24.消费者某些原因无法处理当前接受的消息如何来拒绝?

  • channel .basicNack
  • channel .basicReject

25.消息传输保证层级?

At most once:最多一次。消息可能会丢失,单不会重复传输。

At least once:最少一次。消息觉不会丢失,但可能会重复传输。

Exactly once: 恰好一次,每条消息肯定仅传输一次。

26.vhost?

每一个RabbitMQ服务器都能创建虚拟的消息服务器,也叫虚拟主机(virtual host),简称vhost。

默认为“/”。

27.集群中的节点类型?

内存节点:ram,将变更写入内存。

磁盘节点:disc,磁盘写入操作。

RabbitMQ要求最少有一个磁盘节点。

28.队列结构?

通常由以下两部分组成?

rabbit_amqqueue_process :负责协议相关的消息处理,即接收生产者发布的消息、向消费者交付消息、处理消息的确认(包括生产端的 confirm 和消费端的 ack) 等。

backing_queue:是消息存储的具体形式和引擎,并向 rabbit amqqueue process 提供相关的接口以供调用。

29.RabbitMQ中消息可能有的几种状态?

alpha: 消息内容(包括消息体、属性和 headers) 和消息索引都存储在内存中 。

beta: 消息内容保存在磁盘中,消息索引保存在内存中。

gamma: 消息内容保存在磁盘中,消息索引在磁盘和内存中都有 。

delta: 消息内容和索引都在磁盘中 。

参考:

《RabbitMQ实战指南》

《深入RabbitMQ》

百度百科

本文转载自: 掘金

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

最详细的Redis五种数据结构详解(理论+实战),建议收藏。

发表于 2020-06-15

本文脑图


前言
–

Redis是基于c语言编写的开源非关系型内存数据库,可以用作数据库、缓存、消息中间件,这么优秀的东西客定要一点一点的吃透它。

关于Redis的文章之前也写过两篇,阅读量和读者的反映都还可以,其中第一篇是Redis的缓存三大问题[]。

第二篇是Redis的内存管理和淘汰策略[]。

这是关于Redis的第三篇文章,主要讲解Redis的五种数据结构详解,包括这五种的数据结构的底层原理实现。

理论肯定是要用于实践的,因此最重要的还是实战部分,也就是这里还会讲解五种数据结构的应用场景。

话不多说,我们直接进入主题,很多人都知道Redis的五种数据结构包括以下五种:

  1. String:字符串类型
  2. List:列表类型
  3. Set:无序集合类型
  4. ZSet:有序集合类型
  5. Hash:哈希表类型

但是作为一名优秀的程序员可能不能只停留在只会用着五种类型进行crud工作,还是得深入了解这五种数据结构的底层原理。

Redis核心对象

在Redis中有一个「核心的对象」叫做redisObject ,是用来表示所有的key和value的,用redisObject结构体来表示String、Hash、List、Set、ZSet五种数据类型。

redisObject的源代码在redis.h中,使用c语言写的,感兴趣的可以自行查看,关于redisObject我这里画了一张图,表示redisObject的结构如下所示:

闪瞎人的五颜六色图

闪瞎人的五颜六色图

在redisObject中「type表示属于哪种数据类型,encoding表示该数据的存储方式」,也就是底层的实现的该数据类型的数据结构。因此这篇文章具体介绍的也是encoding对应的部分。

那么encoding中的存储类型又分别表示什么意思呢?具体数据类型所表示的含义,如下图所示:

图片截图出自《Redis设计与实现第二版》

图片截图出自《Redis设计与实现第二版》

可能看完这图,还是觉得一脸懵。不慌,会进行五种数据结构的详细介绍,这张图只是让你找到每种中数据结构对应的储存类型有哪些,大概脑子里有个印象。

举一个简单的例子,你在Redis中设置一个字符串key 234,然后查看这个字符串的存储类型就会看到为int类型,非整数型的使用的是embstr储存类型,具体操作如下图所示:


String类型


String是Redis最基本的数据类型,上面的简介中也说到Redis是用c语言开发的。但是Redis中的字符串和c语言中的字符串类型却是有明显的区别。

String类型的数据结构存储方式有三种int、raw、embstr。那么这三种存储方式有什么区别呢?

int

Redis中规定假如存储的是「整数型值」,比如set num 123这样的类型,就会使用 int的存储方式进行存储,在redisObject的「ptr属性」中就会保存该值。

SDS

假如存储的「字符串是一个字符串值并且长度大于32个字节」就会使用SDS(simple dynamic string)方式进行存储,并且encoding设置为raw;若是「字符串长度小于等于32个字节」就会将encoding改为embstr来保存字符串。

SDS称为「简单动态字符串」,对于SDS中的定义在Redis的源码中有的三个属性int len、int free、char buf[]。

len保存了字符串的长度,free表示buf数组中未使用的字节数量,buf数组则是保存字符串的每一个字符元素。

因此当你在Redsi中存储一个字符串Hello时,根据Redis的源代码的描述可以画出SDS的形式的redisObject结构图如下图所示:

SDS与c语言字符串对比

Redis使用SDS作为存储字符串的类型肯定是有自己的优势,SDS与c语言的字符串相比,SDS对c语言的字符串做了自己的设计和优化,具体优势有以下几点:

(1)c语言中的字符串并不会记录自己的长度,因此「每次获取字符串的长度都会遍历得到,时间的复杂度是O(n)」,而Redis中获取字符串只要读取len的值就可,时间复杂度变为O(1)。

(2)「c语言」中两个字符串拼接,若是没有分配足够长度的内存空间就「会出现缓冲区溢出的情况」;而「SDS」会先根据len属性判断空间是否满足要求,若是空间不够,就会进行相应的空间扩展,所以「不会出现缓冲区溢出的情况」。

(3)SDS还提供「空间预分配」和「惰性空间释放」两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能「减少连续的执行字符串增长带来内存重新分配的次数」。

当字符串被缩短的时候,SDS也不会立即回收不适用的空间,而是通过free属性将不使用的空间记录下来,等后面使用的时候再释放。

具体的空间预分配原则是:「当修改字符串后的长度len小于1MB,就会预分配和len一样长度的空间,即len=free;若是len大于1MB,free分配的空间大小就为1MB」。

(4)SDS是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据);而c语言中的字符串是以空字符串作为结束符,一些图片中含有结束符,因此不是二进制安全的。

为了方便易懂,做了一个c语言的字符串和SDS进行对比的表格,如下所示:

c语言字符串 SDS
获取长度的时间复杂度为O(n) 获取长度的时间复杂度为O(1)
不是二进制安全的 是二进制安全的
只能保存字符串 还可以保存二进制数据
n次增长字符串必然会带来n次的内存分配 n次增长字符串内存分配的次数<=n

String类型应用

说到这里我相信很多人可以说已经精通Redis的String类型了,但是纯理论的精通,理论还是得应用实践,上面说到String可以用来存储图片,现在就以图片存储作为案例实现。

(1)首先要把上传得图片进行编码,这里写了一个工具类把图片处理成了Base64得编码形式,具体得实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码 /**
* 将图片内容处理成Base64编码格式
* @param file
* @return
*/
public static String encodeImg(MultipartFile file) {
byte[] imgBytes = null;
try {
imgBytes = file.getBytes();
} catch (IOException e) {
e.printStackTrace();
}
BASE64Encoder encoder = new BASE64Encoder();
return imgBytes==null?null:encoder.encode(imgBytes );
}

(2)第二步就是把处理后的图片字符串格式存储进Redis中,实现得代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
复制代码    /**
* Redis存储图片
* @param file
* @return
*/
public void uploadImageServiceImpl(MultipartFile image) {
String imgId = UUID.randomUUID().toString();
String imgStr= ImageUtils.encodeImg(image);
redisUtils.set(imgId , imgStr);
// 后续操作可以把imgId存进数据库对应的字段,如果需要从redis中取出,只要获取到这个字段后从redis中取出即可。
}

这样就是实现了图片得二进制存储,当然String类型得数据结构得应用也还有常规计数:「统计微博数、统计粉丝数」等。

Hash类型

Hash对象的实现方式有两种分别是ziplist、hashtable,其中hashtable的存储方式key是String类型的,value也是以key value的形式进行存储。

字典类型的底层就是hashtable实现的,明白了字典的底层实现原理也就是明白了hashtable的实现原理,hashtable的实现原理可以于HashMap的是底层原理相类比。

字典

两者在新增时都会通过key计算出数组下标,不同的是计算法方式不同,HashMap中是以hash函数的方式,而hashtable中计算出hash值后,还要通过sizemask 属性和哈希值再次得到数组下标。

我们知道hash表最大的问题就是hash冲突,为了解决hash冲突,假如hashtable中不同的key通过计算得到同一个index,就会形成单向链表(「链地址法」),如下图所示:

rehash

在字典的底层实现中,value对象以每一个dictEntry的对象进行存储,当hash表中的存放的键值对不断的增加或者减少时,需要对hash表进行一个扩展或者收缩。

这里就会和HashMap一样也会就进行rehash操作,进行重新散列排布。从上图中可以看到有ht[0]和ht[1]两个对象,先来看看对象中的属性是干嘛用的。

在hash表结构定义中有四个属性分别是dictEntry **table、unsigned long size、unsigned long sizemask、unsigned long used,分别表示的含义就是「哈希表数组、hash表大小、用于计算索引值,总是等于size-1、hash表中已有的节点数」。

ht[0]是用来最开始存储数据的,当要进行扩展或者收缩时,ht[0]的大小就决定了ht[1]的大小,ht[0]中的所有的键值对就会重新散列到ht[1]中。

扩展操作:ht[1]扩展的大小是比当前 ht[0].used 值的二倍大的第一个 2 的整数幂;收缩操作:ht[0].used 的第一个大于等于的 2 的整数幂。

当ht[0]上的所有的键值对都rehash到ht[1]中,会重新计算所有的数组下标值,当数据迁移完后ht[0]就会被释放,然后将ht[1]改为ht[0],并新创建ht[1],为下一次的扩展和收缩做准备。

渐进式rehash

假如在rehash的过程中数据量非常大,Redis不是一次性把全部数据rehash成功,这样会导致Redis对外服务停止,Redis内部为了处理这种情况采用「渐进式的rehash」。

Redis将所有的rehash的操作分成多步进行,直到都rehash完成,具体的实现与对象中的rehashindex属性相关,「若是rehashindex 表示为-1表示没有rehash操作」。

当rehash操作开始时会将该值改成0,在渐进式rehash的过程「更新、删除、查询会在ht[0]和ht[1]中都进行」,比如更新一个值先更新ht[0],然后再更新ht[1]。

而新增操作直接就新增到ht[1]表中,ht[0]不会新增任何的数据,这样保证「ht[0]只减不增,直到最后的某一个时刻变成空表」,这样rehash操作完成。

上面就是字典的底层hashtable的实现原理,说完了hashtable的实现原理,我们再来看看Hash数据结构的两一种存储方式「ziplist(压缩列表)」

ziplist

压缩列表(ziplist)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。

压缩列表是列表键和哈希键底层实现的原理之一,「压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间」,压缩列表的内存结构图如下:


压缩列表中每一个节点表示的含义如下所示:

  1. zlbytes:4个字节的大小,记录压缩列表占用内存的字节数。
  2. zltail:4个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。
  3. zllen:2个字节的大小,记录压缩列表中的节点数。
  4. entry:表示列表中的每一个节点。
  5. zlend:表示压缩列表的特殊结束符号'0xFF'。

再压缩列表中每一个entry节点又有三部分组成,包括previous_entry_ength、encoding、content。

  1. previous_entry_ength表示前一个节点entry的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。
  2. encoding:这里保存的是content的内容类型和长度。
  3. content:content保存的是每一个节点的内容。


说到这里相信大家已经都hash这种数据结构已经非常了解,若是第一次接触Redis五种基本数据结构的底层实现的话,建议多看几遍,下面来说一说hash的应用场景。

应用场景

哈希表相对于String类型存储信息更加直观,擦欧总更加方便,经常会用来做用户数据的管理,存储用户的信息。

hash也可以用作高并发场景下使用Redis生成唯一的id。下面我们就以这两种场景用作案例编码实现。

存储用户数据

第一个场景比如我们要储存用户信息,一般使用用户的ID作为key值,保持唯一性,用户的其他信息(地址、年龄、生日、电话号码等)作为value值存储。

若是传统的实现就是将用户的信息封装成为一个对象,通过序列化存储数据,当需要获取用户信息的时候,就会通过反序列化得到用户信息。


但是这样必然会造成序列化和反序列化的性能的开销,并且若是只修改其中的一个属性值,就需要把整个对象序列化出来,操作的动作太大,造成不必要的性能开销。

若是使用Redis的hash来存储用户数据,就会将原来的value值又看成了一个k v形式的存储容器,这样就不会带来序列化的性能开销的问题。

分布式生成唯一ID

第二个场景就是生成分布式的唯一ID,这个场景下就是把redis封装成了一个工具类进行实现,实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码    // offset表示的是id的递增梯度值
public Long getId(String key,String hashKey,Long offset) throws BusinessException{
try {
if (null == offset) {
offset=1L;
}
// 生成唯一id
return redisUtil.increment(key, hashKey, offset);
} catch (Exception e) {
//若是出现异常就是用uuid来生成唯一的id值
int randNo=UUID.randomUUID().toString().hashCode();
if (randNo < 0) {
randNo=-randNo;
}
return Long.valueOf(String.format("%16d", randNo));
}
}

List类型

Redis中的列表在3.2之前的版本是使用ziplist和linkedlist进行实现的。在3.2之后的版本就是引入了quicklist。

ziplist压缩列表上面已经讲过了,我们来看看linkedlist和quicklist的结构是怎么样的。

linkedlist是一个双向链表,他和普通的链表一样都是由指向前后节点的指针。插入、修改、更新的时间复杂度尾O(1),但是查询的时间复杂度确实O(n)。

linkedlist和quicklist的底层实现是采用链表进行实现,在c语言中并没有内置的链表这种数据结构,Redis实现了自己的链表结构。


Redis中链表的特性:

  1. 每一个节点都有指向前一个节点和后一个节点的指针。
  2. 头节点和尾节点的prev和next指针指向为null,所以链表是无环的。
  3. 链表有自己长度的信息,获取长度的时间复杂度为O(1)。

Redis中List的实现比较简单,下面我们就来看看它的应用场景。

应用场景

Redis中的列表可以实现「阻塞队列」,结合lpush和brpop命令就可以实现。生产者使用lupsh从列表的左侧插入元素,消费者使用brpop命令从队列的右侧获取元素进行消费。

(1)首先配置redis的配置,为了方便我就直接放在application.yml配置文件中,实际中可以把redis的配置文件放在一个redis.properties文件单独放置,具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码spring
redis:
host: 127.0.0.1
port: 6379
password: user
timeout: 0
database: 2
pool:
max-active: 100
max-idle: 10
min-idle: 0
max-wait: 100000

(2)第二步创建redis的配置类,叫做RedisConfig,并标注上@Configuration注解,表明他是一个配置类。

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
复制代码@Configuration
public class RedisConfiguration {
@Value("![{spring.redis.host}")
private String host;
@Value("](https://gitee.com/songjianzaina/juejin_p6/raw/master/img/2f39b503ac416bba3f46d75b8cd28b36a4fe7ba47798c1e7c2f69a9a4fb14811){spring.redis.port}")
private int port;
@Value("![{spring.redis.password}")
private String password;
@Value("](https://gitee.com/songjianzaina/juejin_p6/raw/master/img/880369f79f20ad4c09ad0856bcc0b198de2ed1045514d5d2ea39c7c21a3f442c){spring.redis.pool.max-active}")
private int maxActive;
@Value("![{spring.redis.pool.max-idle}")
private int maxIdle;
@Value("](https://gitee.com/songjianzaina/juejin_p6/raw/master/img/46247ecbe5402a5df75b18569d5d5a3459e84ceb64954ad858d28a21c9a489ae){spring.redis.pool.min-idle}")
private int minIdle;
@Value("![{spring.redis.pool.max-wait}")
private int maxWait;
@Value("](https://gitee.com/songjianzaina/juejin_p6/raw/master/img/2b7184eae3ee0b9fc2b42caad0377dd6e04a7e9b19fbf3aaebc195103eeda3ee){spring.redis.database}")
private int database;
@Value("${spring.redis.timeout}")
private int timeout;


@Bean
public JedisPoolConfig getRedisConfiguration(){
JedisPoolConfig jedisPoolConfig= new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(maxActive);
jedisPoolConfig.setMaxIdle(maxIdle);
jedisPoolConfig.setMinIdle(minIdle);
jedisPoolConfig.setMaxWaitMillis(maxWait);
return jedisPoolConfig;
}


@Bean
public JedisConnectionFactory getConnectionFactory() {
JedisConnectionFactory factory = new JedisConnectionFactory();
factory.setHostName(host);
factory.setPort(port);
factory.setPassword(password);
factory.setDatabase(database);
JedisPoolConfig jedisPoolConfig= getRedisConfiguration();
factory.setPoolConfig(jedisPoolConfig);
return factory;
}


`@Bean
public RedisTemplate<?, ?> getRedisTemplate() {
JedisConnectionFactory factory = getConnectionFactory();
RedisTemplate<?, ?> redisTemplate = new StringRedisTemplate(factory);
return redisTemplate;
}
}`

(3)第三步就是创建Redis的工具类RedisUtil,自从学了面向对象后,就喜欢把一些通用的东西拆成工具类,好像一个一个零件,需要的时候,就把它组装起来。

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
复制代码@Component
public class RedisUtil {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**


* 存消息到消息队列中
* @param key 键
* @param value 值
* @return
*/
public boolean lPushMessage(String key, Object value) {
try {
redisTemplate.opsForList().leftPush(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}


/**


* 从消息队列中弹出消息 - <rpop:非阻塞式>
* @param key 键
* @return
*/
public Object rPopMessage(String key) {
try {
return redisTemplate.opsForList().rightPop(key);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}


/**

`* 查看消息

  • @param key 键

  • @param start 开始

  • @param end 结束 0 到 -1代表所有值*@return

  • /
    public List getMessage(String key, long start, long end) {
    try {
    return redisTemplate.opsForList().range(key, start, end);
    } catch (Exception e) {
    e.printStackTrace();
    return null;
    }
    }`

这样就完成了Redis消息队列工具类的创建,在后面的代码中就可以直接使用。

Set集合

Redis中列表和集合都可以用来存储字符串,但是「Set是不可重复的集合,而List列表可以存储相同的字符串」,Set集合是无序的这个和后面讲的ZSet有序集合相对。

Set的底层实现是「ht和intset」,ht(哈希表)前面已经详细了解过,下面我们来看看inset类型的存储结构。

inset也叫做整数集合,用于保存整数值的数据结构类型,它可以保存int16_t、int32_t 或者int64_t 的整数值。

在整数集合中,有三个属性值encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,length就是记录contents里面的大小。

在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:

  1. 首先扩展底层数组的大小,并且数组的类型为新元素的类型。
  2. 然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
  3. 整数集合升级后就不会再降级,编码会一直保持升级后的状态。

应用场景

Set集合的应用场景可以用来「去重、抽奖、共同好友、二度好友」等业务类型。接下来模拟一个添加好友的案例实现:

1
2
3
4
5
6
7
8
9
10
11
复制代码
@RequestMapping(value = "/addFriend", method = RequestMethod.POST)
public Long addFriend(User user, String friend) {
String currentKey = null;
// 判断是否是当前用户的好友
if (AppContext.getCurrentUser().getId().equals(user.getId)) {
currentKey = user.getId.toString();
}
//若是返回0则表示不是该用户好友
return currentKey==null?0l:setOperations.add(currentKey, friend);
}

假如两个用户A和B都是用上上面的这个接口添加了很多的自己的好友,那么有一个需求就是要实现获取A和B的共同好友,那么可以进行如下操作:

1
2
3
复制代码public Set intersectFriend(User userA, User userB) {
return setOperations.intersect(userA.getId.toString(), userB.getId.toString());
}

举一反三,还可以实现A用户自己的好友,或者B用户自己的好友等,都可以进行实现。

ZSet集合

ZSet是有序集合,从上面的图中可以看到ZSet的底层实现是ziplist和skiplist实现的,ziplist上面已经详细讲过,这里来讲解skiplist的结构实现。

skiplist也叫做「跳跃表」,跳跃表是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。

skiplist由如下几个特点:

  1. 有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
  2. 每一层都是一个有序链表,只扫包含两个节点,头节点和尾节点。
  3. 每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
  4. 如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。

具体实现的结构图如下所示:


在跳跃表的结构中有head和tail表示指向头节点和尾节点的指针,能后快速的实现定位。level表示层数,len表示跳跃表的长度,BW表示后退指针,在从尾向前遍历的时候使用。

BW下面还有两个值分别表示分值(score)和成员对象(各个节点保存的成员对象)。

跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。

跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,条表所体现的查询的效率就越高,和平衡树的查询效率相差无几。

应用场景

因为ZSet是有序的集合,因此ZSet在实现排序类型的业务是比较常见的,比如在首页推荐10个最热门的帖子,也就是阅读量由高到低,排行榜的实现等业务。

下面就选用获取排行榜前前10名的选手作为案例实现,实现的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码@Autowired
private RedisTemplate redisTemplate;
/**
* 获取前10排名
* @return
*/
public static List<levelVO > getZset(String key, long baseNum, LevelService levelService){
ZSetOperations<Serializable, Object> operations = redisTemplate.opsForZSet();
// 根据score分数值获取前10名的数据
Set<ZSetOperations.TypedTuple<Object>> set = operations.reverseRangeWithScores(key,0,9);
List<LevelVO> list= new ArrayList<LevelVO>();
int i=1;
for (ZSetOperations.TypedTuple<Object> o:set){
int uid = (int) o.getValue();
LevelCache levelCache = levelService.getLevelCache(uid);
LevelVO levelVO = levelCache.getLevelVO();
long score = (o.getScore().longValue() - baseNum + levelVO .getCtime())/CommonUtil.multiplier;
levelVO .setScore(score);
levelVO .setRank(i);
list.add( levelVO );
i++;
}
return list;
}

以上的代码实现大致逻辑就是根据score分数值获取前10名的数据,然后封装成lawyerVO对象的列表进行返回。

到这里我们已经精通Redis的五种基本数据类型了,又可以去和面试官扯皮了,扯不过就跑路吧,或者这篇文章多看几遍,相信对你总是有好处的。

本文转载自: 掘金

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

Kotlin Jetpack 实战 02 Kotlin

发表于 2020-06-15

简介

本文假设各位已经有了 Kotlin 基础,对 Kotlin 还不熟悉的小伙伴可以去看我之前发的文章–>《Kotlin Jetpack 实战》。

本文将带领各位一步步将 Demo 工程 的 Gradle 脚本改成 Kotlin DSL,让我们一起实战吧!

正文

  1. Kotlin 编写 Gradle 脚本的优势

Kotlin Groovy
自动代码补全 支持 不支持
是否类型安全 是 不是
源码导航 支持 不支持
重构 自动关联 手动修改
  1. 实战前的准备

  • 将 Android Studio 版本升级到最新
  • 将我们的 Demo 工程 clone 到本地,用 Android Studio 打开:
    github.com/chaxiu/Kotl…
  • 切换到分支:chapter_02_kotlin_dsl_training
  • 强烈建议读者跟着本文一起实战,实战才是本文的精髓。
  1. 开始重构

3-1. 将单引号替换成双引号

替换前:

1
groovy复制代码apply plugin: ‘com.android.application’

替换后:

1
groovy复制代码apply plugin: "com.android.application"

小结:

  • 不用修改 Gradle 文件扩展名,直接使用 Android Studio 替换功能即可。
  • 为什么能够直接替换?因为 Grooovy 和 Kotlin 在字符串定义的语法是相近的:双引号表示字符串。
  • 那么,为什么要替换呢?因为单引号 双引号在 Groovy 里都是定义字符串,而 Kotlin 里单引号定义的是单个字符,双引号才是定义字符串。

具体细节可以看我这个 GitHub Commit

3-2. 修改 Gradle 文件扩展名

    1. builde.gradle –> build.gradle.kts
    1. settings.gradle –> settings.gradle.kts
    1. Sync 走起!

Script compilation errors:
Line 1: include “:app” Unexpected tokens (use ‘;’ > to separate expressions on the same line)
Line 1: include “:app” Function invocation ‘include(…)’ > expected
2 errors

不要慌!
报错不可怕,不报错才可怕!最起码我们知道哪里错了。
错误日志告诉我们,问题出在这里:

1
2
groovy复制代码// settings.gradle
include ":app"

我们 Command + 鼠标左键点击 include,来看看源码实现:

1
2
kotlin复制代码override fun include(vararg projectPaths: String?) =
delegate.include(*projectPaths)

哟!原来 settings.gradle 里面的 include 的本质就是个方法调用啊!再结合报错原因:Function invocation 'include(...)' > expected,这就单纯是个语法错误呗!就是说,我们改了 Gradle 扩展名以后,IDE 就认为它是个 Kotlin 语句了。而 include ":app"用的还是 Groovy 的语法,这当然会报错了!

修改成这样就好了:

1
2
kotlin复制代码// 调用 include 方法,传入一个字符串":app"
include(":app")

接下来重复这个的步骤:Sync –> 报错 –> Command + 鼠标左键 看源码

修改前:

1
2
3
groovy复制代码dependencies {
classpath "com.android.tools.build:gradle:4.0.0"
}

修改后:

1
2
3
kotlin复制代码dependencies {
classpath("com.android.tools.build:gradle:4.0.0")
}

3-3. 遇到无法解决的报错怎么办?

比如:如果你继续 Sync,报错的是这里:

1
2
3
groovy复制代码task clean(type: Delete) {
delete rootProject.buildDir
}

e: /KotlinJetpackInAction/build.gradle.kts:19:16: Expecting ‘)’
e: ../KotlinJetpackInAction/build.gradle.kts:19:16: Unexpected tokens (use ‘;’ to separate expressions on the same line)
e: ../KotlinJetpackInAction/build.gradle.kts:20:23: Expecting an element
e: ../KotlinJetpackInAction/build.gradle.kts:20:32: Expecting an element
e: ../KotlinJetpackInAction/build.gradle.kts:19:1: Function invocation ‘task(…)’ expected
e: ../KotlinJetpackInAction/build.gradle.kts:19:1: None of the following functions can be called with the arguments supplied:
public abstract fun task(p0: String!): Task! defined in org.gradle.api.Project
public abstract fun task(p0: String!, p1: Closure<(raw) Any!>!): Task! defined in org.gradle.api.Project
public abstract fun task(p0: String!, p1: Action<in Task!>!): Task! defined in org.gradle.api.Project
public abstract fun task(p0: (Mutable)Map<String!, *>!, p1: String!): Task! defined in org.gradle.api.Project
public abstract fun task(p0: (Mutable)Map<String!, >!, p1: String!, p2: Closure<(raw) Any!>!): Task! defined in org.gradle.api.Project
e: ../KotlinJetpackInAction/build.gradle.kts:19:12: Function invocation ‘type(…)’ expected
e: ../KotlinJetpackInAction/build.gradle.kts:19:12: Unresolved reference. None of the following candidates is applicable because of receiver type mismatch:
public inline fun ObjectConfigurationAction.type(pluginClass: KClass<
>): ObjectConfigurationAction defined in org.gradle.kotlin.dsl
e: ../KotlinJetpackInAction/build.gradle.kts:20:5: Function invocation ‘delete(…)’ expected
e: ../KotlinJetpackInAction/build.gradle.kts:20:12: Unresolved reference: rootProject

好可怕,这回一次性报好多错误,而且看起来都很奇怪。怎么办?
不要慌!
Kotlin 官方都已经给我们准备好了迁移指南:
Migrating build logic from Groovy to Kotlin

嫌上面都迁移指南太长?还是纯英文?
不要怕!
Kotlin 官方给我们准备了迁移案例:
kotlin-dsl-samples:hello-android

看!迁移案例里已经告诉我们怎么改这个 clean task 了:

1
2
3
4
kotlin复制代码// 具体看这里:https://github.com/gradle/kotlin-dsl-samples/blob/master/samples/hello-android/build.gradle.kts
tasks.register("clean", Delete::class) {
delete(rootProject.buildDir)
}

3-4. 参照kotlin-dsl-samples继续修改

修改前:

1
groovy复制代码apply plugin: "com.android.application"

修改后:

1
2
3
kotlin复制代码plugins {
id("com.android.application")
}

修改前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
groovy复制代码android {
compileSdkVersion 29

defaultConfig {
applicationId "com.boycoder.kotlinjetpackinaction"
minSdkVersion 21
targetSdkVersion 29
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
}

修改后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码android {
compileSdkVersion(29)

defaultConfig {
applicationId = "com.boycoder.kotlinjetpackinaction"
minSdkVersion(21)
targetSdkVersion(29)
versionCode = 1
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
getByName("release") {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
}
}
}

修改前:

1
2
3
4
5
6
7
8
groovy复制代码dependencies {
//省略部分...
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "androidx.appcompat:appcompat:1.1.0"
testImplementation "junit:junit:4.12"
androidTestImplementation "androidx.test.ext:junit:1.1.1"
annotationProcessor "com.github.bumptech.glide:compiler:4.8.0"
}

修改后:

1
2
3
4
5
6
7
8
kotlin复制代码dependencies {
//省略部分...
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation("androidx.appcompat:appcompat:1.1.0")
testImplementation("junit:junit:4.12")
androidTestImplementation("androidx.test.ext:junit:1.1.1")
annotationProcessor("com.github.bumptech.glide:compiler:4.8.0")
}

具体可以看我这个 Github Commit

3-5 大功告成!

这下我们可以开始愉快的用 Kotlin 写 Gradle 脚本了。
那么,这篇文章是不是就该结束了呢?并没有。
本文是以实战为核心,咱们刚用 Kotlin DSL 重构完项目,当然还要再实战一波啦!

  1. Kotlin DSL 实战–依赖管理

4-1. Groovy 时代的依赖管理

以前我们这么定义依赖:

1
2
3
4
5
6
7
8
9
10
11
groovy复制代码// 根目录下的 builde.gradle
ext {
versions = [
support_lib: "28.0.0",
glide: "4.8.0"
]
libs = [
support_annotations: "com.android.support:support-annotations:${versions.support_lib}",
glide: "com.github.bumptech.glide:glide:${versions.glide}"
]
}

然后这么用:

1
2
3
groovy复制代码// app 目录下的 builde.gradle
implementation libs.support_annotations
implementation libs.glide

4-2. Kotlin 时代的依赖管理

Kotlin DSL 管理依赖的方式很多,我这里采用相对主流的做法:buildSrc目录下管理。具体细节可以看这个官方文档:Gradle Documentation

简单翻译:

Gradle 运行的时候,会去检查工程根目录下是否存在buildSrc目录,如果存在,这个目录下的所有脚本都会自动被添加到工程的环境变量(classpath)里。

借助上面的机制,我们就可以把所有依赖的都以常量形式定义到 buildSrc目录下,然后我们就可以直接在工程里随意使用了。

具体结构如下所示:

ProjectProperties.kt 定义了工程相关的属性:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码// ProjectProperties.kt 定义了工程相关的属性
object ProjectProperties {
const val compileSdk = 29
const val minSdk = 21
const val targetSdk = 29

const val applicationId = "com.boycoder.kotlinjetpackinaction"
const val versionCode = 1
const val versionName = "1.0.0"

const val agpVersion = "4.0.0"
}

Libs.kt 定义了所有的依赖:

1
2
3
4
kotlin复制代码object Libs {
const val appCompat = "androidx.appcompat:appcompat:${Versions.appCompat}"
const val constraintlayout = "androidx.constraintlayout:constraintlayout:${Versions.constraintlayout}"
}

Versions.kt 定义了所有依赖库的版本号:

1
2
3
4
kotlin复制代码object Versions {
const val appCompat = "1.1.0"
const val constraintlayout = "1.1.3"
}

对应 build.gradle.kts 的修改如下:

1
2
3
4
5
kotlin复制代码dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation(Libs.appCompat)
implementation(Libs.constraintlayout)
}

具体细节可以看这个 Github Commit:

看,现在我们 Gradle 代码就能有自动补全的提示了。

真香!

结尾

注意:在新的工程里用 Kotlin DSL 完全替代 Groovy 是一件很简单的事情,但如果是一个年代久远的工程那就没那么容易了,大坑小坑会不少,Kotlin DSL 迁移的坑后面会讲。

下一节,我会一步步把 Demo 工程里的 Java 代码重构成 Kotlin。

都看到这了,点个赞呗!

下一章–>《Kotlin 编程的三重境界》

目录–>《Kotlin Jetpack 实战》

本文转载自: 掘金

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

面试官别再问系列:Java Exception 和 Erro

发表于 2020-06-15

昨天老源Sir在面试间等待候选人,偶然听到隔壁房间的面试官问了候选人一个问题:“Java 的 Exception 和 Error 有什么区别呢?”。老源Sir 听到这里心里嘿嘿一笑。这是个常见的连环问题了。结果没想到候选人第一个问题就卡壳了。老源 Sir 不禁为候选人捏了一把汗啊,这个问题其实还蛮基础的呢。。。当时心里也就下了个决心,一定要写篇文章把 Java 的异常相关讲明白,让大家再也不怕在面试中遇到这一类的问题。

throw 语句

有点 java 基础的同学应该都知道 throw 这个语句吧(如果不知道的话可以点击右上角的叉号了😂)。我们都知道throw 语句起到的作用,它会抛出一个 throwable 的子类对象,虚拟机会对这个对象进行一系列的操作,要么可以处理这个异常(被 catch),或者不能处理,最终会导致语句所在的线程停止。

那么 JVM 到底是怎么做的呢?让我们一起试试看吧:

首先写一段代码,throw 一个 RuntimeException:

1
2
3
4
5
6
7
复制代码package com.company;

public class TestException {
public static void main(String[] args) {
throw new RuntimeException();
}
}

编译后,到 class 文件所在目录,用javap -verbose 打开 .class 文件:

1
复制代码 javap -verbose TestException

可以看到一些字节码。我们找到 TestException.main 函数对应的字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码  public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: new #2 // class java/lang/RuntimeException
3: dup
4: invokespecial #3 // Method java/lang/RuntimeException."<init>":()V
7: athrow
LineNumberTable:
line 5: 0
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 args [Ljava/lang/String;

看 code 部分,实际上前三行就是对应 new RuntimeExcpetion(),由于主题原因,这里就不展开了。重头戏是后面这个 athrow,它到底做了什么呢?

1
2
3
4
5
6
7
8
9
aspectj复制代码1. 先检查栈顶元素,必须是一个java.lang.Throwable的子类对象的引用;

2. 上述引用出栈,搜索本方法的异常表,是否存在处理此异常的 handler;

2.1 如果找到对应的handler,则用这个handler处理异常;

2.2 如果找不到对应的handler,当前方法栈帧出栈(退出当前方法),到调用该方法的方法中搜索异常表(重复2);

3. 如果一直找不到 handler,当前线程终止退出,并输出异常信息和堆栈信息(其实也就是不断寻找 handler 的过程)。

可以看到 throw 这个动作会造成几个可能的副作用:

  1. 终止当前方法调用,并传递异常信息给方法调用方;
  2. 如果异常一直无法在方法的异常表里找到 handler,最终会导致线程退出。

好了,到这里我们已经搞明白 throw 到底干了些啥。但是我们注意到, athrow 指令寻找的是一个 java.lang.Throwable 的子类对象的引用,也就是说 throw 语句后面只能跟 java.lang.Throwable 的子类对象,否则会编译失败。那么Throwable 到底是个什么东西呢?

Throwable 类簇

Throwable 顾名思义,就是可以被 throw 的对象啦!它是 java 中所有异常的父类:

1
2
3
复制代码public class Throwable implements Serializable {
...
}

JDK 自带的异常类簇,继承关系大概是这个样子的:

首先可以看到 Throwable 分成两个大类,Exception 和 Error

Error

Error 是 java 虚拟机抛出的错误,程序运行中出现的较严重的问题。

例如,虚拟机的堆内存不够用了,就会抛出 OutOfMemoryError。这些异常用户的代码无需捕获,因为捕获了也没用。

这就好比船坏了,而船上的乘客即便知道船坏了也没办法,因为这不是他们能解决的问题。

Exception

Exception 是应用程序中可能的可预测、可恢复问题。

啥意思呢?也就是说Exception都是用户代码层面抛出的异常。换句话说,这些异常都是船上的乘客自己可以解决的。例如常见的空指针异常NullPointerException,取数组下标越界时会抛出ArrayIndexOutOfBoundException。

这些都是“乘客”的错误操作引发的问题,所以“乘客”是可以解决的。

到了这里,老源Sir听到的那道面试题,是不是就已经解决了呢?

CheckedException 和 UncheckedException

老源Sir前面也说了,这是个常见的连环问题。那么解决了第一个问题,面试官接下来会问什么呢?

通过上面一节的叙述大家可以看到,就 Throwable 体系本身,与程序员关系比较大的其实还是 Exception 及其子类。因为船上的乘客都是程序员们创造,所以他们的错误行为,程序员还是要掌握得比较透彻的。

Exception 可分为两种,CheckedException 和 UncheckedException。

  • UncheckedException

顾名思义,UncheckedException 也就是可以不被检查的异常。JVM 规定继承自 RuntimeException 的异常都是 UncheckedException.

  • CheckedException

所有非 RuntimeException 的 Exception.

那么问题来了,什么叫 被检查的异常 ? 谁检查?

throws 语句

试想一下以下这个开发场景:

  • 同学 A 写了一个工具类,编译后打成了一个 jar 包给同学 B 使用
  • 同学 B 调用这个工具类的时候,由于应用场景不同,被抛出了很多不同类型的异常,每出现一种新的异常,同学 B 都要修改代码去适配,非常痛苦。。。

为了解决这个场景中出现的问题,JVM 规定,每个函数必须对自己要抛出的异常心中有数,在函数声明时通过 throws 语句将该函数可能会抛出的异常声明出来:

1
2
复制代码 public Remote lookup(String name)
throws RemoteException, NotBoundException, AccessException;

这个声明就是前面说的被检查的异常。

那么可以不被检查的异常又是咋回事呢?

其实 CheckedExcpetion 之所以要被 Check,主要还是因为调用方是有呢你处理这些异常的。

以 java.net.URL 这个类的构造函数为例:

1
2
3
4
5
6
7
8
9
10
11
复制代码public final class URL implements java.io.Serializable {
...
public URL(String protocol, String host, int port, String file,
URLStreamHandler handler) throws MalformedURLException {
...
if (port < -1) {
throw new MalformedURLException("Invalid port number :" +
port);
}
}
}

MalformedURLException就是一种checked exception. 当输入的 port < -1 时,程序就会抛出 MalformedURLException 异常,这样调用方就可以修正port输入,得到正确的URL了。

但是有一些情况,比如下面这个函数:

1
2
3
4
复制代码public void method(){
int [] numbers = { 1, 2, 3 };
int sum = numbers[0] + numbers[3];
}

由于 numbers 数组只有3个元素,但函数中却取了第4个元素,所以调用 method() 时会抛出异常 ArrayIndexOutOfBoundsException。但是这个异常调用方是无法修正的。

对于这种情况,JVM 特意规定了 RuntimeException 及其子类的这种 UnchekcedException,可以不被 throws 语句声明,编译时不会报错。

异常处理

上面我们介绍了异常的定义和抛出方式,那么怎么捕获并处理异常呢?这个时候就轮到 try catch finally 出场了。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码public void readFile(String filePath) throws FileNotFoundException {
FileReader fr = null;
BufferedReader br = null;
try{
fr = new FileReader(filePath);
br = new BufferedReader(fr);
String s = "";
while((s = br.readLine()) != null){
System.out.println(s);
}
} catch (IOException e) {
System.out.println("读取文件时出错: " + e.getMessage());
} finally {
try {
br.close();
fr.close();
} catch (IOException ex) {
System.out.println("关闭文件时出错: " + ex.getMessage());
}
}
}

这是一个逐行打印文件内容的函数。当输入的 filePath 不存在时,会抛出 CheckedException FileNotFoundException。

在文件读取的过程中,也会出现一些意外情况可能造成一些 IOException,因此代码对可能出现的 IOException 进行了 try catch finally 的处理。 try 代码块中是正常的业务代码, catch 是对异常处理,finally 是无论try 是否异常,都要执行的代码。对于 readFile 这个函数来说,就是要关闭文件句柄,防止内存泄漏。

这里比较难受的是,由于 fr br 需要在 finally 块中执行,所以必须要在 try 前先声明。有没有优雅一点的写法呢?

这里要介绍一下 JDK 7 推出的新特性:

try-with-resources

try-with-resources 不是一个功能,而是一套让异常捕获语句更加优雅的解决方案。

对于任何实现了 java.io.Closeable 接口的类,只要在 try 后面的()中初始化,JVM 都会自动增加 finally 代码块去执行这些 Closeable 的 close()方法。

Closable 定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码public interface Closeable extends AutoCloseable {

/**
* Closes this stream and releases any system resources associated
* with it. If the stream is already closed then invoking this
* method has no effect.
*
* <p> As noted in {@link AutoCloseable#close()}, cases where the
* close may fail require careful attention. It is strongly advised
* to relinquish the underlying resources and to internally
* <em>mark</em> the {@code Closeable} as closed, prior to throwing
* the {@code IOException}.
*
* @throws IOException if an I/O error occurs
*/
public void close() throws IOException;
}

由于 FileReader 和 BufferedReader 都实现了 Closeable 接口,所以上述前面我们的 readFile 函数可以改写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码    public void readFile(String filePath) throws FileNotFoundException {
try(
FileReader fr = new FileReader(filePath);
BufferedReader br = new BufferedReader(fr)
){
String s = "";
while((s = br.readLine()) != null){
System.out.println(s);
}
} catch (IOException e) {
System.out.println("读取文件时出错: " + e.getMessage());
}
}

是不是清爽了许多呢?

finally 的几个特殊情形

finally,让我们来说一说 finally 😂。

对于一个完整的 try catch finally 代码块,它的执行顺序是:
try –> catch –> finally。

但是总有一些很奇怪的代码,值得我们研究一下:

1
2
3
4
5
6
7
复制代码    public static void returnProcess() {
try {
return;
} finally {
System.out.println("Hello");
}
}

大家猜猜 finally里的代码会不会执行呢?按道理说,先执行 try 代码块,直接 return 了, finally 应该不会再执行了吧?但实际情况是, finally 中的代码在 return 之后是会被执行的。

那再看看下面的代码,猜猜 finally 中的代码会不会执行:

1
2
3
4
5
6
7
复制代码    public void exitProcess() {
try {
System.exit(1);
} finally {
System.out.println("Hello");
}
}

有的同学会说了,前面不是说了嘛,无论 try 中的代码有没有抛出异常, finally 中的代码都会执行。但其实这个说法是不准确的。在本例中, try 中的代码调用了 System.exit(1),这条语句会直接退出 Java 进程,进程都退出了,finally 语句代码块的执行机制也就不存在了 finally 中的语句也就不会执行了。

再看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码    public static int returnInt() {
int res = 10;
try {
res = 30;
return res;
} finally {
res = 50;
}
}

public static void main(String[] args) {
System.out.println(returnInt());
}

根据代码1的经验,finally 中的语句会执行,那 res 应该被赋值为50了,因此程序会被输出50吧?

但其实并不是,程序依然会被输出 30。

神马!!!

这里请大家不要站在函数的视角去看 return 语句,而要站在 JVM 视角看 return 语句。从函数视角看, return 总是最后一行执行的语句。但是站在 JVM 视角来看,它也只是一个普通的语句而已。

小调皮同学又问了:那如果在 finally 中 return 一下会怎样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码    public static int returnInt() {
int res = 10;
try {
res = 30;
return res;
} finally {
res = 50;
return res;
}
}

public static void main(String[] args) {
System.out.println(returnInt());
}

哈哈,这个时候就会输出 50 了!

神马鬼东西!我已凌乱在风中!

这是因为 return 语句实际上是会被 “覆盖”的。也就是说,当 finally 中出现了 return 语句时,其他地方出现的 return 语句都无效了。而 finally 语句中的 return 语句时在 res = 50 这个赋值语句之后的,因此就返回了 50。

所以看得出来,在 finally 代码块中使用 return 是个非常危险的事情:

不要在 finally 中使用 return!

小结

本文主要介绍了以下几个内容:

  • Java 异常的工作原理;
  • Java 异常类簇;
  • 异常处理流程;
  • finally 语句的几个特例。

希望大家看了本文以后,不会再被 Java 异常方面的面试题问倒!由于水平有限,如果有什么不足,也很欢迎大家在留言区讨论哦!

本文转载自: 掘金

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

Deno + Oak 构建酷炫的 Todo API

发表于 2020-06-15
  • 原文地址:How to Create a Todo API in Deno and Oak
  • 原文作者:Adeel Imran
  • 原文发布时间:2020-05-29
  • 译者:hylerrix
  • 备注:本文遵循 freeCodeCamp 翻译规范,同时本文会收录在《Deno 钻研之术》的翻译篇中。
  • 备注:《Deno 钻研之术》电子书官网上线啦!deno-tutorial.js.org

序言

我是一位 JavaScript/Node 开发者,默默地喜欢甚至爱慕着 Deno。Deno 诞生之初就深深地吸引了我,此后我成为了 Deno 的忠实粉丝,期待着有朝一日能正式玩上 Deno。

本文专注于创造一个基于 REST API 设计的待做清单(Todo)应用。请记住本文中还不会涉及有关数据库操作的知识,其内容会在我之后的另一篇文章中进行详细介绍。

如果你想能够随时回顾或参考本文的代码,可以访问我的这个仓库:@adeelibr/deno-playground,收录了该系列的所有代码。

译者注:另一篇文章《How to Use MySQL With Deno and Oak》即将会被翻译,其相关 Demo 也会被收录在《Deno 钻研之术》中。

照片来自于 Bernard de Clerk / Unsplash

本文会涉及的内容

  • 创建一个最基础的服务器
  • 创建 5 个 APIs(路由 routes/控制器 controller)
  • 创建一个中间件来给 API 请求添加终端输出的日志功能
  • 创建一个 404 中间件来处理用户访问未知 API 时的情况

本文需要的知识准备

  • 一个已经安装好的 Deno 环境(别怕,我会告诉你怎么做)
  • 对 TypeScript 有浅要的了解
  • 如果你之前对 Node/Express 一定的了解就更好了(不了解也没关系,本文还是很通俗易懂的)

让我们开始吧

首先我们要先安装 Deno。由于我使用的是 Mac 操作系统,所以在这里我将使用 brew。只需要打开终端并输入这条命令即可:

1
复制代码$ brew install deno

但如果你用的是其它操作系统的话,这里有一个安装手册可以看看:deno.land installation。上面有多样化的安装方式可供你根据不同的操作系统来选择。

一旦你安装成功,关闭终端并打开另一个后,输入这条命令:

1
复制代码$ deno --version

一切正常的话终端会产生如下输出:

deno --version 命令用来查看当前安装的 Deno 是哪个版本。

棒极了!通过这个介绍我们已经成功完成了本文 10% 的挑战。

让我们继续探索,并为我们的待做清单应用创建一个后端 API 吧。

项目的准备工作

阅读下文,可以来提前来仓库里看看本文收录的所有代码:@adeelibr/deno-playground。

这里我们从零做起:

  • 创建一个名为 chapter_1:oak 的新文件夹(你也可以随意起名)。
  • 当你创建完毕后使用 cd 命令进入这个文件夹中。创建一个名为 server.ts 的文件并填充如下代码:
1
2
3
4
5
6
7
复制代码import { Application } from "https://deno.land/x/oak/mod.ts";

const app = new Application();
const port: number = 8080;

console.log('running on port ', port);
await app.listen({ port });

让我们先运行这个文件。打开你的终端并进入当前项目的根目录后,输入如下命令:

1
复制代码$ deno run --allow-net server.ts

别急别急,我会在之后来介绍 --allow-net 参数到底做了什么的 😄。

不出意外的话,你会得到如下结果:

到现在为止,我们创建了一个监听着 8080 端口的服务端应用。只有 8080 端口不被占用,这个应用才能正常执行。

如果你有过使用 JavaScript 开发的经验,你可能会注意到我们导入模块的方式有些不一样。我们在这里是这样导入模块的:

1
复制代码import { Application } from "https://deno.land/x/oak/mod.ts";

当你在终端中执行 deno run ---allow-net <file_name> 命令时,Deno 会读取你的导入信息,并在本地的全局环境中没有安装该模块的情况下安装这些模块。

第一次执行时 Deno 会尝试访问 https://deno.land/x/oak/mod.ts 模块并安装 oak 库。 Oak 是一个专注于编写 API 的 Deno Web 框架。

接下来的一行我们是这样写的:

1
复制代码const app = new Application();

这条语句为我们的应用创建了一个实例,这个实例是本文深入探索 Deno 的基石。你可以为这个实例增加路由,配置中间件(如日志中间件),编写 404 未知路由处理程序等等。

接下来我们是这样写的:

1
2
复制代码const port: number = 8080;
// const port = 8080; // => 也可以写成这样

上面两行在功能上是等价的,唯一的区别是 const port: number = 8080 告诉 TypeScript: port 变量的类型是数值类的。

如果你这样写的话:const port: number = "8080",终端会产生类似这样的报错:port 变量应该是 number 类型的,但是这类尝试用 string 类型的 “8080” 来为其赋值。

如果你想学习关于 Type 的更多类型现在就可以看看这个简单的文档:TypeScript 官方 - 基础 Types 类型。仅仅 2~3 分钟就可以重新回到本文。

在文件的最后我们是这样写的:

1
2
复制代码console.log('running on port ', port);
await app.listen({ port });

如上我们让 Deno 监听了 8080 端口,端口号是写死的。

在你的 server.ts 文件中添加如下更多的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码import { Application, Router } from "https://deno.land/x/oak/mod.ts";

const app = new Application();
const port: number = 8080;

const router = new Router();
router.get("/", ({ response }: { response: any }) => {
response.body = {
message: "hello world",
};
});
app.use(router.routes());
app.use(router.allowedMethods());

console.log('running on port ', port);
await app.listen({ port });

相比之前新增的内容是从 oak 中同时导入了 Application 和 Router 变量。

其中关于 Router 的相关代码是:

1
2
3
4
5
6
7
8
复制代码const router = new Router();
router.get("/", ({ response }: { response: any }) => {
response.body = {
message: "hello world",
};
});
app.use(router.routes());
app.use(router.allowedMethods());

我们通过 const router = new Router() 语句创建了新的 Router 示例,然后我们为其根目录 / 创建了处理 get 请求的执行方式。

让我们重点看看如下内容:

1
2
3
4
5
复制代码router.get("/", ({ response }: { response: any }) => {
response.body = {
message: "hello world",
};
});

router.get 函数接收两个参数。第一个参数是路由挂载的路径 /,第二个参数是一个函数。函数本身也接受一个对象参数,这里使用 ES6 语法将其解构,只取了其中 response 变量的值。

接下来就像之前编写 const port: number = 8080; 语句一样为 response 变量声明类型。{ response }: { response: any } 语句告诉 TypeScript 我们这里解构的 response 变量是 any 类型的。

any 类型可以帮准你避免 TypeScript 进行严格的类型检查,你可以通过这个文档来了解更多。

接下来我所编写的就是使用 response 变量,并设置 response.body.message = "hello world";。

1
2
3
复制代码response.body = {
message: "hello world",
};

最后同样重要的是,我们编写了如下两行代码:

1
2
复制代码app.use(router.routes());
app.use(router.allowedMethods());

第一行告诉 Deno 要包含我们的 router 变量里设置的所有路径(目前我们只设置了根路径),第二行让 Deno 允许任意访问方法来请求我们设置的路径,比如 GET, POST, PUT, DELETE。

到这里就可以测试运行了 ✅ ,让我们执行这行语句来看看最终会发生什么:

1
复制代码$ deno run --allow-net server.ts

---allow-net 参数告诉 Deno:用户授予了这个应用在打开的端口上访问网络的权限。

现在通过你常用的浏览器打开 http://localhost:8080 地址,就可以得到如下结果:

浏览器打开 localhost:8080 的执行结果
最难的部分差不多搞定了,但在对概念的更多了解中我们只进行了 60% 的介绍。

来自 Yoda 大师的批准
棒极了。

在我们正式开始编写待做清单的 API 前,我们最后要做的事是将如下代码:

1
2
复制代码console.log('running on port ', port);
await app.listen({ port });

替换成这样:

1
2
3
4
5
6
7
复制代码app.addEventListener("listen", ({ secure, hostname, port }) => {
const protocol = secure ? "https://" : "http://";
const url = ${protocol}${hostname ?? "localhost"}:${port};
console.log(Listening on: ${port});
});

await app.listen({ port });

我们之前的代码是先在控制台上简单的打印一条成功日志,然后再让应用开始在端口上监听,不是很优雅(译者注:有可能会在监听失败的情况下依然打印监听成功的日志)。

在替换后的版本中,我们通过 app.addEventListener("listen", ({ secure, hostname, port }) => {} 语句来向应用实例添加事件侦听器后,再让应用监听在端口上。

侦听器的第一个参数是我们想侦听的事件。一语双关,这里侦听(listen)的就是 listen 事件 😅。第二个参数是一个可以被解构的对象,这里解构出 { secure, hostname, port } 三个变量。Secure 变量是布尔类型,hostname 变量是字符串类型,port 变量是数值类型。

此时运行这个应用的话,只有在成功监听指定端口后才会输出监听成功的日志,

我们可以再向远方迈出一步,使其更加丰富多彩。让我们在 server.ts 文件的顶部添加这样一个新模块:

1
复制代码import { green, yellow } from "https://deno.land/std@0.53.0/fmt/colors.ts";

接下来我们可以在之前的事件侦听器函数里将如下代码:

1
复制代码console.log(Listening on: ${port});

替换为:

1
复制代码console.log(${yellow("Listening on:")} ${green(url)});

接下来当我们执行:

1
复制代码$ deno run --allow-net server.ts

将会打印输出如下日志:

太酷了,我们现在有了一个色彩缤纷的控制台。

如果你在某处卡住了,你可以直接访问本教程的源码仓库:@adeelibr/deno-playground。

让我们接下来创建待做清单的 API 吧。

  • 在项目的根目录创建一个 routes 文件夹,然后再文件夹里面创建一个 todo.ts 文件。
  • 与此同时在项目根目录创建一个 controllers 文件夹,再在文件夹里也创建一个 todo.ts 文件。

我们先来填充 controllers/todo.ts 文件里的内容:

1
2
3
4
5
6
7
复制代码export default {
getAllTodos: () => {},
createTodo: async () => {},
getTodoById: () => {},
updateTodoById: async () => {},
deleteTodoById: () => {},
};

我们在这里先简单地导出了一个包含很多有名字的函数的对象,这些函数目前都是空的。

接下来在 routes/todo.ts 文件中填充这些:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码import { Router } from "https://deno.land/x/oak/mod.ts";
const router = new Router();
// controller 控制器
import todoController from "../controllers/todo.ts";
router
.get("/todos", todoController.getAllTodos)
.post("/todos", todoController.createTodo)
.get("/todos/:id", todoController.getTodoById)
.put("/todos/:id", todoController.updateTodoById)
.delete("/todos/:id", todoController.deleteTodoById);

export default router;

对于编写过 Node 和 Express 的人来说,对如上的代码风格一定很熟悉。

其中包括从 oak 中导入了 Route 变量并通过 const router = new Router(); 语句将其实例化。

接下来我们导入我们的控制器:

1
复制代码import todoController from "../controllers/todo.ts";

这里需要注意的是:在 Deno 中我们每次导入一个本地文件到项目中的时候,我们都必须填写完整这个文件的后缀。否则 Deno 是不知道用户想要导入的文件后缀到底以 .js 还是 .ts 结尾。

接下来我们通过如下代码为应用配置了我们需要的所有 RESTful 风格的路径。

1
2
3
4
5
6
复制代码router
.get("/todos", todoController.getAllTodos)
.post("/todos", todoController.createTodo)
.get("/todos/:id", todoController.getTodoById)
.put("/todos/:id", todoController.updateTodoById)
.delete("/todos/:id", todoController.deleteTodoById);

上面的代码会将路径解析为这样:

请求方式 API 路由
GET /todos
GET /todos/:id
POST /todos
PUT /todos/:id
DELETE /todos/:id

最后我们通过 export default router; 语句来将配置好的路由导出。

此时我们已经完成了创建路由的工作(但是由于我们的控制器还是空的函数,所以每个路由并都不会做任何反应,我们将向其中添加功能)。

在我们开始向每个控制器添加功能之前的最后一个难题是,我们需要将此 router 挂载到我们的 app 实例上。

因此回到 server.ts 文件中我们这样做:

  • 将这行代码添加至文件顶部:
1
2
复制代码// routes 路由
import todoRouter from "./routes/todo.ts";
  • 删除这一段代码:
1
2
3
4
5
6
7
8
复制代码const router = new Router();
router.get("/", ({ response }: { response: any }) => {
response.body = {
message: "hello world",
};
});
app.use(router.routes());
app.use(router.allowedMethods());
  • 将其替换为:
1
2
复制代码app.use(todoRouter.routes());
app.use(todoRouter.allowedMethods());

终于搞定了,你的 server.ts 现在应该是这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码import { Application } from "https://deno.land/x/oak/mod.ts";
import { green, yellow } from "https://deno.land/std@0.53.0/fmt/colors.ts";

// routes
import todoRouter from "./routes/todo.ts";

const app = new Application();
const port: number = 8080;

app.use(todoRouter.routes());
app.use(todoRouter.allowedMethods());

app.addEventListener("listen", ({ secure, hostname, port }) => {
const protocol = secure ? "https://" : "http://";
const url = `${protocol}${hostname ?? "localhost"}:${port}`;
console.log(
`${yellow("Listening on:")} ${green(url)}`,
);
});

await app.listen({ port });

如果你在某处卡住了,你可以直接访问本教程的源码仓库:@adeelibr/deno-playground。

由于路由的控制器上暂时没有任何功能,现在一起来手动为我们的控制器添加功能。

在此之前我们得先创建两个(小)文件:

  • 在项目的根目录上创建一个 interfaces 文件夹并在其中创建一个 Todo.ts(确保 Todo 首字母大写,因为如果不这样做,它将不会在此处给出任何语法错误——这只是一种约定)。
  • 同时在项目根目录创建一个 stubs 文件夹并在其中创建一个 todos.ts 文件。

在 interfaces/Todo.ts 文件中编写如下接口说明:

1
2
3
4
5
复制代码export default interface Todo {
id: string,
todo: string,
isCompleted: boolean,
}

什么是 interface(接口)?

要知道 TypeScript 的核心功能之一是检查一个变量的类型。就像前文的 const port: number = 8080 和 { response }: { response : any } 一样,我们也可以检测一个变量是否为对象类型。

在 TypeScript 中,interface 负责命名类型,并且是定义代码内外类型约束的有效方法。

这里有一个有关 interface 的示例:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码// 写了个接口
interface LabeledValue {
label: string;
}

// 此函数的labeledObj 参数是符合 LabeledValue 接口类型的
function printLabel(labeledObj: LabeledValue) {
console.log(labeledObj.label);
}

let myObj = {label: "Size 10 Object"};
printLabel(myObj);

希望如上示例可以让你对 interface 有更多的了解。如果你想了解更多的信息可以查看:Interfaces 官方文档。

现在关于 interface 的知识已经介绍够了,我们一起来模拟一些假数据(因为本文不涉及有关数据库的操作)。

我们在 stubs/todos.ts 文件中来为 todos 变量填充一些模拟数据。这样即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码import { v4 } from "https://deno.land/std/uuid/mod.ts";
// interface
import Todo from '../interfaces/Todo.ts';

let todos: Todo[] = [
{
id: v4.generate(),
todo: 'walk dog',
isCompleted: true,
},
{
id: v4.generate(),
todo: 'eat food',
isCompleted: false,
},
];

export default todos;
  • 有两件需要注意的事项:我们这里引用了一个新的模块并且通过 import { v4 } from "https://deno.land/std/uuid/mod.ts"; 语句解构了其中的 v4 变量。接下来我们每次使用 v4.generate() 语句都能生成一个随机的 ID 字符串。这个 id 不能是 number 类型的,而需是 string 类型的,因为我们之前的 Todo 接口已经声明了 id 的类型必须是字符串。
  • 另一个需要注意的是 let todos: Todo[] = [] 语句。此语句告诉 Deno 我们的 todos 变量是一个 Todo 数组(此时编译器将会知道数组的每一个元素都是 {id: _string_, todo: _string_ & isCompleted: _boolean_} 类型的,并不允许其他任何类型)。

如果你想了解更多的信息可以查看:Interfaces 官方文档。

太棒了,你已经进行到如此之远,再接再厉。

巨石强森感激你所做的一切努力。

让我们关注在控制器上

在你的 controllers/todo.ts 文件中:

1
2
3
4
5
6
7
复制代码export default {
getAllTodos: () => {},
createTodo: async () => {},
getTodoById: () => {},
updateTodoById: async () => {},
deleteTodoById: () => {},
};

让我们先编写 getAllTodos 控制器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码// stubs
import todos from "../stubs/todos.ts";

export default {
/**
* @description 获取所有 todos
* @route GET /todos
*/
getAllTodos: ({ response }: { response: any }) => {
response.status = 200;
response.body = {
success: true,
data: todos,
};
},
createTodo: async () => {},
getTodoById: () => {},
updateTodoById: async () => {},
deleteTodoById: () => {},
};

在开始介绍这段代码前,让我解释下每个控制器都有的参数——context(上下文)参数。

因此我们才能解构 getAllTodos: (context) => {} 为:

1
复制代码getAllTodos: ({ request, response, params }) => {}

并且自从哪个我们使用 typescript 后,我们需要为每个这样的变量添加类型声明:

1
2
3
4
5
6
7
复制代码getAllTodos: (
{ request, response, params }: {
request: any,
response: any,
params: { id: string },
},
) => {}

此时我们为解构的三个变量 { request, response, params } 添加了类型说明。

  • request 变量有关用户发来的请求(比如请求头和 JSON 类的请求体)。
  • response 变量有关服务器端通过 API 返回的信息。
  • params 变量是我们在路由配置中定义的参数,如下:
1
复制代码.get("/todos/:id", ({ params}: { params: { id: string } }) => {})

/todos/:id 中的 :id 是一个变量,用来从 URL 中获得动态的数据。因此当用户访问这个 API (比如 /todos/756)的时候,756 则是 :id 参数的值。并且我们知道 URL 里的这个值的类型是 string 类的。

现在我们有了基本的声明后,让我们回到我们的 todos 控制器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码// stubs
import todos from "../stubs/todos.ts";

export default {
/**
* @description 获取所有 todos
* @route GET /todos
*/
getAllTodos: ({ response }: { response: any }) => {
response.status = 200;
response.body = {
success: true,
data: todos,
};
},
createTodo: async () => {},
getTodoById: () => {},
updateTodoById: async () => {},
deleteTodoById: () => {},
};

对于 getAllTodos 方法来说我们只需要简单的返回结果。如果你记得之前说的,会想起来 response 是用来处理服务器想要给用户返回的数据。

对于编写过 Node 和 Express 的人来说,这里的一大不同是我们不需要 return 响应对象。 Deno 会自动为我们执行此操作。

我们需要做的第一件事是通过 response.status 来设置此次请求的响应码是 200。

更多 HTTP 响应码可以看 MDN 上的 HTTP 响应状态码文档。

另一件事是设置 response.body 的值为:

1
2
3
4
复制代码{
success: true,
data: todos
}

重新运行我们的服务器:

1
复制代码$ deno run --allow-net server.ts

修订:–allow-net 属性告诉 Deno,此应用程序授予用户通过打开的端口访问网络的权限。

一旦你的服务端示例跑通,挺可以通过 GET /todos 方式来请求这个 API。这里我使用的是 Google Chrome 浏览器下的一个插件 postman,在这里下载。

你可以使用任意的 REST 风格的客户端,我喜欢使用 postman 是因为它真的很简单好用。

在 Postman 中,打开一个新的标签页。设置请求方式为 GET 请求并且在 URL 输入框中输入 http://localhost:8080/todos 。点击 Send 按钮便会得到想要的结果:

GET /todos API 返回结果。

酷!一个 API 搞定了,还剩 4 个等着我们 👍👍。

如果你在某处卡住了,可以在配套的源码仓库中寻找答案。

让我们关注下一个控制器吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
复制代码import { v4 } from "https://deno.land/std/uuid/mod.ts";
// interfaces
import Todo from "../interfaces/Todo.ts";
// stubs
import todos from "../stubs/todos.ts";

export default {
getAllTodos: () => {},
/**
* @description Add a new todo
* @route POST /todos
*/
createTodo: async (
{ request, response }: { request: any; response: any },
) => {
const body = await request.body();
if (!request.hasBody) {
response.status = 400;
response.body = {
success: false,
message: "No data provided",
};
return;
}

// 如果请求体验证通过,则返回新增后的所有 todos
let newTodo: Todo = {
id: v4.generate(),
todo: body.value.todo,
isCompleted: false,
};
let data = [...todos, newTodo];
response.body = {
success: true,
data,
};
},
getTodoById: () => {},
updateTodoById: async () => {},
deleteTodoById: () => {},
};

由于我们将要添加一个新的 Todo 到列表中,因此我在 controller 文件中导入了 2 个通用模块:

  • import { v4 } from "https://deno.land/std/uuid/mod.ts" 语句用来为每一个 todo 元素创建一个独一无二的标识。
  • import Todo from "../interfaces/Todo.ts"; 语句用来保证新建的 todo 遵循 todo 元素的接口格式标准。

我们的 createTodo 控制器是 async 异步的代表函数中会使用到一些 Promise 技术。

让我们来截断说明其中的小片段:

1
2
3
4
5
6
7
8
9
复制代码const body = await request.body();
if (!request.hasBody) {
response.status = 400;
response.body = {
success: false,
message: "No data provided",
};
return;
}

首先我们读取请求体中用户传来的的 JSON 内容。接下来我们使用 oak 的内置 request.hasBody 方法来检查用户传来的内容是否为空。如果为空,我们将进入 if (!request.hasBody) {} 代码块中执行相关操作。

里面我们将响应体的状态码设置成 400(400 代表着用户端出现了一些本不该出现的错误),并且服务端返回的响应体为 {success: false, message: "no data provided }。之后程序直接执行 return; 语句来保证接下来的代码不会被执行。

接下来我们这样编写:

1
2
3
4
5
6
7
8
9
10
11
复制代码// 如果请求体验证通过,则返回新增后的所有 todos
let newTodo: Todo = {
id: v4.generate(),
todo: body.value.todo,
isCompleted: false,
};
let data = [...todos, newTodo];
response.body = {
success: true,
data,
};

其中我们通过如下代码创建了一个全新的 todo 元素:

1
2
3
4
5
复制代码let newTodo: Todo = {
id: v4.generate(),
todo: body.value.todo,
isCompleted: false,
};

let newTodo: Todo = {} 保证 newTodo 变量的值和其它 todo 元素一样都遵循相同的接口格式。然后,我们使用 v4.generate() 分配一个随机 ID,将 todo 的键值设置为 body.value.todo 并将 isCompleted 变量值设置为 false。

这里需要知道的是,用户给我们发的内容我们可以通过 oak 中的 body.value 来获取。

接下来我们这样做:

1
2
3
4
5
复制代码let data = [...todos, newTodo];
response.body = {
success: true,
data,
};

这里将 newTodo 添加到整个 todo 列表中中,并在响应体中返回 {success: true & data: data。

此时这个控制器也运行成功了 ✅。

让我们重新运行我们的服务器:

1
复制代码$ deno run --allow-net server.ts

在 postman 中,我再打开一个新的标签页。设置请求的方式为 POST 类型,并在 URL 输入框中输入 http://localhost:8080/todos 后,点击 Send 便会得到如下结果:

因为上面的请求体中发送了空的内容,所以得到了 400 错误响应码及其错误原因。

但如果我们给请求体中加入如下 JSON 内容,并重新发送:

通过 { todo: “eat a lamma” } 来 POST /todos 后的成功结果,我们可以看到新的元素已经加入到列表中。

酷,我买可以看到我们的 API 已经一个个以预期的方式执行成功了。

两个 API 搞定,还剩三个要做。

我们快要搞定了,因为大部分难的内容已经介绍完毕。☺️ 🙂🤗🤩

让我们看看第三个 API:

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
复制代码import { v4 } from "https://deno.land/std/uuid/mod.ts";
// interfaces
import Todo from "../interfaces/Todo.ts";
// stubs
import todos from "../stubs/todos.ts";

export default {
getAllTodos: () => {},
createTodo: async () => {},
/**
* @description 通过 ID 获取 todo
* @route GET todos/:id
*/
getTodoById: (
{ params, response }: { params: { id: string }; response: any },
) => {
const todo: Todo | undefined = todos.find((t) => {
return t.id === params.id;
});
if (!todo) {
response.status = 404;
response.body = {
success: false,
message: "No todo found",
};
return;
}

// 如果 todo 找到了
response.status = 200;
response.body = {
success: true,
data: todo,
};
},
updateTodoById: async () => {},
deleteTodoById: () => {},
};

我们先来聊聊 GET todos/:id 下的控制器,此控制器会通过 ID 来查找相应的 todo 元素。

让我们继续通过截取小片段来深入分析:

1
2
3
4
5
6
7
8
9
复制代码const todo: Todo | undefined = todos.find((t) => t.id === params.id);
if (!todo) {
response.status = 404;
response.body = {
success: false,
message: "No todo found",
};
return;
}

在第一行我们声明了一个 const todo 变量并将其类型设置为 Todo 或 undefined 类。因此 todo 元素只能是符合 Todo 接口规范的变量或者是一个 undefined 值,而不能是其它任何类型。

我们接下来使用 todos.find((t) => t.id === params.id); 语句来通过 Array.find() 方法和 params.id 的值来查找指定的 todo 元素。如果找到了我们会得到 Todo 类型的 todo 元素,发否则得到一个 undefined 值。

如果得到的 todo 的值是 undefined 的,意味着如下 if 条件中的代码会执行:

1
2
3
4
5
6
7
8
复制代码if (!todo) {
response.status = 404;
response.body = {
success: false,
message: "No todo found",
};
return;
}

这里我们设置响应的状态码为 404,代表着 not found 没有找到相关元素,并且返回体的格式也是标准的 { status, message }。

很酷不是嘛? 😄

接下来我们简单地编写:

1
2
3
4
5
6
复制代码// 如果 todo 找到了
response.status = 200;
response.body = {
success: true,
data: todo,
};

设置一个响应状态码为 200 的响应体并返回 success: true & data: todo 内容。

我们来在 postman 中测试:

先一起重新启动服务端:

1
复制代码$ deno run --allow-net server.ts

在 postman 中,继续打开一个新的标签页,设置请求方式为 GET 请求并在 URL 输入框中输入 http://localhost:8080/todos/:id 后,点击 Send 来执行请求。

自从我们使用了随机 ID 生成器,首先我们需要调取获取所有元素的 API。并在元素列表里选取一个 ID 来测试这个新的 API。每次你重启 Deno 程序时,新的 ID 都会被重新生成。

我们这样输入:

服务端返回 404,且告诉我们没有相关数据被找到。

但如果输入一个正确的 ID,服务端会返回其 ID 和这个 ID 的一样的数据并且响应状态为 200。

如果你需要参考本文的源码可以访问这里:@adeelibr/deno-playground。

不错,3 个 API 搞定,只剩 2 个了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
复制代码import { v4 } from "https://deno.land/std/uuid/mod.ts";
// interfaces
import Todo from "../interfaces/Todo.ts";
// stubs
import todos from "../stubs/todos.ts";

export default {
getAllTodos: () => {},
createTodo: async () => {},
getTodoById: () => {},
/**
* @description Update todo by id
* @route PUT todos/:id
*/
updateTodoById: async (
{ params, request, response }: {
params: { id: string },
request: any,
response: any,
},
) => {
const todo: Todo | undefined = todos.find((t) => t.id === params.id);
if (!todo) {
response.status = 404;
response.body = {
success: false,
message: "No todo found",
};
return;
}

// 如果找到相应 todo 则更新它
const body = await request.body();
const updatedData: { todo?: string; isCompleted?: boolean } = body.value;
let newTodos = todos.map((t) => {
return t.id === params.id ? { ...t, ...updatedData } : t;
});
response.status = 200;
response.body = {
success: true,
data: newTodos,
};
},
deleteTodoById: () => {},
};

让我们来探讨下一个控制器 PUT todos/:id。这个控制器会更新一个元素的内容。

我们继续截断代码来细看:

1
2
3
4
5
6
7
8
9
复制代码const todo: Todo | undefined = todos.find((t) => t.id === params.id);
if (!todo) {
response.status = 404;
response.body = {
success: false,
message: "No todo found",
};
return;
}

这里做的和之前控制器做的一样,所以我就不深入介绍了。

高级提示:如果你想将这段代码设为通用代码块,然后在两个控制器中都使用它,完全可以。

接下来我们这样做:

1
2
3
4
5
6
7
8
9
10
11
复制代码// 如果找到相应 todo 则更新它
const body = await request.body();
const updatedData: { todo?: string; isCompleted?: boolean } = body.value;
let newTodos = todos.map((t) => {
return t.id === params.id ? { ...t, ...updatedData } : t;
});
response.status = 200;
response.body = {
success: true,
data: newTodos,
};

其中我想在这里重点讨论的代码如下:

1
2
3
4
复制代码const updatedData: { todo?: string; isCompleted?: boolean } = body.value;
let newTodos = todos.map((t) => {
return t.id === params.id ? { ...t, ...updatedData } : t;
});

首先,我们执行 const updatedData = body.value,然后将类型检查添加到 updatedData 上,如下所示:

1
复制代码updatedData: { todo?: string; isCompleted?: boolean }

这一小段代码告诉 TS:updatedData 变量是一个有可能包含也有可能不包含 todo、isComplete 熟悉的对象。

接下来我们遍历每一个 todo 元素,就像这样:

1
2
3
复制代码let newTodos = todos.map((t) => {
return t.id === params.id ? { ...t, ...updatedData } : t;
});

其中当 params.id 和 t.id 的值一致时,我们将此时的对象的内容重新覆盖为用户传来的想要更改为的内容。

我们也编写成功了这个 API。

让我们重新启动服务器:

1
复制代码$ deno run --allow-net server.ts

在 Postman 中打开一个标签页。将请求方式设置为 PUT,并在 URL 输入框中输入 http://localhost:8080/todos/:id 后,点击 Send:

自从我们使用了随机 ID 生成器,首先我们需要调取获取所有元素的 API。并在元素列表里选取一个 ID 来测试这个新的 API。

每次重启 Deno 程序时,新的 ID 都会被重新生成。

如上返回了 404 状态码并提示我们没有找到相关的 todo 元素。

提供一个已知的 ID,并且请求体中填写需要改变的内容。服务端会返回一个更改后的元素及其它所有元素。

酷,四个 API 搞定我们只剩最后一个需要做。

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
复制代码import { v4 } from "https://deno.land/std/uuid/mod.ts";
// interfaces
import Todo from "../interfaces/Todo.ts";
// stubs
import todos from "../stubs/todos.ts";

export default {
getAllTodos: () => {},
createTodo: async () => {},
getTodoById: () => {},
updateTodoById: async () => {},
/**
* @description 通过 ID 删除指定 todo
* @route DELETE todos/:id
*/
deleteTodoById: (
{ params, response }: { params: { id: string }; response: any },
) => {
const allTodos = todos.filter((t) => t.id !== params.id);

// remove the todo w.r.t id and return
// remaining todos
response.status = 200;
response.body = {
success: true,
data: allTodos,
};
},
};

让我们最后来讨论下 Delete todos/:id 控制器的执行过程,此控制器会通过给定的 ID 来删除相应 todo 元素。

我们这里只需简单地加一条过滤方法:

1
复制代码const allTodos = todos.filter((t) => t.id !== params.id);

遍历所有元素并删除 todo.id 和 params.id 值一样的元素,并返回其余所有元素。

接下来我们这样编写:

1
2
3
4
5
6
复制代码// 删除这个 todo 并返回其它所有内容
response.status = 200;
response.body = {
success: true,
data: allTodos,
};

只需返回所有没有相同 todo.id 的待办事项清单即可。

让我们重启服务器:

1
复制代码$ deno run --allow-net server.ts

在 Postman 中打开一个标签页。将请求方式设置为 PUT,并在 URL 输入框中输入 http://localhost:8080/todos/:id 后,点击 Send:

自从我们使用了随机 ID 生成器,首先我们需要调取获取所有元素的 API。并在元素列表里选取一个 ID 来测试这个新的 API。每次你重启 Deno 程序时,新的 ID 都会被重新生成。

每次重启 Deno 程序时,新的 ID 都会被重新生成。

我们终于搞定了所有 5 个 API。

现在我们只剩下两件事了:

  • 增加一个 404 中间件,来让用户访问不存在的路由时得到该有的提示;
  • 增加一个日志 API 来打印所有请求的执行时间。

创建一个 404 路由中间件

在项目的根目录中创建一个名为 middlewares 的文件夹,并在其中创建一个名为 notFound.ts 的文件后,添加如下代码:

1
2
3
4
5
6
7
复制代码export default ({ response }: { response: any }) => {
response.status = 404;
response.body = {
success: false,
message: "404 - Not found.",
};
};

如上代码并没有引入什么新的知识点——它对于我们的控制器结构来使用了说很熟悉的风格。这里仅仅返回了 404 状态码(代表着相关路由没有找到)并且返回了一段 JSON 内容: { success, message }。

接下来在你的 server.ts 文件中增加如下内容:

  • 在文件顶部添加相关导入语句:
1
2
复制代码// 没有找到
import notFound from './middlewares/notFound.ts';
  • 接下来在 app.use(todoRouter.allowedMethods()) 下面增加如下内容:
1
2
3
4
5
复制代码app.use(todoRouter.routes());
app.use(todoRouter.allowedMethods());

// 404 page
app.use(notFound);

执行顺序在这里很重要:每当我们尝试访问 API 路由时,它都会首先匹配/检查来自 todoRouter 的路由。 如果没有找到,它将执行 app.use(notFound); 语句。

让我们看看是否能成功运行。

重启服务器:

1
复制代码$ deno run --allow-net server.ts

在 Postman 中打开一个标签页。将请求方式设置为 PUT,并在 URL 输入框中输入 http://localhost:8080/todos/:id 后,点击 Send:

因此,我们现在有了一个路由中间件,将 app.use(notFound); 放在 server.ts 文件中其它路由的后面。如果请求路由不存在,它将执行并返回 404 状态代码(表示未找到),并像往常一样简单地返回一个响应消息,即 {success, message}。

高级贴士:我们已经约束 {success, message} 是在请求失败时返回的格式,{success, data} 是在请求成功时候返回给用户的格式。因此,我们甚至可以将其作为对象接口,并将其添加到项目中,以确保接口的一致性和进行安全的类型检查。

酷,现在我们已经搞定了其中一个中间件——让我们添加另一个中间件来在终端打印日志吧。

切记:如果你在某些地方卡住了,可以看看文章的配套源码:@adeelibr/deno-playground。

终端中打印日志的中间件

在你的 middlewares 文件夹中创建一个新的 logger.ts 文件并填充如下内容:

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
复制代码import {
green,
cyan,
white,
bgRed,
} from "https://deno.land/std@0.53.0/fmt/colors.ts";

const X_RESPONSE_TIME: string = "X-Response-Time";

export default {
logger: async (
{ response, request }: { response: any, request: any },
next: Function,
) => {
await next();
const responseTime = response.headers.get(X_RESPONSE_TIME);
console.log(`${green(request.method)} ${cyan(request.url.pathname)}`);
console.log(`${bgRed(white(String(responseTime)))}`);
},
responseTime: async (
{ response }: { response: any },
next: Function,
) => {
const start = Date.now();
await next();
const ms: number = Date.now() - start;
response.headers.set(X_RESPONSE_TIME, `${ms}ms`)
},
};

在 server.ts 文件中添加如下代码:

  • 文件顶部添加 import 语句来导入模块:
1
2
复制代码// logger
import logger from './middlewares/logger.ts';
  • 在之前提到的 todoRouter 代码前这样增加中间件代码:
1
2
3
4
5
6
复制代码// 以下代码的编写顺序很重要
app.use(logger.logger);
app.use(logger.responseTime);

app.use(todoRouter.routes());
app.use(todoRouter.allowedMethods());

现在我们来讨论到底发生了什么。

我们先来讨论 logger.ts 文件,先截断看这里:

1
2
3
4
5
6
复制代码import {
green,
cyan,
white,
bgRed,
} from "https://deno.land/std@0.53.0/fmt/colors.ts";

我在这里导入了有关终端颜色的模块,想要用在我们的日志中间件上。

这里和我们在之前的 server.ts 文件中使用 eventListener 的方式很像。我们将使用有颜色的日志信息来记录我们的 API 请求。

接下来我们设置了 const X_RESPONSE_TIME: string = "X-Response-Time";。这条语句用来在与用户请求到来时给响应头的 Header 中注入 X_RESPONSE_TIME 变量的值:X-Response-Time。我会在后面进行说明。

然后我们像这样一样导出一个对象:

1
2
3
4
复制代码export default {
logger: async ({ response, request }, next) {}
responseTime: async ({ response }, next) {}
};

此时我们在 server.ts 中这样使用:

1
2
3
复制代码// 以下两行的编写顺序很重要
app.use(logger.logger);
app.use(logger.responseTime);

现在我们来讨论下日志中间件到底做了什么,并且通过 next() 来说明其执行过程。

上图为调用 GET / todos API 时日志记录中间件的执行顺序。

这里和以前的控制器唯一的区别是使用了 next() 函数,此函数有助于我们从一个控制器跳到另一个控制器,如上图所示。

因此有了这段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码export default {
logger: async (
{ response, request }: { response: any, request: any },
next: Function,
) => {
await next();
const responseTime = response.headers.get(X_RESPONSE_TIME);
console.log(${green(request.method)} ${cyan(request.url.pathname)});
console.log(${bgRed(white(String(responseTime)))});
},
responseTime: async (
{ response }: { response: any },
next: Function,
) => {
const start = Date.now();
await next();
const ms: number = Date.now() - start;
response.headers.set(X_RESPONSE_TIME, ${ms}ms)
},
};

请留意我们在 server.ts 中的编写方式:

1
2
3
4
5
6
复制代码// 以下代码的编写顺序很重要
app.use(logger.logger);
app.use(logger.responseTime);

app.use(todoRouter.routes());
app.use(todoRouter.allowedMethods());

这里的执行顺序如下:

  • logger.logger 中间件
  • logger.responseTime 中间件
  • todoRouter 控制器(无论用户想要访问什么路由,出于解释的目的,这里假设用户都调用 GET /todos 来获取所有待办事项)。

因此会先执行 logger.logger 的内容:

1
2
3
4
5
6
7
8
9
复制代码logger: async (
{ response, request }: { response: any, request: any },
next: Function,
) => {
await next();
const responseTime = response.headers.get(X_RESPONSE_TIME);
console.log(${green(request.method)} ${cyan(request.url.pathname)});
console.log(${bgRed(white(String(responseTime)))});
},

当遇到 await next() 时会立即跳到下一个中间件——responseTime 上。

再次分享此图来回顾这个过程。

在 responseTime 中,只会先执行如下两行(参考上图的执行过程 2):

1
2
复制代码const start = Date.now();
await next();

然后跳转到 getAllTodos 控制器中并执行 getAllTodos 里的所有代码。

在这个控制器中我们不需要使用 next(),它会自动返回到 responseTime 中间件中,并执行接下来的内容:

1
2
复制代码const ms: number = Date.now() - start;
response.headers.set(X_RESPONSE_TIME, ${ms}ms)

现在,我们便了解了 2、3、4 的执行顺序过程(参见上图)。

这里是发生的具体过程:

  • 我们通过执行 const start = Date.now(); 来捕获以 ms 为单位的数据。然后,我们立即调用 next() 来跳转到 getAllTodos 控制器并运行其中的代码。然后再次返回到 responseTime 控制器中。
  • 然后,通过执行 const ms: number = Date.now() - start; 来减去请求刚来的时间。在这里,它将返回一个毫秒差的数字,将告诉 Deno 执行 getAllTodos 控制器所花费的所有时间。

再次分享这个文件来回顾这个过程:

  • 接下来我们在 response 响应头的 Headers 中设置:
1
复制代码response.headers.set(X_RESPONSE_TIME, ${ms}ms)

将 X-Response-Time 的值设置为 Deno getAllTodos API 所花费的毫秒数。

  • 然后从执行顺序 4 返回到执行顺序 5(参考上图)。

在这里简单地编写:

1
2
3
复制代码const responseTime = response.headers.get(X_RESPONSE_TIME);
console.log(${green(request.method)} ${cyan(request.url.pathname)});
console.log(${bgRed(white(String(responseTime)))});
  • 打印日志时我们从 X-Response-Time 中获取到了执行 API 耗费的时间。
  • 接下来我们用带有颜色的字体将其打印在终端。

request.method 返回用户请求的方式,比如 GET, PUT 等,同时 request.url.pathname 返回用户请求的路径,比如 /todos。

让我们看看是否能成功运行。

重启服务器:

1
复制代码$ deno run --allow-net server.ts

在 Postman 中打开一个标签页。将请求方式设置为 GET,并在 URL 输入框中输入 http://localhost:8080/todos 后,点击 Send:

在 Postman 中多请求几次 API,然后返回到控制台查看日志时,应该看到类似如下的内容:

每个 API 请求都会被日志中间件记录在终端。

就是这样 —— 我们搞定了这一切。

如果你在哪里卡住了,可以看看本文的全部源码:github.com/adeelibr/de…

我希望你觉得本文会很有帮助,并且真的能帮助你学到一些新的知识。

如果你喜欢,欢迎分享到社交平台上。如果你想要深入交流,可以在 Twitter 上与我联系。

本文转载自: 掘金

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

学习 redux 源码整体架构,深入理解 redux 及其中

发表于 2020-06-15
  1. 前言

你好,我是若川。这是学习源码整体架构系列第八篇。整体架构这词语好像有点大,姑且就算是源码整体结构吧,主要就是学习是代码整体结构,不深究其他不是主线的具体函数的实现。本篇文章学习的是实际仓库的代码。

要是有人说到怎么读源码,正在读文章的你能推荐我的源码系列文章,那真是太好了。

学习源码整体架构系列文章如下:

1.学习 jQuery 源码整体架构,打造属于自己的 js 类库

2.学习 underscore 源码整体架构,打造属于自己的函数式编程类库

3.学习 lodash 源码整体架构,打造属于自己的函数式编程类库

4.学习 sentry 源码整体架构,打造属于自己的前端异常监控SDK

5.学习 vuex 源码整体架构,打造属于自己的状态管理库

6.学习 axios 源码整体架构,打造属于自己的请求库

7.学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理

8.学习 redux 源码整体架构,深入理解 redux 及其中间件原理

感兴趣的读者可以点击阅读。

其他源码计划中的有:express、vue-rotuer、react-redux 等源码,不知何时能写完(哭泣),欢迎持续关注我(若川)。

源码类文章,一般阅读量不高。已经有能力看懂的,自己就看了。不想看,不敢看的就不会去看源码。

所以我的文章,尽量写得让想看源码又不知道怎么看的读者能看懂。

阅读本文你将学到:

  1. git subtree 管理子仓库
  2. 如何学习 redux 源码
  3. redux 中间件原理
  4. redux 各个API的实现
  5. vuex 和 redux 的对比
  6. 等等

1.1 本文阅读最佳方式

把我的redux源码仓库 git clone https://github.com/lxchuan12/redux-analysis.git克隆下来,顺便star一下我的redux源码学习仓库^_^。跟着文章节奏调试和示例代码调试,用chrome动手调试印象更加深刻。文章长段代码不用细看,可以调试时再细看。看这类源码文章百遍,可能不如自己多调试几遍。也欢迎加我微信交流ruochuan12。

  1. git subtree 管理子仓库

写了很多源码文章,vuex、axios、koa等都是使用新的仓库克隆一份源码在自己仓库中。
虽然电脑可以拉取最新代码,看到原作者的git信息。但上传到github后。读者却看不到原仓库作者的git信息了。于是我找到了git submodules 方案,但并不是很适合。再后来发现了git subtree。

简单说下 npm package和git subtree的区别。
npm package是单向的。git subtree则是双向的。

具体可以查看这篇文章@德来(原有赞大佬):用 Git Subtree 在多个 Git 项目间双向同步子项目,附简明使用手册

学会了git subtree后,我新建了redux-analysis项目后,把redux源码4.x(截止至2020年06月13日,4.x分支最新版本是4.0.5,master分支是ts,文章中暂不想让一些不熟悉ts的读者看不懂)分支克隆到了我的项目里的一个子项目,得以保留git信息。

对应命令则是:

1
复制代码git subtree add --prefix=redux https://github.com/reduxjs/redux.git 4.x
  1. 调试 redux 源码准备工作

之前,我在知乎回答了一个问题若川:一年内的前端看不懂前端框架源码怎么办?
推荐了一些资料,阅读量还不错,大家有兴趣可以看看。主要有四点:

1.借助调试

2.搜索查阅相关高赞文章

3.把不懂的地方记录下来,查阅相关文档

4.总结

看源码调试很重要,所以我的每篇源码文章都详细描述(也许有人看来是比较啰嗦…)如何调试源码。

断点调试要领:

赋值语句可以一步按F10跳过,看返回值即可,后续详细再看。

函数执行需要断点按F11跟着看,也可以结合注释和上下文倒推这个函数做了什么。

有些不需要细看的,直接按F8走向下一个断点

刷新重新调试按F5

调试源码前,先简单看看 redux 的工作流程,有个大概印象。

redux 工作流程

redux 工作流程

3.1 rollup 生成 sourcemap 便于调试

修改rollup.config.js文件,output输出的配置生成sourcemap。

1
2
3
4
5
6
7
8
9
复制代码// redux/rollup.config.js 有些省略
const sourcemap = {
sourcemap: true,
};

output: {
// ...
...sourcemap,
}

安装依赖

1
2
3
4
5
复制代码git clone http://github.com/lxchuan12/redux-analysis.git
cd redux-analysi/redux
npm i
npm run build
# 编译结束后会生成 sourcemap .map格式的文件到 dist、es、lib 目录下。

仔细看看redux/examples目录和redux/README。

这时我在根路径下,新建文件夹examples,把原生js写的计数器redux/examples/counter-vanilla/index.html,复制到examples/index.html。同时把打包后的包含sourcemap的redux/dist目录,复制到examples/dist目录。

修改index.html的script的redux.js文件为dist中的路径。

为了便于区分和调试后续html文件,我把index.html重命名为index.1.redux.getState.dispatch.html。

1
2
3
4
5
复制代码# redux-analysis 根目录
# 安装启动服务的npm包
npm i -g http-server
cd examples
hs -p 5000

就可以开心的调试啦。可以直接克隆我的项目git clone http://github.com/lxchuan12/redux-analysis.git。本地调试,动手实践,容易消化吸收。

  1. 通过调试计数器例子的学习 redux 源码

接着我们来看examples/index.1.redux.getState.dispatch.html文件。先看html部分。只是写了几个 button,比较简单。

1
2
3
4
5
6
7
8
9
复制代码<div>
<p>
Clicked: <span id="value">0</span> times
<button id="increment">+</button>
<button id="decrement">-</button>
<button id="incrementIfOdd">Increment if odd</button>
<button id="incrementAsync">Increment async</button>
</p>
</div>

js部分,也比较简单。声明了一个counter函数,传递给Redux.createStore(counter),得到结果store,而store是个对象。render方法渲染数字到页面。用store.subscribe(render)订阅的render方法。还有store.dispatch({type: 'INCREMENT' })方法,调用store.dispatch时会触发render方法。这样就实现了一个计数器。

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
复制代码function counter(state, action) {
if (typeof state === 'undefined') {
return 0
}

switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}

var store = Redux.createStore(counter)
var valueEl = document.getElementById('value')

function render() {
valueEl.innerHTML = store.getState().toString()
}
render()
store.subscribe(render)

document.getElementById('increment')
.addEventListener('click', function () {
store.dispatch({ type: 'INCREMENT' })
})

// 省略部分暂时无效代码...

思考:看了这段代码,你会在哪打断点来调试呢。

1
2
3
4
5
6
7
8
9
10
11
12
复制代码// 四处可以断点来看
// 1.
var store = Redux.createStore(counter)
// 2.
function render() {
valueEl.innerHTML = store.getState().toString()
}
render()
// 3.
store.subscribe(render)
// 4.
store.dispatch({ type: 'INCREMENT' })

redux debugger图

redux debugger图

图中的右边Scope,有时需要关注下,会显示闭包、全局环境、当前环境等变量,还可以显示函数等具体代码位置,能帮助自己理解代码。

断点调试,按F5刷新页面后,按F8,把鼠标放在Redux和store上。

可以看到Redux上有好几个方法。分别是:

  • __DO_NOT_USE__ActionTypes: {INIT: “@@redux/INITu.v.d.u.6.r”, REPLACE: “@@redux/REPLACEg.u.u.7.c”, PROBE_UNKNOWN_ACTION: ƒ}
  • applyMiddleware: ƒ applyMiddleware() 函数是一个增强器,组合多个中间件,最终增强store.dispatch函数,dispatch时,可以串联执行所有中间件。
  • bindActionCreators: ƒ bindActionCreators(actionCreators, dispatch) 生成actions,主要用于其他库,比如react-redux。
  • combineReducers: ƒ combineReducers(reducers) 组合多个reducers,返回一个总的reducer函数。
  • compose: ƒ compose() 组合多个函数,从右到左,比如:compose(f, g, h) 最终得到这个结果 (…args) => f(g(h(…args))).
  • createStore: ƒ createStore(reducer, preloadedState, enhancer) 生成 store 对象

再看store也有几个方法。分别是:

  • dispatch: ƒ dispatch(action) 派发动作,也就是把subscribe收集的函数,依次遍历执行
  • subscribe: ƒ subscribe(listener) 订阅收集函数存在数组中,等待触发dispatch依次执行。返回一个取消订阅的函数,可以取消订阅监听。
  • getState: ƒ getState() 获取存在createStore函数内部闭包的对象。
  • replaceReducer: ƒ replaceReducer(nextReducer) 主要用于redux开发者工具,对比当前和上一次操作的异同。有点类似时间穿梭功能。
  • Symbol(observable): ƒ observable()

也就是官方文档redux.org.js上的 API。

暂时不去深究每一个API的实现。重新按F5刷新页面,断点到var store = Redux.createStore(counter)。一直按F11,先走一遍主流程。

4.1 Redux.createSotre

createStore 函数结构是这样的,是不是看起来很简单,最终返回对象store,包含dispatch、subscribe、getState、replaceReducer等方法。

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
复制代码// 省略了若干代码
export default function createStore(reducer, preloadedState, enhancer) {
// 省略参数校验和替换
// 当前的 reducer 函数
let currentReducer = reducer
// 当前state
let currentState = preloadedState
// 当前的监听数组函数
let currentListeners = []
// 下一个监听数组函数
let nextListeners = currentListeners
// 是否正在dispatch中
let isDispatching = false
function ensureCanMutateNextListeners() {
if (nextListeners === currentListeners) {
nextListeners = currentListeners.slice()
}
}
function getState() {
return currentState
}
function subscribe(listener) {}
function dispatch(action) {}
function replaceReducer(nextReducer) {}
function observable() {}
// ActionTypes.INIT @@redux/INITu.v.d.u.6.r
dispatch({ type: ActionTypes.INIT })
return {
dispatch,
subscribe,
getState,
replaceReducer,
[$observable]: observable
}
}

4.2 store.dispatch(action)

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
复制代码function dispatch(action) {
// 判断action是否是对象,不是则报错
if (!isPlainObject(action)) {
throw new Error(
'Actions must be plain objects. ' +
'Use custom middleware for async actions.'
)
}
// 判断action.type 是否存在,没有则报错
if (typeof action.type === 'undefined') {
throw new Error(
'Actions may not have an undefined "type" property. ' +
'Have you misspelled a constant?'
)
}
// 不是则报错
if (isDispatching) {
throw new Error('Reducers may not dispatch actions.')
}

try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
// 调用完后置为 false
isDispatching = false
}
// 把 收集的函数拿出来依次调用
const listeners = (currentListeners = nextListeners)
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener()
}
// 最终返回 action
return action
}
1
复制代码var store = Redux.createStore(counter)

上文调试完了这句。

继续按F11调试。

1
2
3
4
复制代码function render() {
valueEl.innerHTML = store.getState().toString()
}
render()

4.3 store.getState()

getState函数实现比较简单。

1
2
3
4
5
6
7
8
9
10
11
12
复制代码function getState() {
// 判断正在dispatch中,则报错
if (isDispatching) {
throw new Error(
'You may not call store.getState() while the reducer is executing. ' +
'The reducer has already received the state as an argument. ' +
'Pass it down from the top reducer instead of reading it from the store.'
)
}
// 返回当前的state
return currentState
}

4.4 store.subscribe(listener)

订阅监听函数,存放在数组中,store.dispatch(action)时遍历执行。

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
复制代码function subscribe(listener) {
// 订阅参数校验不是函数报错
if (typeof listener !== 'function') {
throw new Error('Expected the listener to be a function.')
}
// 正在dispatch中,报错
if (isDispatching) {
throw new Error(
'You may not call store.subscribe() while the reducer is executing. ' +
'If you would like to be notified after the store has been updated, subscribe from a ' +
'component and invoke store.getState() in the callback to access the latest state. ' +
'See https://redux.js.org/api-reference/store#subscribelistener for more details.'
)
}
// 订阅为 true
let isSubscribed = true

ensureCanMutateNextListeners()
nextListeners.push(listener)

// 返回一个取消订阅的函数
return function unsubscribe() {
if (!isSubscribed) {
return
}
// 正在dispatch中,则报错
if (isDispatching) {
throw new Error(
'You may not unsubscribe from a store listener while the reducer is executing. ' +
'See https://redux.js.org/api-reference/store#subscribelistener for more details.'
)
}
// 订阅为 false
isSubscribed = false

ensureCanMutateNextListeners()
// 找到当前监听函数
const index = nextListeners.indexOf(listener)
// 在数组中删除
nextListeners.splice(index, 1)
currentListeners = null
}
}

到这里,我们就调试学习完了Redux.createSotre、store.dispatch、store.getState、store.subscribe的源码。

接下来,我们写个中间件例子,来调试中间件相关源码。

  1. Redux 中间件相关源码

中间件是重点,面试官也经常问这类问题。

5.1 Redux.applyMiddleware(…middlewares)

5.1.1 准备 logger 例子调试

为了调试Redux.applyMiddleware(...middlewares),我在examples/js/middlewares.logger.example.js写一个简单的logger例子。分别有三个logger1,logger2,logger3函数。由于都是类似,所以我在这里只展示logger1函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码// examples/js/middlewares.logger.example.js
function logger1({ getState }) {
return next => action => {
console.log('will dispatch--1--next, action:', next, action)

// Call the next dispatch method in the middleware chain.
const returnValue = next(action)

console.log('state after dispatch--1', getState())

// This will likely be the action itself, unless
// a middleware further in chain changed it.
return returnValue
}
}
// 省略 logger2、logger3

logger中间件函数做的事情也比较简单,返回两层函数,next就是下一个中间件函数,调用返回结果。为了让读者能看懂,我把logger1用箭头函数、logger2则用普通函数。

写好例子后,我们接着来看怎么调试Redux.applyMiddleware(...middlewares))源码。

1
2
复制代码cd redux-analysis && hs -p 5000
# 上文说过npm i -g http-server

打开http://localhost:5000/examples/index.2.redux.applyMiddleware.compose.html,按F12打开控制台,

先点击加号操作+1,把结果展示出来。

redux 中间件调试图

redux 中间件调试图

从图中可以看出,next则是下一个函数。先1-2-3,再3-2-1这样的顺序。

这种也就是我们常说的中间件,面向切面编程(AOP)。

中间件图解

中间件图解

接下来调试,在以下语句打上断点和一些你觉得重要的地方打上断点。

1
2
复制代码// examples/index.2.redux.applyMiddleware.compose.html
var store = Redux.createStore(counter, Redux.applyMiddleware(logger1, logger2, logger3))

5.1.2 Redux.applyMiddleware(…middlewares) 源码

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
复制代码// redux/src/applyMiddleware.js
/**
* ...
* @param {...Function} middlewares The middleware chain to be applied.
* @returns {Function} A store enhancer applying the middleware.
*/
export default function applyMiddleware(...middlewares) {
return createStore => (...args) => {
const store = createStore(...args)
let dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
)
}

const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
}
const chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)

return {
...store,
dispatch
}
}
}
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
复制代码// redux/src/createStore.js
export default function createStore(reducer, preloadedState, enhancer) {
// 省略参数校验
// 如果第二个参数`preloadedState`是函数,并且第三个参数`enhancer`是undefined,把它们互换一下。
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState
preloadedState = undefined
}

if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
throw new Error('Expected the enhancer to be a function.')
}
// enhancer 也就是`Redux.applyMiddleware`返回的函数
// createStore 的 args 则是 `reducer, preloadedState`
/**
* createStore => (...args) => {
const store = createStore(...args)
return {
...store,
dispatch,
}
}
** /
// 最终返回增强的store对象。
return enhancer(createStore)(reducer, preloadedState)
}
// 省略后续代码
}

把接收的中间件函数logger1, logger2, logger3放入到 了middlewares数组中。Redux.applyMiddleware最后返回两层函数。
把中间件函数都混入了参数getState和dispatch。

1
2
复制代码// examples/index.2.redux.applyMiddleware.compose.html
var store = Redux.createStore(counter, Redux.applyMiddleware(logger1, logger2, logger3))

最后这句其实是返回一个增强了dispatch的store对象。

而增强的dispatch函数,则是用Redux.compose(...functions)进行串联起来执行的。

5.2 Redux.compose(…functions)

1
2
3
4
5
6
7
8
9
10
11
复制代码export default function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}

if (funcs.length === 1) {
return funcs[0]
}

return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
1
2
3
4
复制代码// applyMiddleware.js
dispatch = compose(...chain)(store.dispatch)
// compose
funcs.reduce((a, b) => (...args) => a(b(...args)))

这两句可能不是那么好理解,可以断点多调试几次。我把箭头函数转换成普通函数。

1
2
3
4
5
复制代码funcs.reduce(function(a, b){
return function(...args){
return a(b(...args));
};
});

其实redux源码中注释很清晰了,这个compose函数上方有一堆注释,其中有一句:组合多个函数,从右到左,比如:compose(f, g, h) 最终得到这个结果 (...args) => f(g(h(...args))).

5.2.1 compose 函数演化

看Redux.compose(...functions)函数源码后,还是不明白,不要急不要慌,吃完鸡蛋还有汤。仔细来看如何演化而来,先来简单看下如下需求。

传入一个数值,计算数值乘以10再加上10,再减去2。

实现起来很简单。

1
2
复制代码const calc = (num) => num * 10 + 10 - 2;
calc(10); // 108

但这样写有个问题,不好扩展,比如我想乘以10时就打印出结果。
为了便于扩展,我们分开写成三个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码const multiply = (x) => {
const result = x * 10;
console.log(result);
return result;
};
const add = (y) => y + 10;
const minus = (z) => z - 2;

// 计算结果
console.log(minus(add(multiply(10))));
// 100
// 108
// 这样我们就把三个函数计算结果出来了。

再来实现一个相对通用的函数,计算这三个函数的结果。

1
2
3
4
5
6
7
8
9
复制代码const compose = (f, g, h) => {
return function(x){
return f(g(h(x)));
}
}
const calc = compose(minus, add, multiply);
console.log(calc(10));
// 100
// 108

这样还是有问题,只支持三个函数。我想支持多个函数。
我们了解到数组的reduce方法就能实现这样的功能。
前一个函数

1
2
3
4
5
6
7
8
9
10
复制代码// 我们常用reduce来计算数值数组的总和
[1,2,3,4,5].reduce((pre, item, index, arr) => {
console.log('(pre, item, index, arr)', pre, item, index, arr);
// (pre, item, index, arr) 1 2 1 (5) [1, 2, 3, 4, 5]
// (pre, item, index, arr) 3 3 2 (5) [1, 2, 3, 4, 5]
// (pre, item, index, arr) 6 4 3 (5) [1, 2, 3, 4, 5]
// (pre, item, index, arr) 10 5 4 (5) [1, 2, 3, 4, 5]
return pre + item;
});
// 15

pre 是上一次返回值,在这里是数值1,3,6,10。在下一个例子中则是匿名函数。

1
2
3
复制代码function(x){
return a(b(x));
}

item是2,3,4,5,在下一个例子中是minus、add、multiply。

1
2
3
4
5
6
7
8
9
10
11
复制代码const compose = (...funcs) => {
return funcs.reduce((a, b) => {
return function(x){
return a(b(x));
}
})
}
const calc = compose(minus, add, multiply);
console.log(calc(10));
// 100
// 108

而Redux.compose(...functions)其实就是这样,只不过中间件是返回双层函数罢了。

所以返回的是next函数,他们串起来执行了,形成了中间件的洋葱模型。
人们都说一图胜千言。我画了一个相对简单的redux中间件原理图。

中间件原理图

redux中间件原理图

如果还不是很明白,建议按照我给出的例子,多调试。

1
2
复制代码cd redux-analysis && hs -p 5000
# 上文说过npm i -g http-server

打开http://localhost:5000/examples/index.3.html,按F12打开控制台调试。

5.2.2 前端框架的 compose 函数的实现

lodash源码中 compose函数的实现,也是类似于数组的reduce,只不过是内部实现的arrayReduce

引用自我的文章:学习lodash源码整体架构

1
2
3
4
5
6
7
8
9
10
11
12
复制代码// lodash源码
function baseWrapperValue(value, actions) {
var result = value;
// 如果是lazyWrapper的实例,则调用LazyWrapper.prototype.value 方法,也就是 lazyValue 方法
if (result instanceof LazyWrapper) {
result = result.value();
}
// 类似 [].reduce(),把上一个函数返回结果作为参数传递给下一个函数
return arrayReduce(actions, function(result, action) {
return action.func.apply(action.thisArg, arrayPush([result], action.args));
}, result);
}

koa-compose源码也有compose函数的实现。实现是循环加promise。
由于代码比较长我就省略了,具体看链接若川:学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理小节 koa-compose 源码(洋葱模型实现)

  1. Redux.combineReducers(reducers)

打开http://localhost:5000/examples/index.4.html,按F12打开控制台,按照给出的例子,调试接下来的Redux.combineReducers(reducers)和Redux.bindActionCreators(actionCreators, dispatch)具体实现。由于文章已经很长了,这两个函数就不那么详细解释了。

combineReducers函数简单来说就是合并多个reducer为一个函数combination。

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
复制代码export default function combineReducers(reducers) {
const reducerKeys = Object.keys(reducers)
const finalReducers = {}
for (let i = 0; i < reducerKeys.length; i++) {
const key = reducerKeys[i]

// 省略一些开发环境判断的代码...

if (typeof reducers[key] === 'function') {
finalReducers[key] = reducers[key]
}
}

// 经过一些处理后得到最后的finalReducerKeys
const finalReducerKeys = Object.keys(finalReducers)

// 省略一些开发环境判断的代码...

return function combination(state = {}, action) {
// ... 省略开发环境的一些判断

// 用 hasChanged变量 记录前后 state 是否已经修改
let hasChanged = false
// 声明对象来存储下一次的state
const nextState = {}
//遍历 finalReducerKeys
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i]
const reducer = finalReducers[key]
const previousStateForKey = state[key]
// 执行 reducer
const nextStateForKey = reducer(previousStateForKey, action)

// 省略容错代码 ...

nextState[key] = nextStateForKey
// 两次 key 对比 不相等则发生改变
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
// 最后的 keys 数组对比 不相等则发生改变
hasChanged =
hasChanged || finalReducerKeys.length !== Object.keys(state).length
return hasChanged ? nextState : state
}
}
  1. Redux.bindActionCreators(actionCreators, dispatch)

如果第一个参数是一个函数,那就直接返回一个函数。如果是一个对象,则遍历赋值,最终生成boundActionCreators对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码function bindActionCreator(actionCreator, dispatch) {
return function() {
return dispatch(actionCreator.apply(this, arguments))
}
}

export default function bindActionCreators(actionCreators, dispatch) {
if (typeof actionCreators === 'function') {
return bindActionCreator(actionCreators, dispatch)
}

// ... 省略一些容错判断

const boundActionCreators = {}
for (const key in actionCreators) {
const actionCreator = actionCreators[key]
if (typeof actionCreator === 'function') {
boundActionCreators[key] = bindActionCreator(actionCreator, dispatch)
}
}
return boundActionCreators
}

redux所提供的的API 除了store.replaceReducer(nextReducer)没分析,其他都分析了。

  1. vuex 和 redux 简单对比

8.1 源码实现形式

从源码实现上来看,vuex源码主要使用了构造函数,而redux则是多用函数式编程、闭包。

8.2 耦合度

vuex 与 vue 强耦合,脱离了vue则无法使用。而redux跟react没有关系,所以它可以使用于小程序或者jQuery等。如果需要和react使用,还需要结合react-redux库。

8.3 扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码// logger 插件,具体实现省略
function logger (store) {
console.log('store', store);
}
// 作为数组传入
new Vuex.Store({
state,
getters,
actions,
mutations,
plugins: process.env.NODE_ENV !== 'production'
? [logger]
: []
})
// vuex 源码 插件执行部分
class Store{
constructor(){
// 把vuex的实例对象 store整个对象传递给插件使用
plugins.forEach(plugin => plugin(this))
}
}

vuex实现扩展则是使用插件形式,而redux是中间件的形式。redux的中间件则是AOP(面向切面编程),redux中Redux.applyMiddleware()其实也是一个增强函数,所以也可以用户来实现增强器,所以redux生态比较繁荣。

8.4 上手难易度

相对来说,vuex上手相对简单,redux相对难一些,redux涉及到一些函数式编程、高阶函数、纯函数等概念。

  1. 总结

文章主要通过一步步调试的方式循序渐进地讲述redux源码的具体实现。旨在教会读者调试源码,不惧怕源码。

面试官经常喜欢考写一个redux中间件,说说redux中间件的原理。

1
2
3
4
5
6
复制代码function logger1({ getState }) {
return next => action => {
const returnValue = next(action)
return returnValue
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码const compose = (...funcs) => {
if (funcs.length === 0) {
return arg => arg
}

if (funcs.length === 1) {
return funcs[0]
}

// 箭头函数
// return funcs.reduce((a, b) => (...args) => a(b(...args)))
return funcs.reduce((a, b) => {
return function(x){
return a(b(x));
}
})
}
1
2
复制代码const enhancerStore = Redux.create(reducer, Redux.applyMiddleware(logger1, ...))
enhancerStore.dispatch(action)

用户触发enhancerStore.dispatch(action)是增强后的,其实就是第一个中间件函数,中间的next是下一个中间件函数,最后next是没有增强的store.dispatch(action)。

最后再来看张redux工作流程图
工作流程图

是不是就更理解些了呢。

如果读者发现有不妥或可改善之处,再或者哪里没写明白的地方,欢迎评论指出。另外觉得写得不错,对你有些许帮助,可以点赞、评论、转发分享,也是对我的一种支持,非常感谢呀。要是有人说到怎么读源码,正在读文章的你能推荐我的源码系列文章,那真是太好了。

推荐阅读

@胡子大哈:动手实现 Redux(一):优雅地修改共享状态,总共6小节,非常推荐,虽然我很早前就看完了《react小书》,现在再看一遍又有收获

美团@莹莹 Redux从设计到源码,美团这篇是我基本写完文章后看到的,感觉写得很好,非常推荐

redux 中文文档

redux 英文文档

若川的学习redux源码仓库

另一个系列

面试官问:JS的继承

面试官问:JS的this指向

面试官问:能否模拟实现JS的call和apply方法

面试官问:能否模拟实现JS的bind方法

面试官问:能否模拟实现JS的new操作符

关于

作者:常以若川为名混迹于江湖。前端路上 | PPT爱好者 | 所知甚少,唯善学。

若川的博客,使用vuepress重构了,阅读体验可能更好些

掘金专栏,欢迎关注~

segmentfault前端视野专栏,欢迎关注~

知乎前端视野专栏,欢迎关注~

语雀前端视野专栏,新增语雀专栏,欢迎关注~

github blog,相关源码和资源都放在这里,求个star^_^~

欢迎加微信交流 微信公众号

可能比较有趣的微信公众号,长按扫码关注。欢迎加我微信ruochuan12(注明来源,基本来者不拒),拉你进【前端视野交流群】,长期交流学习~

若川视野

若川视野

本文使用 mdnice 排版

本文转载自: 掘金

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

Spring 源码第一篇开整!配置文件是怎么加载的?

发表于 2020-06-15

上周把话撂出来,看起来小伙伴们都挺期待的,其实松哥也迫不及待想要开启一个全新的系列。

但是目前的 Spring Security 系列还在连载中,还没写完。连载这事,一鼓作气,再而衰三而竭,一定要一次搞定,Spring Security 如果这次放下来,以后就很难再拾起来了。

所以目前的更新还是 Spring Security 为主,同时 Spring 源码解读每周至少更新一篇,等 Spring Security 系列更新完毕后,就开足马力更新 Spring 源码。其实 Spring Security 中也有很多和 Spring 相通的地方,Spring Security 大家文章认真看,松哥不会让大家失望的!

1.从何说起

Spring 要从何说起呢?这个问题我考虑了很长时间。

因为 Spring 源码太繁杂了,一定要选择一个合适的切入点,否则一上来就把各位小伙伴整懵了,那剩下的文章估计就不想看了。

想了很久之后,我决定就先从配置文件加载讲起,在逐步展开,配置文件加载也是我们在使用 Spring 时遇到的第一个问题,今天就先来说说这个话题。

2.简单的案例

先来一个简单的案例,大家感受一下,然后我们顺着案例讲起。

首先我们创建一个普通的 Maven 项目,引入 spring-beans 依赖:

1
2
3
4
5
复制代码<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.2.6.RELEASE</version>
</dependency>

然后我们创建一个实体类,再添加一个简单的配置文件:

1
2
3
4
5
复制代码public class User {
private String username;
private String address;
//省略 getter/setter
}

resources 目录下创建配置文件:

1
2
3
4
5
6
7
复制代码<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

<bean class="org.javaboy.loadxml.User" id="user"/>
</beans>

然后去加载这个配置文件:

1
2
3
4
5
复制代码public static void main(String[] args) {
XmlBeanFactory factory = new XmlBeanFactory(new ClassPathResource("beans.xml"));
User user = factory.getBean(User.class);
System.out.println("user = " + user);
}

这里为了展示数据的读取过程,我就先用这个已经过期的 XmlBeanFactory 来加载,这并不影响我们阅读源码。

上面这个是一个非常简单的 Spring 入门案例,相信很多小伙伴在第一次接触 Spring 的时候,写出来的可能都是这个 Demo。

在上面这段代码执行过程中,首先要做的事情就是先把 XML 配置文件加载到内存中,再去解析它,再去。。。。。

一步一步来吧,先来看 XML 文件如何被加入到内存中去。

3.文件读取

文件读取在 Spring 中很常见,也算是一个比较基本的功能,而且 Spring 提供的文件加载方式,不仅仅在 Spring 框架中可以使用,我们在项目中有其他文件加载需求也可以使用。

首先,Spring 中使用 Resource 接口来封装底层资源,Resource 接口本身实现自 InputStreamSource 接口:

我们来看下这两个接口的定义:

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
复制代码public interface InputStreamSource {
InputStream getInputStream() throws IOException;
}
public interface Resource extends InputStreamSource {
boolean exists();
default boolean isReadable() {
return exists();
}
default boolean isOpen() {
return false;
}
default boolean isFile() {
return false;
}
URL getURL() throws IOException;
URI getURI() throws IOException;
File getFile() throws IOException;
default ReadableByteChannel readableChannel() throws IOException {
return Channels.newChannel(getInputStream());
}
long contentLength() throws IOException;
long lastModified() throws IOException;
Resource createRelative(String relativePath) throws IOException;
@Nullable
String getFilename();
String getDescription();

}

代码倒不难,我来稍微解释下:

  1. InputStreamSource 类只提供了一个 getInputStream 方法,该方法返回一个 InputStream,也就是说,InputStreamSource 会将传入的 File 等资源,封装成一个 InputStream 再重新返回。
  2. Resource 接口实现了 InputStreamSource 接口,并且封装了 Spring 内部可能会用到的底层资源,如 File、URL 以及 classpath 等。
  3. exists 方法用来判断资源是否存在。
  4. isReadable 方法用来判断资源是否可读。
  5. isOpen 方法用来判断资源是否打开。
  6. isFile 方法用来判断资源是否是一个文件。
  7. getURL/getURI/getFile/readableChannel 分别表示获取资源对应的 URL/URI/File 以及将资源转为 ReadableByteChannel 通道。
  8. contentLength 表示获取资源的大小。
  9. lastModified 表示获取资源的最后修改时间。
  10. createRelative 表示根据当前资源创建一个相对资源。
  11. getFilename 表示获取文件名。
  12. getDescription 表示在资源出错时,详细打印出出错的文件。

当我们加载不同资源时,对应了 Resource 的不同实现类,来看下 Resource 的继承关系:

可以看到,针对不同类型的数据源,都有各自的实现,我们这里来重点看下 ClassPathResource 的实现方式。

ClassPathResource 源码比较长,我这里挑一些关键部分来和大家分享:

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
复制代码public class ClassPathResource extends AbstractFileResolvingResource {

private final String path;

@Nullable
private ClassLoader classLoader;

@Nullable
private Class<?> clazz;

public ClassPathResource(String path) {
this(path, (ClassLoader) null);
}
public ClassPathResource(String path, @Nullable ClassLoader classLoader) {
Assert.notNull(path, "Path must not be null");
String pathToUse = StringUtils.cleanPath(path);
if (pathToUse.startsWith("/")) {
pathToUse = pathToUse.substring(1);
}
this.path = pathToUse;
this.classLoader = (classLoader != null ? classLoader : ClassUtils.getDefaultClassLoader());
}
public ClassPathResource(String path, @Nullable Class<?> clazz) {
Assert.notNull(path, "Path must not be null");
this.path = StringUtils.cleanPath(path);
this.clazz = clazz;
}
public final String getPath() {
return this.path;
}
@Nullable
public final ClassLoader getClassLoader() {
return (this.clazz != null ? this.clazz.getClassLoader() : this.classLoader);
}
@Override
public boolean exists() {
return (resolveURL() != null);
}
@Nullable
protected URL resolveURL() {
if (this.clazz != null) {
return this.clazz.getResource(this.path);
}
else if (this.classLoader != null) {
return this.classLoader.getResource(this.path);
}
else {
return ClassLoader.getSystemResource(this.path);
}
}
@Override
public InputStream getInputStream() throws IOException {
InputStream is;
if (this.clazz != null) {
is = this.clazz.getResourceAsStream(this.path);
}
else if (this.classLoader != null) {
is = this.classLoader.getResourceAsStream(this.path);
}
else {
is = ClassLoader.getSystemResourceAsStream(this.path);
}
if (is == null) {
throw new FileNotFoundException(getDescription() + " cannot be opened because it does not exist");
}
return is;
}
@Override
public URL getURL() throws IOException {
URL url = resolveURL();
if (url == null) {
throw new FileNotFoundException(getDescription() + " cannot be resolved to URL because it does not exist");
}
return url;
}
@Override
public Resource createRelative(String relativePath) {
String pathToUse = StringUtils.applyRelativePath(this.path, relativePath);
return (this.clazz != null ? new ClassPathResource(pathToUse, this.clazz) :
new ClassPathResource(pathToUse, this.classLoader));
}
@Override
@Nullable
public String getFilename() {
return StringUtils.getFilename(this.path);
}
@Override
public String getDescription() {
StringBuilder builder = new StringBuilder("class path resource [");
String pathToUse = this.path;
if (this.clazz != null && !pathToUse.startsWith("/")) {
builder.append(ClassUtils.classPackageAsResourcePath(this.clazz));
builder.append('/');
}
if (pathToUse.startsWith("/")) {
pathToUse = pathToUse.substring(1);
}
builder.append(pathToUse);
builder.append(']');
return builder.toString();
}
}
  1. 首先,ClassPathResource 的构造方法有四个,一个已经过期的方法我这里没有列出来。另外三个,我们一般调用一个参数的即可,也就是传入文件路径即可,它内部会调用另外一个重载的方法,给 classloader 赋上值(因为在后面要通过 classloader 去读取文件)。
  2. 在 ClassPathResource 初始化的过程中,会先调用 StringUtils.cleanPath 方法对传入的路径进行清理,所谓的路径清理,就是处理路径中的相对地址、Windows 系统下的 \ 变为 / 等。
  3. getPath 方法用来返回文件路径,这是一个相对路径,不包含 classpath。
  4. resolveURL 方法表示返回资源的 URL,返回的时候优先用 Class.getResource 加载,然后才会用 ClassLoader.getResource 加载,关于 Class.getResource 和 ClassLoader.getResource 的区别,又能写一篇文章出来,我这里就大概说下,Class.getResource 最终还是会调用 ClassLoader.getResource,只不过 Class.getResource 会先对路径进行处理。
  5. getInputStream 读取资源,并返回 InputStream 对象。
  6. createRelative 方法是根据当前的资源,再创建一个相对资源。

这是 ClassPathResource,另外一个大家可能会接触到的 FileSystemResource ,小伙伴们可以自行查看其源码,比 ClassPathResource 简单。

如果不是使用 Spring,我们仅仅想自己加载 resources 目录下的资源,也可以采用这种方式:

1
2
复制代码ClassPathResource resource = new ClassPathResource("beans.xml");
InputStream inputStream = resource.getInputStream();

拿到 IO 流之后自行解析即可。

在 Spring 框架,构造出 Resource 对象之后,接下来还会把 Resource 对象转为 EncodedResource,这里会对资源进行编码处理,编码主要体现在 getReader 方法上,在获取 Reader 对象时,如果有编码,则给出编码格式:

1
2
3
4
5
6
7
8
9
10
11
复制代码public Reader getReader() throws IOException {
if (this.charset != null) {
return new InputStreamReader(this.resource.getInputStream(), this.charset);
}
else if (this.encoding != null) {
return new InputStreamReader(this.resource.getInputStream(), this.encoding);
}
else {
return new InputStreamReader(this.resource.getInputStream());
}
}

所有这一切搞定之后,接下来就是通过 XmlBeanDefinitionReader 去加载 Resource 了。

4.小结

好啦,今天主要和小伙伴们分享一下 Spring 中的资源加载问题,这是容器启动的起点,下篇文章我们来看 XML 文件的解析。

如果小伙伴们觉得有收获,记得点个在看鼓励下松哥哦~

本文转载自: 掘金

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

一文读懂Redis四种模式,单机、主从、哨兵、集群

发表于 2020-06-15

少点代码,多点头发

本文已经被GitHub收录,欢迎大家踊跃star 和 issues。

https://github.com/midou-tech/articles

入职第一周,我被坑了

最近刚入职新公司,本来想着这刚来新公司,一般都是熟悉熟悉公司同事,看看组内工程文档,找几个demo自己练练手。

咳咳咳,万万没想到啊,一切都是我以为的,我还是太嫩了。

入职那天下午,组长给我丢了几个文档,让我看下这个这些工程的缓存系统问题,让我把redis升级为哨兵模式。

接到任务的我,内心是懵逼的。


第一、不知道都是些什么类型的服务在用redis。

第二、不知道以什么姿势在用redis。

第三、如果redis挂了会不会影响用户。

第四、我完全没用过redis。

虽说没干过,但咋也不怂。毕竟要是天天干的都是干过的工作,那就是有问题了,很快就被优化掉了。

看来社招入职和校招还是不一样的,校招进来都会有些入职培训或者新人班课程。

通过这些形式的教育,第一、了解公司的文化、价值观,第二、学习工作流程、感受公司技术氛围。

任务

把我们部门所有使用redis服务升级到哨兵模式。

redis的多种模式

都说了升级到哨兵模式,那之前用的不是哨兵模式,肯定还有其他模式。

单机模式、主从模式、哨兵模式、集群模式

单机模式

这个最简单,一看就懂。

就是安装一个redis,启动起来,业务调用即可。具体安装步骤和启动步骤就不赘述了,网上随便搜一下就有了。

单机在很多场景也是有使用的,例如在一个并非必须保证高可用的情况下。

咳咳咳,其实我们的服务使用的就是redis单机模式,所以来了就让我改为哨兵模式。

说说单机的优缺点吧。

优点:

  • 部署简单,0成本。
  • 成本低,没有备用节点,不需要其他的开支。
  • 高性能,单机不需要同步数据,数据天然一致性。

缺点:

  • 可靠性保证不是很好,单节点有宕机的风险。
  • 单机高性能受限于CPU的处理能力,redis是单线程的。

单机模式选择需要根据自己的业务场景去选择,如果需要很高的性能、可靠性,单机就不太合适了。

主从复制

主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。

前者称为主节点(master),后者称为从节点(slave);数据的复制是单向的,只能由主节点到从节点。


主从模式配置很简单,只需要在从节点配置主节点的ip和端口号即可。

1
2
3
复制代码slaveof <masterip> <masterport>
# 例如
# slaveof 192.168.1.214 6379

启动主从节点的所有服务,查看日志即可以看到主从节点之间的服务连接。

从上面很容易就想到一个问题,既然主从复制,意味着master和slave的数据都是一样的,有数据冗余问题。

在程序设计上,为了高可用性和高性能,是允许有冗余存在的。这点希望大家在设计系统的时候要考虑进去,不用为公司节省这一点资源。

对于追求极致用户体验的产品,是绝对不允许有宕机存在的。

主从模式在很多系统设计时都会考虑,一个master挂在多个slave节点,当master服务宕机,会选举产生一个新的master节点,从而保证服务的高可用性。

主从模式的优点:

  • 一旦 主节点宕机,从节点 作为 主节点 的 备份 可以随时顶上来。
  • 扩展 主节点 的 读能力,分担主节点读压力。
  • 高可用基石:除了上述作用以外,主从复制还是哨兵模式和集群模式能够实施的基础,因此说主从复制是Redis高可用的基石。

也有相应的缺点,比如我刚提到的数据冗余问题:

  • 一旦 主节点宕机,从节点 晋升成 主节点,同时需要修改 应用方 的 主节点地址,还需要命令所有 从节点 去 复制 新的主节点,整个过程需要 人工干预。
  • 主节点 的 写能力 受到 单机的限制。
  • 主节点 的 存储能力 受到 单机的限制。

哨兵模式

刚刚提到了,主从模式,当主节点宕机之后,从节点是可以作为主节点顶上来,继续提供服务的。

但是有一个问题,主节点的IP已经变动了,此时应用服务还是拿着原主节点的地址去访问,这…

于是,在Redis 2.8版本开始引入,就有了哨兵这个概念。

在复制的基础上,哨兵实现了自动化的故障恢复。


如图,哨兵节点由两部分组成,哨兵节点和数据节点:

  • 哨兵节点:哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的redis节点,不存储数据。
  • 数据节点:主节点和从节点都是数据节点。

访问redis集群的数据都是通过哨兵集群的,哨兵监控整个redis集群。

一旦发现redis集群出现了问题,比如刚刚说的主节点挂了,从节点会顶上来。但是主节点地址变了,这时候应用服务无感知,也不用更改访问地址,因为哨兵才是和应用服务做交互的。

Sentinel 很好的解决了故障转移,在高可用方面又上升了一个台阶,当然Sentinel还有其他功能。

比如 主节点存活检测、主从运行情况检测、主从切换。

Redis的Sentinel最小配置是 一主一从。

说下哨兵模式监控的原理

每个Sentinel以 每秒钟 一次的频率,向它所有的 主服务器、从服务器 以及其他Sentinel实例 发送一个PING 命令。


如果一个 实例(instance)距离最后一次有效回复 PING命令的时间超过 down-after-milliseconds 所指定的值,那么这个实例会被 Sentinel标记为 主观下线。

如果一个 主服务器 被标记为 主观下线,那么正在 监视 这个 主服务器 的所有 Sentinel 节点,要以 每秒一次 的频率确认 该主服务器是否的确进入了 主观下线 状态。

如果一个 主服务器 被标记为 主观下线,并且有 足够数量 的 Sentinel(至少要达到配置文件指定的数量)在指定的 时间范围 内同意这一判断,那么这个该主服务器被标记为 客观下线。

在一般情况下, 每个 Sentinel 会以每 10秒一次的频率,向它已知的所有 主服务器 和 从服务器 发送 INFO 命令。

当一个 主服务器 被 Sentinel标记为 客观下线 时,Sentinel 向 下线主服务器 的所有 从服务器 发送 INFO 命令的频率,会从10秒一次改为 每秒一次。

Sentinel和其他 Sentinel 协商 主节点 的状态,如果 主节点处于 SDOWN`状态,则投票自动选出新的主节点。将剩余的 从节点 指向 新的主节点 进行 数据复制。

当没有足够数量的 Sentinel 同意 主服务器 下线时, 主服务器 的 客观下线状态 就会被移除。当 主服务器 重新向 Sentinel的PING命令返回 有效回复 时,主服务器 的 主观下线状态 就会被移除。

哨兵模式的优缺点

​ 优点:

  • 哨兵模式是基于主从模式的,所有主从的优点,哨兵模式都具有。
  • 主从可以自动切换,系统更健壮,可用性更高。
  • Sentinel 会不断的检查 主服务器 和 从服务器 是否正常运行。当被监控的某个 Redis 服务器出现问题,Sentinel 通过API脚本向管理员或者其他的应用程序发送通知。

​ 缺点:

  • Redis较难支持在线扩容,对于集群,容量达到上限时在线扩容会变得很复杂。

我的任务

我部署的redis服务就如上图所示,三个哨兵节点,三个主从复制节点。

使用java的jedis去访问我的redis服务,下面来一段简单的演示代码(并非工程里面的代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码public static void testSentinel() throws Exception {
//mastername从配置中获取或者环境变量,这里为了演示
String masterName = "master";
Set<String> sentinels = new HashSet<>();
// sentinel的IP一般会从配置文件获取或者环境变量,这里为了演示
sentinels.add("192.168.200,213:26379");
sentinels.add("192.168.200.214:26380");
sentinels.add("192.168.200.215:26381");

//初始化过程做了很多工作
JedisSentinelPool pool = new JedisSentinelPool(masterName, sentinels);
//获取到redis的client
Jedis jedis = pool.getResource();
//写值到redis
jedis.set("key1", "value1");
//读取数据
jedis.get("key1");
}

具体部署的配置文件这里太长了,需要的朋友可以公众号后台回复【redis配置】获取。

听起来是入职第二天就部署了任务感觉很难的样子。

其实现在看来是个so easy的任务,申请一个redis集群,自己配置下。在把工程里面使用到redis的地方改一下,之前使用的是一个两个单机节点。

干完,收工。


虽然领导的任务完成了,但并不意味着学习redis的路结束了。爱学习的龙叔,继续研究了下redis的集群模式。

集群模式

主从不能解决故障自动恢复问题,哨兵已经可以解决故障自动恢复了,那到底为啥还要集群模式呢?

主从和哨兵都还有另外一些问题没有解决,单个节点的存储能力是有上限,访问能力是有上限的。

Redis Cluster 集群模式具有 高可用、可扩展性、分布式、容错 等特性。

Cluster 集群模式的原理

通过数据分片的方式来进行数据共享问题,同时提供数据复制和故障转移功能。

之前的两种模式数据都是在一个节点上的,单个节点存储是存在上限的。集群模式就是把数据进行分片存储,当一个分片数据达到上限的时候,就分成多个分片。

数据分片怎么分?

集群的键空间被分割为16384个slots(即hash槽),通过hash的方式将数据分到不同的分片上的。

1
复制代码HASH_SLOT = CRC16(key) & 16384

CRC16是一种循环校验算法,这里不是我们研究的重点,有兴趣可以看看。

这里用了位运算得到取模结果,位运算的速度高于取模运算。


有一个很重要的问题,为什么是分割为16384个槽?这个问题可能会被面试官随口一问

数据分片之后怎么查,怎么写?


读请求分配给slave节点,写请求分配给master,数据同步从master到slave节点。

读写分离提高并发能力,增加高性能。

如何做到水平扩展?


master节点可以做扩充,数据迁移redis内部自动完成。

当你新增一个master节点,需要做数据迁移,redis服务不需要下线。

举个栗子:上面的有三个master节点,意味着redis的槽被分为三个段,假设三段分别是07000,700112000、12001~16383。

现在因为业务需要新增了一个master节点,四个节点共同占有16384个槽。

槽需要重新分配,数据也需要重新迁移,但是服务不需要下线。

redis集群的重新分片由redis内部的管理软件redis-trib负责执行。redis提供了进行重新分片的所有命令,redis-trib通过向节点发送命令来进行重新分片。

如何做故障转移?


假如途中红色的节点故障了,此时master3下面的从节点会通过 选举 产生一个主节点。替换原来的故障节点。

此过程和哨兵模式的故障转移是一样的。

总结

每种模式都有各自的优缺点,在实际使用场景中要根据业务特点去选择合适的模式。

redis是一个非常常用的中间件,作为一个使用者来说,学习成本一点不高。

如果作为一个很好的中间件去研究的话,还是有很多值得学习和借鉴的地方。比如redis的各种数据结构(动态字符串、跳跃表、集合、字典等)、高效的内存分配(jemalloc)、高效的IO模型等等。

每个点都可以深入研究,在后期设计高并发、高可用系统的时候融入进去。

我是龙叔,一个分享互联网技术和成长心路历程的star。

本文转载自: 掘金

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

1…803804805…956

开发者博客

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