Вы уже наверняка слышали про Deno и, скорее всего, прочитали пару-тройку обзоров. В рамках своей статьи я предлагаю испытать Deno на практике, написав приложение для командной строки, попутно размышляя о преимуществах и возможных способах его применения.
Приложение для терминала позволит взглянуть на стандартную библиотеку, познакомиться с вводом и выводом без использования фреймворков.
Основные преимущества, такие как безопасность, TypeScript из
коробки, встроенные инструменты, обширная стандартная библиотека, а
также возможность импортировать и запускать скрипты по URL, выгодно
отличают Deno от Node и Bash. С помощью Deno мы можем написать
скрипт, залить на GitHub Gist и запускать при необходимости
командой deno run https://path.to/script.ts
, не
заботясь о зависимостях и настройке окружения.
Для начала работы нужно только установить Deno одним из предложенных способов https://deno.land/#installation.
Todo для Todo
Todo-лист должен уметь:
- добавлять, редактировать, удалять и отмечать задачи;
- выводить список задач;
- сохранять состояние;
- работать в интерактивном режиме;
- устанавливаться на компьютер.
Аргументы командной строки
Практически любая консольная утилита управляется с помощью
передаваемых при запуске аргументов. В Deno они лежат массивом в
Deno.args
. А, к примеру, в Node мы бы использовали
process.argv
, в котором помимо аргументов находятся
строки node
и путь к скрипту
.
// deno run args.ts first secondconsole.log(Deno.args) // [ "first", "second" ]
В простых случаях Deno.args
будет достаточно, а в
более сложных можно воспользоваться парсером из стандартной
библиотеки: https://deno.land/std/flags или любым другим на
выбор.
Итак, начнём
Реализуем первую команду вывод информации.
Создадим главный файл нашего приложения todo.ts
.
Лучше назвать файл осмысленно, так как в будущем он понадобится нам
для запуска и установки.
Далее создадим файл /src/ui.ts
, в котором будем
хранить элементы пользовательского интерфейса. Добавим константу
help
, содержащую информационный текст.
export const help = `todo help - to show available commands`;
В todo.ts
напишем свитч для обработки команд и
выведем справку с помощью console.log()
.
#!/usr/bin/env deno runimport { help } from "./src/ui.ts";const [command, ...args] = Deno.args;switch (command) { case "help": console.log(help);}
Проверим работу команды:
deno run todo.ts help
Для того, чтобы при каждом запуске не писать deno run, добавим
шебанг #!
.
#!/usr/bin/env deno run
Разрешим запуск файла:
chmod +x todo.ts
Теперь для вывода справки можно использовать:
./todo.ts help
Стандартный вывод
В листинге выше мы использовали console.log()
, но,
мне кажется, идеологически log это инструмент разработчика, и
реализовать с его помощью полноценный интерфейс не получится.
В Deno за стандартный вывод отвечает Deno.stdout
,
за ввод, соответственно, Deno.stdin
.
/** A handle for `stdin`. */export const stdin: Reader & ReaderSync & Closer & { rid: number };/** A handle for `stdout`. */export const stdout: Writer & WriterSync & Closer & { rid: number };
И stdin
, и stdout
типизированы
пересечением интерфейсов и объекта, содержащего rid
,
идентификатор ресурса.
Более подробно рассмотрим stdout
и выведем Hello
world! в консоль.
const textEncoder = new TextEncoder();const message: string = "Hello world!\n";const encodedMessage: Uint8Array = textEncoder.encode(message);await Deno.stdout.write(encodedMessage);
Запустим скрипт hello.ts:
deno run hello.ts
На экране появляется наш Hello world!.
export interface Writer { write(p: Uint8Array): Promise<number>;}
Функция Deno.stdout.write
принимает на вход массив
байтов и возвращает количество записанных байтов.
Deno.stdout.writeSync
работает точно так же, только
синхронно.
Строку необходимо предварительно закодировать с помощью объекта
TextEncoder
.
Можно не создавать TextEncoder
, а импортировать
функцию encode
из стандартной библиотеки, которая
делает то же самое, точно так же.
import { encode } from 'https://deno.land/std/encoding/utf8.ts';
Воспользуемся полученными знаниями и создадим файл
terminal.ts
с двумя функциями print
и
printLines
. Последняя выводит строку и переводит
каретку.
import { encode } from "https://deno.land/std/encoding/utf8.ts";export async function print(message: string) { await Deno.stdout.write(encode(message));}export async function printLines(message: string) { await print(message + "\n");}
Заменим console.log
на printLines
.
#!/usr/bin/env deno runimport { help } from "./src/ui.ts";import { printLines } from "./src/Terminal.ts";const [command, ...args] = Deno.args;switch (command) { case "help": await printLines(help);}
Чтение и запись файла
В Deno для чтения файла используется функция
Deno.readFile
и её синхронный аналог
Deno.readFileSync
.
function Deno.readFile(path: string | URL): Promise<Uint8Array>
const decoder = new TextDecoder("utf-8");const data = await Deno.readFile("hello.txt");console.log(decoder.decode(data));
Для записи в файл соответственно используются
Deno.writeFile
и Deno.writeFileSync
.
function Deno.writeFile(path: string | URL, data: Uint8Array, options?: WriteFileOptions): Promise<void>
const encoder = new TextEncoder();const data = encoder.encode("Hello world\n");await Deno.writeFile("hello1.txt", data); // overwrite "hello1.txt" or create it
В стандартной библиотеке Deno содержатся функции, позволяющие
упростить взаимодействие с файловой системой:
https://deno.land/std/fs. Например, код выше можно заменить
вызовами функций readFileStr
и
writeFileStr
.
Создадим интерфейс Task
в
src/Task.ts
export interface Task { title: string; isDone: boolean;}
и класс Todo
в файле src/Todo.ts
.
import { writeJsonSync, readJsonSync, } from "https://deno.land/std/fs/mod.ts"; import { Task } from "./Task.ts";export class Todo { tasks: Task[] = []; constructor(private file: string = "tasks.json") { this.open(); } open() { try { this.tasks = readJsonSync(this.file) as Task[]; } catch (e) { console.log(e); this.tasks = []; } } save() { writeJsonSync(this.file, this.tasks, { spaces: 2 }); }}
Как можно заметить, для чтения и записи данных я использовал специальные функции из стандартной библиотеки для работы с json-файлами.
Добавление задачи в список
Для реализации команды todo add "implement add
command"
добавляем в класс Todo
метод
add
.
add(title: string) { const task: Task = { title, isDone: false }; this.tasks.push(task); this.save();}
Так как мы добавили чтение и запись в файл, необходимо добавить
права --allow-read --allow-write
.
#!/usr/bin/env deno run --allow-read --allow-write import { help } from "./src/ui.ts";import { printLines } from "./src/terminal.ts";import { Todo } from "./src/Todo.ts";const [command, ...args] = Deno.args;const todo = new Todo();switch (command) { case "help": await printLines(help); break; case "add": todo.add(args[0]);}
Вывод списка задач
В файл src/ui.ts
добавляем функцию для
форматирования списка задач.
import { Task } from "./Task.ts";import { green, red, yellow, bold } from "https://deno.land/std/fmt/colors.ts";export function formatTaskList(tasks: Task[]): string { const title = `${bold("TODO LIST:")}`; const list = tasks.map((task, index) => { const number = yellow(index.toString()); const checkbox = task.isDone ? green("[*]") : red("[ ]"); return `${number} ${checkbox} ${task.title}`; }); const lines = [ title, ...list, ]; return lines.join("\n");}
Для задания стиля и цвета текста используем функции форматирования из https://deno.land/std/fmt/colors.ts.
Добавляем метод list
в класс Todo
и
команду ls
в свитч.
async list() { await printLines(formatTaskList(this.tasks));}
case "ls": await todo.list(); break;
Таким же образом добавляем остальные методы и команды.
done(index: number): void { const task = this.tasks[index]; if (task) { task.isDone = true; this.save(); }}undone(index: number): void { const task = this.tasks[index]; if (task) { task.isDone = false; this.save(); }}edit(index: number, title: string) { const task = this.tasks[index]; if (task) { task.title = title; this.save(); }}remove(index: number) { this.tasks.splice(index); this.save();}
case "edit": // todo edit 1 "edit second task" await todo.edit(parseInt(args[0], 10), args[1]); break;case "done" : // todo done 1 todo.done(parseInt(args[0], 10)); break;case "undone" : // todo undone 1 todo.undone(parseInt(args[0], 10)); break;case "remove" : // todo remove 1 todo.remove(parseInt(args[0], 10)); break;
Реализованные команды отвечают первым трём пунктам наших требований.
Работа в интерактивном режиме
Интерактивный режим подразумевает, что программа общается с пользователем, не закрываясь после выполнения команды.
Создадим в свитче кейс по умолчанию,
default: await todo.interactive();
а в классе Todo
асинхронный метод
interactive
.
async interactive() { while (true) { // show list // read keypress // do action }}
Метод interactive
содержит бесконечный цикл, в
котором выводится пользовательский интерфейс, ожидается ответ
пользователя и, в зависимости от нажатой клавиши, совершается
действие.
Для управления курсором в интерактивном режиме необходимо
импортировать функции из библиотеки cursor
.
Добавляем права для доступа к энвайронменту, так как библиотека
проверяет, в каком окружении программа запущена
--allow-env
.
import { clearDown, goUp, goLeft } from "https://denopkg.com/iamnathanj/cursor@v2.0.0/mod.ts";
В файле terminal.ts
создадим функцию
printInteractive
.
export async function printInteractive(message: string) { const lines = message.split("\n"); const numberOfLines = lines.length; const lengthOfLastLine = lines[numberOfLines - 1].length; await clearDown(); await print(message); await goLeft(lengthOfLastLine); if (numberOfLines > 1) { await goUp(numberOfLines - 1); }}
Она выводит текст на экран и возвращает курсор в начало. При
следующем вызове всё стирается и печатается заново. Для того чтобы
квадрат курсора не мешался, его можно скрыть функцией
hideCursor
.
Добавим в файл ui.ts
константу
toolbar
, в которой будут перечислены доступные
действия.
const toolbar = `${yellow("d")}one ${yellow("a")}dd ${yellow("e")}dit ${yellow("r")}emove`;
В функцию formatTaskList
добавляем параметр
showToolbar
:
export function formatTaskList(tasks: Task[], showToolbar: boolean): string {// ...if (showToolbar) { lines.push(toolbar);}
В зависимости от флага showToolbar
мы будем
показывать или скрывать подсказку.
Дорабатываем метод list
:
async list(interactive: boolean = false) { if (interactive) { await printInteractive(formatTaskList(this.tasks, true)); } else { await printLines(formatTaskList(this.tasks, false)); }}
Вывод реализован, перейдём к вводу.
Стандартный ввод
Интерфейс ввода, аналогично выводу работает с
Uint8Array
, поэтому для декодирования используется
TextDecoder
.
const textDecoder = new TextDecoder();const buffer: Uint8Array = new Uint8Array(1024);const n: number = <number>await Deno.stdin.read(buffer);const message: string = textDecoder.decode(buffer.subarray(0, n));
Создаём буфер размером, например, в 1024 байта. Передаём его в
функцию Deno.stdin.read
и ждём пока пользователь
закончит ввод. Как только будет нажата клавиша enter, функция
наполнит буфер и вернёт количество прочитанных байтов. Стоит
отметить, что перевод строки \n будет в конце прочитанной
последовательности. Обрезаем лишнее и декодируем полученную строку.
Синхронная функция Deno.stdin.readSync
работает
аналогично.
Обработка клавиш
Для того чтобы получить информацию о нажатии клавиш, необходимо
воспользоваться функцией Deno.setRaw
. На текущий
момент она доступна под флагом --unstable
.
setRaw
и позволяет получить символы по одному, без
обработки. В функцию необходимо передать идентификатор ресурса и
флаг. Как только мы воспользовались этим режимом, нажатие CTRL+C
перестаёт приводить к закрытию программы, и это поведение нужно
реализовать самостоятельно.
Deno.setRaw(Deno.stdin.rid, true);const length = <number> await Deno.stdin.read(buffer);Deno.setRaw(Deno.stdin.rid, false);
В файл terminal.ts
добавляем функцию
readKeypress
:
export async function readKeypress(): Promise<string> { const buffer = new Uint8Array(1024); Deno.setRaw(Deno.stdin.rid, true); const length = <number> await Deno.stdin.read(buffer); Deno.setRaw(Deno.stdin.rid, false); return decode(buffer.subarray(0, length));}
В метод interactive добавляем возможность выйти из приложения.
async interactive() { while (true) { await this.list(true); const key = await readKeypress(); if (key == "\u0003") { // ctrl-c Deno.exit(); } }}
Не отходя далеко от ввода текста, реализуем добавление задачи в интерактивном режиме.
В файл terminal.ts
добавляем 2 функции:
export async function readLine(): Promise<string> { const buffer = new Uint8Array(1024); const length = <number> await Deno.stdin.read(buffer); return decode(buffer.subarray(0, length - 1));}export async function prompt(question: string): Promise<string> { await clearDown(); await print(question); const answer = await readLine(); await goLeft(question.length + answer.length); await goUp(1); return answer;}
Функция readLine
позволяет прочитать одну строку,
исключая последний символ, перевод строки.
Функция prompt
очищает экран, печатает вопрос,
читает ввод пользователя, а затем перемещает курсор в начало,
аналогично функции printInteractive
.
В метод interactive
добавляем обработку клавиши
а:
if (key === "a") { const title = await prompt("Add task: "); if (title) { await this.add(title); }}
Перемещение по списку задач
Добавим поддержку стрелок клавиатуры. Для того чтобы понимать,
какой элемент сейчас выбран, добавляем свойство
currentIndex
в класс Todo
:
private currentIndex: number = 0;
А в функции formatTaskList
активную строку выделяем
жирным и красим в жёлтый:
export function formatTaskList( tasks: Task[], showToolbar: boolean = false, activeIndex?: number,): string { const title = `${bold("TODO LIST:")}`; const list = tasks.map((task, index) => { // ... const isActive = activeIndex === index; const title = isActive ? bold(yellow(task.title)) : task.title; return `${number} ${checkbox} ${title}`; }); // ...}
В Todo
создаём методы, которые будут изменять
currentIndex
:
up() { this.currentIndex = this.currentIndex === 0 ? this.tasks.length - 1 : this.currentIndex - 1;}down() { this.currentIndex = this.currentIndex === this.tasks.length - 1 ? 0 : this.currentIndex + 1;}
В метод interactive
добавляем обработку
стрелок:
if (key === "\u001B\u005B\u0041" || key === "\u001B\u005B\u0044") { // вверх или влево this.up();} else if (key === "\u001B\u005B\u0042" || key === "\u001B\u005B\u0043") { // вниз или вправо this.down();}
Аналогичным образом добавляем возможность отмечать выполненные задачи:
toggle(index: number = this.currentIndex) { const task = this.tasks[index]; if (task) { task.isDone = !task.isDone; this.save(); }}
if (key === "d" || key === " ") { this.toggle();} else if ('0' <= key && key <= '9') { this.toggle(parseInt(key, 10));}
Дорабатываем удаление:
remove(index: number = this.currentIndex) { if (index === this.tasks.length - 1) { this.up(); } this.tasks.splice(index, 1); this.save();}
if (key === "r") { this.remove();}
И, наконец, редактирование:
if (key === "e") { if (!this.tasks[this.currentIndex]) { return; } const title = await prompt("Edit task (" + this.tasks[this.currentIndex].title + "): "); if (title) { this.edit(this.currentIndex, title); }}
Проверяем работу приложения:
deno run --allow-read --allow-write --allow-env --unstable ./todo.ts
или
./todo.ts
Установка
Для того что бы наша программа была доступна в терминале,
воспользуемся замечательной командой deno install
.
deno install --allow-read --allow-write --allow-env --unstable ./todo.ts
Установка, так же как и запуск, может производиться по URL.
deno install --allow-read --allow-write --allow-env --unstable https://raw.githubusercontent.com/dmitriytat/todo/master/todo.ts
Имя файла будет использовано как название программы по умолчанию, но с помощью опции -n/--name можно задать другое.
После чего todo
можно использовать в командной
строке.
Импорт зависимостей
Последняя фича, которую мы использовали, но ещё не обсудили импорт зависимостей. В отличие от Node, в Deno подход к импортированию несколько иной: предполагается, что зависимости импортируются по URL. В этом подходе, на мой взгляд, есть как достоинства, так и недостатки.
При разработке однофайловых скриптов, возможность импортировать по URL является огромным плюсом, но как только мы разработаем что-то большее, уследить за версиями станет труднее.
Для управления сложностью существует два основных решения: файл
dep.ts
и файл с картой импортов.
В первом случае мы делаем реэкспорт библиотек, собирая все импорты в одном месте.
export { green, red, yellow, bold } from "https://deno.land/std@0.55.0/fmt/colors.ts";export { decode, encode } from "https://deno.land/std@v0.55.0/encoding/utf8.ts";export { clearDown, goUp, goLeft } from "https://denopkg.com/iamnathanj/cursor@v2.0.0/mod.ts";
Во втором случае в JSON файле мы пишем alias:
import_map.json
{ "imports": { "fmt/": "https://deno.land/std@0.55.0/fmt/" }}
color.ts
import { red } from "fmt/colors.ts";console.log(red("hello world"));
При запуске программы указываем путь до карты и флаг --unstable, так как фича не готова для продакшена.
deno run --importmap=import_map.json --unstable color.ts.
Резюме
В целом, работой Deno я доволен. Новый инструмент позволяет не
тратить время на настройку окружения и сразу приступать к написанию
кода, что оказалось очень удобно.
Таким образом, я могу рекомендовать Deno для написания небольших
скриптов и программ, упрощающих жизнь разработчика, но пока
воздержался бы от использования Deno для создания
продакшен-приложений.