java基础4:HashMap

要点

  • HashMap JDK1.7有哪些缺点,JDK1.8怎么改进的
  • Hashmap扩容为什么要是2的整数次幂
  • 手写HashMap的put流程图

上面几个问题是昆仑哥面试阿里的时候被问到的 所以这里我也围绕着三个问题去了解HashMap 文章后面会对这三个问题进行回答


HashMap关键字段

下面是一些HashMap初始化容量 扩容的阈值等重要字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 默认的初始化容量大小 必须是2的n次方
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
* 最大容量
*/
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
* 默认负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
* 当哈希表中一个槽的箱中装了8个元素 就将原来在这个箱中的链表转为红黑树
*/
static final int TREEIFY_THRESHOLD = 8;

/**
* 在resize过程中需要将箱子逆树化(红黑树->链表)的阈值?这个没看看到具体在源代码中在哪里使用到 所以不是很确定
*/
static final int UNTREEIFY_THRESHOLD = 6;

下面我们来看一下跟存储相关的字段

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
/**
* 整个哈希表 用数组来实现
*/
transient Node<K,V>[] table;

/**
* 存储着entrySet()的缓存 这里要注意keySet()和values()这两个重要方法其实是在HashMap的父类AbstractMap中的
*/
transient Set<Map.Entry<K,V>> entrySet;

/**
* 有多少对键值对
*/
transient int size;

/**
* HashMap不是线程安全的 这个字段主要是fast-fail机制中用来检测在遍历查询过程中HashMap是否被其他线程修改了
*/
transient int modCount;

/**
* 当哈希表打到阈值这个大小的时候 就扩容 threshold = capacity * loadFactor
*/
int threshold;

/**
* 负载因子 是哈希表中容量达到了 现容量*负载因子的时候就进行扩容 默认负载因子是0.75
*/
final float loadFactor;


put流程

put流程很重要 我们可以通过HaspMap的put流程来了解HashMap的原理

HashMap的机构是下面这样的
image_1d0m9nj3j1n8s1sd05g2gqmu829.png-22.9kB

使用数组实现的哈希表 这个数组是会动态扩容的
动态扩容的条件是
微信图片_20190108164404.png-4.5kB

  • size是HashMap中所有元素的数量之和
  • threshold等于capacity * load factor 也就是容量乘上负载因子

然后哈希表数组的每一个落槽点都会指向三种情况

  1. null
  2. 链表
  3. 红黑树

流程是这样的:

  1. 计算到落槽点之后 如果此处指向null 那么直接将新元素放置于此处
  2. 如果此处指向一个红黑树的节点 那么往红黑树的插入新的元素
  3. 如果此处指向一个链表节点 那么分两种情况 如果链表中节点数量低于树化阈值 直接插入链表 否则将链表转化为红黑树 然后插入新元素

所以上面的流程可以得到HashMap的put流程图
HashMap流程图.jpg-64.1kB


回答上面要点

  • HashMap JDK1.7有哪些缺点,JDK1.8怎么改进的
  • Hashmap扩容为什么要是2的整数次幂
  • 手写HashMap的put流程图

1.第一个问题 HashMap JDK1.7有哪些缺点,JDK1.8怎么改进的
在JDK1.7中HashMap的底层结构都是数组实现一个哈希表 然后每个槽点指向一个链表 但是发展到JDK1.8之后 HashMap的底层存储是数组实现一个哈希表 每个槽点最开始指向一个链表 当打到阈值的时候会转化为一棵红黑树 然后往红黑树中插入新元素

由于上面再底层实现方面上 JDK1.7的HashMap查找的时间复杂度是O(1) + O(n) O(1)是哈希表的查找时间复杂度 O(n)是链表的查找时间复杂度 但是到了JDK1.8之后 由链表转化为红黑树 那么当HashMap中元素特别多的时候链表转化为红黑树 那么时间复杂度由O(n)转化为O(logn) 这样的话时间复杂度会降低不少 这就是JDK1.8中改进的地方

2.第二个问题 为什么扩容都是2的整数次幂
这个问题很有意思
我们来看一下 在HashMap中在哈希表中落槽位置的计算方式
image_1d0mbpd5sp6t1sfr1vg6ksk19rq35.png-10.4kB
也就是

(table.length - 1) & hash

如果table.leng是2^n的时候 有

(table.length - 1) & hash == hash % table.length

这样按位运算就会非常的快 这应该是JVM里面对于位运算有一些优化

3.第三个问题 上面我已经写了


HashMap Hello world

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
class NewKey {
@Override
public int hashCode() {
return 1;
}

@Override
public boolean equals(Object obj) {
return true;
}
}

/*下面的代码输出是1 因为:
*
* TreeMap依靠实现comparable接口 覆写compareTo函数来实现去重
* HashMap依靠hashCode函数跟equal函数来实现去重
*
* 所以下面的代码输出是1 HashMap把两个Key都当作是相同的了*/

public class HashMapUsage {
public static void main(String[] args) {
HashMap hashMap = new HashMap();
hashMap.put(new NewKey(), "value one");
hashMap.put(new NewKey(), "value two");
System.out.println(hashMap.size());
System.out.println(hashMap.entrySet());
}
}

java基础3:红黑树和AVL树

AVL树

本质就是平衡二叉查找树
特点:
增加和删除几点后通过树形旋转重新打到平衡 左子树根右子树之间的高度差不能超过1

红黑树

主要特征是每个节点增加一个属性表示节点的颜色 可以是红色也可以是黑色 他追求的并不是绝对的平衡 而是保证从根节点到叶子节点的最长路径不超过最短路径的2倍 他的最坏运行时间复杂度是logn下面我会提供这个时间复杂度的证明

红黑树有五个约束条件 虽然我都不知道这些条件为什么是这样 有什么用 但是还是觉得这就是人家定下来的规则吧 肯定有人家的道理(达到防止红黑树野蛮往一个方向生长的效果)

  1. 节点只能是红色或者黑色
  2. 根节点必须是黑色
  3. 所有NIL结点都是黑色的 NIL就是叶子结点下挂着的两个虚节点
  4. 一条路径上不能出现相邻的两个红色节点
  5. 在任何递归子树内 根节点到叶子结点的所有路径上包含相同的黑色结点

旋转

无论是AVL树还是红黑书 旋转都是很常见的
微信图片_20190105200323.jpg-67.3kB


比较

因为AVL树追求的是绝对的平衡 红黑树追求的是大致上的平衡 所以相同的情况下红黑树很有可能比AVL树更高 那么查找的次数更多

另一方面 AVL树在回溯的时间成本上最差可能为logn 而红黑树每次回溯的部署步长为2 成本更低

所以

  1. AVL树更合适低频修改 大量查询
  2. 红黑树频繁的插入和删除

红黑树时间复杂度

我们知道红黑树插入和删除最差的时间复杂度都是logn 那么是怎样来的呢

首先由红黑书最后两个约束条件:

  1. 一条路径上不能出现相邻的两个红色结点
  2. 在任何递归子树内 根节点到叶子结点的所有路径上包含相同数目的黑色节点

由上面两个条件可以知道红黑树中的黑色节点都是分部均匀的 但是极端的情况下可以是整棵红黑树都是黑色节点

现在我们来认识一个新概念黑深度Black Depth 顾名思义就是当前节点到NIL节点途径上的黑色节点个数

那么对于根节点和上面两个约束条件 我们知道:

Black Depth >= height / 2

因为约束条件导致黑深度分布均匀 而且因为红红不相连 所以黑深度是大于等于半个深度的

我们假设红黑书的形状接近满二叉树的时候 假设有n个节点 高度为h 那么有:

2 ^ h - 1 = n

那么就有

h = log2(n + 1)

当一棵红黑树全是黑色节点的时候 那么根节点的黑深度就等于上面的h了 那么就有

log2(n + 1) >= height / 2

可以得到

height <= 2log2(n + 1)

我们知道红黑树的插入 是从根节点找到NIL的过程 时间复杂度就是
O(height) = O(2log2(n + 1)) = O(2log2(n + 1)) = O(2log2(2n)) = O(logn)

所以综上所述 红黑树在查找 插入 删除等 最坏时间复杂度是O(logn)

不知道上面的证明严谨不严谨 但是可以让我理解了 并且记忆深刻一点

java基础2:树相关前哨

元素的比较

在java里面元素的对象的比较一般是通过实现comparable或者comparator这两个接口来实现的

比如下面一个类 实现了comparable接口 实现接口必须覆写compareTo这个方法

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
package basics.compare;


/*下面的Comparable<SearchResult>加上了泛型限定*/
public class SearchResult implements Comparable<SearchResult> {
int relativeRatio;
long count;
int recentOrders;

public SearchResult(int relativeRatio, long count) {
this.relativeRatio = relativeRatio;
this.count = count;
}

/*需要覆写compareTo方法*/
@Override
public int compareTo(SearchResult o) {
if (this.relativeRatio != o.relativeRatio) {
return this.relativeRatio > o.relativeRatio ? 1 : -1;
}

if (this.count != o.count) {
return this.count > o.count ? 1 : -1;
}
return 0;
}

public void setRecentOrders(int recentOrders) {
this.recentOrders =recentOrders;
}
}

但是comparable接口其实不如comparator好 因为当我们需求有改变的时候 如果使用前者 意味着需要修改已经提交的代码 如果我们使用后者 那么当与比较相关的需求改变的时候 我们只需要修改第三方比较器就可以了 下面就是一个后者的例子 注意comparator这个接口的实现必须覆写compare这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package basics.compare;

import java.util.Comparator;


/*这里我们实现的是比较器comparator 这样可以让我们对比较标准进行修改的时候可以不修改我们已经交付的代码SearchResult 直接按照需求修改比较器就可以了
*
* 我们可以看到下面的Comparator<SearchResult>接口也是添加了泛型限定的*/
public class SearchResultComparator implements Comparator<SearchResult> {
@Override
public int compare(SearchResult o1, SearchResult o2) {
if (o1.relativeRatio != o2.relativeRatio) {
return o1.relativeRatio > o2.relativeRatio ? 1 : -1;
}
if (o1.recentOrders != o2.recentOrders) {
return o1.recentOrders > o2.recentOrders ? 1 : -1;
}
if (o1.count != o2.count) {
return o1.count > o2.count ? 1 : -1;
}
return 0;
}
}

上面我们也使用了泛型的知识 这个就不展开了


相等

上面我们介绍的是比较 主要说的是comparable跟comparator这两个接口 那么跟我们这里说的相等有什么区别呢

comparable跟comparator都是两个类的实例用来判断大小或者说按照一种规则对这些元素进行排序的 但是这里说的相等主要是判断两个对象是否相等 相等或者不想等就完事了 没有所谓的排序 这里的场景主要是在一些map或者set集合中用来判断两个对象是否是一个相同的对象 在去重方面发挥作用

下面我们主要说一下”相等”这个概念在TreeMap跟HashMap这两个常用的Map里面有什么不同

首先我们了解一下TreeMap跟ConcurrentHashMap和HashMap在使用上的一些不同:

  • TreeMap的Key是有序的 可以支持范围查找 所以在一些需要排序的场景下很好用
  • ConcurrentHashMap和HashMap在插入和删除方面上更高效 所以后面两者更常用

TreeMap的key排序去重并不是基于key元素覆写hashcode函数跟equal函数的 而是实现了接口comparable 覆写了compareTo函数

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
class Key implements Comparable<Key> {
@Override
public int compareTo(Key o) {
return 1;
}
}

/*treeMap一般都是在key需要排序的情况下使用 这样效率很高 但是一般用的很少 因为conncurrentHashMap跟HashMap在插入和删除方面效率很高 后两者用地比较多
*
*
* 但是我们要注意 TreeMap的key排序去重并不是基于key元素覆写hashcode函数跟equal函数的 而是实现了接口comparable 覆写了compareTo函数*/

public class TreeMapUsage {
public static void main(String[] args) {
TreeMap treeMap = new TreeMap();
treeMap.put(new Key(), "value one");
treeMap.put(new Key(), "value two");
System.out.println(treeMap.size());
TreeMap treeMap1 = new TreeMap();
treeMap1.put(1, "one");
/*map的key是有类型 即使一开始你没有显示指定类型 但是第一次添加之后key的类型就已经固定了 后面不允许添加其他类型*/
/*所以下面这一句会报错*/
// treeMap1.put("2", "two");
/*但是下面这一句没有报错 说明value是不限定类型的*/
treeMap1.put(2, 3);
System.out.println(treeMap1.size());
}
}

那么HashMap是怎样实现元素的去重的呢
HashMap依靠hashCode函数跟equal函数来实现去重

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
class NewKey {
@Override
public int hashCode() {
return 1;
}

@Override
public boolean equals(Object obj) {
return true;
}
}

/*下面的代码输出是1 因为:
*
* TreeMap依靠实现comparable接口 覆写compareTo函数来实现去重
* HashMap依靠hashCode函数跟equal函数来实现去重
*
* 所以下面的代码输出是1 HashMap把两个Key都当作是相同的了*/

public class HashMapUsage {
public static void main(String[] args) {
HashMap hashMap = new HashMap();
hashMap.put(new NewKey(), "value one");
hashMap.put(new NewKey(), "value two");
System.out.println(hashMap.size());
}
}


fast-fail和fast-save机制

fast-fail机制

fast-fail机制是一种对集合遍历操作时的错误检测机制 在遍历途中出现意料之外的修改时 通过uncheck异常暴力反馈出来

这种机制通常出现在多线程环境下 当前线程会维护一个计数比较器 即expectedModCount 记录修改的次数 在遍历之前 会把实时修改modCount赋值给expectedModCount如果两个数据不想等就会抛出异常

这里我之前看源代码就有发现 具体见ArrayList相关
微信图片_20190105192702.png-46.7kB

我们知道ArrayList就是fast-fail的 那么我们有哪些常见的是fast-save的呢 下面要介绍CopyOnWriteArrayList用来代替ArrayList 该容器会对内部的迭代器进行加锁操作

顺便我们这里介绍一下COW奶牛家族 就是copy on write的意思
它是并发的一种新思路 实行读写分离

  • 写操作就复制一个新的集合 在新集合上添加或者删除 等到修改结束 将原集合的引用指向新的集合
  • 读和遍历操作 不需要加锁 直接读

但是在使用cow的时候需要注意

  1. 尽量设置合理的初始容量 这跟ArrayList那些是一样的 因为扩容代价太大
  2. 使用批量添加或者修改 避免频繁复制整个集合 消耗内存

下面是一个简单例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*下面我们使用了COW家族的一员 就是copy on write机制
*
*
* 这个机制适合使用在读多写极少的高并发场景下
*
* 因为他的原理就是:实行读写分离 如果是写操作 则复制一个新集合 在新集合中添加和删除元素 待一切修改完成之后 再将原集合的引用指向新的集合 这样做的好处就是可以高并发地对COW进行读和遍历操作 而不需要加锁 因为当前集合不会添加任何元素
*
*
* 使用注意要点:
* 1. 尽量设置合理的容量初始值 因为会设置扩容的问题
* 2. 使用批量添加或删除的方法 因为每一次修改都是需要copy出一份新的集合出来的 如果分开修改 那么代价很大
*
*
* 有点和缺点:
* 优点:是线程安全的
* 缺点:不能读取到最新的数据*/
public class CopyOnWrite {
public static void main(String[] args) {
CopyOnWriteArrayList<Long> list = new CopyOnWriteArrayList<Long>();
long start = System.nanoTime();
for (int i = 0; i < 10; i++) {
list.add(System.nanoTime());
}
long end = System.nanoTime();
System.out.println("cost time " + (end - start));
}
}

ArrayList相关

前言

集合框架有很多可以直接使用的集合 比如常用的就有ArrayList跟HashMap 这篇文章主要研究一下ArrayList


基本用法

声明并且初始化一个arraylist

1
2
3
4
5
6
ArrayList<Integer> arrayList = new ArrayList<Integer>();
arrayList.add(1);
arrayList.add(2);
arrayList.add(3);
/*获取list的大小*/
System.out.println("size of ArrayList is " + arrayList.size());

注意

ArrayList<Integer> arrayList = new ArrayList<Integer>();

尖括号里面只可以是类比如Integer 而不可以是基本数据类型比如int那些

我们先来看看arraylist中有哪些字段

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
//序列化id
private static final long serialVersionUID = 8683452581122892189L;

/**
* 默认的初始化容量是10
*/
private static final int DEFAULT_CAPACITY = 10;

/**
* 被空实例用的空数组实例
*/
private static final Object[] EMPTY_ELEMENTDATA = {};

/**
* 被那些默认容量 但是目前还是空的实例用的数组实例
* 跟上面那个EMPTY_ELEMENTDATA不一样的地方是 这个当第一个元素被添加的时候他知道什么时候开始扩张容量
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

/**
* 这就是真正存储着arraylist的元素的数组
*/
transient Object[] elementData; // non-private to simplify nested class access

/**
* 表示数组的长度
*/
private int size;


接下来我们看一下

arrayList.add(1);

中发生了什么

1
2
3
4
5
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}

上面很明显是先确定容量是否足够 然后使用下标定位到数组中元素到最后 把新的元素添加上去 那么我们看一下ensureCapacityInternal函数是怎样确定容量是否足够 以及怎么扩张容量的 这个函数最终是使用grow函数进行扩容的

1
2
3
4
5
6
7
8
9
10
11
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}

从上面我们很显而易见地知道 每一次扩张容量都是在原来容量的基础上增加一半

int newCapacity = oldCapacity + (oldCapacity >> 1);

从上面我们可以知道ArrayList的空间都是不够就动态增加的 主要使用Arrays.copyOf这个函数重新分配空间 那么就会有一个问题 如果一开始我们无参构造ArrayList的时候 没有指定容量 那么默认容量就是10 如果这时候我们要add大量的元素 比如1000个 那么就需要多次动态扩容 这样会造成被动扩容和数组复制的额外开销

所以 使用ArrayList之前指定恰当的容量 那么是可以避免上面的额外开销 但是如果容量没有指定恰当 有可能会造成内存溢出


遍历问题

我看别人的博客 说ArryList一般有三种遍历方式

  • 下标遍历
  • for循环遍历
  • 迭代器遍历

下标遍历

1
2
3
4
5
/*下标遍历 这是最快的一种方式*/
for (int i = 0; i < arrayList.size(); i++) {
/*使用下标定位数组元素的时候 只可以使用get函数定位*/
System.out.println(arrayList.get(i));
}

显而易见 这是最快的一种遍历方式 没什么好说的

for循环遍历

1
2
3
4
/*for循环遍历*/
for (Integer integer : arrayList) {
System.out.println(integer);
}

这种方法效率介于中间 但是因为无法看到这个方法的源代码 所以无从分析

迭代器遍历

1
2
3
4
5
/*使用迭代器进行遍历*/
Iterator<Integer> iterator = arrayList.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}

这是效率最低的一种方法 我们可以看一下他的源代码分析一下

arrayList.iterator();

这里返回一个内置类的实例 这个内之类实现了Iterator接口 我们可以看一下这个内置类的一些字段比较有意思

1
2
3
int cursor;       // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;

前面两个变量顾名思义 看注释也可以知道他们是干什么的 后面两个
expectedModCount表示预期的数组被修改的次数 modCount是实际上被修改的次数 这两个字段有什么用呢?

首先我们来看一个问题

ArrayList是非线性安全的 就是有可能同时被多个线程操作的话 出现数据不一致性 所以ArrayList必须一个时间内只可以给一个单独的线程操作

但是如果在遍历的时候 如何防止我在遍历一个ArrayList的时候 避免这个arraylist同时被其他线程修改呢

迭代器遍历这种方法就可以做到这种效果 因为迭代器的hasNext函数跟Next函数都可以使用checkForComodification函数来检查是否在遍历的时候被其他线程修改了 怎么检查? 靠的就是上面两个字段expectedModCount modCount 具体如下

1
2
3
4
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}


所以了 我们可以看得出 上面三种遍历ArrayList的方式 从效率来看肯定是
下标定位 > for循环 > 迭代器

但是如果涉及线程安全的时候 又恰巧这时候使用的ArrayList(虽然在线程安全的时候不推荐使用ArrayList) 我们就可以使用迭代器遍历的方法遍历ArrayList

暑假学习总结

郭柱明


注意

因为我之前都要使用博客记录的习惯 所以下面很多链接都链接向我自己写的博客上的 所以导致下面看起来内容不多 其实下面内容还是有的


阶段一:图数据库考察

图数据库的考察是我进入公司开始做的第一部分工作 我们考察图数据库的历程大概是下面这样的
image_1crgtnkj772g3l31ouj1o8tbchm.png-8.2kB

  1. neo4j是由鸿波进行考差 neo4j是很完善的图数据库 但是只有他的企业版是支持集群的 但是收费 所以pass 考察的细节都在洪波那里 那时候我还没来 所以细节不清楚
  2. dgraph是我考察 细节在如下我的博客记录 官方文档也可以学习 推荐教程这个是学习dgraph最好的一个方法了 dgraph也是一个成熟的图数据库 有以下特点
    1. 支持resetful api
    2. 使用graphql+-查询语言进行查询
    3. 有多种语言的client
    4. 但是不支持自由选择底层存储 就是我们公司的高可用mongo集群的作用发挥不出来
  3. cayley是我们重点考察的图数据库 花了很多功夫 这是我对于cayley的基础知识总结 这是我后来对cayley做的一次技术分享PPT
    • 对于cayley的原理跟设计思想我有去思考的 探索出来的细节未必正确 但是思想我觉得还是可取的
    • 研究了cayley整个项目的构建方法 然后修复了cayley在持久层测试方面上的一个bug 为cayley贡献了一点代码 git commit在这里 其实这个问题包含的思想很重要的 扬哥因此还送了我一平红酒 细节请看docker在测试中的应用 后来我也用docker在开发和测试中写了另外一些demo 用法不一样基于容器的开发和测试

阶段二:开发


cayley时期

我们做了一个demo 就是基于我们的cayley图数据库来构建我们的关系图谱 从而找出用户中的最近联系的人以及可能认识的人 当时因为在设计理念以及对cayley的使用上 我跟扬哥跟莹姐之间存在分歧 所以当时每个人都按照自己的想法来写了一个自己的版本 我的版本如下
graphRelation
里面还算有不少注释


hugegraph时期

hugegraph说实话我们没接触多长时间 所以在这一阶段写的代码并不多 有一个需求就是找子图 代码在subgraph 里面我也写了部分注释

博客详情在我的subgraph博客

hugegraph时期我花了很多时间在看gremlin官方文档上 因为gremlin语法众多而且复杂 我并没有像以前翻译cayley的查询语言Gizmo那样用博客把常用查询语法记录下来 所以有点可惜 但是太多了记下来也没用 可以当官方文档是字典 使用的时候去查阅 平时只需要记住常用的查询语法即可


使用docker为测试提供一次性的数据库

docker容器目前有三个用处

  • 提供一次性的环境
  • 提供弹性的云服务
  • 组件微服务架构

我有做基于容器docker的开发和测试上一些简单的尝试 详情请看下面基于容器开发

cayley在它的单元测试跟集成测试部分就是用了docker 但是这里docker的使用跟上面有所不一样 上面是所有的部分都包含在容器内部 但是cayley的思路很特别也很有借鉴意义

cayley因为支持很多种数据库作为底层存储 所以他在每一次的单元测试以及集成测试过程中 都使用docker生成一个数据库容器 并将程序在运行过程中产生的持久层数据都放进这个数据库容器中 这样当测试结束 清除这个容器 保证了测试都进行在一个一次性的环境中 不留下任何测试数据

我为cayley修好的这个bug就是上面这个问题里面一个bug 虽然这个bug没什么太大意义 但是cayley在这里的设计思路确实非常值得我们借鉴 借助docker为我们的单元测试集成测试提供一次性的数据库容器


subgraph概况

背景

因为我们现在使用的图数据库都有一个瓶颈就是不支持图数据库集群 在图数据库的使用上我们只可以使用单点 如果使用多点 那么就会有很多缓存不一致等问题 因为现在开源的图数据库d很多都是企业内部图数据库的阉割版 都是不支持图数据库层面的集群的 所以不可以做上线版本的 那么离线分析可以作为一个方向


离线数据

image_1crhbqje41b356it1sb361o1ujo9.png-20.9kB

都是csv格式的文件 文件中每一行的格式都很单一 就是

userid,fileid

例如

311915299,10437872764

每一条数据意味着某个用户id读了某个文件id

而且这些数据所有都是fileid具有约束条件pv<6的 这次离线分析的思想很简单就是: 物以类聚 人以群分 想通过以fileid为中心形成很多子图 而且pv<6使得这些文件都不是属于烂大街的文件和具有普适性的 比如文档教程之类 过于烂大街的文件形成的子图对于我们来说没有什么价值 因为它们无法为我们的用户画像带来帮助 那么这些文件的属性大概率可以表明这些子图中用户的身份 比如以简历为中心的子图中的用户大概率是应届毕业生之类的 思路是很简单的


schema和graph

所有的代码都在subgraph
构建子图部分如下:
image_1crhd07pqg6ldu7sqo1bp8kna1m.png-31.4kB

构建图的思路很简单
image_1crhdmvi8s2bgfohrl1h3cgg23.png-23.3kB
在hugegraph里面上面的创建其实是分为两部分的

  1. schema
  2. graph

对应上面图中
image_1crhe6s8c1khm16t61hcmmtj1ldj40.png-32.2kB

分别对应项目中如下两部分
image_1crhebmsdu8cpbk1dsiboj17la4t.png-62.5kB

这里有必要说一下我代码里面一些单元测试 单元测试在我的代码里面很重要 在这里我基本是写一个函数就对应写一个单元测试 这样你看我的代码 看我的单元测试就知道这些函数是怎样使用的了 所以看我的代码 看我的单元测试是关键

代码中schema文件夹下schema.go就是用来构建schema中propertyKey vertexLabel edgeLabel中的函数 在schema_test.go中可以看到这些对应函数的使用方法 同时也有部分注释
代码中schema文件夹跟上面类似


读取csv数据构造图

入口在如下main函数
image_1crjbgkh718351l79195c13r1f179.png-49.3kB

ConstructGraph函数每一次读取一个csv文件 迭代处理每一行 对于每一行

userid, fileid

先生成一个user的点和一个file的点 以及生成一条从user指向file类型为connect的边 一直重复上述操作 直至数据被处理完

上述的操作有两点要注意的地方:

  1. 我是单线程同步构造图的 但是73哥csv耗时上并不是太长 可以接受
  2. 如果对构造图有耗时上的要求 那么推荐使用无gremlin的纯graph api(restful api)

查询子图

image_1crjblvqejc1hqpham11ebm5q1m.png-39.5kB
查询的函数和单元测试都写在上面的红色部分内 入口在下面的绿色部分内

查询就是使用gremlin api发送gremlin语句过去 这里真的是一句gremlin用到烂 主要用的一句gremlin就是

1
const SubGraphGremlin = "sg = g.V().hasLabel('file').has('fileid','%s').repeat(bothE().subgraph('sg').otherV().simplePath()).until(bothE().count().is(0)).cap('sg').next();sg = sg.traversal();r=sg.V().choose(hasLabel('user'), count(), values('fileid'));r"

我们在hugegraph上进行查询大多数情况都是在gremlin上做文章 对于gremlin的语法我们需要很注意 所以这里是说一下上面这段gremlin是什么意思

可以分为四部分

1.

sg = g.V().hasLabel('file').has('fileid','%s').repeat(bothE().subgraph('sg').otherV().simplePath()).until(bothE().count().is(0)).cap('sg').next();

g是一个全局Traversal(全局遍历器) 理论上使用这个全局遍历器awo我们可以首先定位到图里面的某个点或者某部分点 并且以这些点作为我们迭代查询的始点 要注意g其实是这个全局遍历器的别名 在代码里面使用gremlin api post发送gremlin语句的时候需要加上这个别名 否则无法识别g是什么 比如我在在子图的代码里是这样写的
image_1crjckhir1sio1ec91dvn3sri9n2j.png-39.9kB

详细解释如下
image_1crjdr1gcbdk1sen1r2el5knd30.png-65.8kB

2.

sg = sg.traversal();

对上一个步骤获取的子图sg 获取这个子图的一个遍历器

3.

r=sg.V().choose(hasLabel('user'), count(), values('fileid'));

通过这个遍历器 通过choose语句 choose语句相当于if else逻辑 满足第一个参数条件 则执行第二个参数 否则执行第三个参数

所以上面的意思是如果子图里面的点是有user的label的话 那么返回这些点的数量(子图中用户数量) 否则(就是file的点) 返回浙西file的fileid属性 并且将上诉结果都放到一个r变量中

4.

r

返回查询结果


docker在测试中的作用

郭柱明


cayley与docker

cayley是一个用go语言编写的很规范的图数据库 他的设计方式很值得我们学习 这篇文章我们简单聊一聊docker在cayley这个图数据库中的应用

docker主要在cayley中有两处使用

  • 将整个cayley作为一个应用打包为一个docker镜像
  • 在每一个涉及持久层操作的单元测试和集成测试中 使用docker生成一个一次性数据库供数据存储

第一点很常见 就是使用dockerfile的方式将应用打包成镜像 这样我们可以通过docker很简单地安装和运行cayley
image_1crovr4t31hmc14814cdg3qmtd9.png-172.6kB
但是第二点真的很少见 cayley在第二点上算是非常创新的做法 现在主要讨论第二点


docker在测试中的使用

docker的使用情景有很多种 我们可以看一下阮一峰博客上说的
image_1crp006g81526bpjoll1t61kk3m.png-24.8kB

cayley在这里正是第一种情况 使用docker提供一次性环境

因为cayley是支持多种数据库作为底层存储 如果在开发测试过程 对每一种数据都进行本地安装 本地系统环境等因素一定会让操作很麻烦 所以cayley选择在各种测试进行的时候使用docker镜像生成一个需要的数据库容器 并且将这个容器运行起来 返回这个容器运行在的IP地址和端口 这样就可以提供给测试的代码进行连接 注意整个测试并不是发生在容器内部的 仅仅是数据库在容器内部

当我们操作完需要的持久层操作之后 就可以彻底删除这个容器 也不会导致测试的数据遗留

如果现在不熟悉docker的基本使用 可以先看一下阮一峰这篇博客先了解简单的docker helloworld


cayley使用一个封装了docker remote api的第三方包go docker client生成一个数据库容器的步骤如下
生成一个数据库容器

注意 cayley是原本缺失第二步pullimage的 所以导致原来运行cayley所有的测试都会导致设计持久层的测试被skip 正就是我帮cayley修复那个bug 扬哥因此送了我一瓶红酒 commit详情在pull image from remote repository if there is not image at local machine 这是我人生第一次给比较大的开源项目贡献代码。。

下面是我看了cayley的代码后 整理的一个hello world 生成一个可用的mongo的容器 运行 并且返回mongo的IP地址和端口进行连接 因为下面我已经写了尽可能多的注释了 所以不一一解释了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
package main

import (
"github.com/fsouza/go-dockerclient"
"time"
"log"
"fmt"
"bytes"
"runtime"
"strconv"
"net"
"math/rand"
)


//配置
type fullConfig struct {
//主要是镜像的配置
docker.Config
//主要是容器的配置
docker.HostConfig
}

//检测IP地址和端口是否可用 可连接
const wait = time.Second * 5
func waitPort(addr string) bool {
start := time.Now()
c, err := net.DialTimeout("tcp", addr, wait)
if err == nil {
c.Close()
} else if dt := time.Since(start); dt < wait {
time.Sleep(wait - dt)
}
return err == nil
}

//随机选择可用的端口
const localhost = "127.0.0.1"
func randPort() int {
const (
min = 10000
max = 30000
)
for {
port := min + rand.Intn(max-min)
c, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", localhost, port), time.Second)
if c != nil {
c.Close()
}
if err != nil {
// TODO: check for a specific error
return port
}
}
}

func main() {
//一般通过以下sock文件初始化docker client
Address := `unix:///var/run/docker.sock`

//初始化镜像和容器的配置
var conf docker.Config
port := "27017"
//镜像的名字叫mongo
conf.Image = "mongo"
conf.OpenStdin = true
conf.Tty = true
//容器暴露出27017这个端口
conf.ExposedPorts = map[docker.Port]struct{} {
"27017/tcp": {},
}
fconf := fullConfig{
Config: conf,
HostConfig: docker.HostConfig{
//端口映射 容器的27017端口映射到本地的27017端口
PortBindings: map[docker.Port][]docker.PortBinding{
"27017/tcp": {
{
HostIP: "0.0.0.0",
HostPort: port,
},
},
},
},
}

//在linux系统下 可以原生地运行docker 但是在其他系统下需要特殊处理 随机选择可用的端口进行映射
if runtime.GOOS != "linux" {
log.Println("this is not linux")
lport := strconv.Itoa(randPort())
// nothing except Linux runs Docker natively,
// so we randomize the port and expose it on Docker VM
fconf.PortBindings = map[docker.Port][]docker.PortBinding{
docker.Port(port + "/tcp"): {{
HostIP: localhost,
HostPort: lport,
}},
}
port = lport
log.Println("this is not linux env, change the port to", lport)
}

//初始化docker client
cli, err := docker.NewClient(Address)
if err != nil {
panic(err)
}

//从远程拉取指定的docker镜像 如果本地已经存在指定镜像 那么操作被跳过
var buf bytes.Buffer
if err := cli.PullImage(docker.PullImageOptions{
//通过 镜像所在组/镜像名称:镜像标签 来指定特定的镜像
Repository: "docker.io/mongo:latest",
//指定输出位置
OutputStream: &buf,
}, docker.AuthConfiguration{}); err != nil {
log.Println("pull 容器失败")
panic(err)
}
log.Println("buf", buf.String())

//从上面的镜像中生成一个docker容器
cont, err := cli.CreateContainer(docker.CreateContainerOptions{
Config: &fconf.Config,
HostConfig: &fconf.HostConfig,
})
if err != nil {
log.Println("创建容器失败")
panic(err)
}
//生成一个强制移除容器的关闭函数
closer := func() {
cli.RemoveContainer(docker.RemoveContainerOptions{
ID: cont.ID,
Force: true,
})
}
defer closer()

//启动容器
if err := cli.StartContainer(cont.ID, &fconf.HostConfig); err != nil {
log.Println("启动容器失败")
closer()
panic(err)
}

//监听容器是否启动成功
info, err := cli.InspectContainer(cont.ID)
if err != nil {
log.Println("监视容器失败")
closer()
panic(err)
}

//获取容器运行在的IP地址和端口 这里默认使用的是 bridge的网络模式
addr := info.NetworkSettings.IPAddress
addr += ":" + port

//在10机会监听数据库的连接url是否可用
ok := false
for i := 0; i < 10 && !ok; i++ {
ok = waitPort(addr)
if !ok {
time.Sleep(time.Second * 2)
}
}

if !ok {
log.Println("一直连接失败")
closer()
log.Fatal("tcp connect fail")
}
fmt.Println("addr", addr)
}

cayley将上面的常用操作都封装成函数在docker操作 这些使用docker生成一次性数据库并使用的一个例子可见mongo_test

cayley在这一点上做的很精彩 也很创新 非常值得我们学习

java基础:入职华为前的重启

前言

以前我学java都是看看菜鸟教程就算了 那时候急功近利 而且因为用的少 每次学完都无一例外会忘记得一干二净 但是明年去华为肯定是需要使用java了 所以现在重新认真学习和使用java 下面是一些基础知识 比较浅显

因为每个小的知识点 我都是会在自己的示例代码里写很多注释 所以就不拓展来讲了


函数参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package basics.parameters;



/*
* 看了书本才知道 原来java的先可以分为:
* 1.基本数据类型
* 2.引用变量 引用变量就是相当于指针了
* 基本数据类型作为函数参数传入是无法被修改的 引用变量作为函数参数传入可以
*
* 除此之外 根据可是否可以被修改 也可以分为两种类型:
* 1.immutable不可修改 比如int跟string
* 2.mutable可修改 比如 StringBuilder
* 两种,不可修改就是当这个类型的参数传入函数中之后 函数中的任何操作都对这个参数不产生实质上的作用*/

public class immutable {

public static void main(String[] args) {
StringBuilder str = new StringBuilder("old");
change(str, str);
System.out.println(str);

String string = new String("lala");
changeStr(string);
System.out.println("after changestr " + string);

int num = 100;
changeInt(num);
System.out.println("after changeInt " + num);
}

/*测试修改StringBuilder的函数
*
* StringBuilder传进来本身是引用变量 是可以被修改的 穿进去之后又因为他本身是mutable的 所以被修改成功*/
public static void change(StringBuilder str0, StringBuilder str1) {
str0.append("append0");
str1.append("append1");

str0 = new StringBuilder("new string builder");
str0.append("new append");
System.out.println("inside func " + str0);
}

/*测试修改string的函数
*
* String传进来的引用变量 所以一开始是可以被修改的 但是因为String是immutable的 所以即使引用变量穿进去之后最后也是修改失败*/
public static void changeStr(String str) {
// string类型的参数 修改效果都是修改了参数的局部变量
str = "change str ";
System.out.println("str inside func " + str);
}

/*测试修改int类型的函数
*
* int这里是作为基本数据类型传进去的 所以本身就不可以被修改 无论是传进去之后是mutable还是immutable的*/
public static void changeInt(int num) {
// int类型的参数 修改效果都是修改了参数的局部变量
num = 10;
System.out.println("inside func" + num);
}
}

输出
微信图片_20181203142538.png-20.1kB


静态

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
package basics.staticBlock;


/*
* 我们静态static变量都是被共享的 在类初始化的时候执行 具有很高的优先级 想想被其他共享 肯定是需要最早进行执行或者初始化的
*
* static{...}这样的就是静态代码块 也是跟静态变量一样*/

class Parent {
/*所有的静态代码块的优先级都是很高的 在类初始化执行*/
static {
System.out.println("这是父类的静态代码块");
}
public Parent() {
System.out.println("这是父类的构造函数");
}
}

class Son extends Parent {
/*所有的静态代码块的优先级都是很高的 在类初始化执行*/
static {
System.out.println("这是子类的静态代码块");
}
public Son() {
System.out.println("这是子类的构造函数");
}


/*静态方法中不能使用可以被修改的对象 否则会出现线程安全问题*/
public static void TestStaticFunc() {
System.out.println("这是静态方法");
}
}

public class staticBlock {
public static void main(String[] args) {
new Son();
/*静态代码块因为是共享的 所以只被执行一次 在上面那条语句中已经被执行了 所以下面的new语句中不会被执行了*/
new Son();
Son.TestStaticFunc();
}
}

输出
微信图片_20181203142854.png-14.6kB


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
package basics.staticBlock;

public class StaticCode {
static String prior = "done";

/*根据输出结果来看 static无论是静态变量还是静态代码块(除了静态方法)
* 都是按照先后顺序来执行的 比如下面的静态变量先进行初始化 再执行下面的静态代码块*/
static String last = f() ? g() : prior;

public static boolean f() {
System.out.println("这是f函数");
return true;
}

public static String g() {
System.out.println("这是g函数");
return "lala";
}

static {
System.out.println("这是静态代码块");
System.out.println(last);
g();
}
public static void main(String[] args) {

}
}

输出
微信图片_20181203143006.png-10.3kB


重载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package basics.overload;

/*方法的重载只允许参数不一样 其他:
* 1.访问控制符
* 2.方法名称
* 都必须一模一样
*
*
*
* 总而言之 重载就是参数不一样而已 只能是参数不一样!!!!!!!*/

public class OverloadMethods {
public void overloadTest() {
System.out.println("这是测试函数");
}
public void overloadTest(int num) {
System.out.println("这是重载的函数内部 传进来的参数为" + num);
}
public static void main(String[] args) {
OverloadMethods overloadMethods = new OverloadMethods();
overloadMethods.overloadTest();
overloadMethods.overloadTest(12);
}
}

输出
微信图片_20181203143326.png-12.3kB


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
package basics.overload;


/*
* 方法重载的注意要点已经写在OverloadMethods这个类里面 这里记录一下重载方法匹配的优先级
*
*
* 匹配优先级:
* 1.精确匹配
* 2.如果是基本数据类型 自动转化为更大表示范围的基本类型
* 3.通过自动拆箱与撞线
* 4.同过子类向上转向继承路线一次匹配 就是子类优先的意思
* 5.通过可变参数匹配 可变参数匹配时最低等级的
*
*
* 要注意子类的方法也是可以重载父类的方法的*/

public class Priority {
public void OverloadMethod(int num) {
System.out.println("参数为int的方法");
}
public void OverloadMethod(Integer num) {
System.out.println("参数为Integer的方法");
}
public static void main(String[] args) {
Priority priority = new Priority();
priority.OverloadMethod(7);
}
}

输出
微信图片_20181203143405.png-7.7kB


覆写

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
package basics.override;

class Father {
protected void DoSomething() {
System.out.println("这是父类函数");
}
}

/*子类覆写的四大要求:
* 1. 子类方法中的访问权限不得小于父类中的
* 2. 子类方法的返回类型必须可以向上转型为父类的返回类型
* 3. 子类方法的异常类型必须可以向上转型为父类的异常类型
* 4. 子类方法的方法签名 参数类型以及个数必须完全一样*/

/*覆写只可以针对:
* 1. 非静态
* 2. 非final
* 3. 非构造方法
*
* 其实不可以覆写静态方法很容易理解 因为静态方法是属于类的 如果子类覆写了父类的静态方法 那么久存在两个名称相同的静态方法 两个都可以能被调用*/

public class Son extends Father {
@Override
public void DoSomething() {
System.out.println("这是子类覆写的函数");
}
public static void main(String[] args) {
Father father = new Son();
father.DoSomething();
/*像下面这样向下转型是不可以的*/
// Son son = new Father();
}
}

输出
微信图片_20181203145213.png-7.7kB


泛型

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
package basics.template;

class Meat {
@Override
public String toString() {
return "Meat";
}
}
class Soup {
@Override
public String toString() {
return "Soup";
}
}

/*使用泛型的好处:
* 1. 避免使用重载写多种方法
* 2. 类型擦除 避免强制转换带来的风险
*
*
* 什么是类型擦除:
* 所有的泛型参数都相当于转换为Object类型 进去是什么 出来就是什么 避免了类型强制转换带来的风险*/


/*任何在尖括号中的指代一种未知类型 即使放String在尖括号里面 这时候的String也不再是jav.lang.String不一样了*/

public class Stove {
/*尖括号必须位于:
1.类名之后 或者
2.方法返回类型之前*/
public static <T> void heat(T food) {
System.out.println(food + " is done");
}

public static void main(String[] args) {
Meat meat = new Meat();
Soup soup = new Soup();
Stove.heat(meat);
Stove.heat(soup);
}
}

输出
微信图片_20181203143644.png-9.8kB


包装数据类型的缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package basics.boxing;


/*除了float和double之外 其他包装数据类型都会使用缓存 六个包装类直接赋值的时候 就是调用对应包装类的静态工厂valueOf*/
/* public static Integer valueOf(int i) {
如果存在缓存区间里面 就用缓存的
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
如果没有就重新创建一个新的
return new Integer(i);
}*/
public class cache {
public static void main(String[] args) {
/*直接重新创建一个对象 分配内存空间*/
Integer integer = new Integer(11);
System.out.println("新创建的Integer类型" + integer);

/*先查询缓存区是否存在这个值 如果存在就是用缓存中的实例 如果没有就重新new一个*/
Integer integer1 = Integer.valueOf(11);
System.out.println("新建的Integer类型" + integer1);
}
}

输出
微信图片_20181203143821.png-10.1kB

基于容器的开发

郭柱明


背景

虚拟化技术在服务端开发中越来越常用 docker就是常用的一种虚拟化技术 docker的虚拟化并不是系统级别的虚拟化 而是进程级别的虚拟化 他把一个进程相关的代码文件 环境变量 第三方包等等相关都打包进一个”容器”之中 可以

  • 实现对运行环境版本控制
  • 提供一次性地测试环境
  • 动态扩容或者缩容
  • 有利于实现微服务架构

一种基于容器的开发模式

花了一个多星期时间 暂时是为了实现以下开发的一种设计模式
微信图片_20180910212550.png-55.1kB
在开发环境 我们只需要按照系统的功能将系统划分为多个模块 分别将这些模块打包为docker镜像 然后推送至远程仓库

在部署和测试环境 只需要安装了go语言 docker 以及封装了docker remote api的go第三方包go docker client 在这边只需要运行我们预先写好的启动文件(这个稍后会详细介绍) 从远程仓库中拉取我们需要的每个小模块的镜像 分别生成容器 运行各个容器 各个容器之间相互协同工作 我们在部署测试这边 我们不需要关注每个容器内部是如何工作的 我们只需要关注在顶层如何调度这些子模块进行协同工作


实现

我现在做的一个demo其实非常简单 就是以mongo为数据库 以beego为框架 写一个简单的用户注册登录页面 登陆之后才有权限浏览一个特殊的页面
gitlab地址

  1. 注册一个新的用户 将用户的名字 id 密码存进mongo的一个表
  2. 登录的时候使用名字和密码进行登录 使用session跟踪用户 session实现方式为借用cookie
  3. 如果登录成功自动跳转到一个display页面 如果没有登录那么提醒用户需要登录

基本逻辑功能实现非常简单了 所以不多说

然后是测试上 在

  • 注册
  • 登录
  • display页面

三个小模块上 都编写了单元测试
注册测试 登录测试 display页面测试(需要登录权限)
以及对上诉各个小模块串联起来测试的集成测试集成测试

煞费苦心写单元测试和集成测试的原因有 我们可以在容器运行的时候让容器自动运行所有的单元测试和集成测试 所有测试都是在容器中进行的 可以借机利用容器可以为我们的测试提供一次性环境的优点来运行测试 试想一下:我们每次在自动化部署项目到服务器之后 肯定是希望我们的单元测试和集成测试都可以自动跑一次 来来检验 这是很方便的 更何况如果是使用容器来运行这些测试 那么测试完成之后 容器被删除 测试过程在持久层产生的数据也会被删除干净 不对整个服务造成其他干扰


打包镜像

根据我们上面的设计 我们可以将整个demo划分为两个模块 一个mongo 一个写好的beego server

mongo已经有人将它打包为镜像 并且推送至远程仓库dockerhub上去了 所以我们可以直接使用 不需要自己打包 那么我们只需要打包上面写好的beego server

docker镜像的打包方式有三种

  • Dockerfile配置文件
  • docker remote api
  • 封装了docker remote api的第三方包

开发过程中打包镜像推荐使用第一种方式 不推荐使用第二第三种方式 第二第三中方式适合拉取已经构造好的远程镜像到本地生成容器来运行

我们来看一下我们的beego server的Dockerfile配置文件

1
2
3
4
5
6
7
8
9
10
11
12
FROM golang:1.10.3
COPY . /go/src/hello
WORKDIR /go/src/hello
RUN go get github.com/astaxie/beego
RUN go get github.com/smartystreets/goconvey
RUN go get gopkg.in/mgo.v2
RUN go build
RUN tr -d "\r" < run.sh > run.sh
RUN chmod -R 777 run.sh
EXPOSE 8080
EXPOSE 27017
CMD sh run.sh

Dockerfile一些详细的使用可以大家可以看一下旭升之前写的docker入门 这里我只简单说一下我这里的dockerfile的大概含义

FROM golang:1.10.3

基于golang作为基础镜像来构造我们的镜像

COPY . /go/src/hello

将我们这个demo的除了.dockerignore上标记的文件都拷贝到go镜像的/go/src/hello文件夹下

WORKDIR /go/src/hello

工作路径为/go/src/hello

RUN go get github.com/astaxie/beego
RUN go get github.com/smartystreets/goconvey
RUN go get gopkg.in/mgo.v2
RUN go build

下载需要的go第三方包 并对demo进行编译 生成了demo名字相同的hello可执行文件

RUN tr -d "\r" < run.sh > run.sh

因为一个Dockerfile文件中只可以有一个CMD命令 我们既需要运行测试文件 又需要运行服务 那么我们只可以将运行服务和测试都写进一个sh脚本里面 然后在dockfile中只需要运行这个sh脚本就可以了 上面的命令只是为了去除脚本的”\r” 使得sh文件中可以cd ..

1
2
3
4
5
#!/bin/bash
./hello &
cd tests && go test -v && cd ..
go test -v -tags integration
wait

正如上面所说 run.sh这个脚本做的内容有

  • 运行之前编译生成的可执行文件
  • 进入tests文件夹 执行所有的单元测试
  • 从tests文件夹出来 执行集成测试

我们可以在dockerfile文件所在的根目录下使用

docker image build -t 镜像名字:标签名字 .

命令来生成打包好的镜像

至此 我们要做的beego server镜像打包好了 我们的镜像根据Dockerfile上所配置的而言:这个镜像被下载到本地 生成容器 之后运行 这个容器会自动运行我们的server服务 并且自动执行所有的单元测试和集成测试

使用

docker push 镜像名字:标签名字

将这个镜像推送至你的远程dockerhub仓库里面(实现需要进行docker login进行登录)

那么 我们已经完成了一半的工作量 如下
微信图片_20180910221816.png-30.6kB


拉取镜像

我们剩下来的一半工作就是编写拉取远程仓库中的镜像到本地 生成相应的容器 协调各个容器之间的工作 使得整个服务运行起来

上面说到 在使用docker的方式中我们有三种

  1. Dockerfile
  2. docker remote api
  3. 封装了remote api的第三包

在拉取镜像这部分 我们是推荐使用第三种方式的 因为设想一下 如果使用这种方式 我们在部署测试的时候只需要那边的环境安装了go和docker以及封装了remote api的第三包 那么需要起这个服务只需要简单de地运行我们写好的这个go启动脚本 那么当给让测试的同学来起我们的服务的时候 他们甚至不需要懂得docker的命令 只需要知道怎么运行go文件就可以了

完整的启动文件startUp.go
对于每一个子模块或者说每一个镜像 我们都需要经历下面的过程 才可以将这个子模块在本地的一个docker容器中进行运行
微信图片_20180910223243.png-9.7kB

  1. 构造Config和HostConfig等等 会定义与镜像和容器相关的配置 Config一般包含基本信息 名字内存等等 HostConfig一般是与ip和端口等相关
  2. PullImage下载远程镜像到本地
  3. CreateContainer根据镜像和上诉的配置构造容器
  4. StartContainer启动容器
  5. InspectContainer监视容器

效果

我们可以来看一下 当我们的环境中只安装了go和docker以及封装了go docker client这个第三方包的时候 运行我们的启动文件startUp.go将整个服务启动起来
微信图片_20180910223809.png-53.5kB

注意上诉运行的本质就是

  1. 拉取mongo远程镜像到本地
  2. 生成一个mongo容器进行运行 返回容器所在的IP地址和端口以备beego server连接mongo数据库
  3. 拉取我们之前打包好的beego server镜像到本地
  4. 生成beego server容器 并且将mongo容器运行返回的IP地址和端口作为环境变量传进beego server容器中以供连接数据库
  5. beego server容器运行之后自动运行所有单元测试和集成测试(这部分已经打包在Dockerfile中了成为镜像本身的一部分)

我们可以看一下这两个容器的运行情况
微信图片_20180910224321.png-25.5kB

在查看一下 我们的beego server容器中自动运行单元测试集成测试的结果输出 需要在docker logs命令中指定容器的名字
微信图片_20180910224438.png-54.7kB


微服务架构

说了这些可能会觉得莫名其妙 为什么要这样做呢 其实喔自己觉得这都是为了微服务架构做准备

goroutine与工作池

github地址


背景描述

我们在使用go的时候 特别是面对并发的情况下 经常需要使用多线程 goroutine可以用来解决这个问题 一个goroutine解决一个线程的问题 但是我们要知道一个系统的最大线程数是有限的 大到这个限制 那么线程数量就不会增加了 更重要的是 线程太多的时候 CPU需要在线程之间频繁地切换 切换过于频繁也会导致CPU的使用率下降的 所以我们是有必要限制在这种情况下的线程数量


方案

网上的一个方案就是设置工作池或者说线程池 我们从工作队列中获取工作之后只可以从这个工作池中获取一个可用的线程 然后执行工作 工作池中我们其实存的是并不是线程 而是用来传输工作的管道 我们通过限制在工作池中的管道的数量 从工作队列中获取的工作只可以被从工作池中获取出来的一条管道传输到另一边 然后在那边就行处理 这样就可以限制了线程的数量(以上思路和下面图片皆来自CSDN博主)

微信图片_20180822185246.png-34.8kB

那么有个问题是怎么限制上面所说的工作池中任务管道的数量呢?
其实我们可以将工作池本身就定义为一个管道 然后将限定数量的工作管道传输进这个工作池大管道中 然后所有的任务从任务队列中取出之后 都需要从工作池大管道中获取一个工作管道 然后通过这个工作管道将工作传输过去 让那边完成这个工作 完成工作之后再将这个工作管道发送回工作池管道 等待下一次被取出


实现

顶一个Task接口 里面有个完成这个工作的方法DoTask 所以的任务都需要实现这接口

1
2
3
type Task interface {
DoTask() error
}

具体定义了一种任务的结构体 实现了Task这个接口

1
2
3
4
5
6
7
8
type ItTask struct {

}

func (t ItTask) DoTask() error {
fmt.Println("a task is finished")
return nil
}

我们需要定义一个传输工作的管道类型

// 用来传输任务的管道
type TaskChan chan Task

像上面所说 我们把工作池也设计为一个管道

// 用来传输 传输任务的管道(就是上面那个) 的管道
type PoolChan chan TaskChan

然后我们需要两个全局变量

  • 缓存我们所有有待完成的工作的一个管道
  • 工作池大管道
1
2
3
4
5
6
7
8
9
var (
AllTaskQueue TaskChan
TaskPool PoolChan
)

func init() {
AllTaskQueue = make(TaskChan)
TaskPool = make(PoolChan)
}

上面开始我忘记了再init函数中对这两个公有变量进行初始化了 导致后面一开始使用这两个管道发送和接受信息一致没反应 但是也不报错 很坑爹 我觉得是因为公有变量如果没有被初始化 是不可以用来传输信息的 但是这种情况是不会报错的

然后我们需要将上面用来传输进一步封装 其实不封装也可以 主要是给给我们这个传输任务的管道加一个quit管道 以应道需要中途中断我们的任务

type Worker struct {
    Todo TaskChan
    quit chan bool
}

给Worker定义一个启动函数 这个函数将我们这个任务管道Todo发送给工作池管道TaskPool 然后等待这个工作管道中发送来新的工作任务 完成这个任务 并且重新将这个任务管道发送回工作池管道中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (w Worker) Start() {
go func() {
for {
TaskPool <- w.Todo
select {
case task := <-w.Todo:
if err := task.DoTask(); err != nil {
fmt.Println("task fail")
}
case <-w.quit:
return
}
}
}()
}
//产生一个新的worker
func NewWorker() *Worker {
w := &Worker{}
w.Todo = make(TaskChan)
w.quit = make(chan bool)
return w
}

此外我们还需要顶一个分发器
分发器中含有两个重要字段 一个数可用的worker指针数组(其实相当于是任务管道数组) 另一个也是一个quit管道 用来接收暂停的信号

type Dispatcher struct {
    AvailableWorkers []*Worker
    Quit chan bool
}

同时我们也需要声明一个分发器运行函数 这个函数首先需要声明限定数量的worker 然后将这些worker中的任务管道发送到公有变量 那个工作池管道中 然后每个worker中的任务管道等到传送过来的消息 并且进行处理

第二部分需要做的就是监听任务队列AllTaskQueue这个管道看看有没有新的任务被发送过来 一旦检测到 从工作池管道中获取一个任务管道 并且将这个任务从这个工作管道中传输过去

main函数中要做的事情就很简单了 一个就是运行一个分发器 一个是多线程地制造多个任务 然后阻塞地等待任务呗接受和完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
dis := pool.Dispatcher{}
forever := make(chan bool)
go func() {
dis.Run()
}()
for i := 0; i < 10; i++ {
go func() {
t := pool.ItTask{}
pool.AllTaskQueue <- t
}()
}
<- forever
}

运行结果
image_1clgm6h5upf4qto1d8ovfiuue15.png-23.9kB

完整的源代码可以看我github地址