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

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


  • 首页

  • 归档

  • 搜索

Android—kotlin-Channel超详细讲解

发表于 2021-12-15

前言

在上一篇,主要讲解了关于Flow异步冷流相关的知识点。在本篇中将会讲解Channel通道(热流)相关的知识点!

那么Channel是什么呢?

1、Channel通道

1.1 认识Channel

1.png

如图所示

Channel实际上是一个并发安全的队列,它可以用来连接协程,实现不同协程之间的通信。

既然如此,来个小demo试试手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码    @Test
fun `test know channel`() = runBlocking<Unit> {
val channel = Channel<Int>()
//生产者
val producer = GlobalScope.launch {
var i = 0
while (true) {
delay(1000)
channel.send(++i)
println("send $i")
}
}

//消费者
val consumer = GlobalScope.launch {
while (true) {
val element = channel.receive()
println("receive $element")
}
}
joinAll(producer, consumer)
}

这里很简单,就两个协程,分别代表:生产者和消费者

来看看运行效果

1
2
3
4
5
6
7
bash复制代码receive 1
send 1
send 2
receive 2
....略
send 999
receive 999

这个就很简单,就直接进入下一专题了!

1.2 Channel的容量

Channel实际上就是一个队列,队列中一定存在缓存区,那么一旦这个缓冲区满了,并且也一直没有人调用receive并取走函数,send就需要挂起。故意让接收端的节奏放慢,发现send总是会挂起,直到receive之后才会继续往下执行。

概念一大堆,来个Demo试试手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码    @Test
fun `test know channel2`() = runBlocking<Unit> {
val channel = Channel<Int>()
//生产者
val producer = GlobalScope.launch {
var i = 0
while (true) {
delay(1000)
channel.send(++i)
println("send $i")
}
}

//消费者
val consumer = GlobalScope.launch {
while (true) {
delay(2000)
val element = channel.receive()
println("receive $element")
}
}
joinAll(producer, consumer)

}

这里我们看到:消费者用时比生产者用时高,那么

来看看运行效果

1
2
3
4
5
6
bash复制代码receive 1
send 1
receive 2 //等了2秒打印
send 2
receive 3 //这里又等了2秒打印
send 3

通过这个运行效果也验证了:一旦这个缓冲区满了,并且也一直没有人调用receive并取走函数,send就需要挂起。

通俗点就是:当消费者处理元素用时大于生产者生产元素用时,并且缓存区也满了时,生产者就会偷会懒,等待消费者处理缓冲区的数据。

这样理解,相信很容易吧,接着下一个专题

1.3 迭代Channel

Channel本身确实像序列,所以我们在读取的时候可以直接获取一个Channel的iterator。

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
kotlin复制代码    @Test
fun `test iterate channel`() = runBlocking<Unit> {
val channel = Channel<Int>(Channel.UNLIMITED)
//生产者
val producer = GlobalScope.launch {
for (x in 1..5) {
channel.send(x * x)
println("send ${x * x}")
}
}

//消费者
val consumer = GlobalScope.launch {
/*val iterator = channel.iterator()
while (iterator.hasNext()){
val element = iterator.next()
println("receive $element")
delay(2000)
}*/

//上下两种写法都可以
for (element in channel) {
println("receive $element")
delay(2000)
}
}
joinAll(producer, consumer)

}

一切尽在注释中。

先来看运行效果

1
2
3
4
5
6
7
8
9
10
bash复制代码send 1
send 4
send 9
send 16
send 25 //前5条消息几乎瞬间出来
receive 1 //往后的每条消息间隔2秒
receive 4
receive 9
receive 16
receive 25

我们可以看到,这运行效果和1.2的完全不一样!这里的生产者根本就没有等待对应的消费者处理完成就提前完成了所有工作!

上面我们提到过:生产者“偷懒”的条件:一是消费者处理时间大于生产者;二是缓存区必须满了!

但这里在定义Channel通道时,使用了:val channel = Channel<Int>(Channel.UNLIMITED),将缓存区改成了无限大,因此生产者才不管消费者能不能处理过来,一梭哈全生成完了!

1.4 produce与actor

  • 构造生产者与消费者的便捷方法
  • 我们可以通过produce方法启动一个生产者协程,并返回一个ReceiveChannel,其他协程就可以用这个Channel来接收数据了。反过来,我们可以用actor启动一个消费协程!

概念说完了,该开始上手了

1.4.1 使用produce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码    @Test
fun `test fast producer channel`() = runBlocking<Unit> {
//生产者,
val receiveChannel: ReceiveChannel<Int> = GlobalScope.produce<Int> {
repeat(100) {
delay(1000)
send(it)
}
}
//消费者
val consumer = GlobalScope.launch {
for (i in receiveChannel) {
println("received: $i")
}
}
consumer.join()
}

来看看运行效果

1
2
3
4
5
bash复制代码received: 0  //每隔一秒打印
received: 1
received: 2
received: 3
...略

这里我们可以看到通过GlobalScope.produce返回了ReceiveChannel生产者协程,在消费者里就可以通过ReceiveChannel来接收对应生产者产生的数据。接下来看下一个!

1.4.2 使用actor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码    @Test
fun `test fast consumer channel`() = runBlocking<Unit> {
val sendChannel: SendChannel<Int> = GlobalScope.actor<Int> {
while (true) {
val element = receive()
println(element)
}
}

val producer = GlobalScope.launch {
for (i in 0..3) {
sendChannel.send(i)
}
}

producer.join()
}

来看看运行效果

1
2
3
4
bash复制代码0
1
2
3

这里我们看到通过GlobalScope.actor产生了对应的消费者sendChannel,在对应的生产者里面通过 sendChannel.send(i)向对应的消费者发送数据!

接着看下一个!

1.5 Channel的关闭

  • produce和actor返回的Channel都会随着对应的协程执行完毕而关闭,也正是这样,Channel才被称为热数据流;
  • 对于一个Channel,如果我们调用了它的close方法,它会立即停止接收新元素,也就是说这时它的isClosedForSend会立即返回true;
+ 而由于Channel缓冲区的存在,这时候可能还有一些元素没有被处理完,因此要等所有的元素都被读取之后`isClosedForSend`才会返回true;
  • Channel的生命周期最好由主导方来维护,建议由主导的一方实现关闭。
+ 因为可能会存在一个生产者对应多个消费者,就好比如,一个老师讲课,有多个学生听课,是否上下课的信号由老师来负责,而不是学生!

老规矩,概念完了,就开始Demo上手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
kotlin复制代码    @Test
fun `test close channel`() = runBlocking<Unit> {
val channel = Channel<Int>(3)
//生产者
val producer = GlobalScope.launch {
List(3) {
channel.send(it)
println("send $it")
}
//由生产者主导生命周期,执行关闭!
channel.close()
println("""close channel.
| - ClosedForSend: ${channel.isClosedForSend}
| - ClosedForReceive: ${channel.isClosedForReceive}""".trimMargin())
}

//消费者
val consumer = GlobalScope.launch {
for (element in channel){
println("receive $element")
delay(1000)
}
println("""After Consuming.
| - ClosedForSend: ${channel.isClosedForSend}
| - ClosedForReceive: ${channel.isClosedForReceive}""".trimMargin())
}

joinAll(producer, consumer)
}

这里我们看到,就仅仅是在生产者里面主导了生命周期,其他的都是状态打印!

来看看运行效果

1
2
3
4
5
6
7
8
9
10
11
12
bash复制代码send 0
receive 0
send 1
send 2
close channel.
- ClosedForSend: true
- ClosedForReceive: false
receive 1
receive 2
After Consuming.
- ClosedForSend: true
- ClosedForReceive: true

从这个运行效果可以看出:

  • 当生产者执行完毕时:对应ClosedForSend为true;
  • 当消费者执行完毕时:对应ClosedForReceive为true。

1.6 BroadcastChannel

上面提到,生产者和消费者在Channel中存在一对多的情形,从数据处理本身来讲,虽然有多个接收端,但是同一个元素只会被一个接收端读到。广播则不然,多个接收端不存在互斥行为。

来看看这个广播如何使用:

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复制代码    @Test
fun `test broadcast`() = runBlocking<Unit> {
//val broadcastChannel = BroadcastChannel<Int>(Channel.BUFFERED)
val channel = Channel<Int>() //这里使用默认缓存区大小
//初始化三个消费者
val broadcastChannel = channel.broadcast(3)
val producer = GlobalScope.launch {
List(3){
delay(100)
broadcastChannel.send(it)
}
//由主导方管理生命周期
broadcastChannel.close()
}

//创建三个消费者
List(3){ index ->
GlobalScope.launch {
val receiveChannel = broadcastChannel.openSubscription()
for (i in receiveChannel){
println("[#$index] received: $i")
}
}
}.joinAll()
}

一切尽在注释中,

来看看运行效果

1
2
3
4
5
6
7
8
9
bash复制代码[#0] received: 0
[#1] received: 0
[#2] received: 0
[#0] received: 1
[#1] received: 1
[#2] received: 1
[#0] received: 2
[#2] received: 2
[#1] received: 2

从这个运行效果可以看出:多个消费者,能够同时接收同一个生成者相同的信息,并没有互斥性!

2、select-多路复用

什么是多路复用

数据通信系统或计算机网络系统中,传输媒体的宽带或容量往往会大于传输单一信号的需求,为了有效的利用通信线路,希望一个信道同时传输多路信息,这就是所谓的多路复用技术(Multiplexing)

2.1 复用多个await

2.png

如图所示

两个API分别从网络和本地缓存获取数据,期望哪个先返回就先用哪个做展示。

2.1.1 开始实战

服务端

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

@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String user = request.getParameter("user");
if(user != null){
System.out.println(user);
}
System.out.println("doGet");
PrintWriter out = response.getWriter();
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("name","jason");
jsonObject.addProperty("address","California");
out.write(jsonObject.toString());
System.out.println(jsonObject.toString());
out.close();
}
}

服务端使用的是最原始的HttpServlet+TomCat方式,没有用现在的SpringBoot,代码也很简单,就不过多说明了。

客户端

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
kotlin复制代码private val cachePath = "E://coroutine.cache" //该文件里面内容为:{"name":"hqk","address":"成都"}
private val gson = Gson()

data class Response<T>(val value: T, val isLocal: Boolean)

//通过本地加载用户信息
fun CoroutineScope.getUserFromLocal(name: String) = async(Dispatchers.IO) {
delay(10000) //故意的延迟 挂起10秒
File(cachePath).readText().let { gson.fromJson(it, User::class.java) }
}

//通过网络加载用户信息
fun CoroutineScope.getUserFromRemote(name: String) = async(Dispatchers.IO) {
userServiceApi.getUser(name)
}

class CoroutineTest02 {
@Test
fun `test select await`() = runBlocking<Unit> {
GlobalScope.launch {
val localRequest = getUserFromLocal("xxx")
val remoteRequest = getUserFromRemote("yyy")

val userResponse = select<Response<User>> {
localRequest.onAwait { Response(it, true) }
remoteRequest.onAwait { Response(it, false) }
}

userResponse.value?.let { println(it) }
}.join()
}

}

//定义用户数据类
data class User(val name: String, val address: String)

//Retrofit 网络数据请求
val userServiceApi: UserServiceApi by lazy {
val retrofit = retrofit2.Retrofit.Builder()
.client(OkHttpClient.Builder().addInterceptor {
it.proceed(it.request()).apply {
Log.d("hqk", "request:${code()}")
//Log.d("hqk", "boy:${body()?.string()}")
}
}.build())
.baseUrl("http://10.0.0.130:8080/kotlinstudyserver/")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create(UserServiceApi::class.java)
}


interface UserServiceApi {

//获取用户信息
@GET("user")
suspend fun getUser(@Query("name") name: String) : User
}

这里可以看到@Test测试类里面分别调用了获取本地、网络用户的方法,并在select{}里面分别调用了对应方法的onAwait ,返回userResponse对象

来看看运行效果

1
bash复制代码User(name=jason, address=California)

因为获取本地用户那里挂起了10秒,而网络请求的数据的时间小于本地加载时间,因此这里,加载的是网络数据。

那如果说将本地挂起10给注释掉,再次运行看看效果:

1
bash复制代码User(name=hqk, address=成都)

很明显,这里加载是本地数据,而非网络数据。

由此,可以得出:当复用多个await时,谁先返回,那就先用哪个做展示

2.2 复用多个Channel

跟await类似,会接收到最快的那个Channel消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码    @Test
fun `test select channel`() = runBlocking<Unit> {
val channels = listOf(Channel<Int>(), Channel<Int>())
GlobalScope.launch {
delay(100)
channels[0].send(200)
}

GlobalScope.launch {
delay(50)
channels[1].send(100)
}

val result = select<Int?> {
channels.forEach { channel ->
channel.onReceive { it }
}
}
println(result)
}

先来看看运行效果:

1
bash复制代码100

这里我们看到,通过listOf将对应通道整合成一个list集合,然后分别开了两个协程,在对应协程里分别挂起的不同的时间。最后我们看到接收了执行了耗时较短的通道信息!

2.3 SelectClause

我们怎么知道哪些事件可以被select呢?其实所有能够被select的时间都是SelectClauseN类型,包括:

  • SelectClause0:对应事件没有返回值,例如join没有返回值,那么onJoin就是SelectClauseN类型。使用时,onJoin的参数是一个无参函数。
  • SelectClause1:对应事件有返回值,上面的onAwait和onReceive都是此类情况(下面就不举该例)
  • SelectClause2:对应事件有返回值,此外还需要一个额外的参数,例如Channel.onSend有两个参数,第一个是Channel数据类型的值,表示即将发送的值;第二个是发送成功时的回调函数。

如果我们想要确认挂起函数是否支持select,只需要查看其是否存在对应的SelectClauseN类型可回调即可。

概念说了一大堆,分别实战看看效果:

2.3.1 示例一(SelectClause0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码    @Test
fun `test SelectClause0`() = runBlocking<Unit> {
val job1 = GlobalScope.launch {
delay(100)
println("job 1")
}

val job2 = GlobalScope.launch {
delay(10)
println("job 2")
}

select<Unit> {
job1.onJoin { println("job 1 onJoin") }
job2.onJoin { println("job 2 onJoin") }
}

delay(1000)
}

来看看运行效果:

1
2
3
bash复制代码job 2
job 2 onJoin
job 1

这是一个非常标准的协程,对应事件没有任何返回值的,这个就是上面所说的SelectClause0类型。

2.3.2 示例二(SelectClause2)

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
kotlin复制代码    @Test
fun `test SelectClause2`() = runBlocking<Unit> {
val channels = listOf(Channel<Int>(), Channel<Int>())
println(channels)
launch(Dispatchers.IO) {
select<Unit?> {
launch {
delay(10)
channels[1].onSend(200) { sentChannel ->
println("sent 1 on $sentChannel")
}
}
launch {
delay(100)
channels[0].onSend(100) { sentChannel ->
println("sent 0 on $sentChannel")
}
}
}
}
GlobalScope.launch {
println(channels[0].receive())
}
GlobalScope.launch {
println(channels[1].receive())
}
delay(1000)
}

来看看运行效果

1
2
3
bash复制代码[RendezvousChannel@2a084b4c{EmptyQueue}, RendezvousChannel@42b93f6b{EmptyQueue}]
200
sent 1 on RendezvousChannel@42b93f6b{EmptyQueue} //回调成功执行业务逻辑——打印

这里我们看到使用了channels.onSend方式,上面所说,第一个参数为对应类型,第二个参数就会回调函数,也就是说,后面大括号里面的内容就会回调成功的业务逻辑处理。

2.4 使用Flow实现多路复用

多数情况下,我们可以通过构造合适的Flow来实现多路复用的效果。

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
kotlin复制代码private val cachePath = "E://coroutine.cache" //该文件里面内容为:{"name":"hqk","address":"成都"}
private val gson = Gson()

data class Response<T>(val value: T, val isLocal: Boolean)

//通过本地获取用户信息
fun CoroutineScope.getUserFromLocal(name: String) = async(Dispatchers.IO) {
// delay(10000) //故意的延迟
File(cachePath).readText().let { gson.fromJson(it, User::class.java) }
}

//通过网络获取用户信息
fun CoroutineScope.getUserFromRemote(name: String) = async(Dispatchers.IO) {
userServiceApi.getUser(name)
}



class CoroutineTest02 {
@Test
fun `test select flow`() = runBlocking<Unit> {
// 函数 -> 协程 -> Flow -> Flow合并
val name = "guest"
coroutineScope {
//通过作用域,将对应方法调用添加至list集合里
listOf(::getUserFromLocal, ::getUserFromRemote)
//遍历集合每个方法,function 就为对应的某个方法
.map { function ->
function.call(name) //这里调用对应方法后,将返回的结果传至下个map里
}.map { deferred -> //这里对应deferred 表示对应方法返回的结果
flow { emit(deferred.await()) }//这里表示,得到谁,就通过flow 发射值
}.merge() //流 合并
.collect { user -> println(user) } //这里只管接收flow对应发射值

}
}
}

一切尽在注释中,

来看看运行效果

1
2
bash复制代码User(name=hqk, address=成都)
User(name=jason, address=California)

这里我们看到,本地和网络都成功的收到了!

3、并发安全

3.1 不安全的并发访问

我们使用线程在解决并发问题的时候总是会遇到线程安全的问题,而Java平台上的Kotlin协程实现免不了存在并发调度的情况,因此线程安全同样值得留意。

比如说:

1
2
3
4
5
6
7
8
kotlin复制代码    @Test
fun `test not safe concurrent`() = runBlocking<Unit> {
var count = 0
List(1000) {
GlobalScope.launch { count++ }
}.joinAll()
println(count)
}

我们可以看到,这里开启了1000个协程并发,每个协程都对count 自加一,理想情况下应该为1000

来看看具体效果如何

1
bash复制代码973 //每次重新运行值都不一样

现在我们看到真实效果值,并非理想情况,因此我们需要重视并发情况!

3.2 协程的并发工具

除了我们在线程中常用的解决并发问题的手段之外,协程框架也提供了一些并发的安全工具,包括:

  • Channel:并发安全的消息通道,我们已经非常熟悉
  • Mutex:轻量级锁,它的lock和unlock从语义上与线程锁比较类似,之所以轻量是因为它在获取不到锁时不会阻塞线程,而是挂起等待锁的释放;
  • Semaphore:轻量级信号量,信号量可以有多个,协程在获取到信号量后即可执行并发操作。
+ 当`Semaphore`的参数为1时,效果等价于`Mutex`

说了那么多,上手试试!

3.2.1 示例一(使用AtomicXXX)

1
2
3
4
5
6
7
8
kotlin复制代码    @Test
fun `test safe concurrent`() = runBlocking<Unit> {
var count = AtomicInteger(0)
List(1000) {
GlobalScope.launch { count.incrementAndGet() }
}.joinAll()
println(count.get())
}

这个是比较Java常规的解决方案:通过原子操作类解决

运行效果就1000,效果就不贴了。

3.2.2 示例二(使用Mutex)

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码   @Test
fun `test safe concurrent tools`() = runBlocking<Unit> {
var count = 0
val mutex = Mutex()
List(1000) {
GlobalScope.launch {
mutex.withLock {
count++
}
}
}.joinAll()
println(count)
}

我们可以看到,在协程开始前初始化了Mutex对象,在对应协程自加操作前通过mutex.withLock将对应逻辑上锁。

接下来看下一个!

3.2.3 示例三(使用Semaphore)

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码    @Test
fun `test safe concurrent tools2`() = runBlocking<Unit> {
var count = 0
val semaphore = Semaphore(1)
List(1000) {
GlobalScope.launch {
semaphore.withPermit {
count++
}
}
}.joinAll()
println(count)
}

这里我们可以看到通过Semaphore(1)得到了对应对象,然后在并发逻辑处额外用semaphore.withPermit 解决了并发安全问题。

3.3 避免访问外部可变状态

1
2
3
4
5
6
7
8
kotlin复制代码    @Test
fun `test avoid access outer variable`() = runBlocking<Unit> {
var count = 0
val result = count + List(1000){
GlobalScope.async { 1 }
}.map { it.await() }.sum()
println(result)
}

编写函数时要求它不得访问外部状态,只能基于参数做运算,通过返回值提供运算结果

结束语

好了,本篇到这里就结束了!相信看到这的小伙伴应该对Channel有所了解!在下一篇中,将会详解协程Flow的综合应用

本文转载自: 掘金

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

从 vue-cli 源码中,我发现27行读取 json 文件

发表于 2021-12-03
  1. 前言

大家好,我是若川。为了能帮助到更多对源码感兴趣、想学会看源码、提升自己前端技术能力的同学。我倾力组织了源码共读活动,感兴趣的可以加我微信 ruochuan12 参与,或者关注我的公众号若川视野,回复“源码”参与。已进行4个月,每周大家一起学习200行左右的源码,共同进步,很多人都表示收获颇丰。

想学源码,极力推荐关注我写的专栏(目前1.8K人关注)《学习源码整体架构系列》 包含jQuery、underscore、lodash、vuex、sentry、axios、redux、koa、vue-devtools、vuex4、koa-compose、vue 3.2 发布、vue-this、create-vue、玩具vite等20余篇源码文章。

本文仓库 https://github.com/lxchuan12/read-pkg-analysis.git,求个star^_^

源码共读活动 每周一期,已进行到15期。源码群里有小伙伴提问,如何用 import 加载 json 文件。同时我之前看到了vue-cli 源码 里有 read-pkg 这个包。源码仅27行,非常值得我们学习。

阅读本文,你将学到:

1
2
3
4
5
6
7
bash复制代码1. 如何学习调试源码
2. 学会如何获取 package.json
3. 学到 import.meta
4. 学到引入 json 文件的提案
5. JSON.parse 更友好的错误提示
6. 规范化 package 元数据
7. 等等
  1. 场景

优雅的获取 package.json 文件。

read-pkg

vue-cli 源码

1
2
3
4
5
6
7
8
9
10
js复制代码const fs = require('fs')
const path = require('path')
const readPkg = require('read-pkg')

exports.resolvePkg = function (context) {
if (fs.existsSync(path.join(context, 'package.json'))) {
return readPkg.sync({ cwd: context })
}
return {}
}

封装这个函数的commit 记录

你也许会想直接 require('package.json'); 不就可以了。但在ES模块下,目前无法直接引入JSON文件。

在 stackoverflow 也有相关提问

我们接着来看 阮一峰老师的 JSON 模块

import 命令目前只能用于加载 ES 模块,现在有一个提案,允许加载 JSON 模块。
import 命令能够直接加载 JSON 模块以后,就可以像下面这样写。

1
2
js复制代码import configData from './config.json' assert { type: "json" };
console.log(configData.appName);

import 命令导入 JSON 模块时,命令结尾的 assert {type: "json"} 不可缺
少。这叫做导入断言,用来告诉 JavaScript 引擎,现在加载的是 JSON 模块。

接下来我们学习 read-pkg 源码。

  1. 环境准备

3.1 克隆

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码# 推荐克隆我的项目,保证与文章同步
git clone https://github.com/lxchuan12/read-pkg-analysis.git
# npm i -g yarn
cd read-pkg && yarn
# VSCode 直接打开当前项目
# code .

# 或者克隆官方项目
git clone https://github.com/sindresorhus/read-pkg.git
# npm i -g yarn
cd read-pkg && yarn
# VSCode 直接打开当前项目
# code .

看源码一般先看 package.json,再看 script。

3.2 package.json

1
2
3
4
5
6
js复制代码{
"name":
"scripts": {
"test": "xo && ava && tsd"
}
}

test命令有三个包,我们一一查阅了解。

xo

JavaScript/TypeScript linter (ESLint wrapper) with great defaults
JavaScript/TypeScript linter(ESLint 包装器)具有很好的默认值

tsd

Check TypeScript type definitions
检查 TypeScript 类型定义

nodejs 测试工具 ava

Node.js test runner that lets you develop with confidence

3.3 调试

提前在入口测试文件 test/test.js 和入口文件 index.js 打好断点。

用最新的VSCode 打开项目,找到 package.json 的 scripts 属性中的 test 命令。鼠标停留在test命令上,会出现 运行命令 和 调试命令 的选项,选择 调试命令 即可。

调试如图所示:

debugger

更多调试细节可以看我的这篇文章:新手向:前端程序员必学基本技能——调试JS代码

我们跟着调试来看测试用例。

  1. 测试用例

这个测试用例文件,主要就是主入口 index.js 导出的两个方法 readPackage, readPackageSync。异步和同步的方法。

判断读取的 package.json 的 name 属性与测试用例的 name 属性是否相等。

判断读取 package.json 的 _id 是否是真值。

同时支持指定目录。{ cwd }

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
js复制代码// read-pkg/test/test.js
import {fileURLToPath} from 'url';
import path from 'path';
import test from 'ava';
import {readPackage, readPackageSync} from '../index.js';

const dirname = path.dirname(fileURLToPath(import.meta.url));
process.chdir(dirname);
const rootCwd = path.join(dirname, '..');

test('async', async t => {
const package_ = await readPackage();
t.is(package_.name, 'unicorn');
t.truthy(package_._id);
});

test('async - cwd option', async t => {
const package_ = await readPackage({cwd: rootCwd});
t.is(package_.name, 'read-pkg');
});

test('sync', t => {
const package_ = readPackageSync();
t.is(package_.name, 'unicorn');
t.truthy(package_._id);
});

test('sync - cwd option', t => {
const package_ = readPackageSync({cwd: rootCwd});
t.is(package_.name, 'read-pkg');
});

这个测试用例文件,涉及到一些值得一提的知识点。接下来就简单讲述下。

4.1 url 模块

url 模块提供用于网址处理和解析的实用工具。

url 中文文档

url.fileURLToPath(url)

url | 要转换为路径的文件网址字符串或网址对象。
返回: 完全解析的特定于平台的 Node.js 文件路径。
此函数可确保正确解码百分比编码字符,并确保跨平台有效的绝对路径字符串。

4.2 import.meta.url

import.meta.url

(1)import.meta.url
import.meta.url返回当前模块的 URL 路径。举例来说,当前模块主文件的路径是https://foo.com/main.js,import.meta.url就返回这个路径。如果模块里面还有一个数据文件 data.txt,那么就可以用下面的代码,获取这个数据文件的路径。
new URL(‘data.txt’, import.meta.url)
注意,Node.js 环境中,import.meta.url 返回的总是本地路径,即是file:URL协议的字符串,比如 file:///home/user/foo.js。

4.3 process.chdir

process.chdir() 方法更改 Node.js 进程的当前工作目录,如果失败则抛出异常(例如,如果指定的 directory 不存在)。

  1. 27行主入口源码

导出异步和同步的两个方法,支持传递参数对象,cwd 默认是 process.cwd(),normalize 默认标准化。

分别是用 fsPromises.readFile fs.readFileSync 读取 package.json 文件。

用 parse-json 解析 json 文件。

用 npm 官方库 normalize-package-data 规范化 package 元数据。

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
js复制代码import process from 'node:process';
import fs, {promises as fsPromises} from 'node:fs';
import path from 'node:path';
import parseJson from 'parse-json';
import normalizePackageData from 'normalize-package-data';

export async function readPackage({cwd = process.cwd(), normalize = true} = {}) {
const filePath = path.resolve(cwd, 'package.json');
const json = parseJson(await fsPromises.readFile(filePath, 'utf8'));

if (normalize) {
normalizePackageData(json);
}

return json;
}

export function readPackageSync({cwd = process.cwd(), normalize = true} = {}) {
const filePath = path.resolve(cwd, 'package.json');
const json = parseJson(fs.readFileSync(filePath, 'utf8'));

if (normalize) {
normalizePackageData(json);
}

return json;
}

5.1 process 进程模块

很常用的模块。

process 中文文档

process 对象提供有关当前 Node.js 进程的信息并对其进行控制。 虽然它作为全局可用,但是建议通过 require 或 import 显式地访问它:

1
js复制代码import process from 'node:process';

Node 文档

也就是说引用 node 原生库可以加 node: 前缀,比如 import util from 'node:util'

5.2 path 路径模块

很常用的模块。

path 中文文档

path 模块提供了用于处理文件和目录的路径的实用工具。

5.3 fs 文件模块

很常用的模块。

fs 中文文档

5.4 parseJson 解析 JSON

parse-json

文档介绍:

Parse JSON with more helpful errors

更多有用的错误提示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码// 源码有删减
const fallback = require('json-parse-even-better-errors');
const parseJson = (string, reviver, filename) => {
if (typeof reviver === 'string') {
filename = reviver;
reviver = null;
}

try {
try {
return JSON.parse(string, reviver);
} catch (error) {
fallback(string, reviver);
throw error;
}
} catch (error) {
// 省略
}
}

5.5 normalizePackageData 规范化包元数据

npm 官方库 normalize-package-data

normalizes package metadata, typically found in package.json file.

规范化包元数据

1
2
3
4
5
js复制代码module.exports = normalize
function normalize (data, warn, strict) {
// 省略若干代码
data._id = data.name + '@' + data.version
}

这也就是为啥测试用例中用了t.truthy(package_._id); 来检测 _id 属性是否为真值。

  1. 总结

最后总结下我们学到了如下知识:

1
2
3
4
5
6
7
bash复制代码1. 如何学习调试源码
2. 学会如何获取 package.json
3. 学到 import.meta
4. 学到引入 json 文件的提案
5. JSON.parse 更友好的错误提示
6. 规范化 package 元数据
7. 等等

read-pkg 源码 整体而言相对比较简单,但是也有很多可以学习深挖的学习的知识点。

作为一个 npm 包,拥有完善的测试用例。

学 Node.js 可以多找找简单的 npm 包学习。比直接看官方文档有趣多了。不懂的就去查官方文档。查的多了,自然常用的就熟练了。

建议读者克隆 我的仓库 动手实践调试源码学习。

最后可以持续关注我@若川。欢迎加我微信 ruochuan12 交流,参与 源码共读 活动,每周大家一起学习200行左右的源码,共同进步。


关于 && 源码共读交流群

最近组织了源码共读活动,感兴趣的可以加我微信 ruochuan12 参与,长期交流学习。

作者:常以若川为名混迹于江湖。欢迎加我微信ruochuan12。前端路上 | 所知甚少,唯善学。

关注公众号若川视野,每周一起学源码,学会看源码,进阶高级前端。

若川的博客

segmentfault若川视野专栏,开通了若川视野专栏,欢迎关注~

掘金专栏,欢迎关注~

知乎若川视野专栏,开通了若川视野专栏,欢迎关注~

github blog,求个star^_^~

本文转载自: 掘金

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

linux基础 - Linux系统文件管理 系统文件管理命令

发表于 2021-11-30

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

系统文件管理命令

一个目录下不可能出现同名的文件,不管是文件夹还是文件都不能同名。linux区分大小写,windows不区分大小写。

创建目录mkdir

1
2
3
4
bash复制代码mkdir [option] directory  # []表示可加可不加
-m:创建目录时授权
-p:递归创建目录,如果目录已经存在则不创建,并且不报错
-v:显示创建目录的信息,查看创建目录的过程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bash复制代码# 基本用法
mkdir /test # /目录下创建test目录
mkdir test # 当前目录创建
mkdir ../test # 当前目录的上一级目录
mkdir ./test # 当前目录创建
mkdir /usr/local/test # 指定目录创建
mkdir .test # 当前目录下创建隐藏目录

# 创建多个目录
cd /tmp
mkdir -p test /usr/local/test /test/teat # 第一种方式
mkdir a{1, 2, 3} # 创建有规律的a1 a2 a3目录
mkdir a{10...100} # 创建a10 - a100目录
mkdir /{home/{test/test{1, 2}, oldboy}, backup}


# -m 数字是是从000-777之间的数字,每一位不能超过7
mkdir -m 777 test # 所有的权限
mkdir -m 000 testb # 没有权限

创建目录时报错处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bash复制代码[root@zhuang ~]# mkdir /abc/def/efg
mkdir: cannot create directory ‘/abc/def/efg’: No such file or directory
报错原因是:/abc目录不存在,所以无法创建后续目录
解决方案:mkdir -p /abc/def/efg

[root@zhuang ~]# mkdir /abc/def/efg
mkdir:cannot create directory ‘/abc/def/efg’: directory is exists
报错原因:目录已经存在了
解决方法:不创建就好了呀~~~,或者加参数-p就不会创建了,而且不报错

[test@zhuang ~]$ mkdir /root/test
mkdir:cannot create directory ‘/root/test’: permission denied
报错原因:权限不够
解决方法:需要授权

创建文件touch

1
2
3
4
5
bash复制代码touch [option]  file
1创建文件时如果文件已存在则修改它的 `修改时间` 和 `访问时间` 和 `改变时间`,不存在则创建
2必须创建在已经存在的目录中
3创建相同文件不会被覆盖
4linux中一切皆文件,没有后缀名之分

显示目录结构tree

1
2
3
4
5
6
7
8
9
10
11
12
bash复制代码# 什么参数都没有
tree # 当前目录的目录结构

# 指定文件夹查看目录结构
tree /r

# 查看指定级数的目录结构-L
tree -L 级数 /root
tree -L 3 /

# 只看目录结构-d,选项是指定给某个参数的,参数必须写在指定选项后面,比如-dL 1 不建议写-Ld 1
tree -d -L 3 /

复制文件或者目录cp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bash复制代码copy:拷贝,无法直接拷贝目录需要借助-r参数
cp [OPTION]... SOURCE... DIRECTORY # source路径下的文件拷贝到新路径下可以改名


cp www /root # 将当前路径下的www复制到/root路径下,文件名不变

cp www /root/aaa
1.如果aaa存在并且是一个目录,会将www放到/root/aaa目录下
2.如果aaa存在并且是一个文件,会将www放到/root目录下,并且覆盖原文件的内容
3.如果aaa不存在,会将www放到/root目录下并改名为aaa

# 三个语法
cp -T 原文件 目标文件(必须有文件名)
cp -t 目标路径 原文件
cp 原文件路径 目标路径(可以改名)
-a:相当于-r -p,既做到了递归又保持了文件的属性
-i:如果拷贝过程中出现重复文件名,询问是否覆盖
-r(-R, --recursivve):递归拷贝文件,直接拷贝会改变文件属性
-p:在拷贝文件的过程中保持文件原有的属性
-v:显示拷贝文件的过程
-t:将原文件和目标文件的书写位置反过来

移动命令mv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bash复制代码# 语法,mv命令可以直接移动目录不需要递归
mv [OPTION]... SOURCE1 SOURCE2.... ... DIRECTORY

mv test test1
# test1是否存在,如果存在并且是个目录,就将test移动到test1中
# 如果test1存在,如果存在是个文件会询问是否覆盖。
# test1如果不存在,则将test文件改名为test1

# 选项
-i:在移动过程中如果存在相同的文件名就询问是否覆盖
-t:将原文件和目录反过来

# 注意
mv命令可以直接移动目录,不需要递归,并且不会修改文件属性
mv命令后可以写多个文件,但是最后一个必须是目录,表示将前面所有的原文件移动到最后的目录中
文件不能覆盖目录,但是可以放入目录里面,目录不能覆盖文件并且不能放到文件里

删除命令rm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bash复制代码# 语法
rm [OPTION]... FILE...
Remove (unlink) the FILE(s).

# 选项
-d:只能删除空目录,没什么用
-r:递归删除
-f:强制
-v:显示删除的细节

# 注意
rm命令本身只能删除文件不能删除目录,如果想要删除目录需要使用选项
linux中没有回收站,rm会永久删除文件
删除文件的时候尽量使用rm -f
如果文件或者目录不存在不会报错

系统文件查看命令cat

1
2
3
4
5
6
7
8
9
10
11
12
bash复制代码# 语法
cat [OPTION]... [FILE]...

# 选项
-n:查看文件并显示文件的行数,空行也编号
-E:以$标注每行结尾
-T:以^I标注文件中的tab键
-A:相当于-v -E -T

# 其他用法
tac file # 把文件反过来看
cat >>xx.txt <<EOF # 向文件中输入内容,EOF表示输入EOF就结尾,也可以是其他字母

结语

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

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

本文转载自: 掘金

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

linux基础 - bash shell是啥? 结语

发表于 2021-11-30

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

bash shell是什么

bash shell就相当于一个应用程序,我们输入命令给bash shell,bash shell翻译成二进制后将命令传输给linux系统,系统调用内核,内核操作硬盘,执行操作。

bash shell就是让我们可以和计算机进行交互,将人类执行翻译成计算机能够理解的二进制指令,操作硬盘。

bash shell的作用

各种管理的增删改查

文件管理

对于文件的增删改查

1
2
3
4
5
6
7
8
9
bash复制代码# 创建文件
touch xx.txt

# 修改文件
vim 文件
echo xxx > 文件

# 查看文件
cat less head grep awk

权限管理

用户管理

磁盘管理

软件管理

网络管理

…

bash shell使用的两种方式

  • 命令行
  • shell脚本语言

shell提示符

1
2
3
4
5
6
7
8
9
bash复制代码[root@zhuang ~]# 

root:当前登录的用户
@:没有任何意义,就是分隔符
zhuang:默认现实主机名中以点为分隔符中的第一部分
~:当前所在路径,默认只显示当前路径的最后一个文件夹名称,~表示root用户家目录

# :表示超级用户的命令提示符
$ :表示普通用户的命令提示符

shell提示符修改

在bash shell中命令中$表示变量,需要和提示符中的进行区分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bash复制代码# 查看命默认的令提示符
echo $PS1
[\u@\h \W]\$

# 修改命令提示符:
vim /root/.bashrc
# 添加内容

\u:当前登录的用户
\h:当前主机名,如果主机名中有点,则以点为分隔符显示第一部分
\H:完整主机名
\W:当前路径的最后一个目录
\w:当前路径的完整目录,绝对路径
\d:现实当前日期
\t:24h格式显示时分秒
\T:12h格式显示时分秒
\A :显示时间为24小时格式:HH:MM
\v:bash版本信息
\#:显示当前下达的命令个数
\$:当前用户的命令提示符,如果是超级用户就显示# 普通用户就显示$

bash shell基本语法

1
2
3
4
5
6
7
8
bash复制代码# 基本语法,命令 选项 参数,选项和参数都可以有多个
command option arguments

# 举例
ls
ls -a
ls -l -a /usr/local/
ls -la /usr/local/ /tmp/

bash shell基本特性

命令补全-tab键

可以补全命令

可以补全路径

命令的选项如果想要补全需要安装包,一般不需要

1
bash复制代码yum install -y bash-completion

命令的选项

选项有长格式和长格式两种

1
2
bash复制代码ls -a  # 默认使用短格式
ls --all

命令快捷键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bash复制代码ctrl+a:将光标跳转到当前命令的行首
ctrl+e:将光标跳转到当前命令的行尾
ctrl+w:按照空格删除光标之前的内容
ctrl+c:终止当前命令
ctrl+l:清屏
ctrl+r:查找执行的最近的一条包含输入字母的命令
ctrl+d:退出当前bash,只退出一个,类似于退出当前登录的用户,开多个bash类似于开多个bash进程
ctrl+z:把进程放在后台运行
ctrl+k:删除从光标开始到行尾的所有内容
ctrl+u:删除从光标开始到行首的所有内容
ctrl+左右键:快速移动光标
esc+.:快速获取上一条命令最后一个空格之后的内容
!+字母:找到历史记录中时间最近的带指定字母的命令执行
!!:执行上一条命令
!+数字:执行历史记录中指定数字编号的历史命令

# 不常用
ctrl+s:锁屏
ctrl+q:解锁后会将把锁屏期间输入的所有内容输出

history-历史命令

历史命令主要用于审计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bash复制代码# 删除历史命令
history -c

# 指定删除某一条历史记录
histoty -d 4 # 删除第四条历史记录

# 保存历史记录到/root/.bash_history
history -w

# 修改历史记录的显示
vim /etc/profile

# shift + g到最后一行
USER_IP=`who -u am i 2>/dev/null | awk '{print $NF}' | sed -e 's/[()]//g'`
if [ "$USER_IP" = "" ]
then
USER_IP=`hostname`
fi
export HISTTIMEFORMAT="%F | %T | 用户IP: $USER_IP | 操作用户: `whoami` |操作命令: "
shopt -s histappend
export PROMPT_COMMAND="history -a"

# 生效
source /etc/profile

命令别名alias

把复杂的命令简化,但是如果使用命令的绝对路径就不使用别名了比如/bin/cp

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码alias wk='vim /etc/systemconfig/network-scripts/ifcfg-etho'

# 用法,临时设置
alias # 查看当前系统有哪些别名
alias grep="grep --color=auto" # 创建别名,如果名字已经存在就是修改,不存在就是创建别名
unalias wk # 删除别名

# root用户下永久生效
vim /root/.bashrc

alias wk='vim /etc/systemconfig/network-scripts/ifcfg-etho'

source /root/.bashrc

命令获取帮助

1
2
3
bash复制代码命令 --help
或者
man 命令

结语

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

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

本文转载自: 掘金

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

长/短 链接/轮询 和websocket 结语

发表于 2021-11-30

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

短连接和长连接

短连接:

  • http协议底层基于socket的tcp协议,每次通信都会新建一个TCP连接,即每次请求和响应过程都经历”三次握手-四次挥手“
  • 优点:方便管理
  • 缺点:频繁的建立和销毁连接占用资源

长连接:

  • 客户端和服务端之间只有一条TCP通信连接,以后所有的请求都使用这条连接,也称为持久连接。
  • 优点:多次请求-响应基于一条连接,避免资源浪费。
  • 缺点:客户端的数量增加,服务端承受的压力增大。对每个请求仍然要单独发header,Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。

总结:

  • 长短连接指的是客户端和服务端建立和保持TCP连接的机制。
  • 不论是短连接还是长连接,都是客户端主动向服务端发请求,才能获悉数据,服务端是无法主动给客户端推送信息的。
  • 服务端主动给客户端推送消息最简单的方式是采用轮询,即客户端每隔一段时间就向服务端发出一个询问,获取服务端最新的消息。最典型的应用就是聊天室。

短轮询和长轮询

短轮询:

  • 浏览器每隔一段时间向服务端发送http请求,服务器端在收到请求后,不论是否有数据更新,都直接进行响应。这种方式实现的即时通信,本质上还是浏览器发送请求,服务器接受请求的一个过程,通过让客户端不断的进行请求,使得客户端能够模拟实时地收到服务器端的数据的变化。
  • 优点:比较简单,易于理解;
  • 缺点:由于需要不断的建立 http 连接,严重浪费了服务器端和客户端的资源。当用户增加时,服务器端的压力就会变大,这是很不合理的。

长轮询:

  • 首先由客户端向服务器发起请求,当服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将 这个请求挂起,然后判断服务器端数据是否有更新。

如果有更新,则进行响应,如果一直没有数据,则到达一定的时间限制才返回。客户端 JavaScript 响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。

  • 优点:「明显减少了很多不必要的 http 请求次数」,相比之下节约了资源。
  • 缺点:实现复杂,且连接挂起也会导致资源的浪费。

总结:

  • 短轮询和长轮询指的是客户端请求服务端,服务端给予响应的方式。
  • 轮询的方式可以解决服务端主动向客户端推送消息的需求,但是轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。
  • 为了更好的解决这个问题,于是出现了WebSocket

WebSocket

简介:

  • WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。
  • 服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。
  • 可以发送文本,也可以发送二进制数据。
  • 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。

websocket:websocket是长连接,是一个真的全双工,第一次tcp链路建立以后,后续所有数据双方都主动发送,不需要发送请求头,与传统的 http 协议不同,该协议允许由服务器主动的向客户端推送信息。与HTTP长连接不同,websocket可以更灵活的控制连接关闭的时机,而不是HTTP协议的Keep-Alive一到,服务端立马就关闭(这样很不人性化)。

优点:

  • 建立在 TCP 协议之上,服务器端的实现比较容易。
  • 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
  • 数据格式比较轻量,性能开销小,通信高效。
  • 没有同源限制,客户端可以与任意服务器通信。

缺点:

  • 使用 WebSocket 协议的缺点是在服务器端的配置比较复杂。

结语

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

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

本文转载自: 掘金

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

dwebsocket基本使用 结语

发表于 2021-11-30

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

目的:实现websocket请求,客户端和服务端实现双工通信(客户端与服务端通过websocket连接成功后,可以双向地主动发消息)

  • 与django推荐的channel不同,dwebsocket使用更加方便简单
  • channels依赖于redis,twisted等,相比之下使用dwebsocket要更为方便一些

WebSocket的工作流程是这 样的:浏览器通过JavaScript向服务端发出建立WebSocket连接的请求,在WebSocket连接建立成功后,客户端和服务端就可以通过 TCP连接传输数据。因为WebSocket连接本质上是TCP连接,不需要每次传输都带上重复的头部数据,所以它的数据传输量比轮询和Comet技术小了很多.

dwebsocket 相关网站

  • pypi.org/project/dwe…
  • github.com/duanhongyi/…

安装dwebsocket

1
undefined复制代码pip3 install dwebsocket

注册APP

注册之后才能使用dwebsocket

1
2
3
4
5
python复制代码INSTALLED_APPS = [
.....
.....
'dwebsocket',
]

使用

使用的方法有两种情况,如下:

  • 第一种则是在配置文件中设置中间件,配置所有视图都可以接收使用websocket功能。
  • 第二种则是利用修饰器的方式单独对某个视图进行增加websocket功能。

第一种:全局配置

在django项目的配置文件的中间件中配置,这种情况下需要每一个视图函数都使用websocket通信,否则报错。

1
2
3
4
5
6
7
python复制代码MIDDLEWARE_CLASSES = [
......
......
'dwebsocket.middleware.WebSocketMiddleware' # 为所有的URL提供websocket,如果只是单独的视图需要可以不选

]
WEBSOCKET_ACCEPT_ALL=True # 可以允许每一个单独的视图实用websockets

第二种:装饰器单一视图使用

只需views.py文件中,将对应的视图函数添加装饰器

  • accept_websocket-—可以接受websocket请求和普通http请求
  • require_websocket—-只接受websocket请求,拒绝普通http请求

如果使用了require_websocket,但使用了普通http请求,则出现Bad Request的报错信息

补充相关方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
python复制代码相关方法函数说明
1.request.is_websocket()
如果是个websocket请求返回True,如果是个普通的http请求返回False,可以用这个方法区分它们。

2.request.websocket
在一个websocket请求建立之后,这个请求将会有一个websocket属性,用来给客户端提供一个简单的api通讯,如果request.is_websocket()是False,这个属性将是None。

3.WebSocket.wait()
返回一个客户端发送的信息,在客户端关闭连接之前他不会返回任何值,这种情况下,方法将返回None

4.WebSocket.read()
如果没有从客户端接收到新的消息,read方法会返回一个新的消息,如果没有,就不返回。这是一个替代wait的非阻塞方法

5.WebSocket.count_messages()
返回消息队列数量

6.WebSocket.has_messages()
如果有新消息返回True,否则返回False

7.WebSocket.send(message)
向客户端发送消息

8.WebSocket.__iter__()
websocket迭代器

案例演示1

视图函数使用装饰器的方式,浏览器客户端与服务端使用websocket通信,增加websocket停止以及重连功能

views.py

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
python复制代码from django.shortcuts import render
from dwebsocket import accept_websocket, require_websocket
import time, json


@accept_websocket
def test_websocket(request):
if request.is_websocket(): # 如果请求是websocket请求:
WebSocket = request.websocket
i = 0 # 设置发送至前端的次数
messages = {}

while True:
i += 1 # 递增次数 i
time.sleep(1) # 休眠1秒

# 判断是否通过websocket接收到数据
if WebSocket.has_messages():

# 存在Websocket客户端发送过来的消息
client_msg = WebSocket.read().decode()
# 设置发送前端的数据
messages = {
'time': time.strftime('%Y.%m.%d %H:%M:%S', time.localtime(time.time())),
'server_msg': 'send %d times!' % i,
'client_msg': client_msg,
}

else:
# 设置发送前端的数据
messages = {
'time':time.strftime('%Y.%m.%d %H:%M:%S',time.localtime(time.time())),
'server_msg': 'send %d times!' % i,
}

# 设置发送数据为json格式
request.websocket.send(json.dumps(messages))
else:
return render(request, 'test_websocket.html')

test_websocket.html

【坑】:注意发送ws请求时,url后面加上”/“

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
python复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
<script type="text/javascript">
$(function () {
// 点击连接websocket按钮,则启动访问websocket
$('#connect_websocket').click(function () {
// 点击连接websocket按钮,则启动访问websocket
$('#connect_websocket').click(function () {

if(window.s){
window.s.close()
}

// 设置websocket的服务端url
var s = new WebSocket("ws://" + window.location.host + "/test_websocket/");

// 打开连接websocket服务,连接成功则打印信息
s.onopen = function () {
console.log('WebSocket open');//成功连接上Websocket
};

// 接收服务端发送过来的数据,在浏览器上刷新
s.onmessage = function (e) {
console.log('message: ' + e.data);//打印出服务端返回过来的数据
$('#messagecontainer').prepend('<p>' + e.data + '</p>');
};

window.s = s;
});

// 点击发送消息按钮,则通过websocket发送数据至服务端
$('#send_message').click(function () {
if (!window.s) {
alert("Please connect server.");
} else {
window.s.send($('#message').val());//通过websocket发送数据
}
});

// 点击关闭websocket连接
$('#close_websocket').click(function () {
if (window.s) {
window.s.close();//关闭websocket
console.log('websocket is closed!');
}
});

});
});
</script>
</head>
<body>

<input type="text" id="message" value="Open websocket!" />
<button type="button" id="connect_websocket">连接websocket</button>
<button type="button" id="send_message">发送 message</button>
<button type="button" id="close_websocket">关闭websocket</button>
<h1>Received Messages</h1>
<div id="messagecontainer"></div>

</body>
</html>

urls.py

1
2
3
4
5
6
7
python复制代码from django.conf.urls import url

from app01 import views

urlpatterns = [
url(r'^test_websocket/', views2.test_websocket),
]

演示2:websocket连接成功后,服务端主动发消息

views.py

修改上述视图文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
python复制代码from django.shortcuts import render
from dwebsocket import accept_websocket, require_websocket
import time, json


@accept_websocket
def test_websocket(request):
if request.is_websocket(): # 如果请求是websocket请求:
WebSocket = request.websocket

time.sleep(3) # 建立websocket连接3s后发一个推送消息
messages = {'name': 'jack'}
WebSocket.send(json.dumps(messages))

else:
return render(request, 'app02-login.html')

dwebsocket的坑

背景:客户端无法自动断开websocket链接,导致django无法处理上一次遗留的websocket客户端从而引起的报错。

方法:调用window.beforunload,在浏览器

1
2
3
4
javascript复制代码    window.onbeforeunload = function () {
ws.close()
console.log(1);//在刷新页面或者关闭页面需要断开websocket
};

刷新,或者关闭页面的时候,自动关闭websocket链接

结语

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

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

本文转载自: 掘金

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

demo4:在Springboot框架上搭建访问mysql数

发表于 2021-11-30

经历了各种不可控与不可预知的磨难(包括但不限于idea闪退、navicat闪退、系统闪退、莫名其妙的bug无数、疯狂搜索各种注解是为什么无数、jdbcTemplate为什么失效了、@autowired为什么失效了,等等!),终于搞定了这个主题!!!!

本篇就来手把手教你搞定这个应用!


  1. 大致思路

首先,大致总结一下,如果你想要从零开始,利用springboot框架,写一个能访问数据库并进行crud操作的,能给RESTful风格的接口的应用,应该怎么做:

  1. 安装一个mysql环境,不会/不喜欢使用命令行的话,需要再装一个数据库软件,例如navicat或者mysqlworkbench,本文以navicat来说明
  2. 建数据库、数据表、可访问数据库的用户名+密码(最好不要直接给root权限)
  3. 推荐:装一个postman,方便请求接口查看结果
  4. 初始化一套springboot的框架,装上我们需要的插件依赖(后续会提到)
  5. 在application.properties文件中,可以配置上连接数据库需要用的信息,也可以配置上想要应用启动所用的端口号
  6. 写三个java类:controller、entity、service。
  • entity:用于写数据表对应的字段在java里面对应的对象,需要具有Bean的特征:getter、setter等;
  • service:用于写mysql语句与对应的jdbc请求
  • controller:用于调用service,制作对应的get/post接口
  1. 启动运行整个app
  1. 建mysql数据库

懂得怎么搞这玩意的可以跳过这一part~

另外,此处不讨论如何本地搭一个docker,并在容器里面安装mysql

2.1 下载一个适合你的电脑版本的mysql

参考教程:blog.csdn.net/baidu_26315…

2.2 下载一个mysql软件

免费:mysqlWorkbench
付费:navicat

2.3 建库、表、数据、用户

2.3.1 启动mysql

image.png

image.png

2.3.2 navicat连接数据库

image.png

2.3.3 新建数据库 spring_example

image.png

2.3.4 新建数据表 user

image.png

image.png

2.3.5 填充数据

image.png

2.3.6 新建用户,用于spring访问该数据库

选择新建用户
image.png

添加常规信息,设置用户的名称和密码
image.png

添加用户的对象权限
image.png

勾选所需要的数据库的权限
image.png

  1. 创建springboot工程

3.1 初始化一个springboot项目

利用官网生成一个springboot项目:start.spring.io/

所需要的依赖:

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
xml复制代码<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.3.13</version>
<scope>compile</scope>
</dependency>
</dependencies>

重点依赖说明:

  • lombok:是一个可以简化代码的库,可以帮助你不用自己去写getter()或者setter(),就能自动在调试编译的时候,生成Bean的相关方法。可以参考这篇文章来理解:# 整合Lombok简化接口对象代码
  • spring-boot-starter-web:用于创建springboot的web服务,可以提供REST风格相关api
  • mysql-connector-java和spring-boot-starter-jdbc:提供了mysql的JDBC连接方法,尤其是JdbcTemplate

然后,我们还需要配置这个工程项目和我们创建的数据库的连接信息:

image.png

1
2
3
4
5
ini复制代码# mysql connection settings
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/spring_example
spring.datasource.username=spring
spring.datasource.password=spring
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

3.2 新建相关的package和java class

image.png

正如本篇开头所提到的,按照规范来说,java代码应该分好层次,所以我们会建立以上所示的三个package,分别代表以下含义:

  • entity:用于写数据表对应的字段在java里面对应的对象,需要具有Bean的特征:getter、setter等;
  • service:用于写mysql语句与对应的jdbc请求
  • controller:用于调用service,制作对应的get/post接口

然后分别对应创建相应的class,下面我们会分别讲解:

3.2.1 user.class

首先,我们要创建一个对应了数据表字段内容的类,便于我们从数据库取得数据后,可以存放在实例化的对象中。

常规来说,我们需要声明了一堆private的变量,然后写一些公共的getter()、setter()方法,来实现一个标准的java bean,便于我们在其他方法中实例化这个类型的对象。

但是,下面的代码就说明了,我们可以轻轻松松的用lombok的方法,添加@Data注解,让自己不用再写一长串getter()、setter(),而让代码在编译的时候给我们自动生成这些东西。

1
2
3
4
5
6
7
8
9
10
java复制代码package shenling.example.springbootJDBC.entity;

import lombok.Data;

@Data
public class user {
private Integer id;
private String firstname;
private String lastname;
}

要注意的是,如果要让lombok生效,还需要让idea中下载并开启插件:lombok

image.png

3.2.2 userService.class

在service层中,我们提供针对这个表的JDBC连接服务。基本上下面代码的逻辑就是:写一个sql,用JdbcTemplate来进行数据库连接和请求。

特别指出:相比起常规的普通框架,这里面由于我们使用了JdbcTemplate这些方法,所以不用再考虑要做database.connection这些“连接数据库 - crud - 关闭连接”这些额外的操作了,让我们更为集中精力在业务逻辑的实现上。这也是springboot的一大特色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
java复制代码package shenling.example.springbootJDBC.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import shenling.example.springbootJDBC.entity.user;

import java.util.List;

@Service
@Component
public class userService {

@Autowired
private JdbcTemplate jdbcTemplate;

// 查询列表
public List<user> getList() {
String sql = "SELECT * FROM user";
List<user> result = jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(user.class));
return result;
}


// 新增
public int addUser(user newUser) {
String sql = "INSERT INTO user(id, firstname, lastname)values(?,?,?)";
return jdbcTemplate.update(sql, newUser.getId(), newUser.getFirstname(), newUser.getLastname());
}

// 更新
public int updateUser(user newUser) {
String sql = "update user set firstname = ?, lastname=? where id = ?";
return jdbcTemplate.update(sql, newUser.getFirstname(), newUser.getLastname(), newUser.getId());
}

// 删除
public int deleteUser(int id) {
String sql = "delete from user where id = ?";
return jdbcTemplate.update(sql, id);
}

}

要注意几个点:

  1. 我们引入了JdbcTemplate的对象,来使用相关的方法,对数据库进行sql查询请求,完成crud操作。这里可以参考学习这篇文章:www.jianshu.com/p/f0cbed671…
  2. 在springboot里面,只要用@Component注解过的class,就不用在调用的时候使用new进行实例化对象了,而是用@Autowired注解搞定。所以:
  • JdbcTemplate变量声明的前面添加@Autowired注解
  • 本userService类,要在controller那边通过@Autowired使用,所以这个类的前面需要添加@Component注解
  1. query的时候,一般来说,使用的是List<Map<T, P>>的方法来保存结果,query的第二个参数也是用的这种map类。但是根据一些文的讨论和推荐,更为建议使用RowMapper的方式保存数据结果,认为速度更快更优。
  • 相关讨论帖:www.oschina.net/question/28…
  • 关于JdbcTemplate和RowMapper的文章:blog.csdn.net/cwr45282953…

3.2.3 userController.class

在controller里面,便是要创建这些接口了。就可以参考前面的关于如何建接口的demo来理解。

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.web.bind.annotation.*;
import shenling.example.springbootJDBC.entity.response;
import shenling.example.springbootJDBC.entity.user;
import shenling.example.springbootJDBC.service.userService;

import java.util.List;

@RestController
@RequestMapping("/user")
public class userController {

@Autowired
private userService userService;

@Autowired
private response res;


@GetMapping("/list")
public @ResponseBody response getUserList(){
try {
List<user> result = userService.getList();

res.setResult(result);
res.setCode(10000);
res.setMsg("查询成功");

}
catch(DataAccessException e) {
e.printStackTrace();

String exceptionMsg = e.getRootCause().getMessage();
System.out.println("ERROR:" + exceptionMsg);
res.setCode(500);
res.setMsg(exceptionMsg);
}

return res;
}

@PostMapping("/add")
public @ResponseBody response addUser(user newUser) {
try {
Integer result = userService.addUser(newUser);

res.setResult(newUser);
res.setCode(10000);
res.setMsg("新增成功");

}
catch(DataAccessException e) {
e.printStackTrace();

String exceptionMsg = e.getRootCause().getMessage();
System.out.println("ERROR:" + exceptionMsg);
res.setCode(500);
res.setMsg(exceptionMsg);
}

return res;
}

@PostMapping("/update")
public @ResponseBody response updateUser(user newUser) {
try {
Integer result = userService.updateUser(newUser);

res.setResult(newUser);
res.setCode(10000);
res.setMsg("更新成功");

}
catch(DataAccessException e) {
e.printStackTrace();

String exceptionMsg = e.getRootCause().getMessage();
System.out.println("ERROR:" + exceptionMsg);
res.setCode(500);
res.setMsg(exceptionMsg);
}

return res;
}

@PostMapping("/delete")
public @ResponseBody response deleteUser(int id) {
try {
Integer result = userService.deleteUser(id);

res.setResult(id);
res.setCode(10000);
res.setMsg("删除成功");

}
catch(DataAccessException e) {
e.printStackTrace();

String exceptionMsg = e.getRootCause().getMessage();
System.out.println("ERROR:" + exceptionMsg);
res.setCode(500);
res.setMsg(exceptionMsg);
}

return res;
}
}

同样的,我们可以在这里面看到几个关键点:

  1. @RestController:要制作成接口,那么类前面就肯定要写上这个注解才行
  2. @RequestMapping("/user"):由于我们想要让接口访问的时候,第一段是user,然后才是后面的请求list、新增用户add等,所以要在类前面写上这个。当然如果不需要这一段跳转的话,可以不写。
  3. @Autowired:我们前面的userService那里用了@Component注解,所以这里就可以直接用autowired来进行实例化,而不用通过new了
  4. 这段代码中,可以看到,我们通过userService.getList()就可以拿到查询后的结果,并且可以通过抛出异常DataAccessException抓取到异常日志。但是为了规范化我们的输出结果为msg/code/result的样式,所以我们还需要建一个response的类来声明我们的结果,下一小节会进行说明。
  5. @ResponseBody:这个写在了方法那里,是为了让方法返回的结果是JSON样式输出的,具有一种格式化、标准化的作用,来实现非常典型的rest接口返回结果风格。

3.2.4 response.class

为了让我们的输出结果标准化,所以在entity的package中,我们再建了一个class,去保存我们的输出结果的样子:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码package shenling.example.springbootJDBC.entity;

import lombok.Data;
import org.springframework.stereotype.Component;

@Data
@Component
public class response {
private String msg;
private Integer code;
private Object result;
}

这里面我们可以看到:

  1. 用了@Data来实现一个Bean的风格的类
  2. 用了@Component来保证在controller中,可以通过autowired进行使用

3.3 创建运行这个应用的class

一般来说,通过官网初始化的springboot应用里面就包含了这个部分了:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码package shenling.example.springbootJDBC;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;


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

}

其中,@SpringBootApplication注解就能让这个应用运行的时候去找到我们的controller

  1. 运行并查看结果

4.1 默认的8080端口已经被占用的问题:

有时候,默认8080端口被占用,所以我们可以查看这个端口号的pid并杀掉这个进程
image.png

当然也可以在application.property中设置一个新的端口号,例如:

1
ini复制代码server.port=1234

4.2 控制台结果

运行成功后,控制台会显示当前web运行的端口号
image.png

4.3 用postman发起请求进行查看

当然,我们完全可以用浏览器直接访问get类型的接口,但是为了显得很专业+直观+美观,我们用postman这种接口管理工具

  1. 下载本地postman软件:www.postman.com/downloads/?…
  2. 发送请求并查看结果:

image.png

  1. 要点小结

1. 推荐使用lombok库,非常方便开发,减少你的代码行数,但是在build的时候需要忽略掉,因为编译会自动生成这个库的

image.png

2. 使用@autowired的时候要注意这个被声明的类,需要有@Component的注解

1
2
3
4
5
6
7
8
9
java复制代码@Component
public class FirstClass {
...
}

public class SecondClass {
@Autowired
FirstClass f;
}

3. 要知道controller、entity、service的区别,分别应该写什么内容(有些框架要求写的是dao层这些)

4. 推荐创建标准的接口输出模板,并抓到请求异常msg进行抛出

5. 在写接口的controller的类前面,一定要写上@RestController

1
2
3
4
java复制代码@RestController
public class controller {
...
}

6. 要注意阅读控制台的报错信息,对应去查bug

本文转载自: 掘金

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

【当心】一次日期格式转化的线上事故

发表于 2021-11-30

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

原创不易,望多多关注、多多点赞🙇‍👍

事故描述

公司的app客户端会上报一些用户数据到Java后台服务,其中有一个点击时间的字段。今天在巡查日志的时候,发现了大量该保存该字段是的error日志。

如下:

Data truncation: Incorrect datetime value: ‘53884-04-07 04:09:44’ for column ‘clickTime’ at row 1

伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码/**
* 根据日期格式DateTime转String
*/
public static String dateTimeMillisToString(long time, String pattern) {
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(time);
return (new SimpleDateFormat(pattern)).format(calendar.getTime());
}

/**
* 保存客户端上报的用户数据
*/
public void save(User user) {
// 注意这里要将 时间*1000 转换成毫秒数
String time = dateTimeMillisToString(user.getClickTime() * 1000, "yyyy-MM-dd HH:mm:ss");
user.setCreateTime(time);
save(user);
}

猜想

根据异常日志和源代码,我们猜想可能是因为有些客户端没有按原定的以秒为单位来上报,而是使用的毫秒为单位。

为了验证猜想,决定写个main方法验证一下。

现场还原

1
2
3
4
5
6
java复制代码public static void main(String[] args) {
long time1 = 1638263956L;
long time2 = 1638263956000L;
System.out.println(dateTimeMillisToString(time1 * 1000, "yyyy-MM-dd HH:mm:ss"));
System.out.println(dateTimeMillisToString(time2 * 1000, "yyyy-MM-dd HH:mm:ss"));
}

结果和预想的一样,果然是因为毫秒的问题。

image.png

解决问题

1
2
3
4
5
6
7
8
9
10
java复制代码String time = user.getClickTime();
if (StringUtils.isNotBlank(time)) {
if (time.length() == 10) {
// 10位,表示该时间以秒为单位
time = dateTimeMillisToString(time * 1000, YYYYMMDD_HHMMSS);
} else if (time.length() == 13) {
// 13位,表示该时间以毫秒为单位
time = dateTimeMillisToString(time, YYYYMMDD_HHMMSS);
}
}

你以为这样就结束了吗?

修复好发生产后,却爆发了更多的异常,量级是原来的十多倍,我一下子慌了神,赶紧找运维大佬回滚版本。

这次的异常日志如下:

Data truncation: Incorrect datetime value: ‘0’ for column ‘clickTime’ at row 1

Data truncation: Incorrect datetime value: ‘1’ for column ‘clickTime’ at row 1

原来客户端还上报了数量庞大的 0 和 1。

当该字段长度不为10或13时,程序中是不做任何处理,直接插入到数据库的,数据库表结构中该字段为 datetime 类型的,所以当保存 0 或 1 时会报错。

那我们之前将 0 或 1, 转换后保存的究竟时什么呢? 再次通过 main 方法模拟一下:

1
2
3
4
5
6
java复制代码public static void main(String[] args) {
System.out.println(dateTimeMillisToString(0 * 1000, "yyyy-MM-dd HH:mm:ss"));
System.out.println(dateTimeMillisToString(1 * 1000, "yyyy-MM-dd HH:mm:ss"));
// 9位长度的时间戳
System.out.println(dateTimeMillisToString(163826395 * 1000, "yyyy-MM-dd HH:mm:ss"));
}

image.png

发现,原来 SimpleDateFormat 的 format() 方法会对所有数字类型都进行格式化,这一点大家一定要注意了。

这次是真的解决了

把代码继续兼容优化:

1
2
3
4
5
6
7
8
9
java复制代码String time = user.getClickTime();
if (StringUtils.isNotBlank(time)) {
// 大于10位长度,则不再将 时间*1000
if (time.length() > 10) {
time = dateTimeMillisToString(time, YYYYMMDD_HHMMSS);
} else {
time = dateTimeMillisToString(time * 1000, YYYYMMDD_HHMMSS);
}
}

同时和客户端的同事沟通,统一时间单位。

本文转载自: 掘金

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

如何分析和修改已签名JAR包 工欲善其事必先利其器 先掌握大

发表于 2021-11-30

本文仅作学习交流目的,提倡软件正版,反对盗版!


工欲善其事必先利其器

  • Java Decompiler 1.4.0,简称JD
  • InteliJ IDEA 2017,简称IDEA
  • dirtyJOE
  • Oracle JDK 1.8

先掌握大局

  1. 将已签名包转化为未签名包;
  2. 定位关键代码位置;
  3. 修改class文件字节码;
  4. 替换class文件重新打包;

付诸行动

1.将已签名包转化为未签名包

如果你用到的Jar文件使用了签名,它会保证里面的每个class文件不能被修改,所以即使你成功修改了class文件中的字节码,得到的Jar也是无法运行的。这些经过签名的Jar包的META-INF文件夹中一般包含了*.SF和相应的*.RSA文件。这些文件记录Jar包中每个文件的签名信息,以保证代码不被篡改。

使用下面的方法可以重新生成一个未签名Jar包。参考自stackoverflow作者Houtman的回答

1
2
3
4
5
arduino复制代码// 使用JDK编译代码
javac JarUnsigner.java

// 执行JarUnsigner,如果是同一个文件夹
java -cp . JarUnsigner <inJar> <outJar>

附上源代码,免得回答被删

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
Java复制代码// JarUnsigner.java
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Enumeration;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

public class JarUnsigner {

private static final String MANIFEST = "META-INF/MANIFEST.MF";

public static void main(String[] args){

if (args.length!=2){
System.out.println("Arguments: <infile.jar> <outfile.jar>");
System.exit(1);
}
String infile = args[0];
String outfile = args[1];
if ((new File(outfile)).exists()){
System.out.println("Output file already exists:" + outfile);
System.exit(1);
}
try{
ZipFile zipFile = new ZipFile(infile);
final ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(outfile));
for (Enumeration e = zipFile.entries(); e.hasMoreElements();) {
ZipEntry entryIn = (ZipEntry) e.nextElement();

if (! exclude_file( entryIn.getName() ) ) {

/* copy the entry as-is */
zos.putNextEntry( new ZipEntry( entryIn.getName() ));
InputStream is = zipFile.getInputStream(entryIn);
byte[] buf = new byte[1024];
int len;
while ((len = (is.read(buf))) > 0) {
zos.write(buf, 0, len);
}
zos.closeEntry();

} else {

if (MANIFEST.equals(entryIn.getName())){
/* if MANIFEST, adjust the entry */
zos.putNextEntry(new ZipEntry(MANIFEST));

// manifest entries until first empty line. i.e. the 'MainAttributes' section
// (this method is used so to keep the formatting exactly the same)
InputStream mIS = zipFile.getInputStream(entryIn);
BufferedReader in = new BufferedReader(new InputStreamReader(mIS));
String line = in.readLine();
byte[] mNL = "\n".getBytes("UTF-8");
while( line != null && !line.trim().isEmpty() ) {
zos.write( line.getBytes("UTF-8"));
zos.write( mNL );
line = in.readLine();
}
zos.write( mNL );
zos.closeEntry();

}else{
/* else: Leave out the Signature files */
}

}

}
zos.close();
System.out.println("Successfully unsigned " + outfile);

}catch(IOException ex){
System.err.println("Error for file: " + infile);
ex.printStackTrace();
System.exit(1);
}
}

/**
* Exclude .SF signature file
* Exclude .RSA and DSA (signed version of .SF file)
* Exclude SIG- files (unknown sign types for signed .SF file)
* Exclude Manifest file
* @param filename
* @return
*/
public static boolean exclude_file(String filename){
return filename.equals("META-INF/MANIFEST.MF") ||
filename.startsWith("META-INF/SIG-") ||
filename.startsWith("META-INF/") && ( filename.endsWith(".SF") || filename.endsWith(".RSA") || filename.endsWith(".DSA") );
}

}

  1. 定位代码关键位置

举一个需要输入序列号才能试用的库文件的例子,但是为了保护Jar包作者权益,对包名进行打码了。通过Jar包的说明书,可以知道如何使用它,就知道是什么地方输入序列号啦,要不然是个正常人也没法用对吧。例如

1
2
3
Java复制代码// 文档说这样子可以验证序列号
authentication.User("333");
authentication.Serial("94306-56191-128286-2967422");

详细步骤

  1. 使用JD反编译Jar包,然后Save All Sources,具体步骤这里省略;
  2. 从IDEA新建一个工程,把反编译的源代码放到源目录,Jar包可能是经过混淆过的,不过这个关系不大,我们字节码都能改,还怕看不懂混淆?继续往下走
  3. 右击authentication这个类文件,选择Find Usages,快捷键一般是Ctrl+G,看到有个叫做p.java的文件用到,做了一些判断操作Blabla,看屏幕截图1:

屏幕截图1

  1. 点开这个p.java类搜索到的地方,可以看到,它在比较一个返回结果,然后给出不同的错误提示,这个结果是由一个t.a(三个参数)的方法计算得到,并且看得出来当计算结果的a属性值为0时就是Licence OK!,看屏幕截图2:
    屏幕截图2
  2. 好,我们再来看t.java这个文件的a方法又有什么东东(按住Ctrl键,鼠标点击那个t.a),我们只要保证他得到的结果的a属性等于0就好了,可以看到只有一种情况下a属性才会等于0,其他时候都是等于-1的,到这一步IDEA的使命就完成了,看屏幕截图3:
    屏幕截图3

结果

通过上面5步(取决于不同的包,不一定都是5步),我们知道了需要改动t类里面的a方法,让它里面操作属性a时,值为-1的都改为0就能成功。

  1. 修改class文件字节码

接下来就要使用dirtyJOE软件来定位并修改字节码了,作者也试过Class Editor, Java Bytecode Editor都因年久失修,没改成功。通过查阅JVM文档我们知道给整数赋值有几种指令,这里就说两种:

  • iconst_<i> i可以为 m1,0,1,2,3,4,5,分别设置的值为-1,0,1,2,3,4,5,指令16进制字节码分别是02设置-1,03设置0,04设置1,以此类推
  • bipush <i> i范围可以是0-255,指令16进制字节码是10,比如代码里面有的21就是bipush 21,16字节码表示为10 15

let’s go

  1. 使用dirtyJOE打开t.class文件,切换到 Methods 页上,如下图所示:

image.png

  1. 双击我们需要修改的方法(同名时通过比对方法签名来区分),进入编辑界面,图中的两个指令的组合就是将值-1赋值给t$a.a属性,双击图中的iconst_ml,字节码是02,改为03,然后回车即可,可以使用Ctrl+F查找多处进行修改:

image.png

  1. 关闭编辑窗口,保存修改。

warning

要保证字节码的数量不增多,也不减少,因为类和类之间代码调用跟字节位置关系密切。不然会导致修改的class文件无法使用。原来一行是1个字节的,继续用1个字节,2个的就继续两个,3个的继续保持三个。

  1. 替换class文件重新打包

这是最简单的一步,将修改好的class文件,使用zip压缩工具替换掉即可。如果是你Windows用户不推荐使用WinRaR,可以将jar文件(第一步生成的未签名包)重命名为zip文件,然后选择用Windows资源管理器打开,将修改的class文件复制粘贴进去。

本文转载自: 掘金

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

kafka如何保证有序性

发表于 2021-11-30

kafka 中的每个 partition 中的消息在写入时都是有序的,而且单独一个 partition 只能由一个消费者去消费,可以在里面保证消息的顺序性。但是分区之间的消息是不保证有序的。

Kafka 保证一个 Partition 内的消息的有序性

Kafka 分布式的单位是 partition,同一个 partition 用一个 write ahead log 组织,所以可以保证 FIFO 的顺序。不同 partition 之间不能保证顺序。但是绝大多数用户都可以通过 message key 来定义,因为同一个 key 的 message 可以保证只发送到同一个 partition。

Kafka 中发送 1 条消息的时候,可以指定(topic, partition, key) 3 个参数。partiton 和 key 是可选的。如果你指定了 partition,那就是所有消息发往同 1个 partition,就是有序的。并且在消费端,Kafka 保证,1 个 partition 只能被1 个 consumer 消费。或者你指定 key( 比如 order id),具有同 1 个 key 的所有消息,会发往同 1 个 partition。但是消费者内部如果多线程就有问题,此时的解决方案是【使用内存队列处理,将key hash后分发到内存队列中,然后每个线程处理一个内存队列的数据。】

kafka 如何不消费重复数据?比如扣款,我们不能重复的扣。

其实还是得结合业务来思考,我这里给几个思路:

1比如你拿个数据要写库,你先根据主键查一下,如果这数据都有了,你就别插入了,update 一下好吧。

比如你是写 Redis,那没问题了,反正每次都是 set,天然幂等性。

比如你不是上面两个场景,那做的稍微复杂一点,你需要让生产者发送每条数据的时候,里面加一个全局唯一的 id,类似订单 id 之类的东西,然后你这里消费到了之后,先根据这个 id 去比如 Redis 里查一下,之前消费过吗?如果没有消费过,你就处理,然后这个 id 写 Redis。如果消费过了,那你就别处理了,保证别重复处理相同的消息即可。

比如基于数据库的唯一键来保证重复数据不会重复插入多条。因为有唯一键约束了,重复数据插入只会报错,不会导致数据库中出现脏数据。

Kafka 判断一个节点是否还活着有那两个条件?

(1)节点必须可以维护和 ZooKeeper 的连接,Zookeeper 通过心跳机制检查每个节点的连接

(2)如果节点是个 follower,他必须能及时的同步 leader 的写操作,延时不能太久

本文转载自: 掘金

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

1…101102103…956

开发者博客

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