This cli component was trickier to build than I thought

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
recently I was building a CLI application in go and I wanted to display a progress spinner for a long running task usually my first instinct is to reach for a third-party package however this time I decided I wanted to build my own to do so I went about it like I normally would thinking a little about the design before jumping straight into the code however after building it for a couple of hours I still couldn't get it to work as I kept running into issues caused by concurrency so I decided to scrap what I had and start again from the beginning but this time I was going to use test driven development in hindsight I'm really glad that I did because this component was a lot trickier than I thought to get started I decided to create a new project called Terminal D UI which I then initialized using the go mod init command followed by opening it up in my favorite text editor once inside I opened up my telescope file browser and created a new directory and file both called spinner this directory would be the spinner package and this file would contain the main spinner code inside of this file I first defined the package name followed by creating a new struct type called spinner whenever I begin building out a new abstraction I always start with designing the public interface by doing so it helps me understand how I want the component to work laying the foundation for the actual implementation when it came to the spinner the first idea I had was to use a simple start and stop method whilst I was initially happy with this design I did feel like it opened up the spinner to a number of different edge cases such as what the expected behavior of restarting a stopped spinner was or what should happen if you call start on a spinner that's already spinning despite this however I did like this interface design but I wanted to see if I could simplify it in some way and so I decided to play around with removing the stop method instead only having the single method of start in order to actually stop the spinner I decided to use a context. context which would be passed into the start method as a parameter whilst this approach was less intuitive I felt like it removed a lot of code and so for my initial implementation I decided to roll with it predictably however this decision ended up being a mistake with the interface defined the next thing to do was to begin implementation and because I was using tdd that meant beginning with a test in order to do so I created a new go file called spinner uncore test and set the package name to be the same after defining the test package I then went about writing the code for my initial test function the way TD works is you start by defining your test case and then write the least amount of code in order to make it pass once it's passing you can then go about refactoring your implementation this is known as the red green refactor Loop and is actually a really interesting way to build software the first test case I decided to implement was that after a scheduled period of time two frames would be printed to the console via sdd error unfortunately this presented a bit of a problem as we needed a way to capture what had been written to the stream whilst there is a way to capture SD error using operating system pipes doing so adds a lot of complexity to the tests not only that but because we're patching a global value then it also has the potential to cause some KnockOn effects so I decided to use another approach this approach was to Define an io. writer property on the spinner which would default to sdd error then in our tests we could use dependency injection to overwrite this property allowing us to easily read the value to enable this I like to use the Constructor pattern which works by defining a function called new which returns an instance of the type in this case the spinner inside of this function I configure an instance with default properties which I can override by using dependency injection when it comes to go you can actually do dependency injection via a number of different ways and I actually have a video plan for the future to look at some of these different methods in this case because I was setting default values then I wanted the injected dependencies to be optional and so I decided to achieve this by using a new type called config which itself contained a property of io. writer this config type would then be passed as a parameter to the Constructor function and if the io. writer was set inside of the config this value would be used instead of the Spinner's default this approach made it easy for the tests to overwrite this value whilst also preventing end users from having to pass in a default as well as a property of io. writer I also wanted to add in another property for the frame rate which was of type time do duration and would be used to determine how fast the spinner would cycle through each of its frames by default I set this to be 250 milliseconds which equal 4 frames per second however I also made this available to be overridden using the constructor's config this made it so that our tests could run quicker as they could now configure the amount of time it would take for a condition to be met lastly I added another property to the spinner called frames which was a slice of runes used to represent each individual frame in the spinner animation again I set this to its default value inside of the Constructor function which was set to four asky values for lines in various different orientations when these characters are used as an animation it gives the appearance of a line spinning in a clockwise Direction with our properties defined and a way to overwrite them the next thing to do was to go and Implement my initial test before I move on to how I did that however let me first take a minute to talk about the sponsor of today's video brilliant.org one of the best things you can do for your own personal development is to learn a little every day and using a platform such as brilliant can help you to achieve This brilliant has thousands of interactive lessons in math data analysis programming and AI each of these courses on brilliant encourages you to learn by doing rather than just by memorization additionally each of the lessons are set up so you can learn a little every day which makes it easier to exercise your brain improving your ability to learn which when it comes to software development is extremely important given how quickly the landscape changes as well as helping you to develop your learning capabilities brilliant also helps you to build real knowledge in just minutes every day such as with the thinking in code course which will not only teach you the foundations of writing code but will also teach you essential coding elements such as Loops variables nesting and conditionals so to try everything that brilliant has to offer for a full 30 days visit brilliant.org dreamof code or click the link in the description down below you'll also get 20% of brilliant's annual premium subscription a big thank you to brilliant for sponsoring this video now that my spinner had some properties applied to it I was able to implement my first test to do so I instantiated an instance of the spinner with a bite stop buffer and a frame rate of 20 milliseconds then I created a cancellation context with a timeout of 25 milliseconds which would be enough time for the spinner to hit two frames then I called the start method passing in the cancellation context afterwards I then pull out all of the written data from the buffer using the read all function of the io package because this function can return an error it's worthwhile adding in a check to ensure that this is null when it comes to testing in go I often refer to the stretcher / testify package for my assertions which I added to my project using the following goget command after asserting that the error didn't exist the next thing to assert was that the data from the buffer contained the expected string this string consisted of our first and second frame separated by a back SLB this back SLB is a special Escape character when it comes to the terminal and is used to move the character back one space this will cause our spinner to replace each character with the next frame which will make it appear as if it's spinning with the test now implemented I quickly checked that it was working before moving on to implement the code to make it pass as I mentioned before when it comes to DDD the idea is to write the least amount of code in order to make the test pass therefore for the start method I wrote a simple iterator to iterate over each frame convert that frame into a bite and then write the bite to the internal io. writer once the frame has been written the next step is to use the select keyword in order to wait on two different channels the first channel is the context DOD which if selected means that the context is canceled and we want to return out of the loop the second channel is created using the after function of the time package passing in our frame duration when this channel is invoked we want to continue with the for Loop to the next frame therefore I'm adding in the following line to break out of the select statement causing the loop to move on to the next iteration before the next frame is printed however I make sure to write the backspace character to the internal writer which will cause the next frame to overwrite the previous character next I check if I've written enough code in order to make sure the test is passing which I had managed to do meaning I was able to move on to writing the next test case before doing so however I take the opportunity to refactor the test code turning it into a parametric or table driven test which is commonly used in go by doing so it reduces the amount of boiler plat needed for each test making it much easier to test different parameters once the code has been refactored the next test I add is to ensure that we get the correct output when we wait for six frames when I go to run this test it fails as expected which means I can now implement the code to fix it again by taking the approach of writing the most minimal amount of code for the test to pass all I end up doing is wrapping the existing for Loop inside of another one that will run indefinitely which is enough for all of the tests to pass whilst my spinner is working as defined by the tests because it's a UI based component I always find it's worthwhile adding in a visual integration to make sure it works as intended for this project I achiev this by setting up a new example application which starts a spinner sleeps for 5 seconds and then stops it by cancelling the context however when I went to run the application it presented a couple of bugs the first is that the spinner didn't stop running this was happening due to the spinner blocking the main thread which meant that the line cancelling our context was never being called this Behavior wasn't being encountered in the tests due to the fact they were using the context. with timeout method which meant that the cancellation was happening asynchronously therefore I added in another test to check the asynchronous behavior this sort of test can sometimes be a little tricky to implement as you want to make sure that the test also exits and doesn't get stuck indefinitely my approach to these tests is to run the problematic function inside of a guru routine which will then close a done Channel after the function exits then inside of the main test body I'll use a select statement on both the done Channel and a Time dot after if the done channel is co then that means the function exited however if the time dot after is co then we can assume that the method is still running and the test has failed this meant the test would only ever run up to 200 milliseconds before exiting once I had the bug recreated The Next Step was to implement a solution for that I chose to run the entire logic inside of the start function in a go routine which meant that the spinner would work asynchronously upon implementing the solution my example now worked however my tests were now failing initially this was a little confusing but the root cause ended up being the latency caused by invoking a new go routine I was able to solve this by modifying my tests to not use the context. with timeout method instead using a time. sleep after starting the spinner and then calling the context cancel function afterwards at this point I was starting to wonder if using a cont text. context was the right decision the good news was I didn't have to wait too long to find out when testing the example application to ensure that the code was now working asynchronously I discovered another minor bug this was where one of the frames was left behind when the spinner stopped in order to solve it I needed to make sure I was clearing the line when the context was cancelled which meant I needed to write in another backspace character before implementing this I quickly modified the expectations of my existing tests then once I confirmed that my tests were failing I went and added in the following line to make them pass this line would write in the backspace character once the context was canceled however when I went to run my tests it didn't work neither of the tests had the backspace character printed at the end again I was a little confused however after a little bit of trial and error I realized that the context cancellation function doesn't wait for the work to complete this meant that the code would continue before the backspace character was written I was able to validate that this was Happening by writing yet another test one that would wait a couple more frames after canceling the context which ended up producing the correct result unfortunately because I had chosen to stop my spinner using a context. context I was unable to perform any thread synchronization this was actually a very similar problem I encountered when I wrote my initial implementation and it was what caused me to start again using tdd this time however I had an idea on how to solve the problem and I had the tests to validate it but in order to do so I needed to implement a stop method with the method defined the next thing to do was to modify my tests to make use of it replacing any calls to the cancel function then I went and ran my tests to make sure that they were all failing next I went about implementing my solution which was actually very similar to what I had already I started by assigning a property of cancel Funk to the spinner followed by creating a brand new context and cancel Funk inside of the start method I then assigned this cancel Funk to the Spinner's property followed by calling this cancel Funk inside of the stop method this was actually very similar to what I already had however now it was all encapsulated within the spinner so when I went to run my tests they were now all passing except for the ones with the missing backspace character however now I was able to implement a fix I started by adding the most minimal code approach which was a loadbearing time do sleep with the duration of a single frame now when I went to run my tests they were passing however this was a bit of a hacky solution but thinking back to the red green loop I was now able to make my refactor the approach I chose to use was to make use of a done Channel which is another idiomatic pattern when it comes to go this channel will be created inside of the spinner start method and then assigned to the spinner property then this channel will be closed after the final backspace had been written in the context. dun Method All That remained now was to make use of this done channel in the stop method preventing it from returning until it was closed if your brain is spinning at this point then you're not alone mine was also starting to do so as well fortunately I had my test cases to prove that everything was working as expected which showed that the backspace character was now being added I also quickly modified my example application to make use of this new stop method and everything worked as expected however I wasn't done yet as I needed to address the main reason I didn't want to use a stop method in the first place the associated edge cases again I was going to check these off using tdd and the first test I wrote was to check what happened if I called stop and a non-st started spinner the expectation here was that nothing should happen however instead my test panicked caused by a no pointer dfference again I took the minimal code approach to solve this which was to check if the done channel was nil and return early if that was the case this caused the test to pass so I was able to move on to the next Edge case which was checking what would happen if I called the start method on an already started spinner again my expectation was nothing should happen instead however it ended up causing two progress Spinners to be writing into the same stream at the same time again I solved this using the most minimal code I could which was to check if the done channel was not nil which had sort of become the de facto runtime check if that was the case then the start function would return early with this test passing I was then able to move on to my third and final Edge case which was calling start on a spinner that had been stopped my expectation for this test was that it should restart from the beginning however instead my spinner refused to restart this was happening because I wasn't res setting the spinner State inside of the stop method and so all I had to do was set the done channel to be nil after waiting for it to be closed with that all of my tests were now passing and my edge cases had been handled the last thing I wanted to do was some refactoring especially as I now had a good picture of how the code worked the first thing I chose to do was to remove the context parameter of the start method given that this was now useless with the stop method and by using it you could actually cause the spinner to be left in a bad state after removing it I also had to make a few changes inside of the test cases the next thing I changed was to make use of a time. ticker rather than using the time. after function there's a very slight difference between the way that these two work however the tldr is that the time. ticker is more consistent once I made this change I then checked that my tests were still passing which they were the next thing I wanted to refactor was to add in a private helper method to check if the spinner was running I added this change just to make the code a little bit more readable especially as we had ended up using the done channel to perform this check with the method implemented I then made the following changes to make use of it in both the start and stop methods the final change I decided to make was to add in some thread safety using a read write mutex from the sync package by doing so it would help to prevent any race conditions if using the spinner across multiple go routines once added I verified that everything was working by running my unit tests and checking my example integration lastly all that remained was to initialize a git repository add and commit the code and then push it to a remote repo
Info
Channel: Dreams of Code
Views: 55,633
Rating: undefined out of 5
Keywords:
Id: LguOA6HS1es
Channel Id: undefined
Length: 17min 34sec (1054 seconds)
Published: Sun Jun 30 2024
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.