[JavaScript, Node.JS, MongoDB, TypeScript] Практическое знакомство с Deno: разрабатываем REST API + MongoDB + Linux
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Всем привет. В этот раз я решил сделать нечто более интересное, чем очередной бот, поэтому далее я покажу как реализовать REST API с Deno, подключить и использовать MongoDB в качестве базы данных, и всё это запустить из под Linux.Видео версия данной заметки доступна ниже:Извините, данный ресурс не поддреживается. :( Описание задачиВ качестве примера я выбрал Github Gists API и следующие методы:
- [POST] Create a gist;
- [GET] List public gists;
- [GET] Get a gist;
- [PATCH] Update a gist;
- [DELETE] Delete a gist.
Создание проектаДля начала мы добавляем файл api/mod.ts :
console.log('hello world');
И проверяем, что всё работает командой deno run mod.ts:
mod.tsДобавление зависимостейСоздаём файл api/deps.ts и добавляем следующие зависимости:
/* REST API */
export { Application, Router } from "<https://deno.land/x/oak/mod.ts>";
export type { RouterContext } from "<https://deno.land/x/oak/mod.ts>";
export { getQuery } from "<https://deno.land/x/oak/helpers.ts>";
/* MongoDB driver */
export { MongoClient, Bson } from "<https://deno.land/x/mongo@v0.21.0/mod.ts>";
Отступление: В отличие от NodeJS, авторы Deno отказались от поддержки npm и node_modules, а необходимые библиотеки подключаются по url и кешируются локально. Сами библиотеки можно найти в разделе Third Party Modules на сайте http://deno.land.Добавление API BoilerplateДалее, добавляем код для запуска API в файл mod.ts:
import { Application, Router } from "./deps.ts";
const router = new Router();
router
.get("/", (context) => {
context.response.body = "Hello world!";
});
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 8000 });
Причём функции Application и Router импортируем уже из локального файла deps.ts.Проверим, что всё было сделано верно:
- Запускаем приложение командой deno run --allow-net mod.ts;
- Открываем в браузере http://localhost:8000;
- Получаем страницу с сообщением 'Hello world!';
Отступление: Deno позиционируется как secure by default. Другими словами, у запускаемого приложения (скрипта) не будет доступа к сети (--allow-net, файловой системе (--allow-readи --allow-write, параметрам окружения (--allow-env) пока этот доступ явно не разрешён.Добавление метода POST /gistsПришло время добавить первый метод, который будет сохранять запись в базу данных.Прежде всего опишем контракт:
- [POST] /gists
- Параметры:
- content: string | body;
- Ответы:
- 201 Created;
- 400 Bad Request;
ОбработчикДобавляем папку handlers и файл create.ts, в котором будет расположен handler (обработчик) запроса:
import { RouterContext } from "../deps.ts";
import { createGist } from "../service.ts";
export async function create(context: RouterContext) {
if (!context.request.hasBody) {
context.throw(400, "Bad Request: body is missing");
}
const body = context.request.body();
const { content } = await body.value;
if (!content) {
context.throw(400, "Bad Request: content is missing");
}
const gist = await createGist(content);
context.response.body = gist;
context.response.status = 201;
}
В этой функции мы:
- Валидируем входные значения (request.hasBody и !content);
- Вызываем функцию createGist нашего сервиса (добавим далее);
- Возвращаем добавленный объект в ответе и 201 Created.
СервисДалее, нам необходимо передать управление из обработчика в сервис (добавляем service.ts):
import { insertGist } from "./db.ts";
export async function createGist(content: string): Promise<IGist> {
const values = {
content,
created_at: new Date(),
};
const _id = await insertGist(values);
return {
_id,
...values,
};
}
interface IGist {
_id: string;
content: string;
created_at: Date;
}
В данном случае функция принимает единственный аргумент content: string и возвращает объект, структура которого описывается интерфейсом IGist.РепозиторийПоследним этапом обработки запроса является сохранение записи в MongoDB. Для этого мы добавляем файл db.ts и соответствующую функцию:
import { Collection } from "<https://deno.land/x/mongo@v0.21.0/src/collection/collection.ts>";
import { Bson, MongoClient } from "./deps.ts";
async function connect(): Promise<Collection<IGistSchema>> {
const client = new MongoClient();
await client.connect("mongodb://localhost:27017");
return client.database("gist_api").collection<IGistSchema>("gists");
}
export async function insertGist(gist: any): Promise<string> {
const collection = await connect();
return (await collection.insertOne(gist)).toString();
}
interface IGistSchema {
_id: { $oid: string };
content: string;
created_at: Date;
}
В этом файле мы:
- Импортируем необходимые типы и функции для работы с MongoDB;
- Подключаемся к базе данных gist_api в функции connect;
- Описываем формат объектов, которые хранятся в коллекции gist_api интерфейсом IGistSchema;
- Сохраняем объект методом insertOne и возвращаем его идентификатор (inserted id);
Запускаем экземпляр MongoDBДалее мы запускаем терминал, запускаем и проверяем статус нашей базы данных следующими командами:
sudo systemctl start mongod
sudo systemctl status mongod
Если всё было сделано верно, то получим следующий результат:
Отступление: Как установить MongoDB на UbuntuЗапускаем приложение
- Компилируем и запускаем приложение командой deno run --allow-net mod.ts;
- Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 201 Created и сохранённый объект с проставленным _id:
Отступление: Как вы могли заметить, в процессе разработки мы используем TypeScript без транспайлеров. Причина проста - Deno поддерживает TypeScript из коробки.Добавление метода GET /gistsСледующим методом мы сможем получить записи из базы данных, а заодно реализовать базовую пагинацию.Прежде всего опишем контракт:
- [GET] /gists
- Параметры:
- skip: string | query;
- limit: string | query;
- Ответы:
- 200 OK;
ОбработчикДобавляем файл handlers/list.ts, в котором будет расположен handler (обработчик) запроса:
import { getQuery, RouterContext } from "../deps.ts";
import { getGists } from "../service.ts";
export async function list(context: RouterContext) {
const { skip, limit } = getQuery(context);
const gists = await getGists(+skip || 0, +limit || 0);
context.response.body = gists;
context.response.status = 200;
}
В этой функции мы:
- Получаем параметры с query string с помощь функции getQuery;
- Вызываем функцию getGists нашего сервиса (добавим далее);
- Возвращаем массив найденных объектов в ответе и 200 OK;
Отступление: Функция сервиса будет принимать аргументы типа number, в то время как в обработчик к нам приходят параметры типа string. Для этого мы делаем приведение типов следующей конструкцией +skip || 0 (корректные значения конвертируются, некорректные приводятся к NaN и игнорируются в пользу 0).СервисДалее, передаём управление из обработчика в сервис:
export function getGists(skip: number, limit: number): Promise<IGist[]> {
return fetchGists(skip, limit);
}
В данном случае функция принимает аргументы skip: number и limit: number, и возвращает массив объектов, структура которых описывается интерфейсом IGist.РепозиторийПоследним этапом обработки запроса является получение записей из MongoDB. Для этого мы добавляем функцию fetchGists в файл db.ts:
export async function fetchGists(skip: number, limit: number): Promise<any> {
const collection = await connect();
return await collection.find().skip(skip).limit(limit).toArray();
}
В этой функции мы:
- Подключаемся к базе данных gist_api в функции connect;
- Получаем все записи коллекции, пропускаем skip из них и возвращаем в кол-ве limit;
Запускаем приложение
- Компилируем и запускаем приложение командой deno run --allow-net mod.ts;
- Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 200 OK и массив ранее добавленных объектов:
Добавление метода GET /gists/:idСледующим методом мы получаем запись из базы данных по её идентификатору.Прежде всего опишем контракт:
- [GET] /gists/:id
- Параметры:
- id: string | path
- Ответы:
- 200 OK;
- 400 Bad Request;
- 404 Not Found.
ОбработчикДобавляем файл handlers/get.ts, в котором будет расположен handler (обработчик) запроса:
import { RouterContext } from "../deps.ts"
import { getGist } from "../service.ts";
export async function get(context: RouterContext) {
const { id } = context.params;
if(!id) {
context.throw(400, "Bad Request: id is missing");
}
const gist = await getGist(id);
if(!gist) {
context.throw(404, "Not Found: the gist is missing");
}
context.response.body = gist;
context.response.status = 200;
}
В этой функции мы:
- Проверяем наличие id и возвращаем 400 если он отсутствует;
- Запрашиваем объект в базе данных функцией getGist и возвращаем 404 если он не найден (добавим далее);
- Возвращаем найденный объект и 200 OK;
СервисДалее, передаём управление из обработчика в сервис:
export function getGist(id: string): Promise<IGist> {
return fetchGist(id);
}
interface IGist {
_id: string;
content: string;
created_at: Date;
}
В данном случае функция принимает аргумент id: string и возвращает объект, структура которого описывается интерфейсом IGist.РепозиторийПоследним этапом обработки запроса является получение записи из MongoDB. Для этого мы добавляем функцию fetchGist в файл db.ts:
export async function fetchGist(id: string): Promise<any> {
const collection = await connect();
return await collection.findOne({ _id: new Bson.ObjectId(id) });
}
В этой функции мы:
- Подключаемся к базе данных gist_api в функции connect;
- Используем метод findOne для поиска записи удовлетворяющей фильтру по _id;
Запускаем приложение
- Компилируем и запускаем приложение командой deno run --allow-net mod.ts;
- Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 200 OK и ранее добавленный объект:
Добавление метода PATCH /gists/:idСледующим методом мы обновляем запись в базе данных по её идентификатору.Как и прежде, начинаем с контракта:
- [PATCH] /gists/:id
- Параметры:
- id: string | path
- content: string | body
- Ответы:
- 200 OK;
- 400 Bad Request;
- 404 Not Found.
ОбработчикДобавляем файл handlers/update.ts, в котором будет расположен handler (обработчик) запроса:
import { RouterContext } from "../deps.ts";
import { getGist, patchGist } from "../service.ts";
export async function update(context: RouterContext) {
const { id } = context.params;
if (!id) {
context.throw(400, "Bad Request: id is missing");
}
const body = context.request.body();
const { content } = await body.value;
if (!content) {
context.throw(400, "Bad Request: content is missing");
}
const gist = await getGist(id);
if (!gist) {
context.throw(404, "Not Found: the gist is missing");
}
await patchGist(id, content);
context.response.status = 200;
}
В этой функции мы:
- По аналогии проверяем наличие id и возвращаем 400 если он отсутствует;
- Валидируем входное значение !content;
- Запрашиваем объект в базе данных функцией getGist и возвращаем 404 если он не найден;
- Обновляем объект в базе данных функцией patchGist (добавим далее);
- Возвращаем 200 OK.
СервисДалее, передаём управление из обработчика в сервис:
export async function patchGist(id: string, content: string): Promise<any> {
return updateGist({ id, content });
}
interface IGist {
_id: string;
content: string;
created_at: Date;
}
В данном случае функция принимает аргументы id: string и content: string, и возвращает any.РепозиторийПоследним этапом обработки запроса является обновлении записи в MongoDB. Для этого мы добавляем функцию updateGist в файл db.ts:
export async function updateGist(gist: any): Promise<any> {
const collection = await connect();
const filter = { _id: new Bson.ObjectId(gist.id) };
const update = { $set: { content: gist.content } };
return await collection.updateOne(filter, update);
}
В этой функции мы:
- Подключаемся к базе данных gist_api в функции connect;
- Описываем фильтр filter объектов, которые мы хотим обновить;
- Описываем инструкцию update, которую применяем для обновления найденных объектов;
- Используем метод updateOne собрав всё воедино;
Запускаем приложение
- Компилируем и запускаем приложение командой deno run --allow-net mod.ts;
- Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 200 OK:
Добавление метода DELETE /gists/:idПоследним по списку, но не по важности, мы добавляем метод удаления записей из базы данных по идентификатору.По традиции, начинаем с контракта:
- [DELETE] /gists/:id
- Параметры:
- id: string | path
- Ответы:
- 204 No Content;
- 404 Not Found.
ОбработчикДобавляем файл handlers/remove.ts, в котором будет расположен handler (обработчик) запроса:
import { RouterContext } from "../deps.ts";
import { getGist, removeGist } from "../service.ts";
export async function remove(context: RouterContext) {
const { id } = context.params;
if (!id) {
context.throw(400, "Bad Request: id is missing");
}
const gist = await getGist(id);
if (!gist) {
context.throw(404, "Not Found: the gist is missing");
}
await removeGist(id);
context.response.status = 204;
}
В этой функции мы:
- По аналогии проверяем наличие id и возвращаем 400 если он отсутствует;
- Запрашиваем объект в базе данных функцией getGist и возвращаем 404 если он не найден;
- Удаляем объект из базы данных функцией removeGist (добавим далее);
- Возвращаем 204 No Content.
СервисДалее, передаём управление из обработчика в сервис:
export function removeGist(id: string): Promise<number> {
return deleteGist(id);
}
В данном случае функция принимает единственный аргумент id: string и возвращает number.РепозиторийПоследним этапом обработки запроса является удаление записи из коллекции MongoDB. Для этого мы добавляем функцию deleteGist в файл db.ts:
export async function deleteGist(id: string): Promise<any> {
const collection = await connect();
return await collection.deleteOne({ _id: new Bson.ObjectId(id) });
}
В этой функции мы:
- Подключаемся к базе данных gist_api в функции connect;
- Используем метод deleteOne для удаления объекта удовлетворяющего фильтру по _id;
Запускаем приложение
- Компилируем и запускаем приложение командой deno run --allow-net mod.ts;
- Открываем Postman и вызываем метод нашего API:
Если всё сделано верно, то в качестве ответа получаем 204 No Content:
Отступление: В данном случае фактическое удаление объекта из коллекции выбрано для наглядности. В реальных приложениях я предпочитаю добавить и обновлять у объекта поле isDeleted: boolean.FAQВызывая методы API я всегда получаю только 404 Not FoundУбедитесь что вы не забыли сконфигурировать router в файле mod.ts соответствующими обработчиками:
import { Application, Router } from "./deps.ts";
import { list } from "./handlers/list.ts";
import { create } from "./handlers/create.ts";
import { remove } from "./handlers/remove.ts";
import { get } from "./handlers/get.ts";
import { update } from "./handlers/update.ts";
const app = new Application();
const router = new Router();
router
.post("/gists", create)
.get("/gists", list)
.get("/gists/:id", get)
.delete("/gists/:id", remove)
.patch("/gists/:id", update);
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 8000 });
Вызывая методы API я получаю 500 Internal Server ErrorОтловить ошибку можно следующим способом:
const app = new Application();
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
console.log(err);
}
});
...
Ссылки
- Исходный код;
- GitHub Gist API аналог которой мы разрабатываем;
- Пакет для работы с API;
- Драйвер для MongoDB;
- Как установить MongoDB на Ubuntu.
ЗаключениеСпасибо за то что дочитали до конца.
В заключении упомяну, что к сожалению, не каждое из моих видео удаётся опубликовать в текстовом виде, поэтому если данные тема и формат вам интересны, то я приглашаю вас подписаться на телеграм-канал.
===========
Источник:
habr.com
===========
Похожие новости:
- [Разработка веб-сайтов, JavaScript] Поговорим об инструментах для создания клиентских веб-приложений с использованием традиционных языков программирования
- [JavaScript, Google Chrome, Тестирование веб-сервисов] Автогенерация тестов на Puppeteer встроена в Chrome DevTools
- [Настройка Linux, *nix, Оболочки] Как «приручить» консоль, или 5 шагов к жизни с командной строкой
- [Разработка веб-сайтов, JavaScript, Программирование, Алгоритмы, Читальный зал] Библиотека Frontend-разработчика, часть 4: Алгоритмы
- [Высокая производительность, Настройка Linux, Тестирование IT-систем] Бинарники BPF: BTF, CO-RE и будущее средств оценки производительности BPF (перевод)
- [Open source, *nix] FOSS News №53 – дайджест материалов о свободном и открытом ПО за 18-24 января 2021 года
- [*nix] Основы Bash-скриптинга для непрограммистов
- [Python, Разработка на Raspberry Pi, DIY или Сделай сам] Я сделаю свою «умную» колонку… «with blackjack and hookers!»
- [Настройка Linux, Графические оболочки] UbuntuDDE: замечательный гибрид
- [JavaScript, Функциональное программирование] Lens JS как менеджер состояния приложения
Теги для поиска: #_javascript, #_node.js, #_mongodb, #_typescript, #_deno, #_rest_api, #_mongodb, #_typescript, #_linux, #_ubunty, #_tutorial, #_javascript, #_node.js, #_mongodb, #_typescript
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 17:06
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Всем привет. В этот раз я решил сделать нечто более интересное, чем очередной бот, поэтому далее я покажу как реализовать REST API с Deno, подключить и использовать MongoDB в качестве базы данных, и всё это запустить из под Linux.Видео версия данной заметки доступна ниже:Извините, данный ресурс не поддреживается. :( Описание задачиВ качестве примера я выбрал Github Gists API и следующие методы:
console.log('hello world');
mod.tsДобавление зависимостейСоздаём файл api/deps.ts и добавляем следующие зависимости: /* REST API */
export { Application, Router } from "<https://deno.land/x/oak/mod.ts>"; export type { RouterContext } from "<https://deno.land/x/oak/mod.ts>"; export { getQuery } from "<https://deno.land/x/oak/helpers.ts>"; /* MongoDB driver */ export { MongoClient, Bson } from "<https://deno.land/x/mongo@v0.21.0/mod.ts>"; import { Application, Router } from "./deps.ts";
const router = new Router(); router .get("/", (context) => { context.response.body = "Hello world!"; }); const app = new Application(); app.use(router.routes()); app.use(router.allowedMethods()); await app.listen({ port: 8000 });
Отступление: Deno позиционируется как secure by default. Другими словами, у запускаемого приложения (скрипта) не будет доступа к сети (--allow-net, файловой системе (--allow-readи --allow-write, параметрам окружения (--allow-env) пока этот доступ явно не разрешён.Добавление метода POST /gistsПришло время добавить первый метод, который будет сохранять запись в базу данных.Прежде всего опишем контракт:
import { RouterContext } from "../deps.ts";
import { createGist } from "../service.ts"; export async function create(context: RouterContext) { if (!context.request.hasBody) { context.throw(400, "Bad Request: body is missing"); } const body = context.request.body(); const { content } = await body.value; if (!content) { context.throw(400, "Bad Request: content is missing"); } const gist = await createGist(content); context.response.body = gist; context.response.status = 201; }
import { insertGist } from "./db.ts";
export async function createGist(content: string): Promise<IGist> { const values = { content, created_at: new Date(), }; const _id = await insertGist(values); return { _id, ...values, }; } interface IGist { _id: string; content: string; created_at: Date; } import { Collection } from "<https://deno.land/x/mongo@v0.21.0/src/collection/collection.ts>";
import { Bson, MongoClient } from "./deps.ts"; async function connect(): Promise<Collection<IGistSchema>> { const client = new MongoClient(); await client.connect("mongodb://localhost:27017"); return client.database("gist_api").collection<IGistSchema>("gists"); } export async function insertGist(gist: any): Promise<string> { const collection = await connect(); return (await collection.insertOne(gist)).toString(); } interface IGistSchema { _id: { $oid: string }; content: string; created_at: Date; }
sudo systemctl start mongod
sudo systemctl status mongod Отступление: Как установить MongoDB на UbuntuЗапускаем приложение
Отступление: Как вы могли заметить, в процессе разработки мы используем TypeScript без транспайлеров. Причина проста - Deno поддерживает TypeScript из коробки.Добавление метода GET /gistsСледующим методом мы сможем получить записи из базы данных, а заодно реализовать базовую пагинацию.Прежде всего опишем контракт:
import { getQuery, RouterContext } from "../deps.ts";
import { getGists } from "../service.ts"; export async function list(context: RouterContext) { const { skip, limit } = getQuery(context); const gists = await getGists(+skip || 0, +limit || 0); context.response.body = gists; context.response.status = 200; }
export function getGists(skip: number, limit: number): Promise<IGist[]> {
return fetchGists(skip, limit); } export async function fetchGists(skip: number, limit: number): Promise<any> {
const collection = await connect(); return await collection.find().skip(skip).limit(limit).toArray(); }
Добавление метода GET /gists/:idСледующим методом мы получаем запись из базы данных по её идентификатору.Прежде всего опишем контракт:
import { RouterContext } from "../deps.ts"
import { getGist } from "../service.ts"; export async function get(context: RouterContext) { const { id } = context.params; if(!id) { context.throw(400, "Bad Request: id is missing"); } const gist = await getGist(id); if(!gist) { context.throw(404, "Not Found: the gist is missing"); } context.response.body = gist; context.response.status = 200; }
export function getGist(id: string): Promise<IGist> {
return fetchGist(id); } interface IGist { _id: string; content: string; created_at: Date; } export async function fetchGist(id: string): Promise<any> {
const collection = await connect(); return await collection.findOne({ _id: new Bson.ObjectId(id) }); }
Добавление метода PATCH /gists/:idСледующим методом мы обновляем запись в базе данных по её идентификатору.Как и прежде, начинаем с контракта:
import { RouterContext } from "../deps.ts";
import { getGist, patchGist } from "../service.ts"; export async function update(context: RouterContext) { const { id } = context.params; if (!id) { context.throw(400, "Bad Request: id is missing"); } const body = context.request.body(); const { content } = await body.value; if (!content) { context.throw(400, "Bad Request: content is missing"); } const gist = await getGist(id); if (!gist) { context.throw(404, "Not Found: the gist is missing"); } await patchGist(id, content); context.response.status = 200; }
export async function patchGist(id: string, content: string): Promise<any> {
return updateGist({ id, content }); } interface IGist { _id: string; content: string; created_at: Date; } export async function updateGist(gist: any): Promise<any> {
const collection = await connect(); const filter = { _id: new Bson.ObjectId(gist.id) }; const update = { $set: { content: gist.content } }; return await collection.updateOne(filter, update); }
Добавление метода DELETE /gists/:idПоследним по списку, но не по важности, мы добавляем метод удаления записей из базы данных по идентификатору.По традиции, начинаем с контракта:
import { RouterContext } from "../deps.ts";
import { getGist, removeGist } from "../service.ts"; export async function remove(context: RouterContext) { const { id } = context.params; if (!id) { context.throw(400, "Bad Request: id is missing"); } const gist = await getGist(id); if (!gist) { context.throw(404, "Not Found: the gist is missing"); } await removeGist(id); context.response.status = 204; }
export function removeGist(id: string): Promise<number> {
return deleteGist(id); } export async function deleteGist(id: string): Promise<any> {
const collection = await connect(); return await collection.deleteOne({ _id: new Bson.ObjectId(id) }); }
Отступление: В данном случае фактическое удаление объекта из коллекции выбрано для наглядности. В реальных приложениях я предпочитаю добавить и обновлять у объекта поле isDeleted: boolean.FAQВызывая методы API я всегда получаю только 404 Not FoundУбедитесь что вы не забыли сконфигурировать router в файле mod.ts соответствующими обработчиками: import { Application, Router } from "./deps.ts";
import { list } from "./handlers/list.ts"; import { create } from "./handlers/create.ts"; import { remove } from "./handlers/remove.ts"; import { get } from "./handlers/get.ts"; import { update } from "./handlers/update.ts"; const app = new Application(); const router = new Router(); router .post("/gists", create) .get("/gists", list) .get("/gists/:id", get) .delete("/gists/:id", remove) .patch("/gists/:id", update); app.use(router.routes()); app.use(router.allowedMethods()); await app.listen({ port: 8000 }); const app = new Application();
app.use(async (ctx, next) => { try { await next(); } catch (err) { console.log(err); } }); ...
В заключении упомяну, что к сожалению, не каждое из моих видео удаётся опубликовать в текстовом виде, поэтому если данные тема и формат вам интересны, то я приглашаю вас подписаться на телеграм-канал.
=========== Источник: habr.com =========== Похожие новости:
|
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 17:06
Часовой пояс: UTC + 5