Пишем консольное todo

Перевод статьи "Writing a Command-line Task Tracker in Go". Оригинал тут.

Из этого туториала вы узнаете, как с помощью Go написать простое консольное приложение. Предполагается, что вы уже ознакомились с языком и у вас настроено окружение для нормальной разработки на нем.

Наше приложение будет напоминать известное todo.txt.

Мы сможем добавлять задачи, просматривать список задач и отмечать выполненные с помощью команды todo в консоли:

$ todo ls
[1]     [2014-3-27]     Get groceries
[2]     [2014-3-27]     Fix Issue #4501
[3]     [2014-3-28]     Add more features to
$ todo add "Update readme file"
Task is added: Update readme file
$ todo ls
[1]     [2014-3-27]     Get groceries
[2]     [2014-3-27]     Fix Issue #4501
[3]     [2014-3-28]     Add more features to
[4]     [2014-3-29]     Update readme file
$ todo complete 1
Task Marked as complete: Get groceries
$ todo ls
[1]     [2014-3-27]     Fix Issue #4501
[2]     [2014-3-28]     Add more features to
[3]     [2014-3-29]     Update readme file

Реализуя функцинал этого приложения, вы узнаете как сохранять введенные пользователем данные, отображать эти данные в удобной форме и изменять их по необходимости.

Содержание

  • Разбираемся с консолью
  • Добавление задач, хранение в JSON
  • Список задач
  • Выполнение задачи

Разбираемся с консолью

Для начала нам нужен пакет для работы с консолью от Codegangsta, который немного упростит нам жизнь.

go get github.com/codegangsta/cli

Если вы не поленитесь сходить на гитхаб и почитать README, то найдете отличное описание работы этого пакета и примеры, которые мы можем использовать для быстрого старта. Давайте начнем писать наше приложение с определения основных команд.

Создайте файл todo.go:

package main

import (
    "fmt"
    "github.com/codegangsta/cli"
    "os"
)

func main() {
    app := cli.NewApp()
    app.Name = "todo"
    app.Usage = "add, list, and complete tasks"
    app.Commands = []cli.Command{
        {
            Name:      "add",
            Usage:     "add a task",
            Action: func(c *cli.Context) {
                fmt.Println("added task: ", c.Args().First())
            },
        },
        {
            Name:      "complete",
            Usage:     "complete a task",
            Action: func(c *cli.Context) {
                fmt.Println("completed task: ", c.Args().First())
            },
        },
    }
    app.Run(os.Args)
}

cli.NewApp() возвращает указатель на структуру App. Эта структура выступает в роли обертки над основным функционалом и различными метаданными. Есть множество атрибутов и настроек, которые можно менять, как вы можете видеть. Но нас сейчас интересуют только name,Usage, и Commands

app.Commands = []cli.Command {....} - это добавление массива типа Command (определение типа можно глянуть тут). Command это тоже структура. Name - это поле, которое определяет когда запустится анонимная функция в поле Action. Это значит, что команда:

$ godo run todo.go add "Hello World!"

выведет:

added task: Hello World!

Очевидно, пока наше приложение не очень полезно. Давайте рассмотрим, как мы можем сохранять наши задачи.

Добавление задач, хранение в JSON

Go поставляется с отличной библиотекой для работы с JSON. Мы будем использовать ее для хранения списка задач в виде JSON файла.

Пакет json предоставляет возможность конвертировать обычные Go структуры в JSON данные. Мы можем определить структуру:

type Task struct {
    Content  string
    Complete bool
}

И можем использовать метод Marhsal для конвертации структуры в JSON:

m := Task{Content: "Hello", Complete: true}
b, error := json.Marshal(m)

b - это слайс байтов, который содержит JSON текст {"Content":"Hello","Complete":true}. Вот так все просто.

Добавим код структуры Task под импортом в нашем файле todo.go. Это будет выглядеть вот так:

import (
    "fmt"
    "github.com/codegangsta/cli"
    "os"
)

type Task struct {
    Content  string
    Complete bool
}

Теперь нам нужен экземпляр структуры Task. Поля нужно заполнить данными от пользователя. Для этого изменим Action нашей add команды:

app.Commands = []cli.Command{
{
    Name:      "add",
    ShortName: "a",
    Usage:     "add a task to the list",
    Action: func(c *cli.Context) {
        task := Task{Content: c.Args().First(), Complete: false}
        fmt.Println(task)
    },
},

Если мы сейчас запустим наше приложение go run todo.go add "hello!", то увидим hello! false. Тут нужно отметить, что по умолчанию fmt.Println не выводит название полей структуры. Для этого нужно воспользоваться функцией fmt.Printf("%+v", task).

Можем сохранять нашу задачу как JSON файл. Не забудьте указать io/ioutil и encoding/json в импорте.

task := Task{Content: c.Args().First(), Complete: false}
j, err := json.Marshal(task)
if err != nil {
    panic(err)
}
ioutil.WriteFile("tasks.json", j, 0600)

При добавлении нового таска, он запишется в JSON файл, который будет создан в папке с вашей программой.

Наверняка, вы обратили внимание, что ioutil.WriteFile перезаписывает файл tasks.json. Технически, мы могли бы сначала прочитать файл, сохранить его в память, дополнить новыми данными и опять записать в tasks.json. Такой подход нормально работает когда у нас не очень много данных. Но что будет, если количество задач вырастит в разы? Если их будет 10 миллионов? И сколько это займет памяти? Конечно, это не про наш случай. Но будем писать правильно сразу. Сделаем так, чтобы строки дописывались в файл.

Для реализации этого будем открывать файл функцией os.OpenFile с указанием опции os.O_APPEND. os.OpenFile возвращает ошибку, если файла не существует. Поэтому будем также указывать опцию os.O_CREATE. Тогда, если файл не существует, то он будет создан.

Action: func(c *cli.Context) {
    task := Task{Content: c.Args().First(), Complete: false}
    j, err := json.Marshal(task)
    if err != nil {
            panic(err)
    }
    // Добавляем перенос на новую строку
    // для лучшей читабельности
    j = append(j, "\n"...)

    // Открываем tasks.json с опциями добавления, записи и
    // создания если не существует
    f, _ := os.OpenFile("tasks.json",
                os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
    // Добавляем новые данные к нашему файлу tasks.json
    if _, err = f.Write(j); err != nil {
            panic(err)
    }
},

При выполнении команды todo add "task", наша программа добавит задачу в конец файла.

Давайте сделаем наш код более структурированным и вынесем добавление задачи в отдельную функцию.

func AddTask(task Task) {
    j, err := json.Marshal(task)
    if err != nil {
        panic(err)
    }
    // Добавляем перенос на новую строку
    // для лучшей читабельности
    j = append(j, "\n"...)
    // Open tasks.json in append-mode.
    f, _ := os.OpenFile("tasks.json",
                os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
    // Append our json to tasks.json
    if _, err = f.Write(j); err != nil {
        panic(err)
    }
}

Теперь мы можем вызывать функцию AddTask(task) в нашем Action для добавления задач в файл.

Список задач

Мы уже умеем добавлять новые задачи в наш список, но будет намного удобней, если мы сможем просматривать задачи без необходимости открывать файл tasks.json вручную.

Давайте добавим новую команду которую назовем "list".

{
    Name:      "list",
    ShortName: "ls",
    Usage:     "print all uncompleted tasks in list",
    Action: func(c *cli.Context) {
        ListTasks()
    },
},

Поле ShortName используется для указания сокращенного имени команды. Теперь пользователь может набирать и "list", и просто "ls".

Для отображения всех задач нам нужно выполнить итерацию по всему файлу tasks.json.

Теперь, наиболее простое решение это загрузить все таски из файла целиком в память как слайс. Но, как уже опоминалось, наш файл с задачами может быть очень большим. Намного предпочтительней загружать в память по одной строке, делать из ней экземпляр Task и сразу отображать задачу в консоли.

Для построчного доступа к файлу мы будем использовать пакет bufio. Это позволит нам загружать только одну строку в буфер, без загрузки всего файла в память. Воспользуемся buffer.Scanner с помощью которого можно разбить файл на строки по указанному разделителю(по умолчанию это “\n”).

func ListTasks() {
    // Проверяем, существует ли файл
    if _, err := os.Stat("tasks.json"); os.IsNotExist(err) {
        log.Fatal("tasks file does not exist")
        return
    }
    file, err := os.Open("tasks.json")
    if err != nil {
        panic(err)
    }
    defer file.Close()
    scanner := bufio.NewScanner(file)
    // Наш индекс, который мы будем использовать как номер задачи
    i := 1
    // `scanner.Scan()` перемещает сканер к следующему разделителю  
    // и возвращает true. По умолчанию разделитель это перенос на новую
    //строку. Когда сканер доходит до конца файла, то возвращает false.
    for scanner.Scan() {
        // `scanner.Text()` возвращает текущий токен как строку
        j := scanner.Text()
        t := Task{}
        // Мы передаем в Unmarshall json строку конвертированную в байт слайс
        // и указатель на переменную типа `Task`. Поля этой переменной будут 
        // заполнены значениями из json.
        err := json.Unmarshal([]byte(j), &t)
        // По умолчанию мы будем показывать только 
        // не выполненные задания
        if err != nil {
            panic(err)
        }
        if !t.Complete {
            fmt.Printf("[%d] %s\n", i, t.Content)
            i++
        }
    }
}

Как указанно в комментариях к коду, каждый раз когда мы вызываем scanner.Scan() мы перемещаем сканер на следующий токен. Цикл с одним условием будет работать пока это условие возвращает true. Scan возвращает true пока сканирование не закончится и false по завершению. Цикл будет работать пока мы дочитаем файл до конца.

Можем выполнить команду go run todo.go ls для просмотра всех невыполненных задач:

$ todo ls
[1] Task 1
[2] Task two
[3] Task number 3

Выполнение задачи

Наконец, мы сделаем функциональность, чтобы можно было сделать задачу выполненной. У нас должна быть возможность выполнить команду:

todo complete #

# это номер задачи, которая должна быть выполненной. Стоит обратить внимание, что это число, которое отображает при запуске todo ls, а не реальная позиция задачи в фале tasks.json. Это потому что, когда мы выводим задачи, мы игнорируем уже выполненные задачи, наш индекс не инкрементируется.

Мы можем реализовать выполнение задачи несколькими способами.

Самый простой - это загрузить все задачи в слайс []Tasks, пройтись по нему(учитывая выполненность) до задачи с нужным индексом, отметить ее как выполненную, удалить файл и записать новый с измененными задачами. Но это не очень красивый подход. К тому же, у нас опять будут проблемы с большими файлами.

Что если мы будем относиться к задачам в файле как к обычному тексту и просто найдем поле "bool" которое false и заменим его на true? Написать такое лексер или регулярку будет не так просто. А что будет если пользователь сделает вот так todo add ""bool":true"? Вы никогда не будете на 100% уверенными, что это сработает. Кроме того, если строки будут разной длины, то файл будет поврежден. В общем, это весьма болезненный подход.

Самый безопасный способ - это читать построчно из файла с задачам, писать их в временный файл, изменить необходимую задачу, когда доберемся до нее. После этого заменить старый файл новым. Весь процесс будет выглядеть так:

  1. Читаем каждую строку в нашем файле с задачами.
  2. Используем Unmarshal для создания экземпляра Task.
  3. Если задача не выполнена, инкрементируем индекс.
    1. Проверяем, совпадает ли индекс с числом, которое указал пользователь.
    2. Если это так, устанавливаем Complete равным true.
  4. Используем Marshall для преобразования переменной и записи ее в временный файл.
  5. Как только доходим до конца файла, заменяем оригинальный файл на временный.

Как вы видите, большая часть функциональности уже реализована в нашем коде. Нам нужно только немного модифицировать его.

Запись в файл

Для записи задач в временный файл мы можем использовать функцию AddTask, которую мы написали ранее. Только нам нужно добавить еще один параметр, который будет определять в какой файл мы хотим записывать(tasks.json или .temp).

func AddTask(task Task, filename string) {

Далее, в самой AddTask() замените строку, в которой открывается файл:

f, _ := os.OpenFile("tasks.json", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)

на такую:

f, _ := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)

Теперь нужно модифицировать вызов функции, добавив название файла:

AddTask(task, "tasks.json")

Открытие файла

Так как нам нужно открывать файл tasks.json в обоих функциях ListTasks() и CompleteTasks(), то можем перенести код отвечающий за это в отдельную функцию:

func OpenTaskFile() *os.File {
    // Проверяем существование файла
    if _, err := os.Stat("tasks.json"); os.IsNotExist(err) {
        log.Fatal("tasks file does not exist")
        return nil
    }
    file, err := os.Open("tasks.json")
    if err != nil {
        panic(err)
    }
    return file
}

После модификации и добавления OpenTaskFile() функция ListTasks() будет выглядеть так:

func ListTasks() {
    file := OpenTaskFile()
    defer file.Close()
    scanner := bufio.NewScanner(file)
    i := 1
    for scanner.Scan() {
        j := scanner.Text()
        t := Task{}
        err := json.Unmarshal([]byte(j), &t)
        if err != nil {
                panic(err)
        }
        if !t.Complete {
                fmt.Printf("[%d] %s\n", i, t.Content)
                i++
        }
    }
}

Значительно красивее.

CompleteTask() принимает параметр idx. Это индекс задачи, который указывает пользователь. В программе мы можем получить его с помощью c.Args().Flag(). Но эта функция возвращает строку и нам нужно конвертировать ее в int. Для этого мы будем использовать пакет strconv:

import (
// ...
    "strconv"
// ...
)

Нам нужна функция strconv.Atoi() для конвертирования нашей строки в int. После конвертирования передаем это значение в CompleteTask():

{
    Name:  "complete",
    Usage: "complete a task",
    Action: func(c *cli.Context) {
        idx, err := strconv.Atoi(c.Args().First())
        if err != nil {
                panic(err)
        }
        CompleteTask(idx)
    },
},

Теперь можем написать код самой функции CompleteTask():

func CompleteTask(idx int) {
    file := OpenTaskFile()
    defer file.Close()
    scanner := bufio.NewScanner(file)
    i := 1
    for scanner.Scan() {
        j := scanner.Text()
        t := Task{}
        err := json.Unmarshal([]byte(j), &t)
        if err != nil {
            panic(err)
        }
        if !t.Complete {
            if idx == i {
                t.Complete = true
            }
            i++
        }
        // Добавляем текущую задачу к временному файлу.
        // Обратите внимание, когда мы вызываем эту функцию
        // первый раз, то создается файл и записывается задача.
        AddTask(t, ".tempfile")
    }
    // Когда мы записали все в .tempfile, заменяем им файл tasks.json
    os.Rename(".tempfile", "tasks.json")
    // Теперь можем удалять .tempfile
    os.Remove(".tempfile")
}

На этом наша работа закончена. Теперь мы можем добавлять, просматривать и выполнять задачи используя наш консольный такс трекер!

Вы можете обратить внимание, что цикл, который читает строки в CompleteTask() идентичен ListTasks() вплоть до if !t.Complete {. В следующем посте рассмотрим, как перенести этот код в отдельную функцию и использовать замыкания для уменьшения дублирования кода. Кроме того, вы наверняка заметили, что в отличии от демо сверху, у нас не отображается дата добавления задачи. Это тоже будет в следующем посте.

Код приложения на GitHub.

updatedupdated2021-03-062021-03-06