- Hands-On Dependency Injection in Go
- Corey Scott
- 1061字
- 2021-06-10 19:17:46
Single responsibility principle (SRP)
–Robert C. Martin
Go doesn't have classes, but if we squint a little and replace the word class with objects (structs, functions, interfaces or packages), then the concept still applies.
Why do we want our objects to do only one thing? Let's look at a couple of objects that do one thing:
These objects are simple and easy to use, and have a wide range of uses.
Designing objects so that they all do only one thing sounds okay in the abstract. But you are probably thinking that doing so for an entire system would add a lot more code. Yes, it will. However, what it doesn't do is add complexity; in fact, it significantly reduces it. Each piece of code would be smaller and easier to understand, and therefore easier to test. This fact gives us the first advantage of SRP:
SRP reduces the complexity by decomposing code into smaller, more concise pieces
With a name like single responsibility principle, it would be safe to assume that it is all about responsibility, but so far, all we have talked about is change. Why is this? Let's look at an example:
// Calculator calculates the test coverage for a directory
// and it's sub-directories
type Calculator struct {
// coverage data populated by `Calculate()` method
data map[string]float64
}
// Calculate will calculate the coverage
func (c *Calculator) Calculate(path string) error {
// run `go test -cover ./[path]/...` and store the results
return nil
}
// Output will print the coverage data to the supplied writer
func (c *Calculator) Output(writer io.Writer) {
for path, result := range c.data {
fmt.Fprintf(writer, "%s -> %.1f\n", path, result)
}
}
The code looks reasonable—one member variable and two methods. It does not, however, conform to SRP. Let's assume that the app was successful, and we decided that we also needed to output the results to CSV. We could add a method to do that, as shown in the following code:
// Calculator calculates the test coverage for a directory
// and it's sub-directories
type Calculator struct {
// coverage data populated by `Calculate()` method
data map[string]float64
}
// Calculate will calculate the coverage
func (c *Calculator) Calculate(path string) error {
// run `go test -cover ./[path]/...` and store the results
return nil
}
// Output will print the coverage data to the supplied writer
func (c Calculator) Output(writer io.Writer) {
for path, result := range c.data {
fmt.Fprintf(writer, "%s -> %.1f\n", path, result)
}
}
// OutputCSV will print the coverage data to the supplied writer
func (c Calculator) OutputCSV(writer io.Writer) {
for path, result := range c.data {
fmt.Fprintf(writer, "%s,%.1f\n", path, result)
}
}
We have changed the struct and added another Output() method. We have added more responsibilities to the struct and, in doing so, we have added complexity. In this simple example, our changes are confined to one method, so there's no risk that we broke the previous code. However, as the struct gets bigger and more complicated, our changes are unlikely to be so clean.
Conversely, if we were to break the responsibilities into Calculate and Output, then adding more outputs would mere define new structs. Additionally, should we decide that we don't like the default output format, we could change it separately from other parts.
Let's try a different implementation:
// Calculator calculates the test coverage for a directory
// and it's sub-directories
type Calculator struct {
// coverage data populated by `Calculate()` method
data map[string]float64
}
// Calculate will calculate the coverage
func (c *Calculator) Calculate(path string) error {
// run `go test -cover ./[path]/...` and store the results
return nil
}
func (c *Calculator) getData() map[string]float64 {
// copy and return the map
return nil
}
type Printer interface {
Output(data map[string]float64)
}
type DefaultPrinter struct {
Writer io.Writer
}
// Output implements Printer
func (d *DefaultPrinter) Output(data map[string]float64) {
for path, result := range data {
fmt.Fprintf(d.Writer, "%s -> %.1f\n", path, result)
}
}
type CSVPrinter struct {
Writer io.Writer
}
// Output implements Printer
func (d *CSVPrinter) Output(data map[string]float64) {
for path, result := range data {
fmt.Fprintf(d.Writer, "%s,%.1f\n", path, result)
}
}
Do you notice anything significant about the printers? They have no connection at all to the calculation. They could be used for any data in the same format. This leads to the second advantage of SRP:
SRP increases the potential reusability of code.
In the first implementation of our coverage calculator, to test the Output() method we would be first call the Calculate() method. This approach increases the complexity of our tests by coupling the calculation with the output. Consider the following scenarios:
- How do we test for no results?
- How do we test edge conditions, such as 0% or 100% coverage?
After decoupling these responsibilities, we should encourage ourselves to consider the inputs and outputs of each part in a less interdependent manner, hence making the tests easier to write and maintain. This leads to the third advantage of SRP:
SRP makes tests simpler to write and maintain.
SRP is also an excellent way to improve general code readability. Take a look at this next example:
func loadUserHandler(resp http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
return
}
userID, err := strconv.ParseInt(req.Form.Get("UserID"), 10, 64)
if err != nil {
resp.WriteHeader(http.StatusPreconditionFailed)
return
}
row := DB.QueryRow("SELECT * FROM Users WHERE ID = ?", userID)
person := &Person{}
err = row.Scan(&person.ID, &person.Name, &person.Phone)
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
return
}
encoder := json.NewEncoder(resp)
encoder.Encode(person)
}
I'd bet that took more than five seconds to understand. How about this code?
func loadUserHandler(resp http.ResponseWriter, req *http.Request) {
userID, err := extractIDFromRequest(req)
if err != nil {
resp.WriteHeader(http.StatusPreconditionFailed)
return
}
person, err := loadPersonByID(userID)
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
return
}
outputPerson(resp, person)
}
By applying SRP at the function level, we have reduced the function's bloat and increased its readability. The function's single responsibility is now to coordinate the calls to the other functions.
- 零基礎學Visual C++第3版
- 自制編譯器
- Python數據挖掘與機器學習實戰
- Mastering Drupal 8 Views
- Android開發:從0到1 (清華開發者書庫)
- 軟件品質之完美管理:實戰經典
- Python全棧數據工程師養成攻略(視頻講解版)
- INSTANT Silverlight 5 Animation
- Spring MVC+MyBatis開發從入門到項目實踐(超值版)
- Mastering Apache Storm
- Python自然語言理解:自然語言理解系統開發與應用實戰
- HTML5+CSS3+JavaScript 從入門到項目實踐(超值版)
- INSTANT LESS CSS Preprocessor How-to
- Learning D3.js 5 Mapping(Second Edition)
- Android熱門應用開發詳解