Practice GoF’s 23 design patterns: SOLID Principles

Simple distributed application (example code engineering) : github.com/ruanrunxue/…

The paper

GoF defines a Singleton as follows:

Ensure a class only has one instance, and provide a global point of access to it.

That is, ensure that a class has only one instance and provide it with a global access point.

In programming, some objects usually only need a shared instance, such as thread pools, global caches, object pools, and so on. Global variables are the simplest and most straightforward way to implement shared instances. However, using global variables brings some problems, such as:

  1. Client programs can create instances of the same class, so there is no guarantee that there is only one shared instance on the entire system.

  2. It is difficult to control the access of objects, for example, it is difficult to add a function of “access count”, which has low scalability.

  3. Exposing implementation details to the client program deepens the coupling and makes it easy to make shotgun changes.

It is better to use the singleton pattern for this globally unique scenario. The singleton pattern can restrict client programs from creating instances of the same class, and can extend or modify functionality at global access points without affecting the client program.

However, not all global uniqueness applies to the singleton pattern. Consider the following scenario:

Consider the case where you need to count an API call. There are two metrics, the number of successful calls and the number of failed calls. Both metrics are globally unique, so one might model them as two singleton SuccessapiMetrics and failapimetrics. Along these lines, as the number of metrics increases, you’ll find that your code will become more and more defined and bloated with classes. This is also the most common misapplication scenario for the singleton pattern. A better approach would be to design two metrics into two instances of ApiMetric success and ApiMetic Fail under one object.

So how do you tell if an object should be modeled as a singleton? In general, objects modeled as singletons have a “central point” meaning, such as a thread pool, which is the center that manages all threads. So, when deciding whether an object fits the singleton pattern, think first, is it a central point?

UML structure

Code implementation

According to the singleton pattern definition, there are two key points of implementation:

  1. Restrict callers from instantiating the object directly;

  2. Provide a globally unique access method for a singleton of the object.

For C++ / Java, you simply make the constructor of an object private and provide a static method to access a unique instance of the object. But the Go language has no concept of constructors and no static methods, so you need to look elsewhere.

We can implement this by using the Go language package access rules, making the singleton lowercase so that it can be accessed only under the current package, emulating C++ / Java private constructors. Then, implement an accessor function in uppercase under the current package, which is equivalent to the static method.

The sample

In a simple distributed application system (example code engineering), we define a network module network to simulate the network packet forwarding function. The design of network is also very simple. The mapping between Endpoint and Socket is maintained through a hash table. During packet forwarding, packets are addressed to the Socket through Endpoint, and then the Socket Receive method is invoked to complete the forwarding.

Because there is only one Network object for the whole system and it has the semantics of a central point in the domain model, it is natural to use the singleton pattern to implement it. Singleton patterns can be roughly divided into two categories, “hungrier pattern” and “slacker pattern.” The former completes the singleton instantiation during system initialization. The latter is lazily instantiated at invocation time, thereby saving memory to a certain extent.

The “Hungry and Han Mode” has been realized

// demo/network/network.go package network/ / 1. It starts with a lowercase letter, indicating that the network package is visible only. Type network struct {sockets sync.Mapvar instancevar instance} Var instance = &network{sockets: Func Instance() *network {return Instance} func (n *network) Listen(endpoint) Endpoint, socket Socket) error { if _, ok := n.sockets.Load(endpoint); ok { return ErrEndpointAlreadyListened } n.sockets.Store(endpoint, socket) return nil } func (n *network) Send(packet *Packet) error { record, rOk := n.sockets.Load(packet.Dest()) socket, sOk := record.(Socket) if ! rOk || ! sOk { return ErrConnectionRefuse } go socket.Receive(packet) return nil }Copy the code

The client can then reference the singleton via network.instance () :

// demo/sidecar/flowctrl_sidecar.go package sidecar type FlowCtrlSidecar struct {... Func (f *FlowCtrlSidecar) Listen(endpoint network.endpoint) error {return network.Instance().Listen(endpoint, f) } ...Copy the code

“Slacker mode” implemented

It is well known that “lazy mode” causes thread-safety problems and can be optimized with plain locking or, more efficiently, double-checked locking. Either way, the goal is to ensure that singletons are initialized only once.

type network struct {... Var mutex = sync. mutex {} Func Instance() *network {mutex.lock () if Instance == nil {Instance = &network{sockets: Sync.map {}}} mutex.unlock () return instance} Func Instance() *network {if Instance == nil {mutex.lock () if Instance == nil {Instance = &network{sockets: sync.Map{}} } mutex.Unlock() } return instance }Copy the code

A more elegant implementation of the Go language for “lazy mode” takes advantage of sync.once. It has a Do method declared func(o *Once) Do(f func()), where the input parameter is the method type of func(), and Go guarantees that the method will only be called Once. With this feature, we can implement singletons that are initialized only once.

type network struct {... } // singleton var instance *network // define once object var once = sync.once {} // ensure that instance is initialized only once func instance () *network {once.Do(func() {// will only be called once instance = &network{sockets: sync.map {}}) return instance}Copy the code

extension

Provide multiple instances

Although the singleton pattern by definition means that there can only be one instance of each object, we should not be limited by this definition and have to understand it from the motivation of the pattern itself. One of the motivations for the singleton pattern is to limit the number of instances that the client program can instantiate objects. It doesn’t really matter how many instances there are, just model and design them according to the specific scenario.

For example, in the previous Network module, there is now a requirement to split the network into Internet and LAN. So, we can design it like this:

type network struct {... Var inetInstance = &network{sockets: sync.Map{}} var inetInstance = &network{sockets: sync. Sync.map {}} // Define a globally visible unique access method to the Internet func Internet() *network {return inetInstance} // Define a globally visible unique access method to the Lan func Lan() *network  { return lanInstance }Copy the code

Although there are two instances of the Network structure in the above example, it is essentially a singleton pattern because it limits client instantiation and provides globally unique access methods for each singleton.

Provide multiple implementations

The singleton pattern can also be polymorphic. If you predict that the singleton will be extended in the future, you can design it as an abstract interface that the client can rely on so that the client program can be extended in the future without changing it.

For example, we can design network as an abstract interface:

// network interface type network interface {Listen(endpoint, Socket Socket) error Send(packet * packet) error} // network implementation 1 type networkImpl1 struct {sockets sync.map} func (n *networkImpl1) Listen(endpoint Endpoint, socket Socket) error {... } func (n *networkImpl1) Send(packet *Packet) error {... Var instance = &networkImpl1{sockets: sync.map {}} var instance = &networkimpl1 {sockets: sync.map {}} Func Instance() network {return Instance} // Example for clients func client() {packet := network.newpacket (srcEndpoint, destEndpoint, payload) network.Instance().Send(packet) }Copy the code

If we need to add a new networkImpl2 implementation in the future, we can simply change the instance initialization logic, without changing the client program:

2 type networkImpl2 struct {... } func (n *networkImpl2) Listen(endpoint Endpoint, socket Socket) error {... } func (n *networkImpl2) Send(packet *Packet) error {... Var instance = &networkimpl2 {... Func Instance() network {return Instance} func client() {packet := network.NewPacket(srcEndpoint, destEndpoint, payload) network.Instance().Send(packet) }Copy the code

Sometimes we also need to read the configuration to determine which singleton implementation to use, so we can maintain all implementations through the map and select the corresponding implementation for the specific configuration:

// network interface type network interface {Listen(endpoint, Socket socket) error Send(packet * packet) error} } type networkImpl2 struct {... } type networkImpl3 struct {... } type networkImpl4 struct {... Func init() {instances["impl1"] = &networkImpl1{instances["impl1"] = &networkImpl1{instances["impl1"] = &networkImpl1{ } instances["impl2"] = &networkImpl2{... } instances["impl3"] = &networkImpl3{... } instances["impl4"] = &networkImpl4{... Func Instance() network {impl := readConf() Instance, ok := instances[impl] if! ok { panic("instance not found") } return instance }Copy the code

Typical Application Scenarios

  1. The logs. Each service typically requires a global log object to record the logs generated by its own service.

  2. Global configuration. For some global configurations, you can define a singleton for the client to use.

  3. Unique sequence number generation. Unique sequence number generation necessarily requires that the whole system can only have one instance of generation, which is very suitable to use singleton mode.

  4. Thread pools, object pools, connection pools, etc. XXX pools are by nature shared and are a common scenario for singleton patterns.

  5. Global cache

  6. .

The advantages and disadvantages

advantages

In appropriate scenarios, using the singleton pattern has the following advantages:

  1. The entire system has only one or a few instances, effectively saving memory and object creation overhead.

  2. With global access points, you can easily extend functionality, such as adding statistics for new accesses.

  3. Hiding implementation details from the client avoids shotgun changes.

disadvantages

Although the singleton pattern has many advantages over global variables, it is still a “global variable” in nature and cannot avoid some disadvantages of global variables:

  1. Implicit coupling of function calls. We usually expect to know from the declaration of a function what the function does, what it depends on, and what it returns. Using the singleton pattern means that the instance can be used in the function without passing the parameter through the function. Dependencies/coupling will also be implicit, which is not good for understanding the code.

  2. Not test friendly. When testing a method/function, we don’t need to know its implementation. However, if a method/function uses a singleton object, we have to consider the change in singleton state, i.e. the implementation of the method/function.

  3. Concurrency problems. Sharing implies concurrency issues that need to be considered not only during initialization, but also after initialization. Therefore, in high concurrency scenarios, the singleton pattern may also have lock conflicts.

The singleton pattern, while simple and easy to use, is also one of the most easily abused design patterns. It is not a silver bullet. Use it with caution based on specific service scenarios.

Associations with other patterns

The factory method pattern, or abstract factory pattern, is often implemented as a singleton pattern because factory classes are usually stateless and require only one instance globally, thus avoiding frequent object creation and destruction.