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

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


  • 首页

  • 归档

  • 搜索

Kotlin协程之Flow使用 本章前言 kotlin协程之

发表于 2021-11-25

banners_twitter.png

本章前言

这篇文章是kotlin协程系列的时候扩展而来,如果对kotlin协程感兴趣的可以通过下面链接进行阅读、

Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装
  • 史上最详Android版kotlin协程入门进阶实战(六) -> 深入kotlin协程原理(一)
  • 史上最详Android版kotlin协程入门进阶实战(七) -> 深入kotlin协程原理(二)
  • [史上最详Android版kotlin协程入门进阶实战(八) -> 深入kotlin协程原理(三)]
  • [史上最详Android版kotlin协程入门进阶实战(九) -> 深入kotlin协程原理(四)]

Flow系列

  • Kotlin协程之Flow使用(一)
  • Kotlin协程之Flow使用(二)
  • Kotlin协程之Flow使用(三)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装
    笔者也只是一个普普通通的开发者,设计不一定合理,大家可以自行吸收文章精华,去糟粕。

kotlin协程之Flow的使用

本来Flow这章节个人感觉是不太需要讲解,因为主要还是一些协程知识结合响应式流。这些东西我们在使用RxJava和学习协程的过程中已经掌握。但是最近发现还是不少人问关于Flow的一些知识。

本着授人以鱼不如授人以渔的原则,本章节不单单只是讲解如果使用,也会同步讲解一些实现原理。我们将对Flow使用以及实现原理进行同步讲解,篇幅可能有些过长,可以按需跳着看。

感谢催更大军中的每一位,如果不是你们日复一日的催更,可能就没有这篇文章。

**特别鸣谢群友,感谢你们在每一次的吹水摸鱼中不经意的暗示我:
@傻白嫖 @花落随 @AilurusFulgens @阶前听雨 @少年 @本初子午线 @你知道我是谁吗 @贝塞尔曲线 @直线 @篝火 @一本歪经 @MING 下一个昵称~ @null @Jerry J @想**等等365个群友

image.png

异步流

通过对协程的学习我们知道,挂起函数可以异步的返回单个结果值。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码fun test(){
GlobalScope.launch {
val withStr = withContext(Dispatchers.Default){
"a"
}
val awaitStr = async {
"b"
}
val list = simple()
Log.d("test","withStr :$withStr")
Log.d("test","awaitStr :${awaitStr.await()}")
Log.d("test","list :$list ")

}
}
1
2
3
log复制代码D/test: withStr :a
D/test: awaitStr :b
D/test: list :[1, 2, 3]

即使我们在函数中使用List返回一个集合结果,这样也只能认为是返回一个结果,只不过返回的结果类型是List类型。

那么如果我们想在协程中和使用RxJava一样,通过响应式编程方式如何异步返回多个计算好的值呢。可能有人想到使用序列Sequence进行操作。

1
kotlin复制代码public fun <T> sequence(@BuilderInference block: suspend SequenceScope<T>.() -> Unit): Sequence<T> = Sequence { iterator(block) }

使用序列Sequence确实是可以实现,因为sequence本身接接受的也是一个suspend的挂起函数:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码private fun simple(): Sequence<Int> = sequence {
for (i in 1..3) {
Thread.sleep(100)
yield(i)
}
}

fun test() {
simple().forEach { value ->
Log.d(TAG, "value :${value}")
}
}
1
2
3
log复制代码D/carman: value :1
D/carman: value :2
D/carman: value :3

但是这里我我们是不可使用delay挂起函数来做延时的,只能使用Thread.sleep。这是因为sequence接收的是一个SequenceScope的扩展函数,而在SequenceScope类上使用了RestrictsSuspension注解。此注解标记的类和接口在用作扩展挂起函数的接收器时受到限制。这些挂起扩展只能调用这个特定接收器上的其他成员或扩展挂起函数,并且不能调用任意的挂起函数。

1
2
3
4
kotlin复制代码@RestrictsSuspension
public abstract class SequenceScope<in T> internal constructor() {
//....
}

如果没有这限制的话,可能就会出现在使用下一个元素的时候,还会有切换线程的副作用。同理,如果我们想通过指定调度器,来指定序列创建所在的线程,同样是不可以的,甚至都不可能设置协程上下文。

既然序列Sequence有这么多限制,那么就必须创造有个新的东西来实现,这个时候Flow就应运而生。

image.png

Flow与RxJava区别

对于熟悉响应式流(Reactive Streams)或RxJava这样的响应式框架的人来说。Flow的设计也许看起来会非常熟悉,尤其是各种操作符看起来都近乎一样。

Flow的设计灵感也来源于响应式流以及其各种实现。但是 Flow 的主要目标是拥有尽可能简单的设计,以及对kotlin协程更友好的支持。有兴趣可以看看 Reactive Streams and Kotlin Flows 这篇文章了解Flow的故事。

虽然有所不同,但从概念上讲,Flow 依然是响应式流。和RxJava一样,依然有冷热流之分。相比于RxJava的切换线程,Flow也会更加简单。

官方在 kotlinx.coroutines中提供的相关响应式模块(如:kotlinx-coroutines-reactive 用于 Reactive Streams, kotlinx-coroutines-rx2/kotlinx-coroutines-rx3 用于 RxJava2/RxJava3等)。 这些模块可以让Flow与其他实现之间进行转换。

Flow本身是一个接口,在这个接口里面定义了一个挂起函数collect函数,它接收的是一个FlowCollector对象。FlowCollector接口中有一个挂起函数emit。那它们又是如何实现响应式流的呢。

1
2
3
4
5
6
7
8
kotlin复制代码public interface Flow<out T> {
@InternalCoroutinesApi
public suspend fun collect(collector: FlowCollector<T>)
}

public interface FlowCollector<in T> {
public suspend fun emit(value: T)
}

创建冷数据流Flow

老规矩,现在我们Flow来替换之前的使用序列Sequence的实现:

通过flow {...}函数创建

1
2
3
4
5
6
7
8
9
10
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
for (i in 1..3) {
delay(100)
emit(i)
}
}.collect { value -> Log.d(TAG, "value :${value}") }
}
}

注意使用Flow的代码与先前示例的区别。这里使用的是flow {...} 函数创建了一个冷数据流Flow,通过emit来发射数据,然后通过collect函数来收集这些数据。但是因为collect是挂起函数,挂起函数的调用又必须在另一个挂起函数或者协程作用域中。此时就需要我们使用协程来执行。

我们继续来看看它们具体是如何实现的,上源码:

1
kotlin复制代码public fun <T> flow(@BuilderInference block: suspend FlowCollector<T>.() -> Unit): Flow<T> = SafeFlow(block)

虽然我们使用的是flow {...} 函数,但是实际是通过SafeFlow类创建的Flow对象。SafeFlow继承自AbstractFlow。而AbstractFlow同时继承了Flow和CancellableFlow两个接口。这也就意味着我们创建的冷数据流Flow是可以取消的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码private class SafeFlow<T>(private val block: suspend FlowCollector<T>.() -> Unit) : AbstractFlow<T>() {
override suspend fun collectSafely(collector: FlowCollector<T>) {
collector.block()
}
}

@FlowPreview
public abstract class AbstractFlow<T> : Flow<T>, CancellableFlow<T> {

public final override suspend fun collect(collector: FlowCollector<T>) {
val safeCollector = SafeCollector(collector, coroutineContext)
try {
collectSafely(safeCollector)
} finally {
safeCollector.releaseIntercepted()
}
}

public abstract suspend fun collectSafely(collector: FlowCollector<T>)
}

这里可以看到虽然我们调用的是collect函数,但是实际是通过collectSafely函数执行。调用SafeCollector执行collect的block高阶函数参数。只不过是在出现异常的时候它会执行SafeCollector的releaseIntercepted函数。我们继续往下看SafeCollector的实现。

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
kotlin复制代码internal actual class SafeCollector<T> actual constructor(
@JvmField internal actual val collector: FlowCollector<T>,
@JvmField internal actual val collectContext: CoroutineContext
) : FlowCollector<T>, ContinuationImpl(NoOpContinuation, EmptyCoroutineContext), CoroutineStackFrame {
//...
override val context: CoroutineContext
get() = completion?.context ?: EmptyCoroutineContext

override fun invokeSuspend(result: Result<Any?>): Any {
result.onFailure { lastEmissionContext = DownstreamExceptionElement(it) }
completion?.resumeWith(result as Result<Unit>)
return COROUTINE_SUSPENDED
}

public actual override fun releaseIntercepted() {
super.releaseIntercepted()
}

override suspend fun emit(value: T) {
return suspendCoroutineUninterceptedOrReturn sc@{ uCont ->
try {
emit(uCont, value)
} catch (e: Throwable) {
lastEmissionContext = DownstreamExceptionElement(e)
throw e
}
}
}

private fun emit(uCont: Continuation<Unit>, value: T): Any? {
//...
return emitFun(collector as FlowCollector<Any?>, value, this as Continuation<Unit>)
}
}

到这里看过协程原理篇的小伙伴应该很熟悉了,这不就协程的执行、调度、恢复过程嘛。这里就不再重复讲解了。如果有需要的可以自己单独去看看。传送门->协程原理1 传送门->协程原理2。

image.png

通过扩展函数asFlow创建

Flow的创建除了使用flow {...} 函数以外,我们还可以使用asFlow进行创建,如下:

1
2
3
4
5
kotlin复制代码fun test() {
lifecycleScope.launch {
(1..3).asFlow().collect { value -> Log.d(TAG, "value :${value}") }
}
}

其实asFlow最终调用的还是flow {...},asFlow的扩展函数有很多种,我们这里只是举例:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码public fun <T> Array<T>.asFlow(): Flow<T> = flow {
forEach { value ->
emit(value)
}
}
//....
public fun IntRange.asFlow(): Flow<Int> = flow {
forEach { value ->
emit(value)
}
}

通过flowOf函数创建

flowOf只支持单个值或者可变值。同样的最终调用的还是flow {...}。

1
2
3
4
5
6
7
8
kotlin复制代码public fun <T> flowOf(vararg elements: T): Flow<T> = flow {
for (element in elements) {
emit(element)
}
}
public fun <T> flowOf(value: T): Flow<T> = flow {
emit(value)
}

例如:

1
2
3
4
5
6
7
kotlin复制代码fun test() {
lifecycleScope.launch {
flowOf(1, 2, 2, 3).collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}

上面提到通过Flow 是可以取消的,但是Flow好像没有提供取消操作,那么我们该如何取消Flow的执行呢。

其实很简单,我们知道Flow的执行是依赖于collect的,而它又必须在协程当中调用,因此取消Flow的主要依赖于collect所在的协程的状态。所以取消Flow只需要取消它所在的协程即可。

1
2
3
4
5
6
7
8
9
10
11
KOTLIN复制代码fun test() {
val job = lifecycleScope.launch {
flow {
for (i in 1..3) {
delay(100)
emit(i)
}
}.collect { value -> Log.d(TAG, "value :${value}") }
}
job.cancel()
}

是不是突然感觉Flow也没有想象中的那么难搞。不过是在协程的基础上进一步封装。重点来了。为了保证flow上下文的一致性,禁止在flow代码块中出现线程调度的情况的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
for (i in 1..3) {
delay(100)
if (i ==2 ){
withContext(Dispatchers.IO){
//骚操作
emit(i)
}
}else{
emit(i)
}
}
}.collect { value -> Log.d(TAG, "value :${value}") }
}
}

上面的代码在编译的时候编译期是不会提示你调用错误的,但是在执行的时候会抛出一个java.lang.IllegalStateException: Flow invariant is violated异常。那么在执行的时候如果想切换线程又该怎么办呢

image.png

Flow的线程切换

在使用Flow的时候如果想切换线程,我们就需要使用Flow的扩展函数flowOn。

1
2
3
4
5
6
7
8
kotlin复制代码public fun <T> Flow<T>.flowOn(context: CoroutineContext): Flow<T> {
checkFlowContext(context)
return when {
context == EmptyCoroutineContext -> this
this is FusibleFlow -> fuse(context = context)
else -> ChannelFlowOperatorImpl(this, context = context)
}
}

flowOn将执行此流的上下文更改为指定上下文。该操作符是可组合的。需要注意的是flowOn只影响前面没有自己上下文的操作符。这个要怎么理解能呢。我们先看默认状态flow是都执行在哪些线程上的:

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
for (i in 1..3) {
Log.d(TAG, "flow :${ currentCoroutineContext()}")
delay(100)
emit(i)
}
}.collect { value ->
Log.d(TAG, "collect:${ currentCoroutineContext()} value :${value}")
}
}
}

通过前面的学习我们知道,lifecycleScope的launch默认是主线程执行的,那么按照协程的执行原理,我们可以确定上面例子中所有的执行操作都是在主线程上:

1
2
3
4
5
6
kotlin复制代码D/carman: flow :[StandaloneCoroutine{Active}@78b0fe4, Dispatchers.Main.immediate]
D/carman: collect:[StandaloneCoroutine{Active}@78b0fe4, Dispatchers.Main.immediate] value :1
D/carman: flow :[StandaloneCoroutine{Active}@78b0fe4, Dispatchers.Main.immediate]
D/carman: collect:[StandaloneCoroutine{Active}@78b0fe4, Dispatchers.Main.immediate] value :2
D/carman: flow :[StandaloneCoroutine{Active}@78b0fe4, Dispatchers.Main.immediate]
D/carman: collect:[StandaloneCoroutine{Active}@78b0fe4, Dispatchers.Main.immediate] value :3

这个时候我们使用flowOn切换一下线程再看看,会产生有何不一样的变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
for (i in 1..3) {
Log.d(TAG, "flow :${ currentCoroutineContext()}")
delay(100)
emit(i)
}
}.flowOn(Dispatchers.IO)
.collect { value ->
Log.d(TAG, "collect:${ currentCoroutineContext()} value :${value}")
}
}
}
1
2
3
4
5
6
kotlin复制代码D/carman: flow :[ProducerCoroutine{Active}@78b0fe4, Dispatchers.IO]
D/carman: flow :[ProducerCoroutine{Active}@78b0fe4, Dispatchers.IO]
D/carman: collect:[ScopeCoroutine{Active}@1e865fe, Dispatchers.Main.immediate] value :1
D/carman: flow :[ProducerCoroutine{Active}@78b0fe4, Dispatchers.IO]
D/carman: collect:[ScopeCoroutine{Active}@1e865fe, Dispatchers.Main.immediate] value :2
D/carman: collect:[ScopeCoroutine{Active}@1e865fe, Dispatchers.Main.immediate] value :3

可以看到flow代码块中的执行已经切换到另外一个线程执行。但是collect中的代码依然执行在主线程上。那如果我们再增加一个又会是什么结果呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
for (i in 1..3) {
Log.d(TAG, "flow :${ currentCoroutineContext()}")
delay(100)
emit(i)
}
}.flowOn(Dispatchers.IO)
.map {
Log.d(TAG, "map :${ currentCoroutineContext()}")
it
}.flowOn(Dispatchers.Default)
.collect { value ->
Log.d(TAG, "collect:${ currentCoroutineContext()} value :${value}")
}
}
}
1
2
3
4
5
6
7
8
9
kotlin复制代码D/carman: flow :[ProducerCoroutine{Active}@78b0fe4, Dispatchers.IO]
D/carman: flow :[ProducerCoroutine{Active}@78b0fe4, Dispatchers.IO]
D/carman: map :[ScopeCoroutine{Active}@cc43a14, Dispatchers.Default]
D/carman: collect:[ScopeCoroutine{Active}@8b702bd, Dispatchers.Main.immediate] value :1
D/carman: flow :[ProducerCoroutine{Active}@78b0fe4, Dispatchers.IO]
D/carman: map :[ScopeCoroutine{Active}@cc43a14, Dispatchers.Default]
D/carman: collect:[ScopeCoroutine{Active}@8b702bd, Dispatchers.Main.immediate] value :2
D/carman: map :[ScopeCoroutine{Active}@cc43a14, Dispatchers.Default]
D/carman: collect:[ScopeCoroutine{Active}@8b702bd, Dispatchers.Main.immediate] value :3

这里我们先跳过map操作符,只看我们本次关注的地方。可以看到在flowOn(Dispatchers.IO)前的flow{...}中的代码是执行在IO线程上的,而在调用flowOn(Dispatchers.Default)并没有改变flow{...}的执行线程,只是改变了没有上下文的map执行线程,使map中的代码块执行在Default线程中。而collect中的代码依然执行在主线程上。

如果这里时候我们把flowOn(Dispatchers.IO)去掉,我们就会发现flow{...}和map中的代码块都将执行在Default线程中。

1
2
3
4
5
6
7
8
9
kotlin复制代码D/carman: flow :[ProducerCoroutine{Active}@3656c4d, Dispatchers.Default]
D/carman: map :[ProducerCoroutine{Active}@3656c4d, Dispatchers.Default]
D/carman: flow :[ProducerCoroutine{Active}@3656c4d, Dispatchers.Default]
D/carman: collect:[ScopeCoroutine{Active}@840cc75, Dispatchers.Main.immediate] value :1
D/carman: map :[ProducerCoroutine{Active}@3656c4d, Dispatchers.Default]
D/carman: flow :[ProducerCoroutine{Active}@3656c4d, Dispatchers.Default]
D/carman: collect:[ScopeCoroutine{Active}@840cc75, Dispatchers.Main.immediate] value :2
D/carman: map :[ProducerCoroutine{Active}@3656c4d, Dispatchers.Default]
D/carman: collect:[ScopeCoroutine{Active}@840cc75, Dispatchers.Main.immediate] value :3

通过四次日志的对比,我们可以做一些总结:

  • flowOn可以将执行此流的上下文更改为指定的上下文。
  • flowOn可以进行组合使用。
  • flowOn只影响前面没有自己上下文的操作符。已经有上下文的操作符不受后面flowOn影响。
  • 不管flowOn如何切换线程,collect始终是运行在调用它的协程调度器上。

image.png

Flow的常用操作符

上面提到Flow的操作符map,实际上collect也是一个操作符。只是他们的责任不一样。根据官方的说法,再结合自身使用感觉,笔者把Flow的操作符主要分为五种(非官方):

  • 过度操作符:又或者叫做流程操作符,用来区分流程执行到某一个阶段。比如:onStart/onEach/onCompletion。过渡操作符应用于上游流,并返回下游流。这些操作符也是冷操作符,就像流一样。这类操作符本身不是挂起函数。它运行的速度很快,返回新的转换流的定义。
  • 异常操作符:用来捕获处理流的异常。比如:catch,onErrorCollect(已废弃,建议用catch)。
  • 转换操作符:主要做一些数据转换操作。比如:transform/map/filter/flatMapConcat等
  • 限制操作符:流触及相应限制的时候会将它的执行取消。比如:drop/take等
  • 末端操作符:是在流上用于启动流收集挂起函数。collect 是最基础的末端操作符,但是还有另外一些更方便使用的末端操作符。例如:toList、toSet、first、single、reduce、fold等等

流程操作符

  • onStart:在上游流启动之前被调用。
  • onEach:在上游流的每个值被下游发出之前调用。
  • onCompletion:在流程完成或取消后调用,并将取消异常或失败作为操作的原因参数传递。

需要注意的是,onStart在SharedFlow(热数据流)一起使用时,并不能保证发生在onStart操作内部或立即发生在onStart操作之后的上游流排放将被收集。这个问题我们在后面文章的热数据流时讲解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
Log.d(TAG, "flow")
emit(1)
}.onStart {
Log.d(TAG, "onStart ")
}.onEach {
Log.d(TAG, "onEach :${it}")
}.onCompletion {
Log.d(TAG, "onCompletion")
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
3
4
5
kotlin复制代码D/carman: onStart 
D/carman: flow
D/carman: onEach :1
D/carman: collect :1
D/carman: onCompletion

可以看到整个执行流程依次是onStart->flow{ ...}->onEach->collect->onCompletion。

异常操作符

上面提到了Flow执行的时候可能会出现异常。我们先修改下代码,在onEach中抛出一个异常信息。再看看代码出现异常后会输出怎样的日志信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
Log.d(TAG, "flow")
emit(1)
}.onStart {
Log.d(TAG, "onStart ")
}.onEach {
Log.d(TAG, "onEach :${it}")
throw NullPointerException("空指针")
}.onCompletion { cause ->
Log.d(TAG, "onCompletion catch $cause")
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
3
4
5
6
7
8
kotlin复制代码 D/carman: onStart 
D/carman: flow
D/carman: onEach 1
D/carman: onCompletion catch java.lang.NullPointerException: 空指针
Process: com.example.myapplication, PID: 31145
java.lang.NullPointerException: 空指针
...
...

可以看到在onEach中抛出一个异常后,因为异常导致协程退出,所以collect没有执行,但是执行了onCompletion。这又是怎么回事呢。

onCompletion不应该是在collect后执行吗?为什么没有执行collect,反而执行了onCompletion。这个时候我们需要看下源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码public fun <T> Flow<T>.onCompletion(
action: suspend FlowCollector<T>.(cause: Throwable?) -> Unit
): Flow<T> = unsafeFlow {
try {
collect(this)
} catch (e: Throwable) {
ThrowingCollector(e).invokeSafely(action, e)
throw e
}
val sc = SafeCollector(this, currentCoroutineContext())
try {
sc.action(null)
} finally {
sc.releaseIntercepted()
}
}

可以看到在onCompletion中,通过try/catch 块来捕获了collect方法,然后在catch分支里。通过invokeSafely执行了onCompletion中的代码,然后重新抛出异常。既然onCompletion又重新抛出了异常,那我们又该通过什么方式合理的处理这个异常呢?

在协程基础篇文章中,我们提到通过使用try/catch 块来处理异常。那么看下如何使用try/catch 进行捕获异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码fun test() {
lifecycleScope.launch {
try {
flow {
Log.d(TAG, "flow")
emit(1)
throw NullPointerException("空指针")
}.onStart {
Log.d(TAG, "onStart ")
}.onEach {
Log.d(TAG, "onEach ")
}.onCompletion {
Log.d(TAG, "onCompletion")
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
} catch (e: Exception) {
Log.d(TAG, "Exception : $e ")
}
}
}

虽然我们同样的可以使用try/catch来处理异常,但是这种写法是不是看上去没有那么优雅。而且出现异常后,无法再继续往下执行。即使我们在flow {...} 构建器内部使用 try/catch,然后再通过emit中发射,这也是不合理的。因为它是违反异常透明性的。

这个时候我们需要使用catch操作符来保留此异常的透明性,并允许封装它的异常处理。catch操作符的代码块可以分析异常并根据捕获到的异常以不同的方式对其做出反应:

  • 可以使用 throw 重新抛出异常。
  • 可以在catch代码块中通过emit将异常转换为新的值发射出去。
  • 可以将异常忽略,或用日志打印,或使用一些其他代码处理它。

现在我们修改一下代码,去掉try/catch块。然后通过catch操作符来捕获异常后,最后通过emit中发射一个新的值出去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
Log.d(TAG, "flow")
emit(1)
throw NullPointerException("空指针")
}.onStart {
Log.d(TAG, "onStart ")
}.onEach {
Log.d(TAG, "onEach ")
}.catch { cause ->
Log.d(TAG, "catch $cause")
emit(2)
}.onCompletion { cause ->
Log.d(TAG, "onCompletion catch $cause")
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
3
4
5
6
kotlin复制代码D/carman: onStart 
D/carman: flow
D/carman: onEach 1
D/carman: catch java.lang.NullPointerException: 空指针
D/carman: collect :2
D/carman: onCompletion catch null

可以看到我们通过catch操作符捕获异常后,collect能够只能收集到上游发射的值。通过我们在catch操作符中通过emit发射的值2也正常被收集。而且我们在onCompletion也不会收集到异常信息。

这个时候我们如果再修改一下代码,在catch操作符后面再加一个map操作符,通过它再抛出一个新的异常又会是什么情况呢。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
Log.d(TAG, "flow")
emit(1)
}.onStart {
Log.d(TAG, "onStart ")
}.onEach {
Log.d(TAG, "onEach $it")
throw NullPointerException("空指针")
}.catch { cause ->
Log.d(TAG, "catch $cause")
emit(2)
}.map {
Log.d(TAG, "map")
throw NullPointerException("新的异常")
it
}.onCompletion { cause ->
Log.d(TAG, "onCompletion2 catch $cause")
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
3
4
5
6
7
8
9
10
kotlin复制代码D/carman: onStart 
D/carman: flow
D/carman: onEach 1
D/carman: catch java.lang.NullPointerException: 空指针
D/carman: map
D/carman: onCompletion2 catch java.lang.NullPointerException: 新的异常
Process: com.example.myapplication, PID: 32168
java.lang.NullPointerException: 新的异常
...
...

程序直接崩溃了。这又是什么情况。这是因为每个操作符只是针对它上游的流,如果下游的流中出现异常,我们需要再次添加一个catch操作符才能正常捕获。

但是如果我们的异常是在collect末端操作符中出现,这个时候我们就只能通过try/catch整个Flow数据流或来处理,或者通过协程上下文中的CoroutineExceptionHandler来处理(这里可以自己动手试试)。

image.png

转换操作符

在流转换操作符中,最通用的一种称为transform。它可以用来模仿简单的转换。还有像map、fliter、zip、Combine、flatMapConcat、flatMapMerge、flatMapLatest等等

transform操作符

transform操作符任意值任意次,其他转换操作符都是基于transform进行扩展的。比如:可以在执行长时间运行的异步请求之前,发射一个字符串并跟踪这个响应。

1
2
3
4
5
6
7
8
9
10
kotlin复制代码fun test() {
lifecycleScope.launch {
(1..3).asFlow().transform {
emit(it)
emit("transform $it")
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
3
4
5
6
kotlin复制代码D/carman: collect :1
D/carman: collect :transform 1
D/carman: collect :2
D/carman: collect :transform 2
D/carman: collect :3
D/carman: collect :transform 3

map操作符

学过RxJava的同学就比较熟悉,我们同通过map操作符进行数据转换操作,包括转换发射出去的数据的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码fun test() {
lifecycleScope.launch {
flow {
emit(1)
}.map {
Log.d(TAG, "第一次转换")
it * 5
}.map {
Log.d(TAG, "第一次转换后的值 :$it")
"map $it"
}.collect { value ->
Log.d(TAG, "最终转换后的值 :${value}")
}
}
}
1
2
3
kotlin复制代码D/carman: 第一次转换
D/carman: 第一次转换后的值 :5
D/carman: 最终转换后的值 :map 5

可以看到我们在第一个map操作符中进行乘运算,第二map操作符中进行类型转换。最终接收到我们经过多次转换处理后的数据。这样做的好处就是,能够保证我们在每一个流的过程中单一职责,一次转换只执行一种操作,而不是把所有过程集中到一起处理完成以后再下发。

map还有同类型操作符mapNotNull,它会过滤掉空值,只发射不为空的值。

1
2
3
4
5
6
7
8
9
10
kotlin复制代码fun test() {
val flow = flowOf("one", "two", "three",null, "four")
lifecycleScope.launch {
flow.mapNotNull {
it
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
3
4
kotlin复制代码D/carman: collect :one
D/carman: collect :two
D/carman: collect :three
D/carman: collect :four

fliter操作符

顾名思义fliter操作符主要是对数据进行一个过滤,返回仅包含与给定匹配的原始流的值的流。

1
2
3
4
5
6
7
8
9
kotlin复制代码fun test() {
lifecycleScope.launch {
(1..3).asFlow().filter {
it < 2
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
kotlin复制代码D/carman: collect :1

fliter还有很多同类型操作符,如:filterNot/filterIsInstance/filterNotNull。

filterNot效果恰恰与fliter想法,它取得是与判断条件相反的值。

1
2
3
4
5
6
7
kotlin复制代码fun test() {
lifecycleScope.launch {
(1..3).asFlow().filterNot { it < 2 }.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
kotlin复制代码D/carman: collect :2
D/carman: collect :3

zip操作符

zip操作符用于组合两个流中的相关值,与RxJava中的zip功能一样:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码fun test() {
val flow1 = (1..3).asFlow()
val flow2 = flowOf("one", "two", "three")
lifecycleScope.launch {
flow2.zip(flow1) { value1, value2 ->
"$value1 :$value2"
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
3
kotlin复制代码D/carman: collect :1 :one
D/carman: collect :2 :two
D/carman: collect :3 :three

限制操作符

take操作符

take操作符返回包含第一个计数元素的流。当发射次数大于等于count的值时,通过抛出异常来取消执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码public fun <T> Flow<T>.take(count: Int): Flow<T> {
require(count > 0) { "Requested element count $count should be positive" }
return flow {
var consumed = 0
try {
collect { value ->
if (++consumed < count) {
return@collect emit(value)
} else {
return@collect emitAbort(value)
}
}
} catch (e: AbortFlowException) {
e.checkOwnership(owner = this)
}
}
}
private suspend fun <T> FlowCollector<T>.emitAbort(value: T) {
emit(value)
throw AbortFlowException(this)
}

我们通过例子来看一下:

1
2
3
4
5
6
7
8
kotlin复制代码fun test() {
lifecycleScope.launch {
(1..3).asFlow().take(2)
.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
kotlin复制代码D/carman: collect :1
D/carman: collect :2

takeWhile操作符

takeWhile操作符与filter类似,不过它是当遇到条件判断为false的时候,将会中断后续的操作。

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码fun test() {
lifecycleScope.launch {
flowOf(1,1,1,2,3,4,4,5,1,2,2,3,3).map {
delay(100)
it
}.takeWhile {
it == 1
}.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
2
3
kotlin复制代码D/carman: collect :1
D/carman: collect :1
D/carman: collect :1

可以看到虽然我们在设置的之中有四个1,但是因为在第四个1之前遇到了false的判断,所以取消了后续流的执行。

drop操作符

drop操作符与take恰恰相反,它是丢弃掉指定的count数量后执行后续的流。

1
2
3
4
5
6
7
8
kotlin复制代码fun test() {
lifecycleScope.launch {
(1..3).asFlow().drop(2)
.collect { value ->
Log.d(TAG, "collect :${value}")
}
}
}
1
kotlin复制代码D/carman: collect :3

image.png

末端流操作符

collect是最基础的末端操作符,基本上每一个例子当中我们都是使用collect。接下来我们讲解一下其他的末端操作符。

toList操作符

toList操作符是讲我们的流转换成一个List集合

1
2
3
4
5
6
kotlin复制代码fun test() {
lifecycleScope.launch {
val list = (1..5).asFlow().toList()
Log.d(TAG, "toList :${list}")
}
}
1
kotlin复制代码D/carman: toList :[1, 2, 3, 4, 5]

到这里我们对于Flow的使用以及在什么情况下,对应的使用哪些操作符已经非常清楚。不过我们还需要补充一点。就是我们在执行流的时候,因为每一次发射都上下游都需要时间去处理,这就会导致我们整个flow的处理时间变成长,那我们应该如何缩短这个时间呢。

image.png

Flow的的缓冲

例如:当我们上游的流的发射很慢,每花费100毫秒才产生一个元素而下游的收集器也非常慢,需要花费300毫秒来处理元素。让我们看看从该流收集三个数字要花费多长时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码fun test() {
lifecycleScope.launch {
val time = measureTimeMillis {
flow {
for (i in 1..3) {
delay(100)
emit(i)
}
}.collect { value ->
delay(300)
Log.d(TAG, "collect :${value}")
}
}
Log.d(TAG, "Collected in $time ms")
}
}
1
2
3
4
kotlin复制代码D/carman: collect :1
D/carman: collect :2
D/carman: collect :3
D/carman: Collected in 1273 ms

它会整个收集过程大约需要1300多毫秒(个人设备不一样会有偏差),这是因为这三个数字,他们每个花费400毫秒。这个时候我们就需要通过buffer操作符来压缩转增时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码fun test() {
lifecycleScope.launch {
val time = measureTimeMillis {
flow {
for (i in 1..3) {
delay(100)
emit(i)
}
}.buffer().collect { value ->
delay(300)
Log.d(TAG, "collect :${value}")
}
}
Log.d(TAG, "Collected in $time ms")
}
}
1
2
3
4
kotlin复制代码D/carman: collect :1
D/carman: collect :2
D/carman: collect :3
D/carman: Collected in 1039 ms

虽然他们的运行结果是一样的,但是过buffer操作符来执行时候变得更快了。因为buffer高效地创建了处理流,仅仅需要等待第一个数字产生的 100 毫秒以及处理每个数字各需花费的 300 毫秒。这种方式大约花费了 1000 毫秒来运行。

到处为止,Flow的基础篇就结束了。下一章节我们讲对Flow在Android中更高级的用法StateFlow和 SharedFlow进行讲解。

原创不易。如果您喜欢这篇文章,您可以动动小手点赞收藏image.png。

Android技术交流群,有兴趣的可以加入

关联文章
Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装
  • 史上最详Android版kotlin协程入门进阶实战(六) -> 深入kotlin协程原理(一)
  • 史上最详Android版kotlin协程入门进阶实战(七) -> 深入kotlin协程原理(二)
  • [史上最详Android版kotlin协程入门进阶实战(八) -> 深入kotlin协程原理(三)]
  • [史上最详Android版kotlin协程入门进阶实战(九) -> 深入kotlin协程原理(四)]

Flow系列

  • Kotlin协程之Flow使用(一)
  • Kotlin协程之Flow使用(二)
  • Kotlin协程之Flow使用(三)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装

本文转载自: 掘金

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

刷了那么多朋友圈,终于搞明白了

发表于 2021-11-25

这是我参与11月更文挑战的第25天,活动详情查看:2021最后一次更文挑战

大家好,我是程序员学长~

今天我们来分享一道腾讯二面算法题,盆友圈问题~

如果喜欢,记得点波关注吧~

朋友圈问题

现在有 105个用户,编号为 1- 105。已知有 m 对关系,每一对关系给你两个数 x 和 y ,代表编号为 x 的用户和编号为 y 的用户是在一个圈子中,例如: A 和 B 在一个圈子中, B 和 C 在一个圈子中,那么 A , B , C 就在一个圈子中。现在想知道最多的一个圈子内有多少个用户。

数据范围:1<= m <= 2 * 10 6 。

进阶:空间复杂度 O(n),时间复杂度 O(nlogn)。

输入描述:

第一行输入一个整数T,接下来有T组测试数据。对于每一组测试数据:第一行输入1个整数n,代表有n对关系。接下来n行,每一行输入两个数x和y,代表编号为x和编号为y的用户在同一个圈子里。

1 ≤ T ≤ 10

1 ≤ n ≤ 2 * 106

1 ≤ x, y ≤ 105

输出描述:

对于每组数据,输出一个答案代表一个圈子内的最多人数。

示例:

输入:

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

输出:

1
2
复制代码4
2

分析问题

通过分析题目,我们可以知道,这道题是求元素分组的问题,即将所有用户分配到不相交的圈子中,然后求出所有圈子中人数最多的那个圈子。

很显然,我们可以使用并查集来求解。

首先,我们来看一下什么是并查集。

并查集是用来将一系列的元素分组到不相交的集合中,并支持合并和查询操作。

  • 合并(Union):把两个不相交的集合合并为一个集合。
  • 查询(Find):查询两个元素是否在同一个集合中。

并查集的重要思想在于,用集合中的一个元素代表集合。

理论总是过于抽象化,下面我们通过一个例子来说明并查集是如何运作的。

我们这里把集合比喻成帮派,而集合中的代表就是帮主。

一开始,江湖纷争四起,所有大侠各自为战,他们每个人都是自己的帮主(对于只有一个元素的集合,代表元素自然就是唯一的那个元素)。

有一天,江湖人士张三和李四偶遇,都想把对方招募到麾下,于是他们进行了一场比武,结果张三赢了,于是把李四招募到了麾下,那么李四的帮主就变成了张三(合并两个集合,帮主就是这个集合的代表元素)。

然后,李四又和王五偶遇,两个人互相不服,于是他们进行了一场比武,结果李四又输了(李四怎么那么菜呢),此时李四能乖乖认怂,加入王五的帮派吗?那当然是不可能!! 此时的李四已经不再是一个人在战斗,于是他呼叫他的老大张三来,张三听说小弟被欺负了,那必须收拾他!!于是和王五比试了一番,结果张三赢了,然后把王五也拉入了麾下(其实李四没必要和王五比试,因为李四比较怂,直接找大哥来收拾王五即可)。此时王五的帮主也是张三了。

我们假设张三二,李四二也进行了帮派的合并,江湖局势变成了如下的样子,形成了两大帮派。

通过上图,我们可以知道,每个帮派(一个集合)是一个树状的结构。

要想寻找到集合的代表元素(帮主),只需要一层层往上访问父节点,直达树的根节点即可。其中根节点的父节点是它自己。

采用这个方法,我们就可以写出最简单版本的并查集代码。

  1. 初始化

我们用数组 fa 来存储每个元素的父节点(这里每个元素有且只有一个父节点)。一开始,他们各自为战,我们将它们的父节点设为自己(假设目前有编号为1~n的n个元素)。

1
2
3
4
python复制代码 def __init__(self,n):
self.fa=[0]*(n+1)
for i in range(1,n+1):
self.fa[i]=i
  1. 查询

这里我们使用递归的方式查找某个元素的代表元素,即一层一层的访问父节点,直至根节点(根节点是指其父节点是其本身的节点)。

1
2
3
4
5
6
lua复制代码 def find(self,x):

if self.fa[x]==x:
return x
else:
return self.find(self.fa[x])
  1. 合并

我们先找到两个元素的根节点,然后将前者的父节点设为后者即可。当然也可以将后者的父节点设为前者,这里暂时不重要。后面会给出一个更合理的比较方法。

1
2
3
4
lua复制代码    def merge(self,x,y):
x_root=self.find(x)
y_root=self.find(y)
self.fa[x_root]=y_root

整体代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
python复制代码class Solution(object):
def __init__(self,n):
self.fa=[0]*(n+1)
for i in range(1,n+1):
self.fa[i]=i

def find(self,x):

if self.fa[x]==x:
return x
else:
return self.find(self.fa[x])

def merge(self,x,y):
x_root=self.find(x)
y_root=self.find(y)
self.fa[x_root]=y_root

优化

上述最简单的并查集代码的效率比较低。假设目前的集合情况如下所示。

此时要调用merge(2,4)函数,于是从2找到1,然后执行f[1]=4,即此时的集合情况变成如下形式。

然后我们执行merge(2,5)函数,于是从2找到1,然后找到4,最后执行f[4]=5,即此时的集合情况变成如下形式。

一直执行下去,我们就会发现该算法可能会形成一条长长的链,随着链越来越长,我们想要从底部找到根节点会变得越来越难。

所以就需要进行优化处理,这里我们可以使用路径压缩的方法,即使每个元素到根节点的路径尽可能的短。
具体来说,我们在查询的过程中,把沿途的每个节点的父节点都设置为根节点即可。那么下次再查询时,就可以很简单的获取到元素的根节点了。代码如下所示:

1
2
3
4
5
6
lua复制代码    def find(self,x):
if x==self.fa[x]:
return x
else:
self.fa[x] = self.find(self.fa[x])
return self.fa[x]

经过路径压缩后,并查集代码的时间复杂度已经很低了。

下面我们再来进一步的进行优化处理—按秩合并。

这里我们需要先说明一点,因为路径压缩优化只是在查询时进行的,也只能压缩一条路径,因此经过路径优化后,并查集最终的结构仍然可能是比较复杂的。假设,我们现在有一颗比较复杂的树和一个元素进行合并操作。

如果此时我们要merge(1,6),我们应该把6的父节点设为1。因为如果把1的父节点设为6,会使树的深度加深,这样就会使树中的每个元素到根节点的距离都变长了,从而使得之后我们寻找根节点的路径也就会相应的变长。而如果把6的父节点设为1,就不会出现这个问题。

这就启发我们应该把简单的树往复杂的树上去合并,因为这样合并后,到根节点距离变长的节点个数比较少。

具体来说,我们用一个数组rank 来记录每个根节点对应的树的深度(如果对应元素不是树的根节点,其rank值相当于以它作为根节点的子树的深度)。

初始时,把所有元素的rank设为1。在合并时,比较两个根节点,把rank较小者往较大者上合并。

下面我们来看一下代码的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python复制代码    def merge(self,x,y):
#找个两个元素对应的根节点
x_root=self.find(x)
y_root=self.find(y)

if self.rank[x_root] <= self.rank[y_root]:
self.fa[x_root]=y_root
else:
self.fa[y_root] = x_root

#如果深度相同且根节点不同,则新的根节点的深度
if self.rank[x_root] == self.rank[y_root] \
and x_root != y_root:
self.rank[y_root]=self.rank[y_root]+1

所以,我们终极版的并查集代码如下所示。

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
python复制代码class Solution(object):
def __init__(self,n):
self.fa=[0]*(n+1)
self.rank=[0]*(n+1)
for i in range(1,n+1):
self.fa[i]=i
self.rank[i]=i

def find(self,x):
if x==self.fa[x]:
return x
else:
self.fa[x] = self.find(self.fa[x])
return self.fa[x]

def merge(self,x,y):
#找个两个元素对应的根节点
x_root=self.find(x)
y_root=self.find(y)

if self.rank[x_root] <= self.rank[y_root]:
self.fa[x_root]=y_root
else:
self.fa[y_root] = x_root

#如果深度相同且根节点不同,则新的根节点的深度
if self.rank[x_root] == self.rank[y_root] \
and x_root != y_root:
self.rank[y_root]=self.rank[y_root]+1

有了并查集的思想,那我们这道朋友圈的问题就迎刃而解了。下面我们给出可以AC的代码。

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
ini复制代码class Solution(object):
def __init__(self,n):
self.fa=[0]*(n+1)
self.rank=[0]*(n+1)
self.node_num=[0]*(n+1)

for i in range(1,n+1):
self.fa[i]=i
self.rank[i]=1
self.node_num[i]=1

def find(self,x):
if x==self.fa[x]:
return x
else:
self.fa[x] = self.find(self.fa[x])
return self.fa[x]

def merge(self,x,y):
#找个两个元素对应的根节点
x_root=self.find(x)
y_root=self.find(y)

if self.rank[x_root] <= self.rank[y_root]:
#将x_root集合合并到y_root上
self.fa[x_root]=y_root
self.node_num[y_root] = self.node_num[y_root] + self.node_num[x_root]
else:
#将y_root集合合并到x_root上
self.fa[y_root] = x_root
self.node_num[x_root] = self.node_num[x_root] + self.node_num[y_root]

#如果深度相同且根节点不同,则新的根节点的深度
if self.rank[x_root] == self.rank[y_root] \
and x_root != y_root:
self.rank[y_root]=self.rank[y_root]+1


if __name__ == '__main__':
#最多有N个用户
N=100000
result=[]
T = int(input("请输入多少组检测数据?"))
while T>0:
n = int(input("输入多少对用户关系"))
print("输入{}组用户关系".format(n))
s1=Solution(N)
for i in range(n):
cur=input()
cur_users=cur.split(" ")
s1.merge(int(cur_users[0]), int(cur_users[1]))

max_people=1
for i in range(len(s1.node_num)):
max_people=max(max_people, s1.node_num[i])
result.append(max_people)
T=T-1

for x in result:
print(x)

到此,我们的并查集就聊完了。

啰嗦一句

现在给出一个思考题,可以把你的思考写在留言区。

现在给出某个亲戚关系图,判断任意给出的两个人是否具有亲戚关系。

原创不易!各位小伙伴觉得文章不错的话,不妨点赞(在看)、留言、转发三连走起!

你知道的越多,你的思维越开阔。我们下期再见。

更多硬核知识,请关注公众号:程序员学长,欢迎大家一起来聊聊天。

本文转载自: 掘金

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

重拾面向对象软件设计 一 前言 二 编程思想的演进 三 面向

发表于 2021-11-25

简介:从上个世纪五十年代冯·诺依曼创造第一台计算机开始,一直到现在只有短短70年时间,从第一门计算机语言FORTRAN,到现在我们常用的C++,JAVA,PYTHON等,计算机语言的演进速度远超我们所使用的任何一门自然语言。从最早的面向机器,再到面向过程,到演化为现在我们所使用的面向对象。不变的是编程的宗旨,变化的是编程的思想。

作者 | 聂晓龙

来源 | 阿里技术公众号

你还在用面向对象的语言,写着面向过程的代码吗?

一 前言

在欧洲文艺复兴时期,一位伟大的数学家天文学家-哥白尼,在当时提出了日心说,驳斥了以地球为宇宙中心的天体思想,由于思想极其超前,直到半个世纪后开普勒伽利略等人经过后期研究,才逐步认可并确立了当时哥白尼思想的先进性。

无独有偶,在软件工程领域也上演着同样的故事。半个世纪前 Kristen Nygaard发明了Simula语言,这也是现在被认同的世界上第一个明确实现面向对象编程的语言,他提出了基于类的编程风格,确定了”万物皆对象”这一面向对象理论的”终极思想”,但在当时同样未受到认可。Peter Norvig 在 Design Patterns in Dynamic Programming 对此予以了驳斥,并表述我们并不需要什么面向对象。半个世纪后 Robert C.Martin、Bertrand Meyer、Martin Fowler等人,再次印证并升华了面向对象的设计理念。编程思想的演进也不是一蹴而就,但在这一个世纪得到了飞速的发展。

二 编程思想的演进

从上个世纪五十年代冯·诺依曼创造第一台计算机开始,一直到现在只有短短70年时间,从第一门计算机语言FORTRAN,到现在我们常用的C++,JAVA,PYTHON等,计算机语言的演进速度远超我们所使用的任何一门自然语言。从最早的面向机器,再到面向过程,到演化为现在我们所使用的面向对象。不变的是编程的宗旨,变化的是编程的思想。

1 面向机器

计算机是01的世界,最早的程序就是通过这种01机器码来控制计算机的,比如0000代表读取,0001代表保存等。理论上这才是世界上最快的语言,无需翻译直接运行。但弊端也很明显,那就是几乎无法维护。运行5毫秒,编程3小时。由于机器码无法维护,人们在此基础上发明了汇编语言,READ代表0000,SAVE代表0001,这样更易理解和维护。虽然汇编在机器码上更可视更直观,但本质上还是一门面向机器的语言,依然还是存在很高的编程成本。

2 面向过程

面向过程是一种以事件为中心的编程思想,相比于面向机器的编程方式,是一种巨大的进步。我们不用再关注机器指令,而是聚焦于具体的问题。它将一件事情拆分成若干个执行的步骤,然后通过函数实现每一个环节,最终串联起来完成软件设计。

流程化的设计让编码更加清晰,相比于机器码或汇编,开发效率得到了极大改善,包括现在仍然有很多场景更适合面向过程来完成。但软件工程最大的成本在于维护,由于面向过程更多聚焦于问题的解决而非领域的设计,代码的重用性与扩展性弊端逐步彰显出来,随着业务逻辑越来越复杂,软件的复杂性也变得越来越不可控。

3 面向对象

面向对象以分类的方式进行思考和解决问题,面向对象的核心是抽象思维。通过抽象提取共性,通过封装收敛逻辑,通过多态实现扩展。面向对象的思想本质是将数据与行为做结合,数据与行为的载体称之为对象,而对象要负责的是定义职责的边界。面向过程简单快捷,在处理简单的业务系统时,面向对象的效果其实并不如面向过程。但在复杂系统的设计上,通用性的业务流程,个性化的差异点,原子化的功能组件等等,更适合面向对象的编程模式。

但面向对象也不是银弹,甚至有些场景用比不用还糟,一切的根源就是抽象。根据 MECE法则 将一个事物进行分类,if else 是软件工程最严谨的分类。我们在设计抽象进行分类时,不一定能抓住最合适的切入点,错误的抽象比没有抽象复杂度更高。里氏替换原则的创始人Barbara Liskov 谈抽象的力量 The Power of Abstraction。

三 面向领域设计

1 真在“面向对象”吗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
arduino复制代码// 捡入客户到销售私海
public String pick(String salesId, String customerId){
// 校验是否销售角色
Operator operator = dao.find("db_operator", salesId);
if("SALES".equals(operator.getRole())){
return "operator not sales";
}
// 校验销售库容是否已满
int hold = dao.find("sales_hold", salesId);
List<CustomerVo> customers = dao.find("db_sales_customer", salesId);
if(customers.size() >= hold){
return "hold is full";
}
// 校验是否客户可捡入
Opportunity opp = dao.find("db_opportunity", customerId);
if(opp.getOwnerId() != null){
return "can not pick other's customer";
}
// 捡入客户
opp.setOwnerId(salesId);
dao.save(opp);
return "success";
}

这是一段CRM领域销售捡入客户的业务代码。这是我们熟悉的Java-面向对象语言,但这是一段面向对象代码吗?完全面向事件,没有封装没有抽象,难以复用不易扩展。相信在我们代码库,这样的代码不在少数。为什么?因为它将成本放到了未来。我们将此称之为“披着面向对象的外衣,干着面向过程的勾当。”

在系统设计的早期,业务规则不复杂,逻辑复用与扩展体现得也并不强烈,而面向过程的代码在支撑这些相对简单的业务场景是非常容易的。但软件工程最大的成本在于维护,当系统足够复杂时,当初那些写起来最easy的代码,将来就是维护起来最hard的债务。

2 领域驱动设计

还有一种方式我们也可以这么来写,新增“商机”模型,通过商机来关联客户与销售之间的关系。而商机的归属也分为公海、私海等具体归属场景。商机除了有必要的数据外,还应该收拢一些业务行为,捡入、开放、分发等。通过领域建模,利用面向对象的特性,确定边界、抽象封装、行为收拢,对业务分而治之。

当我们业务上说“商机分发到私海”,而我们代码则是“opportunity.pickTo(privateSea)”。这是领域驱动所带来的改变,面向领域设计,面向对象编程,领域模型的抽象就是对现实世界的描述。但这并非一蹴而就的过程,当你只触碰到大象的身板时,你认为这是一扇门,当你触碰到大象的耳朵时,你认为是一片芭蕉。只有我们不断抽象不断重构,我们才能愈发接近业务的真实模型。

Use the model as the backbone of a language, Recognize that a change in the language is a change to the model.Then refactor the code, renaming classes, methods, and modules to conform to the new model

— Eric Evans 《Domain-Driven Design Reference》

译:使用模型作为语言的支柱,意识到言语的改变就是对模型的改变,然后重构代码,重命名类,方法和模块以符合新模型。

3 软件的复杂度

这是Martin Flowler在 Patterns of Enterprise Application Architecture 这本书中所提的关于复杂度的观点,他将软件开发分为数据驱动与领域驱动。很多时候开发的方式大家倾向于,拿到需求后看表怎么设计,然后看代码怎么写,这其实也是面向过程的一个表现。在软件初期,这样的方式复杂度是很低的,没有复用没有扩展,一人吃饱全家不饿。但随着业务的发展系统的演进,复杂度会陡增。

而一开始通过领域建模方式,以面向对象思维进行软件设计,复杂度的上升可以得到很好的控制。先思考我们领域模型的设计,这是我们业务系统的核心,再逐步外延,到接口到缓存到数据库。但领域的边界,模型的抽象,从刚开始成本是高于数据驱动的。

The goal of software architecture is to minimize the human resources required to build and maintain the required system.

— Robert C. Martin 《Clean Architecture》

译:软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求

如果刚开始我们直接以数据驱动面向过程的流程式代码,可以很轻松的解决问题,并且之后也不会面向更复杂的场景与业务,那这套模式就是最适合这套系统的架构设计。如果我们的系统会随着业务的发展逐渐复杂,每一次的发布都会提升下一次发布的成本,那么我们应该考虑投入必要的成本来面向领域驱动设计。

四 抽象的品质

抽象永远是软件工程领域最难的命题,因为它没有规则,没有标准,甚至没有对错,只分好坏,只分是否适合。同样一份淘宝商品模型的领域抽象,可以算是业界标杆了,但它并非适合你的系统。那我们该如何驾驭“抽象”呢?UML的创始人Grady booch在 Object Oriented Analysis and Design with Applications 一书中,提到了评判一种抽象的品质可以通过如下5个指标进行测量:耦合性、内聚性、充分性、完整性与基础性。

1 耦合性

一个模块与另一个模块之间建立起来的关联强度的测量称之为耦合性。一个模块与其他模块高度相关,那它就难以独立得被理解、变化或修改。TCL语言发明者John Ousterhout教授也有同样的观点。我们应该尽可能减少模块间的耦合依赖,从而降低复杂度。

Complexity is caused by two things: dependencies and obscurity.

— John Ousterhout 《A Philosophy of Software Design》

译:复杂性是由两件事引起的:依赖性和模糊性。

但这并不意味着我们就不需要耦合。软件设计是朝着扩展性与复用性发展的,继承天然就是强耦合,但它为我们提供了软件系统的复用能力。如同摩擦力一般,起初以为它阻碍了我们前进的步伐,实则没有摩擦力,我们寸步难行。

2 内聚性

内聚性与耦合性都是结构化设计中的概念,内聚性测量的是单个模块里,各个元素的的联系程度。高内聚低耦合,是写在教科书里的观点,但我们也并非何时何地都应该盲目追求高内聚。

内聚性分为偶然性内聚与功能性内聚。金鱼与消防栓,我们一样可以因为它们都不会吹口哨,将他们抽象在一起,但很明显我们不该这么干,这就是偶然性内聚。最希望出现的内聚是功能性内聚,即一个类或模式的各元素一同工作,提供某种清晰界定的行为。比如我将消防栓、灭火器、探测仪等内聚在一起,他们是都属于消防设施,这是功能性内聚。

3 充分性

充分性指一个类或模块需要应该记录某个抽象足够多的特征,否则组件将变得不用。比如Set集合类,如果我们只有remove、get却没有add,那这个类一定没法用了,因为它没有形成一个闭环 。不过这种情况相对出现较少,只要当我们真正去使用,完成它的一系列流程操作后,缺失的一些内容是比较容易发现并解决的。

4 完整性

完整性指类或模块需要记录某个抽象全部有意义的特征。完整性与充分性相对,充分性是模块的最小内涵,完整性则是模块的最大外延。我们走完一个流程,可以清晰得知道我们缺哪些,可以让我们马上补齐抽象的充分性,但可能在另一个场景这些特征就又不够了,我们需要考虑模块还需要具备哪些特征或者他应该还补齐哪些能力。

5 基础性

充分性、完整性与基础性可以说是3个相互辅助相互制约的原则。基础性指抽象底层表现形式最有效的基础性操作(似乎用自己在解释自己)。比如Set中的add操作,是一个基础性操作,在已经存在add的情况下,我们是否需要一次性添加2个元素的add2操作?很明显我们不需要,因为我们可以通过调用2次add来完成,所以add2并不符合基础性。

但我们试想另一个场景,如果要判断一个元素是否在Set集合中,我们是否需要增加一个contains方法。Set已经有foreach、get等操作了,按照基础性理论,我们也可以把所有的元素遍历一遍,然后看该元素是否包含其中。但基础性有一个关键词叫“有效”,虽然我们可以通过一些基础操作进行组合,但它会消耗大量资源或者复杂度,那它也可以作为基础操作的一个候选者。

五 软件设计原则

抽象的品质可以指导我们抽象与建模,但总归还是不够具象,在此基础上一些更落地更易执行的设计原则涌现出来,最著名的当属面向对象的五大设计原则 S.O.L.I.D。

1 开闭原则OCP

Software entities should be open for extension,but closed for modification

– Bertrand Meyer 《Object Oriented Software Construction》

译:软件实体应当对扩展开放,对修改关闭。

开闭原则是Bertrand Meyer 1988年在 Object Oriented Software Construction 书中所提到一个观点,软件实体应该对扩展开放对修改关闭。

我们来看一个关于开闭原则的例子,需要传进来的用户列表,分类型进行二次排序,我们代码可以这样写。

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码public List<User> sort(List<User> users, Enum type){
if(type == AGE){
// 按年龄排序
users = resortListByAge(users);
}else if(type == NAME){
// 按名称首字母排序
users = resortListByName(users);
}else if(type == NAME){
// 按客户健康分排序
users = resortListByHealth(users);
}
return users;
}

上述代码就是一个明显违背开闭原则的例子,当我们需要新增一种类似时,需要修改主流程。由于这些方法都定义在私有函数中,我们哪怕对现有逻辑做调整,我们也需要修改到这份代码文件。

还有一种做法,可以实现对扩展开放对修改关闭,JDK的排序其实已经为我们定义了这样的标准。我们将不同的排序方式进行抽象,每种逻辑单独实现,单个调整逻辑不影响其他内容,新增排序方式也无需对已有模块进行调整。

2 依赖倒置DIP

High level modules shouldnot depend upon low level modules.Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions

— Robert C.Martin C++ Report 1996

译:高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。

Robert C.Martin是《Clean Code》《Code Architecture》两本经典书籍的作者,1996年他在C++ Report中发表了一篇名为 The Dependency Inversion Principle 的文章。他认为模块间的依赖应该是有序的,高层不应该依赖低层,低层应该依赖高层,抽象不应该依赖细节,细节应该依赖抽象。

怎么理解Robert C.Martin的这一观点。我们看这张图,我们的手可以握住这个杯子,是我们依赖杯子吗?有人说我们需要调杯子提供的hold服务,我们才能握住它,所以是我们依赖杯子。但我们再思考一下,棍子我们是不是也可以握,水壶我们也可以握,但猫狗却不行,为什么?因为我们的杯子是按照我们的手型进行设计的,我们定义了一个可握持的holdable接口,杯子依赖我们的需求进行设计。所以是杯子依赖我们,而非我们依赖杯子。

依赖倒置原则并非一个新创造的理论,我们生活的很多地方都有在运用。比如一家公司需要设立“法人”,如果这家公司出了问题,监管局就会找公司法人。并非监管局依赖公司提供的法人职位,它可以找到人,而是公司依赖监管局的要求,才设立法人职位。这也是依赖倒置的一种表现。

3 其他设计原则

这里没有一一将 S.O.L.I.D 一一列举完,大家想了解的可以自行查阅。除了SOLID之外,还有一些其他的设计原则,同样也非常优秀。

PLOA最小惊讶原则

If a necessary feature has a high astonishment factor, it may be necessary to redesign the feature

– Michael F. Cowlishaw

译:如果必要的特征具有较高的惊人因素,则可能需要重新设计该特征。

PLOA最小惊讶原则是斯坦福大家计算机教授 Michael F. Cowlishaw 提出的。不管你的代码有“多好”,如果大部分人都对此感到吃惊,或许我们应该重新设计它。JDK中就存在一例违反PLOA原则的案例,我们来看下面这段代码。

在分享会上,我故意将这行注释遮盖起来,大家都猜不到 newFormatter.getClass() 这句代码写在这里的作用。如果要检查空指针,完全可以用Objects工具类提供的方法,实现完全一样,但代码表现出来的含义就千差万别了。

KISS简单原则

Keep it Simple and Stupid

– Robert S. Kaplan

译:保持愚蠢,保持简单

KISS原则是 Robert S. Kaplan 提出的一个理论,Kaplan并非是一个软件学家,他是平衡积分卡Balanced Scorecard创始人,而他所提出的这个理论对软件行业依然适用。把事情变复杂很简单,把事情变简单很复杂。我们需要尽量让复杂的问题简明化、简单化。

六 写在最后

软件设计的最大目标,就是降低复杂性,万物不为我所有,但万物皆为我用。引用JDK集合框架创办人Josh Bloch 的一句话来结束。学习编程艺术首先要学会基本的规则,然后才能知道什么时候可以打破这些规则。

You should not slavishly follow these rules, but violate them only occasionally and with good reason. Learning the art of programming, like most other disciplines, consists of first learning the rules and then learning when to break them.

— Josh Bloch 《Effective Java》

译:你不该盲目的遵从这些规则,应该只在偶尔情况下,有充分理由后才去打破这些规则

学习编程艺术首先要学会基本的规则,然后才能知道什么时候可以打破这些规则

参阅书籍

1、《Object Oriented Analysis and Design with Applications》niexiaolong.github.io/Object%20Or…

2、《Clean Architecture》

detail.tmall.com/item.htm?id…

3、《A Philosophy of Software Design》

www.amazon.com/-/zh/dp/173…

原文链接

本文为阿里云原创内容,未经允许不得转载。

本文转载自: 掘金

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

Hibernate数据校验简介 JSR数据校验规范 Hibe

发表于 2021-11-25

我们在业务中经常会遇到参数校验问题,比如前端参数校验、Kafka消息参数校验等,如果业务逻辑比较复杂,各种实体比较多的时候,我们通过代码对这些数据一一校验,会出现大量的重复代码以及和主要业务无关的逻辑。Spring MVC提供了参数校验机制,但是其底层还是通过Hibernate进行数据校验,所以有必要去了解一下Hibernate数据校验和JSR数据校验规范。

JSR数据校验规范

Java官方先后发布了JSR303与JSR349提出了数据合法性校验提供的标准框架:BeanValidator,BeanValidator框架中,用户通过在Bean的属性上标注类似于@NotNull、@Max等标准的注解指定校验规则,并通过标准的验证接口对Bean进行验证。

JSR注解列表

JSR标准中的数据校验注解如下所示:

注解名 注解数据类型 注解作用 示例
AssertFalse boolean/Boolean 被注释的元素必须为False @AssertFalse private boolean success;
AssertTrue boolean/Boolean 被注释的元素必须为True @AssertTrue private boolean success;
DecimalMax BigDecimal/BigInteger/CharSequence/byte/short/int/long及其包装类 被注释的值应该小于等于指定的最大值 @DecimalMax("10") private BigDecimal value;
DecimalMin BigDecimal/BigInteger/CharSequence/byte/short/int/long及其包装类 被注释的值应该大于等于指定的最小值 @DecimalMin("10") private BigDecimal value;
Digits BigDecimal/BigInteger/CharSequence/byte/short/int/long及其包装类 integer指定整数部分最大位数,fraction指定小数部分最大位数 @Digits(integer = 10,fraction = 4) private BigDecimal value;
Email CharSequence 字符串为合法的邮箱格式 @Email private String email;
Future java中的各种日期类型 指定日期应该在当期日期之后 @Future private LocalDateTime future;
FutureOrPresent java中的各种日期类型 指定日期应该为当期日期或当期日期之后 @FutureOrPresent private LocalDateTime futureOrPresent;
Max BigDecimal/BigInteger/byte/short/int/long及包装类 被注释的值应该小于等于指定的最大值 @Max("10") private BigDecimal value;
Min BigDecimal/BigInteger/byte/short/int/long及包装类 被注释的值应该大于等于指定的最小值 @Min("10") private BigDecimal value;
Negative BigDecimal/BigInteger/byte/short/int/long/float/double及包装类 被注释的值应该是负数 @Negative private BigDecimal value;
NegativeOrZero BigDecimal/BigInteger/byte/short/int/long/float/double及包装类 被注释的值应该是0或者负数 @NegativeOrZero private BigDecimal value;
NotBlank CharSequence 被注释的字符串至少包含一个非空字符 @NotBlank private String noBlankString;
NotEmpty CharSequence/Collection/Map/Array 被注释的集合元素个数大于0 @NotEmpty private List<string> values;
NotNull any 被注释的值不为空 @NotEmpty private Object value;
Null any 被注释的值必须空 @Null private Object value;
Past java中的各种日期类型 指定日期应该在当期日期之前 @Past private LocalDateTime past;
PastOrPresent java中的各种日期类型 指定日期应该在当期日期或之前 @PastOrPresent private LocalDateTime pastOrPresent;
Pattern CharSequence 被注释的字符串应该符合给定得到正则表达式 @Pattern(\d*) private String numbers;
Positive BigDecimal/BigInteger/byte/short/int/long/float/double及包装类 被注释的值应该是正数 @Positive private BigDecimal value;
PositiveOrZero BigDecimal/BigInteger/byte/short/int/long/float/double及包装类 被注释的值应该是正数或0 @PositiveOrZero private BigDecimal value;
Size CharSequence/Collection/Map/Array 被注释的集合元素个数在指定范围内 @Size(min=1,max=10) private List<string> values;

JSR注解内容

我们以常用的比较简单的@NotNull注解为例,看看注解中都包含那些内容,如下边的源码所示,可以看到@NotNull注解包含以下几个内容:

  1. message:错误消息,示例中的是错误码,可以根据国际化翻译成不同的语言。
  2. groups: 分组校验,不同的分组可以有不同的校验条件,比如同一个DTO用于create和update时校验条件可能不一样。
  3. payload:BeanValidation 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
java复制代码@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@Documented
@Constraint(validatedBy = { })
public @interface NotNull {
String message() default "{javax.validation.constraints.NotNull.message}";

Class<?>[] groups() default { };

Class<? extends Payload>[] payload() default { };

/**
* Defines several {@link NotNull} annotations on the same element.
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@interface List {

NotNull[] value();
}
}

错误消息message、分组group这些功能我们程序中使用比较多,在我介绍Spring Validator数据校验的文章中有详细说明,但是关于payload我们接触的比较少,下面我们举例说明以下payload的使用,下面的示例中,我们用payload来标识数据校验失败的严重性,通过以下代码。在校验完一个ContactDetails的示例之后, 你就可以通过调用ConstraintViolation.getConstraintDescriptor().getPayload()来得到之前指定到错误级别了,并且可以根据这个信息来决定接下来到行为.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public class Severity {
public static class Info extends Payload {};
public static class Error extends Payload {};
}

public class ContactDetails {
@NotNull(message="Name is mandatory", payload=Severity.Error.class)
private String name;

@NotNull(message="Phone number not specified, but not mandatory", payload=Severity.Info.class)
private String phoneNumber;

// ...
}

JSR校验接口

通过前面的JSR校验注解,我们可以给某个类的对应字段添加校验条件,那么怎么去校验这些校验条件呢?JSR进行数据校验的核心接口是Validation,该接口的定义如下所示,我们使用比较多的接口应该是<T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups);,该方法可以用于校验某个Object是否符合指定分组的校验规则,如果不指定分组,那么只有默认分组的校验规则会生效。

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
java复制代码public interface Validator {

/**
* Validates all constraints on {@code object}.
*/
<T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups);

/**
* Validates all constraints placed on the property of {@code object}
* named {@code propertyName}.
*/
<T> Set<ConstraintViolation<T>> validateProperty(T object, String propertyName,Class<?>... groups);

/**
* Validates all constraints placed on the property named {@code propertyName}
* of the class {@code beanType} would the property value be {@code value}.
*/
<T> Set<ConstraintViolation<T>> validateValue(Class<T> beanType, String propertyName, Object value, Class<?>... groups);

/**
* Returns the descriptor object describing bean constraints.
* The returned object (and associated objects including
* {@link ConstraintDescriptor}s) are immutable.
*/
BeanDescriptor getConstraintsForClass(Class<?> clazz);

/**
* Returns an instance of the specified type allowing access to
* provider-specific APIs.
* <p>
* If the Jakarta Bean Validation provider implementation does not support
* the specified class, {@link ValidationException} is thrown.call
*/
<T> T unwrap(Class<T> type);

/**
* Returns the contract for validating parameters and return values of methods
* and constructors.
*/
ExecutableValidator forExecutables();
}

Hibernate数据校验

基于JSR数据校验规范,Hibernate添加了一些新的注解校验,然后实现了JSR的Validator接口用于数据校验。

Hibernate新增注解

注解名 注解数据类型 注解作用 示例
CNPJ CharSequence 被注释的元素必须为合法的巴西法人国家登记号 @CNPJ private String cnpj;
CPF CharSequence 被注释的元素必须为合法的巴西纳税人注册号 @CPF private String cpf;
TituloEleitoral CharSequence 被注释的元素必须为合法的巴西选民身份证号码 @TituloEleitoral private String tituloEleitoral;
NIP CharSequence 被注释的元素必须为合法的波兰税号 @NIP private String nip;
PESEL CharSequence 被注释的元素必须为合法的波兰身份证号码 @PESEL private String pesel;
REGON CharSequence 被注释的元素必须为合法的波兰区域编号 @REGON private String regon;
DurationMax Duration 被注释的元素Duration的时间长度小于指定的时间长度 @DurationMax(day=1) private Duration duration;
DurationMin Duration 被注释的元素Duration的时间长度大于指定的时间长度 @DurationMin(day=1) private Duration duration;
CodePointLength CharSequence 被注释的元素CodPoint数目在指定范围内,unicode中每一个字符都有一个唯一的识别码,这个码就是CodePoint。比如我们要限制中文字符的数目,就可以使用这个 @CodePointLength(min=1) private String name;
ConstraintComposition 其它数据校验注解 组合注解的组合关系,与或等关系 —
CreditCardNumber CharSequence 用于判断一个信用卡是不是合法格式的信用卡 @CreditCardNumber private String credictCardNumber;
Currency CharSequence 被注释的元素是指定类型的汇率 @Currency(value = {"USD"}) private String currency;
ISBN CharSequence 被注释的元素是合法的ISBN号码 @ISBN private String isbn;
Length CharSequence 被注释的元素是长度在指定范围内 @Length(min=1) private String name;
LuhnCheck CharSequence 被注释的元素可以通过Luhn算法检查 @LuhnCheck private String luhn;
Mod10Check CharSequence 被注释的元素可以通过模10算法检查 @Mod10Check private String mod10;
ParameterScriptAssert 方法 参数脚本校验 ————
ScriptAssert 类 类脚本校验 ————
UniqueElements 集合 集合中的每个元素都是唯一的 @UniqueElements private List<String> elements;

Hibiernate数据校验

如何使用Hibernate进行数据校验呢?我们知道JSR规定了数据校验的接口Validator,Hibernate用ValidatorImpl类中实现了Validator接口,我们可以通过Hibernate提供的工厂类HibernateValidator.buildValidatorFactory创建一个ValidatorImpl实例。使用Hibernate创建一个Validator实例的代码如下所示。

1
2
3
4
5
java复制代码ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
.configure()
.addProperty( "hibernate.validator.fail_fast", "true" )
.buildValidatorFactory();
Validator validator = validatorFactory.getValidator();

Hibernate校验源码

通过上面的内容,我们知道Hibernate可以用工厂方法实例化一个Validator接口的实例,这个实例可以用于带有校验注解的校验JavaBean,那么Hibernate底层是如何实现这些校验逻辑的呢?我们以如下JavaBean为例,解析Hibernate校验的源码。

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Data
public class Person {

@NotBlank
@Size(max=64)
private String name;

@Min(0)
@Max(200)
private int age;
}

ConstraintValidator介绍

ConstraintValidator是Hibernate中数据校验的最细粒度,他可以校验指定注解和类型的数值是否合法。比如上面例子中的@Max(200)private int age;,对于age字段的校验就会使用一个叫MaxValidatorForInteger的ConstraintValidator,这个ConstraintValidator在校验的时候会判断指定的数值是不是大于指定的最大值。

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
java复制代码public class MaxValidatorForInteger extends AbstractMaxValidator<Integer> {

@Override
protected int compare(Integer number) {
return NumberComparatorHelper.compare( number.longValue(), maxValue );
}
}

public abstract class AbstractMaxValidator<T> implements ConstraintValidator<Max, T> {

protected long maxValue;

@Override
public void initialize(Max maxValue) {
this.maxValue = maxValue.value();
}

@Override
public boolean isValid(T value, ConstraintValidatorContext constraintValidatorContext) {
// null values are valid
if ( value == null ) {
return true;
}

return compare( value ) <= 0;
}

protected abstract int compare(T number);
}

ConstraintValidator初始化

我们在前面的内容中说到Hibernate提供了ValidatorImpl用于数据校验,那么ValidatorImpl和ConstraintValidator是什么关系呢,简单来说就是ValidatorImpl在初始化的时候会初始化所有的ConstraintValidator,在校验数据的过程中调用这些内置的ConstraintValidator校验数据。内置ConstraintValidator的对应注解的@Constraint(validatedBy = { })是空的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@Documented
@Constraint(validatedBy = { }) // 这儿是空的
public @interface AssertFalse {

String message() default "{javax.validation.constraints.AssertFalse.message}";

Class<?>[] groups() default { };

Class<? extends Payload>[] payload() default { };

/**
* Defines several {@link AssertFalse} annotations on the same element.
*
* @see javax.validation.constraints.AssertFalse
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@interface List {

AssertFalse[] value();
}
}

自定义ConstraintValidator

如果Hibernate和JSR中的注解不够我用,我需要自定义一个注解和约束条件,我们应该怎么实现呢。实现一个自定义校验逻辑一共分两步:1.注解的实现。2.校验逻辑的实现。比如我们需要一个校验字段状态的注解,我们可以使用以下示例定义一个注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Target( { METHOD, FIELD, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = StatusValidator.class)
@Documented
public @interface ValidStatus {
String message() default "状态错误 ";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/**
* 有效的状态值集合,默认{1,2}
*/
int[] value() default {1,2};
}

实现了注解之后,我们需要实现注解中的@Constraint(validatedBy = StatusValidator.class),示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码/**
* 校验状态是否属于指定状态集
(ConstraintValidator后指定的泛型对象类型为
注解类和注解注释的字段类型<ValidStatus, Integer>)
*/
public class StatusValidator implements ConstraintValidator<ValidStatus, Integer> {
private Integer[] validStatus;

@Override
public void initialize(ValidStatus validStatus) {
int[] ints = validStatus.value();
int n = ints.length;
Integer[] integers = new Integer[n];
for (int i = 0; i < n; i++) {
integers[i] = ints[i];
}
this.validStatus = integers;
}

@Override
public boolean isValid(Integer n, ConstraintValidatorContext constraintValidatorContext) {
List<Integer> status = Arrays.asList(validStatus);
if (status.contains(n)) {
return true;
}
return false;
}
}

Validator的特性

四种约束级别

成员变量级别的约束

约束可以通过注解一个类的成员变量来表达。如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Data
public class Person {

@NotBlank
@Size(max=64)
private String name;

@Min(0)
@Max(200)
private int age;
}

属性约束

如果你的模型类遵循javabean的标准,它也可能注解这个bean的属性而不是它的成员变量。关于JavaBean的介绍可以看我的另外一篇博客。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Data
public class Person {

private String name;

@Min(0)
@Max(200)
private int age;

@NotBlank
@Size(max=64)
public String getName(){
return name;
}
}

集合约束

通过在约束注解的@Target注解在约束定义中指定ElementType.TYPE_USE,就可以实现对容器内元素进行约束

类级别约束

一个约束被放到类级别上,在这种情况下,被验证的对象不是简单的一个属性,而是一个完整的对象。使用类级别约束,可以验证对象几个属性之间的相关性,比如不允许所有字段同时为null等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Data
@NotAllFieldNull
public class Person {

private String name;

@Min(0)
@Max(200)
private int age;

@NotBlank
@Size(max=64)
public String getName(){
return name;
}
}

校验注解的可继承性

父类中添加了约束的字段,子类在进行校验时也会校验父类中的字段。

递归校验

假设我们上面例子中的Person多了一个Address类型的字段,并且Address也有自己的校验,我们怎么校验Address中的字段呢?可以通过在Address上添加@Valid注解实现递归校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码@Data
public class Person {

private String name;

@Min(0)
@Max(200)
private int age;

@Valid
public Address address;
}

@Data
public class Address{

@NotNull
private string city;
}

方法参数校验

我们可以通过在方法参数中添加校验注解,实现方法级别的参数校验,当然这些注解的生效需要通过一些AOP实现(比如Spring的方法参数校验)。

1
2
3
4
java复制代码
public void createPerson(@NotNull String name,@NotNull Integer age){

}

方法参数交叉校验

方法也支持参数之间的校验,比如如下注解不允许创建用户时候用户名和年龄同时为空,注解校验逻辑需要自己实现。交叉校验的参数是Object[]类型,不同参数位置对应不同的Obj。

1
2
3
4
java复制代码@NotAllPersonFieldNull
public void createPerson( String name,Integer age){

}

方法返回值校验

1
2
3
java复制代码public @NotNull Person getPerson( String name,Integer age){
return null;
}

分组功能

我在另一篇介绍Spring校验注解的文章中说过,在Spring的校验体系中,@Valid注解不支持分组校验,@Validated注解支持分组校验。 事实上这并不是JSR注解中的@Valid不支持分组校验,而是Spring层面把@Valid注解的分组校验功能屏蔽了。

所以原生的JSR注解和Hibernate校验都支持分组校验功能,具体校验逻辑可以参考我有关Spring数据校验的文章。

分组继承

我们知道JSR分组校验功能是使用注解中的group字段,group字段存储了分组的类别,那么如果分组的类之间有继承关系,分组校验会被继承吗?答案是会的。

分组顺序

如果我们在校验的过程中需要指定校验顺序,那么我们可以给校验条件分组,分组之后就会按照顺序校验对象中的各个属性。

GroupSequence({ Default.class, BaseCheck.class, AdvanceCheck.class })
public interface OrderedChecks {
}

Payload

如果我们需要在不同的情况下有不同的校验方式,比如中英文环境之类的,这种时候用分组就不是很合适了,可以考虑使用PayLoad。用户可以在初始化Validator时候指定当前环境的payload,然后在校验环节拿到环境中的payload走不同的校验流程:

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
java复制代码ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
.configure()
.constraintValidatorPayload( "US" )
.buildValidatorFactory();

Validator validator = validatorFactory.getValidator();

public class ZipCodeValidator implements ConstraintValidator<ZipCode, String> {

public String countryCode;

@Override
public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
if ( object == null ) {
return true;
}

boolean isValid = false;

String countryCode = constraintContext
.unwrap( HibernateConstraintValidatorContext.class )
.getConstraintValidatorPayload( String.class );

if ( "US".equals( countryCode ) ) {
// checks specific to the United States
}
else if ( "FR".equals( countryCode ) ) {
// checks specific to France
}
else {
// ...
}

return isValid;
}
}

我是御狐神,欢迎大家关注我的微信公众号:wzm2zsd

qrcode_for_gh_83670e17bbd7_344-2021-09-04-10-55-16

本文最先发布至微信公众号,版权所有,禁止转载!

本文转载自: 掘金

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

2《spring从零到壹》-认识IOC(二)(JAVA 小虚

发表于 2021-11-25

这是我参与11月更文挑战的第25天,活动详情查看:2021最后一次更文挑战」

❤️作者简介:大家好,我是小虚竹。Java领域优质创作者🏆,CSDN博客专家认证🏆,华为云享专家认证🏆

❤️技术活,该赏

❤️点赞 👍 收藏 ⭐再看,养成习惯

回顾:

spring框架-认识spring框架(一)


上一章提到的一些概念,本章会详细解读


IOC是什么

IoC(Inversion of Control)控制反转,包含了两个方面:一、控制。二、反转

类与类依赖关系交给容器处理。IoC不是一种技术,只是一种思想,一个重要的面向对象编程的法则,它能指导我们如何设计出松耦合、更优良的程序。

IOC不够开门见山,于是Martin Fowler提出了DI(dependency injection)依赖注入来替代IoC,即让调用类对某一接口实现类的依赖关系由第三方(容器或协作类)注入,以移除调用类对某一接口实现类的依赖。

IOC有两种方式:DI(依赖注入)和DL (依赖查找)


IOC的优点

  1. 减少了对象的创建和管理 ,使代码层次更加清晰。
  2. Spring 的IOC容器是一个轻量级的容器 ,没有侵入性(不依赖容器的API) ,不需要实现一些特殊接口。
  3. 鼓励我们面向接口编程。
  4. 减少了代码的耦合,将耦合的部分推到了配置文件中 ,如果他们的关系发生了改变,只需要修改配置文件。

DL (依赖查找)

程序提供查找方式,交给容器去查找(回调函数)

容器提供回调接口和上下文环境给组件。EJB和Apache Avalon都使用这种方式

下面代码展示了基于JNDI实现的依赖查找机制。

1
2
3
4
5
6
7
8
9
10
11
12
ini复制代码 public class MyBusniessObject{
private DataSource ds;
private MyCollaborator myCollaborator;

public MyBusnissObject(){
Context ctx = null;
try{
ctx = new InitialContext();
ds = (DataSource) ctx.lookup(“java:comp/env/dataSourceName”);
myCollaborator =
(MyCollaborator) ctx.lookup(“java:comp/env/myCollaboratorName”);
}……

但是日常开发中,EJB类似的已经很少用到了,所以很多同学没听过DL(依赖查找),这很正常,大家更熟悉的是DI(依赖注入)。

不过这两个查找大家应该用过:

  1. 名称查找 - autowireByName
  2. 类型查找 - autowireByType

名称查找 - autowireByName

直接从 BeanFactory 中取出这个 bean 就可以了,常用的就是@Qualifier

类型查找 - autowireByType

常用的就是@autowire

如果容器中存在一个与指定属性类型相同的bean,那么将与该属性自动装配。如果存在多个该类型的bean,那么将会抛出异常

简单的理解就是通过类名去匹配


DI(依赖注入)

一个对象需要另外一个对象时,无需在代码中创建被调用者,而是依赖于外部容器,由外部容器创建后传递给程序

用图例说明一下,传统程序设计如下图1,都是主动去创建相关对象然后再组合起来:

图1

当有了IoC/DI的容器后,在客户端类中不再主动去创建这些对象了,如图2所示

图2


IOC容器

IOC容器其实就是一个大工厂,它用来管理我们所有的对象以及依赖关系。

  • 原理就是通过Java的反射技术来实现的!通过反射我们可以获取类的所有信息(成员变量、类名等等等)!
  • 再通过配置文件(xml)或者注解来描述类与类之间的关系
  • 我们就可以通过这些配置信息和反射技术来构建出对应的对象和依赖关系了!

Spring容器(Bean工厂)

  1. BeanFactory:这是最基础、面向Spring的
  2. ApplicationContext:这是在BeanFactory基础之上,面向使用Spring框架的开发者。提供了一系列的功能!

ApplicationContext这个大家就很熟悉了吧,spring绝大部分应用都是使用ApplicationContext

BeanFactory和ApplicationContext区别

BeanFactory 可以理解为含有bean集合的工厂类。BeanFactory 包含了种bean的定义,以便在接收到客户端请求时将对应的bean实例化。

BeanFactory还能在实例化对象的时生成协作类之间的关系。此举将bean自身与bean客户端的配置中解放出来。BeanFactory还包含了bean生命周期的控制,调用客户端的初始化方法(initialization methods)和销毁方法(destruction methods)。

applicationcontext是beanFactory的子接口,拥有BeanFactory的所有功能,但applicationcontext在此基础上还提供了其他的功能。

  1. 提供了支持国际化的文本消息
  2. 统一的资源文件读取方式
  3. 已在监听器中注册的bean的事件
  4. 且beanFactory是延迟加载,需要类的时候才创建类的实例,而ApplicationContext在初始化时就加载完成了所有的单例bean

以下是三种较常见的 ApplicationContext 实现方式:

1、ClassPathXmlApplicationContext:从classpath的XML配置文件中读取上下文,并生成上下文定义。应用程序上下文从程序环境变量中取得

1
ini复制代码    ApplicationContext context = new ClassPathXmlApplicationContext(“bean.xml”);

2、FileSystemXmlApplicationContext :由文件系统中的XML配置文件读取上下文。

ApplicationContext context = new FileSystemXmlApplicationContext(“bean.xml”);

3、XmlWebApplicationContext:由Web应用的XML文件读取上下文。

Spring Bean的生命周期

Spring Bean的生命周期简单易懂。在一个bean实例被初始化时,需要执行一系列的初始化操作以达到可用的状态。同样的,当一个bean不在被调用时需要进行相关的析构操作,并从bean容器中移除。

Spring bean factory 负责管理在spring容器中被创建的bean的生命周期。Bean的生命周期由两组回调(call back)方法组成。

  1. 初始化之后调用的回调方法。
  2. 销毁之前调用的回调方法。

Spring框架提供了以下四种方式来管理bean的生命周期事件:

  • InitializingBean和DisposableBean回调接口
  • 针对特殊行为的其他Aware接口
  • Bean配置文件中的Custom init()方法和destroy()方法
  • @PostConstruct和@PreDestroy注解方式

使用customInit()和 customDestroy()``方法管理``bean``生命周期的代码样例如下:

1
2
3
4
java复制代码<beans> 
<bean id="demoBean" class="com.somnus.task.DemoBean" init-method="customInit" destroy-method="customDestroy">
</bean>
</beans>

装配Bean方式

Spring4.x开始IOC容器装配Bean有4种方式:

  1. XML配置
  2. 注解
  3. JavaConfig
  4. 基于Groovy DSL配置(这种很少见)

日常开发中,常用到的是XML配置+注解。

剩下的两种有兴趣的可以自行百度+google


依赖注入方式

依赖注入的方式有3种方式:

  1. 属性注入–>通过setter()方法注入
  2. 构造方法注入
  3. 工厂方法注入

构造方法注入和属性注入有什么区别

  1. 在属性注入方法支持大部分的依赖注入,如果我们仅需要注入int、string和long型的变量,我们不要用设值的方法注入。对于基本类型,如果我们没有注入的话,可以为基本类型设置默认值。在构造方法注入不支持大部分的依赖注入,因为在调用构造方法中必须传入正确的构造参数,否则的话为报错。
  2. 属性注入不会重写构造方法的值。如果我们对同一个变量同时使用了构造方法注入又使用了设置方法注入的话,那么构造方法将不能覆盖由设值方法注入的值。很明显,因为构造方法尽在对象被创建时调用。
  3. 在使用属性注入时有可能还不能保证某种依赖是否已经被注入,也就是说这时对象的依赖关系有可能是不完整的。而在另一种情况下,构造器注入则不允许生成依赖关系不完整的对象。
  4. 在属性注入时如果对象A和对象B互相依赖,在创建对象A时Spring会抛出sObjectCurrentlyInCreationException异常,因为在B对象被创建之前A对象是不能被创建的,反之亦然。所以Spring用设值注入的方法解决了循环依赖的问题,因对象的设值方法是在对象被创建之前被调用的。

Bean的作用域

Spring容器中的bean可以分为5个范围。所有范围的名称都是自说明的,但是为了避免混淆,还是让我们来解释一下:

使用3,4,5作用域的,需要手动设置代理

  1. singleton:这种bean范围是默认的,这种范围确保不管接受到多少个请求,每个容器中只有一个bean的实例,单例的模式由bean factory自身来维护。
  2. prototype:多例范围与单例范围相反,为每一个bean请求提供一个实例。
  3. request:在请求bean范围内会每一个来自客户端的网络请求创建一个实例,在请求完成以后,bean会失效并被垃圾回收器回收。
  4. Session:与请求范围类似,确保每个session中有一个bean的实例,在session过期后,bean会随之失效。
  5. global-session:global-session和Portlet应用相关。当你的应用部署在Portlet容器中工作时,它包含很多portlet。如果你想要声明让所有的portlet共用全局的存储变量的话,那么这全局变量需要存储在global-session中。全局作用域与Servlet中的session作用域效果相同。

bean的自动装配

使用bean元素的autowire属性来指定Bean定义的自动装配,共有5中模式:

  1. no 默认的方式是不进行自动装配,通过手工设置ref 属性来进行装配bean
  2. byName 依赖的 bean 名称需要与类中引用的名称一致 ,就会匹配依赖关系,我们在类中的引用的名称是 userAutowireDao 所以就会去匹配我们的 userAutowireDao 方法
  3. byType 通过参数的数据类型自动自动装配,如果一个bean的数据类型和另外一个bean的property属性的数据类型兼容,就自动装配,简单的理解就是通过类名去匹配
  4. construct 构造方法中的参数通过byType的形式,自动装配。
  5. default 由上级标签的default-autowire属性确定。

常用注解详解

注解注入就是用注解标签的方式来替换掉我们 xml 配置文件里面 bean 的注册和依赖

@Component

用于类上

所有的类上面都可以这么写,通用注解,这是不规范的写法,哈哈哈

@Repository

用于类上

这个注解主要是声明 dao 的类组件

@Service

这个注解主要是声明 service 服务类

@Controller

主要是声明控制类 (springmvc/struts2 action/controller)

@Resource

用于类内

javaEE 的注解 ,默认是以 byName 方式注入,byName 找不到的话,再用 byType 去匹配

效果跟Autowired一样,查找顺序相反

@Resource有两个属性是比较重要的,分是name和type,Spring将@Resource注解的name属性解析为bean的名字,而type属性则解析为bean的类型。所以如果使用name属性,则使用byName的自动注入策略,而使用type属性时则使用byType自动注入策略。如果既不指定name也不指定type属性,这时将通过反射机制使用byName自动注入策略。

@Autowired

用于类内

spring 的注解,默认是以 byType 注入,-如果有多个实现类,他再用 byName 的方式(@Qualifier)去匹配

效果跟Resource一样,查找顺序相反

Autowired和Qualifier一起用,

eg:

@Autowired

@Qualifier(value = “TestService2”)

private TestService testService;

//实现类

@Service(“TestService1”)

public class TestServiceImpl implements TestService {…}

//实现类

@Service(“TestService2”)

public class TestServiceImpl implements TestService {…}

@Qualifier

spring的注解,可以指定实现的方法名称

@Scope

bean的作用域,可以查看上面的概念,这里就不再重复了


总结

借鉴了其他博主的思路:会整理出Spring思维导图出来,等AOP写好一并放出来。

今天的spring介绍就写到这里,再见!


参考资料:

  • 《精通Spring4.x 企业应用开发实战》

my.oschina.net/u/3777556/b…Spring IOC知识点一网打尽!

www.zhihu.com/question/48…怎么回答面试官:你对Spring的理解

www.cnblogs.com/liangyihui/…最全的Spring面试题和答案

blog.csdn.net/sunqingzhon…spring笔记——bean自动装配的5种模式(autowrite属性)


本文转载自: 掘金

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

requests库与 lxml 库常用操作整理+总结,爬虫1

发表于 2021-11-25

「这是我参与11月更文挑战的第25天,活动详情查看:2021最后一次更文挑战」

一路学习,一路总结,技术就是这样,应用之后,在进行整理,才可以加深印象。
本篇博客为小节篇,核心总结 requests 库与 lxml 库

requests 库

在《爬虫 120 例》中,最先接触的第三方库就是 requests,该库开源地址为:https://github.com/psf/requests,官方的 solgan 为 Requests is a simple, yet elegant, HTTP library。

日常在编码过程中,最重要的行为就是查询手册,requests 库提供了中文手册 - https://docs.python-requests.org/zh_CN/latest/,大幅度降低了学习的难度。不过中文翻译夹带了不少翻译者的情绪化文字,阅读的时候忽略即可。

requests 库最常见的操作

由于该专栏前面已经对 requests 库进行了大量的使用,其常见操作我们也可以进行一些相应的总结了。

请求参数以及请求方法

导入 requests 库之后,基本都在围绕 requests.get 做文章,这里重点要回顾的是 get 方法的参数,其中包含如下内容,下述内容在官方手册没有呈现清单,通过最新版源码分析。

除 url 参数外,其余都为可选参数,即非必选。

  • url:请求地址;
  • params:要发送的查询字符串,可以为字典,列表,元组,字节;
  • data:body 对象中要传递的参数,可以为字段,列表,元组,字节或者文件对象;
  • json:JSON 序列化对象;
  • headers:请求头,字典格式;
  • cookies:传递 cookie,字段或 CookieJar 类型;
  • files:最复杂的一个参数,一般出现在 POST 请求中,格式举例 "name":文件对象 或者 {'name':文件对象},还可以在一个请求中发送多个文件,不过一般爬虫场景不会用到;
  • auth:指定身份验证机制;
  • timeout:服务器等待响应时间,在源码中检索到可以为元组类型,这个之前没有使用过,即 (connect timeout, read timeout);
  • allow_redirects:是否允许重定向;
  • proxies:代理;
  • verify:SSL 验证;
  • stream:流式请求,主要对接流式 API;
  • cert:证书。

以上内容就是 GET 请求中可以配置的参数,除了 GET 请求外,requests 还内置了其他的服务器请求方式,如下所示,这些方法需要的参数与上述清单一致。

GET, OPTIONS, HEAD, POST, PUT, PATCH, or DELETE

在 Python 爬虫的实战当中,主要以 GET 与 POST 为主,常用的参数为:url,params,data,headers,cookies,timeout,proxies,verify。

响应对象的属性与方法

使用 requests 库请求之后,会得到一个 Response 对象,该对象最重要的内容就是属性与方法,通过 dir 函数可以获取 Response 对象的属性和方法。

1
2
python复制代码    help(res)
print(dir(res))

获取到的内容如下所示,其中有我们之前案例中常见的一些内容。

1
2
3
4
5
6
7
8
9
shell复制代码['__attrs__', '__bool__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__',
'__enter__', '__eq__', '__exit__', '__format__', '__ge__', '__getattribute__', '__getstate__',
'__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__',
'__module__', '__ne__', '__new__', '__nonzero__', '__reduce__', '__reduce_ex__', '__repr__',
'__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__',
'_content', '_content_consumed', '_next', 'apparent_encoding', 'close', 'connection',
'content', 'cookies', 'elapsed', 'encoding', 'headers', 'history', 'is_permanent_redirect',
'is_redirect', 'iter_content', 'iter_lines', 'json', 'links', 'next', 'ok', 'raise_for_status',
'raw', 'reason', 'request', 'status_code', 'text', 'url']

如果只将 requests 库应用在爬虫采集领域,那上述属性与方法中,比较常用的有:

属性 property

  • ok:只要状态码 status_code 小于 400,都会返回 True;
  • is_redirect:重定向属性;
  • content:响应内容,字节类型;
  • text:响应内容,Unicode 类型;
  • status_code:响应状态码;
  • url:响应的最终 URL 位置;
  • encoding:当访问 r.text 时的编码;

方法

  • json:将响应结果序列化为 JSON;

会话对象

在本专栏前面的文章中,存在一个被忽略的 requests 高级特性,即会话对象,该对象能够在跨域请求的时候,保持住某些参数,尤其是 cookie,如果你想向同一主机发送多个请求,使用会话对象可以将底层的 TCP 连接进行重用,带来显著的性能提升。

会话对象使用非常简单,在发起 requests 对象之前,增加如下所示代码即可。

1
2
3
4
5
6
python复制代码# 建立会话对象
s = requests.Session()
# 后续都使用会话对象进行进行,而不是直接使用 requests 对象
s.get('http://httpbin.org/cookies/set/sessioncookie/123456789')
r = s.get("http://httpbin.org/cookies")
print(r.text)

由于专栏前面并未涉及相关案例,故直接引入官方手册案例说明。

下述代码演示的是会话也可用来为请求方法提供缺省数据,顾名思义就是直接给会话对象增加的请求参数,在后续代码中默认可用。

1
2
3
4
5
6
7
8
9
python复制代码import requests
s = requests.Session()
s.auth = ('user', 'pass')
s.headers.update({'x-test': 'true'})

# both 'x-test' and 'x-test2' are sent
r = s.get('http://httpbin.org/headers', headers={'x-test2': 'true'})

print(r.text)

接下来官网案例还展示了 法级别的参数也不会被跨请求保持,即在 s.get() 方法中如果传递了 cookie,那不会被保持住,这两个案例,从正面与反面为我们核心展示的就是,如何使用会话对象保持参数,通过会话对象的属性设置的参数,能被保持,而通过会话对象方法传递的参数,不能被保持。

SSL 证书验证,客户端证书,CA 证书

在爬虫采集数据的过程中,碰到 https 的网站在正常不过,requests 库使用过程中 SSL 验证是默认开启的,如果证书验证失败,即抛出 SSLError错误。

不过更多的时候,我们通过设置 verify = False ,忽略对 SSL 证书的验证,除非及其特殊的情况,必须增加相关证书逻辑。

代理

有的网站在采集过程中,会针对 IP 进行限制,此时就需要使用代理进行跳过操作,设置 proxies 参数即可,本部分内容比较简单,后续很多案例还会复用到。

除了 HTTP 代理外, requests 2.10 版本之后,增加了 SOCKS 代理,如果你需要使用,需要通过 pip 安装相应库。

1
python复制代码pip install requests[socks]

安装完毕,出现新的第三方库 PySocks,使用方式与 HTTP 代理一致。

Cookie

爬虫采集过程会大量的与 cookie 打交道,获取网站响应的 cookie,使用 response 对象的 cookies 属性即可。如果希望向服务器传递 cookie,可以通过 cookies 参数,例如下述代码:

1
2
3
python复制代码url = 'http://httpbin.org/cookies'
cookies = dict(cookies_are='working')
r = requests.get(url, cookies=cookies)

如果你希望对 cookie 有更加细致的操作,重点研究 requests.cookies.RequestsCookieJar 对象即可,简单的代码使用如下所示:

1
2
3
4
5
6
python复制代码jar = requests.cookies.RequestsCookieJar()
jar.set('tasty_cookie', 'yum', domain='httpbin.org', path='/cookies')
jar.set('gross_cookie', 'blech', domain='httpbin.org', path='/elsewhere')
url = 'http://httpbin.org/cookies'
r = requests.get(url, cookies=jar)
print(r.text)

RequestsCookieJar 对象具备更加丰富的接口,适合跨域名跨路径使用,相关接口可在 docs.python-requests.org/zh_CN/lates… 查询。

requests 非常适合作为 Python 爬虫入门阶段第一选择,其简单的接口与代码封装,能大幅度降低网络请求代码编写难度,让你专注与目标数据的提取,更有基于高级请求的封装作为提高部分,该库完全可以贯穿你的整个爬虫工程师生涯。

lxml 库

lxml 库是一款 Python 数据解析库,参考重要文档在 lxml.de/,项目开源地址在:github.com/lxml/lxml,在一顿检索之后,发现 lxml 没有中文相关手册,不过好在英文文档阅读难度不大,我们可以直接进行学习。

lxml.etree

纵览之前的博客内容,出场率最高的就是 lxml.etree ,其次就是 Element 对象,我们在解析数据的时候,大量的代码都是基于 Element 对象的 API 实现。

在爬虫代码采集过程中,通过 etree.HTML 直接将字符串实例化为 element 对象。

1
2
3
4
5
6
7
8
9
10
11
python复制代码import requests
from lxml import etree

res = requests.get("http://www.jsons.cn/zt/")

html = res.text

root_element = etree.HTML(html)

print(root_element)
print(root_element.tag)

上述代码输出内容如下所示:

1
2
shell复制代码<Element html at 0x3310508>
html

其中需要注意的是 Element 后面的 html,该字符串表示对象的标签名为 html,如果使用下述代码:

1
python复制代码print(root_element[1])

上述内容得到的是 <Element body at 0x356e248>,即 body 标签,同样的操作可以使用子元素获取。

1
2
3
python复制代码print("*"*100)
for child in root_element:
print(child.tag)

上述代码输出的内容为:

1
2
shell复制代码head
body

该输出表示在 html 标签中,只包含head 与 body 标签,实际情况也确实如此,为了验证,你可以在 循环中继续嵌套一层。

此时输出的内容就变得丰富了需求,如下图所示:

requests库与 lxml 库常用操作整理+总结,爬虫120例阶段整理篇

你也可以通过 etree.tostring(element对象) 直接将该对象转换为字符串进行输出。

1
2
3
4
python复制代码for child in root_element:
for item in child:
print(item.tag)
print(etree.tostring(item))

XPath

lxml 库可以配合其他的解析引擎进行工作,首次接触的就是 XPath,关于 XPath 相关的知识,我们后续博客会细化学习,本节课依旧从 lxml 的角度出发,为你介绍。

在爬虫代码编写中,直接使用 html.xpath("xpath表达式") 即可获取目标数据,例如获取网页 title。

1
python复制代码print(root_element.xpath('//title'))

获取网页所有文本:

1
python复制代码print(root_element.xpath('string()'))

获取到 element 对象之后,可调用 text 属性,获取对应文本,在使用的时候,需要注意使用 XPath 获取到的 element 对象,都是列表。

1
2
python复制代码title_element = root_element.xpath('//title')
print(title_element[0].text)

在 lxml 中,还内置了一款 简单的类似 XPath 的路径语言,称为 ElementPath,例如查询 title,需要从 head 开始检索,否则返回 None。

1
python复制代码print(root_element[0].find("title"))

官方提供的方法如下:

  • iterfind(): 返回查找到的数据,迭代器形式返回;
  • findall(): 返回匹配到的列表;
  • find(): 返回第一个匹配到的数据;
  • findtext(): 返回匹配到的文本数据,第一个。

lxml 其他说明

lxml 除了可以配合 XPath 实现数据解析外,还可以与 cssselect ,BeautifulSoup,html5lib 配合使用,这部分在后续的案例中,将逐步进行展开。

lxml 在爬虫领域,更多的是在提取数据,因此较于该库本身,掌握 XPath 等解析表达式的写法更加重要。

鉴于该库手册没有被翻译,后期可以尝试将其翻译为中文。

收藏时间

爬虫120例代码下载地址:codechina.csdn.net/hihell/pyth…,可否给个 Star。

==来都来了,不发个评论,点个赞,收个藏吗?==

本文转载自: 掘金

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

常用Linux命令 - 权限管理的基本命令 权限管理的基本命

发表于 2021-11-25

这是我参与11月更文挑战的第18天,活动详情查看:2021最后一次更文挑战」。

权限管理的基本命令

权限的介绍

权限位的含义
前面讲解 ls 命令时,我们已经知道长格式显示的第一列就是文件的权限,例如:

1
2
c复制代码[root@localhost ~]# ls -l install.log
-rw-r--r--. 1 root root 24772 1 月 14 18:17 install.log

第一列的权限位如果不计算最后的“.”(这个点的含义为seLinux 保护),则共有 10 位,这 10位权限位的含义如图

  • 第 1 位代表文件类型。Linux 不像 Windows 使用扩展名表示文件类型,而是使用权限位的第 1
    位表示文件类型。虽然 Linux 文件的种类不像 Windows 中那么多,但是分类也不少,详细情况
    可以使用“info ls”命令查看。超哥在这里只讲一些常见的文件类型。
  • “-”:普通文件。
  • “b”:块设备文件。这是一种特殊设备文件,存储设备都是这种文件,如分区文件/dev/sda1
    就是这种文件。
  • “c”:字符设备文件。这也是特殊设备文件,输入设备一般都是这种文件,如鼠标、键盘
    等。
  • “d”:目录文件。Linux 中一切皆文件,所以目录也是文件的一种。
  • “l”:软链接文件。
  • “p”:管道符文件。这是一种非常少见的特殊设备文件。
  • “s”:套接字文件。这也是一种特殊设备文件,一些服务支持 Socket 访问,就会产生这样
    的文件。
  • 第 2~4 位代表文件所有者的权限。
+ r:代表 read,是读取权限。
+ w:代表 write,是写权限。
+ x:代表 execute,是执行权限。
如果有字母,则代表拥有对应的权限;如果是“-”,则代表没有对应的权限。
  • 第 5~7 位代表文件所属组的权限,同样拥有“rwx”权限。
  • 第 8~10 位代表其他人的权限,同样拥有“rwx”权限。

基本权限命令

chmod 命令

首先来看修改权限的命令 chmod,其基本信息如下。

  • 命令名称:chmod。
  • 英文原意:change file mode bits。
  • 所在路径:/bin/chmod。
  • 执行权限:所有用户。
  • 功能描述:修改文件的权限模式。
  • 示例
1
2
3
4
5
6
7
8
shell复制代码zxd@izwz99gyct1a1rh6iblyucz testLinux]$ chmod u-x,g-x,o+wrx 111 
[zxd@izwz99gyct1a1rh6iblyucz testLinux]$ ll
总用量 12
-rw-r--rwx 2 zxd zxd 49 3月 31 17:16 111
-rw-rw-r-- 1 zxd zxd 49 3月 31 19:27 120
lrwxrwxrwx 1 zxd zxd 3 3月 31 17:04 ddd -> 111
lrwxrwxrwx 1 zxd zxd 3 3月 31 17:04 sss -> 111
-rw-r--rwx 2 zxd zxd 49 3月 31 17:16 test111

chown 命令

chown 是修改文件和目录的所有者和所属组的命令,其基本信息如下。

  • 命令名称:chown。
  • 英文原意:change file owner and group。
  • 所在路径:/bin/chown。
  • 执行权限:所有用户。
  • 功能描述:修改文件和目录的所有者和所属组。

命令格式

1
2
3
csharp复制代码[root@localhost ~]# chown [选项] 所有者:所属组 文件或目录
选项:
-R: 递归设置权限,也就是给子目录中的所有文件设置权限

普通用户不能修改文件的所有者,哪怕自己是这个文件的所有者也不行。
普通用户可以修改所有者是自己的文件的权限。

普通用户不能修改文件的所有者,哪怕自己是这个文件的所有者也不行。
普通用户可以修改所有者是自己的文件的权限。

chgrp 命令

chgrp 是修改文件和目录的所属组的命令,其基本信息如下。

  • 命令名称:chgrp。
  • 英文原意:change group ownership。
  • 所在路径:/bin/chgrp。
  • 执行权限:所有用户。
  • 功能描述:修改文件和目录的所属组。

数字权限

数字权限的赋予方式是最简单的,但是不如之前的字母权限好记、直观。我们来看看这些数字权
限的含义。

  • 4:代表“r”权限。
  • 2:代表“w”权限。
  • 1:代表“x”权限。

常用权限

数字权限的赋予方式更加简单,但是需要用户对这几个数字更加熟悉。其实常用权限也并不多,
只有如下几个。

  • 644:这是文件的基本权限,代表所有者拥有读、写权限,而所属组和其他人拥有只读权限。
  • 755:这是文件的执行权限和目录的基本权限,代表所有者拥有读、写和执行权限,
    和其他人拥有读和执行权限。
  • 777:这是最大权限。在实际的生产服务器中,要尽力避免给文件或目录赋予这样的权限,这
    会造成一定的安全隐患

权限含义的解释

首先,读、写、执行权限对文件和目录的作用是不同的。

  • 权限对文件的作用。
    • 读(r):对文件有读(r)权限,代表可以读取文件中的数据。如果把权限对应到命令上,
      那么一旦对文件有读(r)权限,就可以对文件执行 cat 、more 、less 、head 、tail 等文件查
      看命令。
    • 写(w):对文件有写(w)权限,代表可以修改文件中的数据。如果把权限对应到命令上,
      那么一旦对文件有写(w)权限,就可以对文件执行 vim 、echo 等修改文件数据的命令。 注
      意:对文件有写权限,是不能删除文件本身的,只能修改文件 中 的数据。如果要想删除文件,
      则 需要对文件的上级目录拥有写权限。
    • 执行(x):对文件有执行(x)权限,代表文件拥有了执行权限,可以运行。在 Linux 中,
      只要文件有执行(x)权限,这个文件就是执行文件了。只是这个文件到底能不能正确执行,
      不仅需要执行(x)权限,还要看文件中的代码是不是正确的语言代码。对文件来说,执行
      (x)权限是最高权限。

权限对目录的作用

  • 读(r):对目录有读(r)权限,代表可以查看目录下的内容,也就是可以查看目录下有哪
    些子文件和子目录。如果把权限对应到命令上,那么一旦对目录拥有了读(r)权限,就可
    以在目录下执行 ls 命令,查看目录下的内容了。
  • 写(w):对目录有写(r)权限,代表可以修改目录下的数据,也就是可以在目录中新建、
    删除、复制、剪切子文件或子目录。如果把权限对应到命令上,那么一旦对目录拥有了写(w)
    权限,就可以在目录下执行 touch 、rm 、cp 、mv 命令。对目录来说,写(w)权限是最高
    权限。
  • 执行(x):目录是不能运行的,那么对目录拥有执行(x)权限,代表可以进入目录。如果
    把权限对应到命令上,那么一旦对目录拥有了执行(x)权限,就可以对目录执行 cd 命令,进入
    目录。

目录的可用权限

目录的可用权限其实只有以下几个。

  • 0:任何权限都不赋予。
  • 5:基本的目录浏览和进入权限。
  • 7 :完全权限。

umask权限

查看系统的 umask 权限

1
2
csharp复制代码[root@localhost ~]# umask
0022

用八进制数值显示 umask 权限

[root@localhost ~]# umask -S
u=rwx,g=rx,o=rx

用字母表示文件和目录的初始权限

umask 权限的计算方法

我们需要先了解一下新建文件和目录的默认最大权限

1
2
3
4
diff复制代码- 对文件来讲,新建文件的默认最大权限是 666,没有执行(x)权限。这是因为执行权限对文件
来讲比较危险,不能在新建文件的时候默认赋予,而必须通过用户手工赋予。
- 对目录来讲,新建目录的默认最大权限是 777。这是因为对目录而言,执行(x)权限仅仅代表
进入目录,所以即使建立新文件时直接默认赋予,也没有什么危险。

按照官方的标准算法,umask 默认权限需要使用二进制进行逻辑与和逻辑非联合运算才可以得到
正确的新建文件和目录的默认权限。这种方法既不好计算,也不好理解,超哥并不推荐。
我们在这里还是按照权限字母来讲解 umask 权限的计算方法。我们就按照默认的 umask 值是 022
来分别计算一下新建文件和目录的默认权限吧。

关键点

  • 文件的默认权限最大只能是 666,而 umask 的值是 022
    “-rw-rw-rw-”减去 “—–w–w-”等于“-rw-r–r—”
  • 目录的默认权限最大可以是 777,而 umask 的值是 022
    “drwxrwxrwx”减去“d—-w–w-”等于“drwx-r-xr-x”

注意:umask 默认权限的计算 绝不是数字直接相减。
例如 umask 是 033 呢?

  • 文件的默认权限最大只能是 666,而 umask 的值是 033
    “-rw-rw-rw-”减去“—–wx-wx”等于“-rw-r–r—”

如何永久的修改umask ?

在一般的情况之下,使用 umask xxx 这种形式是临时生效

而想要永久生效要使用下列的形式:

vim /etc/profile *注意只能是root权限才允许修改

会看到如下内容

By default, we want umask to get set. This sets it for login shell

#Current threshold for system reserved uid/gids is 200

You could check uidgid reservation validity in

/usr/share/doc/setup-*/uidgid file

if [ $UID -gt 199 ] && [ “/usr/bin/id -gn“ = “/usr/bin/id -un“ ]; then
umask 002
else
umask 022
fi

解释:

如果用户的id大于199

本文转载自: 掘金

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

操作系统学习笔记(十二)~临界区+信号量+生产者消费者问题

发表于 2021-11-25

「这是我参与11月更文挑战的第25天,活动详情查看:2021最后一次更文挑战」

前言

Hello!小伙伴!

非常感谢您阅读海轰的文章,倘若文中有错误的地方,欢迎您指出~

自我介绍 ଘ(੭ˊᵕˋ)੭

昵称:海轰

标签:程序猿|C++选手|学生

简介:因C语言结识编程,随后转入计算机专业,有幸拿过一些国奖、省奖…已保研。目前正在学习C++/Linux/Python

学习经验:扎实基础 + 多做笔记 + 多敲代码 + 多思考 + 学好英语!

6.1 临界区

1、下面有关数据不一致性的论述中,错误的是()。C
A.存在于并发运行的协同进程之间
B.同一进程在同一批数据上多次运行的结果可能不一样
C.一个进程在同一批数据上的运行结果每次都不一样
D.单任务操作系统不存在数据不一致性

2、防止竞争条件出现的有效方法有()。A、C
A.同步
B.调度
C.互斥
D.不共享数据

解释:在这里插入图片描述

3、临界区使用准则包括()。A、B、D
A.互斥
B.有限等待
C.同步
D.有空让进

解释:在这里插入图片描述

4、临界区是涉及临界资源的数据区。×

解释:在这里插入图片描述
5、一次只允许一个进程使用的资源称为互斥资源。√

补充:
在这里插入图片描述

6.2 信号量

1、如P和V操作的信号量S初值为4,则现在S=-1,表示有()个进程在等待该信号量。B
A.0
B.1
C.2
D.4

2、用V操作可以唤醒一个进程,被唤醒的进程状态可能会变为()。A
A.就绪
B.运行
C.等待
D.结束

3、S必须置一次且只能置一次初值。√

解释:在这里插入图片描述

4、记录型信号量S的当前值为0,一个进程调用wait(S)会把自己阻塞并挂到S的等待队列上。√

5、记录型信号量的值不可能为负数。×

6.3 生产者消费者问题

1、有两个并发进程,设置了互斥信号量mutex,现在mutex=0,则表示()。C
A.两个进程均进入临界区
B.没有进程进入临界区
C.一个进程进入临界区
D.一个进程进入临界区,另一个在等待

解释:mutex初值为1,表示允许一个进程进入临界资源,当由一个进程进入临界区且没有进程等待进入时,mutex减1,变为0,|mutex|为等待进入的进程数。就此题而言,当mutex=1时表示没有进程进入临界区;当mutex=-1时表示有一个进程进入临界区,另一个进程等待进入。 —来源:牛客网

2、所有的生产者必须等待消费者先运行的前提条件是()。B
A.缓冲区为空
B.缓冲区为满
C.缓冲区不可用
D.缓冲区半满

3、所有的消费者必须等待生产者先运行的前提条件是()。A
A.缓冲区空
B.缓冲区满
C.缓冲区不可用
D.缓冲区半空

4、消费者阻塞在wait(m)(m是互斥信号量)的条件是()。C、D
A.没有空缓冲区
B.没有满缓冲区
C.有其它生产者已经进入临界区存放产品
D.有其它消费者已经进入临界区取产品

5、在生产者消费者问题中,以下关于唤醒操作正确的是()。A、B、C、D
A.生产者唤醒其它生产者
B.生产者唤醒消费者
C.消费者唤醒其它消费者
D.消费者唤醒生产者

本文转载自: 掘金

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

Rust - 切片Slice Slice类型 字符串字面值

发表于 2021-11-25

这是我参与11月更文挑战的第25天,活动详情查看:2021最后一次更文挑战

Slice类型

Slice数据类型没有所有权,slice允许我们引用集合中一段连续的元素序列而不用引用整个集合。字符串slice(string slice) 是String中 一部分值的引用。如下述代码示例,不是对整个String的引用而是对部分String的引用:

1
2
3
4
5
6
rust复制代码fn main() {
let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];
}

可以使用一个由中括号中的 [starting_index..ending_index] 指定的 range 创建一个 slice,其中 starting_index 是 slice 的第一个位置,ending_index 则是 slice 最后一个位置的后一个值。在其内部,slice 的数据结构存储了 slice 的开始位置和长度,长度对应于 ending_index 减去 starting_index 的值。所以对于 let world = &s[6..11]; 的情况,world 将是一个包含指向 s 第 7 个字节(从 1 开始)的指针和长度值 5 的 slice。如下图所示:

image-20211123160932958

对于 Rust 的 .. range 语法,如果想要从第一个索引(0)开始,可以不写两个点号之前的值。同样的,如果 slice 包含 String 的最后一个字节,也可以舍弃尾部的数字。如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
rust复制代码// 舍弃开头数字
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

// 舍弃尾部数字
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

字符串字面值

现在我们知道了Slice,就可以正确的理解字符串字面值了。这里 s 的类型是 &str:它是一个指向二进制程序特定位置的 slice。这也就是为什么字符串字面值是不可变的;&str 是一个不可变引用。

1
rust复制代码let s = "hello rust";

其他类型的slice

字符串 slice,是针对字符串的。不过也有更通用的 slice 类型,比如数组(array),就和获取一部分字符串一样,如果我们想要引用数组的一部分,也可以和字符串slice一样的操作。

1
2
rust复制代码let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];

这个slice变量的类型是&[i32],它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。你可以对其他所有集合使用这类 slice。

总结

所有权系统影响了 Rust 中很多其他部分的工作方式。所有权、借用和 slice 这些概念让 Rust 程序在编译时确保内存安全。Rust 语言提供了跟其他系统编程语言相同的方式来控制你使用的内存,但拥有数据所有者在离开作用域后自动清除其数据的功能意味着你无须额外编写和调试相关的控制代码。

结语

文章首发于微信公众号程序媛小庄,同步于掘金。

码字不易,转载请说明出处,走过路过的小伙伴们伸出可爱的小指头点个赞再走吧(╹▽╹)

本文转载自: 掘金

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

Rust - 可变引用和悬垂引用 可变引用 悬垂引用(Dan

发表于 2021-11-25

这是我参与11月更文挑战的第25天,活动详情查看:2021最后一次更文挑战

可变引用

在上一篇文章中,我们提到了借用的概念,将获取引用作为函数参数称为 借用(borrowing),通常情况下,我们无法修改借来的变量,但是可以通过可变引用实现修改借来的变量。代码示例如下:

1
2
3
4
5
6
7
8
9
rust复制代码fn main() {
let mut s = String::from("hello"); // s是可变的变量

change(&mut s); // &mut 表示可变引用
}

fn change(some_string: &mut String) { // &mut 表示可变引用
some_string.push_str(", world");
}

要想实现修改借来的变量就必须将 s 改为 mut。然后必须创建一个可变引用 &mut s 和接受一个可变引用 some_string: &mut String。

但是可变引用有一个很大的限制:在特定作用域中的特定数据只能有一个可变引用。比如下述代码就不会被成功编译。

1
2
3
4
5
6
rust复制代码fn main() {
let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;
}

编译运行就会抛出如下异常:

1
2
3
4
5
6
7
8
9
10
ini复制代码error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here

所以这种修改借来的变量的可变引用是以一种受限制的方式允许修改,这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争(data race)类似于竞态条件,它可由这三个行为造成:

  • 两个或更多指针同时访问同一数据。
  • 至少有一个指针被用来写入数据。
  • 没有同步数据访问的机制。

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生。我们可以使用{}创建一个新的作用域,这样就能够允许多个可变引用了,只是不能在同一个作用域中同时拥有:

1
2
3
4
5
6
7
8
9
10
rust复制代码fn main() {
let mut s = String::from("hello");

{
let r1 = &mut s;

} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用

let r2 = &mut s;
}

另外还需要注意的是,不能在拥有不可变引用的同时拥有可变引用。不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!但是多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。如下述代码:

1
2
3
4
5
6
7
8
9
10
rust复制代码fn main() {
let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 在拥有不可变引用的同时拥有可变引用

println!("{}, {}, and {}", r1, r2, r3);

}

上面代码示例编译时会抛出如下异常:

1
2
3
4
5
6
7
8
9
10
11
rust复制代码error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:6:14
|
4 | let r1 = &s; // no problem
| -- immutable borrow occurs here
5 | let r2 = &s; // no problem
6 | let r3 = &mut s; // BIG PROBLEM
| ^^^^^^ mutable borrow occurs here
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here

但是如果可变引用和不可变引用他们的作用域不重叠代码就是可以编译的,我们可以将上面的代码示例进行修改就可以正常运行了。

1
2
3
4
5
6
7
8
9
10
11
12
rust复制代码fn main() {
let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{} and {}", r1, r2);
// 此位置之后 r1 和 r2 不再使用

let r3 = &mut s; // 没问题
println!("{}", r3);

}

悬垂引用(Dangling References)

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

当我们不小心创建了悬垂引用,Rust在编译的时候就会抛出异常:

1
2
3
4
5
6
7
8
9
rust复制代码fn main() {
let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle 返回一个字符串的引用
let s = String::from("hello"); // s 是一个新字符串

&s // 返回字符串 s 的引用
}// 这里 s 离开作用域并被丢弃。其内存被释放。

因为 s 是在 dangle 函数内创建的,当 dangle 的代码执行完毕后,s 将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String,所以在编译时Rust就会抛出异常,解决方式就是直接返回String。

1
2
3
4
5
rust复制代码fn no_dangle() -> String {
let s = String::from("hello");

s
} // 所有权被移动出去,内存没有被释放

结语

文章首发于微信公众号程序媛小庄,同步于掘金。

码字不易,转载请说明出处,走过路过的小伙伴们伸出可爱的小指头点个赞再走吧(╹▽╹)

本文转载自: 掘金

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

1…198199200…956

开发者博客

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