Take a look at the following code:
var DefaultCacher Cacher
const (
defaultRefreshTTL = time.Second
defaultTTL = time.Minute
defaultTTL4NotInvalid = time.Second * 10
defaultMaxFetchItemsCount = 10
)
type CacheLoader struct {
cacher Cacher // Cache methods
loader Loader // back to the source method
refreshTTL time.Duration // The automatic source return time, if any, must be shorter than TTL
ttl time.Duration // Cache expiration time
ttl4NotInvalid time.Duration // Invalid data cache time
maxFetchItemsCount int32 // How many pieces of data can be requested at a time
}
func NewCacheLoader(loader Loader, cacher Cacher, refreshTTL, ttl, ttl4NotInvalid time.Duration, maxFetchItemsCount int32) (*CacheLoader, error) {
if loader == nil {
return nil, fmt.Errorf("invalid loader")
}
cl := &CacheLoader{
loader: loader,
cacher: cacher,
refreshTTL: refreshTTL,
ttl: ttl,
ttl4NotInvalid: ttl4NotInvalid,
maxFetchItemsCount: maxFetchItemsCount,
}
if cacher == nil {
cl.cacher = DefaultCacher
}
if refreshTTL == 0 {
cl.refreshTTL = defaultRefreshTTL
}
if ttl == 0 {
cl.ttl = defaultTTL
}
if ttl4NotInvalid == 0 {
cl.ttl4NotInvalid = defaultTTL4NotInvalid
}
if maxFetchItemsCount == 0 {
cl.maxFetchItemsCount = defaultMaxFetchItemsCount
}
return cl, nil
}
Copy the code
CacheLoader is an automatic source return component developed by our group leaders. The main idea of this component is based on the Read Throught mode, and the source return process is implemented by the component. The user only needs to get data from the CacheLoader, which greatly simplifies the data acquisition process.
What’s wrong with the component initialization code?
In fact, the main problem is that there are too many optional attributes, resulting in the long parameters of the new method. If we continue to maintain the component and add many new attributes, then the number of configurable items will become more and more. If we continue to use the current design idea, the parameter list of the constructor will become longer and longer. As you can imagine, the code gets worse in terms of readability and ease of use, and while using this constructor, it’s easy to get the arguments in the wrong order and pass in the wrong values, leading to very subtle bugs (forget it, I’ve actually experienced this).
So what is the solution to these problems? That’s the subject of this article — how to gracefully initialize an entity.
This paper is based on golang implementation.
For an entity like the one above, some are mandatory, such as back to source methods, some are optional, can have default values, such as cacher, TTL, etc., and even some can be null. The requirement is to be able to declare different implementations for different arguments, much like the definition of overloaded functions. An overloaded function is a special case of a function that allows several functions of the same name to be declared in the same scope, but with different formal arguments (the number, type, or order of arguments). In other words, the same function performs different functions.
So using the idea of overloaded functions, we can break different configuration item combinations into different methods:
func NewDefaultCacheLoader(loader Loader) *CacheLoader {
return &CacheLoader{
loader: loader,
cacher: DefaultCacher,
refreshTTL: defaultRefreshTTL,
ttl: defaultTTL,
ttl4NotInvalid: defaultTTL4NotInvalid,
maxFetchItemsCount: defaultMaxFetchItemsCount,
}
}
func NewCacheLoaderWithCacher(loader Loader, cacher Cacher) *CacheLoader {
return &CacheLoader{
cacher: cacher,
loader: loader,
refreshTTL: defaultRefreshTTL,
ttl: defaultTTL,
ttl4NotInvalid: defaultTTL4NotInvalid,
maxFetchItemsCount: defaultMaxFetchItemsCount,
}
}
func NewCacheLoaderWithCacherAndTTL(loader Loader, cacher Cacher, ttl time.Duration) *CacheLoader {
return &CacheLoader{
cacher: cacher,
loader: loader,
ttl: ttl,
refreshTTL: defaultRefreshTTL,
ttl4NotInvalid: defaultTTL4NotInvalid,
maxFetchItemsCount: defaultMaxFetchItemsCount,
}
}
// Other configurations
Copy the code
Because GO does not support overloading, we need to declare that different method names represent different configurations. This approach is easy to implement, but if there are too many combinations of options, adding constructors can be disastrous. For example, if there are five optional parameters in this example, there should be 2^5, or 32 combinations. Obviously, it would be too painful to implement all dozens of functions.
The first way to solve this problem is to use the set() method, which is essentially a constructor that adds n optional arguments to the set() method to get the desired state:
func NewCacheLoader(loader Loader) *CacheLoader {
return &CacheLoader{
loader: loader,
}
}
func (cl *CacheLoader) SetCacher(cacher Cacher) {
cl.cacher = cacher
}
func (cl *CacheLoader) SetTTL(ttl time.Duration) {
cl.ttl = ttl
}
// omit the other set methods
Copy the code
It does seem a lot simpler, and we can combine the parameters we want. But there are other problems: We cannot verify the validity of parameters in A centralized manner, that is to say, parameter A and parameter B may be valid when used alone, but invalid when combined together. For example, refreshTTL and TTL attributes in our source component are configured. Then you need to ensure that refreshTTL is not greater than TTL, otherwise automatic source back is meaningless. There is also the problem of exposing the intermediate state of the entity. It is possible to start using the parameters before they are fully assembled, because the set() method can be called anywhere in the business code.
Another solution is to implement configuration, encapsulating optional parameters in Option/Config:
type CacheLoader struct {
loader Loader / / back to the source
option *Option
}
type Option struct {
cacher Cacher / / cache
refreshTTL time.Duration // Automatic source time
ttl time.Duration // Cache expiration time
ttl4NotInvalid time.Duration // Invalid data cache time
maxFetchItemsCount int32 // How many pieces of data can be requested at a time} the New method looks like this:func NewCacheLoader(loader Loader, option *Option) *CacheLoader {
// check
return &CacheLoader{
loader: loader,
option: option,
}
}
Copy the code
Good, our code now looks a lot cleaner, just need a new method, and then do a centralized check in there, and a lot of optimizations will probably end there. However, we can still find some problems from the above code, that is, because Option and some of its internal attributes are not mandatory, it is obviously a very confusing problem for users to choose to pass nil or Option{} null value, because they do not know what impact the two choices will have on the system. And because the Option is handed in by the user, we cannot guarantee that must be used in the current under the same package, so we need to put the variable declarations into the exported, considering the characteristics of object-oriented encapsulation modes of the, we should not be under the current package can access the variable direct export, and should be defined as private variables. So is there a way to get rid of these problems?
Builder model
The Builder pattern is one of the 23 classic design patterns and, by its name, is a creative design pattern. The Builder pattern separates the Build (WithConfig) of a complex object from its representation (Build), allowing the same Build process to create different representations. By using the Builder mode, you can mask the specifics of the build, and users can create complex objects without knowing how the object was built or the details. And it avoids invalid state by setting the builder variable first and then creating the object once and for all, leaving the object in a valid state all the time. At the same time, centralized verification can be carried out after setting all the required parameters, which can avoid the verification failure caused by the order disorder of set() caused by the dependency between attributes when using set(), and solve the embarrassing situation that the maximum value is less than the minimum value. The transformation of the entity Builder mode is as follows:
type CacheLoader struct {
cacher Cacher / / cache
loader Loader / / back to the source
refreshTTL time.Duration // Automatic source time
ttl time.Duration // Cache expiration time
ttl4NotInvalid time.Duration // Invalid data cache time
maxFetchItemsCount int32 // How many pieces of data can be requested at a time
}
type Builder struct {
*CacheLoader
}
func NewBuilder(loader Loader) *Builder {
return &Builder{
&CacheLoader{
loader: loader,
},
}
}
func (b *Builder) WithCacher(cacher Cacher) *Builder {
b.CacheLoader.cacher = cacher
return b
}
func (b *Builder) WithTTL(ttl time.Duration) *Builder {
b.CacheLoader.ttl = ttl
return b
}
// with...
func (b *Builder) Build(a) (*CacheLoader, error) {
// TODO parameter set check
// Error is returned when verification fails
return b.CacheLoader, nil
}
func main(a) {
_, _ = NewBuilder(defaultLoader).WithTTL(time.Second*5).Build()
}
Copy the code
The use of Builder mode is also very simple, that is, first create a Builder, then add parameters in chain, and finally construct business entities through Build method. The advantage of this method is to eliminate the intermediate state of the entity, and the constructed state must be the final state that we can use. And by uniformly injecting parameters in Build(), we can easily verify the combined parameters.
Builder mode is great for scenarios with complex parameters. However, the existing business entities need to be wrapped once and the corresponding methods need to be implemented. This is obviously a heavy step, so is there any way to omit this layer of wrapping?
Option model
Now it’s time for our choice mode.
The Go language supports higher-order functions, which are first a function but, unlike normal functions, take one or more functions as parameters or return values. For example:
type fn func(int) int
func bar(num int) fn {
return func(i int) int {
return i << 1}}Copy the code
The above code takes an int and returns a fn function that takes an int and returns an int, but we can specify the internal operations ourselves. In this way, we can do anything to int as long as the return value type is also satisfied. So how do we modify our cache Reader initialization through option mode?
Here is the code directly posted:
type CacheLoader struct {
cacher Cacher / / cache
loader Loader / / back to the source
refreshTTL time.Duration // Automatic source time
ttl time.Duration // Cache expiration time
ttl4NotInvalid time.Duration // Invalid data cache time
maxFetchItemsCount int32 // How many pieces of data can be requested at a time
}
type Optional func(cl *CacheLoader)
func WithCacher(cacher Cacher) Optional {
return func(cl *CacheLoader) {
cl.cacher = cacher
}
}
func WithRefreshTTL(ttl time.Duration) Optional {
return func(cl *CacheLoader) {
cl.refreshTTL = ttl
}
}
func WithTTL(ttl time.Duration) Optional {
return func(cl *CacheLoader) {
cl.ttl = ttl
}
}
func WithTTL4NotInvalid(ttl4NotInvalid time.Duration) Optional {
return func(cl *CacheLoader) {
cl.ttl4NotInvalid = ttl4NotInvalid
}
}
func WithMaxFetchItemsCount(cnt int32) Optional {
return func(cl *CacheLoader) {
cl.maxFetchItemsCount = cnt
}
}
func NewCacheLoader(loader Loader, options ... Optional) (*CacheLoader, error) {
ch := &CacheLoader{
loader: loader,
}
for _, op := range options {
op(ch)
}
// TODO parameter set check
return ch, nil
}
Copy the code
Using the options pattern, we define a set of option functions that pass in a parameter and then return a function that sets its own CacherLoader parameter. In the new method, we simply pass in the corresponding option method as required:
cl, err := NewCacheLoader(defaultLoader, WithRefreshTTL(time.Second), WithTTL(time.Second * 10))
Copy the code
By using the option pattern, we can achieve a high degree of configuration, and the code is relatively clean, perfectly preserving the object-oriented encapsulation nature. At the same time, the code is easy to understand and maintain, and it is particularly friendly in terms of expansion. When we need to increase/decrease parameters, we only need to increase or decrease the corresponding option function.
conclusion
So we’ve talked about six different ways to initialize entities. While we’ve emphasized the advantages of the Builder and option modes above, and they are really good designs, this is not to say that we always need to choose between them, or that they are not always the best choice. For example, when we have a limited number of properties, we can choose the first or second method and declare different constructors, which is simple to implement and use. For example, if our properties are dynamically configurable during use, we can use set().
There is no best, only the most suitable.
reference
- Left ear listen to the wind -Go programming mode: Functional Options