Пишем консольное 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% уверенными, что это сработает. Кроме того, если строки будут разной длины, то файл будет поврежден. В общем, это весьма болезненный подход.
Самый безопасный способ - это читать построчно из файла с задачам, писать их в временный файл, изменить необходимую задачу, когда доберемся до нее. После этого заменить старый файл новым. Весь процесс будет выглядеть так:
- Читаем каждую строку в нашем файле с задачами.
- Используем
Unmarshal
для создания экземпляраTask
. - Если задача не выполнена, инкрементируем индекс.
- Проверяем, совпадает ли индекс с числом, которое указал пользователь.
- Если это так, устанавливаем
Complete
равным true.
- Используем
Marshall
для преобразования переменной и записи ее в временный файл. - Как только доходим до конца файла, заменяем оригинальный файл на временный.
Как вы видите, большая часть функциональности уже реализована в нашем коде. Нам нужно только немного модифицировать его.
Запись в файл
Для записи задач в временный файл мы можем использовать функцию 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 {
. В следующем посте рассмотрим, как перенести этот код в отдельную функцию и использовать замыкания для уменьшения дублирования кода. Кроме того, вы наверняка заметили, что в отличии от демо сверху, у нас не отображается дата добавления задачи. Это тоже будет в следующем посте.