📜 ⬆️ ⬇️

Doing well, doing badly: writing “evil” code with Go, part 1

Bad tips for the go programmer


image

After decades of Java programming, for the past few years I have mainly worked on Go. Working with Go is great, first of all because the code is very easy to follow. Java simplified the C ++ programming model by removing multiple inheritance, manual memory management, and operator overloading. Go does the same thing, continuing to move to a simple and understandable programming style, completely removing inheritance and function overloading. Simple code is readable code, and readable code is supported code. And it's great for the company and my staff.

As in all cultures, software development has its own legends, stories, which are retold by a water cooler. We all heard about developers who, instead of focusing on creating a quality product, are fixated on protecting their own work from outsiders. They do not need supported code, because it means that other people will be able to understand and modify it. Is it possible on Go? Is it possible to make Go code so complicated? I will say right away - this is not an easy task. Let's consider the possible options.

You think: “ How much can you fuck up code in a programming language? Is it possible to write such a terrible code on Go, so that its author becomes indispensable in the company? " Do not worry. When I was a student, I had a project in which I supported someone else's Lisp-e code written by a graduate student. In fact, he managed to write Fortran-e code using Lisp. The code looked like this:
')
(defun add-mult-pi (in1 in2) (setq a in1) (setq b in2) (setq c (+ ab)) (setq d (* 3.1415 c) d ) 

There were dozens of files of this code. He was absolutely terrible and absolutely brilliant at the same time. I spent months trying to figure it out. Compared to this, write bad code on Go - just spit.

There are many different ways to make code unsupported, but we’ll look at a few. To properly do evil, you must first learn to do good. Therefore, we first look at how the “good” programmers of Go write, and then analyze how the opposite can be done.

Bad packaging


Packages are a convenient topic to start with. How can code organization degrade readability?

In Go, the package name is used to refer to the entity being exported (for example, ` fmt.Println` or` http.RegisterFunc `). Since we can see the name of the package, “good” Go programmers ensure that this name describes what the exported entities are. We should not have util packages, because names like ` util.JSONMarshal` will not suit us, we need ` json.Marshal` .

The "good" Go developers also do not create a separate package for the DAO or model. For those unfamiliar with this term, DAO is a “ data access object—a layer of code that interacts with your database. I used to work at a company where 6 Java services imported the same DAO library to access the same database that they used together because “ ... well, you know, microservices are the same ... ”.

If you have a separate package with all your DAOs, then the likelihood that you will get a cyclic dependency between packages, which is forbidden in Go, increases. And if you have several services that connect this DAO package as a library, you may also encounter a situation where a change in one service requires updating all of your services, otherwise something will break. This is called a distributed monolith, and it is incredibly difficult to update.

When you know how packaging should work, and what makes it worse, “starting to serve evil” becomes easy. Poorly organize your code and give your packages bad names. Break your code into packages such as model , util, and dao . If you really want to start “create chaos”, try creating packages in honor of your cat or your favorite color. When people encounter cyclic dependencies or distributed monoliths because they try to use your code, you have to sigh, roll your eyes and tell them that they are just doing wrong ...

Unsuitable interfaces


Now that all of our packages have been corrupted, we can go to the interfaces. The interfaces in Go are not similar to interfaces in other languages. The fact that you do not explicitly declare that this type implements the interface seems insignificant at first, but in fact it completely reverses the concept of interfaces.

In most languages ​​with abstract types, the interface is defined before or simultaneously with the implementation. You will have to do this at least for testing. If you do not create the interface in advance, you will not be able to insert it later without breaking all the code that this class uses. Because you have to rewrite it with reference to the interface instead of a specific type.

For this reason, Java code often has giant service interfaces with a variety of methods. Classes that implement these interfaces then use the methods they need and ignore the rest. Writing tests is possible, but you add an extra layer of abstraction, and when writing tests, you often use tools to generate implementations of those methods that you don’t need.

In Go, implicit interfaces determine which methods you need to use. The code owns the interface, not the other way around. Even if you use a type with a variety of methods defined in it, you can specify the interface that includes only the methods you need. Other code using separate fields of the same type will define other interfaces that cover only the functionality that is needed. Typically, these interfaces have only a couple of methods.

This makes it easy to understand your code, because the method declaration not only determines what data it needs, but also indicates exactly what functionality it is going to use. This is one of the reasons why good Go developers follow the advice: “ Accept interfaces, return structures ”.

But just because it is a good practice does not mean that you should do that ...
The best way to make your interfaces "evil" is to return to the principles of using interfaces from other languages, i.e. define interfaces in advance as part of the code being called. Define huge interfaces with a variety of methods that are used by all clients of the service. It becomes unclear what methods are really needed. This complicates the code, and complication, as you know, is the best friend of the “evil” programmer.

Pass-to-Heap Pointers


Before explaining what this means, you need to philosophize a little. If you digress and think, each written program does the same thing. It takes data, processes it, and then sends the processed data to another location. This is so regardless of whether you are writing a payroll system, accepting HTTP requests and returning web pages, or even checking a joystick to track a button click — the programs process the data.

If we look at the programs in this way, the most important thing to do is to make sure that it is easy for us to understand how the data is transformed. And because of this, it will be good practice to keep the data unchanged for as long as possible during the program. Because data that does not change is data that is easy to track.

In Go, we have reference types and value types. The difference between them is whether the variable refers to a copy of the data or to the place of the data in memory. Pointers, slices, maps, channels, interfaces, and functions are reference types, and everything else is a value type. If you assign a value type variable to another variable, it creates a copy of the value; changing one variable does not change the value of another.

Assigning one variable of reference type to another variable of reference type means that they both share the same memory area, so if you change the data pointed to by the first, you change the data pointed to by the second. This is true for both local variables and function parameters.

 func main() { //  a := 1 b := a b = 2 fmt.Println(a, b) // prints 1 2 //  c := &a *c = 3 fmt.Println(a, b, *c) // prints 3 2 3 } 

“Good” Go developers want to simplify their understanding of how data is collected. They try to use the type of values ​​as parameters of functions as often as possible. In Go, there is no way to mark fields in structures or function parameters as final. If the function uses value parameters, changing parameters will not change the variables in the calling function. All the called function can do is return the value to the calling function. Thus, if you fill a structure by calling a function with value parameters, you can not be afraid to transfer data to the structure, because you understand where each field in the structure came from.

 type Foo struct { A int B string } func getA() int { return 20 } func getB(i int) string { return fmt.Sprintf("%d",i*2) } func main() { f := Foo{} fA = getA() fB = getB(fA) //  ,    f fmt.Println(f) } 

So how do we become "evil"? Very simple - turning this model over.

Instead of calling functions that return the desired values, you pass a pointer to the structure in the function and allow them to make changes to the structure. Because each function owns the entire structure, the only way to find out which fields change is to view the entire code. You may also have implicit dependencies between functions - the 1st function transfers data required by the 2nd function. But in the code itself, nothing indicates that you must first call the 1st function. If you build your data structures in this way, you can be sure that nobody will understand what your code does.

 type Foo struct { A int B string } func setA(f *Foo) { fA = 20 } //   fA! func setB(f *Foo) { fB = fmt.Sprintf("%d", fA*2) } func main() { f := Foo{} setA(&f) setB(&f) // ,  setA  setB //    ? fmt.Println(f) } 

Panic ascent


Now we proceed to error handling. Probably, you think that it is bad to write programs that handle errors by about 75%, and I will not say that you are wrong. The Go code is often filled with error handling from head to toe. And of course, it would be convenient to handle them not so straightforward. Mistakes happen, and processing them is what distinguishes professionals from beginners. Mild error handling leads to unstable programs that are difficult to debug and difficult to maintain. Sometimes to be a “good” programmer is to “strain”.

 func (dus DBUserService) Load(id int) (User, error) { rows, err := dus.DB.Query("SELECT name FROM USERS WHERE ID = ?", id) if err != nil { return User{}, err } if !rows.Next() { return User{}, fmt.Errorf("no user for id %d", id) } var name string err = rows.Scan(&name) if err != nil { return User{}, err } err = rows.Close() if err != nil { return User{}, err } return User{Id: id, Name: name}, nil } 

Many languages, such as C ++, Python, Ruby and Java, use exceptions to handle errors. If something goes wrong, the developers in these languages ​​throw or throw an exception, expecting some code to handle it. Of course, the program calculates that the client is aware of a possible error in the given place, so that it is possible to generate an exception. Because, with the exception of (without a pun), checked Java exceptions, there is nothing in the languages ​​or functions in the method signature to indicate that an exception may be thrown. So how do developers know which exceptions to worry about? They have two options:


So, how do we bring this evil into Go? Abusing panic ( panic ) and recovering ( recover ), of course! Panic is designed for situations such as "the disk fell off" or "network card exploded." But not for such - "someone passed the string instead of int."

Unfortunately, other, “less enlightened developers” will return errors from their code. Therefore, here is a small helper function PanicIfErr. Use it to turn other developers' mistakes into a panic.

 func PanicIfErr(err error) { if err != nil { panic(err) } } 

You can use PanicIfErr to wrap other people's errors, compress the code. No more ugly error handling! Any mistake is now panic. It is so productive!

 func (dus DBUserService) LoadEvil(id int) User { rows, err := dus.DB.Query( "SELECT name FROM USERS WHERE ID = ?", id) PanicIfErr(err) if !rows.Next() { panic(fmt.Sprintf("no user for id %d", id)) } var name string PanicIfErr(rows.Scan(&name)) PanicIfErr(rows.Close()) return User{Id: id, Name: name} } 

You can place the reserver somewhere closer to the beginning of the program, maybe in your own middleware . And then to say that you not only handle errors, but also make someone else's code cleaner. To do evil by doing good is the best kind of evil.

 func PanicMiddleware(h http.Handler) http.Handler { return http.HandlerFunc( func(rw http.ResponseWriter, req *http.Request){ defer func() { if r := recover(); r != nil { fmt.Println(", - .") } }() h.ServeHTTP(rw, req) } ) } 

Side effects setup


Next we will create a side effect. Remember, the “good” developer Go wants to understand how the data passes through the program. The best way to know what the data goes through is to set up explicit dependencies in the application. Even entities that match the same interface can vary greatly in behavior. For example, a code that stores data in memory, and a code that accesses a database for the same work. However, there are ways to establish dependencies in Go without explicit calls.

As in many other languages, Go has a way to magically execute code without invoking it directly. If you create a function called init with no parameters, it will automatically start when the package is loaded. And, to make things even more confusing, if there are several functions in the same file with the name init or several files in the same package, they all start.

 package account type Account struct{ Id int UserId int } func init() { fmt.Println("  !") } func init() { fmt.Println("   ,     init()") } 

The init functions are often associated with an empty import. There is a special way to declare import in Go, which looks like `import _“ github.com / lib / pq`. When you set an empty name identifier for an imported package, it invokes the init method, but it does not show any of the package identifiers. For some Go libraries — such as database drivers or image formats — you must load them by including an empty package import, just to call the init function so that the package can register its code.

 package main import _ "github.com/lib/pq" func main() { db, err := sql.Open( "postgres", "postgres://jon@localhost/evil?sslmode=disable") } 

And this is clearly the "evil" option. When you use initialization, the code that works in a magical way is completely outside the control of the developer. Best-practices do not recommend using initialization functions - these are not obvious features, they confuse code, and they are easy to hide in the library.

In other words, init functions are perfect for our evil purposes. Instead of explicitly configuring or registering entities in packages, you can use initialization functions and empty import to adjust the state of your application. In this example, we make the account accessible to the rest of the application through the registry, and the package itself is placed on the registry using the init function.

 package account import ( "fmt" "github.com/evil-go/example/registry" ) type StubAccountService struct {} func (a StubAccountService) GetBalance(accountId int) int { return 1000000 } func init() { registry.Register("account", StubAccountService{}) } 

If you want to use an account, then put an empty import in your program. It should not be the main or related code - it just has to be “somewhere”. It `s Magic!

 package main import ( _ "github.com/evil-go/example/account" "github.com/evil-go/example/registry" ) type Balancer interface { GetBalance(int) int } func main() { a := registry.Get("account").(Balancer) money := a.GetBalance(12345) } 

If you use inits in your libraries to configure dependencies, you will immediately see that other developers are puzzled, how these dependencies were installed, and how to change them. And no one will be wiser than you.

Advanced Configuration


There is still a lot of everything that we can create with the configuration. If you are a “good” Go developer, you will want to isolate the configuration from the rest of the program. In the main () function, you get variables from the environment and convert them to the values ​​necessary for components that are clearly related to each other. Your components do not know anything about the settings files, or what their properties are called. For simple components, you set public properties, and for more complex ones, you can create a factory function that receives configuration information and returns a correctly configured component.

 func main() { b, err := ioutil.ReadFile("account.json") if err != nil { fmt.Errorf("error reading config file: %v", err) os.Exit(1) } m := map[string]interface{}{} json.Unmarshal(b, &m) prefix := m["account.prefix"].(string) maker := account.NewMaker(prefix) } type Maker struct { prefix string } func (m Maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } func NewMaker(prefix string) Maker { return Maker{prefix: prefix} } 

But "evil" developers know that it is better to scatter information about the configuration throughout the program. Instead of having one function in the package that defines the names and types of values ​​for your package, use a function that accepts the config as is and converts it on its own.

If this seems too “evil,” use the init function to load the properties file from within your package and set the values ​​yourself. It may seem that you have made the lives of other developers easier, but we all know ...

Using the init function, you can define new properties in the depth of the code, and no one will ever find them until they get into production, and everything will not fall off, because they will not get something in one of the dozens of properties files needed to run. If you want even more “evil power”, you can suggest creating a wiki to keep track of all the properties in all libraries and “forget” to add new ones periodically. As a Property Keeper, you become the only person who can run the software.

 func (m maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } var Maker maker func init() { b, _ := ioutil.ReadFile("account.json") m := map[string]interface{}{} json.Unmarshal(b, &m) Maker.prefix = m["account.prefix"].(string) } 

Frameworks for functionality


Finally, we come to the topic of framework vs libraries. The difference is very subtle. It's not just about the size; You can have large libraries and small frameworks. The framework calls your code while you call the library code. The frameworks require that you write your code in a specific way, be it naming your methods according to specific rules, or that they match certain interfaces, or force you to register your code with the framework. The frameworks make their demands on all your code. That is, in general, the frameworks command you.

Go encourages the use of libraries because libraries are linked. Although, of course, each library is waiting for data to be sent in a specific format, you can write some glue code to convert the output of one library to an input for another.
It is difficult to force frameworks to work smoothly together because each framework wants complete control over the life cycle of the code.Often the only way to get frameworks to work cohesively is to get the framework authors to come together and explicitly organize mutual support. And the best way to use the “evil frameworks” for long-term power is to write your own framework, which is used only within the company.

Current and future evil


Having mastered these techniques, you will forever take the path of evil. In the second part, I will show how to deploy all this “evil”, and how to properly turn the “good” code into “evil”.

Source: https://habr.com/ru/post/460645/


All Articles