Chatbot 04: Code organization using separate files and functions

Organizing code is essential to future work on code. It allows for re-use, which increases development velocity. Done correctly it provides ease of initial use, which in turn decreases the amount of time someone else needs to understand your code. There are a plethora of approaches for code organization – and more then one of them are right. How I think about code is not how someone else thinks about code. But, with a decent amount of organization – we can both understand the code and build upon each others successes. It is one thing to develop code in a vacuum. The next level is building in tandem with a team.

Let’s dive right into it. https://github.com/Waryway/chatbot/tree/primary/chatbot-lesson-04-organize is the fourth directory for the chatbot. If you remember from the 3rd lesson – our main.go file was getting a bit lengthy. Nothing terribly egregious, but just enough where we can use it for an example on reorganizing code. Now, bear with me – there is a lot of code listed below – feel free to scroll past it.

Organized Code Files

// main.go
// main function
func main() {
	var history handlers.History
	err := history.Initialize("history.txt").LoadHistory()

	// check for the error.
	if err != nil { // a 'nil' error means no error occurred.
		fmt.Println(err) // print the error
		return           // stop processing the context.
	}

	var echo handlers.EchoBot
	echo.Initialize(history).GreetUser().ChatLoop()
}

// history.go

type History struct {
	Location string
	Current  []string
	file     *os.File
}

func (h *History) Initialize(location string) *History {
	h.Location = location
	return h
}

func (h *History) LoadHistory() error {

	err := h.open()
	if err != nil {
		return err
	}

	defer func() { _ = h.close() }()
	// Slice for storing the History of the world.
	scanner := bufio.NewScanner(h.file) // use the buffer input output package to start a file scanner
	for scanner.Scan() {                // loop through the file scanner
		h.addRecord(scanner.Text() + "\r\n") // add the scanned line to the lines slice.
	}

	return nil
}

func (h *History) Record(record string, isPrintable bool) {
	if isPrintable {
		fmt.Print(record + "\r\n")
	}
	h.addRecord(record + "\r\n")
	h.writeRecord(record + "\r\n")
}

func (h *History) addRecord(record string) {
	h.Current = append(h.Current, record)
}

func (h *History) writeRecord(record string) {
	_ = h.open()
	defer func() { _ = h.close() }()
	_, _ = h.file.WriteString(record)
}

func (h History) SaveAll() {
	for _, record := range h.Current {
		h.writeRecord(record)
	}
}

/**
 * This will perform the three error producing steps to erase the History file.
 * 1. Close the writer
 * 2. Truncate the History file and the local History
 * 3. Re-open the file
 */
func (h *History) EraseHistory() error {
	err := h.close()
	if err == nil {
		err = os.Truncate(h.Location, 0)
	}
	if err == nil {
		h.Current = []string{}
		err = h.open()
	}

	return err
}

func (h History) close() error {
	return h.file.Close()
}

func (h *History) open() error {
	f, err := os.OpenFile(h.Location, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
	h.file = f
	// check for the error.
	if err != nil { // a 'nil' error means no error occurred.
		fmt.Println(err) // print the error
		return err       // stop processing the context.
	}

	return nil
}

func (h History) Length() int {
	return len(h.Current)
}

func (h History) Print() {
	// iterate (loop) through the history
	for k, v := range h.Current {
		// print out the Key and the Value
		fmt.Print(k, v)
	}
}

// echobot.go
type EchoBot struct {
	greeting   string
	userPrompt string
	history    History
}

func (e EchoBot) Initialize(history History) EchoBot {
	e.greeting = "Hello, World! \n I am Echo! Please tell me something to say by typing it in, and pressing enter!"
	e.userPrompt = ">"
	e.history = history
	return e
}

func (e EchoBot) GreetUser() EchoBot {
	e.history.Record(e.greeting, true)
	return e
}

func (e EchoBot) ChatLoop() EchoBot {
	// string for storing input
	var input string

	// Stop when we see "bye"
	for input != "bye" {
		e.prompt()
		scanner := bufio.NewScanner(os.Stdin)
		for scanner.Scan() {
			input = scanner.Text()
			break
		}

		// Add the new input to the history
		e.history.Record(e.userPrompt+input, false)
		e.history.Record("Echo: "+input, true)
		// Check if the input was the string 'history'
		if input == "history" {
			// We won't add the history to the history, instead we can add how many lines of history exist.
			e.history.Record("<Truncated> "+strconv.Itoa(e.history.Length())+" Lines of history repetition.", false)
			e.history.Print()
		}
	}
	return e
}

func (e EchoBot) prompt() {
	fmt.Print(e.userPrompt)
}

Lesson 3 Code Reference

// main function
func main() {
	f, err := os.OpenFile("history.txt", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)

	// check for the error.
	if err != nil { // a 'nil' error means no error occurred.
		fmt.Println(err) // print the error
		return           // stop processing the context.
	}

	defer func() {
		_ = f.Close()
	}()

	// string for storing input
	var input string

	// Slice for storing the history of the world.
	var history []string
	scanner := bufio.NewScanner(f) // use the buffer input output package to start a file scanner
	for scanner.Scan() {           // loop through the file scanner
		history = append(history, scanner.Text()) // add the scanned line to the lines slice.
	}

	fmt.Println("Hello, World!")
	fmt.Println("I am Echo! Please tell me something to say by typing it in, and pressing enter!")
	_, _ = f.WriteString("Hello, World! \n")
	_, _ = f.WriteString("I am Echo! Please tell me something to say by typing it in, and pressing enter! \n")
	// Stop when we see "bye"
	for input != "bye" {

		fmt.Print("You: ")
		scanner := bufio.NewScanner(os.Stdin)
		for scanner.Scan() {
			input = scanner.Text()
			break
		}
		_, _ = f.WriteString("You: " + input + "\n")
		// Add the new input to the history
		history = append(history, input)
		fmt.Print("Echo: " + input + "\n")
		_, _ = f.WriteString("Echo: " + input + "\n")
		// Check if the input was the string 'history'
		if input == "history" {
			// We won't add the history to the history, instead we can add how many lines of history exist.
			_, _ = f.WriteString("<Truncated> " + strconv.Itoa(len(history)) + " Lines of history repetition.")
			// iterate (loop) through the history
			for k, v := range history {
				// print out the Key and the Value
				fmt.Println(k, v)
			}
		}
	}
}

Break down of changes

  • Created an independent concept of History
  • Created an independent concept of Echobot
  • Main handles really only handles the initialization, and kickoff of the Echobot

But how did I come to decide those were useful items? Well, looking through the original main.go file, I noticed we were doing a lot of ‘history’ type operations. In noticing this, I considered the concept that, maybe in the future – something else might need to write history. I also considered that idea that history might need to be written to another location. Separating history, and setting it into its own area proved a bit more work then just isolating the same calls from main.


Step 1. Creating a history object

I work a bit through trial and error. I’ve learned in the past, that I like talking to objects. It keeps an object in its own state, it allows me to mock out an object on a whim for testing. It even gives me operations on the object in a simple to reference location. With this in mind, I created the History struct. I then proceeded to add an initialization step. This just makes certain the object knows where it will persist data. It keeps things simple.

Step 2: Adding functionality

Because an object was used – I am able to call the object’s functions and keep the objects state readily available. This will allow me to potentially have different histories, but at the same time, largely avoid having to manage which is what when running methods for the objects.

To figure out the functions for the object, I reference what the main.go originally did. Then, I proceed to create distinct functions for file i/o or history operations. This makes it much easier to find a problem in talking to files, or in actually ‘logical’ operations – such as Print, or Length.

Step 3: Repeating the process somewhere else.

After going through the history object – I quickly came to realize that the main code was still a bit lengthy. (Apologies, I didn’t record the code in this state!). Evaluating what was left in main, and thinking about the goal of this project. I decided the only proper thing to do would be to make an actual echobot struct for handling echo details.

Echobot had some really clear purpose. It needed to listen, it needed to loop, and it absolutely needed to talk to the previously created History object. This quickly led to a lot of dependence on the history object, but, at the same time allowed the concept of a ‘forever listening’ echobot to be present and have its own historical record. This greatly changed the state of the program, without actually really changing anything for the end user.

Summary

Sometimes – you can plan ahead for how you are going to organize pieces of the code base. This will allow you to write tests for you code with ease alongside developing the code, or, in the case of test driven development. It could allow you to design your tests ahead of your code. In this example, we didn’t create any tests. However – with the reorganization of the code – the next step is truly creating tests to make sure things are working as expected. Look out for the next step – Unit Testing your Chatbot

Advertisement