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

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


  • 首页

  • 归档

  • 搜索

为数不多的人知道的AndroidStudio快捷键(二)

发表于 2019-12-18

这篇文章将会继续分享一些实用的快捷键,没有看过为数不多的人知道的AndroidStudio快捷键(一),可以点击下方连接前往

  • 为数不多的人知道的AndroidStudio快捷键(一)

软件环境:

  1. Mac/Win/Linux快捷键: Default
  2. AndroidStudio v3.5.0
  3. 不同的外接键盘使用下列快捷键,需要根据自己的键盘做相应的调整

显示当前类所有的方法

当我们在阅读别人的代码,或者在阅读源码的时候,想查看当前类都有那些方法、常量、变量等等,下面这个快捷键将会给你一个大局观,非常的实用

  • MAC: Cmd + Fn + F12
  • MAC外接机械键盘: Cmd + F12
  • Win/Linux: Ctrl + F12

高亮

当我们需要查看某个变量在那个地方调用了,这个快捷键会标识出所有使用这个变量的地方

  • Mac: Cmd + Shift + Fn + F7
  • MAC外接机械键盘: Cmd + Shift + F7
  • Win/Linux: Ctrl + Shift + F7

快速跳转到父类

当我们正在阅读代码,这时如果想跳转到父类,通常的做法回到类的开头点击进去,如果代码很长,这样做效率很低,下面的快捷键可以帮助你快速跳转到父类

  • Mac: Cmd + U
  • Win/Linux: Ctrl + U

快速跳转到实现

一个接口有多个实现,当我们点击接口的一个方法,想查看他在子类的不同实现,这个快捷键可以帮助你快速跳转到子类的实现

  • Mac: Cmd + Option + B
  • Win/Linux: Ctrl + Alt + B

覆写或者实现方法

这个快捷键就很简单了,弹出一个框让你自己去选择要重写或者复写某个方法

  • Mac: Control + O
  • Win/Linux: Ctrl + O

生成默认方法

如果我们想要快速生成类默认方法如构造方法、toString()、Setter()、Gettter()等等,可以使用下面的快捷键

  • Mac: Cmd + N
  • Win/Linux: Alt+Insert

汇总

为了方便查看将上面的快捷键和前面一篇文章 为数不多的人知道的AndroidStudio快捷键(一) 分享的快捷键做了一个总结

说明 快捷键
参数提示 Mac: Cmd + P win/Linux: Ctrl + P
自动提取方法 Mac: Cmd + Alt + M Win/Linux: Ctrl + Alt + M
自动提取参数 Mac: Cmd + Alt + P Win/Linux: Ctrl + Alt + P
自动生成变量 Mac: Cmd + Alt + V Win/Linux: Ctrl + Alt + V
搜索某个方法是否被调用 Mac/Win/Linux: Alt + F7
重构快捷键 Mac : Ctrl + T Win/Linux: Ctrl + Alt + Shift + T
包裹代码 Mac: Cmd + Alt + T Win/Linux: Ctrl + Alt + T
批量重命名 Mac/Win/Linux: shift + F6
查看方法调用层次结构 Mac: Control + Option + H Win/Linux: Ctrl + Alt + H
查看类的层次结构 Mac: Control + H Win/Linux: Ctrl + H
错误快捷提示 Mac/Win/Linux:Alt + Enter
快速跳转到父类 Mac: Cmd + U Win/Linux: Ctrl + U
快速跳转到实现 Mac: Cmd + Option + B Win/Linux: Ctrl + Alt + B
显示当前类所有的方法 MAC: Cmd + F12 Win/Linux: Ctrl + F12
删除行 Mac: Cmd + Delete Win/Linux: Ctrl + Y
自动补全 Mac/Win/Linux: Enter/Tab
覆写或者实现方法 Mac: Control + O Win/Linux: Ctrl + O
生成默认方法 Mac: Cmd + N Win/Linux: Alt+Insert

本文转载自: 掘金

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

《我们一起进大厂》系列-ConcurrentHashMap

发表于 2019-12-18

你知道的越多,你不知道的越多

点赞再看,养成习惯

本文 GitHub github.com/JavaFamily 已收录,有一线大厂面试点思维导图,也整理了很多我的文档,欢迎Star和完善,大家面试可以参照考点复习,希望我们一起有点东西。

回手掏

上次面试呀,我发现面试官对我的几个回答还是不够满意,觉得还是有点疑问,我就挑几个回答一下。

16是2的幂,8也是,32也是,为啥偏偏选了16?

我觉得就是一个经验值,定义16没有很特别的原因,只要是2次幂,其实用 8 和 32 都差不多。

用16只是因为作者认为16这个初始容量是能符合常用而已。

Hashmap中的链表大小超过八个时会自动转化为红黑树,当删除小于六时重新变为链表,为啥呢?

根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。

正文

一个婀娜多姿,穿着衬衣的小姐姐,拿着一个精致的小笔记本,径直走过来坐在我的面前。

就在我口水要都要流出来的时候,小姐姐的话语打断了我的YY。

喂小鬼,你养我啊!

呸呸呸,说错了,上次的HashMap回答得不错,最后因为天色太晚了面试草草收场,这次可得好好安排你。

诶,面试官上次是在抱歉,因为公司双十二要值班,实在是没办法,不过这次不会了,我推掉了所有的事情准备全身心投入到今天的面试中,甚至推掉了隔壁王大爷的约会邀约。

这样最好,上次我们最后聊到HashMap在多线程环境下存在线程安全问题,那你一般都是怎么处理这种情况的?

美丽迷人的面试官您好,一般在多线程的场景,我都会使用好几种不同的方式去代替:

  • 使用Collections.synchronizedMap(Map)创建线程安全的map集合;
  • Hashtable
  • ConcurrentHashMap

不过出于线程并发度的原因,我都会舍弃前两者使用最后的ConcurrentHashMap,他的性能和效率明显高于前两者。

哦,Collections.synchronizedMap是怎么实现线程安全的你有了解过么?

卧*!不按照套路出牌呀,正常不都是问HashMap和ConcurrentHashMap么,这次怎么问了这个鬼东西,还好我饱读诗书,经常看敖丙的《吊打面试官》系列,不然真的完了。

小姐姐您这个问题真好,别的面试官都没问过,说真的您水平肯定是顶级技术专家吧。

别贫嘴,快回答我的问题!抿嘴一笑😁

在SynchronizedMap内部维护了一个普通对象Map,还有排斥锁mutex,如图

1
java复制代码Collections.synchronizedMap(new HashMap<>(16));

我们在调用这个方法的时候就需要传入一个Map,可以看到有两个构造器,如果你传入了mutex参数,则将对象排斥锁赋值为传入的对象。

如果没有,则将对象排斥锁赋值为this,即调用synchronizedMap的对象,就是上面的Map。

创建出synchronizedMap之后,再操作map的时候,就会对方法上锁,如图全是🔐

卧*,小伙子,秒啊,其实我早就忘了源码了,就是瞎问一下,没想到还是回答上来了,接下来就面对疾风吧。

回答得不错,能跟我聊一下Hashtable么?

这个我就等着你问呢嘿嘿!

跟HashMap相比Hashtable是线程安全的,适合在多线程的情况下使用,但是效率可不太乐观。

哦,你能说说他效率低的原因么?

嗯嗯面试官,我看过他的源码,他在对数据操作的时候都会上锁,所以效率比较低下。

除了这个你还能说出一些Hashtable 跟HashMap不一样点么?

!呐呢?这叫什么问题嘛?这个又是知识盲区呀!

呃,面试官我从来没使用过他,你容我想想区别的点,说完便开始抓头发,这次不是装的,是真的!

Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null。

呃我能打断你一下么?为啥 Hashtable 是不允许 KEY 和 VALUE 为 null, 而 HashMap 则可以呢?

尼*,我这个时候怎么觉得面前的人不好看了,甚至像个魔鬼,看着对自己面试官心里想到。

因为Hashtable在我们put 空值的时候会直接抛空指针异常,但是HashMap却做了特殊处理。

1
2
3
4
java复制代码static final int hash(Object key) {  
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

但是你还是没说为啥Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null?

这是因为Hashtable使用的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据。

如果你使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap同理。

好的你继续说不同点吧。

  • 实现方式不同:Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。

Dictionary 是 JDK 1.0 添加的,貌似没人用过这个,我也没用过。

  • 初始化容量不同:HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75。
  • 扩容机制不同:当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。
  • 迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。

所以,当其他线程改变了HashMap 的结构,如:增加、删除元素,将会抛出ConcurrentModificationException 异常,而 Hashtable 则不会。

fail-fast是啥?

卧*,你自己不知道么?为啥问我!!!还好我会!

快速失败(fail—fast)是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。

他的原理是啥?

迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。

集合在被遍历期间如果内容发生变化,就会改变modCount的值。

每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。

Tip:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。

因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。

说说他的场景?

java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。

Tip:安全失败(fail—safe)大家也可以了解下,java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

嗯?这个小鬼这么有东西的嘛?居然把不同点几乎都说出来了,被人遗忘的Hashtable都能说得头头是道,看来不简单,不知道接下来的ConcurrentHashMap连环炮能不能顶得住了。

都说了他的并发度不够,性能很低,这个时候你都怎么处理的?

他来了他来了,他终于还是来了,等了这么久,就是等你问我这个点,你还是掉入了我的陷阱啊,我早有准备,在HashMap埋下他线程不安全的种子,就是为了在ConcurrentHashMap开花结果!

小姐姐:这样的场景,我们在开发过程中都是使用ConcurrentHashMap,他的并发的相比前两者好很多。

哦?那你跟我说说他的数据结构吧,以及为啥他并发度这么高?

ConcurrentHashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。

我先说一下他在1.7中的数据结构吧:

如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。

Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码static final class Segment<K,V> extends ReentrantLock implements Serializable {  

    private static final long serialVersionUID = 2249069246763182397L;

    // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
    transient volatile HashEntry<K,V>[] table;

    transient int count;
        // 记得快速失败(fail—fast)么?
    transient int modCount;
        // 大小
    transient int threshold;
        // 负载因子
    final float loadFactor;

}

HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。

volatile的特性是啥?

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
  • 禁止进行指令重排序。(实现有序性)
  • volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。

我就不大篇幅介绍了,多线程章节我会说到的,大家知道用了之后安全了就对了。

那你能说说他并发度高的原因么?

原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。

不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。

每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。

就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。

1
2
3
4
5
6
7
8
9
10
11
java复制代码public V put(K key, V value) {  
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();//这就是为啥他不可以put null值的原因
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          
         (segments, (j << SSHIFT) + SBASE)) == null) 
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

他先定位到Segment,然后再进行put操作。

我们看看他的put源代码,你就知道他是怎么做到线程安全的了,关键句子我注释了。

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
java复制代码        final V put(K key, int hash, V value, boolean onlyIfAbsent) {  
          // 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
 // 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                 // 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
               //释放锁
                unlock();
            }
            return oldValue;
        }

首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。

  1. 尝试自旋获取锁。
  2. 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。

那他get的逻辑呢?

get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。

由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。

你有没有发现1.7虽然可以支持每个Segment并发访问,但是还是存在一些问题?

是的,因为基本上还是数组加链表的方式,我们去查询的时候,还得遍历链表,会导致效率很低,这个跟jdk1.7的HashMap是存在的一样问题,所以他在jdk1.8完全优化了。

那你再跟我聊聊jdk1.8他的数据结构是怎么样子的呢?

其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。

同样的,你能跟我聊一下他值的存取操作么?以及是怎么保证线程安全的?

ConcurrentHashMap在进行put操作的还是比较复杂的,大致可以分为以下步骤:

  1. 根据 key 计算出 hashcode 。
  2. 判断是否需要进行初始化。
  3. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  5. 如果都不满足,则利用 synchronized 锁写入数据。
  6. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

你在上面提到CAS是什么?自旋又是什么?

CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。

CAS 操作的流程如下图所示,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。

这是一种乐观策略,认为并发操作并不总会发生。

还是不明白?那我再说明下,乐观锁在实际开发场景中非常常见,大家还是要去理解。

就比如我现在要修改数据库的一条数据,修改之前我先拿到他原来的值,然后在SQL里面还会加个判断,原来的值和我手上拿到的他的原来的值是否一样,一样我们就可以去修改了,不一样就证明被别的线程修改了你就return错误就好了。

SQL伪代码大概如下:

1
sql复制代码update a set value = newValue where value = #{oldValue}//oldValue就是我们执行前查询出来的值

CAS就一定能保证数据没被别的线程修改过么?

并不是的,比如很经典的ABA问题,CAS就无法判断了。

什么是ABA?

就是说来了一个线程把值改回了B,又来了一个线程把值又改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被人改过,其实很多场景如果只追求最后结果正确,这是没关系的。

但是实际过程中还是需要记录修改过程的,比如资金修改什么的,你每次修改的都应该有记录,方便回溯。

那怎么解决ABA问题?

用版本号去保证就好了,就比如说,我在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。

1
sql复制代码update a set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision} // 判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,但是版本号100%不一样

牛*,有点东西,除了版本号还有别的方法保证么?

其实有很多方式,比如时间戳也可以,查询的时候把时间戳一起查出来,对的上才修改并且更新值的时候一起修改更新时间,这样也能保证,方法很多但是跟版本号都是异曲同工之妙,看场景大家想怎么设计吧。

CAS性能很高,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反而多了synchronized?

synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。

针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。

所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的。

🐂,那我们回归正题,ConcurrentHashMap的get操作又是怎么样子的呢?

  • 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
  • 如果是红黑树那就按照树的方式获取值。
  • 就不满足那就按照链表的方式遍历获取值。

小结:1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。

总结

Hashtable&ConcurrentHashMap跟HashMap基本上就是一套连环组合,我在面试的时候经常能吹上很久,经常被面试官说:好了好了,我们继续下一个话题吧哈哈。

是的因为提到HashMap你肯定会聊到他的线程安全性这一点,那你总不能加锁一句话就搞定了吧,java的作者们也不想,所以人家写开发了对应的替代品,那就是线程安全的Hashtable&ConcurrentHashMap。

两者都有特点,但是线程安全场景还是后者用得多一点,原因我在文中已经大篇幅全方位的介绍了,这里就不再过多赘述了。

你们发现了面试就是一个个的坑,你说到啥面试官可能就怼到你啥,别问我为啥知道嘿嘿。

你知道不确定能不能为这场面试加分,但是不知道肯定是减分的,文中的快速失败(fail—fast)问到,那对应的安全失败(fail—safe)也是有可能知道的,我想读者很多都不知道吧,因为我问过很多仔哈哈。

还有提到CAS乐观锁,你要知道ABA,你要知道解决方案,因为在实际的开发场景真的不要太常用了,sync的锁升级你也要知道。

我没过多描述线程安全的太多东西,因为我都写了,以后更啥?对吧哈哈。

常见问题

  • 谈谈你理解的 Hashtable,讲讲其中的 get put 过程。ConcurrentHashMap同问。
  • 1.8 做了什么优化?
  • 线程安全怎么做的?
  • 不安全会导致哪些问题?
  • 如何解决?有没有线程安全的并发容器?
  • ConcurrentHashMap 是如何实现的?
  • ConcurrentHashMap并发度为啥好这么多?
  • 1.7、1.8 实现有何不同?为什么这么做?
  • CAS是啥?
  • ABA是啥?场景有哪些,怎么解决?
  • synchronized底层原理是啥?
  • synchronized锁升级策略
  • 快速失败(fail—fast)是啥,应用场景有哪些?安全失败(fail—safe)同问。
  • ……

加分项

在回答Hashtable和ConcurrentHashMap相关的面试题的时候,一定要知道他们是怎么保证线程安全的,那线程不安全一般都是发生在存取的过程中的,那get、put你肯定要知道。

HashMap是必问的那种,这两个经常会作为替补问题,不过也经常问,他们本身的机制其实都比较简单,特别是ConcurrentHashMap跟HashMap是很像的,只是是否线程安全这点不同。

提到线程安全那你就要知道相关的知识点了,比如说到CAS你一定要知道ABA的问题,提到synchronized那你要知道他的原理,他锁对象,方法、代码块,在底层是怎么实现的。

synchronized你还需要知道他的锁升级机制,以及他的兄弟ReentantLock,两者一个是jvm层面的一个是jdk层面的,还是有很大的区别的。

那提到他们两个你是不是又需要知道juc这个包下面的所有的常用类,以及他们的底层原理了?

那提到……

点关注,不迷路

好了各位,以上就是这篇文章的全部内容了,能看到这里的人呀,都是人才。

我后面会每周都更新几篇一线互联网大厂面试和常用技术栈相关的文章,非常感谢人才们能看到这里,如果这个文章写得还不错,觉得「敖丙」我有点东西的话 求点赞👍 求关注❤️ 求分享👥 对暖男我来说真的 非常有用!!!

白嫖不好,创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!

敖丙 | 文 【原创】

如果本篇博客有任何错误,请批评指教,不胜感激 !


文章每周持续更新,可以微信搜索「 三太子敖丙 」第一时间阅读和催更(比博客早一到两篇哟),本文 GitHub github.com/JavaFamily 已经收录,有一线大厂面试点思维导图,也整理了很多我的文档,欢迎Star和完善,大家面试可以参照考点复习,希望我们一起有点东西。

腹有诗书气自华

本文转载自: 掘金

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

三分钟带你使用Python通过OpenStack API接口

发表于 2019-12-17

开发背景:

由于在OpenStack上有过热迁移失败的记录,虚机重装挂载磁盘会报错,数据盘挂载不上,所以需要在OpenStack上找出所有有过热迁移并且迁移失败的虚机处理,以免后续重装挂载不上磁盘。

开发环境:

centos,Python 2.7.5,OpenStack

具体思路:

Shell筛选命令
nova list –all 找出所有虚机
nova instance-action-list 虚机ID | egrep -in ‘live-migration|error’ 筛选出具有相应动作的虚机。
使用shell脚本筛选,但是由于nova instance-action-list命令执行太慢,所以考虑使用Python通过OS的接口去找出这部分虚机。
通过OpenStack的API接口调用,找出所有虚机,并且查询每个虚机的所有动作,筛选出有live-migration动作并且message为Error的虚机就输出虚机名称和虚机ID。

关键步骤:

1、登录认证实现:OpenStack主要是通过keystoneauth组件来提供认证服务,我通过keystoneauth组件的keystoneauth1.session.Session 类初始化一个Session对象,此对象可以存储用户名密码和token等信息。
2、shell命令对应novaclient库的方法查询,主要通过OpenStack的官方文档:docs.openstack.org/python-nova… 去查询想要的方法以及方法具有的参数。

具体源代码

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
复制代码#!/usr/bin/python2
# -*- coding: utf-8 -*-
from novaclient import client
from keystoneauth1 import session
from keystoneauth1.identity import v3
import requests
#登录认证
auth = v3.Password(user_domain_name='Default',
username='******',
password='*********',
project_domain_name='Default',
project_name='admin',
auth_url='http://IP:35357/v3')
sess = session.Session(auth=auth)
nova = client.Client(2,session=sess)
#查询所有虚机
instances = nova.servers.list(search_opts={'all_tenants':'1'})
#循环
for instance in instances:
#查询每台虚机的动作
for i in nova.instance_action.list(instance.id):
#筛选出live-migration的动作并且结果是Error
if i.to_dict()['action']=='live-migration' and i.to_dict()['message']=='Error':
#输出虚机名称和虚机ID
print instance.name ,instance.id
break

总结

Python的novaclient库和keystoneauth1库,已经封装了具体的请求过程,基本只需要填好关键信息就可以直接调用API接口去获取虚机信息,主要遇到的问题就是OpenStack的API接口资料网上比较少,基本都是需要阅读官方文档。

希望大家多多支持,点赞分享转发一下

本文转载自: 掘金

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

链表反转(Java三种实现方式)

发表于 2019-12-17

链表这个数据结果经常遇见,这里提供一个链表反转的java代码实现,有三种算法,一种是递归的,俩种是非递归的。

首先为了方便测试,在博文最后贴上递归实现链表创建的代码,以供读者快速上手测试,提供的代码可以复制以后直接测试

先看看Node节点把

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码public class Node {
//链表用于存储值
private final int value;
//指向下一个节点 理解为Node next更加恰当
private Node node;

public Node(int value) {
this.value = value;
this.node = null;
}

public int getValue() {
return value;
}

public Node getNode() {
return node;
}

public void setNode(Node node) {
this.node = node;
}

}

看看链表反转递归方法

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public Node reverserLinkedList(Node node){
if (node.getNode() == null || node == null){
return node;
}
Node newdata = reverserLinkedList(node.getNode());
node.getNode().setNode(node);
node.setNode(null);
return newdata;
}
//这个递归,返回值只是为了控制返回的是最后一个节点
//然后通过递归通过栈的特性,这里就是让它可以从最后一个节点开始把自己的子节点的子节点改成自己
//自己的子节点改为null
  • 递归的实现总是这么的简单,代码简练就是递归的好处,而且逻辑易于处理,只要能够出找出一层的逻辑,然后找出特殊值和出口,一个递归就已经完成啦
  • 这里出口显然就是那个if,为的是找到最后一个节点,然后就可以开始往前递归反转,同时这个if可以排除参数只有一个节点,参数为null的情况。
  • 递归的栈累计到最高层的时候(递归本质是栈,每一次递归放入一个栈,如果这层运行结束,就会弹出,运行下一层),最后一个if结束以后, 开始反转, 反转的逻辑其实很简单, 吧当前节点的下一个节点指向自己,然后自己指向null

说完了递归的算法,也了解递归其实就是栈,现在就用相同的逻辑,只不过把递归变成循环,用java本身实现的Stack数据结构编写一个更加高效的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码public Node reverserLinkedList2(Node node){
Stack<Node> nodeStack = new Stack<>();
Node head = null;
//存入栈中,模拟递归开始的栈状态
while (node != null){
nodeStack.push(node);
node = node.getNode();
}
//特殊处理第一个栈顶元素(也就是反转前的最后一个元素,因为它位于最后,不需要反转,如果它参与下面的while,因为它的下一个节点为空,如果getNode(), 那么为空指针异常)
if ((!nodeStack.isEmpty())){
head = nodeStack.pop();
}
//排除以后就可以快乐的循环
while (!nodeStack.isEmpty()){
Node tempNode = nodeStack.pop();
tempNode.getNode().setNode(tempNode);
tempNode.setNode(null);
}
return head;
}

逻辑一目了然,备注上面的解释已经很清楚啦

还有一个循环写法更加简单,使用俩个指针,不需要栈结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码public Node reverserLinkedList3(Node node){
//指向空,可以想象成位于第一个节点之前
Node newNode = null;
//指向第一个节点
Node curNode = node;

//循环中,使用第三变量事先保存curNode的后面一个节点

while (curNode != null){
Node tempNode = curNode.getNode();
//把curNode反向往前指
curNode.setNode(newNode);
//newNode向后移动
newNode = curNode;
//curNode 向后移动
curNode = tempNode;
}

return newNode;
}

这个的思路就是 俩个指针,把一个链表分成俩个部分, newNode是已经反转部分,curNode是为反转部分,然后通过俩个指针的配合,不断的右移直到前部反转

现在贴其他代码部分啦,先贴链表构建的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码public class LinkedListCreator {
//构建函数
public Node createLinkedList(List<Integer> list){
if (list.isEmpty()){
return null;
}
Node headNode = new Node(list.get(0));
Node tempNode = createLinkedList(list.subList(1, list.size()));
headNode.setNode(tempNode);
return headNode;
}

//测试方便的打印函数
public void printList(Node node){
while (node != null){
System.out.print(node.getValue());
System.out.print(" ");
node = node.getNode();
}
System.out.println();
}
}

main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public static void main(String[] args) {
LinkedListCreator linkedListCreator = new LinkedListCreator();
Node node = linkedListCreator.createLinkedList(Arrays.asList(1, 2, 3, 4, 5));
Node node2 = linkedListCreator.createLinkedList(Arrays.asList(1, 2, 3, 4, 5));
Node node3 = linkedListCreator.createLinkedList(Arrays.asList(1, 2, 3, 4, 5));
LinkedListReverser linkedListReverser = new LinkedListReverser();

Node res = linkedListReverser.reverserLinkedList(node);
Node res2 = linkedListReverser.reverserLinkedList2(node2);
Node res3 = linkedListReverser.reverserLinkedList3(node3);

linkedListCreator.printList(res);
linkedListCreator.printList(res2);
linkedListCreator.printList(res3);
}

博文是作者原本在其他平台的,现迁移过来

本文转载自: 掘金

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

学Maven,这篇万余字的教程,真的够用了!

发表于 2019-12-16

1 Maven 介绍

1.1 为什么使用 Maven

由于 Java 的生态非常丰富,无论你想实现什么功能,都能找到对应的工具类,这些工具类都是以 jar 包的形式出现的,例如 Spring,SpringMVC、MyBatis、数据库驱动,等等,都是以 jar 包的形式出现的,jar 包之间会有关联,在使用一个依赖之前,还需要确定这个依赖所依赖的其他依赖,所以,当项目比较大的时候,依赖管理会变得非常麻烦臃肿,这是 Maven 解决的第一个问题。

Maven 还可以处理多模块项目。简单的项目,单模块分包处理即可,如果项目比较复杂,要做成多模块项目,例如一个电商项目有订单模块、会员模块、商品模块、支付模块…,一般来说,多模块项目,每一个模块无法独立运行,要多个模块合在一起,项目才可以运行,这个时候,借助 Maven 工具,可以实现项目的一键打包。

Maven 之前,我们更多的是使用 Ant 的项目构建工具,Ant 有一个特点,每次都得写,每次都写的差不多,配置也臃肿。所以,后来搞出来 Maven。Maven 就是最先进的版本构建工具吗?不是的,只不过,目前在 Java 领域 Maven 使用比较多。除了 Maven,还有 Gradle。

1.2 Maven 是什么

Maven 是一个项目管理工具,它包含了一个项目对象模型(Project Object Model),反映在配置中,就是一个 pom.xml 文件。是一组标准集合,一个项目的生命周期、一个依赖管理系统,另外还包括定义在项目生命周期阶段的插件(plugin)以及目标(goal)。

当我们使用 Maven 的使用,通过一个自定义的项目对象模型,pom.xml 来详细描述我们自己的项目。

Maven 中的有两大核心:

  • 依赖管理:对 jar 的统一管理(Maven 提供了一个 Maven 的中央仓库,mvnrepository.com/,当我们在项目中添加完… 会自动去中央仓库下载相关的依赖,并且解决依赖的依赖问题)
  • 项目构建:对项目进行编译、测试、打包、部署、上传到私服等
  1. Maven 安装

  • Maven 是 Java 项目,因此必须先安装 JDK。

下载 Maven:

  • 下载 Maven

下载地址:maven.apache.org/download.cg…

  • 解压并配置

配置,只需要配置环境变量即可:

首先配置 MAVEN_HOME:

然后配置环境变量:

  • 检验安装

如果使用了 IntelliJ IDEA ,可以不用去额外下载 Maven,直接使用 IDEA 中自带的 Maven 插件即可。IntelliJ IDEA 中自带的 Maven 插件在 \ideaIU-2019.2.4.win\plugins\maven\lib\maven3

  1. Maven 配置

实际上,没有特殊需求的话,安装好之后直接就可以用了。一般来说,还是需要稍微配置一下,比如中央仓库的问题。默认使用 Maven 自己的中央仓库,使用起来网速比较慢,这个时候,可以通过修改配置文件,将仓库改成国内的镜像仓库,国内仓库使用较多的是阿里巴巴的仓库。

3.1 仓库类型

仓库类型 说明
本地仓库 就是你自己电脑上的仓库,每个人电脑上都有一个仓库,默认位置在 当前用户名\.m2\repository
私服仓库 一般来说是公司内部搭建的 Maven 私服,处于局域网中,访问速度较快,这个仓库中存放的 jar 一般就是公司内部自己开发的 jar
中央仓库 有 Apache 团队来维护,包含了大部分的 jar,早期不包含 Oracle 数据库驱动,从 2019 年 8 月开始,包含了 Oracle 驱动

现在存在 3 个仓库,那么 jar 包如何查找呢?

3.2 本地仓库配置

本地仓库默认位置在 当前用户名\.m2\repository,这个位置可以自定义,但是不建议大家自定义这个地址,有几个原因:

  1. 虽然所有的本地的 jar 都放在这个仓库中,但是并不会占用很大的空间。
  2. 默认的位置比较隐蔽,不容易碰到

技术上来说,当然是可以自定义本地仓库位置的,在 conf/settings.xml 中自定义本地仓库位置:

3.3 远程镜像配置

由于默认的中央仓库下载较慢,因此,也可以将远程仓库地址改为阿里巴巴的仓库地址:

1
2
3
4
5
6
复制代码<mirror>
<id>nexus-aliyun</id>
<mirrorOf>central</mirrorOf>
<name>Nexus aliyun</name>
<url>http://maven.aliyun.com/nexus/content/groups/public</url>
</mirror>

这段配置,加在 settings.xml 中的 mirrors 节点中:

  1. Maven 常用命令

Maven 中有一些常见的命令,如果使用 Eclipse 需要手动敲命令,如果使用 IDEA 的话,可以不用命令,直接点点点就可以了。

常用命令 中文含义 说明
mvn clean 清理 这个命令可以用来清理已经编译好的文件
mvn compile 编译 将 Java 代码编译成 Class 文件
mvn test 测试 项目测试
mvn package 打包 根据用户的配置,将项目打成 jar 包或者 war 包
mvn install 安装 手动向本地仓库安装一个 jar
mvn deploy 上传 将 jar 上传到私服

这里需要注意的是,这些命令都不是独立运行的,它有一个顺序。举个简单例子:

我想将 jar 上传到私服,那么就要构建 jar,就需要执行 package 命令,要打包,当然也需要测试,那就要走 mvn test 命令,要测试就要先编译…..,因此,最终所有的命令都会执行一遍。不过,开发者也可以手动配置不执行某一个命令,这就是跳过。一般来是,除了测试,其他步骤都不建议跳过。

当然,如果开发者使用了 IDEA ,这些命令不用手动敲,点一下就行:

4.1 通过命令来构建项目

可以直接通过命令来构建一个 Maven 项目,不过在实际开发中,一般使用 Eclipse 或者 IDEA 就可以直接创建 Maven 项目了。

创建命令:

1
复制代码mvn archetype:generate -DgroupId=org.javaboy -DartifactId=firstapp -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false

看到如下提示,表示项目创建成功:

项目创建成功后,就两个文件:

说明对一个任何一个项目而言,最最核心的就是这两个。

pom.xml 中,则定义了所有的项目配置。

4.2 对项目进行打包

接下来,我们通过 mvn package 命令可以将刚刚创建的项目打成一个 jar 包。

在打包之前,需要配置 JDK 的版本至少为 7 以上,因此,我们还需要手动修改一下 pom.xml 文件,即添加如下配置:

添加完成后,执行打包命令,注意执行所有命令时,命令行要定位到 pom.xml 文件所在的目录,看到如下提示,表示项目打包成功。

4.3 将项目安装到本地仓库

如果需要将项目安装到本地仓库,可以直接执行 mvn install 命令,注意,mvn install 命令会包含上面的 mvn package 过程。

安装到本地仓库之后,这个时候,点开自己的本地仓库,就可以看到相关的 jar 了。

  1. IDEA 中使用 Maven

不同于 Eclipse,IDEA 安装完成后,就可以直接使用 Maven 了。

5.1 Maven 相关配置

IDEA 中,Maven 的配置在 File->Settings->Build,Execution,Deployment->Build Tools->Maven:

5.2 JavaSE 工程创建

首先在创建一个工程时,选择 Maven 工程:

如果勾选上 Create from archetype ,则表示可以根据一个项目骨架(项目模板)来创建一个新的工程,不过,如果只是创建 JavaSE 项目,则不用选择项目骨架。直接 Next 即可。然后填入项目的坐标,即 groupId 和 artifactId。

填完之后,直接 Next 即可。这样,我们就会获取一个 JavaSE 工程,项目结构和你用命令创建出来的项目一模一样。

5.3 JavaWeb 工程创建

在 IDEA 中,创建 Maven Web 项目,有两种思路:

  • 首先创建一个 JavaSE 项目,然后手动将 JavaSE 项目改造成一个 JavaWeb 项目
  • 创建项目时选择项目骨架,骨架就选择 webapp

两种方式中,推荐使用第一种方式。

5.3.1 改造 JavaSE 项目

这种方式,首先创建一个 JavaSE 项目,创建步骤和上面的一致。

项目创建完成后,首先修改 pom.xml ,配置项目的打包格式为 war 包。 这样,IDEA 就知道当前项目是一个 Web 项目:

然后,选中 JavaSE 工程,右键单击,选择 Open Module Settings,或者直接按 F4,然后选择 Web,如下图:

接下来,在 webapp 目录中,添加 web.xml 文件。

注意,一定要修改 web.xml 文件位置:

配置完成后,点击 OK 退出。

项目创建完成后,接下来就是部署了。

部署,首先点击 IDEA 右上角的 Edit Configurations:

然后,配置 Tomcat:

接下来选择 Deployment 选项卡,配置要发布的项目:

最后,点击 IDEA 右上角的三角符号,启动项目。

5.3.2 通过 webapp 骨架直接创建

这种方式比较简单,基本上不需要额外的配置,项目创建完成后,就是一个 web 项目。只需要我们在创建项目时,选择 webapp 骨架即可。

选择骨架之后,后面的步骤和前文一致。

项目创建成功后,只有 webapp 目录,这个时候,自己手动创建 java 和 resources 目录,创建完成后,右键单击,选择 Mark Directory As,将 java 目录标记为 sources root,将 resources 目录标记为 resources root 即可。

凡是在 IDEA 右下角看到了 Enable Auto Import 按钮,一定点一下

  1. Maven 依赖管理

Maven 项目,如果需要使用第三方的控件,都是通过依赖管理来完成的。这里用到的一个东西就是 pom.xml 文件,概念叫做项目对象模型(POM,Project Object Model),我们在 pom.xml 中定义了 Maven 项目的形式,所以,pom.xml 相当于是 Maven 项目的一个地图。就类似于 web.xml 文件用来描述三大 web 组件一样。

这个地图中都涉及到哪些东西呢?

6.1 Maven 坐标

1
2
3
4
5
6
7
8
复制代码<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>test</scope>
</dependency>
</dependencies>
  • dependencies

在 dependencies 标签中,添加项目需要的 jar 所对应的 maven 坐标。

  • dependency

一个 dependency 标签表示一个坐标

  • groupId

团体、公司、组织机构等等的唯一标识。团体标识的约定是它以创建这个项目的组织名称的逆向域名(例如 org.javaboy)开头。一个 Maven 坐标必须要包含 groupId。一些典型的 groupId 如 apache 的 groupId 是 org.apache.

  • artifactId

artifactId 相当于在一个组织中项目的唯一标识符。

  • version

一个项目的版本。一个项目的话,可能会有多个版本。如果是正在开发的项目,我们可以给版本号加上一个 SNAPSHOT,表示这是一个快照版(新建项目的默认版本号就是快照版)

  • scope

表示依赖范围。

我们添加了很多依赖,但是不同依赖的使用范围是不一样的。最典型的有两个,一个是数据库驱动,另一个是单元测试。

数据库驱动,在使用的过程中,我们自己写代码,写的是 JDBC 代码,只有在项目运行时,才需要执行 MySQL 驱动中的代码。所以,MySQL 驱动这个依赖在添加到项目中之后,可以设置它的 scope 为 runtime,编译的时候不生效。

单元测试,只在测试的时候生效,所以可以设置它的 scope 为 test,这样,当项目打包发布时,单元测试的依赖就不会跟着发布。

6.2 依赖冲突

  • 依赖冲突产生的原因

在图中,a.jar 依赖 b.jar,同时 a.jar 依赖 d.jar,这个时候,a 和 b、d 的关系是直接依赖的关系,a 和 c 的关系是间接依赖的关系。

6.2.1 冲突解决

  1. 先定义先使用
  2. 路径最近原则(直接声明使用)

以 spring-context 为例,下图中 x 表示失效的依赖(优先级低的依赖,即路径近的依赖优先使用):

上面这两条是默认行为。

我们也可以手动控制。手动控制主要是通过排除依赖来实现,如下:

1
2
3
4
5
6
7
8
9
10
11
复制代码<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.1.9.RELEASE</version>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
</exclusions>
</dependency>

这个表示从 spring-context 中排除 spring-core 依赖。

  1. Maven 私服

Maven 仓库管理也叫 Maven 私服或者代理仓库。使用 Maven 私服有两个目的:

  1. 私服是一个介于开发者和远程仓库之间的代理
  2. 私服可以用来部署公司自己的 jar

7.1 Nexus 介绍

Nexus 是一个强大的 Maven 仓库管理工具,使用 Nexus 可以方便的管理内部仓库同时简化外部仓库的访问。官网是:www.sonatype.com/

7.2 安装

  • 下载

下载地址:www.sonatype.com/download-os…

  • 解压

将下载下来的压缩包,拷贝到一个没有中文的路径下,然后解压。

  • 启动

解压之后,打开 cmd 窗口(以管理员身份打开 cmd 窗口),然后定位了 nexus 解压目录,执行 nexus.exe/run 命令启动服务。

这个启动稍微有点慢,大概有 1 两分钟的样子

启动成功后,浏览器输入 http://lcoalhost:8081 打开管理页面。

打开管理页面后,点击右上角上的登录按钮进行登录,默认的用户名/密码是 admin/admin123。当然,用户也可以点击设置按钮,手动配置其他用户。

点击 Repositories 可以查看仓库详细信息:

7.2.1 仓库类型

名称 说明
proxy 表示这个仓库是一个远程仓库的代理,最典型的就是代理 Maven 中央仓库
hosted 宿主仓库,公司自己开发的一些 jar 存放在宿主仓库中,以及一些在 Maven 中央仓库上没有的 jar
group 仓库组,包含代理仓库和宿主仓库
virtual 虚拟仓库

7.2.2 上传 jar

上传 jar,配置两个地方:

  • Maven 的 conf/settings.xml 文件配置:
1
2
3
4
5
6
7
8
9
10
复制代码<server>
<id>releases</id>
<username>admin</username>
<password>admin123</password>
</server>
<server>
<id>snapshots</id>
<username>admin</username>
<password>admin123</password>
</server>

在要上传 jar 的项目的 pom.xml 文件中,配置上传路径:

1
2
3
4
5
6
7
8
9
10
复制代码<distributionManagement>
<repository>
<id>releases</id>
<url>http://localhost:8081/repository/maven-releases/</url>
</repository>
<snapshotRepository>
<id>snapshots</id>
<url>http://localhost:8081/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>

配置完成后,点击 deploy 按钮,或者执行 mvn deploy 命令就可以将 jar 上传到私服上。

7.2.3 下载私服上的 jar

直接在项目中添加依赖,添加完成后,额外增加私服地址即可:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码<repositories>
<repository>
<id>local-repository</id>
<url>http://localhost:8081/repository/maven-public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
  1. 聚合工程

所谓的聚合工程,实际上也就是多模块项目。在一个比较大的互联网项目中,项目需要拆分成多个模块进行开发,比如订单模块、VIP 模块、支付模块、内容管理模块、CMS、CRM 等等。这种拆分方式,实际上更接近于微服务的思想。在一个模块中,还可以继续进行拆分,例如分成 dao、service、controller 等。

有人可能会说,这个分包不就行了吗?

小项目当然可以分包,大项目就没法分包了。比如,在一个大的电商系统中,有一个子模块叫做用户管理、还有一个子模块叫做订单管理,这两个子模块都涉及到用户,像这种情况,我们就需要将用户类单独提取出来,做成单独的模块,供其他模块调用。

8.1 多模块项目展示

1
2
3
4
5
6
7
8
复制代码|--javaboy-parent
|-- javaboy-cms
|-- javaboy-crm
|-- javaboy-manger
|-- javaboy-manager-model
|-- javaboy-manager-dao
|-- javaboy-manager-service
|-- javaboy-manager-web

以 javaboy-manger 为例,javaboy-manager 本身并不提供功能,它只负责管理他自己的子模块,而他的子模块每一个都无法独立运行,需要四个结合在一起,才可以运行。项目打包时,model、dao、service 都将打包成 jar,然后会自动将打包好的 jar 复制到 web 中,再自动将 web 打包成 war 包。

8.2 IDEA 中创建聚合工程

1.创建一个空的 Maven 项目:

项目创建完成后,由于 parent 并不参与业务的实现,只是用来管理它的子模块,因此,src 目录可以将其删除。

2.选中当前工程,右键单击,New->Module

然后继续选择创建一个 Maven 项目:

在 IDEA 中,已经默认指明了当前 Module 的 parent,开发者只需要填入当前 Module 的 artifactId 即可:

javaboy-manager 创建完成后,此时,观察 javaboy-parent 的 pom.xml 文件,发现它自动加上了 packing 属性:

其中,它的 packaging 属性值为 pom,这表示它是一个聚合工程,同时,他还多了 modules 节点,指明了它自己的子模块。
同时,注意 javaboy-manager ,它自身多了一个 parent 节点,这个 parent 节点描述了它的父模块的属性值:

1
2
3
4
5
复制代码<parent>
<artifactId>javaboy-parent</artifactId>
<groupId>org.javaboy</groupId>
<version>1.0-SNAPSHOT</version>
</parent>

这个 parent 不仅仅是一个简单的父子关系描述,它存在继承关系,一般我们可以在 parent 中统一定义依赖或者插件的版本号

3.由于 javaboy-manager 本身也是一个聚合工程,因此,javaboy-manager 的 src 目录也可以删除。

4.选中 javaboy-manager,右键单击,New->Module 创建一个新的 Maven 模块出来。这个步骤类似于第二步,不在赘述。
这里,新的 javaboy-manager-model 创建成功后,我们手动配置它的 packaging 属性值为 jar。

5.依照第 4 步,再分别创建 javaboy-manager-service 以及 javaboy-manager-dao
6.继续创建 javaboy-manager-web 模块,不同于其他模块,web 模块需要打包成 war。web 模块创建可以参考【第五篇文章】。
7.web 工程创建完成后,完善模块之间的继承关系。

javaboy-manager-web 依赖 javaboy-manager-service
javaboy-manager-service 依赖 javaboy-manager-dao
javaboy-manager-dao 依赖 javaboy-manager-model

注意,依赖默认是有传递性的,即在 javaboy-manager-dao 中依赖了 javaboy-manager-model,在 javaboy-manager-service 也能访问到。

配置后的依赖关系如下图:

接下来就可以在不同的模块中写代码,然后进行项目部署了。部署方式参考【第五篇文章】

有一个需要注意的地方,在多模块项目中,web 项目打包需要注意以下问题:

  1. 不可以直接单独打包
  2. 如果要打包,有两种方式:
  • 第一种就是先手动挨个将 model、dao、service 安装到本地仓库
  • 从聚合工程处打包,即从 web 的parent 处打包。

关注微信公众号【江南一点雨】,回复 Maven,获取本文电子版,或者访问 maven.javaboy.org 查看本文电子书。

本文转载自: 掘金

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

推荐收藏 —— MySQL视图详细介绍

发表于 2019-12-16

前言:

在MySQL中,视图可能是我们最常用的数据库对象之一了。那么你知道视图和表的区别吗?你知道创建及使用视图要注意哪些点吗?可能很多人对视图只是一知半解,想详细了解视图的同学看过来哟,本篇文章会详细介绍视图的概念、创建及使用方法。

1.视图定义及简单介绍

视图是基于 SQL 语句的结果集的可视化的表,即视图是一个虚拟存在的表,可以包含表的全部或者部分记录,也可以由一个表或者多个表来创建。使用视图就可以不用看到数据表中的所有数据,而是只想得到所需的数据。当我们创建一个视图的时候,实际上是在数据库里执行了SELECT语句,SELECT语句包含了字段名称、函数、运算符,来给用户显示数据。

视图的数据是依赖原来表中的数据的,所以原来的表的数据发生了改变,那么显示的视图的数据也会跟着改变,例如向数据表中插入数据,那么在查看视图的时候,会发现视图中也被插入了同样的数据。视图实际上是由预定义的查询形式的表所组成的。

2.视图创建及使用方法

创建视图标准语法:

1
2
3
4
5
6
7
8
复制代码CREATE
[OR REPLACE]
[ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]
[DEFINER = user]
[SQL SECURITY { DEFINER | INVOKER }]
VIEW view_name [(column_list)]
AS select_statement
[WITH [CASCADED | LOCAL] CHECK OPTION]

语法解读:

1)OR REPLACE:表示替换已有视图,如果该视图不存在,则CREATE OR REPLACE VIEW与CREATE VIEW相同。

2)ALGORITHM:表示视图选择算法,默认算法是UNDEFINED(未定义的):MySQL自动选择要使用的算法 ;merge合并;temptable临时表,一般该参数不显式指定。

3)DEFINER:指出谁是视图的创建者或定义者,如果不指定该选项,则创建视图的用户就是定义者。

4)SQL SECURITY:SQL安全性,默认为DEFINER。

5)select_statement:表示select语句,可以从基表或其他视图中进行选择。

6)WITH CHECK OPTION:表示视图在更新时保证约束,默认是CASCADED。

其实我们日常创建视图时,无需指定每个参数,一般情况下,建议这样创建视图:

1
2
3
复制代码create view <视图名称> [(column_list)]
as select语句
with check option;

下面给出几个具体创建示例:

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
复制代码# 单表视图
mysql> create view v_F_players(编号,名字,性别,电话)
-> as
-> select PLAYERNO,NAME,SEX,PHONENO from PLAYERS
-> where SEX='F'
-> with check option;
Query OK, 0 rows affected (0.00 sec)

mysql> desc v_F_players;
+--------+----------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+--------+----------+------+-----+---------+-------+
| 编号 | int(11) | NO | | NULL | |
| 名字 | char(15) | NO | | NULL | |
| 性别 | char(1) | NO | | NULL | |
| 电话 | char(13) | YES | | NULL | |
+--------+----------+------+-----+---------+-------+
4 rows in set (0.00 sec)

mysql> select * from v_F_players;
+--------+-----------+--------+------------+
| 编号 | 名字 | 性别 | 电话 |
+--------+-----------+--------+------------+
| 8 | Newcastle | F | 070-458458 |
| 27 | Collins | F | 079-234857 |
| 28 | Collins | F | 010-659599 |
| 104 | Moorman | F | 079-987571 |
| 112 | Bailey | F | 010-548745 |
+--------+-----------+--------+------------+
5 rows in set (0.02 sec)

# 多表视图
mysql> create view v_match
-> as
-> select a.PLAYERNO,a.NAME,MATCHNO,WON,LOST,c.TEAMNO,c.DIVISION
-> from
-> PLAYERS a,MATCHES b,TEAMS c
-> where a.PLAYERNO=b.PLAYERNO and b.TEAMNO=c.TEAMNO;
Query OK, 0 rows affected (0.03 sec)

mysql> select * from v_match;
+----------+-----------+---------+-----+------+--------+----------+
| PLAYERNO | NAME | MATCHNO | WON | LOST | TEAMNO | DIVISION |
+----------+-----------+---------+-----+------+--------+----------+
| 6 | Parmenter | 1 | 3 | 1 | 1 | first |
| 44 | Baker | 4 | 3 | 2 | 1 | first |
| 83 | Hope | 5 | 0 | 3 | 1 | first |
| 112 | Bailey | 12 | 1 | 3 | 2 | second |
| 8 | Newcastle | 13 | 0 | 3 | 2 | second |
+----------+-----------+---------+-----+------+--------+----------+
5 rows in set (0.04 sec)

视图在使用时和基础表一样,比如我们可以使用 select from view_name 或 select from view_name where … ,视图可以将我们不需要的数据过滤掉,将相关的列名用我们自定义的列名替换。视图作为一个访问接口,不管基表的表结构和表名有多复杂。一般情况下视图只用于查询,视图本身没有数据,因此对视图进行的dml操作最终都体现在基表中,对视图进行delete、update、insert操作,原表同样会更新,drop视图原表不会变,视图不可以truncate。但是一般情况下我们要避免更新视图,dml操作可以直接对原表进行更新。

3.视图相关最佳实践

下面简单介绍下视图的优点,通过这些优点我们很容易总结出视图的适用场景。

1)简单:使用视图的用户完全不需要关心后面对应的表的结构、关联条件和筛选条件,对用户来说已经是过滤好的复合条件的结果集。

2)安全:使用视图的用户只能访问他们被允许查询的结果集,对表的权限管理并不能限制到某个行某个列,但是通过视图就可以简单的实现。

3)数据独立:一旦视图的结构确定了,可以屏蔽表结构变化对用户的影响,源表增加列对视图没有影响;源表修改列名,则可以通过修改视图来解决,不会造成对访问者的影响。

总而言之,使用视图的大部分情况是为了保障数据安全性,提高查询效率。比如说我们经常用到几个表的关联结果,那么我们就可以使用视图来处理,或者说第三方程序需要调用我们的业务库,可以按需创建视图给第三方程序查询。

对于日常使用及维护视图的过程中,个人总结出以下几点实践,可供参考:

  • 视图命名建议统一前缀,比如以v或view开头,便于识别。
  • SQL SECURITY使用默认的DEFINER,表示已视图定义者的权限去查询视图。
  • 视图定义者建议使用相关程序用户。
  • 视图不要关联太多的表,造成数据冗余。
  • 查询视图时要附带条件,不建议每次都查询出所有数据。
  • 视图迁移要注意在新环境有该视图的定义者用户。
  • 不要直接更新视图中的数据,视图只作查询。

总结:

视图在MySQL中经常会用到,本篇文章介绍了视图的概念以及创建方法,延伸而来,后续又讲述了视图的使用场景及优点。可能在使用时感觉不出视图和表的区别,其实这里面的门道还有很多,在这里建议视图只作查询使用,按照规范来,视图会带来很大的便捷。希望这篇文章对你有帮助。

gongzhonghao

本文转载自: 掘金

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

捅娄子了,写个bug被国家信息安全漏洞共享平台抓到了?

发表于 2019-12-16

quote

如果你觉得这篇沙雕文章还算有意思,可以给我一些实际点的支持哦! 求点赞👍 求关注❤️ 这些对于初来掘金的我来说真的蛮重要的!创作不易,熬夜辛苦,各位的支持和认可,就是我继续创作的最大动力。

我的 2019 年记录:
「合抱之木,生于毫末,百丈之台,起于垒土」记录我的 2019

摸不了鱼了

2019 年 11 月 26 日,本来应该是无比平静的一天,开开会,改改bug,摸摸鱼之后等着下班。刷着新闻的间隙,手机的消息提示音响了起来,收到了一条邮件,平时收到邮件我都会选择稍后处理模式继续摸鱼,但是看到邮件标题后,我感觉摸鱼是摸不得了,怕不是捅了什么篓子,邮件的标题是这样的:

什么东西?怎么了?我一看到“国家信息安全”几个大字,手里摸的鱼都抓不牢了,我当时真的被唬住了,赶紧点开邮件查看一下我到底做了什么,我真的不知道我干了什么,瞬间进入巨怂模式,屏气凝神不敢说话。

国家信息安全漏洞共享平台的邮件

点开邮件之后,内容如下:

主要文案为:

近日,国家信息安全漏洞共享平台(即中国国家漏洞库,CNVD)接收到报告,获知如下漏洞信息,CNVD-C-2019-195336 Newbee-mall v1.0.0存在SQL注入漏洞。该漏洞经测试,情况属实。现将漏洞情况通报,请贵单位协助做好漏洞分析和处置工作。

国家信息安全漏洞共享平台发现了一处程序漏洞,通知我赶紧处理。

看了邮件内容之后,心情缓和了很多,哎呀,我还以为你要抢我鸡蛋呢,原来是这个 bug,这个问题其实我早就知道了。虽然现在不是巨怂模式,但是依然有点怂,我很纳闷儿,当时主要有如下几个疑问:

  • 这个 bug 并不严重,而且已经修复了,现在这个邮件是什么意思?
  • 就是这么一个问题怎么就会被“国家信息安全漏洞共享平台”给捅出来了?
  • 这个“国家信息安全漏洞共享平台”是真的吗?怕不是要被骗了吧?

好的,带着这几个问题,摸鱼是摸不下去了,赶紧查一下吧,顺便也问问大家到底是怎么回事。

来龙去脉

很多朋友看到这里应该会很好奇,狗十三你到底写了一个什么 bug,还被国家信息安全漏洞共享平台给发现了?

莫慌莫慌,我们从头讲起,十三带着大家来捋一捋这之中的来龙去脉。

几个月之前,也就是 2019 年 9 月份的时候,我在 GitHub 开源平台上发布了一个开源项目 newbee-mall,newbee-mall 项目(新蜂商城)是一套电商系统,包括 newbee-mall 商城系统及 newbee-mall-admin 商城后台管理系统,基于 Spring Boot 2.X 及相关技术栈开发,这个开源项目也开源了好几个月,但是因为最近比较忙,我还没有介绍给大家,后续我会整理一些文章来详细地介绍一下这个 Spring Boot 开源商城项目。

以上是事件背景,由于是刚刚开源嘛,肯定还有很多小问题还没来得及修复,也因此有一些朋友给我提了一些 issue,其中有一条 issue 内容如下:

这是开源项目 newbee-mall 的第一条 issue,这位朋友给我提的意见是:项目里的部分 SQL 语句存在 SQL 注入的危险。好的,我看到这条 issue 之后就放在那里了,准备在空的时候给修复掉,大家能够从图中看出,这条 issue 是在 2019 年 10 月 20 号提给我的,我也在 10 月 23 号把这个问题给修复并且关掉了这条 issue,本以为是很小的一件事情,项目有 bug,修复嘛,对吧?

但是后面又出现了一些事情,一些意料之外的事情,并不是这封邮件,在这封邮件之前还有一件事情要和大家说一说。

CVE 国际安全漏洞库

来,继续。

刚刚说了“国家信息安全漏洞共享平台”,这是我们国内的漏洞库,在接到这封邮件之前呢,我发现 newbee-mall 项目的那个 SQL 注入问题已经出现在了 CVE 的官网,也就是国际安全漏洞库也收录了这条 SQL 注入漏洞。

被 CVE 收录这件事情我是怎么发现的呢?

做过开源项目的都知道,在仓库里我们是可以查看近期项目的访问来源的,也就是大家通过哪些渠道进入到我们的开源仓库这些都是可以追溯的,大致的页面如下:

分别是网站的 LOGO 以及网址,还有就是通过这个网址的访问统计,我偶尔也会看一些这个页面,关心一下项目的访问情况。某一天呢,我忽然发现在这些记录里有一条很陌生很陌生的记录,也就是 CVE 网站的访问记录,而且那几天,天天都有好几条统计记录,其实一开始我也不知道 CVE 是什么,我只是觉得这个 LOGO 和网址有些陌生,于是就点进去了。

好的,点进去就发现之前提到的那条 SQL 注入问题被收录到这个网站里了,阿西吧,内容如下:

很搞人心态的是什么呢?我压根儿没理这个东西,因为我觉得毕竟是一个小 bug,而且我都已经修掉了,你挂着就挂着吧,老子才不理你呢。对,狗十三就是这么傲娇。

至于当时的心态为什么那么傲娇,而收到国内漏洞库邮件的时候差点尿裤子呢?为什么反差如此之大?这一切的背后到底是道德的沦丧,还是人性的扭曲?

其实都不是,主要因为 CVE 是一个国外站点,我并不熟悉,我也根本没有在意,而且我真的觉得那个 SQL 注入的小 bug 应该不至于搞多大阵仗。

只是这封邮件之后我才想到,早在收到这封邮件的一个月之前呢,国际安全漏洞库也收录了这个漏洞,我还真的是丢人丢到国际上了,真的是有够好笑呢。

国家信息安全漏洞共享平台邮件的后续反馈

好的,咱们把视线拉回到 11 月 26 号,我在收到那封邮件之后就带着几个疑问,于是我就把这封邮件的一些内容发给了一些朋友,也发到了自己的 QQ 群里,主要是想了解一下我是不是很危险,又怂了。

折腾了一个多小时吧,也收到了大家很多的信息,最终得出结论,组织是真的,问题也不大,都已经修复了,就别再担心了。

终于松了一口气。

最终呢,我也给国家信息安全漏洞共享平台回了一封邮件,告诉他们问题已经修复,不用担心,同时也感谢他们的善意提醒。

事情到了这里,其实已经结束,整理这篇文章的目的也就是供大家摸鱼的时候有点内容可以看看,同时,也了解一下 CVE 和 CNVD 这两个组织,如果有什么问题或者想要了解的事情呢,大家也可以留言给我,大家一起讨论讨论。

写在最后

做个小推广,感兴趣的朋友可以看一看,最近我在掘金平台上发布了一本小册《Spring Boot 大型线上商城项目实战教程》(点击该链接或者点击下方图片购买可以优惠 8 折哦):

my-xiaoce
小册将围绕 Spring Boot 技术栈,使用的其它技术框架也会兼顾最新技术动向,对知识进行拓展,由浅入深,步步为营,在学习基础的同时也能够掌握一定的开发技巧,不仅仅只是学习 Spring Boot 的皮毛,也知晓它的源码设计和内部原理,不仅仅只是学习 Spring Boot 的相关技术栈整合,也能够使用 Spring Boot 技术栈搭建一个大型的商城系统,从而让你拥有一个高质量的学习进阶体验。远离 Hello World 项目,让你既能够得到一份完整的实操项目,也能够帮你点满目前炽手可热的 Spring Boot 技术栈,为你的技术深度和薪水职位的提升提供充足的保障。

这是一个商城的实战项目,部分页面预览图如下:

  • 首页

index-1

  • 订单列表

my-orders

感兴趣的朋友可以关注一下。

除注明转载/出处外,皆为作者原创,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接,否则保留追究法律责任的权利。

感谢大家的观看,我是十三,文章首发于我的公众号“程序员的小故事”。

本文转载自: 掘金

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

【搞定Jvm面试】 Java 内存区域揭秘附常见面试题解析

发表于 2019-12-15

本文已经收录自笔者开源的 JavaGuide: github.com/Snailclimb (【Java学习+面试指南】 一份涵盖大部分Java程序员所需要掌握的核心知识)如果觉得不错的还,不妨去点个Star,鼓励一下!

Java 内存区域详解

如果没有特殊说明,都是针对的是 HotSpot 虚拟机。

写在前面 (常见面试题)

基本问题

  • 介绍下 Java 内存区域(运行时数据区)
  • Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么)
  • 对象的访问定位的两种方式(句柄和直接指针两种方式)

拓展问题

  • String 类和常量池
  • 8 种基本类型的包装类和常量池

一 概述

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。

二 运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK. 1.8 和之前的版本略有不同,下面会介绍到。

JDK 1.8 之前:

JDK 1.8 :

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 堆
  • 方法区
  • 直接内存 (非运行时数据区的一部分)

2.1 程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

2.2 Java 虚拟机栈

与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)

局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。

Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

扩展:那么方法/函数如何调用?

Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。

Java 方法有两种返回方式:

  1. return 语句。
  2. 抛出异常。

不管哪种返回方式都会导致栈帧被弹出。

2.3 本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

2.4 堆

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

在 JDK 7 版本及JDK 7 版本之前,堆内存被通常被分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)

JVM堆内存结构-JDK7

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

JVM堆内存结构-JDK8

上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

修正(issue552):“Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值”。

动态年龄计算的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
> 复制代码uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
> //survivor_capacity是survivor空间的大小
> size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
> size_t total = 0;
> uint age = 1;
> while (age < table_size) {
> total += sizes[age];//sizes数组是每个年龄段对象大小
> if (total > desired_survivor_size) break;
> age++;
> }
> uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
> ...
> }
>
>
>

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的对内存大小有关!)
  3. ……

2.5 方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

方法区也被称为永久代。很多人都会分不清方法区和永久代的关系,为此我也查阅了文献。

2.5.1 方法区和永久代的关系

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

2.5.2 常用参数

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

1
2
复制代码-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

下面是一些常用参数:

1
2
复制代码-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

2.5.3 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

  1. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  2. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

2.6 运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

——图片来源:blog.csdn.net/wangbiao007…

2.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

三 HotSpot 虚拟机对象探秘

通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

3.1 对象的创建

下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。

Java创建对象的过程

Step1:类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

Step2:分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配的两种方式:(补充内容,需要掌握)

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的

内存分配的两种方式

内存分配并发问题(补充内容,需要掌握)

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

Step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

Step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

Step5:执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

3.2 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。

Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

3.3 对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:

  1. 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;

对象的访问定位-使用句柄

  1. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

对象的访问定位-直接指针

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

四 重点补充内容

4.1 String 类和常量池

String 对象的两种创建方式:

1
2
3
4
5
复制代码String str1 = "abcd";//先检查字符串常量池中有没有"abcd",如果字符串常量池中没有,则创建一个,然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向"abcd"";
String str2 = new String("abcd");//堆中创建一个新的对象
String str3 = new String("abcd");//堆中创建一个新的对象
System.out.println(str1==str2);//false
System.out.println(str2==str3);//false

这两种不同的创建方法是有差别的。

  • 第一种方式是在常量池中拿对象;
  • 第二种方式是直接在堆内存空间创建一个新的对象。

记住一点:只要使用 new 方法,便需要创建新的对象。

再给大家一个图应该更容易理解,图片来源:www.journaldev.com/797/what-is…:

String-Pool-Java

String 类型的常量池比较特殊。它的主要使用方法有两种:

  • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
  • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法。String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7之前(不包含1.7)的处理方式是在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7以及之后的处理方式是在常量池中记录此字符串的引用,并返回该引用。
1
2
3
4
5
6
复制代码	      String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println(s2);//计算机
System.out.println(s1 == s2);//false,因为一个是堆内存中的 String 对象一个是常量池中的 String 对象,
System.out.println(s3 == s2);//true,因为两个都是常量池中的 String 对象

字符串拼接:

1
2
3
4
5
6
7
8
9
复制代码		  String str1 = "str";
String str2 = "ing";

String str3 = "str" + "ing";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "string";//常量池中的对象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false

字符串拼接

尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。

4.2 String s1 = new String(“abc”);这句话创建了几个字符串对象?

将创建 1 或 2 个字符串。如果池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。如果池中没有字符串常量“abc”,那么它将首先在池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。

验证:

1
2
3
4
复制代码		String s1 = new String("abc");// 堆内存的地址值
String s2 = "abc";
System.out.println(s1 == s2);// 输出 false,因为一个是堆内存,一个是常量池的内存,故两者是不同的。
System.out.println(s1.equals(s2));// 输出 true

结果:

1
2
复制代码false
true

4.3 8 种基本类型的包装类和常量池

  • Java 基本类型的包装类的大部分都实现了常量池技术,即 Byte,Short,Integer,Long,Character,Boolean;这 5 种包装类默认创建了数值[-128,127] 的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。 为啥把缓存设置为[-128,127]区间?(参见issue/461)性能和资源之间的权衡。
  • 两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。
1
2
3
4
5
6
7
8
9
复制代码		Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出 true
Integer i11 = 333;
Integer i22 = 333;
System.out.println(i11 == i22);// 输出 false
Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出 false

Integer 缓存源代码:

1
2
3
4
5
6
7
8
复制代码/**
*此方法将始终缓存-128 到 127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
*/
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

应用场景:

  1. Integer i1=40;Java 在编译的时候会直接将代码封装成 Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
  2. Integer i1 = new Integer(40);这种情况下会创建新的对象。
1
2
3
复制代码  Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);//输出 false

Integer 比较更丰富的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码  Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);

System.out.println("i1=i2 " + (i1 == i2));
System.out.println("i1=i2+i3 " + (i1 == i2 + i3));
System.out.println("i1=i4 " + (i1 == i4));
System.out.println("i4=i5 " + (i4 == i5));
System.out.println("i4=i5+i6 " + (i4 == i5 + i6));
System.out.println("40=i5+i6 " + (40 == i5 + i6));

结果:

1
2
3
4
5
6
复制代码i1=i2   true
i1=i2+i3 true
i1=i4 false
i4=i5 false
i4=i5+i6 true
40=i5+i6 true

解释:

语句 i4 == i5 + i6,因为+这个操作符不适用于 Integer 对象,首先 i5 和 i6 进行自动拆箱操作,进行数值相加,即 i4 == 40。然后 Integer 对象无法与数值进行直接比较,所以 i4 自动拆箱转为 int 值 40,最终这条语句转为 40 == 40 进行数值比较。

参考

  • 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第二版》
  • 《实战 java 虚拟机》
  • docs.oracle.com/javase/spec…
  • www.pointsoftware.ch/en/under-th…
  • dzone.com/articles/jv…
  • stackoverflow.com/questions/9…
  • 深入解析String#interntech.meituan.com/2014/03/06/…

公众号

如果大家想要实时关注我更新的文章以及分享的干货的话,可以关注我的公众号。

《Java面试突击》: 由本文档衍生的专为面试而生的《Java面试突击》V2.0 PDF 版本公众号后台回复 “Java面试突击” 即可免费领取!

Java工程师必备学习资源: 一些Java工程师常用学习资源公众号后台回复关键字 “1” 即可免费无套路获取。

我的公众号

开源项目推荐

作者的其他开源项目推荐:

  1. JavaGuide:【Java学习+面试指南】 一份涵盖大部分Java程序员所需要掌握的核心知识。
  2. springboot-guide : 适合新手入门以及有经验的开发人员查阅的 Spring Boot 教程(业余时间维护中,欢迎一起维护)。
  3. programmer-advancement : 我觉得技术人员应该有的一些好习惯!
  4. spring-security-jwt-guide :从零入门 !Spring Security With JWT(含权限验证)后端部分代码。

公众号

安利一下阿里云双 12 的活动,1 核 2g 只要 89 一年,薅波羊毛,感觉甚爽,不过最低的优惠都是新人才能享有的,我是用我女朋友的账号买的,没有女朋友的,emm…..,可以考虑一下亲人的。

本文转载自: 掘金

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

为数不多的人知道的AndroidStudio快捷键(一)

发表于 2019-12-14

好的idea能提高我们的工作效率,如果掌握一些快捷键,能帮我们从繁琐重复的工作解放出来,写起代码来效率就越高,接下来想分享一些实用的快捷键, 如果已经看完了这篇,点击下方连接前往为数不多的人知道的AndroidStudio快捷键(二)

  • 为数不多的人知道的AndroidStudio快捷键(二)

软件环境:

  1. Mac/Win/Linux快捷键: Default
  2. AndroidStudio v3.5.0
  3. 不同的外接键盘使用下列快捷键,需要根据自己的键盘做相应的调整

参数提示

如果某个方法参数超级长,你不知道参数是什么怎么办?我们可以使用下面快捷键

  • Mac: Cmd + P
  • win/Linux: Ctrl + P

自动提取方法

我们在优化代码的时候,会看见有臭有长的代码,而我们传统的做法是新建一个函数,把代码复制过去,这个过程可以用使用下面快捷键完成

  • Mac: Cmd + Alt + M
  • Win/Linux: Ctrl + Alt + M

自动提取参数

我们在开发过程中,发现函数用到的变量最好是当作参数传递进来比较好,如果手动修改要在每个添加函数的地方修改繁琐至极,下面的快捷键可以解放你

  • Mac: Cmd + Alt + P
  • Win/Linux: Ctrl + Alt + P

当然如果只是想进行方法重载,在弹出的那个对话框里面打勾

自动生成变量

  • Mac: Cmd + Alt + V
  • Win/Linux: Ctrl + Alt + V

搜索某个方法是否被调用

我们在重构、优化或者阅读别人的代码,要知道某个方法被谁调用, 可以使用下面命令

Mac/Win/Linux: Alt + F7

重构快捷键

我们在重构的时候,很多操作都可以使用快捷键来完成,但是重构的快捷键太多了,我们没有办法全部记住,有一个快捷键可以帮助我们

  • Mac : Ctrl + T
  • Win/Linux: Ctrl + Alt + Shift + T

15763062112291032

包裹代码

  • Mac: Cmd + Alt + T
  • Win/Linux: Ctrl + Alt + T

批量重命名

如果在开发过程中或者优化过程中,发现变量名字取的不好,或者单词拼写错误,需要修改用到的所有变量,下面这个快捷键可以解放你的双手

Mac/Win/Linux: shift + F6

查看方法调用层次结构

有时候我们在阅读代码的时候,想知道一个方法的调用结构是怎么样的,下面的一个快捷键会帮助到你

  • Mac: Control + Option + H
  • Win/Linux: Ctrl + Alt + H

查看类的层次结构

在阅读源码的时候,想知道某个类的调用结构是怎么样的,可以使用下面的快捷键

  • Mac: Control + H
  • Win/Linux: Ctrl + H

错误快捷提示

在写代码的时候,可能会遇到idea给出的警告,下面的快捷键可以帮助你有效的解决警告

Mac/Win/Linux:Alt + Enter

汇总

为了方便查看将上面的快捷键做一个汇总, 因篇幅太长所以将文章分为两篇,点击前往为数不多的人知道的AndroidStudio快捷键(二)

说明 快捷键
参数提示 Mac: Cmd + P win/Linux: Ctrl + P
自动提取方法 Mac: Cmd + Alt + M Win/Linux: Ctrl + Alt + M
自动提取参数 Mac: Cmd + Alt + P Win/Linux: Ctrl + Alt + P
自动生成变量 Mac: Cmd + Alt + V Win/Linux: Ctrl + Alt + V
搜索某个方法是否被调用 Mac/Win/Linux: Alt + F7
重构快捷键 Mac : Ctrl + T Win/Linux: Ctrl + Alt + Shift + T
包裹代码 Mac: Cmd + Alt + T Win/Linux: Ctrl + Alt + T
批量重命名 Mac/Win/Linux: shift + F6
查看方法调用层次结构 Mac: Control + Option + H Win/Linux: Ctrl + Alt + H
查看类的层次结构 Mac: Control + H Win/Linux: Ctrl + H
错误快捷提示 Mac/Win/Linux:Alt + Enter

本文转载自: 掘金

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

学习 axios 源码整体架构,打造属于自己的请求库

发表于 2019-12-13
  1. 前言

你好,我是若川。这是学习源码整体架构系列第六篇。整体架构这词语好像有点大,姑且就算是源码整体结构吧,主要就是学习是代码整体结构,不深究其他不是主线的具体函数的实现。本篇文章学习的是实际仓库的代码。

学习源码整体架构系列文章如下:

1.学习 jQuery 源码整体架构,打造属于自己的 js 类库

2.学习 underscore 源码整体架构,打造属于自己的函数式编程类库

3.学习 lodash 源码整体架构,打造属于自己的函数式编程类库

4.学习 sentry 源码整体架构,打造属于自己的前端异常监控SDK

5.学习 vuex 源码整体架构,打造属于自己的状态管理库

6.学习 axios 源码整体架构,打造属于自己的请求库

7.学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理

8.学习 redux 源码整体架构,深入理解 redux 及其中间件原理

感兴趣的读者可以点击阅读。下一篇可能是vue-router源码。

本文比较长,手机上阅读,可以直接看文中的几张图即可。建议点赞或收藏后在电脑上阅读,按照文中调试方式自己调试或许更容易吸收消化。

导读

文章详细介绍了 axios 调试方法。详细介绍了 axios 构造函数,拦截器,取消等功能的实现。最后还对比了其他请求库。

本文学习的版本是v0.19.0。克隆的官方仓库的master分支。
截至目前(2019年12月14日),最新一次commit是2019-12-09 15:52 ZhaoXC dc4bc49673943e352,fix: fix ignore set withCredentials false (#2582)。

本文仓库在这里若川的 axios-analysis github 仓库。求个star呀。

如果你是求职者,项目写了运用了axios,面试官可能会问你:

1.为什么 axios 既可以当函数调用,也可以当对象使用,比如axios({})、axios.get。

2.简述 axios 调用流程。

3.有用过拦截器吗?原理是怎样的?

4.有使用axios的取消功能吗?是怎么实现的?

5.为什么支持浏览器中发送请求也支持node发送请求?

诸如这类问题。

  1. chrome 和 vscode 调试 axios 源码方法

前不久,笔者在知乎回答了一个问题一年内的前端看不懂前端框架源码怎么办?
推荐了一些资料,阅读量还不错,大家有兴趣可以看看。主要有四点:

1.借助调试

2.搜索查阅相关高赞文章

3.把不懂的地方记录下来,查阅相关文档

4.总结

看源码,调试很重要,所以笔者详细写下 axios 源码调试方法,帮助一些可能不知道如何调试的读者。

2.1 chrome 调试浏览器环境的 axios

调试方法

axios打包后有sourcemap文件。

1
2
3
4
5
6
7
bash复制代码# 可以克隆笔者的这个仓库代码
git clone https://github.com/lxchuan12/axios-analysis.git
cd axios-analaysis/axios
npm install
npm start
# open [http://localhost:3000](http://localhost:3000)
# chrome F12 source 控制面板 webpack// . lib 目录下,根据情况自行断点调试

本文就是通过上述的例子axios/sandbox/client.html来调试的。

顺便简单提下调试example的例子,虽然文章最开始时写了这部分,后来又删了,最后想想还是写下。

找到文件axios/examples/server.js,修改代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码server = http.createServer(function (req, res) {
var url = req.url;
// 调试 examples
console.log(url);
// Process axios itself
if (/axios\.min\.js$/.test(url)) {
// 原来的代码 是 axios.min.js
// pipeFileToResponse(res, '../dist/axios.min.js', 'text/javascript');
pipeFileToResponse(res, '../dist/axios.js', 'text/javascript');
return;
}
// 原来的代码 是 axios.min.map
// if (/axios\.min.map$/.test(url)) {
if (/axios\.map$/.test(url)) {
// 原来的代码 是 axios.min.map
// pipeFileToResponse(res, '../dist/axios.min.map', 'text/javascript');
pipeFileToResponse(res, '../dist/axios.map', 'text/javascript');
return;
}
}
1
2
3
4
5
bash复制代码# 上述安装好依赖后
# npm run examples 不能同时开启,默认都是3000端口
# 可以指定端口 5000
# npm run examples === node ./examples/server.js
node ./examples/server.js -p 5000

打开http://localhost:5000,然后就可以开心的在Chrome浏览器中调试examples里的例子了。

axios 是支持 node 环境发送请求的。接下来看如何用 vscode 调试 node 环境下的axios。

2.2 vscode 调试 node 环境的 axios

在根目录下 axios-analysis/
创建.vscode/launch.json文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
json复制代码{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/axios/sandbox/client.js",
"skipFiles": [
"<node_internals>/**"
]
},
]
}

按F5开始调试即可,按照自己的情况,单步跳过(F10)、单步调试(F11)断点调试。

其实开源项目一般都有贡献指南axios/CONTRIBUTING.md,笔者只是把这个指南的基础上修改为引用sourcemap的文件可调试。

  1. 先看 axios 结构是怎样的

1
2
3
4
bash复制代码git clone https://github.com/lxchuan12/axios-analysis.git
cd axios-analaysis/axios
npm install
npm start

按照上文说的调试方法, npm start 后,直接在 chrome 浏览器中调试。
打开 http://localhost:3000,在控制台打印出axios,估计很多人都没打印出来看过。

1
js复制代码console.log({axios: axios});

层层点开来看,axios 的结构是怎样的,先有一个大概印象。

笔者画了一张比较详细的图表示。

axios 结构关系图

看完结构图,如果看过jQuery、underscore和lodash源码,会发现其实跟axios源码设计类似。

jQuery 别名 $,underscore loadsh 别名 _ 也既是函数,也是对象。比如jQuery使用方式。$('#id'), $.ajax。

接下来看具体源码的实现。可以跟着断点调试一下。

断点调试要领:

赋值语句可以一步跳过,看返回值即可,后续详细再看。

函数执行需要断点跟着看,也可以结合注释和上下文倒推这个函数做了什么。

  1. axios 源码 初始化

看源码第一步,先看package.json。一般都会申明 main 主入口文件。

1
2
3
4
5
6
7
8
json复制代码// package.json
{
"name": "axios",
"version": "0.19.0",
"description": "Promise based HTTP client for the browser and node.js",
"main": "index.js",
// ...
}

主入口文件

1
2
js复制代码// index.js
module.exports = require('./lib/axios');

4.1 lib/axios.js主文件

axios.js文件 代码相对比较多。分为三部分展开叙述。

  1. 第一部分:引入一些工具函数utils、Axios构造函数、默认配置defaults等。
  2. 第二部分:是生成实例对象 axios、axios.Axios、axios.create等。
  3. 第三部分取消相关API实现,还有all、spread、导出等实现。

4.1.1 第一部分

引入一些工具函数utils、Axios构造函数、默认配置defaults等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码// 第一部分:
// lib/axios
// 严格模式
'use strict';
// 引入 utils 对象,有很多工具方法。
var utils = require('./utils');
// 引入 bind 方法
var bind = require('./helpers/bind');
// 核心构造函数 Axios
var Axios = require('./core/Axios');
// 合并配置方法
var mergeConfig = require('./core/mergeConfig');
// 引入默认配置
var defaults = require('./defaults');

4.1.2 第二部分

是生成实例对象 axios、axios.Axios、axios.create等。

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
js复制代码/**
* Create an instance of Axios
*
* @param {Object} defaultConfig The default config for the instance
* @return {Axios} A new instance of Axios
*/
function createInstance(defaultConfig) {
// new 一个 Axios 生成实例对象
var context = new Axios(defaultConfig);
// bind 返回一个新的 wrap 函数,
// 也就是为什么调用 axios 是调用 Axios.prototype.request 函数的原因
var instance = bind(Axios.prototype.request, context);
// Copy axios.prototype to instance
// 复制 Axios.prototype 到实例上。
// 也就是为什么 有 axios.get 等别名方法,
// 且调用的是 Axios.prototype.get 等别名方法。
utils.extend(instance, Axios.prototype, context);
// Copy context to instance
// 复制 context 到 intance 实例
// 也就是为什么默认配置 axios.defaults 和拦截器 axios.interceptors 可以使用的原因
// 其实是new Axios().defaults 和 new Axios().interceptors
utils.extend(instance, context);
// 最后返回实例对象,以上代码,在上文的图中都有体现。这时可以仔细看下上图。
return instance;
}

// Create the default instance to be exported
// 导出 创建默认实例
var axios = createInstance(defaults);
// Expose Axios class to allow class inheritance
// 暴露 Axios class 允许 class 继承 也就是可以 new axios.Axios()
// 但 axios 文档中 并没有提到这个,我们平时也用得少。
axios.Axios = Axios;

// Factory for creating new instances
// 工厂模式 创建新的实例 用户可以自定义一些参数
axios.create = function create(instanceConfig) {
return createInstance(mergeConfig(axios.defaults, instanceConfig));
};

这里简述下工厂模式。axios.create,也就是用户不需要知道内部是怎么实现的。

举个生活的例子,我们买手机,不需要知道手机是怎么做的,就是工厂模式。

看完第二部分,里面涉及几个工具函数,如bind、extend。接下来讲述这几个工具方法。

4.1.3 工具方法之 bind

axios/lib/helpers/bind.js

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码'use strict';
// 返回一个新的函数 wrap
module.exports = function bind(fn, thisArg) {
return function wrap() {
var args = new Array(arguments.length);
for (var i = 0; i < args.length; i++) {
args[i] = arguments[i];
}
// 把 argument 对象放在数组 args 里
return fn.apply(thisArg, args);
};
};

传递两个参数函数和thisArg指向。

把参数arguments生成数组,最后调用返回参数结构。

其实现在 apply 支持 arguments这样的类数组对象了,不需要手动转数组。

那么为啥作者要转数组,为了性能?当时不支持?抑或是作者不知道?这就不得而知了。有读者知道欢迎评论区告诉笔者呀。

关于apply、call和bind等不是很熟悉的读者,可以看笔者的另一个面试官问系列。

面试官问:能否模拟实现JS的bind方法

举个例子

1
2
3
4
5
js复制代码function fn(){
console.log.apply(console, arguments);
}
fn(1,2,3,4,5,6, '若川');
// 1 2 3 4 5 6 '若川'

4.1.4 工具方法之 utils.extend

axios/lib/utils.js

1
2
3
4
5
6
7
8
9
10
js复制代码function extend(a, b, thisArg) {
forEach(b, function assignValue(val, key) {
if (thisArg && typeof val === 'function') {
a[key] = bind(val, thisArg);
} else {
a[key] = val;
}
});
return a;
}

其实就是遍历参数 b 对象,复制到 a 对象上,如果是函数就是则用 bind 调用。

4.1.5 工具方法之 utils.forEach

axios/lib/utils.js

遍历数组和对象。设计模式称之为迭代器模式。很多源码都有类似这样的遍历函数。比如大家熟知的jQuery $.each。

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
js复制代码/**
* @param {Object|Array} obj The object to iterate
* @param {Function} fn The callback to invoke for each item
*/
function forEach(obj, fn) {
// Don't bother if no value provided
// 判断 null 和 undefined 直接返回
if (obj === null || typeof obj === 'undefined') {
return;
}

// Force an array if not already something iterable
// 如果不是对象,放在数组里。
if (typeof obj !== 'object') {
/*eslint no-param-reassign:0*/
obj = [obj];
}

// 是数组 则用for 循环,调用 fn 函数。参数类似 Array.prototype.forEach 的前三个参数。
if (isArray(obj)) {
// Iterate over array values
for (var i = 0, l = obj.length; i < l; i++) {
fn.call(null, obj[i], i, obj);
}
} else {
// Iterate over object keys
// 用 for in 遍历对象,但 for in 会遍历原型链上可遍历的属性。
// 所以用 hasOwnProperty 来过滤自身属性了。
// 其实也可以用Object.keys来遍历,它不遍历原型链上可遍历的属性。
for (var key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
fn.call(null, obj[key], key, obj);
}
}
}
}

如果对Object相关的API不熟悉,可以查看笔者之前写过的一篇文章。JavaScript 对象所有API解析

4.1.6 第三部分

取消相关API实现,还有all、spread、导出等实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码// Expose Cancel & CancelToken
// 导出 Cancel 和 CancelToken
axios.Cancel = require('./cancel/Cancel');
axios.CancelToken = require('./cancel/CancelToken');
axios.isCancel = require('./cancel/isCancel');

// Expose all/spread
// 导出 all 和 spread API
axios.all = function all(promises) {
return Promise.all(promises);
};
axios.spread = require('./helpers/spread');

module.exports = axios;

// Allow use of default import syntax in TypeScript
// 也就是可以以下方式引入
// import axios from 'axios';
module.exports.default = axios;

这里介绍下 spread,取消的API暂时不做分析,后文再详细分析。

假设你有这样的需求。

1
2
3
js复制代码function f(x, y, z) {}
var args = [1, 2, 3];
f.apply(null, args);

那么可以用spread方法。用法:

1
js复制代码axios.spread(function(x, y, z) {})([1, 2, 3]);

实现也比较简单。源码实现:

1
2
3
4
5
6
7
8
9
js复制代码/**
* @param {Function} callback
* @returns {Function}
*/
module.exports = function spread(callback) {
return function wrap(arr) {
return callback.apply(null, arr);
};
};

上文var context = new Axios(defaultConfig);,接下来介绍核心构造函数Axios。

4.2 核心构造函数 Axios

axios/lib/core/Axios.js

构造函数Axios。

1
2
3
4
5
6
7
8
9
js复制代码function Axios(instanceConfig) {
// 默认参数
this.defaults = instanceConfig;
// 拦截器 请求和响应拦截器
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
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
js复制代码Axios.prototype.request = function(config){
// 省略,这个是核心方法,后文结合例子详细描述
// code ...
var promise = Promise.resolve(config);
// code ...
return promise;
}
// 这是获取 Uri 的函数,这里省略
Axios.prototype.getUri = function(){}
// 提供一些请求方法的别名
// Provide aliases for supported request methods
// 遍历执行
// 也就是为啥我们可以 axios.get 等别名的方式调用,而且调用的是 Axios.prototype.request 方法
// 这个也在上面的 axios 结构图上有所体现。
utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
/*eslint func-names:0*/
Axios.prototype[method] = function(url, config) {
return this.request(utils.merge(config || {}, {
method: method,
url: url
}));
};
});

utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {
/*eslint func-names:0*/
Axios.prototype[method] = function(url, data, config) {
return this.request(utils.merge(config || {}, {
method: method,
url: url,
data: data
}));
};
});

module.exports = Axios;

接下来看拦截器部分。

4.3 拦截器管理构造函数 InterceptorManager

请求前拦截,和请求后拦截。

在Axios.prototype.request函数里使用,具体怎么实现的拦截的,后文配合例子详细讲述。

axios github 仓库 拦截器文档

如何使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
js复制代码// Add a request interceptor
// 添加请求前拦截器
axios.interceptors.request.use(function (config) {
// Do something before request is sent
return config;
}, function (error) {
// Do something with request error
return Promise.reject(error);
});

// Add a response interceptor
// 添加请求后拦截器
axios.interceptors.response.use(function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
}, function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
return Promise.reject(error);
});

如果想把拦截器,可以用eject方法。

1
2
js复制代码const myInterceptor = axios.interceptors.request.use(function () {/*...*/});
axios.interceptors.request.eject(myInterceptor);

拦截器也可以添加自定义的实例上。

1
2
js复制代码const instance = axios.create();
instance.interceptors.request.use(function () {/*...*/});

源码实现:

构造函数,handles 用于存储拦截器函数。

1
2
3
js复制代码function InterceptorManager() {
this.handlers = [];
}

接下来声明了三个方法:使用、移除、遍历。

4.3.1 InterceptorManager.prototype.use 使用

传递两个函数作为参数,数组中的一项存储的是{fulfilled: function(){}, rejected: function(){}}。返回数字 ID,用于移除拦截器。

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码/**
* @param {Function} fulfilled The function to handle `then` for a `Promise`
* @param {Function} rejected The function to handle `reject` for a `Promise`
*
* @return {Number} 返回ID 是为了用 eject 移除
*/
InterceptorManager.prototype.use = function use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1;
};

4.3.2 InterceptorManager.prototype.eject 移除

根据 use 返回的 ID 移除 拦截器。

1
2
3
4
5
6
7
8
js复制代码/**
* @param {Number} id The ID that was returned by `use`
*/
InterceptorManager.prototype.eject = function eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null;
}
};

有点类似定时器setTimeout 和 setInterval,返回值是id。用clearTimeout 和clearInterval来清除定时器。

1
2
3
4
5
6
7
js复制代码// 提一下 定时器回调函数是可以传参的,返回值 timer 是数字
var timer = setInterval((name) => {
console.log(name);
}, 1000, '若川');
console.log(timer); // 数字 ID
// 在控制台等会再输入执行这句,定时器就被清除了
clearInterval(timer);

4.3.3 InterceptorManager.prototype.forEach 遍历

遍历执行所有拦截器,传递一个回调函数(每一个拦截器函数作为参数)调用,被移除的一项是null,所以不会执行,也就达到了移除的效果。

1
2
3
4
5
6
7
8
9
10
js复制代码/**
* @param {Function} fn The function to call for each interceptor
*/
InterceptorManager.prototype.forEach = function forEach(fn) {
utils.forEach(this.handlers, function forEachHandler(h) {
if (h !== null) {
fn(h);
}
});
};
  1. 实例结合

上文叙述的调试时运行npm start 是用axios/sandbox/client.html路径的文件作为示例的,读者可以自行调试。

以下是一段这个文件中的代码。

1
2
3
4
5
6
7
js复制代码axios(options)
.then(function (res) {
response.innerHTML = JSON.stringify(res.data, null, 2);
})
.catch(function (res) {
response.innerHTML = JSON.stringify(res.data, null, 2);
});

5.1 先看调用栈流程

如果不想一步步调试,有个偷巧的方法。

知道 axios 使用了XMLHttpRequest。

可以在项目中搜索:new XMLHttpRequest。

定位到文件 axios/lib/adapters/xhr.js

在这条语句 var request = new XMLHttpRequest();

chrome 浏览器中 打个断点调试下,再根据调用栈来细看具体函数等实现。

Call Stack

1
2
3
4
5
6
7
bash复制代码dispatchXhrRequest (xhr.js:19)
xhrAdapter (xhr.js:12)
dispatchRequest (dispatchRequest.js:60)
Promise.then (async)
request (Axios.js:54)
wrap (bind.js:10)
submit.onclick ((index):138)

简述下流程:

  1. Send Request 按钮点击 submit.onclick
  2. 调用 axios 函数实际上是调用 Axios.prototype.request 函数,而这个函数使用 bind 返回的一个名为wrap的函数。
  3. 调用 Axios.prototype.request
  4. (有请求拦截器的情况下执行请求拦截器),中间会执行 dispatchRequest方法
  5. dispatchRequest 之后调用 adapter (xhrAdapter)
  6. 最后调用 Promise 中的函数dispatchXhrRequest,(有响应拦截器的情况下最后会再调用响应拦截器)

如果仔细看了文章开始的axios 结构关系图,其实对这个流程也有大概的了解。

接下来看 Axios.prototype.request 具体实现。

5.2 Axios.prototype.request 请求核心方法

这个函数是核心函数。
主要做了这几件事:

1.判断第一个参数是字符串,则设置 url,也就是支持axios('example/url', [, config]),也支持axios({})。

2.合并默认参数和用户传递的参数

3.设置请求的方法,默认是是get方法

4.将用户设置的请求和响应拦截器、发送请求的dispatchRequest组成Promise链,最后返回还是Promise实例。

也就是保证了请求前拦截器先执行,然后发送请求,再响应拦截器执行这样的顺序。

也就是为啥最后还是可以then,catch方法的缘故。

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复制代码Axios.prototype.request = function request(config) {
/*eslint no-param-reassign:0*/
// Allow for axios('example/url'[, config]) a la fetch API
// 这一段代码 其实就是 使 axios('example/url', [, config])
// config 参数可以省略
if (typeof config === 'string') {
config = arguments[1] || {};
config.url = arguments[0];
} else {
config = config || {};
}

// 合并默认参数和用户传递的参数
config = mergeConfig(this.defaults, config);

// Set config.method
// 设置 请求方法,默认 get 。
if (config.method) {
config.method = config.method.toLowerCase();
} else if (this.defaults.method) {
config.method = this.defaults.method.toLowerCase();
} else {
config.method = 'get';
}
// Hook up interceptors middleware
// 组成`Promise`链 这段拆开到后文再讲述
};

5.2.1 组成Promise链,返回Promise实例

这部分:用户设置的请求和响应拦截器、发送请求的dispatchRequest组成Promise链。也就是保证了请求前拦截器先执行,然后发送请求,再响应拦截器执行这样的顺序

也就是保证了请求前拦截器先执行,然后发送请求,再响应拦截器执行这样的顺序

也就是为啥最后还是可以then,catch方法的缘故。

如果读者对Promise不熟悉,建议读阮老师的书籍《ES6 标准入门》。
阮一峰老师 的 ES6 Promise-resolve 和 JavaScript Promise迷你书(中文版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
js复制代码  // 组成`Promise`链
// Hook up interceptors middleware
// 把 xhr 请求 的 dispatchRequest 和 undefined 放在一个数组里
var chain = [dispatchRequest, undefined];
// 创建 Promise 实例
var promise = Promise.resolve(config);

// 遍历用户设置的请求拦截器 放到数组的 chain 前面
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

// 遍历用户设置的响应拦截器 放到数组的 chain 后面
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});

// 遍历 chain 数组,直到遍历 chain.length 为 0
while (chain.length) {
// 两两对应移出来 放到 then 的两个参数里。
promise = promise.then(chain.shift(), chain.shift());
}

return promise;
1
js复制代码var promise = Promise.resolve(config);

解释下这句。作用是生成Promise实例。

1
2
3
4
5
6
7
js复制代码var promise = Promise.resolve({name: '若川'})
// 等价于
// new Promise(resolve => resolve({name: '若川'}))
promise.then(function (config){
console.log(config)
});
// {name: "若川"}

同样解释下后文会出现的Promise.reject(error);:

1
js复制代码Promise.reject(error);
1
2
3
4
5
6
7
8
9
10
11
12
js复制代码var promise = Promise.reject({name: '若川'})
// 等价于
// new Promise(reject => reject({name: '若川'}))

// promise.then(null, function (config){
// console.log(config)
// });
// 等价于
promise.catch(function (config){
console.log(config)
});
// {name: "若川"}

接下来结合例子,来理解这段代码。

很遗憾,在example文件夹没有拦截器的例子。笔者在example中在example/get的基础上添加了一个拦截器的示例。axios/examples/interceptors,便于读者调试。

1
bash复制代码node ./examples/server.js -p 5000

promise = promise.then(chain.shift(), chain.shift());这段代码打个断点。

会得到这样的这张图。
request方法中promise链

特别关注下,右侧,local中的chain数组。也就是这样的结构。

1
2
3
4
5
6
7
js复制代码var chain = [
'请求成功拦截2', '请求失败拦截2',
'请求成功拦截1', '请求失败拦截1',
dispatch, undefined,
'响应成功拦截1', '响应失败拦截1',
'响应成功拦截2', '响应失败拦截2',
]

这段代码相对比较绕。也就是会生成如下类似的代码,中间会调用dispatchRequest方法。

1
2
3
4
5
6
7
8
9
10
js复制代码// config 是 用户配置和默认配置合并的
var promise = Promise.resolve(config);
promise.then('请求成功拦截2', '请求失败拦截2')
.then('请求成功拦截1', '请求失败拦截1')
.then(dispatchRequest, undefined)
.then('响应成功拦截1', '响应失败拦截1')
.then('响应成功拦截2', '响应失败拦截2')

.then('用户写的业务处理函数')
.catch('用户写的报错业务处理函数');

这里提下promise then和catch知识:

Promise.prototype.then方法的第一个参数是resolved状态的回调函数,第二个参数(可选)是rejected状态的回调函数。所以是成对出现的。

Promise.prototype.catch方法是.then(null, rejection)或.then(undefined, rejection)的别名,用于指定发生错误时的回调函数。

then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。

结合上述的例子更详细一点,代码则是这样的。

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
js复制代码var promise = Promise.resolve(config);
// promise.then('请求成功拦截2', '请求失败拦截2')
promise.then(function requestSuccess2(config) {
console.log('------request------success------2');
return config;
}, function requestError2(error) {
console.log('------response------error------2');
return Promise.reject(error);
})

// .then('请求成功拦截1', '请求失败拦截1')
.then(function requestSuccess1(config) {
console.log('------request------success------1');
return config;
}, function requestError1(error) {
console.log('------response------error------1');
return Promise.reject(error);
})

// .then(dispatchRequest, undefined)
.then( function dispatchRequest(config) {
/**
* 适配器返回的也是Promise 实例
adapter = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {})
}
**/
return adapter(config).then(function onAdapterResolution(response) {
// 省略代码 ...
return response;
}, function onAdapterRejection(reason) {
// 省略代码 ...
return Promise.reject(reason);
});
}, undefined)

// .then('响应成功拦截1', '响应失败拦截1')
.then(function responseSuccess1(response) {
console.log('------response------success------1');
return response;
}, function responseError1(error) {
console.log('------response------error------1');
return Promise.reject(error);
})

// .then('响应成功拦截2', '响应失败拦截2')
.then(function responseSuccess2(response) {
console.log('------response------success------2');
return response;
}, function responseError2(error) {
console.log('------response------error------2');
return Promise.reject(error);
})

// .then('用户写的业务处理函数')
// .catch('用户写的报错业务处理函数');
.then(function (response) {
console.log('哈哈哈,终于获取到数据了', response);
})
.catch(function (err) {
console.log('哎呀,怎么报错了', err);
});

仔细看这段Promise链式调用,代码都类似。then方法最后返回的参数,就是下一个then方法第一个参数。

catch错误捕获,都返回Promise.reject(error),这是为了便于用户catch时能捕获到错误。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码var p1 = new Promise((resolve, reject) => {
reject(new Error({name: '若川'}));
});

p1.catch(err => {
console.log(res, 'err');
return Promise.reject(err)
})
.catch(err => {
console.log(err, 'err1');
})
.catch(err => {
console.log(err, 'err2');
});

err2不会捕获到,也就是不会执行,但如果都返回了return Promise.reject(err),则可以捕获到。

最后画个图总结下 Promise 链式调用。

axios promise 链式调用

小结:1. 请求和响应的拦截器可以写Promise。

  1. 如果设置了多个请求响应器,后设置的先执行。

  2. 如果设置了多个响应拦截器,先设置的先执行。

dispatchRequest(config) 这里的config是请求成功拦截器返回的。接下来看dispatchRequest函数。

5.3 dispatchRequest 最终派发请求

这个函数主要做了如下几件事情:

1.如果已经取消,则 throw 原因报错,使Promise走向rejected。

2.确保 config.header 存在。

3.利用用户设置的和默认的请求转换器转换数据。

4.拍平 config.header。

5.删除一些 config.header。

6.返回适配器adapter(Promise实例)执行后 then执行后的 Promise实例。返回结果传递给响应拦截器处理。

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
js复制代码'use strict';
// utils 工具函数
var utils = require('./../utils');
// 转换数据
var transformData = require('./transformData');
// 取消状态
var isCancel = require('../cancel/isCancel');
// 默认参数
var defaults = require('../defaults');

/**
* 抛出 错误原因,使`Promise`走向`rejected`
*/
function throwIfCancellationRequested(config) {
if (config.cancelToken) {
config.cancelToken.throwIfRequested();
}
}

/**
* Dispatch a request to the server using the configured adapter.
*
* @param {object} config The config that is to be used for the request
* @returns {Promise} The Promise to be fulfilled
*/
module.exports = function dispatchRequest(config) {
// 取消相关
throwIfCancellationRequested(config);

// Ensure headers exist
// 确保 headers 存在
config.headers = config.headers || {};

// Transform request data
// 转换请求的数据
config.data = transformData(
config.data,
config.headers,
config.transformRequest
);

// Flatten headers
// 拍平 headers
config.headers = utils.merge(
config.headers.common || {},
config.headers[config.method] || {},
config.headers || {}
);

// 以下这些方法 删除 headers
utils.forEach(
['delete', 'get', 'head', 'post', 'put', 'patch', 'common'],
function cleanHeaderConfig(method) {
delete config.headers[method];
}
);
// adapter 适配器部分 拆开 放在下文讲
};

5.3.1 dispatchRequest 之 transformData 转换数据

上文的代码里有个函数 transformData ,这里解释下。其实就是遍历传递的函数数组 对数据操作,最后返回数据。

axios.defaults.transformResponse 数组中默认就有一个函数,所以使用concat链接自定义的函数。

使用:

文件路径
axios/examples/transform-response/index.html

这段代码其实就是对时间格式的字符串转换成时间对象,可以直接调用getMonth等方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码var ISO_8601 = /(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})Z/;
function formatDate(d) {
return (d.getMonth() + 1) + '/' + d.getDate() + '/' + d.getFullYear();
}

axios.get('https://api.github.com/users/mzabriskie', {
transformResponse: axios.defaults.transformResponse.concat(function (data, headers) {
Object.keys(data).forEach(function (k) {
if (ISO_8601.test(data[k])) {
data[k] = new Date(Date.parse(data[k]));
}
});
return data;
})
})
.then(function (res) {
document.getElementById('created').innerHTML = formatDate(res.data.created_at);
});

源码:

就是遍历数组,调用数组里的传递 data 和 headers 参数调用函数。

1
2
3
4
5
6
7
8
js复制代码module.exports = function transformData(data, headers, fns) {
/*eslint no-param-reassign:0*/
utils.forEach(fns, function transform(fn) {
data = fn(data, headers);
});

return data;
};

5.3.2 dispatchRequest 之 adapter 适配器执行部分

适配器,在设计模式中称之为适配器模式。讲个生活中简单的例子,大家就容易理解。

我们常用以前手机耳机孔都是圆孔,而现在基本是耳机孔和充电接口合二为一。统一为typec。

这时我们需要需要一个typec转圆孔的转接口,这就是适配器。

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
js复制代码  // adapter 适配器部分
var adapter = config.adapter || defaults.adapter;

return adapter(config).then(function onAdapterResolution(response) {
throwIfCancellationRequested(config);

// Transform response data
// 转换响应的数据
response.data = transformData(
response.data,
response.headers,
config.transformResponse
);

return response;
}, function onAdapterRejection(reason) {
if (!isCancel(reason)) {
// 取消相关
throwIfCancellationRequested(config);

// Transform response data
// 转换响应的数据
if (reason && reason.response) {
reason.response.data = transformData(
reason.response.data,
reason.response.headers,
config.transformResponse
);
}
}

return Promise.reject(reason);
});

接下来看具体的 adapter。

5.4 adapter 适配器 真正发送请求

1
js复制代码var adapter = config.adapter || defaults.adapter;

看了上文的 adapter,可以知道支持用户自定义。比如可以通过微信小程序 wx.request 按照要求也写一个 adapter。

接着来看下 defaults.ddapter。

文件路径:axios/lib/defaults.js

根据当前环境引入,如果是浏览器环境引入xhr,是node环境则引入http。

类似判断node环境,也在sentry-javascript源码中有看到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码function getDefaultAdapter() {
var adapter;
// 根据 XMLHttpRequest 判断
if (typeof XMLHttpRequest !== 'undefined') {
// For browsers use XHR adapter
adapter = require('./adapters/xhr');
// 根据 process 判断
} else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
// For node use HTTP adapter
adapter = require('./adapters/http');
}
return adapter;
}
var defaults = {
adapter: getDefaultAdapter(),
// ...
};

xhr

接下来就是我们熟悉的 XMLHttpRequest 对象。

可能读者不了解可以参考XMLHttpRequest MDN 文档。

主要提醒下:onabort是请求取消事件,withCredentials是一个布尔值,用来指定跨域 Access-Control 请求是否应带有授权信息,如 cookie 或授权 header 头。

这块代码有删减,具体可以看若川的axios-analysis仓库,也可以克隆笔者的axios-analysis仓库调试时再具体分析。

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
js复制代码module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
// 这块代码有删减
var request = new XMLHttpRequest();
request.open()
request.timeout = config.timeout;
// 监听 state 改变
request.onreadystatechange = function handleLoad() {
if (!request || request.readyState !== 4) {
return;
}
// ...
}
// 取消
request.onabort = function(){};
// 错误
request.onerror = function(){};
// 超时
request.ontimeout = function(){};
// cookies 跨域携带 cookies 面试官常喜欢考这个
// 一个布尔值,用来指定跨域 Access-Control 请求是否应带有授权信息,如 cookie 或授权 header 头。
// Add withCredentials to request if needed
if (!utils.isUndefined(config.withCredentials)) {
request.withCredentials = !!config.withCredentials;
}

// 上传下载进度相关
// Handle progress if needed
if (typeof config.onDownloadProgress === 'function') {
request.addEventListener('progress', config.onDownloadProgress);
}

// Not all browsers support upload events
if (typeof config.onUploadProgress === 'function' && request.upload) {
request.upload.addEventListener('progress', config.onUploadProgress);
}

// Send the request
// 发送请求
request.send(requestData);
});
}

而实际上现在 fetch 支持的很好了,阿里开源的 umi-request 请求库,就是用fetch封装的,而不是用XMLHttpRequest。
文章末尾,大概讲述下 umi-request 和 axios 的区别。

http

http这里就不详细叙述了,感兴趣的读者可以自行查看,若川的axios-analysis仓库。

1
2
3
4
js复制代码module.exports = function httpAdapter(config) {
return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
});
};

上文 dispatchRequest 有取消模块,我觉得是重点,所以放在最后来细讲:

5.5 dispatchRequest 之 取消模块

可以使用cancel token取消请求。

axios cancel token API 是基于撤销的 promise 取消提议。

The axios cancel token API is based on the withdrawn cancelable promises proposal.

axios 文档 cancellation

文档上详细描述了两种使用方式。

很遗憾,在example文件夹也没有取消的例子。笔者在example中在example/get的基础上添加了一个取消的示例。axios/examples/cancel,便于读者调试。

1
bash复制代码node ./examples/server.js -p 5000

request中的拦截器和dispatch中的取消这两个模块相对复杂,可以多调试调试,吸收消化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/get/server', {
cancelToken: source.token
}).catch(function (err) {
if (axios.isCancel(err)) {
console.log('Request canceled', err.message);
} else {
// handle error
}
});

// cancel the request (the message parameter is optional)
// 取消函数。
source.cancel('哎呀,我被若川取消了');

5.5.1 取消请求模块代码示例

结合源码取消流程大概是这样的。这段放在代码在axios/examples/cancel-token/index.html。

参数的 config.cancelToken 是触发了source.cancel('哎呀,我被若川取消了');才生成的。

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
js复制代码// source.cancel('哎呀,我被若川取消了');
// 点击取消时才会 生成 cancelToken 实例对象。
// 点击取消后,会生成原因,看懂了这段在看之后的源码,可能就好理解了。
var config = {
name: '若川',
// 这里简化了
cancelToken: {
promise: new Promise(function(resolve){
resolve({ message: '哎呀,我被若川取消了'})
}),
reason: { message: '哎呀,我被若川取消了' }
},
};
// 取消 抛出异常方法
function throwIfCancellationRequested(config){
// 取消的情况下执行这句
if(config.cancelToken){
// 这里源代码 便于执行,我改成具体代码
// config.cancelToken.throwIfRequested();
// if (this.reason) {
// throw this.reason;
// }
if(config.cancelToken.reason){
throw config.cancelToken.reason;
}
}
}

function dispatchRequest(config){
// 有可能是执行到这里就取消了,所以抛出错误会被err2 捕获到
throwIfCancellationRequested(config);
// adapter xhr适配器
return new Promise((resovle, reject) => {
var request = new XMLHttpRequest();
console.log('request', request);
if (config.cancelToken) {
// Handle cancellation
config.cancelToken.promise.then(function onCanceled(cancel) {
if (!request) {
return;
}

request.abort();
reject(cancel);
// Clean up request
request = null;
});
}
})
.then(function(res){
// 有可能是执行到这里就才取消 取消的情况下执行这句
throwIfCancellationRequested(config);
console.log('res', res);
return res;
})
.catch(function(reason){
// 有可能是执行到这里就才取消 取消的情况下执行这句
throwIfCancellationRequested(config);
console.log('reason', reason);
return Promise.reject(reason);
});
}

var promise = Promise.resolve(config);

// 没设置拦截器的情况下是这样的
promise
.then(dispatchRequest, undefined)
// 用户定义的then 和 catch
.then(function(res){
console.log('res1', res);
return res;
})
.catch(function(err){
console.log('err2', err);
return Promise.reject(err);
});
// err2 {message: "哎呀,我被若川取消了"}

5.5.2 接下来看取消模块的源码

看如何通过生成config.cancelToken。

文件路径:

axios/lib/cancel/CancelToken.js

1
2
3
js复制代码const CancelToken = axios.CancelToken;
const source = CancelToken.source();
source.cancel('哎呀,我被若川取消了');

由示例看 CancelToken.source的实现,

1
2
3
4
5
6
7
8
9
10
11
js复制代码CancelToken.source = function source() {
var cancel;
var token = new CancelToken(function executor(c) {
cancel = c;
});
// token
return {
token: token,
cancel: cancel
};
};

执行后source的大概结构是这样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码{
token: {
promise: new Promise(function(resolve){
resolve({ message: '哎呀,我被若川取消了'})
}),
reason: { message: '哎呀,我被若川取消了' }
},
cancel: function cancel(message) {
if (token.reason) {
// Cancellation has already been requested
// 已经取消
return;
}
token.reason = {message: '哎呀,我被若川取消了'};
}
}

接着看 new CancelToken

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
js复制代码// CancelToken
// 通过 CancelToken 来取消请求操作
function CancelToken(executor) {
if (typeof executor !== 'function') {
throw new TypeError('executor must be a function.');
}

var resolvePromise;
this.promise = new Promise(function promiseExecutor(resolve) {
resolvePromise = resolve;
});

var token = this;
executor(function cancel(message) {
if (token.reason) {
// Cancellation has already been requested
// 已经取消
return;
}

token.reason = new Cancel(message);
resolvePromise(token.reason);
});
}

module.exports = CancelToken;

发送请求的适配器里是这样使用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码// xhr
if (config.cancelToken) {
// Handle cancellation
config.cancelToken.promise.then(function onCanceled(cancel) {
if (!request) {
return;
}

request.abort();
reject(cancel);
// Clean up request
request = null;
});
}

dispatchRequest 中的throwIfCancellationRequested具体实现:throw 抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码// 抛出异常函数
function throwIfCancellationRequested(config) {
if (config.cancelToken) {
config.cancelToken.throwIfRequested();
}
}
// 抛出异常 用户 { message: '哎呀,我被若川取消了' }
CancelToken.prototype.throwIfRequested = function throwIfRequested() {
if (this.reason) {
throw this.reason;
}
};

取消流程调用栈

1.source.cancel()

2.resolvePromise(token.reason);

3.config.cancelToken.promise.then(function onCanceled(cancel) {})

最后进入request.abort();``reject(cancel);

到这里取消的流程就介绍完毕了。主要就是通过传递配置参数cancelToken,取消时才会生成cancelToken,判断有,则抛出错误,使Promise 走向rejected,让用户捕获到消息{message: ‘用户设置的取消信息’}。

文章写到这里就基本到接近尾声了。

能读到最后,说明你已经超过很多人啦^_^

axios是非常优秀的请求库,但肯定也不能满足所有开发者的需求,接下来对比下其他库,看看其他开发者有什么具体需求。

  1. 对比其他请求库

6.1 KoAjax

FCC成都社区负责人水歌开源的KoAJAX。

如何用开源软件办一场技术大会?
以下这篇文章中摘抄的一段。

前端请求库 —— KoAJAX
国内前端同学最常用的 HTTP 请求库应该是 axios 了吧?虽然它的 Interceptor(拦截器)API 是 .use(),但和 Node.js 的 Express、Koa 等框架的中间件模式完全不同,相比 jQuery .ajaxPrefilter()、dataFilter() 并没什么实质改进;上传、下载进度比 jQuery.Deferred() 还简陋,只是两个专门的回调选项。所以,它还是要对特定的需求记忆特定的 API,不够简洁。

幸运的是,水歌在研究如何用 ES 2018 异步迭代器实现一个类 Koa 中间件引擎的过程中,做出了一个更有实际价值的上层应用 —— KoAJAX。它的整个执行过程基于 Koa 式的中间件,而且它自己就是一个中间件调用栈。除了 RESTful API 常用的 .get()、.post()、.put()、.delete() 等快捷方法外,开发者就只需记住 .use() 和 next(),其它都是 ES 标准语法和 TS 类型推导。

6.2 umi-request 阿里开源的请求库

umi-request github 仓库

umi-request 与 fetch, axios 异同。

umi-request 与 fetch, axios 异同

不得不说,umi-request 确实强大,有兴趣的读者可以阅读下其源码。

看懂axios的基础上,看懂umi-request源码应该不难。

比如 umi-request 取消模块代码几乎与axios一模一样。

  1. 总结

文章详细介绍了 axios 调试方法。详细介绍了 axios 构造函数,拦截器,取消等功能的实现。最后还对比了其他请求库。

最后画个图总结一下axios的总体大致流程。

axios的总体大致流程

解答下文章开头提的问题:

如果你是求职者,项目写了运用了axios,面试官可能会问你:

1.为什么 axios 既可以当函数调用,也可以当对象使用,比如axios({})、axios.get。

答:axios本质是函数,赋值了一些别名方法,比如get、post方法,可被调用,最终调用的还是Axios.prototype.request函数。

2.简述 axios 调用流程。

答:实际是调用的Axios.prototype.request方法,最终返回的是promise链式调用,实际请求是在dispatchRequest中派发的。

3.有用过拦截器吗?原理是怎样的?

答:用过,用axios.interceptors.request.use添加请求成功和失败拦截器函数,用axios.interceptors.response.use添加响应成功和失败拦截器函数。在Axios.prototype.request函数组成promise链式调用时,Interceptors.protype.forEach遍历请求和响应拦截器添加到真正发送请求dispatchRequest的两端,从而做到请求前拦截和响应后拦截。拦截器也支持用Interceptors.protype.eject方法移除。

4.有使用axios的取消功能吗?是怎么实现的?

答:用过,通过传递config配置cancelToken的形式,来取消的。判断有传cancelToken,在promise链式调用的dispatchRequest抛出错误,在adapter中request.abort()取消请求,使promise走向rejected,被用户捕获取消信息。

5.为什么支持浏览器中发送请求也支持node发送请求?

答:axios.defaults.adapter默认配置中根据环境判断是浏览器还是node环境,使用对应的适配器。适配器支持自定义。

回答面试官的问题,读者也可以根据自己的理解,组织语言,笔者的回答只是做一个参考。

axios 源码相对不多,打包后一千多行,比较容易看完,非常值得学习。

建议 clone 若川的 axios-analysis github 仓库,按照文中方法自己调试,印象更深刻。

基于Promise,构成Promise链,巧妙的设置请求拦截,发送请求,再试试响应拦截器。

request中的拦截器和dispatch中的取消这两个模块相对复杂,可以多调试调试,吸收消化。

axios 既是函数,是函数时调用的是Axios.prototype.request函数,又是对象,其上面有get、post等请求方法,最终也是调用Axios.prototype.request函数。

axios 源码中使用了挺多设计模式。比如工厂模式、迭代器模式、适配器模式等。如果想系统学习设计模式,一般比较推荐豆瓣评分9.1的JavaScript设计模式与开发实践

如果读者发现有不妥或可改善之处,再或者哪里没写明白的地方,欢迎评论指出。另外觉得写得不错,对您有些许帮助,可以点赞、评论、转发分享,也是对笔者的一种支持,非常感谢呀。

推荐阅读

官方axios github 仓库

写文章前,搜索了以下几篇文章泛读了一下。有兴趣在对比看看以下这几篇,有代码调试的基础上,看起来也快。

一直觉得多搜索几篇文章看,对自己学习知识更有用。有个词语叫主题阅读。大概意思就是一个主题一系列阅读。

@叫我小明呀:Axios 源码解析

@尼库尼库桑:深入浅出 axios 源码

@小贼先生_ronffy:Axios源码深度剖析 - AJAX新王者

逐行解析Axios源码

[译]axios 是如何封装 HTTP 请求的

知乎@Lee : TypeScript 重构 Axios 经验分享

笔者另一个系列

面试官问:JS的继承

面试官问:JS的this指向

面试官问:能否模拟实现JS的call和apply方法

面试官问:能否模拟实现JS的bind方法

面试官问:能否模拟实现JS的new操作符

关于

作者:常以若川为名混迹于江湖。前端路上 | PPT爱好者 | 所知甚少,唯善学。

若川的博客,使用vuepress重构了,阅读体验可能更好些

掘金专栏,欢迎关注~

segmentfault前端视野专栏,欢迎关注~

知乎前端视野专栏,欢迎关注~

github blog,相关源码和资源都放在这里,求个star^_^~

欢迎加微信交流 微信公众号

可能比较有趣的微信公众号,长按扫码关注。欢迎加笔者微信ruochuan12(注明来源,基本来者不拒),拉您进【前端视野交流群】,长期交流学习~

本文转载自: 掘金

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

1…841842843…956

开发者博客

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