摘要:设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结,使用设计模式是为了可重用代码、让代码更容易被他人理解并且保证代码可靠性。

本文分享自华为云社区《快来,这里有23种设计模式的Go语言实现》,原文作者:元闰子 。

前言

从1995年GoF提出23种设计模式到现在,25年过去了,设计模式依旧是软件领域的热门话题。在当下,如果你不会一点设计模式,都不好意思说自己是一个合格的程序员。设计模式通常被定义为:

设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结,使用设计模式是为了可重用代码、让代码更容易被他人理解并且保证代码可靠性。

从定义上看,设计模式其实是一种经验的总结,是针对特定问题的简洁而优雅的解决方案。既然是经验总结,那么学习设计模式最直接的好处就在于可以站在巨人的肩膀上解决软件开发过程中的一些特定问题。然而,学习设计模式的最高境界是习得其中解决问题所用到的思想,当你把它们的本质思想吃透了,也就能做到即使已经忘掉某个设计模式的名称和结构,也能在解决特定问题时信手拈来

好的东西有人吹捧,当然也会招黑。设计模式被抨击主要因为以下两点:

1、设计模式会增加代码量,把程序逻辑变得复杂。这一点是不可避免的,但是我们并不能仅仅只考虑开发阶段的成本。最简单的程序当然是一个函数从头写到尾,但是这样后期的维护成本会变得非常大;而设计模式虽然增加了一点开发成本,但是能让人们写出可复用、可维护性高的程序。引用《软件设计的哲学》里的概念,前者就是战术编程,后者就是战略编程,我们应该对战术编程Say No

2、滥用设计模式。这是初学者最容易犯的错误,当学到一个模式时,恨不得在所有的代码都用上,从而在不该使用模式的地方刻意地使用了模式,导致了程序变得异常复杂。其实每个设计模式都有几个关键要素:适用场景解决方法优缺点。模式并不是万能药,它只有在特定的问题上才能显现出效果。所以,在使用一个模式前,先问问自己,当前的这个场景适用这个模式吗?

《设计模式》一书的副标题是“可复用面向对象软件的基础”,但并不意味着只有面向对象语言才能使用设计模式。模式只是一种解决特定问题的思想,跟语言无关。就像Go语言一样,它并非是像C++和Java一样的面向对象语言,但是设计模式同样适用。本系列文章将使用Go语言来实现GoF提出的23种设计模式,按照创建型模式(Creational Pattern)、结构型模式(Structural Pattern)和行为型模式(Behavioral Pattern)三种类别进行组织,文本主要介绍其中的创建型模式。

单例模式(Singleton Pattern)

简述

单例模式算是23中设计模式里最简单的一个了,它主要用于保证一个类仅有一个实例,并提供一个访问它的全局访问点

在程序设计中,有一些对象通常我们只需要一个共享的实例,比如线程池、全局缓存、对象池等,这种场景下就适合使用单例模式。

但是,并非所有全局唯一的场景都适合使用单例模式。比如,考虑需要统计一个API调用的情况,有两个指标,成功调用次数和失败调用次数。这两个指标都是全局唯一的,所以有人可能会将其建模成两个单例SuccessApiMetricFailApiMetric。按照这个思路,随着指标数量的增多,你会发现代码里类的定义会越来越多,也越来越臃肿。这也是单例模式最常见的误用场景,更好的方法是将两个指标设计成一个对象ApiMetric下的两个实例ApiMetic successApiMetic fail

如何判断一个对象是否应该被建模成单例?

通常,被建模成单例的对象都有“中心点”的含义,比如线程池就是管理所有线程的中心。所以,在判断一个对象是否适合单例模式时,先思考下,这个对象是一个中心点吗?

Go实现

在对某个对象实现单例模式时,有两个点必须要注意:(1)限制调用者直接实例化该对象;(2)为该对象的单例提供一个全局唯一的访问方法

对于C++/Java而言,只需把类的构造函数设计成私有的,并提供一个static方法去访问该类点唯一实例即可。但对于Go语言来说,即没有构造函数的概念,也没有static方法,所以需要另寻出路。

我们可以利用Go语言package的访问规则来实现,将单例结构体设计成首字母小写,就能限定其访问范围只在当前package下,模拟了C++/Java中的私有构造函数;再在当前package下实现一个首字母大写的访问函数,就相当于static方法的作用了。

在实际开发中,我们经常会遇到需要频繁创建和销毁的对象。频繁的创建和销毁一则消耗CPU,二则内存的利用率也不高,通常我们都会使用对象池技术来进行优化。考虑我们需要实现一个消息对象池,因为是全局的中心点,管理所有的Message实例,所以将其实现成单例,实现代码如下:

 package msgpool...// 消息池type messagePool struct {pool *sync.Pool}// 消息池单例var msgPool = &messagePool{// 如果消息池里没有消息,则新建一个Count值为0的Message实例pool: &sync.Pool{New: func() interface{} { return &Message{Count: 0} }},}// 访问消息池单例的唯一方法func Instance() *messagePool {return msgPool}// 往消息池里添加消息func (m *messagePool) AddMsg(msg *Message) {m.pool.Put(msg)}// 从消息池里获取消息func (m *messagePool) GetMsg() *Message {return m.pool.Get().(*Message)}...

测试代码如下:

package test...func TestMessagePool(t *testing.T) {msg0 := msgpool.Instance().GetMsg()if msg0.Count != 0 {t.Errorf("expect msg count %d, but actual %d.", 0, msg0.Count)}msg0.Count = 1msgpool.Instance().AddMsg(msg0)msg1 := msgpool.Instance().GetMsg()if msg1.Count != 1 {t.Errorf("expect msg count %d, but actual %d.", 1, msg1.Count)}}// 运行结果=== RUN   TestMessagePool--- PASS: TestMessagePool (0.00s)PASS

以上的单例模式就是典型的“饿汉模式”,实例在系统加载的时候就已经完成了初始化。对应地,还有一种“懒汉模式”,只有等到对象被使用的时候,才会去初始化它,从而一定程度上节省了内存。众所周知,“懒汉模式”会带来线程安全问题,可以通过普通加锁,或者更高效的双重检验锁来优化。对于“懒汉模式”,Go语言有一个更优雅的实现方式,那就是利用sync.Once,它有一个Do方法,其入参是一个方法,Go语言会保证仅仅只调用一次该方法。

 // 单例模式的“懒汉模式”实现package msgpool...var once = &sync.Once{}// 消息池单例,在首次调用时初始化var msgPool *messagePool// 全局唯一获取消息池pool到方法func Instance() *messagePool {// 在匿名函数中实现初始化逻辑,Go语言保证只会调用一次once.Do(func() {msgPool = &messagePool{// 如果消息池里没有消息,则新建一个Count值为0的Message实例pool: &sync.Pool{New: func() interface{} { return &Message{Count: 0} }},}})return msgPool}...

建造者模式(Builder Pattern)

简述

在程序设计中,我们会经常遇到一些复杂的对象,其中有很多成员属性,甚至嵌套着多个复杂的对象。这种情况下,创建这个复杂对象就会变得很繁琐。对于C++/Java而言,最常见的表现就是构造函数有着长长的参数列表:

 MyObject obj = new MyObject(param1, param2, param3, param4, param5, param6, ...)

而对于Go语言来说,最常见的表现就是多层的嵌套实例化:

obj := &MyObject{Field1: &Field1 {Param1: &Param1 {Val: 0,},Param2: &Param2 {Val: 1,},...},Field2: &Field2 {Param3: &Param3 {Val: 2,},...},...}

上述的对象创建方法有两个明显的缺点:(1)对对象使用者不友好,使用者在创建对象时需要知道的细节太多;(2)代码可读性很差

针对这种对象成员较多,创建对象逻辑较为繁琐的场景,就适合使用建造者模式来进行优化。

建造者模式的作用有如下几个:

1、封装复杂对象的创建过程,使对象使用者不感知复杂的创建逻辑。

2、可以一步步按照顺序对成员进行赋值,或者创建嵌套对象,并最终完成目标对象的创建。

3、对多个对象复用同样的对象创建逻辑。

其中,第1和第2点比较常用,下面对建造者模式的实现也主要是针对这两点进行示例。

Go实现

考虑如下的一个Message结构体,其主要有HeaderBody组成:

package msg...type Message struct {Header *HeaderBody   *Body}type Header struct {SrcAddr  stringSrcPort  uint64DestAddr stringDestPort uint64Items    map[string]string}type Body struct {Items []string}...

如果按照直接的对象创建方式,创建逻辑应该是这样的:

 // 多层的嵌套实例化message := msg.Message{Header: &msg.Header{SrcAddr:  "192.168.0.1",SrcPort:  1234,DestAddr: "192.168.0.2",DestPort: 8080,Items:    make(map[string]string),},Body:   &msg.Body{Items: make([]string, 0),},}// 需要知道对象的实现细节message.Header.Items["contents"] = "application/json"message.Body.Items = append(message.Body.Items, "record1")message.Body.Items = append(message.Body.Items, "record2")

虽然Message结构体嵌套的层次不多,但是从其创建的代码来看,确实存在对对象使用者不友好代码可读性差的缺点。下面我们引入建造者模式对代码进行重构:

package msg...// Message对象的Builder对象type builder struct {once *sync.Oncemsg *Message}// 返回Builder对象func Builder() *builder {return &builder{once: &sync.Once{},msg: &Message{Header: &Header{}, Body: &Body{}},}}// 以下是对Message成员对构建方法func (b *builder) WithSrcAddr(srcAddr string) *builder {b.msg.Header.SrcAddr = srcAddrreturn b}func (b *builder) WithSrcPort(srcPort uint64) *builder {b.msg.Header.SrcPort = srcPortreturn b}func (b *builder) WithDestAddr(destAddr string) *builder {b.msg.Header.DestAddr = destAddrreturn b}func (b *builder) WithDestPort(destPort uint64) *builder {b.msg.Header.DestPort = destPortreturn b}func (b *builder) WithHeaderItem(key, value string) *builder {// 保证map只初始化一次b.once.Do(func() {b.msg.Header.Items = make(map[string]string)})b.msg.Header.Items[key] = valuereturn b}func (b *builder) WithBodyItem(record string) *builder {b.msg.Body.Items = append(b.msg.Body.Items, record)return b}// 创建Message对象,在最后一步调用func (b *builder) Build() *Message {return b.msg}

测试代码如下:

package test...func TestMessageBuilder(t *testing.T) {// 使用消息建造者进行对象创建message := msg.Builder().WithSrcAddr("192.168.0.1").WithSrcPort(1234).WithDestAddr("192.168.0.2").WithDestPort(8080).WithHeaderItem("contents", "application/json").WithBodyItem("record1").WithBodyItem("record2").Build()if message.Header.SrcAddr != "192.168.0.1" {t.Errorf("expect src address 192.168.0.1, but actual %s.", message.Header.SrcAddr)}if message.Body.Items[0] != "record1" {t.Errorf("expect body item0 record1, but actual %s.", message.Body.Items[0])}}// 运行结果=== RUN   TestMessageBuilder--- PASS: TestMessageBuilder (0.00s)PASS

从测试代码可知,使用建造者模式来进行对象创建,使用者不再需要知道对象具体的实现细节,代码可读性也更好。

工厂方法模式(Factory Method Pattern)

简述

工厂方法模式跟上一节讨论的建造者模式类似,都是将对象创建的逻辑封装起来,为使用者提供一个简单易用的对象创建接口。两者在应用场景上稍有区别,建造者模式更常用于需要传递多个参数来进行实例化的场景。

使用工厂方法来创建对象主要有两个好处:

1、代码可读性更好。相比于使用C++/Java中的构造函数,或者Go中的{}来创建对象,工厂方法因为可以通过函数名来表达代码含义,从而具备更好的可读性。比如,使用工厂方法productA := CreateProductA()创建一个ProductA对象,比直接使用productA := ProductA{}的可读性要好。

2、与使用者代码解耦。很多情况下,对象的创建往往是一个容易变化的点,通过工厂方法来封装对象的创建过程,可以在创建逻辑变更时,避免霰弹式修改

工厂方法模式也有两种实现方式:(1)提供一个工厂对象,通过调用工厂对象的工厂方法来创建产品对象;(2)将工厂方法集成到产品对象中(C++/Java中对象的static方法,Go中同一package下的函数)

Go实现

考虑有一个事件对象Event,分别有两种有效的时间类型StartEnd

package event...type Type uint8// 事件类型定义const (Start Type = iotaEnd)// 事件抽象接口type Event interface {EventType() TypeContent() string}// 开始事件,实现了Event接口type StartEvent struct{content string}...// 结束事件,实现了Event接口type EndEvent struct{content string}...

1、按照第一种实现方式,为Event提供一个工厂对象,具体代码如下:

 package event...// 事件工厂对象type Factory struct{}// 更具事件类型创建具体事件func (e *Factory) Create(etype Type) Event {switch etype {case Start:return &StartEvent{content: "this is start event",}case End:return &EndEvent{content: "this is end event",}default:return nil}}

测试代码如下:

package test...func TestEventFactory(t *testing.T) {factory := event.Factory{}e := factory.Create(event.Start)if e.EventType() != event.Start {t.Errorf("expect event.Start, but actual %v.", e.EventType())}e = factory.Create(event.End)if e.EventType() != event.End {t.Errorf("expect event.End, but actual %v.", e.EventType())}}// 运行结果=== RUN   TestEventFactory--- PASS: TestEventFactory (0.00s)PASS

2、按照第二种实现方式,分别给StartEnd类型的Event单独提供一个工厂方法,代码如下:

package event...// Start类型Event的工厂方法func OfStart() Event {return &StartEvent{content: "this is start event",}}// End类型Event的工厂方法func OfEnd() Event {return &EndEvent{content: "this is end event",}}

测试代码如下:

package event...func TestEvent(t *testing.T) {e := event.OfStart()if e.EventType() != event.Start {t.Errorf("expect event.Start, but actual %v.", e.EventType())}e = event.OfEnd()if e.EventType() != event.End {t.Errorf("expect event.End, but actual %v.", e.EventType())}}// 运行结果=== RUN   TestEvent--- PASS: TestEvent (0.00s)PASS

抽象工厂模式(Abstract Factory Pattern)

简述

在工厂方法模式中,我们通过一个工厂对象来创建一个产品族,具体创建哪个产品,则通过swtich-case的方式去判断。这也意味着该产品组上,每新增一类产品对象,都必须修改原来工厂对象的代码;而且随着产品的不断增多,工厂对象的职责也越来越重,违反了单一职责原则

抽象工厂模式通过给工厂类新增一个抽象层解决了该问题,如上图所示,FactoryAFactoryB都实现·抽象工厂接口,分别用于创建ProductAProductB。如果后续新增了ProductC,只需新增一个FactoryC即可,无需修改原有的代码;因为每个工厂只负责创建一个产品,因此也遵循了单一职责原则

Go实现

考虑需要如下一个插件架构风格的消息处理系统,pipeline是消息处理的管道,其中包含了inputfilteroutput三个插件。我们需要实现根据配置来创建pipeline ,加载插件过程的实现非常适合使用工厂模式,其中inputfilteroutput三类插件的创建使用抽象工厂模式,而pipeline的创建则使用工厂方法模式。

各类插件和pipeline的接口定义如下:

package plugin...// 插件抽象接口定义type Plugin interface {}// 输入插件,用于接收消息type Input interface {PluginReceive() string}// 过滤插件,用于处理消息type Filter interface {PluginProcess(msg string) string}// 输出插件,用于发送消息type Output interface {PluginSend(msg string)}
package pipeline...// 消息管道的定义type Pipeline struct {input  plugin.Inputfilter plugin.Filteroutput plugin.Output}// 一个消息的处理流程为 input -> filter -> outputfunc (p *Pipeline) Exec() {msg := p.input.Receive()msg = p.filter.Process(msg)p.output.Send(msg)}

接着,我们定义inputfilteroutput三类插件接口的具体实现:

package plugin...// input插件名称与类型的映射关系,主要用于通过反射创建input对象var inputNames = make(map[string]reflect.Type)// Hello input插件,接收“Hello World”消息type HelloInput struct {}​func (h *HelloInput) Receive() string {return "Hello World"}// 初始化input插件映射关系表func init() {inputNames["hello"] = reflect.TypeOf(HelloInput{})}
 package plugin...// filter插件名称与类型的映射关系,主要用于通过反射创建filter对象var filterNames = make(map[string]reflect.Type)// Upper filter插件,将消息全部字母转成大写type UpperFilter struct {}​func (u *UpperFilter) Process(msg string) string {return strings.ToUpper(msg)}// 初始化filter插件映射关系表func init() {filterNames["upper"] = reflect.TypeOf(UpperFilter{})}
 package plugin...// output插件名称与类型的映射关系,主要用于通过反射创建output对象var outputNames = make(map[string]reflect.Type)// Console output插件,将消息输出到控制台上type ConsoleOutput struct {}​func (c *ConsoleOutput) Send(msg string) {fmt.Println(msg)}// 初始化output插件映射关系表func init() {outputNames["console"] = reflect.TypeOf(ConsoleOutput{})}

然后,我们定义插件抽象工厂接口,以及对应插件的工厂实现:

package plugin...// 插件抽象工厂接口type Factory interface {Create(conf Config) Plugin}// input插件工厂对象,实现Factory接口type InputFactory struct{}// 读取配置,通过反射机制进行对象实例化func (i *InputFactory) Create(conf Config) Plugin {t, _ := inputNames[conf.Name]return reflect.New(t).Interface().(Plugin)}// filter和output插件工厂实现类似type FilterFactory struct{}func (f *FilterFactory) Create(conf Config) Plugin {t, _ := filterNames[conf.Name]return reflect.New(t).Interface().(Plugin)}type OutputFactory struct{}func (o *OutputFactory) Create(conf Config) Plugin {t, _ := outputNames[conf.Name]return reflect.New(t).Interface().(Plugin)}

最后定义pipeline的工厂方法,调用plugin.Factory抽象工厂完成pipelien对象的实例化:

package pipeline...// 保存用于创建Plugin的工厂实例,其中map的key为插件类型,value为抽象工厂接口var pluginFactories = make(map[plugin.Type]plugin.Factory)// 根据plugin.Type返回对应Plugin类型的工厂实例func factoryOf(t plugin.Type) plugin.Factory {factory, _ := pluginFactories[t]return factory}// pipeline工厂方法,根据配置创建一个Pipeline实例func Of(conf Config) *Pipeline {p := &Pipeline{}p.input = factoryOf(plugin.InputType).Create(conf.Input).(plugin.Input)p.filter = factoryOf(plugin.FilterType).Create(conf.Filter).(plugin.Filter)p.output = factoryOf(plugin.OutputType).Create(conf.Output).(plugin.Output)return p}// 初始化插件工厂对象func init() {pluginFactories[plugin.InputType] = &plugin.InputFactory{}pluginFactories[plugin.FilterType] = &plugin.FilterFactory{}pluginFactories[plugin.OutputType] = &plugin.OutputFactory{}}

测试代码如下:

package test...func TestPipeline(t *testing.T) {// 其中pipeline.DefaultConfig()的配置内容见【抽象工厂模式示例图】// 消息处理流程为 HelloInput -> UpperFilter -> ConsoleOutputp := pipeline.Of(pipeline.DefaultConfig())p.Exec()}// 运行结果=== RUN   TestPipelineHELLO WORLD--- PASS: TestPipeline (0.00s)PASS

原型模式(Prototype Pattern)

简述

原型模式主要解决对象复制的问题,它的核心就是clone()方法,返回Prototype对象的复制品。在程序设计过程中,往往会遇到有一些场景需要大量相同的对象,如果不使用原型模式,那么我们可能会这样进行对象的创建:新创建一个相同对象的实例,然后遍历原始对象的所有成员变量, 并将成员变量值复制到新对象中。这种方法的缺点很明显,那就是使用者必须知道对象的实现细节,导致代码之间的耦合。另外,对象很有可能存在除了对象本身以外不可见的变量,这种情况下该方法就行不通了。

对于这种情况,更好的方法就是使用原型模式,将复制逻辑委托给对象本身,这样,上述两个问题也都迎刃而解了。

Go实现

还是以建造者模式一节中的Message作为例子,现在设计一个Prototype抽象接口:

package prototype...// 原型复制抽象接口type Prototype interface {clone() Prototype}​type Message struct {Header *HeaderBody   *Body}​func (m *Message) clone() Prototype {msg := *mreturn &msg}

测试代码如下:

package test...func TestPrototype(t *testing.T) {message := msg.Builder().WithSrcAddr("192.168.0.1").WithSrcPort(1234).WithDestAddr("192.168.0.2").WithDestPort(8080).WithHeaderItem("contents", "application/json").WithBodyItem("record1").WithBodyItem("record2").Build()// 复制一份消息newMessage := message.Clone().(*msg.Message)if newMessage.Header.SrcAddr != message.Header.SrcAddr {t.Errorf("Clone Message failed.")}if newMessage.Body.Items[0] != message.Body.Items[0] {t.Errorf("Clone Message failed.")}}// 运行结果=== RUN   TestPrototype--- PASS: TestPrototype (0.00s)PASS

总结

本文主要介绍了GoF的23种设计模式中的5种创建型模式,创建型模式的目的都是提供一个简单的接口,让对象的创建过程与使用者解耦。其中,单例模式主要用于保证一个类仅有一个实例,并提供一个访问它的全局访问点;建造者模式主要解决需要创建对象时需要传入多个参数,或者对初始化顺序有要求的场景;工厂方法模式通过提供一个工厂对象或者工厂方法,为使用者隐藏了对象创建的细节;抽象工厂模式是对工厂方法模式的优化,通过为工厂对象新增一个抽象层,让工厂对象遵循单一职责原则,也避免了霰弹式修改;原型模式则让对象复制更加简单。

点击关注,第一时间了解华为云新鲜技术~

快来,这里有23种设计模式的Go语言实现相关推荐

  1. 从追MM谈23种设计模式

    从追MM谈Java的23种设计模式 1.FACTORY-追MM少不了请吃饭了,麦当劳的鸡翅和肯德基的鸡翅都是MM爱吃的东西,虽然口味有所不同,但不管你带MM去麦当劳或肯德基,只管向服务员说&quo ...

  2. 23种设计模式的有趣见解 .

    转载地址:http://blog.csdn.net/leichelle/article/details/7999588 23种设计模式的有趣见解 23种设计模式的有趣见解创 建型模式 1.FACTOR ...

  3. 23种设计模式简介_设计模式简介

    23种设计模式简介 本文是我们名为" Java设计模式 "的学院课程的一部分. 在本课程中,您将深入研究大量的设计模式,并了解如何在Java中实现和利用它们. 您将了解模式如此重要 ...

  4. (转):GOF设计模式趣解(23种设计模式)

    GOF设计模式趣解(23种设计模式) 创建型模式 1.FACTORY--追MM少不了请吃饭了,麦当劳的鸡翅和肯德基的鸡翅都是MM爱吃的东西,虽然口味有所不同,但不管你带MM去麦当劳或肯德基,只管向服务 ...

  5. 经典:从追MM谈Java的23种设计模式

    2019独角兽企业重金招聘Python工程师标准>>> 从追MM谈Java的23种设计模式1.FACTORY-追MM少不了请吃饭了,麦当劳的鸡翅和肯德基的鸡翅都是MM爱吃的东西,虽然 ...

  6. 追MM“23式”—— GOF 23种设计模式

    追MM"23式" -- GOF 23种设计模式 创建型模式 1.FACTORY 2.BUILDER 3.FACTORY METHOD 4.PROTOTYPE 5.SINGLETON ...

  7. 备战面试日记(3.2) - (设计模式.23种设计模式之创建型模式)

    本人本科毕业,21届毕业生,一年工作经验,简历专业技能如下,现根据简历,并根据所学知识复习准备面试. 记录日期:2022.1.6 大部分知识点只做大致介绍,具体内容根据推荐博文链接进行详细复习. 文章 ...

  8. java 23种设计模式详解

    1. 软件设计模式意义 文章链接:http://c.biancheng.net/design_pattern/     设计模式的本质是对面向对象设计原则运用,是对类的封装.继承和多态以及类的关联关系 ...

  9. 23种设计模式-个人笔记(二)

    目录 五.23 种设计模式 1.单例模式 1.1.单例模式的定义与特点 1.2.单例模式的优点和缺点 1.3.单例模式的应用场景 1.4.单例模式的结构与实现 1.5.八种方式详解 1.6.单例模式在 ...

最新文章

  1. 官宣!又一“国字号”大学要来了?
  2. 【C++】 vector.erase()
  3. Windows XP添加网络打印机的方法
  4. C# 类的派生 输出个人信息
  5. oracle服务器和客户端字符集的查看和修改
  6. 【转】Microsoft Teams快速上手系列-02Teams组成概述及使用分享
  7. 棒棒的毛笔字PS教程
  8. 什么是IP防护等级,又该如何区分
  9. 关于芯片最高工作频率的计算
  10. 如何让 MacBook 最适化 macOS Big Sur 和 Monterey 电池
  11. iOS:编译问题Presenting view controllers on detached view controllers is discouraged
  12. 分析全基因组上的蛋白信息
  13. 时光飞逝,思考,实践,伴我一生的经验
  14. 【新东郊商城】周年店庆 关注微信,转发微博抽奖品!玩赚乐不停
  15. win10电脑计算机内存不足,win10系统电脑总是提示“计算机的内存不足”的恢复方案...
  16. python 读取行数据_openpyxl读取所有行数据之rows属性
  17. DNS 域名称服务
  18. (二)弹性布局Flex
  19. 微信小程序之数据交互
  20. 【CSS】css 获取从第n个元素开始,之后的所有元素 :nth-of-type(n)与:nth-child(n)

热门文章

  1. Bootstrap3 工具提示插件的事件
  2. 定制Bootstrap
  3. ROS笔记(17) Gazebo
  4. 联想投资服务器5g芯片,从5G投票到要没必要做芯片,联想到了最危险的时候
  5. vb6.0企业版win7_比win7更快?官方精简win10:3G+不更新,旧电脑福音
  6. 导入,导出字符和数字的转换
  7. 雷林鹏分享:jQuery EasyUI 拖放 - 基本的拖动和放置
  8. javascript遍历json对象数据的方法
  9. Android中最常用也是最难用的控件——ListView
  10. Git:错误:error:src refspec master does not match any