dream

一个菜鸟程序员的成长历程

0%

设计原则之KISS原则和YAGNI原则

KISS原则

KISS(Keep It simple and Stupid)原则总的来说就是简单,你的代码要写的简单易懂。增加代码的可读性。

并不一定是代码量的多少来判断简单,而是通过可读性,如果这个代码可读性很好,比如你一下子就能看懂,这就说明符合KISS原则。

这个原则也比较主观,因为如果看代码的人水平比较差可能看不懂,而比你水平好的则可能一下子看懂。就像我们读框架源码读不懂并不是框架源码写的不好而是我们水平不够,哈哈哈哈。

怎么让代码简洁易懂呢?

  • 命名清晰易懂
  • 可以写一些注释辅助看懂
  • 遵守代码规范
  • 统一团队风格

我们要写出可读性好的代码而不是一些复杂的代码。我们的目的是写出实现需求的代码。

YAGNI原则

YAGNI(You Ain't Gonna Need It)原则的意思是你不需要他的时候就不要提前写好,不要做过度设计。我们可以基于扩展性留好’坑’,方便以后扩展新的代码。比如我们现在使用了微信支付,以后可能会接入支付宝支付,我们可以基于接口编程方便扩展,但不用直接写好支付宝支付的代码。

同样的,我们不要在代码中依赖不需要的东西,比如包管理,我们只需要当前需要的扩展包,不要把现在不需要的都加入进来。

比如vue的组件化设计,引入一些组件的时候,比如element ui组件,可以选择全部引入也可以选择按需加载。

还有各种懒加载,比如图片懒加载,树形结构的懒加载,我们只加载当前需要的东西。

参考资料:

  • 极客时间设计模式之美

设计原则之迪米特法则–我只依赖我需要的类

迪米特法则(Law of Demeter) LOD。这个原则是说我只依赖我确实需要的类,也叫最小知识原则。

这个原则的英文Each unit should have only limited knowledge about other units:only units ‘closely’ related to the current unit. Or:Each unit should only talk to its friends; Don’t talk to strangers.

每个模块之应该了解哪些与他关系密切的模块的有限知识。或者说,每个模块只和自己的朋友说话,而不和陌生人说话。

如果说用好了单一职责可以写出高内聚的代码,那么用好了迪米特就可以实现低耦合。

在类的结构上,每一个类都应当尽量降低成员的访问权限。这样可以避免别人调用不应该调用的方法。我们只对外暴露应该暴露的方法。

这么做有什么好处呢?如果你的依赖越少,或者依赖你的类越少,那么当你修改的时候,你影响到的也越少,出bug的概率也越少。

我们应该设计好类之间的依赖关系。谁也不想管理一团乱麻的代码。如果你现在的项目依赖关系混乱,那么你可能根本不敢修改任何一个地方,生怕整个系统崩溃。依赖关系过多也会导致不好测试。

设计原则之依赖倒置原则–我的依赖被反转了

依赖倒置原则(Dependency Inversion Principle)DIP。这个原则的英文是high-level modules shouldn't depend on low-level modules. both modules should depend on abstractions. In addition, abstractions shouldn't depend on details.Details depend on abstractions.。意思是高层模块不要依赖底层模块。高层模块和底层模块都应该依赖抽象。抽象不要依赖具体实现,具体实现应该依赖抽象。

什么是依赖倒置原则

通常来说,调用者属于高层模块,被调用者就是低层模块。为什么叫依赖倒置或者依赖反转呢?正常开发来说类A调用类B,类A属于高层模块,类B是低层模块。高层模块依赖低层模块,需要调用低层模块的方法。直接和低层模块高度耦合。

如果这时候我们的低层模块需要适配不同的高层模块,那么就无法复用。因为低层模块和以前的高层模块耦合在一起,如果修改适配新模块可能会导致以前的高层模块出现问题。

依赖倒置原则就是把这个依赖关系进行反转。以前是高层模块依赖低层模块,现在我不依赖你了。咋俩都依赖抽象。我们使用抽象类或者接口。我们的实现都基于这个接口来进行,而不是产生直接的依赖关系。我们都依赖同样的接口,同样的抽象。

比如我们的电脑有CPU,键盘,鼠标,内存,硬盘,显示器这些东西,我们只要组装起来就是电脑,这些东西就像高内聚的程序。内聚在一起,他们依赖相同的接口进行调用,你的罗技鼠标雷蛇鼠标都使用同一个接口。那么电脑就可以正常运转。

如何使用依赖倒置原则

我们进行软件设计的时候应该由上而下的设计,先进行抽象设计,然后来具体实现。如果先写出具体实现在进行抽象设计,那么抽象出来的东西就容易依赖具体实现。因为具体实现很可能会变,但是抽象一般不会改变。所以抽象使得程序更加稳定。

那么为什么抽象一般不会改变呢?这就是之前说的里氏替换原则。子类可以替换父类,需要改变我们只需要扩展具体实现就可以而不是修改抽象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

//电脑需要一个能插入usb接口的鼠标
class computer{
public function __construct(USB $mouse) {
$this->mouse = $mouse;
}
}

interface USB{
public function usb();
}

//雷蛇鼠标实现了usb接口
class snakeMouse implements USB{
public function usb() {}
}

//罗技鼠标实现了usb接口
class luoMouse implements USB{
public function usb() {}
}

在这里,不管是哪个鼠标都可以使用,如果我们依赖具体的鼠标,那么就无法灵活更换了。两边,调用者和被调用者,高层模块和低层模块都依赖抽象。

参考资料:

  • 大话设计模式
  • 极客时间设计模式之美

设计原则之接口隔离原则–如何通过接口隔离职责

接口隔离原则(Interface Segregation Principle) ISP。这个原则是说客户端不应该依赖他不需要的接口。

这个原则的英文是Clients should not be forced to depend upon interfaces that they do not use

如何使用接口隔离原则

这里面的接口不同于我们的API接口,也不是电脑的USB接口这种,而是我们程序中使用的接口Interface

我们通过让程序实现不同的接口来完成不同的职责。这个原则和单一职责原则也有点类似。比如一个类既有查询功能还有修改功能。

1
2
3
4
5
6
7
8
9
class demo{
public function list() {

}

public function update(){

}
}

那么现在有一个类需要使用这个类的查询功能。它只需要使用查询,但是他还是可以知道这个类有修改功能,可以使用他的修改功能。另外有一个类只需要修改却同样被迫加载了查询功能。

如果我们增加两个接口。

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

interface list {
public function list()
}

interface update{
public function update()
}

class demo implements list, update{
public function list() {

}

public function update(){

}
}

class testList {
public function test(list $demo) {
$demo->list();
}
}

class testUpdate {
public function test(update $demo) {
$demo->update();
}
}

这样的话,我们通过接口将查询和更新分离开,查询方只依赖查询接口,只能感知到查询操作,更新方只依赖更新接口,只能感知到更新操作,不需要知道这个类其他的功能,也防止了误操作。

参考资料:

  • 大话设计模式
  • 极客时间设计模式之美

设计原则之里氏替换原则–子类可以替换父类吗

里氏替换原则(Liskov Substitution Principle)LSP,这个原则是说子类应该可以替换父类进行使用。

这个原则的英文描述是functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。

为什么要使用里氏替换原则

在我们的日常开发中很常用依赖注入,简单来说就是通过外部传入对象而不是内部new对象。传入参数的时候我们可以通过标识父类或者接口。

1
2
3
4
5
function demo(Idemo $demo) {

$demo->run();

}

在这个函数中,我不关心你传进来的是个什么玩意,只要你实现了Idemo这个接口的对象就可以。或者是继承了Idemo这个类的自类对象也可以,只要你实现了run这个方法,并且属于Idemo这个类或者接口。我们经常使用这个方法来增加程序的测试行,扩展性,灵活性。比如我们需要一个支付接口的时候。

1
2
3
4

function buy(Payment $payment) {
$payment->pay();
}

我们购买东西的时候,只要知道我要支付就可以了,至于你传进来的是支付宝支付还是微信支付我不需要知道。

在这里面,支付宝或者微信支付就是具体的自类,而Payment就是他们的父类。这些子类对象可以替换他们的父类对象,并且保证原来程序的逻辑行为不变及正确性不被破坏。

里氏替换和多态

其实我们上面使用的就是面向对象里面的多态。但是多态并不是里氏替换。类的多态特性允许我们重新实现父类的函数,我们可以根据不同的需求来改写。但是里氏替换原则要求不能修改原来的逻辑行为和正确性。

比如父类中这个函数返回了异常而子类没有,那么这就违反了里氏替换原则。

比如原来内容是123,而子类改成了456,那么同样违反了里氏替换原则。

参考资料:

  • 大话设计模式
  • 极客时间设计模式之美

设计原则之开闭原则–对扩展开发对修改关闭

开闭原则(Open Closed Principle) OCP,这个原则是说对扩展可以开放,但是对修改要关闭。

这个原则的英文描述是software entities(modules,classes,functions,etc.) should be open for extension, but closed for modification.

为什么要使用开闭原则

引用大话设计模式书中的故事。在香港澳门回归的时候,我国使用了一国两制的方针。这其实就是没有修改任何地方,但却扩展了我国的领土。这也就是对修改关闭,对扩展开放。

开放封闭原则,是说软件实体应该可以扩展,但是不可修改。

在现在的开发中,改需求是一个很常见的事情,我们经常需要这个需求要改成这样,明天上线!!这种事情。这时候我们加班加点改完一个类实现了新需求。这个时候发现由于这个类的依赖太多,我们的修改引起了其他地方的bug,那我们需要整体测试,然后各种修改,还可能引起更多的bug,如此耗时耗力。

在思考另外一个场景,同样我们实现了需求,这个时候没出其他问题,但是!!!这个需求要改回去,你把之前的代码拿出来吧。我相信你一定很不爽的,如果我们可以通过git来回滚到之前的代码还好,如果我们之前的代码在本地没有提交这个时候我们又修改完了,那,呵呵,再改回去吧,鬼知道之前代码啥样。

如果我们遵循开闭原则,不是通过修改类来完成,而是通过扩展类的方式来完成这个新需求,那么我们就避免了这些问题,扩展不会引起其他的未知bug。不会影响到其他的类。如果要改回去我们原来的代码也还在。这就是开闭原则的好处了。

如何使用开闭原则

无论模块是多么的封闭,都会存在一些无法对之封闭的变化。既然不可能完全封闭,设计人员必须对于他设计的模块应该对那种变化封闭做出选择。它必须先猜测出最有可能发生的变化种类,然后构造抽象来隔离哪些变化

我们开发中,最开始的时候,很难预测接下来的变化。但是,一旦发生变化我们可以根据变化来采取行动,进行抽象重构。使得接下来通过扩展和少量的必要修改来应对需求的变化。

开闭原则是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术所生成的巨大好处,也就是可维护,可扩展,可复用,灵活性好。我们应该仅对程序中呈现出频繁变化的那些部分做出抽象,然而,对于程序中的每个部分都刻意的进行抽象同样不是一个好主意。拒绝不成熟的抽象和抽象本身一样重要。

我们可以在开发的开始阶段对接下来能预测到的变化留出扩展空间,然后再不断的持续重构开发代码中一直遵循开闭原则。

很多设计模式都是以提高扩展性为目的。最常用的方法有:多态,依赖注入,基于接口编程等。

参考资料:

  • 大话设计模式
  • 极客时间设计模式之美

设计原则之单一职责–为什么要设计成单一职责

单一职责原则(Single Responsibility Principle) SRP的意思是,就一个类而言,应该仅有一个引起他变化的原因。

这个原则的英文描述是A class or module should have a single reponsibility。一个类或者模块只有一个职责。

为什么要使用单一职责

借用大话设计模式一书中使用的例子。比如现在科技高度发达,手机成为了日常使用中不可替代的产品。手机的功能很多,可以拍照,可以上网,可以打电话,聊天,打游戏等。假设我们没有手机,那么我们拍照需要摄像机,打游戏需要游戏机,听歌需要mp3,但是现在不用带这么多东西,只需要一个手机就搞定了。觉得很方便。但是呢,手机的拍照功能并没有单反好,游戏性能和游戏数量也比不上ps4和xbox以及ns。

如果放在代码中就是你的所有代码都写在一起,像一团乱麻一样。严重影响了可读性,扩展性,维护性等。相信没有人喜欢看到这样的代码。所以这就是单一职责。对代码要有结构,要清晰明了,其实代码中到处都体现了这个原则。比如mvc的分层架构,比如前后端分离,比如docker的兴起,比如我们日常使用的框架也分离了我们的职责,使得我们只需要关注一些实现就好了。再生活中也有比如公司分成了各个部门,人类分成了男人女人,由各个职业。每个人只需要关注自己的事情,简单明了。这就是单一职责的好处。

如果一个类承担的职责过多,就等于把这些职责耦合在一起,一个变化会引起其他意想不到的变化,使得整个程序脆弱不堪。我们经常说要写高内聚,低耦合的代码,如果违反了单一职责那么就只能写出高耦合的代码了。

如何使用单一职责

如果你能想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责。如果类的职责多了,我们就需要把他们分离开来,当然了,这个原则比较具有主观性。有的人觉得这个类不是单一职责,有的人觉得是单一职责。

判断是否单一职责要结合具体的业务场景和使用,如果你有多个引起这个类变化的地方,那么就需要重新考虑一下这个类是否符合单一职责了。

在我们平常开发中,没必要一开始纠结是否符合单一职责,随着开发的演进,程序越来越庞大,我们需要随着业务的发展将类分的越来越细才好,要持续重构。一定不要等到已经往一个类里面塞了很多代码才考虑划分成单一职责。那样的话拆分起来很费劲而且耗时耗力。在添加新功能的时候要想一想这个功能应该添加到哪里,是否符合单一职责

开发中可以根据下面几点来判断是否满足单一职责:

  • 类中的代码过多,影响了代码的可读性和维护性。
  • 和这个类有依赖关系的类比较多,耦合过高。
  • 比较难给类命名,不清楚这个类是干啥的了。
  • 类中大量的方法都是集中操作类的某几个属性。

类的职责是否越单一越好

显然不是的,如果一个类过于职责单一,比如加密类,有一个加密方法和解密方法。

1
2
3
4
5
6
7
8
9
class hash {
public function encode() {
//...
}

public function decode() {
//...
}
}

如果我们执着于单一职责,要把这个类拆分成两个类。一个加密类一个解密类。

1
2
3
4
5
6
7
8
9
10
11
12
class hashDecode{
public function decode() {

}
}

class hashEncode{
public function encode() {

}
}

如果这时候根据新的需求,我们的加密算法需要改变,那么解密算法也需要改变,我们要同时改变两个类,如果忘记其中一个,那么整个程序就会崩溃。我们的最终目标是写出高内聚低耦合的代码。而不是生搬硬套某一个设计原则或者设计模式。设计原则是总结出来指导我们进行程序设计的方针。合理使用可以使得我们的程序更加健壮

参考资料:

  • 大话设计模式
  • 极客时间设计模式之美

贫血模型

什么是贫血模型

贫血模型就是缺血了,缺东西,也就是只有数据但是没有业务逻辑或者有业务逻辑但是没有数据。

比如你有一个计算类,他有一个加法计算的方法。但是他不持有计算的数据。

和贫血模型对应的就是充血模型。

什么是充血模型

充血模型就是不缺血了,有数据同样有业务逻辑。

比如你的计算类现在不只有加法计算,还有需要的数据。

我们现在进行的开发基本上都是基于贫血模型开发的。

比如一个电商系统,有商品模型,但是一个商品模型只有商品的基本信息,数据。如果需要获取一个商品的总价,那么我们需要调用model里面的一个方法来计算。这就是典型的贫血模型。

如果我们在商品模型里面提供一个计算总价的方法。把数据和业务逻辑放在一起,这就是充血模型。

充血模型

充血模型要将数据和业务高度内聚在一个类中,使得类更加饱满,也是高内聚低耦合的一种实践。不过充血模型需要更好的设计整个类才能实现。因为要考虑到扩展性,测试性,可读性,复用性等。充血模型注定比贫血模型更加耗费时间,更加难以实现。

现在更推崇敏捷开发。快速实现需求,慢慢迭代升级。如果使用充血模型也容易搞得四不像。也因此贫血模型更让人喜欢。贫血模型也更容易设计和实现,日常开发中随便写的都是贫血模型。而充血模型需要进行好好的设计。那怎么设计充血模型呢?可以根据DDD领域驱动设计来实现。

领域驱动设计DDD

DDD的概念很早就有了,但是一直没有火起来。现在又出现了充血模型的概念。

DDD的核心思想其实就是根据不同的领域,功能来设计建模,划分模块。微服务的划分就可以借鉴DDD的思想,根据不同的业务领域来划分。

DDD可以用来指导我们如果做软件设计,但是想要用好DDD,需要对自身的业务足够理解,足够熟练。

领域是一个组织所做的事情以及其包含的一切。领域驱动设计就是从自身的领域出发,分析自身领域内的一切关联关系,根据其设计我们的软件。构造充血模型。也就是充血模型是和业务高度耦合的,完全从自身的领域出发进行设计。可以先将自身的领域细分出多个子领域,然后再每个子领域中设计出各自的领域模型。也就是充血模型。

比如电商领域可以分成用户,商品,订单,物流等子领域。然后设计出各自的实体对象,根据全局领域的上下文来分析需要承担哪些责任也就是哪些数据和业务逻辑。

为什么很多人不推荐mysql连表join查询

join查询是什么?是连表查询,我们需要两个表的数据,就会使用join来进行连表。那么mysql里面是怎么连表的呢?它和我们自己查询出一张表的数据在遍历去查询另外一个表是不是一样呢?

join查询

join查询还可以写成left join,表示的是根据左边的表来查询右边的表。但实际上,优化器会进行优化,选择合适的表来做驱动表,不一定是左边的表。

Index Nested-Loop Join

NLJ算法,这是一种基于索引的算法,如果你的连表用到了索引,则会使用这个算法。

比如下面的查询语句:

b.uid字段有索引,并且假设a是小表,优化器选择了a表作为驱动表,b表作为被驱动表。

1
select * from a left join b on a.id = b.uid;

执行流程大致如下:

  1. 读取表a的第一行数据
  2. 使用表a的id去b表的索引树上查询到对应的b表id
  3. 通过查询的b表id回表查询b表数据
  4. 把a表和b表数据放在一起
  5. 重复上面的步骤直到a表遍历完成
  6. 返回数据

可以看到这和我们自己查询出一张表然后遍历查询过程是一样的。

但是它的优势是省略了多次连接数据库,连接数据库是比较耗费资源和时间的,这样来看,使用join是更加好的选择。当然前提是你的join被驱动表有索引。

这个过程扫描了整个表a和表a的id对应的每一行表b。假设a表的数据量是n,那么扫描了n + n行,当然不算回表。如果算上回表其实相当于n + n + n行。

我们再看一下这个算法的时间复杂度。

b+树中,定位一个记录的时间复杂度大约是log(m)。m是b表的数据行数。为什么这里使用了b表的数据行数而不是上面说的a表的行数呢,因为这里指的是b表索引树的时间复杂度,当然是跟b表索引树大小挂钩也就是b表数据大小挂钩了。如果在算上回表查询,那么时间复杂度大约是2log(m)。

a表要进行全表扫描,那么a表的时间复杂度就是n,再加上每一行要去b表中查询,那么去b表查询的时间就是n * 2log(m)。加起来就是n + (2nlog(m))。

这里面2是常量可以忽略不记,而且2是因为回表造成的,如果我们使用覆盖索引,那么这个2就可以去掉了。

nlog(m) 比 n小,所以显然n的影响是最大的。也就是说n越小,那么我们连表的速度就越快。所以我们连表的时候要使用更小的那张表作为驱动表,然后给被驱动表的连表字段上面加上索引或者覆盖索引。这样我们的连表其实还是很快的。

Block Nested-Loop Join

如果你的被驱动表字段上面没有索引,那么mysql就会使用另外一种算法。这个算法叫BNL算法。

本来没有索引的情况下,按照上面的流程应该是下面这样:

  1. 读取表a的第一行数据
  2. 使用表a的id去b表上进行全表扫描查询到对应的b表数据
  3. 把a表和b表数据放在一起
  4. 重复上面的步骤直到a表遍历完成
  5. 返回数据

这样的话对于每一行表a的数据都要进行b表的全表扫描,也就是如果表a记为n行,表b记为m行,那么需要扫描n * m + n行数据。和上面的n + (2nlog(m))相比,可以看到慢了多少倍。

所以mysql使用了另外一种方法,也就是BNL算法,这个算法做了一下优化,流程变成了下面这样:

  1. 读取表a的所有数据放入join buffer
  2. 对表b进行全表扫描,然后把每一条数据和join buffer中的数据做对比。
  3. 把满足条件的数据返回。

这样的话,也就是扫描了一遍表a和表b,那么需要扫描n + m行数据。可以看到比上面的n + (nm)来说少了n倍的扫描量。当然了,这个算法,还需要在join buffer中进行nm次对比数据,但是这样内存判断也要比上面的方法好很多。

同样的这个算法对于两张表都是全表扫描,也就无所谓上面说的需要小表做驱动表了,反正都一样,都要全表扫描。

join buffer存在内存中,那么他就有大小的限制,参数join_buffer_size就是限制join buffer大小的。默认值是256k。如果表a的数据大于join buffer的大小,那么就会分段,也就是分多次进行。

但是分多次执行就会有一个问题了。比如分成了c次,那么表b就要经历c次的全表扫描。所以内存允许,当然Join buffer大一点速度会快一些。

这样的话就不是上面的n + m了,而是变成了n + (c*m),c是根据join buffer大小和n的大小来决定的。如果n越小或者join buffer size越大,那么c就越小。也就是说,如果我们使用小表作为驱动表,那么在遇到数据量超过join buffer的时候,速度也会比较快。

总结

总的来说,Join的时候注意下面几点:

  • 总是用小表作为驱动表比较好。
  • 能加索引就在被驱动表join字段上面加索引,使用NLJ算法而不是BNL算法。
  • 在没有索引,并且驱动表数据量过大时,可以通过调大join_buffer_size的值来加速连表查询。

mysql是怎么操作order by来进行排序的

mysql的排序用到了sort buffer,sort buffer是一个内存块。

mysql会先取出需要排序的数据,然后把数据放入sort buffer,当所有数据都放入sort buffer或者sort buffer满了就开始排序,然后将排序好的结果返回给客户端。

参数sort_buffer_size显示的就是sort buffer的大小。

如果数据量超过sort buffer,那么就会通过磁盘临时文件辅助进行排序,如果数据量比较小,则可以直接在内存中进行。

在内存中排序会使用快排算法,而通过磁盘临时文件则会使用归并排序算法

排序步骤可以分为以下几步:

  1. 取出select的数据存入sort buffer。
  2. 在sort buffer中进行快排或者归并排序算法。
  3. 如果有limit按照limit取相应的结果集进行返回。

rowid排序算法

这里面select出来的数据量可能会很大,跟你要查询的列多少有关,如果你的列很多,那么mysql可能会使用另外一种排序方法,叫做rowid排序。

rowid算法不管你查询出来的结果集,它只把必要的字段放入sort buffer中,这样sort buffer就可以存入更多的数据来进行排序。

必要的字段也就是你排序需要的字段和主键字段,比如order by time那么他只会放入id 和 time,然后按照time字段排序完成后再通过主键id回表查询一遍数据,然后返回数据。

显然rowid算法还需要再次回表,所以效率上要低一些,所以不是mysql默认使用的排序方法。

只有当你的内存不够用,查询的列太多的时候,mysql才会使用这种算法。

排序步骤可以分为以下几步:

  1. 取出排序的字段和id存入sort buffer。
  2. 在sort buffer中进行快排或者归并排序算法。
  3. 按照排序结果和limit数量回表查询然后返回数据。

不需要排序的方法

既然这样,我们为了更快速,可以避免mysql排序。

innoDB的索引是有序的,也就是说,如果我们要排序的字段本身就是有序的,那么就不用排序了。

所以我们可以在排序字段上建立索引,而如果我们查询的字段不多,甚至可以建立覆盖索引,那么速度会快很多。

mysql到底有没有进行排序,可以通过explain的执行计划来看。如果最后有using filesort,就表示使用了排序算法。