Доброго времени суток.
В этой статье мы познакомимся с языком программирования TypeScript на примере создания небольшого серверного приложения (простая авторизация), используя такие популярные технологии как Node.js и Express.js. Также вкратце коснёмся рассмотрения довольно интересной embeddeb NoSQL базы данных NeDB.
Статья ориентирована прежде всего на людей уже знакомых с Node и Express и не рассматривает самые базовые вещи, относящиеся к этим технологиям. Я не буду подробно описывать и все особенности TypeScript, а код далеко не является эталонным — это всего лишь пример. Однако, если у вас есть желание попробовать TypeScript, но вы не знаете как к нему подобраться, особенно со стороны backend’a, тогда, возможно, вы найдёте что-то полезное в этой статье. Ссылка на GitHub репозиторий в конце статьи.
TypeScript
TypeScript (далее по тексту TS) — это язык программирования, являющийся надмножеством JavaScript. Начал разрабатываться компанией Microsoft и был впервые представлен в 2012 году. Автор языка — Андреас Хейлсберг, который до TS приложил руку к разработке таких языков как C# и Delphi. В самом Microsoft заявляют о TS следующим образом: «Язык программирования TypeScript является средством разработки веб-приложений, расширяющим возможности JavaScript».
Технология сама по себе довольно интересная и выгодно отличается от того же JavaScript. Главное, что выделяет TS — это строгая типизация, а также более привычная многим (java-like) реализация ООП. В TS вы можете использовать всё, что есть на данный момент в JavaScript, плюс объявления типов, модификаторы доступа, классы, интерфейсы и ещё много всего. Получается такой JavaScript с человеческим лицом. При этом компилируется TS в тот-же JavaScript. На выбор любой из стандартов: ES3, ES5 или ES6. Как и большинство новых технологий TS является open source проектом, доступным в репозитории на GitHub: https://github.com/Microsoft/TypeScript Ещё одним аргументом в пользу данного языка является тот факт, что на нём написан один из самых популярных frontend фреймворков — Angular2. И его можно использовать с другими библиотеками и фреймворками, например с тем-же React. Ну а мы попробуем разобраться насколько TS применим на сервере. И так, приступим.
Установка TypeScript
TypeScript ставится как обычный npm пакет. Желательно устанавливать глобально:
1 |
npm install -g typescript |
После установки нам будет доступен компилятор самого языка. Проверить его версию можно командой:
1 |
tsc --version |
Теперь можно попробовать создать какой-нибудь скрипт и его скомпилировать. Для наглядности, чтобы сразу было видно отличие от JavaScript, создадим простой класс:
1 2 3 4 5 6 7 8 9 10 |
class Greeting { private greet: string = 'hello!'; constructor() { console.log("Say " + this.greet); } } let myGreeting = new Greeting(); |
Сохраним файл и запустим компилятор:
1 |
tsc Greeting.ts |
Если всё прошло удачно, то рядом мы увидим скомпилированный файл Greeting.js
, а в нём примерно такой JavaScript:
1 2 3 4 5 6 7 8 9 10 |
var Greeting = (function () { functionGreeting() { this.greet='hello!'; console.log("Say "+this.greet); } returnGreeting; }()); var myGreeting = new Greeting(); |
Как видим — никакой магии.
TypeScript предоставляет возможность настраивать параметры компиляции. Для этого создадим файл tsconfig.json
со следующим содержимым:
1 2 3 4 5 6 7 |
{ "compilerOptions": { "target": "ES6", "removeComments": true, "sourceMap": false } } |
Мы указали самые базовые параметры. Теперь исходные файлы будут компилироваться в JavaScript версии ES6 (последняя нода его замечательно поддерживает). Более подробно о настройке компиляции можно прочитать в официальной документации.
Что с поддержкой в редакторах и IDE?
Все современные редакторы в достаточной степени поддерживают TypeScript. Лично я рекомендую Visual Studio Code от Microsoft. У него поддержка одна из лучших. Причём из коробки, что и не удивительно, т.к. всё это продукты одной компании.
Пример приложения
Теперь перейдём к созданию нашего простого примера, а заодно посмотрим как использовать TypeScript в связке с другими технологиями, которые написаны на обычном JavaScript. Наше приложение будет представлять из себя простейший API для аутентификации пользователей. Сосредоточимся исключительно на backend части. Прежде чем начнём, хотелось бы уточнить, что API не будет являться RESTful (мы будем использовать сессии) и больше подойдёт для обычный Ajax запросов. Однако саму структуру сильно упрощать не будем. Представим, что в будущем мы захотим расширить приложение.
Собственно, базовая структура проекта будет следующая:
Для простоты понимания было решено сделать структуру по типу всем известного MVC паттерна, только, как уже упоминалось выше, без использования слоя представлений.
Определение зависимостей и настройка компиляции
Добавим файл package.json
со следующим содержимым:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
{ "name": "ts_node--example", "version": "1.0.0", "description": "Example nodejs-typescript application", "main": "index.ts", "scripts": { "start": "tsc --watch", "built:watch": "nodemon ./bin/index.js" }, "author": "ts_user", "license": "MIT", "dependencies": { "body-parser": "^1.17.2", "express": "^4.15.3", "express-session": "^1.15.3", "nedb": "^1.8.0" }, "devDependencies": { "nodemon": "^1.11.0" } } |
Установим зависимости выполнив команду: npm install
Зависимостей у нас по минимуму: Express.js, модули для обработки запросов и работы с сессиями, а также библиотека NeDB для работы с БД. Для автоматизации процесса сборки и перезапуска используем команды, объявленные в блоке scripts
:
npm start
— будет запускать компилятор TypeScript c флагом --watch
для использования кросскомпиляции.
npm run built:watch
— запустит уже скомпилированный сервер через утилиту nodemon,
которая будет производить перезапуск приложения при каждом изменении JS файлов.
Теперь настроим компиляцию. Создадим новый файл tsconfig.json
, в котором укажем следующие параметры:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ "compilerOptions": { "module": "commonjs", "target": "ES6", "noImplicitAny": true, "removeComments": true, "outDir": "bin/", "sourceMap": false }, "include": [ "src/**/*" ] } |
Собственно, здесь мы указываем, что хотим использовать commonjs загрузку модулей (imports будут конвертированы в require). А также, помимо прочего, директорию, где лежат исходные TypeScript файлы (src
), и куда помещать скомпилированные JavaScript файлы (bin
). Генерация sourceMap нам пока не нужна, поэтому мы её отключаем. А вот на опции noImplicitAny
хотелось бы остановиться подробнее. Одна из особенностей TypeScript — это так называемая точечная типизация и использование внутреннего типа данных any
. Суть в том, что мы не обязаны везде объявлять типы данных. А только в тех местах где это имеет смысл. Однако там, где мы не указали тип, компилятор TypeScript будет пытаться этот тип вывести. Ели у него это не получается сделать явно, то, если опция noImplicitAny
выставлена в false (по умолчанию) неявно, будет назначен тип any
, иначе каждый раз при компиляции будет производиться проверка на указание типа данных any
, и если мы его не укажем явно, то получим ошибку компиляции.
Благодаря наличию таких опций, TypeScript уже из коробки поддерживает линтинг кода. Кроме того, существует отдельное расширение — tslint, которое позволяет ещё более гибко настроить различного рода проверки как в процессе компиляции, так и на лету.
Типизация сторонних библиотек
Если с типизацией наших исходных кодов на TypeScript всё более-менее ясно, то как нам использовать типы в сторонних библиотеках, написанных на JavaScript? К счастью, community давно решило этот вопрос создав вот этот замечательный репозиторий: https://github.com/DefinitelyTyped/DefinitelyTyped
В этом репозитории собраны и описаны типы для самых популярных библиотек и фреймворков, которые можно подключить к проектам на TypeScript. С помощью чего, собственно, происходит описание и добавление внешней библиотеки? Для решения этой задачи у TypeScript существуют так называемые декларативные файлы, имеющие расширение *.d.ts.
Данные файлы служат для декларации (описания без реализации) различных компонентов внешней подключаемой библиотеки. Такими компонентами могут быть интерфейсы, переменные, константы, функции и т.д., которые описаны уже с использованием типов TypeScript. Например, так выглядит файл деклараций для Express.js: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express/index.d.ts Естественно, мы можем сами создать .d.ts
файл для какой-нибудь библиотеки, описать компоненты и подключить её в свой проект. Вдогонку ко всему вышесказанному хочу порекомендовать ресурс для быстрого поиска по доступным декларациям различных библиотек и фреймворков: http://microsoft.github.io/TypeSearch/
Теперь мы можем добавить типизацию для наших зависимостей. Так как проверка типов нам нужна только в процессе разработки, то устанавливаем типы как devDependencies:
1 |
npm install --save-dev @types/node @types/express @types/express-session @types/body-parser @types/nedb |
По умолчанию TypeScript будет искать определения типов в директории node_modules/@types.
Разумеется, это можно настроить в tsconfig.json, через параметр typeRoots
.
С настройкой вроде бы разобрались. Теперь перейдём к самому приложению. Создадим главный класс:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
/** * App * Главный класс приложения */ import * as express from 'express'; import { Express, Request, Response, NextFunction } from 'express'; import * as bodyParser from 'body-parser'; import * as session from 'express-session'; import IApplicationConfig from './core/IApplicationConfig'; import AppRoutes from './routes/AppRoutes'; import AppDataProviders from './providers/AppDataProviders'; export default class App { /** * App instance */ private static app: App; /** * Express instance */ private expApp: Express; /** * Провайдеры данных */ private dataProviders: AppDataProviders; public static getInstance(): App { return App.app; } constructor(private config: IApplicationConfig) { if (App.app instanceof App) { throw new Error('Нельзя создать более одного экземпляра приложения'); } this.config = config; this.expApp = express(); App.app = this; } /** * Инициализация и запуск приложения */ run(): void { // Подключаем использование сессий this.expApp.use(session({ resave: false, saveUninitialized: false, secret: 'chuck norris', cookie: {maxAge: 3600000} })); // Подключаем возможность обрабатывать запросы через // формы: application/x-www-form-urlencoded this.expApp.use(bodyParser.urlencoded({extended: false})); this.expApp.use((req: Request, res: Response, next: NextFunction) => { res.contentType('application/json'); next(); }); // Подключаем провайдеры this.dataProviders = new AppDataProviders(); // Подключаем маршрутизацию let appRouter = new AppRoutes(); appRouter.mount(this.expApp); // Запускаем сервер this.expApp.listen(this.config.listenPort, (err) => { if (err !== undefined) { console.log(err); } else { console.log("Server run on port: " + this.config.listenPort); } }); } /** * Возвращает провайдеры данных */ get providers(): AppDataProviders { return this.dataProviders; } } |
Главный класс реализован как singleton. Здесь мы подключаем провайдеры и маршрутизацию, создаём экземпляр Express и запускаем приложение. Обратите внимание на то, как импортируются модули, а именно Express. Мы импортируем все компоненты фреймворка в виде функции express
, а для типизации этих компонентов только необходимые интерфейсы. Вообще, интерфейсы довольно мощная штука. Например, конфигурация приложения вынесена в отдельный типизированный js объект, описанный через интерфейс IApplicationConfig
. Ещё одной интересной особенностью TypeScript, которую мы использовали, является возможность определить поле класса для передаваемого аргумента непосредственно в сигнатуре конструктора (собственно, так мы конфигурацию и подключаем).
Маршрутизация и контроллеры
Далее нам нужно добавить маршрутизацию и контроллеры. Начнём с интерфейсов маршрутизаторов. Как вы уже могли обратить внимание, интерфейс конфигурации приложения мы подключали из директории core
. Добавим туда же два интерфейса, которые описывают маршрутизацию.
IApplicationRoute:
1 2 3 4 5 6 7 8 9 10 11 |
/** * Интерфейс IApplicationRoute * Описывает структуру маршрутизатора */ import { Router } from 'express'; interface IApplicationRoute { createRouter(router): Router; } export default IApplicationRoute; |
IPathRoute:
1 2 3 4 5 6 7 8 9 10 11 12 |
/** * Интерфейс IPathRoute * Описывает структуру объекта подключения маршрута */ import IApplicationRoute from './IApplicationRoute'; interface IPathRoute { path: string, router: IApplicationRoute } export default IPathRoute; |
Это всего лишь демонстрация использования интерфейсов в TypeScript. С помощью первого описан непосредственно сам объект, который будет создавать и возвращать маршрут. С помощью второго — объект через который будет связан маршрутизатор и корневая точка входа. Подключение корневых маршрутов реализовано в классе AppRoutes.ts
.
Далее создадим маршрутизатор пользователя:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
/** * UserRoute * Маршрутизатор пользователя */ import { Request, Response } from 'express'; import App from '../App'; import IApplicationRoute from '../core/IApplicationRoute'; import AuthController from '../controllers/AuthController'; import UserController from '../controllers/UserController'; const UserRoute: IApplicationRoute = { /** * Создание маршрута */ createRouter(router) { let app = App.getInstance(); // Контроллеры в качестве middleware let AuthCtrl = new AuthController(app); let UserCtrl = new UserController(app); return router() .use(AuthCtrl.checkSession) .get('/', (req: Request, res: Response) => { UserCtrl.findAll((err: any, data: any) => { res.send({users: data}); }); }) .post('/add', (req: Request, res: Response) => { if (!req.body) { res.send({msg:"Empty body request", code: 400}); } else { UserCtrl.create(req.body, (newData) => { res.send({userCreated: newData}); }, (msg, code) => { res.send({message: msg, code: code}); }); } }) .post('/login', (req: Request, res: Response) => { if (!req.body) { res.send({msg:"Empty body request", code: 400}); } else { AuthCtrl.login(req, res); } }) .get('/logout', AuthCtrl.logout); } }; export default UserRoute; |
Как мы видим, сам по себе маршрутизатор не является классом — это обычный объект, но типизированный с помощью интерфейса. Такие конструкции бывает довольно удобно использовать и здесь это показано исключительно для наглядности. Можете обратить внимание что в обработчиках маршрутов в качестве middleware мы используем методы отдельных контроллеров. Вот так у нас будет выглядеть AuthController
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
/** * AuthController * Контроллер аутентификации */ import { Request, Response, NextFunction } from 'express'; import App from '../App'; import User from '../models/User'; import UserDataProvider from '../providers/UserDataProvider'; import SecurityHelper from '../helpers/SecurityHelper'; export default class AuthController { /** * UserDataProvider */ private userProvider: UserDataProvider; constructor(private app: App) { this.userProvider = this.app.providers.user; } /** * Аутентификация пользователя */ login(req: Request, res: Response) { let email = req.body.email; let pswd = req.body.password; this.userProvider.findOne({email: email}, (err, user) => { if (err) { res.sendStatus(500); } else { if (!user || !SecurityHelper.validatePassword(pswd, user.password)) { res.send({msg:'Неверный email или пароль', code: 400}); } else { user.lastVisit = Date.now().toString(); this.userProvider.update({_id: user._id}, user, (err, numReplaced) => { console.log(`User ${user.email} lastVisit: ${user.lastVisit}`); }); req.session.userId = user._id; res.send({msg:'Welcome'}); } } }); } /** * Завершение пользовательской сессии */ logout(req: Request, res: Response) { let session = req.session; if (!session) { res.sendStatus(400); } else { session.destroy((err) => { res.send({msg:'Logout success'}); }); } } /** * Проверка пользовательской сессии */ checkSession(req: Request, res: Response, next: NextFunction) { let session = req.session; if (~['/login', '/add'].indexOf(req.path)) { if (!session.userId) { next(); } else { // Not acceptable res.sendStatus(406); } } else { if (session.userId) { next(); } else { // Unauthorized res.sendStatus(401); } } } } |
Модели и провайдеры данных
Теперь перейдём к вопросу хранения и представления данных пользователей. Начнём с провайдера данных. Создадим базовый абстрактный провайдер данных:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
/** * DataProvider * Базовый провайдер данных * Данный провайдер использует NoSQL-embedded решение "NeDB" * https://github.com/louischatriot/nedb */ import * as nedb from 'nedb'; import * as path from 'path'; export default abstract class DataProvider { /** * Путь до директории хранилища БД */ static readonly ROOT_STORE = path.normalize(__dirname + '/../../db/'); /** * Datastore */ protected store: nedb; constructor(storeName = 'data') { this.store = new nedb({ filename: DataProvider.ROOT_STORE + storeName + '.db' }); this.store.loadDatabase((err) => { this.onLoadStore(err); }); } /** * Очищает хранилище * Подробнее: https://github.com/louischatriot/nedb#persistence */ protected vacuumStore(): void { if (this.store instanceof nedb) { this.store.persistence.compactDatafile(); } } /** * Обработчик загрузки хранилища */ protected abstract onLoadStore(err: any): void } |
TypeScript позволяет использовать абстрактные классы, что собственно мы и сделали в провайдере данных. Что такое абстрактный класс, я думаю, вы прекрасно знаете и без меня. А вот о хранилище данных NeDB
, которое мы используем, хотелось бы рассказать подробнее.
NeDB
— это встраиваемая NoSQL база данных, написанная на JavaScript с похожим на MongoDB API. БД не имеет бинарных зависимостей. Может быть использована как на клиенте, так и на сервере, а также на платформах NW.js и Electron. Устанавливается как обычный npm пакет или через bower, а данные может хранить в оперативной памяти. При хранении на диске используется JSON. Ещё одной интересной особенностью является достаточно быстрая скорость работы, для embedded базы. Привожу данные из официальной документации:
- Insert: 10,680 ops/s
- Find: 43,290 ops/s
- Update: 8,000 ops/s
- Remove: 11,750 ops/s
Иcпользует довольно простой и понятный синтаксис для работы с данными. Вот, например, реализация провайдера пользователя, в котором каждый из методов является обёрткой над методом экземпляра хранилища NeDB:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
/** * UserDataProvider * Провайдер данных пользователя */ import DataProvider from './DataProvider'; import User from '../models/User'; export default class UserDataProvider extends DataProvider { constructor() { super('User'); } /** * Загрузка массива данных по условию */ select(where: any, onSelect: (err: any, users: User[]) => void) { this.store.find(where, onSelect); } /** * Создание нового пользователя */ create(data: User, onCreate: (err: any, newData: User) => void) { this.store.insert(data, onCreate); } /** * Обновление данных пользователя */ update(where: any, newData: User, onUpdate?: (err: any, numReplaced: number) => void) { this.store.update(where, {$set: newData}, onUpdate); } /** * Удаление записей по условию */ delete(where: any, onDelete?: (err: any, numRemoved: number) => void) { this.store.remove(where, {multi: true}, onDelete); } /** * Загрузка экземпляра пользователя */ findOne(where: any, onSelect: (err: any, user: User) => void) { this.store.findOne(where, onSelect); } /** * @inheritdoc */ protected onLoadStore(err: any) { if (err !== null) { console.error(err); } } } |
Механизм наследования TypeScript требует явного вызова метода super() в наследниках. Поэтому при создании экземпляра класса мы передаём наименование хранилища (фактически имя файла) в родительский конструктор: super('User')
. Если хранилище с данными на диске не будет обнаружено, то создастся новый файл. Так как провайдер унаследован от абстрактного класса, мы реализовали метод onLoadStore, в котором обрабатываем возможную ошибку при загрузке хранилища. Вы также можете обратить внимание на то, как объявлены callback функции в сигнатурах методов. Такой способ объявления позволяет нам типизировать любую callback функцию, при этом никто не запрещает вынести её определение в отдельный интерфейс. Для создания и хранения провайдеров реализован класс AppDataProviders
, экземпляр которого создаётся один раз при старте приложения и доступен глобально через геттер главного класса (см. UserController). Таким образом мы сможем и дальше расширять слой для работы с данными используя провайдеры-обёртки над NeDB.
Смотрим, что получилось
Для запуска приложения нам понадобится отдельный стартовый скрипт index.ts
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/** * Bootstrap скрипт */ import App from './App'; const APP_CONFIG = { listenPort: 8080, appName: 'Example Application' }; try { (new App(APP_CONFIG)).run(); } catch (e) { console.error(e.message); } |
Теперь попробуем скомпилировать это всё и запустить. Сначала выполним команду: npm start
. Если ошибок нет, запустим в той же директории ещё один сеанс консоли и выполним npm run built:watch
. Результат должен быть таким:
После компиляции директория с проектом будет выглядеть следующим образом:
В директории bin
находится наше скомпилированное приложение, а в директории db
появился файл хранилища. Попробуем проверить работоспособность. Для тестирования запросов я буду использовать приложение POSTMAN.
Убедимся, что сервер запущен и попробуем перейти по адресу выполнив GET запрос: http://localhost:8080/user
В ответе видим 401 статус. Значит всё ok, ведь мы не зарегистрировались. Пробуем регистрироваться, но уже через POST запрос: http://localhost:8080/user/add
В ответе на регистрацию получаем объект нового пользователя.
Теперь можно пройти аутентификацию. Выполняем POST запрос: http://localhost:8080/user/login
:
Вроде бы всё работает) Теперь можно запросить список пользователей выполнив GET запрос: http://localhost:8080/user
Заключение
Нашей целью было понять, можно ли использовать TypeScript при разработке серверных приложений, и насколько это удобно. Даже на таком небольшом примере мы убедились, что TypeScript уже вполне зрелая технология, позволяющая использовать существующий инструментарий платформы Node.js без особых проблем. Язык активно развивается и поддерживается сообществом. В нём появляются новые фичи, которые по-настоящему облегчают разработку. Те особенности, которые были рассмотрены в этой статье, являются лишь малой частью того, что предоставляет нам TypeScript. Поэтому в будущем хотелось бы вернуться к этой теме. Надеюсь, и вы нашли что-то полезное для себя. Спасибо за внимание)
Ссылка на репозиторий с исходниками: https://github.com/GusNitrous/TypeScriptBackend
2 комментария
Thanks for sharing your thoughts about typescript.
Regards
Я позволяю себе вежливо усомниться в весомости аргументов в пользу оного TypeScript, из которых я до сих пор слышал только дифирамбы Майкрософту и титанам мысли, решившим уже за нас, сирых и убогих, ну просто все-все-все проблемы, только нужно расписаться вот здесь. Да, кровью, а вы не в курсе что ли?