DEV Community

Rituraj Borpujari
Rituraj Borpujari

Posted on • Originally published at riturajborpujari.com

Building a Downloader in Go

People say Go is well suited for Network applications. With its light
Goroutines and huge standard library it can get most of the job done
without even needing a third-party library.

Great, let me build a terminal program to download files

Accio

harry-potter-casting-spell-accio-dittany

The summoning charm from Harry Potter
seems like a fit name for my downloader. Like when Harry Potter says
"Accio Dittany" and out pops the bottle of Dittany potion from
Hermione's bag with an "Undectable Extension Charm", our version of
Accio would do something similar, albeit with URLs.

So running accio url would get you the file from the url.

Simple enough! Let's start then.

The process

The module net/http gives us this amazing method for making a GET request -

http.Get(url string) (resp *httpResponse, err error)

With this, we can run a GET request to url and we get a response object
(of type httpResponse) and a possible error (of type error). We check for any error that might have occured while making the request and then
move on to reading the data bytes from httpResponse.Body.

But before reading the body, we would need to create and open a file for
writing with the following method. And os module has the capability we need

os.OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error)

we also need os to get the CLI argument for URL being passed into our program
by the user while running command accio URL. We need io module because it has defined the io.EOF error value that
we need to test to know when we've reached the end of reading the response
body. With the file opened and the response body ready for reading, we would simply
need to loop this following

Copy Data process

  1. Initialize a buffer
  2. Read n bytes from response body into a buffer
  3. If n is non-zero we write n bytes to the file
  4. If error occured is EOF (End of file - indicates response data end)

    We break the loop (go to step 6)

  5. Go to step 2 (repeat)

  6. We close the file and the response body

Here's the actual code

response, err := http.Get(downloadUrl)
// handle error
defer response.Body.Close()

file, err := os.OpenFile(
    "download",
    os.O_WRONLY | os.O_CREATE,
    os.ModePerm,
)
// handle error
defer file.Close()

buffer := make([]byte, 512)
for {
    buffer = buffer[0:512]
    n, err := response.Body.Read(buffer)

    if n > 0 {
        file.Write(buffer[0:n])
    }

    if err != nil {
        if err != io.EOF {
            fmt.Fprintf(
                os.Stderr,
                "download error: %s\n",
                err,
            )
            os.Exit(-1)
        }
        break
    }
}
Enter fullscreen mode Exit fullscreen mode

We open the file with flags as os.O_WRONLY | os.O_CREATE which causes it to
open a file for writing and the perm as os.ModePerm so that the opened file
gets the same permissions as our executable program accio

We reset the buffer slice's capacity to 512 each time in the loop to
utilize the full capacity and let the Read method of response.Body to fill
it up with n bytes.

We then proceed to write only n bytes to the file because that's how much we
read in this iteration from response.Body

If error has occured, we need to stop everything. If the error is not EOF
we also log this onto the terminal. Otherwise, we silently break the loop

Showing progress

The next part is to show progress of the download. For this I'm going to use the
channel feature of golang.

Basically the download (more specifically the copy of bytes from response body
to file) would be happening on another go routine and it will send progress
stats over a channel. The main routine can then read messages from this channel
to display progress.

Here's the structure I used for the download progress status message

type DownloadStatus struct {
    Error           error
    IsComplete      bool
    BytesDownloaded int64
}
Enter fullscreen mode Exit fullscreen mode

This will suffice. The copying logic will post a message of type
DownloadStatus on the statusChannel channel so that the main routine can
display the progress

here's the updated function to copy data from src to dest while posting
updates of the progress

func copyVerbose(dest io.Writer,
                 src io.Reader,
                 statusChannel chan DownloadStatus) {
    buf := make([]byte, COPY_BUFFER_SIZE)
    status := DownloadStatus{}

    for {
        nread, err := src.Read(buf)
        if nread > 0 {
            nwritten, err := dest.Write(buf[0:nread])
            if err != nil {
                status.Error = err
                break
            }
            status.BytesDownloaded += int64(nwritten)
        }
        if err != nil {
            if err == io.EOF {
                status.IsComplete = true
            } else {
                status.Error = err
            }
        }
        statusChannel <- status
    }
}
Enter fullscreen mode Exit fullscreen mode

Showing progress in

Now to show progress on the main routine, we need to do this

  1. create a ticker (which ticks periodically at say 500 ms)
  2. use select to read from ticker and the statusChannel
  3. if read from statusChannel, store it in a local variable
  4. if read from ticker channel, show the progress
  5. go to step 2 and repeat

of course, we would close this repeat loop once we find the status message
contains IsComplete as true

here's the code for that (partial main function)

lastStatus := DownloadStatus{}
ticker:= time.NewTicker(time.Millisecond * 500)
go downloadUrl(url, &DownloadOptions{Filepath: filename}, statusChannel)

done := false
for done != true {
    select {
    case <-ticker.C:
        n, unit := getFormattedSize(lastStatus.BytesDownloaded)
        if lastStatus.IsComplete == true {
            fmt.Printf("\x1B[1K\rcompleted: %.2f %s\n", n, unit)
            done = true
            break
        }
        if lastStatus.Error != nil {
            fmt.Fprintf(
                os.Stderr,
                "download failed: %s\n",
                lastStatus.Error)
            done = true
            break
        }
        fmt.Printf("\x1B[1K\r%.2f %s", n, unit)
    case status := <-statusChannel:
        lastStatus = status
    }
}
Enter fullscreen mode Exit fullscreen mode

The escape sequence in printf (\x1B[1K) deletes the current line and \r
moves the cursor to the beginning of the line. See more

This effectively shows us the updated progress by deleting the old progress
every 500 ms

Conclusion

We've got ourselves a http(s) downloader using Golang's standard libraries only.
The downloader shows progress as it downloads and saves the file with name as
found looking at the url (or download if the url doesn't contain a
/resource_name.extension part at the end

There are many more things that can be done here. Some of the basic ones are

  1. Add multi connections to speed up downloads
  2. Add pause / resume capability

Both of them requires the usage of response header Accept-Range which denotes
whether the server supports requesting file data by a specific range. The
corresponding request header is Range which can be added in the request to let
the server know which range of data we need

I'll pick this up in the next iteration of Accio our very own http downloader.

Full code

https://github.com/riturajborpujari/accio

References

  1. https://pkg.go.dev/std
  2. https://developer.mozilla.org/en-US/docs/Web/HTTP/Overview
  3. https://www2.ccs.neu.edu/research/gpc/VonaUtils/vona/terminal/vtansi.htm

Top comments (0)