程序员修仙-前奏
数据结构之 CXO让我做一个计算器
有人说数据结构是为算法服务的,我还要在加一句:数据结构和算法都是为业务服务的!!
CXO的需求果然不同凡响,又让菜菜想到了新的数据结构:栈
◆◆栈的特性◆◆
定义
栈(stack)又名堆栈,它是一种运算受限的线性表。其限制是仅允许在表的一端进行插入和删除运算。这一端被称为栈顶,相对的,把另一端称为栈底。向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。
栈作为一种数据结构,其中有几个特性需要提起大家注意:
1. 操作受限:何为操作受限?在栈的操作中,一般语言中针对栈的操作只有两种:入栈和出栈。并且操作只发生在栈的顶部。 有的同学会问,我用其他数据结构也一样能实现栈的效果。不错,但是每种数据结构都有自己的使用场景,没有一种绝对无用的数据结构。
2. 栈在数据结构上属于一种线性表,满足后进先出的原则。这也是栈的最大特性,几乎大部分后进先出的场景都可以使用栈这个容器。比如一个函数的调用过程中,局部变量的存储就是栈原理。当执行一个函数结束的时候,局部变量其实最先释放的是最后的局部变量。

◆◆实现◆◆
在内存分布上栈是用什么实现的呢?既然栈是一种线性结构,也就说可以用线性的内存分布数据结构来实现。
1. 数组实现栈(顺序栈):数组是在内存分布上连续的一种数据结构。经过以前的学习,我们知道数组的容量是不变的。如果业务上可以知道一个栈的元素的最大数量,我们完全可以用数组来实现。为什么这么说?因为数组的扩容在某些时候性能是比较低的。因为需要开辟新空间,并发生复制过程。
class MyStack
{
//数组容器
int[] container = new int[100];
//栈顶元素的索引
int TopIndex = -1;
//入栈操作
public void Push(int newValue)
{
if (TopIndex >= 99)
{
return ;
}
TopIndex++;
container[TopIndex] = newValue;
}
//出栈操作
public int Pop()
{
if (TopIndex < 0)
{
return 0;
}
var topValue = container[TopIndex];
TopIndex--;
return topValue;
}
}
2. 链表实现栈(链式栈):为了应对数组的扩容问题,我们可以用链表来实现栈。栈的顶部元素永远指向链表的头元素即可。具体代码有兴趣的同学可以实现一下。
由以上可以看出,栈其实是基于基础数据结构之上的一个具体业务形式的封装。即:先进后出。
◆◆性能◆◆
基于数组的栈我们暂且只讨论未发生数组重建的场景下。无论是数组实现还是链表实现,我们发现栈的内部其实是有一个指向栈顶元素的指针,不会发生遍历数组或者链表的情形,所以栈的出栈操作时间复杂度为O(1)。
至于入栈,如果你看过我以前介绍数组和链表的文章,你可以知道,给一个数组下标元素赋值的操作时间复杂度为O(1),在链表头部添加一个元素的操作时间复杂度也是O(1)。所以无论是数组还是链表实现栈,入栈操作时间复杂度也是O(1)。并且栈只有入栈出栈两种操作,比其他数据结构有N个操作方法要简单很多,也不容易出错。
至于发生数组重建,copy全部数据的过程其实是一个顺序栈最坏的时间复杂度,因为和原数组的元素个数n有关,所以时间复杂度为O(n)
◆◆设计要点◆◆
那一个计算器怎么用栈来实现呢?其实很多计算器就是通过两个栈来实现的,其中一个栈保存操作的数,另一个栈保存运算符。
我们从左到右遍历表达式,当遇到数字,我们直接压入操作数栈;当遇到操作符的时候,当前操作符与操作符栈顶的元素比较优先级(先乘除后加减的原则)。如果当前运算符比栈顶运算符优先级高,那说明不需要执行栈顶运算符运算,我们直接将当前运算符也入栈;
如果当前运算符比栈顶运算符优先级低,那说明该执行栈顶运算符的运算了。然后出栈运算符栈顶元素,数据栈顶两个元素,然后进行相关运算,然后把运算结果再次压入数据栈。
golang版本
1package stack
2
3import (
4 "errors"
5 "fmt"
6)
7
8type Stack struct {
9 Element []interface{} //Element
10}
11
12func NewStack() *Stack {
13 return &Stack{}
14}
15
16func (stack *Stack) Push(value ...interface{}) {
17 stack.Element = append(stack.Element, value...)
18}
19
20//返回下一个元素
21func (stack *Stack) Top() (value interface{}) {
22 if stack.Size() > 0 {
23 return stack.Element[stack.Size()-1]
24 }
25 return nil //read empty stack
26}
27
28//返回下一个元素,并从Stack移除元素
29func (stack *Stack) Pop() (value interface{}) {
30 if stack.Size() > 0 {
31 d := stack.Element[stack.Size()-1]
32 stack.Element = stack.Element[:stack.Size()-1]
33 return d
34 }
35 return nil
36}
37
38//交换值
39func (stack *Stack) Swap(other *Stack) {
40 switch {
41 case stack.Size() == 0 && other.Size() == 0:
42 return
43 case other.Size() == 0:
44 other.Element = stack.Element[:stack.Size()]
45 stack.Element = nil
46 case stack.Size() == 0:
47 stack.Element = other.Element
48 other.Element = nil
49 default:
50 stack.Element, other.Element = other.Element, stack.Element
51 }
52 return
53}
54
55//修改指定索引的元素
56func (stack *Stack) Set(idx int, value interface{}) (err error) {
57 if idx >= 0 && stack.Size() > 0 && stack.Size() > idx {
58 stack.Element[idx] = value
59 return nil
60 }
61 return errors.New("Set失败!")
62}
63
64//返回指定索引的元素
65func (stack *Stack) Get(idx int) (value interface{}) {
66 if idx >= 0 && stack.Size() > 0 && stack.Size() > idx {
67 return stack.Element[idx]
68 }
69 return nil //read empty stack
70}
71
72//Stack的size
73func (stack *Stack) Size() int {
74 return len(stack.Element)
75}
76
77//是否为空
78func (stack *Stack) Empty() bool {
79 if stack.Element == nil || stack.Size() == 0 {
80 return true
81 }
82 return false
83}
84
85//打印
86func (stack *Stack) Print() {
87 for i := len(stack.Element) - 1; i >= 0; i-- {
88 fmt.Println(i, "=>", stack.Element[i])
89 }
90}
91//========================分割线==============================//
92package calculator
93
94import (
95 "calculator/stack"
96 "strconv"
97)
98
99type Calculator struct{}
100
101var DataStack *stack.Stack
102var OperatorStack *stack.Stack
103
104func NewCalculator() *Calculator {
105 DataStack = stack.NewStack()
106 OperatorStack = stack.NewStack()
107 return &Calculator{}
108}
109
110func (c *Calculator) Cal(dataOrOperator string) int {
111
112 if data, ok := strconv.ParseInt(dataOrOperator, 10, 64); ok == nil {
113 //如果是数据直接入数据栈
114 // fmt.Println(dataOrOperator)
115 DataStack.Push(data)
116 } else {
117
118 //如果是操作符,和栈顶操作符比较优先级,如果大于栈顶,则直接入栈,否则栈顶元素出栈 进行操作
119 if OperatorStack.Size() <= 0 {
120 OperatorStack.Push(dataOrOperator)
121 } else {
122 //当前运算符的优先级
123 currentOpePrecedence := operatorPrecedence(dataOrOperator)
124 //当前运算符栈顶元素的优先级
125 stackTopOpePrecedence := operatorPrecedence(OperatorStack.Top().(string))
126 if currentOpePrecedence > stackTopOpePrecedence {
127 //如果当前运算符的优先级大于栈顶元素的优先级,则入栈
128 OperatorStack.Push(dataOrOperator)
129 } else {
130 //运算符栈顶元素出栈,数据栈出栈两个元素,然后进行运算
131 stackOpe := OperatorStack.Pop()
132 data2 := DataStack.Pop()
133 data1 := DataStack.Pop()
134
135 ret := calculateData(stackOpe.(string), data1.(int64), data2.(int64))
136 DataStack.Push(ret)
137 OperatorStack.Push(dataOrOperator)
138 }
139 }
140 }
141 return 0
142}
143
144func (c *Calculator) GetResult() int64 {
145 var ret int64
146 for {
147
148 if OperatorStack.Size() > 0 {
149 stackOpe := OperatorStack.Pop()
150 data2 := DataStack.Pop()
151 data1 := DataStack.Pop()
152
153 ret = calculateData(stackOpe.(string), data1.(int64), data2.(int64))
154
155 DataStack.Push(ret)
156 } else {
157 break
158 }
159 }
160
161 return ret
162}
163
164func calculateData(operatorString string, data1, data2 int64) int64 {
165 switch operatorString {
166 case "+":
167 return data1 + data2
168 case "-":
169 return data1 - data2
170 case "*":
171 return data1 * data2
172 case "/":
173 return data1 + data2
174 default:
175 return 0
176 }
177}
178
179func operatorPrecedence(a string) int {
180 i := 0
181 switch a {
182 case "+":
183 i = 1
184 case "-":
185 i = 1
186 case "*":
187 i = 2
188 case "/":
189 i = 2
190 }
191 return i
192}
193//========================分割线==============================//
194package main
195
196import (
197 "calculator/calculator"
198 "flag"
199 "fmt"
200)
201
202var (
203 inputStr = flag.String("input", "", "请输入...")
204)
205
206func main() {
207 flag.Parse()
208
209 var lstAllData []string
210 var tempData string
211
212 rs := []rune(*inputStr)
213 for i := 0; i < len(rs); i++ {
214 if string(rs[i]) == "+" || string(rs[i]) == "-" || string(rs[i]) == "*" || string(rs[i]) == "/" {
215 lstAllData = append(lstAllData, tempData)
216 lstAllData = append(lstAllData, string(rs[i]))
217 tempData = ""
218 } else {
219 tempData += string(rs[i])
220 }
221 if i == len(rs)-1 {
222 lstAllData = append(lstAllData, tempData)
223 }
224 }
225
226 ca := calculator.NewCalculator()
227 for _, v := range lstAllData {
228 ca.Cal(v)
229 }
230 ret := ca.GetResult()
231 fmt.Println(ret)
232}
c#版本
1class Program
2 {
3 static void Main(string[] args)
4 {
5 List<string> lstAllData = new List<string>();
6 //读取输入的表达式,并整理
7 string inputStr = Console.ReadLine();
8 string tempData = "";
9 for (int i = 0; i < inputStr.Length; i++)
10 {
11 if (inputStr[i] == '+' || inputStr[i] == '-' || inputStr[i] == '*' || inputStr[i] == '/')
12 {
13 lstAllData.Add(tempData);
14 lstAllData.Add(inputStr[i].ToString());
15 tempData = "";
16 }
17 else
18 {
19 tempData += inputStr[i];
20 }
21 if(i== inputStr.Length - 1)
22 {
23 lstAllData.Add(tempData);
24 }
25 }
26 foreach (var item in lstAllData)
27 {
28 Calculator.Cal(item.ToString());
29 }
30 var ret = Calculator.GetResult();
31 Console.WriteLine(ret);
32 Console.Read();
33 }
34
35 }
36 //计算器
37 class Calculator
38 {
39 //存放计算数据的栈
40 static Stack<int> DataStack = new Stack<int>();
41 //存放操作符的栈
42 static Stack<string> OperatorStack = new Stack<string>();
43 public static int Cal(string dataOrOperator)
44 {
45 int data;
46 bool isData = int.TryParse(dataOrOperator, out data);
47 if (isData)
48 {
49 //如果是数据直接入数据栈
50 DataStack.Push(data);
51 }
52 else
53 {
54 //如果是操作符,和栈顶操作符比较优先级,如果大于栈顶,则直接入栈,否则栈顶元素出栈 进行操作
55 if (OperatorStack.Count <= 0)
56 {
57 OperatorStack.Push(dataOrOperator);
58 }
59 else
60 {
61 //当前运算符的优先级
62 var currentOpePrecedence = OperatorPrecedence(dataOrOperator);
63 //当前运算符栈顶元素的优先级
64 var stackTopOpePrecedence = OperatorPrecedence(OperatorStack.Peek());
65 if (currentOpePrecedence > stackTopOpePrecedence)
66 {
67 //如果当前运算符的优先级大于栈顶元素的优先级,则入栈
68 OperatorStack.Push(dataOrOperator);
69 }
70 else
71 {
72 //运算符栈顶元素出栈,数据栈出栈两个元素,然后进行运算
73 var stackOpe = OperatorStack.Pop();
74 var data2 = DataStack.Pop();
75 var data1 = DataStack.Pop();
76 var ret = CalculateData(stackOpe, data1, data2);
77 DataStack.Push(ret);
78 OperatorStack.Push(dataOrOperator);
79 }
80 }
81 }
82 return 0;
83 }
84 //获取表达式最后的计算结果
85 public static int GetResult()
86 {
87 var ret = 0;
88 while (OperatorStack.Count > 0)
89 {
90 var stackOpe = OperatorStack.Pop();
91 var data2 = DataStack.Pop();
92 var data1 = DataStack.Pop();
93 ret = CalculateData(stackOpe, data1, data2);
94 DataStack.Push(ret);
95 }
96 return ret;
97 }
98 //根据操作符进行运算,这里可以抽象出接口,请自行实现
99 static int CalculateData(string operatorString, int data1, int data2)
100 {
101 switch (operatorString)
102 {
103 case "+":
104 return data1 + data2;
105 case "-":
106 return data1 - data2;
107 case "*":
108 return data1 * data2;
109 case "/":
110 return data1 + data2;
111 default:
112 return 0;
113 }
114 }
115 //获取运算符优先级
116 public static int OperatorPrecedence(string a) //操作符优先级
117 {
118 int i = 0;
119 switch (a)
120 {
121 case "+": i = 1; break;
122 case "-": i = 1; break;
123 case "*": i = 2; break;
124 case "/": i = 2; break;
125 }
126 return i;
127
128 }
129 }
程序猿修仙之路--数据结构之设计高性能访客记录系统
我想给咱们的用户做个个人空间,目前先有访客记录就可以,最近访问的人显示在最上边,由于用户量有十几亿,可能对性能要求比较高,三天后上线,你做一下吧!
需求要点
每个用户都有自己的个人空间,当有其他用户来访问的时候,需要添加访客记录,并且更新为最新的访客,这里设计到一个坑,如果存在这个用户的访问记录需要更新用户的最后访问时间。那这个需求在技术维度来说,有什么特点吗?
先想10秒钟,在接着往下看!!!
有什么设计要点呢?
用户的访客记录一定要缓存,要不然怎么抗住大并发呢?
由于最新的访客记录变化非常快,要有一种能快速添加新数据,删除老数据的数据结构。
缓存的篇章今日暂且不说,说一下以上的第二点,也就引出了今日数据结构主角:链表
链表
链表百科:链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表属于线性结构
链表分类
1. 单链表:链表中的元素的指向只能指向链表中的下一个元素或者为空,元素之间不能相互指向。也就是一种线性链表。

public class Node<T>
{
//当前节点的数据元素
public T Data { get; set; }
//当前节点的下一个元素
public Node<T> NextNode { get; set; }
}
2. 双向链表:每个链表元素既有指向下一个元素的指针,又有指向前一个元素的指针,其中每个结点都有两种指针。

public class Node<T>
{
//当前节点的前一个节点
public Node<T> PreNode { get; set; }
//当前节点的数据元素
public T Data { get; set; }
//当前节点的下一个元素
public Node<T> NextNode { get; set; }
}
3. 循环链表:指的是在单向链表和双向链表的基础上,将两种链表的最后一个结点指向第一个结点从而实现循环。


特性
1. 元素的数量可以随时扩充。由于链表在物理的存储单元上是非连续的,这就早就了它天生的优势,我的节点可以在任意符合要求的地方分配内存。
2. 添加元素:
单链表:
当在一个位置N之后插入新元素的时候,单链表首先把当前位置N的元素的Next指针指向新的元素,然后新的元素的Next指针指向N+1位置的元素。当然如果是在首位置插入新元素,只需要把新元素的Next指针指向链表的首元素即可,同理,如果要在单链表尾部插入新元素,只需要把单链表的尾部元素的Next指针指向新元素。至于循环单链表,无所谓首元素和尾元素之分。

双向链表:
在位置N之后添加新元素和单链表原理类似,原理也是修改元素的指针指向。但是这里有一个不同,双向链表要修改前后元素(N位置和N+1位置)和新元素三个Node的指针,所以略微麻烦一点。

3. 删除元素:
单链表:
当要删除位置N的元素的时候,只需要把N-1位置元素的Next指针指向N+1即可。

双向链表:
当要删除位置N的元素的时候,需要修改N-1位置元素的Next指针指向N+1元素,同时还要修改N+1位置元素的Pre指针指向N-1元素。

4. 查找元素:
由于链表的元素在内存中并非连续,所以不能像数组那样拥有O(1)的查找时间复杂度,只能是通过首元素去遍历链表,所以时间复杂度为O(n)
程序设计
给你10秒回到X总的需求中来。通过对链表的介绍,我们该选择哪种链表呢?这里我先说一下我的思路,如有错误请指正:
1. 当一个访客进入个人空间的首页时,大多数情况下,访客记录只需要缓存前100条或者200条即可,也就是说这个场景是存在热点数据的,80%(甚至更高)的请求命中在最近100条访客数据上,很少人会去查看很久以前的记录。所以基于占用内存空间上的考虑,我决定缓存最近的100条访客数据。
2. 假设我用链表缓存了前100条数据,其中在非首位置有一条访客A的记录,此时A又访问的这个用户空间,我需要把A的记录移到首位置,这个过程经历了删除A数据,在首位置添加A数据。假如A开始的位置是N,我在删除N位置数据的时候,需要查找N-1的位置元素修改其指针指向,如果是单链表由于当前位置N的元素中没有N-1位置元素的信息,所有需要重新遍历链表。如果是双向链表呢,位置N的元素中保存了位置N-1的元素,所以没有必要在重新遍历链表了,这也是双向链表对比单链表的优势,虽然内存占用上多了一个指针的内存大小,但是在实际的应用场景中更为常用。所以我选择双向链表。删除操作和添加操作时间复杂度都是O(1).
3. 对同一个空间的访问,必然存在锁和多线程的问题。所以我在选择框架的时候优先选择了基于Actor模型的框架。避免了在同一个用户空间上加锁的操作。
4. 由于基于Actor模型的框架,所以我没有采用类似Redis这样的进程外缓存,而是采用了进程内缓存,毕竟网络传输的速度再快也比内存操作要慢的多。应用层的Actor服务天然支持分布式。如果对actor 不太了解的同学可以度娘一下。
优化
1. 阅读到这里你是否感觉哪里有问题呢?是的,就是链表元素的查找,由于只能是遍历,所有链表查找元素的时间复杂度为O(n),那有没有办法优化呢?那就是我们以后要讲的另外一种数据结构了。
2. 空间的访客记录是以时间为维度的倒序排列,所以业务以及DB时间列的设计类型推荐为UTC时间戳long类型,毕竟long类型在多数语言中比datetime类型占用内存要小很多。
3. 无论是否使用缓存,用户的访问记录都是需要DB来持久化的,当有大量的请求的时候,我们可以利用某种机制来批量持久化到DB,而不是一个请求就访问数据库一次。
4. 当对空间的访客记录实时性要求不是很高的时候,我们可以每10秒或者5秒更新缓存,也就是批量更新缓存,这比单条加锁更新缓存效果更好。
程序猿修仙之路--数据结构之你是否真的懂数组?
数据结构
但凡IT江湖侠士,算法与数据结构为必修之课。早有前辈已经明确指出:程序=算法+数据结构 。要想在之后的江湖历练中通关,数据结构必不可少。数据结构与算法相辅相成,亦是阴阳互补之法。
开篇
说道数组,几乎每个IT江湖人士都不陌生,甚至过半人还会很自信觉的它很简单。 的确,在菜菜所知道的编程语言中几乎都会有数组的影子。不过它不仅仅是一种基础的数据类型,更是一种基础的数据结构。如果你觉的对数组足够了解,那能不能回答一下:
数组的本质定义?
数组的内存结构?
数组有什么优势?
数组有什么劣势?
数组的应用场景?
数组为什么大部分都从0开始编号?
数组能否用其他容器来代替?
定义
所谓数组,是相同的元素序列。数组是在程序设计中,为了处理方便,把具有相同类型的若干元素按无序的形式组织起来的一种形式。
——百科
正如以上所述,数组在应用上属于数据的容器。不过我还是要补充两点:
1. 数组在数据结构范畴属于一种线性结构,也就是只有前置节点和后续节点的数据结构,除数组之外,像我们平时所用的队列,栈,链表等也都属于线性结构。

有线性结构当然就有非线性结构,比如之后我们要介绍的二叉树,图 等等,这里不再展开~~~

2. 数组元素在内存分配上是连续的。这一点对于数组这种数据结构来说非常重要,甚至可以说是它最大的“杀手锏”。下边会有更详细的介绍。
优势和劣势
优势
我相信所有人在使用数组的时候都知道数组可以按照下标来访问,例如 array[1] 。作为一种最基础的数据结构是什么使数组具有这样的随机访问方式呢?天性聪慧的你可能已经想到了:内存连续+相同数据类型。
现在我们抽象一下数据在内存上分配的情景。

1. 说到数组按下标访问,不得不说一下大多数人的一个“误解”:数组适合查找元素。为什么说是误解呢,是因为这种说法不够准确,准确的说数组适合按下标来查找元素,而且按照下标查找元素的时间复杂度是O(1)。为什么呢?我们知道要访问数组的元素需要知道元素在内存中对应的内存地址,而数组指向的内存的地址为首元素的地址,即:array[0]。由于数组的每个元素都是相同的类型,每个类型占用的字节数系统是知道的,所以要想访问一个数组的元素,按照下标查找可以抽象为:
array[n]=array[0]+size*n
以上是元素地址的运算,其中size为每个元素的大小,如果为int类型数据,那size就为4个字节。其实确切的说,n的本质是一个离首元素的偏移量,所以array[n]就是距离首元素n个偏移量的元素,因此计算array[n]的内存地址只需以上公式。

论证一下,如果下标从1开始计算,那array[n]的内存地址计算公式就会变为:
array[n]=array[0]+size*(n-1)
对比很容易发现,从1开始编号比从0开始编号每次获取内存地址都多了一次 减法运算,也就多了一次cpu指令的运行。这也是数组从0下标开始访问一个原因。
其实还有一种可能性,那就是所有现代编程语言的鼻祖:C语言,它是从0开始计数下标的,所以现在所有衍生出来的后代语言也就延续了这个传统。虽然不符合人类的思想,但是符合计算机的原理。当然也有一些语言可以设置为不从下标0开始计算,这里不再展开,有兴趣的可以去搜索一下。
2. 由于数组的连续性,所以在遍历数组的时候非常快,不仅得益于数组的连续性,另外也得益于cpu的缓存,因为cpu读取缓存只能读取连续内存的内容,所以数组的连续性正好符合cpu缓存的指令原理,要知道cpu缓存的速度要比内存的速度快上很多。
劣势
1. 由于数组在内存排列上是连续的,而且要保持这种连续性,所以当增加一个元素或删除一个元素的时候,为了保证连续性,需要做大量元素的移动工作。
举个栗子:要在数组头部插入一个新元素,为了在头部腾出位置,所有的元素都要后移一位,假设元素个数为n,这就导致了时间复杂度为O(n)的一次操作,当然如果是在数组末尾插入新元素,其他所有元素都不必移动,操作的时间复杂度为O(1)。
当然这里有一个技巧:如果你的业务要求并不是数组连续有序的,当在位置k插入元素的时候,只需要把k元素转移到数组末尾,新元素插入到k位置即可。当然仔细沉思一下这种业务场景可能性太小了,数组都可以无序,我直接插入末尾即可,没有必要非得在k位置插入把。~~
当然还有一个特殊场景:如果是多次连续的k位置插入操作,我们完全可以合并为一次“批量插入”操作:把k之后的元素整体移动sum(插入次数)个位置,无需一个个位置移动,把三次操作的时间复杂度合并为一次。
与插入对应的就有删除操作,同理,删除操作数组为了保持连续性,也需要元素的移动。
综上所述,数组在添加和删除元素的场景下劣势比较明显,所以在具体业务场景下应该避免频繁添加和删除的操作。
2. 数组的连续性就要求创建数组的时候,内存必须有相应大小的连续区块,如果不存在,数组就有可能出现创建失败的现象。在某些高级语言中(比如c#,golang,java)就有可能引发一次GC(垃圾回收)操作,GC操作在系统运行中是非常昂贵的,有的语言甚至会挂起所有线程的操作,对外的表现就是“暂停服务”。
3. 数组要求所有元素为同一个类型。在存储数据维度,它可能算是一种劣势,但是为了按照下标快速查找元素,业务中这也是一种优势。仁者见仁智者见智而已。
4. 数组是长度固定的数据结构,所以在原始数组的基础上扩容是不可能的,有的语言可能实现数组的“伪扩容”,为什么说是“伪”呢,因为原理其实是创建了一个容量更大的数组来存放原数组元素,发生了数据复制的过程,只不过对于调用者而已透明而已。
5. 数组有访问越界的可能。我们按照下标访问数组的时候如果下标超出了数组长度,在现代多数高级语言中,直接就会引发异常了,但是一些低级语言比如C 有可能会访问到数组元素以外的数据,因为要访问的内存地址确实存在。
其他
很多编程语言中你会发现“纯数组”并没有提供直接删除元素的方法(例如:c#,golang),而是需要将数组转化为另一种数据结构来实现数组元素的删除。比如在golang种可以转化为slice。这也验证了数组的不变性。
应用场景我们学习的每个数据结构其实都有对应的适合场景,只不过是场景多少的问题,具体什么时候用,需要我们对该数据结构的特性做深入分析。
关于数组的特性,通过以上介绍可以知道最大的一个亮点就是按照下标访问,那有没有具体业务映射这种特性呢?
1. 相信很多IT人士都遇到过会员机制,每个会员到达一定的经验值就会升级,怎么判断当前的经验是否到达升级条件呢?我们是不是可以这样做:比如当前会员等级为3,判断是否到达等级4的经验值,只需要array[4]的值判断即可,大多数人把配置放到DB,资源耗费太严重。也有的人放到其他容器缓存。但是大部分场景下查询的时间复杂度要比数组大很多。
2. 在分布式底层应用中,我们会有利用一致性哈希方案来解决每个请求交给哪个服务器去处理的场景。有兴趣的同学可以自己去研究一下。其中有一个环节:根据哈希值查找对应的服务器,这是典型的读多写少的应用,而且比较偏底层。如果用其他数据结构来解决大量的查找问题,可能会触碰到性能的瓶颈。而数据按下标访问时间复杂度为O(1)的特性,使得数组在类似这些应用中非常广泛。
程序员过关斩将--面试官再问你Http请求过程,怼回去!
描述一个Http请求的过程,
Http介绍
超文本传输协议(HTTP,HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议。所有的WWW文件都必须遵守这个标准。设计HTTP最初的目的是为了提供一种发布和接收HTML页面的方法。1960年美国人Ted Nelson构思了一种通过计算机处理文本信息的方法,并称之为超文本(hypertext),这成为了HTTP超文本传输协议标准架构的发展根基。
以上并非此次文章重点,更详细的http介绍请移步 www.baidu.com
Http是一种网络协议,而且是无状态的超文本协议,基于Tcp/Ip协议的应用层协议。
我要IP
当用户请求某个域名的资源,比如在浏览器敲入http://www.qq.com的时候,浏览器首先会根据输入的域名去查询IP地址。去哪查呢?这里就需要引入DNS的概念,可以把DNS看做是域名映射IP的账簿。
当客户端发送一个DNS请求的时候,首先本地的DNS服务器会接收到请求,会在本地先查询缓存中有没有当前域名和IP的映射关系,如果有则直接返回IP信息,如果没有,则会询问其他DNS服务器,这里简单说一下网络上DNS服务器的结构,DNS服务器在网络上是树状结构的,存在一个根服务器,根服务器的子节点是一级域名服务器(比如 .com, .cn),一级域名服务器的子节点又称为权威(权限)DNS服务器

当本地DNS服务器没有相关查询信息的时候会依照以上树的顺序查询域名和IP的对应关系,查到之后会缓存到本地DNS,这个过程最终的结果就是获取到相关域名对应的IP地址,如果客户端输入的是IP地址信息,则省略了以上查询IP的过程了。
访问互联网的任何网站本质上都是依据IP来寻址的。
建立Tcp连接
当一个http请求发出之后,并且获取到了正确的服务器IP地址,这个时候就可以建立连接了。有一点需要明确:http协议是基于Tcp协议的。所以第一步就需要建立Tcp连接,这个过程就是很多网络文章所说的三次握手:
Client:Hi,我是 Client。
Server:您好 Client,我是 Server。
Client:您好 Server...
这里是三次握手可以以这样的顺序来表示:client的问->server的答->client的答
有的面试官无聊会问为什么是三次握手而不是两次或者四次五次呢?你可以理解,当两个人A和B要想互相联系的时候,最简单的方式就是A提问然后能收到B的回答,B提问能收到A的回答。这也是三次握手的核心。
平时所说的Tcp是面向连接的,这里的连接其实是双方约定一定格式来进行通信的过程(包括发包的顺序,buffer的大小等约定),在逻辑上好像是维持了一条连接而已。
我要出网关
一旦Tcp连接建立起来,http请求就可以组织数据发送报文了。目前http协议的版本大部分是1.1,在这个版本中有一个属性 Keep-Alive,这个属性标示要保持此http连接建立的TCP连接,默认是开启的。
网络上有文章大篇幅描述http的长连接信息,其实是错误的说法,长连接是针对tcp连接,http连接打开keepalive选项只不过保持了tcp连接不断开而已。
HTTP 的报文大概分为三大部分。第一部分是请求行,第二部分是请求头(header),第三部分是请求体(body)。这里具体的http协议其他概念不再展开讨论,因为内容有点多。http协议位于应用层,所以要发送的报文首先会把http协议相关的内容包含在包中,然后传给下一层。

下一层是传输层,这一层主要有两个协议:Tcp和Udp,http协议选择的是tcp协议,tcp会有两个端口信息,一个是源端口,一个是目标端口,比如http请求一般目标端口是80。传输层把端口信息封装完毕,接着把请求包传给网络层。

网络层的协议是IP协议,在这一层会把源Ip地址和目标IP地址封装进去(目标IP就是请求的网站ip,查询dns获得)。

操作系统知道了要发往的IP地址,会判断这个ip是否在本地局域网内(根据子网掩码来判断),如果不在的话,则需要网关把这个请求发送出去(网关的ip一般是DHCP协议配置的)。操作系统怎么获取网关在哪呢?这个过程基本上靠的是广播,应用的协议是ARP协议,当局域网内的所有设备接收到ARP协议的内容之后会判断ip是否和网关ip相同,如果相同就会回复,经过这个过程,系统找到了网关,获取到了网关的MAC地址,并把网关的MAc地址和本机的MAc地址封装进请求包,发给下一层MAC层,最后网卡把消息发往网关。

MAC地址主要用在同一个局域网内定位某个计算机,是在局域网内才有效的地址
到达目标服务器
请求包到达网关,网关会根据消息的MAC地址来判断是否和自己的mac相同,如果相同则把消息接收下来。接着会判断消息中目标IP,如果目标IP未在自己的局域网中,则需要根据自己的路由规则把消息发送给下一个相连的网关。网关和网关之间是通信的,至于网关怎么计算出最优路径这里不再展开。我们以常见的家庭路由器为例,每个路由器的网关IP其实是运营商给分配的,并且网络包发送出去一般都是采用修改IP的方式(NAT)。具体步骤:
1网关检查目标IP是否在自己的局域网内,如果不在,则获取要传送的下一个网关mac和IP,把目标IP和mac修改为下一个网关的IP和mac,并把来源IP和mac修改为当前网关的IP(外网IP)和mac。2下一个网关收到消息,首先检查mac是否和自己匹配,如果匹配接会检查目标IP是否在自己的局域网内,如果不在则重复以上步骤
3重复以上步骤直到目标服务器所在的网关。
4目标服务器所在的网关收到消息,会判断出来当前的目标IP在我的局域网范围,就不会在跳跃下一个网关,而是向局域网内发ARP请求寻找目标服务器,目标服务器收到请求会响应,网关会把具体的请求发送给目标服务器。
5目标服务器收到消息会解析请求的消息,在对比完mac和IP信息之后,会得到端口的信息,目标服务器则在本机寻找监听这个端口的程序,http服务器很有可能是nginx或者其他web服务器。
6请求通过端口传送给具体的处理的程序,程序会解析http请求的内容,根据内容作出相应的回复。
7请求按照以上所有步骤把响应返回给请求方(网关路由器会记住来源路径),至此一个http请求结束
网关(路由器)之间通过路由表来决定下一个跳跃的网关地址
写在最后以上只是http请求的一个大概过程,其实每一步都非常复杂,没有详细展开。比如:路由协议、ip的分配 等等。
程序员过关斩将--你的业务是可变的吗
....是这样的,我做的商城有一个订单统计的功能,我除了记录订单之外,还记录了下单人的信息,其中有省市区县id的信息
然后业务部门会有根据区域(省市区县)订单的各种统计需求,比如xx省的订单数,订单总金额等统计。
由于人员的区域信息可能会变动,用户区域信息就算是同步或者不同步都会出现差错
每个用户信息只存在一条记录?
是的。比如用户A现在属于省id为1000的省,生成了一个订单,这个省的订单数统计会加1,假如订单总数变为了20001,然后用户A所属的省的Id变为了1001,那Id是1000的省的订单总数又变成了20000
请不要跟我说用ES或者其他,其实很多中小公司的业务就是如此,就是基于mysql或者sqlserver 来搞这样的业务
业务场景不知道通过D妹子的阐述,大家了解情况了没。这里菜菜再详细说一下。D妹子的程序记录了订单的log来供其他业务(比如统计)使用,这里就以统计业务来说,OrderLog表设计如下:
| 列名 | 数据类型 | 描述 |
|---|---|---|
| OrderId | nvarchar(100) | 订单号,主键 |
| UserId | int | 下单用户id |
| Amount | int | 订单的金额 |
| 其他字段省略... |
除此之外还有一个用户信息表UserInfo,设计如下:
| 列名 | 数据类型 | 描述 |
|---|---|---|
| UserId | int | 用户id,主键 |
| ProvinceId | int | 用户省的id |
| CityId | int | 用户市的id |
| CountyId | int | 用户区县的id |
涉及到拆单等复杂的订单操作,表的设计可能并非如此,但是不影响菜菜要说的事
变数的业务现在假如要统计某个省的订单总数,sql如下:
select count(0) from OrderLog o inner join UserInfo u on o.UserId=u.UserId where ProvinceId=@ProvinceId
有问题吗,sql没问题,这时候用户A的省市区县信息突然变了(也许是在其他地区买房,户口迁移了),也就是说UserInfo表里的信息变了,那用以上的sql统计用户A以前省市区县的订单信息是不是就会出错了呢?(产品狗说在哪下的订单就属于哪的订单)
业务的定位以上的问题你觉得是不是很简单呢?只要稍微修改一下表也许就够了。但是,菜菜要说的不是针对这一个业务场景,而是所有的业务场景的设计。那你有没有想过为什么D妹子的设计会出现这样的问题呢?
深刻理解业务才能避免以上类似的错误发生,一定要深刻理解不变和可变的业务点。 拿D妹子的统计来说,你的业务是统计区域的订单数,这个业务在产品设计上定义的是不变性,也就是说在行为产生的那个时间点就确定了业务性质,这个业务的性质不会随着其他变而变。具体到当前业务就是:用户在X省下的订单不会随着用户区域信息的变化而变化,说白了就是说用户在X省生成的订单永远属于X省。
谈到业务性质的不变性,对应的就有业务的可变性。假如你开发过类似于QQ空间这样的业务,那肯定也做过类似访客的功能。当要显示访客记录的时候,访客的名称在多数情况的设计中属于可变性的业务。什么意思呢?也就是说一个用户修改了姓名,那所有显示这个用户访问记录的的地方姓名都会同时改变。
说到这里,各位再回头看一下D妹子的业务,这里又牵扯到一个系统设计的问题,众所周知,一个好的系统设计需要把业务的变化点抽象提取出来,D妹子订单统计的业务变化点在于用户的省市区县会变化,订单的金额、订单号等信息不会变化。所以你们觉得是不是D妹子的数据表可以修改一下呢?
数据表的改进
01
改进用户信息按照以上的阐述,D妹子业务的变化点在于用户的省市区域信息,所以可以把用户信息的表抽象提取出来,主键不再是用户id
| 列名 | 数据类型 | 描述 |
|---|---|---|
| Id | int | 主键Id,主键 |
| UserId | int | 用户id |
| ProvinceId | int | 用户省的id |
| CityId | int | 用户市的id |
| CountyId | int | 用户区县的id |
这样的话用户订单log表中就变为
| 列名 | 数据类型 | 描述 |
|---|---|---|
| OrderId | nvarchar(100) | 订单号,主键 |
| UserBId | int | 对应用户表中的主键id |
| Amount | int | 订单的金额 |
| 其他字段省略... |
这样设计的话,如果用户的省市区县信息有变动,相应的用户信息表中会存在多条用户省市区县数据
这里的用户信息表并非是用户对象的主表,而是根据订单业务衍生出来的表02
改进业务数据表根据业务的变性和不变性,既然把订单区域统计的业务定义为不变的业务性质,那订单的log表完全可以这样设计
| 列名 | 数据类型 | 描述 |
|---|---|---|
| OrderId | nvarchar(100) | 订单号,主键 |
| UserId | int | 下单用户id |
| ProvinceId | int | 用户省的id |
| CityId | int | 用户市的id |
| CountyId | int | 用户区县的id |
| Amount | int | 订单的金额 |
| 其他字段省略... |
写在最后
各位读到这里,可能会感觉菜菜这次写的其实很鸡肋,但是,D妹子的场景却是真实环境中遇到的问题。问题的本质还是变性业务和非变性业务的定义和划分,和架构设计一样,数据库的设计其实也需要把变动的业务存储点进行抽象,其实应该说是抽离出来。
论商品促销代码的优雅性
据我所知,几乎所有的互联网公司都带有和电商有关的项目,而且在大多数公司里面还是举足轻重的重头戏,比如京东,淘宝。既然有电商项目,必然会涉及到商品,一旦有商品就会有各种促销活动,比如 满100减20,三八妇女节9折等等类似活动。作为一个coder怎么才能在实现产品狗的需求下,最小改动代码,最优雅的实现呢。今天菜菜不才,就D妹子的问题献丑一番。以下以.netCore c#代码为例,其他语言类似。
◆◆D妹子版本◆◆首先D妹子有一个商品的对象,商品里有一个价格的属性,价格的单位是分
class Product
{
//其他属性省略
public int Price { get; set; }
}
下面有一个满100减20的活动,在结算价格的时候代码是这样的
public int GetPrice()
{
Product p = new Product();
int ret = p.Price;
if (p.Price >= 100*100)
{
ret = ret - 20 * 100;
}
return ret;
}
有问题吗?按照需求来说没有问题,而且计算的结果也正确。但是从程序艺术来说,其实很丑陋。现在又有一个全场9折的活动,恰巧有一个商品参与了以上两个活动,而且还可以叠加使用(假设活动参与的顺序是先折扣后满减)。这时候D妹子的代码就变成了这样
public int GetPrice()
{
Product p = new Product();
//9折活动
int ret = p.Price * 90 / 100;
//满减活动
if (ret >= 100 * 100)
{
ret = ret - 20 * 100;
}
return ret;
}
假如现在又来一个类似活动,那这块代码还需要修改,严重违反了开放关闭原则,而且频繁修改已经上线的代码,bug的几率会大大增高。这也是D妹子领导骂她并且让她codereview的原因。
◆◆优化版本◆◆那具体要怎么优化呢?修改代码之前,我还是想提醒一下,有几个要点需要注意一点:
1. 商品菜菜认为有一个共同的基类比较好,这样就有了一个所有商品的控制点,为以后统一添加属性留一个入口。好比一个网关系统,为什么会诞生网关这个组件呢,因为有了它我们能方便的统一添加认证,授权,统计等一些列行为。
2. 任何促销的活动最好有一个基类,作用类似商品基类。
3. 对于商品而言,任何促销活动是商品的行为变化点,影响到的是最终的商品价格,所以获取价格这个行为要做特殊的处理。
4. 不同种类的促销活动应该能自行扩展,不会影响别的类型促销活动。
5. 不同种类的促销活动能叠加使用(其实这里涉及到每个活动计算的标准是商品原价还是促销之后价格的问题)。
基于以上几点,首先把商品的对象做一下抽象
//商品抽象基类
abstract class BaseProduct
{
//商品价格,单位:分
public int Price { get; set; }
//获取商品价格抽象方法
public abstract int GetPrice();
}
//抽象商品(比如话费商品),继承商品基类
class VirtualProduct : BaseProduct
{
public override int GetPrice()
{
return this.Price;
}
}
接下来活动的基类也需要抽象出来
//各种活动的抽象基类,继承要包装的类型基类
abstract class BaseActivity : BaseProduct
{
}
有的同学会问,这里为什么要继承商品的基类呢?主要是为了活动的基类能嵌套使用,这样我就可以实现多个活动同时使用,如果不明白没关系,带着这个问题接着往下看
实现一个打折的活动
//打折活动基类,支持多个商品同时结算
class DiscountActivity : BaseActivity
{
BaseProduct product = null;
public DiscountActivity(int discount, BaseProduct _product)
{
Discount = discount;
product = _product;
}
//折扣,比如 90折 即为90
public int Discount { get; set; }
//获取折扣之后的价格
public override int GetPrice()
{
return product.GetPrice() * Discount / 100;
}
}
实现一个满减的活动,而且支持自定义满减条件
class ReductionActivity : BaseActivity
{
BaseProduct product = null;
//满减的对应表
Dictionary<int, int> reductMap = null;
public ReductionActivity(Dictionary<int, int> _redutMap, BaseProduct _product)
{
reductMap = _redutMap;
product = _product;
}
//获取折扣之后的价格
public override int GetPrice()
{
var productAmount = product.GetPrice();
//根据商品的总价获取到要减的价格
var reductValue = reductMap.OrderByDescending(s => s.Key).FirstOrDefault(s => productAmount >= s.Key).Value;
return productAmount - reductValue;
}
}
现在我们来给商品做个促销活动吧
VirtualProduct p = new VirtualProduct() { Price=1000};
//打折活动
DiscountActivity da = new DiscountActivity(90, p);
var retPrice= da.GetPrice();
Console.WriteLine($"打折后的价格{retPrice}");
//还能叠加参加满减活动
Dictionary<int, int> m = new Dictionary<int, int>() ;
m.Add(200, 5); //满200减5
m.Add(300, 10);
m.Add(500, 20);
m.Add(1000, 50);
//这里活动能叠加使用了
ReductionActivity ra = new ReductionActivity(m, da);
retPrice = ra.GetPrice();
Console.WriteLine($"打折满减后的价格{retPrice}");
ReductionActivity ra2 = new ReductionActivity(m, ra);
retPrice = ra2.GetPrice();
Console.WriteLine($"再打折后的价格{retPrice}");
输出结果:
打折后的价格900
打折满减后的价格880
再打折后的价格860
现在我们终于能优雅一点的同时进行商品的满减和打折活动了
◆◆进化到多个商品同时促销◆◆以上代码已经可以比较优雅的能进行单品的促销活动了,但是现实往往很骨感,真实的电商场景中多以多个商品结算为主,那用同样的思路怎么实现呢?
1. 由于这次需要实现的是多商品促销结算,所以需要一个自定义的商品列表来作为要进行结算的对象。此对象行为级别上与单品类似,有一个需求变化点的抽象:获取价格
//商品列表的基类,用于活动结算使用
class ActivityListProduct : List<BaseProduct>
{
//商品列表活动结算的方法,基类必须重写
public virtual int GetPrice()
{
int ret = 0;
base.ForEach(s =>
{
ret += s.GetPrice();
});
return ret;
}
}
2. 把多商品促销活动的基类抽象出来,供不同的促销活动继承使用,这里需要继承ActivityListProduct,为什么呢?和单品的类似,为了多个子类能够嵌套调用
//商品列表 活动的基类,继承自商品列表基类
internal abstract class BaseActivityList : ActivityListProduct
{
}
3. 创建一个打折和满减活动
//打折活动基类,支持多个商品同时结算
class DiscountActivityList : BaseActivityList
{
ActivityListProduct product = null;
public DiscountActivityList(int discount, ActivityListProduct _product)
{
Discount = discount;
product = _product;
}
//折扣,比如 90折 即为90
public int Discount { get; set; }
public override int GetPrice()
{
var productPrice = product.GetPrice();
return productPrice * Discount / 100;
}
}
//满减的活动
class ReductionActivityList : BaseActivityList
{
ActivityListProduct product = null;
//满减的对应表
Dictionary<int, int> reductMap = null;
public ReductionActivityList(Dictionary<int, int> _redutMap, ActivityListProduct _product)
{
reductMap = _redutMap;
product = _product;
}
//获取折扣之后的价格
public override int GetPrice()
{
var productAmount = product.GetPrice();
//根据商品的总价获取到要减的价格
var reductValue = reductMap.OrderByDescending(s => s.Key).FirstOrDefault(s => productAmount >= s.Key).Value;
return productAmount - reductValue;
}
}
先来一波多商品促销活动
VirtualProduct p = new VirtualProduct() { Price = 1000 };
VirtualProduct p2 = new VirtualProduct() { Price = 1000 };
ActivityListProduct lst = new ActivityListProduct();
lst.Add(p);
lst.Add(p2);
DiscountActivityList dalist = new DiscountActivityList(80, lst);
Console.WriteLine($"打折后的价格{dalist.GetPrice()}");
DiscountActivityList dalist2 = new DiscountActivityList(90, dalist);
Console.WriteLine($"打折后的价格{dalist2.GetPrice()}");
DiscountActivityList dalist3 = new DiscountActivityList(90, dalist2);
Console.WriteLine($"打折后的价格{dalist3.GetPrice()}");
//还能叠加参加满减活动
Dictionary<int, int> m = new Dictionary<int, int>();
m.Add(200, 5); //满200减5
m.Add(300, 10);
m.Add(500, 20);
m.Add(1000, 50);
ReductionActivityList ral = new ReductionActivityList(m, dalist3);
Console.WriteLine($"再满减打折后的价格{ral.GetPrice()}");
结算结果:
打折后的价格1600
打折后的价格1440
打折后的价格1296
再满减打折后的价格1246
程序员过关斩将--请不要随便修改基类
如果你对问题的背景不太熟悉,不如复习一下上一篇,入口》.
这是玩家的抽象基础类,这个设计很好,把一些玩家共有的特性抽象出来
//玩家的基础抽象类
abstract class Player
{
//玩家的级别
public int Level { get; set; }
//其他属性代码省略一万字
}
这是新加需求:10级可以跳跃,具体跳跃动作是客户端做处理
//玩家的基础抽象类
abstract class Player
{
//玩家的级别
public int Level { get; set; }
//其他属性代码省略一万字
//新加玩家跳跃动作,由于需要到达10级所以需要判断level
public virtual bool Jump()
{
if (Level >= 10)
{
return true;
}
return false;
}
}
这种代码初级人员很容易犯,有什么问题呢?
1. 跳跃的动作被添加到了基类,那所有的子类就都有了这个行为,如果子类机器人玩家不需要这个跳跃的行为呢?
2. 为了新需求,修改了基类,如果每次需求都需要修改基类,时间长了,项目大了,这个是比较要命的。
◆◆优化版本◆◆
由于需求是增加玩家一个行为,根据上一节的介绍,我们应该了解到,行为在代码级别更倾向于用接口来表示。而且不是所有的玩家类型都需要附加跳跃这个行为。据此优化如下:
//玩家跳跃的行为
interface IJump
{
bool Jump();
}
//玩家的基础抽象类
abstract class Player
{
//玩家的级别
public int Level { get; set; }
//其他属性代码省略一万字
}
//真实玩家
class PersonPlayer : Player, IJump
{
public bool Jump()
{
if (Level >= 10)
{
return true;
}
return false;
}
}
不错,到此我们已经避免了初级人员所犯的错误了,每种玩家类型可以根据需要自行去扩展行为,改天产品狗在加一个10级玩家可以飞的行为,顶多在加一个IFly的行为接口,然后实现即可。但是这样的设计就没有问题了吗?有,当然有
1. 每次需求其实还是改动了已经存在的并且稳定运行的老代码,这是不可取的。而且修改老代码,大大增加了bug出现的概率。
2. 假如现在我们的游戏有20种玩家类型,其中19种需要添加跳跃的行为,那我们需要修改19个玩家的子类,工作量是如此之大。
3. 利用类似继承的方式扩展对象的行为,是在编译期就把对象的行为确定了。也就是说在设计层面,其实你已经把代码写死了。
有很多同学的代码就到目前为止了
假设以下为产品狗一个月之后的新需求:
1. 能跳跃的等级调整为11级
2. 玩家添加能遁地的行为
3. 新加了10种玩家类型
如果你读到了这里,说明大家都是对于设计追求卓越的技术人。这里菜菜再强调一遍架构设计的一项重要原则
类应该对修改关闭,对扩展开放。
这里需要强调一点,设计的每个部分想要都遵循开放-关闭原则,通常很难做到。因为要想在不修改现有代码的情况下,你需要花费许多时间和精力。遵循开放关闭原则,通常需要引入更多的抽象,增加更多的层次,增大代码的复杂度。因此菜菜建议把注意力集中在业务中最有可能变化的点上,这些地方应用开放关闭原则。至于怎么确定哪些是变化的点,这需要对业务领域很强的理解和经验了。
现在我们分析一下我们要做的事情,我们希望一个对象(player)在不改动的情况下动态的给它赋予新的行为,在业务上实现的功能和用继承的结果类似。总之一句话:
现有的类型优雅的添加新行为,并且可以灵活叠加和替换
理想中的设计图大致如下:

◆◆再次优化◆◆
现在我们认真分析一下,如果每个新的行为要想扩展对象而又能保持该对象的自身特性,新行为对象必须是扩展对象的子类,还必须包含对象的一个引用才能实现。

◆◆重要提示◆◆
1. 在系统设计过程中,实现一个接口泛指实现某个对象的超类型,也就是说可以是类或者接口。
2. 在你系统设计中,如果你的代码依赖于某个具体的类型,并非抽象的超类型,应用此篇介绍的设计方法可能会受到影响。
3. 附加在对象最外层的行为,不应该窥视被包装的类型内部的一些特性。
4. 附加在对象外层的行为,可以在内层对象的行为前后加入自己的行为,甚至可以覆盖掉内层对象的行为。
5. 如果扩展的行为过多,会出现很多小对象,过度使用会使程序变的很复杂,所以设计扩展行为时候需要注意。
◆◆落实到代码◆◆
假设现在真实玩家的定义如下:
//玩家的基础抽象类
public abstract class Player
{
//玩家的级别
public int Level { get; set; }
//其他属性代码省略一万字
}
//真实玩家
public class PersonPlayer : Player
{
}
现在的需求是给真实玩家添加一个10级能跳跃的行为,在不修改原有玩家代码的情况下,扩展跳跃行为代码如下
//玩家行为的扩展积累
public class PlayerExtension : Player
{
protected Player player;
}
//跳跃玩家的行为扩展类
public class PlayerJumpExtension: PlayerExtension
{
public PlayerJumpExtension(Player _player)
{
player = _player;
}
public bool Jump()
{
if (player. Level >= 10)
{
return true;
}
return false;
}
}
测试代码如下:
PersonPlayer player = new PersonPlayer();
//给用户动态添加跳跃的行为
PlayerJumpExtension jumpPlayer = new PlayerJumpExtension(player);
var ret= jumpPlayer.Jump();
Console.WriteLine("玩家能不能跳跃:"+ret);
//现在玩家升级到10级了
player.Level = 10;
ret = jumpPlayer.Jump();
Console.WriteLine("玩家能不能跳跃:" + ret);
测试加过如下:
玩家能不能跳跃:False
玩家能不能跳跃:True
一个月后产品狗新加一个需求:真实玩家20级获得飞行的行为,无序改动现有代码,只需继续添加一个可以飞行的新扩展
//玩家可以飞行的扩展
public class PlayerFlyExtension : PlayerExtension
{
public PlayerFlyExtension(Player _player)
{
player = _player;
}
public bool Fly()
{
if (player.Level >= 20)
{
return true;
}
return false;
}
}
测试代码如下:
PlayerFlyExtension flyPlayer = new PlayerFlyExtension(player);
Console.WriteLine( "玩家能不能飞行"+flyPlayer.Fly());
player.Level = 20;
Console.WriteLine("玩家能不能飞行" + flyPlayer.Fly());
测试结果:
玩家能不能飞行False
玩家能不能飞行True
重要提示
以上代码级别上属于演示代码,但是设计的理念却很重要。基于以上的设计思想,扩展的行为完全有能力修改,覆盖玩家的某些行为。比如玩家对象本身有一个喊话的行为,那扩展类根据业务完全可以让喊话行为执行两次等等修改。
程序员过关斩将--你的面向接口编程一定对吗?
妹子的游戏是个对战类的游戏,其中有一个玩家的概念,玩家可以攻击,这个业务正是妹子开始挠头的起点
02第一次需求玩家有很多属性,例如:身高,性别 blalalala ,玩家可以攻击其他玩家。产品狗
YY妹子写程序也是很利索,一天就把程序搞定了,而且还抽象出一个palyer的基类出来,堪称高级程序员必备技能
//玩家的基础抽象类
abstract class Player
{
public string Name { get; set; }
//.
//.
//.
//玩家的攻击
public abstract void Attack();
}
//真实玩家
class PersonPlayer : Player
{
public override void Attack()
{
//to do something
return;
}
}
01第二次需求游戏里要增加机器人玩家来增加在线的人数,属性和真实玩家一样,但是攻击不太一样产品狗
这个需求修改还是难不住YY妹子,没过几天代码改好了,增加了一个机器人玩家的类,用到了OO的继承。在这里为玩家抽象类点赞
class RobotPlayer : Player
{
public override void Attack()
{
//修改攻击内容等 to do something
return;
}
}
02第三次需求我要创建一批怪物,没有真实玩家的那些属性,但是和真实玩家一样有攻击行为产品狗
这个时候YY妹子终于意识到攻击是一种行为了,需要抽象出接口来了。
//攻击接口
interface IAttack
{
void Attack();
}
//玩家的基础抽象类
abstract class Player
{
//其他属性代码省略一万字
}
//真实玩家
class PersonPlayer :Player, IAttack
{
public void Attack()
{
//to do something
return;
}
}
//机器人玩家
class RobotPlayer :Player, IAttack
{
public void Attack()
{
// to do something
return;
}
}
//怪物玩家
class MonsterPlayer : IAttack
{
public void Attack()
{
// to do something
return;
}
}
到了这里,我们遇到了大家耳熟能详的面向接口编程,没错,这个做法是对的。这也是设计的一大原则:程序依赖接口,不依赖具体实现。这里要为YY继续点赞。顺便说一下,在多数情况下,很多同学就到此为止了
01第四次需求我要设计玩家的攻击方式了,目前有远程攻击,近程攻击,贴身攻击这三类,其他需求 blalalalala。产品狗据说此刻YY妹子的心里是一万头羊驼飘过的状态。这次要怎么设计呢?这也是菜菜要说的重点部分。
现在我们需要静下心来思考一番了,为什么我们使用了面向接口编程,遇到这次需求,程序还是需要修改很多东西呢?
设计原则:找出应用中将来可能变化的地方,把他们独立出来,不需要和那些不变的代码混在一起。
这样的概念很简单,确是每个设计模式背后的灵魂所在。到目前为止,设计中不断在变的是Attack这个接口,更准确的应该是Attack这个行为。面向接口这个概念没有问题,是大多数人把语言层面和设计层面的接口含义没搞明白,真正的面向接口编程更偏向于面向架构中行为的编程,另外一个角度也可以看做是利用OO的多态原则。
说到这里,我们可以更系统的给Attack行为定义成一类行为,而具体的行为实现可以描述为一簇算法。想想看,Attack行为其实不止作用于player的类型,改日产品经理新加一个XX对象也具有攻击行为,理想的情况是我只需要让这个xx对象有Attack行为即可,而不需要改动以前的任何代码。你现在是不是对这个行为的定义理解的更深刻一些。
另外一点,到目前为止YY妹子的代码中一直是以继承的方式来实现行为,这会有什么问题呢?假如要想在程序运行时动态修改player的Attack行为,会显得力不从心了。
谈到这里又引入了其他一个设计理念:一般情况下,有一个可能比是一个更好。具体概念为:多用组合,少用继承。继承通常情况下适用于事物本身的一些特性,比如:玩家基类具有姓名这个属性,继承类完全可以继承这个属性,不会发生任何问题。而组合多用于行为的设计方面,因为这个行为类型,我可能会在多个事物中出现,用组合能实现更大的弹性设计
02面向行为编程(千言万语不如10行代码)封装行为一簇
//攻击行为接口
interface IAttack
{
void Attack();
}
class RemoteAttack : IAttack
{
public void Attack()
{
//远程攻击
}
}
class ShortAttack : IAttack
{
public void Attack()
{
//近程攻击
}
}
事物包含行为组合
//玩家的基础抽象类
abstract class Player
{
//其他属性代码省略一万字
}
//真实玩家
class PersonPlayer : Player
{
//玩家可以有攻击的行为
IAttack attack;
public PersonPlayer(IAttack _attack)
{
attack = _attack;
}
public void Attack()
{
//调用行为一簇算法的实现
attack.Attack();
return;
}
//玩家可以运行时修改攻击行为
public void ChangeAttack(IAttack _attack)
{
attack = _attack;
}
}
写在最后
接口是一种规范和约束,更高层的抽象更像是一类行为,面向接口编程只是代码层体现的一种格式体现而已,真正的面向接口设计更贴近面向行为编程
程序员过关斩将--快速迁移10亿级数据
业务BJKJ有个表数据需要做迁移,大约21亿吧,2017年以前的数据没有业务意义了,经过几分钟的排查,数据库情况如下:
1. 数据库采用Sqlserver 2008 R2,单表数据量21亿

2. 无水平或者垂直切分,但是采用了分区表。分区表策略是按时间降序分的区,将近30个分区。正因为分区表的原因,系统才保证了在性能不是太差的情况下坚持至今。
3. 此表除聚集索引之外,无其他索引,无主键(主键其实是利用索引来快速查重的)。所以在频繁插入新数据的情况下,索引调整所耗费的性能比较低。

至于业务,不是太复杂。经过相关人员咨询,大约40%的请求为单条Insert,大约60%的请求为按class_id 和in_time(倒序)分页获取数据。Select请求全部命中聚集索引,所以性能非常高。这也是聚集索引之所以这样设计的目的。
解决问题由于单表数据量已经超过21亿,并且2017年以前的数据几乎不影响业务,所以决定把2017年以前(不包括2017年)的数据迁移到新表,仅供以后特殊业务查询使用。经过查询大约有9亿数据量。
数据迁移工作包括三个个步骤:
1. 从源数据表查询出要迁移的数据
2. 把数据插入新表
3. 把旧表的数据删除
传统做法
这里申明一点,就算是传统的做法也需要分页获取源数据,因为你的内存一次性装载不下9亿条数据。
1. 从源数据表分页获取数据,具体分页条数,太少则查询原表太频繁,太多则查询太慢。
SQL语句类似于
SELECT * FROM (
SELECT *,ROW_NUMBER() OVER(ORDER BY class_id,in_time) p FROM tablexx WHERE in_time <'2017.1.1'
) t WHERE t.p BETWEEN 1 AND 100
2. 把查询出来的数据插入目标数据表,这里强调一点,一定不要用单条插入策略,必须用批量插入。
3. 把数据删除,其实这里删除还是有一个小难点,表没有标示列。这里不展开,因为这不是菜菜要说的重点。
如果你的数据量不大,以上方法完全没有问题,但是在9亿这个数字前面,以上方法显得心有余而力不足。一个字:慢,太慢,非常慢。
可以大体算一下,假如每秒可以迁移1000条数据,大约需要的时间为(单位:分)
900000000/1000/60=15000(分钟)大约需要10天^ V ^
改进做法以上的传统做法弊端在哪里呢?
1. 在9亿数据前查询必须命中索引,就算是非聚集索引菜菜也不推荐,首推聚集索引。
2. 如果你了解索引的原理,你应该明白,不停的插入新数据的时候,索引在不停的更新,调整,以保持树的平衡等特性。尤其是聚集索引影响甚大,因为还需要移动实际的数据。
提取以上两点共同的要素,那就是聚集索引。相应的解决方案也就应运而生:
1. 按照聚集索分页引查询数据
2. 批量插入数据迎合聚集索引,即:按照聚集索引的顺序批量插入。
3. 按照聚集索引顺序批量删除
由于做了表分区,如果有一种方式把2017年以前的分区直接在磁盘物理层面从当前表剥离,然后挂载到另外一个表,可算是神级操作。有谁能指导一下菜菜,感激不尽
扩展阅读1. 一个表的聚集索引的顺序就是实际数据文件的顺序,映射到磁盘上,本质上位于同一个磁道上,所以操作的时候磁盘的磁头不必跳跃着去操作。
2. 存储在硬盘中的每个文件都可分为两部分:文件头和存储数据的数据区。文件头用来记录文件名、文件属性、占用簇号等信息,文件头保存在一个簇并映射在FAT表(文件分配表)中。而真实的数据则是保存在数据区当中的。平常所做的删除,其实是修改文件头的前2个代码,这种修改映射在FAT表中,就为文件作了删除标记,并将文件所占簇号在FAT表中的登记项清零,表示释放空间,这也就是平常删除文件后,硬盘空间增大的原因。而真正的文件内容仍保存在数据区中,并未得以删除。要等到以后的数据写入,把此数据区覆盖掉,这样才算是彻底把原来的数据删除。如果不被后来保存的数据覆盖,它就不会从磁盘上抹掉。
NetCore 代码(实际运行代码)1. 第一步:由于聚集索引需要class_id ,所以宁可花2-4秒时间把要操作的class_id查询出来(ORM为dapper),并且升序排列
DateTime dtMax = DateTime.Parse("2017.1.1");
var allClassId = DBProxy.GeSourcetLstClassId(dtMax)?.OrderBy(s=>s);
2. 按照第一步class_id 列表顺序查询数据,每个class_id 分页获取,然后插入目标表,全部完成然后删除源表相应class_id的数据。(全部命中聚集索引)
D int pageIndex = 1; //页码
int pageCount = 20000;//每页的数据条数
DataTable tempData =null;
int successCount = 0;
foreach (var classId in allClassId)
{
tempData = null;
pageIndex = 1;
while (true)
{
int startIndex = (pageIndex - 1) * pageCount+1;
int endIndex = pageIndex * pageCount;
tempData = DBProxy.GetSourceDataByClassIdTable(dtMax, classId, startIndex, endIndex);
if (tempData == null || tempData.Rows.Count==0)
{
//最后一页无数据了,删除源数据源数据然后跳出
DBProxy.DeleteSourceClassData(dtMax, classId);
break;
}
else
{
DBProxy.AddTargetData(tempData);
}
pageIndex++;
}
successCount++;
Console.WriteLine($"班级:{classId} 完成,已经完成:{successCount}个");
}
DBProxy 完整代码:
class DBProxy
{
//获取要迁移的数据所有班级id
public static IEnumerable<int> GeSourcetLstClassId(DateTime dtMax)
{
var connection = Config.GetConnection(Config.SourceDBStr);
string Sql = @"SELECT class_id FROM tablexx WHERE in_time <@dtMax GROUP BY class_id ";
using (connection)
{
return connection.Query<int>(Sql, new { dtMax = dtMax }, commandType: System.Data.CommandType.Text);
}
}
public static DataTable GetSourceDataByClassIdTable(DateTime dtMax, int classId, int startIndex, int endIndex)
{
var connection = Config.GetConnection(Config.SourceDBStr);
string Sql = @" SELECT * FROM (
SELECT *,ROW_NUMBER() OVER(ORDER BY in_time desc) p FROM tablexx WHERE in_time <@dtMax AND class_id=@classId
) t WHERE t.p BETWEEN @startIndex AND @endIndex ";
using (connection)
{
DataTable table = new DataTable("MyTable");
var reader = connection.ExecuteReader(Sql, new { dtMax = dtMax, classId = classId, startIndex = startIndex, endIndex = endIndex }, commandType: System.Data.CommandType.Text);
table.Load(reader);
reader.Dispose();
return table;
}
}
public static int DeleteSourceClassData(DateTime dtMax, int classId)
{
var connection = Config.GetConnection(Config.SourceDBStr);
string Sql = @" delete from tablexx WHERE in_time <@dtMax AND class_id=@classId ";
using (connection)
{
return connection.Execute(Sql, new { dtMax = dtMax, classId = classId }, commandType: System.Data.CommandType.Text);
}
}
//SqlBulkCopy 批量添加数据
public static int AddTargetData(DataTable data)
{
var connection = Config.GetConnection(Config.TargetDBStr);
using (var sbc = new SqlBulkCopy(connection))
{
sbc.DestinationTableName = "tablexx_2017";
sbc.ColumnMappings.Add("class_id", "class_id");
sbc.ColumnMappings.Add("in_time", "in_time");
.
.
.
using (connection)
{
connection.Open();
sbc.WriteToServer(data);
}
}
return 1;
}
}
运行报告:
程序本机运行,开vpn连接远程DB服务器,运行1分钟,迁移的数据数据量为 1915560,每秒约3万条数据
1915560 / 60=31926 条/秒
cpu情况(不高):

磁盘队列情况(不高):

在以下情况下速度还将提高
1. 源数据库和目标数据库硬盘为ssd,并且分别为不同的服务器
2. 迁移程序和数据库在同一个局域网,保障数据传输时候带宽不会成为瓶颈
3. 合理的设置SqlBulkCopy参数
4. 菜菜的场景大多数场景下每次批量插入的数据量达不到设置的值,因为有的class_id 对应的数据量就几十条,甚至几条而已,打开关闭数据库连接也是需要耗时的
5. 单纯的批量添加或者批量删除操作

浙公网安备 33010602011771号