rewrite CoP post to use new examples

This commit is contained in:
Brian Picciano 2020-11-27 17:26:39 -07:00
parent e346068f58
commit d40fe10213
5 changed files with 654 additions and 527 deletions

View File

@ -1,6 +1,6 @@
--- ---
title: >- title: >-
Component Oriented Programming Component-Oriented Programming
description: >- description: >-
A concise description of. A concise description of.
--- ---
@ -16,29 +16,31 @@ programming pattern and without the unnecessary framework.
Many languages, libraries, and patterns make use of a concept called Many languages, libraries, and patterns make use of a concept called
"component", but in each case the meaning of "component" might be slightly "component", but in each case the meaning of "component" might be slightly
different. Therefore to begin talking about components we must first describe different. Therefore to begin talking about components it is necessary to first
specifically what is meant by "component" in this post. describe what is meant by "component" in this post.
For the purposes of this post, properties of components include: For the purposes of this post, properties of components include:
 1... **Abstract**: A component is an interface consisting of one or more  1... **Abstract**: A component is an interface consisting of one or more
methods. Being an interface, a component may have one or more implementations, methods.
but generally will have a primary implementation, which is used during a
program's runtime, and secondary "mock" implementations, which are only used
when testing other components.
   1a... A function might be considered a single-method    1a... A function might be considered to be a single-method
component if the language supports first-class functions. component _if_ the language supports first-class functions.
 2... **Creatable**: An instance of a component, given some defined set of    1b... A component, being an interface, may have one or more
parameters, can be created independently of any other instance of that or any implementations. Generally there will be a primary implementation, which is used
other component. during a program's runtime, and secondary "mock" implementations, which are only
used when testing other components.
 2... **Instantiatable**: An instance of a component, given some set of
parameters, can be instantiated as a standalone entity. More than one of the
same component can be instantiated, as needed.
 3... **Composable**: A component may be used as a parameter of another  3... **Composable**: A component may be used as a parameter of another
component's instantiation. This would make it a child component of the one being component's instantiation. This would make it a child component of the one being
instantiated (i.e. the parent). instantiated (the parent).
 4... **Isolated**: A component may not use mutable global variables (i.e.  4... **Pure**: A component may not use mutable global variables (i.e.
singletons) or impure global functions (e.g. system calls). It may only use singletons) or impure global functions (e.g. system calls). It may only use
constants and variables/components given to it during instantiation. constants and variables/components given to it during instantiation.
@ -52,312 +54,167 @@ components given as instantiation parameters.
   5b... This cleanup method should not return until the    5b... This cleanup method should not return until the
component's cleanup is complete. component's cleanup is complete.
Components are composed together to create programs. This is done by passing    5c... A component should not be cleaned up until all of its
components as parameters to other components during instantiation. The `main` parent components are cleaned up.
process of the program is responsible for instantiating and composing most, if
not all, components in the program.
A component oriented program is one which primarily, if not entirely, uses Components are composed together to create component-oriented programs This is
components for its functionality. Components generally have the quality of being done by passing components as parameters to other components during
able to interact with code written in other patterns without any toes being instantiation. The `main` process of the program is responsible for
stepped on. instantiating and composing the components of the program.
## Example ## Example
Let's start with an example: suppose a program is desired which accepts a string It's easier to show than to tell. This section will posit a simple program and
over stdin, hashes it, then writes the string to a file whose name is the hash. then describe how it would be implemented in a component-oriented way. The
program chooses a random number and exposes an HTTP interface which allows
users to try and guess that number. The following are requirements of the
program:
A naive implementation of this program in go might look like: * A guess consists of a name identifying the user performing the guess and the
number which is being guessed.
```go * A score is kept for each user who has performed a guess.
package main
import ( * Upon an incorrect guess the user should be informed of whether they guessed
"crypto/sha1" too high or too low, and 1 point should be deducted from their score.
"encoding/hex"
"io"
"io/ioutil"
"os"
)
func hashFileWriter() error { * Upon a correct guess the program should pick a new random number to check
h := sha1.New() subsequent guesses against, and 1000 points should be added to the user's
r := io.TeeReader(os.Stdin, h) score.
body, _ := ioutil.ReadAll(r)
fileName := hex.EncodeToString(h.Sum(nil))
if err := ioutil.WriteFile(fileName, body, 0644); err != nil { * The HTTP interface should have two endpoints: one for users to submit guesses,
return err and another which lists out user scores from highest to lowest.
}
return nil * Scores should be saved to disk so they survive program restarts.
}
func main() { It seems clear that there will be two major areas of functionality to our
if err := hashFileWriter(); err != nil { program: keeping scores and user interaction via HTTP. Each of these can be
panic(err) // consider the error handled encapsulated into components called `scoreboard` and `httpHandlers`,
} respectively.
}
`scoreboard` will need to interact with a filesystem component in order to
save/restore scores (since it can't use system calls directly, see property 4).
It would be wasteful for `scoreboard` to save the scores to disk on every score
update, so instead it will do so every 5 seconds. A time component will be
required to support this.
`httpHandlers` will be choosing the random number which is being guessed, and so
will need a component which produces random numbers. `httpHandlers` will also be
recording score changes to the `scoreboard`, so will need access to the
`scoreboard`.
The example implementation will be written in go, which makes differentiating
HTTP handler functionality from the actual HTTP server quite easy, so there will
be an `httpServer` component which uses the `httpHandlers`.
Finally a `logger` component will be used in various places to log useful
information during runtime.
[The example implementation can be found
here.](/assets/component-oriented-design/v1/main.html) While most of it can be
skimmed, it is recommended to at least read through the `main` function to see
how components are composed together. Note how `main` is where all components
are instantiated, and how all components' take in their child components as part
of their instantiation.
## DAG
One way to look at a component-oriented program is as a directed acyclic graph
(DAG), where each node in the graph represents a component, and each edge
indicates the one component depends upon another component for instantiation.
For the previous program it's quite easy to construct such a DAG just by looking
at `main`:
```
net.Listener rand.Rand os.File
^ ^ ^
| | |
httpServer --> httpHandlers --> scoreboard --> time.Ticker
| | |
+---------------+---------------+--> log.Logger
``` ```
Notice that there's not a clear separation here between different components; Note that all the leaves of the DAG (i.e. nodes with no children) describe the
`hashFileWriter` _might_ be considered a one method component, except that it points where the program meets the operating system via system calls. The leaves
breaks component property 4, which says that a component may not use mutable are, in essence, the program's interface with the outside world.
global variables (`os.Stdin`) or impure global functions (`ioutil.WriteFile`).
Notice also that testing the program would require integration tests, and could While it's not necessary to actually draw out the DAG for every program one
not be unit tested (because there are no units, i.e. components). For a trivial writes, it can be helpful to at least think about the program's structure in
program like this one writing unit and integration tests would be redundant, but these terms.
for larger programs it may not be. Unit tests are important because they are
fast to run, (usually) easy to formulate, and yield consistent results.
This program could instead be written as being composed of three components: ## Benefits
* `stdin`: a construct given by the runtime which outputs a stream of bytes. Looking at the previous example implementation, one would be forgiven for having
the immediate reaction of "This seems like a lot of extra work for little gain.
Why can't I just make the system calls where I need to, and not bother with
wrapping them in interfaces and all these other rules?"
* `disk`: accepts a file name and file contents as input, writes the file The following sections will answer that concern by showing the benefits gained
contents to a file of the given name, and potentially returns an error back. by following a component-oriented pattern.
* `hashFileWriter`: reads a stream of bytes off a `stdin`, collects the stream ### Testing
into a string, hashes that string to generate a file name, and uses `disk` to
create a corresponding file with the string as its contents. If `disk` returns
an error then `hashFileWriter` returns that error.
Sprucing up our previous example to use these more clearly defined components Testing is important, that much is being assumed.
might look like:
```go A distinction to be made with testing is between unit and non-unit (sometimes
package main called "integration") tests. Unit tests are those which do not make any
requirements of the environment outside the test, such as the existence of a
running database, filesystem, or network service. Unit tests do not interact
with the world outside the testing process, but instead use mocks in place of
functionality which would be expected by that world.
import ( Unit tests are important because they are faster to run and more consistent than
"crypto/sha1" non-unit tests. Unit tests also force the programmer to consider different
"encoding/hex" possible states of a component's dependencies during the mocking process.
"fmt"
"io"
"io/ioutil"
"os"
)
// Disk defines the methods of the disk component. Unit tests are often not employed by programmers because they are difficult to
type Disk interface { implement for code which does not expose any way of swapping out dependencies
WriteFile(fileName string, fileContents []byte) error for mocks of those dependencies. The primary culprit of this difficulty is
} direct usage of singletons and impure global functions. With component-oriented
programs all components inherently allow for swapping out any dependencies via
their instantiation parameters, so there's no extra effort needed to support
unit tests.
// disk is the primary implementation of Disk. It implements the methods of [Tests for the example implementation can be found
// Disk (WriteFile) by performing actual system calls. here.](/assets/component-oriented-design/v1/main_test.html) Note that all
type disk struct{} dependencies of each component being tested are mocked/stubbed next to them.
func NewDisk() Disk { return disk{} } ### Configuration
func (disk) WriteFile(fileName string, fileContents []byte) error {
return ioutil.WriteFile(fileName, fileContents, 0644)
}
func hashFileWriter(stdin io.Reader, disk Disk) error {
h := sha1.New()
r := io.TeeReader(stdin, h)
body, err := ioutil.ReadAll(r)
if err != nil {
return fmt.Errorf("reading input: %w", err)
}
fileName := hex.EncodeToString(h.Sum(nil))
if err := disk.WriteFile(fileName, body); err != nil {
return fmt.Errorf("writing to file %q: %w", fileName, err)
}
return nil
}
func main() {
if err := hashFileWriter(os.Stdin, NewDisk()); err != nil {
panic(err) // consider the error handled
}
}
```
`hashFileWriter` no longer directly uses `os.Stdin` and `ioutil.WriteFile`, but
instead takes in components wrapping them; `io.Reader` is a built-in interface
which `os.Stdin` inherently implements, and `Disk` is a simple interface defined
just for this program.
At first glance this would seem to have doubled the line-count for very little
gain. This is because we have not yet written tests.
## Testing
Testing is important. This post won't attempt to defend that statement, that's
for another time. Let's just accept it as true for now.
In the second form of the program we can test the core-functionality of the
`hashFileWriter` component without resorting to using the actual `stdin` and
`disk` components. Instead we use mocks of those components. A mock component
implements the same input/outputs that the "real" component does, but in a way
which makes it possible to write tests of another component which don't reach
outside the process. These are unit tests.
Tests for the latest form of the program might look like this:
```go
package main
import (
"strings"
"testing"
)
// mockDisk implements the Disk interface. When WriteFile is called mockDisk
// will pretend to write the file, but instead will simply store what arguments
// WriteFile was called with.
type mockDisk struct {
fileName string
fileContents []byte
}
func (d *mockDisk) WriteFile(fileName string, fileContents []byte) error {
d.fileName = fileName
d.fileContents = fileContents
return nil
}
func TestHashFileWriter(t *testing.T) {
type test struct {
in string
expFileName string
// expFileContents can be inferred from in
}
tests := []test{
{
in: "",
expFileName: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
},
{
in: "hello",
expFileName: "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
},
{
in: "hello\nworld", // make sure newlines don't break things
expFileName: "7db827c10afc1719863502cf95397731b23b8bae",
},
}
for _, test := range tests {
// stdin is mocked via a strings.Reader, which outputs the string it was
// initialized with as a stream of bytes.
in := strings.NewReader(test.in)
// Disk is mocked by mockDisk, go figure.
disk := new(mockDisk)
if err := hashFileWriter(in, disk); err != nil {
t.Errorf("in:%q got err:%v", test.in, err)
} else if string(disk.fileContents) != test.in {
t.Errorf("in:%q got contents:%q", test.in, disk.fileContents)
} else if string(disk.fileName) != test.expFileName {
t.Errorf("in:%q got fileName:%q", test.in, disk.fileName)
}
}
}
```
Notice that these tests do not _completely_ cover the desired functionality of
the program: if `disk` returns an error that error should be returned from
`hashFileWriter`, but this functionality is not tested. Whether or not this must
be tested as well, and indeed the pedantry level of tests overall, is a matter
of taste. I believe these tests to be sufficient.
## Configuration
Practically all programs require some level of runtime configuration. This may Practically all programs require some level of runtime configuration. This may
take the form of command-line arguments, environment variables, configuration take the form of command-line arguments, environment variables, configuration
files, etc. Almost all configuration methods will require some system call, and files, etc.
so any component accessing configuration directly would likely break component
property 4.
Instead each component should take in whatever configuration parameters it needs With a component-oriented program all components are instantiated in the same
during instantiation, and let `main` handle collecting all configuration from place, `main`, so it's very easy to expose any arbitrary parameter to the user.
outside of the process and instantiating the components appropriately. For any component which a configurable parameter effects, that component merely
needs to take an instantiation parameter for that configurable parameter;
`main` can connect the two together. This accounts for unit testing a
component with different configurations, while still allowing for configuring
any arbitrary internal functionality.
Let's take our previous program, but add in two new desired behaviors: first, For more complex configuration systems it is also possible to implement a
there should be a command-line parameter which allows for specifying the string `configuration` component, wrapping whatever configuration-related functionality
on the command-line, rather than reading from stdin, and second, there should be is needed, which other components use as a sub-component. The effect is the
a command-line parameter declaring which directory to write files into. The new same.
implementation looks like:
```go To demonstrate how configuration works in a component-oriented program the
package main example program's requirements will be augmented to include the following:
import ( * The point change amounts for both correct and incorrect guesses (currently
"crypto/sha1" hardcoded at 1000 and 1, respectively) should be configurable on the
"encoding/hex" command-line.
"flag"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strings"
)
// Disk defines the methods of the disk component. * The save file's path, HTTP listen address, and save interval should all be
type Disk interface { configurable on the command-line.
WriteFile(fileName string, fileContents []byte) error
}
// disk is the concrete implementation of Disk. It implements the methods of [The new implementation, with newly configurable parameters, can be found
// Disk (WriteFile) by performing actual OS calls. here.](/assets/component-oriented-design/v2/main.html) Most of the program has
type disk struct { remained the same, and all unit tests from before remain valid. The primary
dir string difference is that `scoreboard` takes in two new parameters for the point change
} amounts, and configuration is set up inside `main`.
func NewDisk(dir string) Disk { return disk{dir: dir} } ### Setup/Runtime/Cleanup
func (d disk) WriteFile(fileName string, fileContents []byte) error {
fileName = filepath.Join(d.dir, fileName)
return ioutil.WriteFile(fileName, fileContents, 0644)
}
func hashFileWriter(in io.Reader, disk Disk) error {
h := sha1.New()
r := io.TeeReader(in, h)
body, err := ioutil.ReadAll(r)
if err != nil {
return fmt.Errorf("reading input: %w", err)
}
fileName := hex.EncodeToString(h.Sum(nil))
if err := disk.WriteFile(fileName, body); err != nil {
return fmt.Errorf("writing to file %q: %w", fileName, err)
}
return nil
}
func main() {
str := flag.String("str", "", "If set, hash and write this string instead of stdin")
dir := flag.String("dir", ".", "Directory which files should be written to")
flag.Parse()
var in io.Reader
if *str == "" {
in = os.Stdin
} else {
in = strings.NewReader(*str)
}
disk := NewDisk(*dir)
if err := hashFileWriter(in, disk); err != nil {
panic(err) // consider the error handled
}
}
```
Very little has changed, and in fact `hashFileWriter` was not touched at all,
meaning all unit tests remained valid.
## Setup/Runtime/Cleanup
A program can be split into three stages: setup, runtime, and cleanup. Setup A program can be split into three stages: setup, runtime, and cleanup. Setup
is the stage during which internal state is assembled in order to make runtime is the stage during which internal state is assembled in order to make runtime
@ -365,147 +222,66 @@ possible. Runtime is the stage during which a program's actual function is being
performed. Cleanup is the stage during which runtime stops and internal state is performed. Cleanup is the stage during which runtime stops and internal state is
disassembled. disassembled.
A graceful (i.e. reliably correct) setup is quite natural to accomplish, but A graceful (i.e. reliably correct) setup is quite natural to accomplish for
unfortunately a graceful cleanup is not a programmer's first concern (and most. On the other hand a graceful cleanup is, unfortunately, not a programmer's
frequently is not a concern at all). However, when building reliable and correct first concern (frequently it is not a concern at all).
programs, a graceful cleanup is as important as a graceful setup and runtime. A
program is still running while it is being cleaned up, and it's possibly even When building reliable and correct programs a graceful cleanup is as important
acting on the outside world still. Shouldn't it behave correctly during that as a graceful setup and runtime. A program is still running while it is being
time? cleaned up, and it's possibly even acting on the outside world still. Shouldn't
it behave correctly during that time?
Achieving a graceful setup and cleanup with components is quite simple: Achieving a graceful setup and cleanup with components is quite simple:
During setup a single-threaded process (usually `main`) will construct the During setup a single-threaded procedure (`main`) constructs the leaf components
"leaf" components (those which have no child components of their own) first, first, then the components which take those leaves as parameters, then the
then the components which take those leaves as parameters, then the components components which take _those_ as parameters, and so on, until the component DAG
which take _those_ as parameters, and so on, until all are constructed. The is constructed.
components end up assembled into a directed acyclic graph (DAG).
In the previous examples our DAG looked like this: At this point the program's runtime has begun.
``` Once runtime is over, signified by a process signal or some other mechanism,
---> stdin it's only necessary to call each component's cleanup method (if any, see
/ property 5) in the reverse of the order the components were instantiated in.
hashFileWriter This order is inherently deterministic, since the components were instantiated
\ by a single-threaded procedure.
---> disk
```
At this point the program will begin runtime. Inherent to this pattern is the fact that each component will certainly be
Once runtime is over and it is time for the program to exit it's only necessary
to call each component's cleanup method(s) in the reverse of the order the
components were instantiated in. A component's cleanup method should not be
called until all of its parent components have been cleaned up.
Inherent to the pattern is the fact that each component will certainly be
cleaned up before any of its child components, since its child components must cleaned up before any of its child components, since its child components must
have been instantiated first and a component will not clean up child components have been instantiated first and a component will not clean up child components
given as parameters (as-per component property 5a). Therefore the pattern avoids given as parameters (properties 5a and 5c). Therefore the pattern avoids
use-after-cleanup situations. use-after-cleanup situations.
With go this pattern can be achieved easily using `defer`, but writing it out To demonstrate a graceful cleanup in a component-oriented program the example
manually is not so hard, as in this toy example: program's requirements will be augmented to include the following:
```go * The program will terminate itself upon an interrupt signal.
package main
import ( * During termination (cleanup) the program will save the latest set of scores to
"fmt" disk one final time.
"time"
)
// sleeper is a component which prints its children and sleeps when it's time to [The new implementation which accounts for these new requirements can be found
// cleanup. here.](/assets/component-oriented-design/v3/main.html) For this example go's
type sleeper struct { `defer` feature could have been used instead, which would have been even
children []*sleeper cleaner, but was omitted for the sake of those using other languages.
toSleep time.Duration
// The builtin time.Sleep is an impure global function, a component can't
// use it, so the component must be instantiated with it as a parameter.
sleep func(time.Duration)
// likewise os.Stdout is a global singleton, and so must also be a ## Conclusion
parameter.
stdout io.Writer
}
func (s *sleeper) print() { The component pattern helps make programs more reliable with only a small amount
fmt.Fprintf(s.stdout, "I will sleep for %v\n", s.toSleep) of extra effort incurred. In fact most of the pattern has to do with
for _, child := range s.children { establishing sensible abstractions around global functionality and remembering
child.print() certain idioms for how those abstractions should be composed together, something
} most of us do to some extent already anyway.
}
func (s *sleeper) cleanup() { While beneficial in many ways, component-oriented programming is merely a tool
s.sleep(s.toSleep) which can be applied in many cases. It is certain that there are cases where it
fmt.Fprintf(s.stdout, "I slept for %v\n", s.toSleep) is not the right tool for the job, so apply it deliberately and intelligently.
}
func main() { ## Criticisms/Questions
// Within main we make a helper function to easily construct sleepers. for a In lieu of a FAQ I will attempt to premeditate questions and criticisms of the
// toy like this it's not worth the effort of giving sleeper a real component-oriented programming pattern laid out in this post:
// initialization function.
newSleeper := func(toSleep time.Duration, children ...*sleeper) *sleeper {
return &sleeper{
children: children,
toSleep: toSleep,
sleep: time.Sleep,
stdout: os.Stdout,
}
}
aa := newSleeper(250 * time.Millisecond)
defer aa.cleanup()
ab := newSleeper(250 * time.Millisecond)
defer ab.cleanup()
// A's children are AA and AB
a := newSleeper(500*time.Millisecond, aa, ab)
defer a.cleanup()
b := newSleeper(750 * time.Millisecond)
defer b.cleanup()
// root's children are A and B
root := newSleeper(1*time.Second, a, b)
defer root.cleanup()
// All components are now instantiated and runtime begins.
root.print()
// ... and just like that, runtime ends.
fmt.Println("--- Alright, fun is over, time for bed ---")
// Now to clean up, cleanup methods are called in the reverse order of the
// component's instantiation.
root.cleanup()
b.cleanup()
a.cleanup()
ab.cleanup()
aa.cleanup()
// Expected output is:
//
// I will sleep for 1s
// I will sleep for 500ms
// I will sleep for 250ms
// I will sleep for 250ms
// I will sleep for 750ms
// --- Alright, fun is over, time for bed ---
// I slept for 1s
// I slept for 750ms
// I slept for 500ms
// I slept for 250ms
// I slept for 250ms
}
```
## Criticisms
In lieu of a FAQ I will attempt to premeditate criticisms of the component
oriented pattern laid out in this post:
**This seems like a lot of extra work.** **This seems like a lot of extra work.**
@ -518,9 +294,9 @@ bad thing, it's just how the industry functions.
All that said, a pattern need not be followed perfectly to be worthwhile, and All that said, a pattern need not be followed perfectly to be worthwhile, and
the amount of extra work incurred by it can be decided based on practical the amount of extra work incurred by it can be decided based on practical
considerations. I merely maintain that when it comes time to revisit some considerations. I merely maintain that code which is (mostly) component-oriented
existing code, either to fix or augment it, that the job will be notably easier is easier to maintain in the long run, even if it might be harder to get off the
if the code _mostly_ follows this pattern. ground initially.
**My language makes this difficult.** **My language makes this difficult.**
@ -534,32 +310,43 @@ feature needed is abstract typing.
It would be nice to one day see a language which explicitly supported this It would be nice to one day see a language which explicitly supported this
pattern by baking the component properties in as compiler checked rules. pattern by baking the component properties in as compiler checked rules.
**This will result in over-abstraction.** **My `main` is too big**
There's no law saying all component construction needs to happen in `main`,
that's just the most sensible place for it. If there's large sections of your
program which are independent of each other then they could each have their own
construction functions which `main` then calls.
Other questions which are worth asking: Can my program be split up
into multiple programs? Can the responsibilities of any of my components be
refactored to reduce the overall complexity of the component DAG? Can the
instantiation of any components be moved within their parent's
instantiation function?
(This last suggestion may seem to be disallowed, but is in fact fine as long as
the parent's instantiation function remains pure.)
**Won't this will result in over-abstraction?**
Abstraction is a necessary tool in a programmer's toolkit, there is simply no Abstraction is a necessary tool in a programmer's toolkit, there is simply no
way around it. The only questions are "how much?" and "where?". way around it. The only questions are "how much?" and "where?".
The use of this pattern does not effect how those questions are answered, but The use of this pattern does not effect how those questions are answered, in my
instead aims to more clearly delineate the relationships and interactions opinion, but instead aims to more clearly delineate the relationships and
between the different abstracted types once they've been established using other interactions between the different abstracted types once they've been
methods. Over-abstraction is the fault of the programmer, not the language or established using other methods. Over-abstraction is possible and avoidable no
pattern or framework. matter what language, pattern, or framework is being used.
**The acronymn is CoP.** **Does CoP conflict with object-oriented or functional programming?**
Why do you think I've just been ackwardly using "this pattern" instead of the I don't think so. OoP languages will have abstract types as part of their core
acronymn for the whole post? Better names are welcome. feature-set; most difficulties are going to be with deliberately _not_ using
other features of an OoP language, and with imported libraries in the language
perhaps making life inconvenient by not following CoP (specifically when it
comes to cleanup and use of singletons).
## Conclusion With functional programming it may well be, depending on the language, that CoP
is technically being used, as functional languages are generally antagonistic
The component oriented pattern helps make our code more reliable with only a towards to globals and impure functions already, which is most of the battle.
small amount of extra effort incurred. In fact most of the pattern has to do Going from functional to component-oriented programming will generally be a
establishing sensible abstractions around global functionality and remembering problem of organization.
certain idioms for how those abstractions should be composed together, something
most of us do to some extent already anyway.
While beneficial in many ways, component oriented programming is merely a tool
which can be applied in many cases. It is certain that there are cases where it
is not the right tool for the job. I've found these cases to be
few-and-far-between, however. It's a solid pattern that I've gotten good use out
of, and hopefully you'll find it, or some parts of it, to be useful as well.

View File

@ -114,7 +114,7 @@ func TestScoreboard(t *testing.T) {
} }
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
// Test httpHandler component // Test httpHandlers component
type mockScoreboard map[string]int type mockScoreboard map[string]int
@ -155,3 +155,13 @@ func TestHTTPHandlers(t *testing.T) {
r = httptest.NewRequest("GET", "/scores", nil) r = httptest.NewRequest("GET", "/scores", nil)
assertRequest(t, 200, "bar: 2\nfoo: 1\n", r) assertRequest(t, 200, "bar: 2\nfoo: 1\n", r)
} }
////////////////////////////////////////////////////////////////////////////////
//
// httpServer is NOT tested, for the following reasons:
// * It depends on a `net.Listener`, which is not trivial to mock.
// * It does very little besides passing an httpHandlers along to an http.Server
// and managing cleanup.
// * It isn't likely to be changed often.
// * If it were to break it would be very apparent in subsequent testing stages.
//

View File

@ -1,9 +1,9 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"flag"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
@ -12,7 +12,6 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"os/signal"
"sort" "sort"
"strconv" "strconv"
"sync" "sync"
@ -41,11 +40,7 @@ type scoreboard struct {
scoresM map[string]int scoresM map[string]int
scoresLock sync.Mutex scoresLock sync.Mutex
// The cleanup method closes cleanupCh to signal to all scoreboard's running pointsOnCorrect, pointsOnIncorrect int
// go-routines to clean themselves up, and cleanupWG is then used to wait
// for those goroutines to do so.
cleanupCh chan struct{}
cleanupWG sync.WaitGroup
// this field will only be set in tests, and is used to synchronize with the // this field will only be set in tests, and is used to synchronize with the
// the for-select loop in saveLoop. // the for-select loop in saveLoop.
@ -55,7 +50,7 @@ type scoreboard struct {
// newScoreboard initializes a scoreboard using scores saved in the given File // newScoreboard initializes a scoreboard using scores saved in the given File
// (which may be empty). The scoreboard will rewrite the save file with the // (which may be empty). The scoreboard will rewrite the save file with the
// latest scores everytime saveTicker is written to. // latest scores everytime saveTicker is written to.
func newScoreboard(file File, saveTicker <-chan time.Time, logger Logger) (*scoreboard, error) { func newScoreboard(file File, saveTicker <-chan time.Time, logger Logger, pointsOnCorrect, pointsOnIncorrect int) (*scoreboard, error) {
fileBody, err := ioutil.ReadAll(file) fileBody, err := ioutil.ReadAll(file)
if err != nil { if err != nil {
return nil, fmt.Errorf("reading saved scored: %w", err) return nil, fmt.Errorf("reading saved scored: %w", err)
@ -69,36 +64,23 @@ func newScoreboard(file File, saveTicker <-chan time.Time, logger Logger) (*scor
} }
scoreboard := &scoreboard{ scoreboard := &scoreboard{
file: file, file: file,
scoresM: scoresM, scoresM: scoresM,
cleanupCh: make(chan struct{}), pointsOnCorrect: pointsOnCorrect,
saveLoopWaitCh: make(chan struct{}), pointsOnIncorrect: pointsOnIncorrect,
saveLoopWaitCh: make(chan struct{}),
} }
scoreboard.cleanupWG.Add(1) go scoreboard.saveLoop(saveTicker, logger)
go func() {
scoreboard.saveLoop(saveTicker, logger)
scoreboard.cleanupWG.Done()
}()
return scoreboard, nil return scoreboard, nil
} }
func (s *scoreboard) cleanup() error {
close(s.cleanupCh)
s.cleanupWG.Wait()
if err := s.save(); err != nil {
return fmt.Errorf("saving scores during cleanup: %w", err)
}
return nil
}
func (s *scoreboard) guessedCorrect(name string) int { func (s *scoreboard) guessedCorrect(name string) int {
s.scoresLock.Lock() s.scoresLock.Lock()
defer s.scoresLock.Unlock() defer s.scoresLock.Unlock()
s.scoresM[name] += 1000 s.scoresM[name] += s.pointsOnCorrect
return s.scoresM[name] return s.scoresM[name]
} }
@ -106,7 +88,7 @@ func (s *scoreboard) guessedIncorrect(name string) int {
s.scoresLock.Lock() s.scoresLock.Lock()
defer s.scoresLock.Unlock() defer s.scoresLock.Unlock()
s.scoresM[name] -= 1 s.scoresM[name] += s.pointsOnIncorrect
return s.scoresM[name] return s.scoresM[name]
} }
@ -141,8 +123,6 @@ func (s *scoreboard) saveLoop(ticker <-chan time.Time, logger Logger) {
if err := s.save(); err != nil { if err := s.save(); err != nil {
logger.Printf("error saving scoreboard to file: %v", err) logger.Printf("error saving scoreboard to file: %v", err)
} }
case <-s.cleanupCh:
return
case <-s.saveLoopWaitCh: case <-s.saveLoopWaitCh:
// test will unblock, nothing to do here. // test will unblock, nothing to do here.
} }
@ -295,90 +275,46 @@ func newHTTPServer(listener net.Listener, httpHandlers *httpHandlers, logger Log
return server return server
} }
func (s *httpServer) cleanup() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
return fmt.Errorf("shutting down http server: %w", err)
}
return <-s.errCh
}
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
// main // main
const (
saveFilePath = "./save.json"
listenAddr = ":8888"
saveInterval = 5 * time.Second
)
func main() { func main() {
saveFilePath := flag.String("save-file", "./save.json", "File used to save scores")
listenAddr := flag.String("listen-addr", ":8888", "Address to listen for HTTP requests on")
saveInterval := flag.Duration("save-interval", 5*time.Second, "How often to resave scores")
pointsOnCorrect := flag.Int("points-on-correct", 1000, "Amount to change a user's score by upon a correct score")
pointsOnIncorrect := flag.Int("points-on-incorrect", -1, "Amount to change a user's score by upon an incorrect score")
flag.Parse()
logger := log.New(os.Stdout, "", log.LstdFlags) logger := log.New(os.Stdout, "", log.LstdFlags)
logger.Printf("opening scoreboard save file %q", saveFilePath) logger.Printf("opening scoreboard save file %q", *saveFilePath)
file, err := os.OpenFile(saveFilePath, os.O_RDWR|os.O_CREATE, 0644) file, err := os.OpenFile(*saveFilePath, os.O_RDWR|os.O_CREATE, 0644)
if err != nil { if err != nil {
logger.Fatalf("failed to open file %q: %v", saveFilePath, err) logger.Fatalf("failed to open file %q: %v", *saveFilePath, err)
} }
saveTicker := time.NewTicker(saveInterval) saveTicker := time.NewTicker(*saveInterval)
randSrc := rand.New(rand.NewSource(time.Now().UnixNano())) randSrc := rand.New(rand.NewSource(time.Now().UnixNano()))
logger.Printf("initializing scoreboard") logger.Printf("initializing scoreboard")
scoreboard, err := newScoreboard(file, saveTicker.C, logger) scoreboard, err := newScoreboard(file, saveTicker.C, logger, *pointsOnCorrect, *pointsOnIncorrect)
if err != nil { if err != nil {
logger.Fatalf("failed to initialize scoreboard: %v", err) logger.Fatalf("failed to initialize scoreboard: %v", err)
} }
logger.Printf("listening on %q", listenAddr) logger.Printf("listening on %q", *listenAddr)
listener, err := net.Listen("tcp", listenAddr) listener, err := net.Listen("tcp", *listenAddr)
if err != nil { if err != nil {
logger.Fatalf("failed to listen on %q: %v", listenAddr, err) logger.Fatalf("failed to listen on %q: %v", *listenAddr, err)
} }
logger.Printf("setting up HTTP handlers") logger.Printf("setting up HTTP handlers")
httpHandlers := newHTTPHandlers(scoreboard, randSrc, logger) httpHandlers := newHTTPHandlers(scoreboard, randSrc, logger)
logger.Printf("serving HTTP requests") logger.Printf("serving HTTP requests")
httpServer := newHTTPServer(listener, httpHandlers, logger) newHTTPServer(listener, httpHandlers, logger)
logger.Printf("initialization done, waiting for interrupt signal") logger.Printf("initialization done")
sigCh := make(chan os.Signal) select {} // block forever
signal.Notify(sigCh, os.Interrupt)
<-sigCh
logger.Printf("interrupt signal received, cleaning up")
go func() {
<-sigCh
log.Fatalf("interrupt signal received again, forcing shutdown")
}()
if err := httpServer.cleanup(); err != nil {
logger.Fatalf("cleaning up http server: %v", err)
}
// NOTE go's builtin http server does not follow component property 5a, and
// instead closes the net.Listener given to it as a parameter when Shutdown
// is called. Because of that inconsistency this Close would error if it
// were called.
//
// While there are ways to work around this, it's instead highlighted in
// this example as an instance of a language making the component-oriented
// pattern more difficult.
//
//if err := listener.Close(); err != nil {
// logger.Fatalf("closing listener %q: %v", listenAddr, err)
//}
if err := scoreboard.cleanup(); err != nil {
logger.Fatalf("cleaning up scoreboard: %v", err)
}
saveTicker.Stop()
if err := file.Close(); err != nil {
logger.Fatalf("closing file %q: %v", saveFilePath, err)
}
os.Stdout.Sync()
} }

View File

@ -0,0 +1,390 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"math/rand"
"net"
"net/http"
"os"
"os/signal"
"sort"
"strconv"
"sync"
"time"
)
// Logger describes a simple component used for printing log lines.
type Logger interface {
Printf(string, ...interface{})
}
////////////////////////////////////////////////////////////////////////////////
// The scoreboard component
// File wraps the standard os.File type.
type File interface {
io.ReadWriter
Truncate(int64) error
Seek(int64, int) (int64, error)
}
// scoreboard loads player scores from a save file, tracks score updates, and
// periodically saves those scores back to the save file.
type scoreboard struct {
file File
scoresM map[string]int
scoresLock sync.Mutex
pointsOnCorrect, pointsOnIncorrect int
// The cleanup method closes cleanupCh to signal to all scoreboard's running
// go-routines to clean themselves up, and cleanupWG is then used to wait
// for those goroutines to do so.
cleanupCh chan struct{}
cleanupWG sync.WaitGroup
// this field will only be set in tests, and is used to synchronize with the
// the for-select loop in saveLoop.
saveLoopWaitCh chan struct{}
}
// newScoreboard initializes a scoreboard using scores saved in the given File
// (which may be empty). The scoreboard will rewrite the save file with the
// latest scores everytime saveTicker is written to.
func newScoreboard(file File, saveTicker <-chan time.Time, logger Logger, pointsOnCorrect, pointsOnIncorrect int) (*scoreboard, error) {
fileBody, err := ioutil.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("reading saved scored: %w", err)
}
scoresM := map[string]int{}
if len(fileBody) > 0 {
if err := json.Unmarshal(fileBody, &scoresM); err != nil {
return nil, fmt.Errorf("decoding saved scores: %w", err)
}
}
scoreboard := &scoreboard{
file: file,
scoresM: scoresM,
pointsOnCorrect: pointsOnCorrect,
pointsOnIncorrect: pointsOnIncorrect,
cleanupCh: make(chan struct{}),
saveLoopWaitCh: make(chan struct{}),
}
scoreboard.cleanupWG.Add(1)
go func() {
scoreboard.saveLoop(saveTicker, logger)
scoreboard.cleanupWG.Done()
}()
return scoreboard, nil
}
func (s *scoreboard) cleanup() error {
close(s.cleanupCh)
s.cleanupWG.Wait()
if err := s.save(); err != nil {
return fmt.Errorf("saving scores during cleanup: %w", err)
}
return nil
}
func (s *scoreboard) guessedCorrect(name string) int {
s.scoresLock.Lock()
defer s.scoresLock.Unlock()
s.scoresM[name] += s.pointsOnCorrect
return s.scoresM[name]
}
func (s *scoreboard) guessedIncorrect(name string) int {
s.scoresLock.Lock()
defer s.scoresLock.Unlock()
s.scoresM[name] += s.pointsOnIncorrect
return s.scoresM[name]
}
func (s *scoreboard) scores() map[string]int {
s.scoresLock.Lock()
defer s.scoresLock.Unlock()
scoresCp := map[string]int{}
for name, score := range s.scoresM {
scoresCp[name] = score
}
return scoresCp
}
func (s *scoreboard) save() error {
scores := s.scores()
if _, err := s.file.Seek(0, 0); err != nil {
return fmt.Errorf("seeking to start of save file: %w", err)
} else if err := s.file.Truncate(0); err != nil {
return fmt.Errorf("truncating save file: %w", err)
} else if err := json.NewEncoder(s.file).Encode(scores); err != nil {
return fmt.Errorf("encoding scores to save file: %w", err)
}
return nil
}
func (s *scoreboard) saveLoop(ticker <-chan time.Time, logger Logger) {
for {
select {
case <-ticker:
if err := s.save(); err != nil {
logger.Printf("error saving scoreboard to file: %v", err)
}
case <-s.cleanupCh:
return
case <-s.saveLoopWaitCh:
// test will unblock, nothing to do here.
}
}
}
////////////////////////////////////////////////////////////////////////////////
// The httpHandlers component
// Scoreboard describes the scoreboard component from the point of view of the
// httpHandler component (which only needs a subset of scoreboard's methods).
type Scoreboard interface {
guessedCorrect(name string) int
guessedIncorrect(name string) int
scores() map[string]int
}
// RandSrc describes a randomness component which can produce random integers.
type RandSrc interface {
Int() int
}
// httpHandlers implements the http.HandlerFuncs used by the httpServer.
type httpHandlers struct {
scoreboard Scoreboard
randSrc RandSrc
logger Logger
mux *http.ServeMux
n int
nLock sync.Mutex
}
func newHTTPHandlers(scoreboard Scoreboard, randSrc RandSrc, logger Logger) *httpHandlers {
n := randSrc.Int()
logger.Printf("first n is %v", n)
httpHandlers := &httpHandlers{
scoreboard: scoreboard,
randSrc: randSrc,
logger: logger,
mux: http.NewServeMux(),
n: n,
}
httpHandlers.mux.HandleFunc("/guess", httpHandlers.handleGuess)
httpHandlers.mux.HandleFunc("/scores", httpHandlers.handleScores)
return httpHandlers
}
func (h *httpHandlers) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(rw, r)
}
func (h *httpHandlers) handleGuess(rw http.ResponseWriter, r *http.Request) {
r.Header.Set("Content-Type", "text/plain")
name := r.FormValue("name")
nStr := r.FormValue("n")
if name == "" || nStr == "" {
http.Error(rw, `"name" and "n" GET args are required`, http.StatusBadRequest)
return
}
n, err := strconv.Atoi(nStr)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
h.nLock.Lock()
defer h.nLock.Unlock()
if h.n == n {
newScore := h.scoreboard.guessedCorrect(name)
h.n = h.randSrc.Int()
h.logger.Printf("new n is %v", h.n)
rw.WriteHeader(http.StatusOK)
fmt.Fprintf(rw, "Correct! Your score is now %d\n", newScore)
return
}
hint := "higher"
if h.n < n {
hint = "lower"
}
newScore := h.scoreboard.guessedIncorrect(name)
rw.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(rw, "Try %s. Your score is now %d\n", hint, newScore)
}
func (h *httpHandlers) handleScores(rw http.ResponseWriter, r *http.Request) {
r.Header.Set("Content-Type", "text/plain")
h.nLock.Lock()
defer h.nLock.Unlock()
type scoreTup struct {
name string
score int
}
scores := h.scoreboard.scores()
scoresTups := make([]scoreTup, 0, len(scores))
for name, score := range scores {
scoresTups = append(scoresTups, scoreTup{name, score})
}
sort.Slice(scoresTups, func(i, j int) bool {
return scoresTups[i].score > scoresTups[j].score
})
for _, scoresTup := range scoresTups {
fmt.Fprintf(rw, "%s: %d\n", scoresTup.name, scoresTup.score)
}
}
////////////////////////////////////////////////////////////////////////////////
// The httpServer component.
type httpServer struct {
httpServer *http.Server
errCh chan error
}
func newHTTPServer(listener net.Listener, httpHandlers *httpHandlers, logger Logger) *httpServer {
loggingHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
logger.Printf("HTTP request -> %s %s %s", ip, r.Method, r.URL.String())
httpHandlers.ServeHTTP(rw, r)
})
server := &httpServer{
httpServer: &http.Server{
Handler: loggingHandler,
},
errCh: make(chan error, 1),
}
go func() {
err := server.httpServer.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
server.errCh <- err
}()
return server
}
func (s *httpServer) cleanup() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
return fmt.Errorf("shutting down http server: %w", err)
}
return <-s.errCh
}
////////////////////////////////////////////////////////////////////////////////
// main
func main() {
saveFilePath := flag.String("save-file", "./save.json", "File used to save scores")
listenAddr := flag.String("listen-addr", ":8888", "Address to listen for HTTP requests on")
saveInterval := flag.Duration("save-interval", 5*time.Second, "How often to resave scores")
pointsOnCorrect := flag.Int("points-on-correct", 1000, "Amount to change a user's score by upon a correct score")
pointsOnIncorrect := flag.Int("points-on-incorrect", -1, "Amount to change a user's score by upon an incorrect score")
flag.Parse()
logger := log.New(os.Stdout, "", log.LstdFlags)
logger.Printf("opening scoreboard save file %q", *saveFilePath)
file, err := os.OpenFile(*saveFilePath, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
logger.Fatalf("failed to open file %q: %v", *saveFilePath, err)
}
saveTicker := time.NewTicker(*saveInterval)
randSrc := rand.New(rand.NewSource(time.Now().UnixNano()))
logger.Printf("initializing scoreboard")
scoreboard, err := newScoreboard(file, saveTicker.C, logger, *pointsOnCorrect, *pointsOnIncorrect)
if err != nil {
logger.Fatalf("failed to initialize scoreboard: %v", err)
}
logger.Printf("listening on %q", *listenAddr)
listener, err := net.Listen("tcp", *listenAddr)
if err != nil {
logger.Fatalf("failed to listen on %q: %v", *listenAddr, err)
}
logger.Printf("setting up HTTP handlers")
httpHandlers := newHTTPHandlers(scoreboard, randSrc, logger)
logger.Printf("serving HTTP requests")
httpServer := newHTTPServer(listener, httpHandlers, logger)
logger.Printf("initialization done, waiting for interrupt signal")
sigCh := make(chan os.Signal)
signal.Notify(sigCh, os.Interrupt)
<-sigCh
logger.Printf("interrupt signal received, cleaning up")
go func() {
<-sigCh
log.Fatalf("interrupt signal received again, forcing shutdown")
}()
if err := httpServer.cleanup(); err != nil {
logger.Fatalf("cleaning up http server: %v", err)
}
// NOTE go's builtin http server does not follow component property 5a, and
// instead closes the net.Listener given to it as a parameter when Shutdown
// is called. Because of that inconsistency this Close would error if it
// were called.
//
// While there are ways to work around this, it's instead highlighted in
// this example as an instance of a language making the component-oriented
// pattern more difficult.
//
//if err := listener.Close(); err != nil {
// logger.Fatalf("closing listener %q: %v", listenAddr, err)
//}
if err := scoreboard.cleanup(); err != nil {
logger.Fatalf("cleaning up scoreboard: %v", err)
}
saveTicker.Stop()
if err := file.Close(); err != nil {
logger.Fatalf("closing file %q: %v", *saveFilePath, err)
}
os.Stdout.Sync()
}

View File

@ -0,0 +1,4 @@
---
layout: code
include: main.go
---