Недавно я проходил курс Advanced Web Developer от Colt Steele на Udemy. Мы работали над тем, как создать RESTful JSON API, поэтому я решил проверить свои новые навыки, создав собственный JSON API.

Работающую производственную версию можно найти здесь. Весь код финальной версии можно найти здесь.

Соревнование

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

Например, если бы я отправил HTTP-запрос GET на маршрут, /api/weight/H2O,я ожидал бы получить обратно массу 18 (водород имеет массу 1, а кислород имеет массу 16). В окончательной версии это возвращается как JSON,{"H2O":18}.

Я также решил создать второй маршрут, который бы разбивал формулу на составные элементы и подсчитывал, сколько у нас было каждого из них. Например, запрос к api/parse/CH3CH2OH вернет следующий JSON, {"C":2,"H":6,"O":1}.

Наконец, я сделал простой внешний интерфейс, чтобы использовать API и обеспечить более удобный интерфейс.

Зависимости

Это приложение использует Экспресс-фреймворк и встроенные шаблоны Javascript. Я бы также рекомендовал Nodemon для разработки.

Организация кода

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

В корневом каталоге у меня просто есть основной файл приложения (app.js) и файл с именем Period.js, который содержит объект с массой каждого отдельного элемента.

Существует каталог /routes , который содержит все, да, как вы уже догадались, маршруты…

Чтобы сохранить каталог /routes в чистоте, я переместил все связанные функции в файлы в каталоге helpers.

Каталог /views содержит все файлы шаблонов внешнего интерфейса. Для этого проекта я использовал встроенные шаблоны JavaScript (ejs). Существует также подкаталог views/partials, содержащий верхний и нижний колонтитулы, которые используются на каждой странице внешнего интерфейса.

Наконец, каталог public содержал мой пользовательский файл CSS (в основном я использовал Bootstrap).

Корневые файлы

/app.js

const express = require('express');
const app = express();
const port = process.env.PORT;

Настройте приложение для использования платформы Express. Все будущие вызовы app используют методы из этого фреймворка. Затем я устанавливаю порт, с которого я буду позже слушать. Я разрабатывал в Cloud9, и именно так они выставляют свой порт, но в других средах это может быть по-другому.

app.set('view engine', 'ejs');

Я упомянул, что использую шаблоны ejs для структурирования своего внешнего интерфейса. Они позволяют динамически передавать им данные, но во время выполнения переменные должны быть заменены фактическими значениями. Это говорит Express, что ему нужно интерпретировать шаблоны как файлы ejs.

app.use(express.static(__dirname + '/views'));
app.use(express.static(__dirname + '/public'));

Позволяет серверу обслуживать статические файлы в каталогах /views и /public.

app.use('/', require('./routes'));

Включите все маршруты, определенные в папке маршрутов.

app.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});

Скажите приложению прослушивать указанный порт. Приложение запущено.

Как я упоминал ранее, periodic.js просто экспортирует объект, содержащий вес всех элементов, в формате {"element":weight}. Я не буду перечислять его здесь, потому что у него более 100 ключей! Но вы поняли.

Файлы маршрутов

/routes/index.js

Этот файл управляет всеми маршрутами внешнего интерфейса и требует /routes/api.js, который содержит маршруты конечных точек API.

const express = require('express');
const router = express.Router();

Класс express.Router() позволяет создавать маршруты по модульному принципу. я. е. в нескольких файлах JavaScript. Это вместо того, чтобы все маршруты были определены в app.js, что очень быстро запутало бы!

router.get('/', (req, res) => {
    res.render('index', {page: 'home'});
});
router.get('/api', (req, res) => {
    res.render('api', {page: 'api'});
});
router.get('/contact', (req, res) => {
    res.render('contact', {page: 'contact'});
});

Это три маршрута, используемые внешним интерфейсом: /, /api и /contact. Метод router.get используется для ответа на HTTP-запросы GET по пути, указанному в первом параметре.

Второй параметр — это функция, которая вызывается при запросе этого маршрута. Здесь я использую стрелочные функции, которые впервые появились в ES6. Если вы не видели их раньше, посмотрите их здесь. Обратному вызову передаются два объекта, которые содержат данные и методы запроса (req) и ответа (res). В каждом случае я вызываю метод res.render, который указывает приложению отображать конкретное представление (помните, ранее мы указали, что наш механизм представления должен использовать ejs). Первый параметр — это имя файла, который мы хотим визуализировать. В app.js мы уже указали искать представления в папке /views, и он знает, что расширение файла должно быть .ejs, поэтому мы можем их опустить. Второй параметр позволяет передавать в шаблон ejs данные, которые можно использовать для динамического изменения внешнего интерфейса. Данные передаются как объект, и в этом случае у меня есть ключ с именем page, который я меняю, чтобы он соответствовал имени маршрута. Я буду использовать это позже в моем файле header.ejs для динамического обновления панели навигации.

router.use('/api', require('./api'));
module.exports = router;

Первая строка используется для импорта маршрутов из /routes/api.js. Здесь первый параметр используется для добавления префикса /api к каждому маршруту, указанному в api.js. Мы увидим это в действии ниже.

Наконец, вторая строка экспортирует объект router как модуль Node, чтобы его можно было импортировать в основной файл app.js.

/routes/api.js

Этот файл обрабатывает все маршруты, связанные с конечными точками API. Здесь у нас есть только два, /parse и /weight, оба из которых являются запросами HTTP GET.

const express = require('express');
const router = express.Router();
const helpers = require('../helpers');
router.get('/parse/:mol', helpers.parseMol);
router.get('/weight/:mol', helpers.sumWeight);
module.exports = router;

Поскольку методы, используемые для разбора входных данных и расчета молекулярной массы, немного громоздки, я переместил их в отдельный файл, который здесь импортируется в объект helpers.

Важно отметить, что в файл /routes/index.js мы импортировали файл api.js с префиксом /api. Поэтому полные пути маршрута здесь на самом деле api/parse/:mol и /api/weight/:mol.

Но что на самом деле означает :mol? Все, что предшествует :, называется параметром маршрута. Во-первых, это означает, что эти маршруты будут соответствовать любому значению для :mol. Например, и /api/parse/XYZ123, и /api/parse/C2H5Cl являются здесь допустимыми маршрутами. Во-вторых, приложение берет эти параметры и сохраняет их как переменную, которую я могу использовать позже. В этом случае у меня будет переменная с именем mol, которая будет содержать 'XYZ123' или 'C2H5Cl' соответственно. Мы будем использовать это позже в наших вспомогательных функциях.

Файлы помощников

/helpers/index.js

const periodic = require('../periodic');

Во-первых, нам нужен объект periodic, так как мы используем эти методы helpers для расчета молекулярной массы входных данных.

const split = str => {
  const i = str.search(/\d+/);
  if(i === -1) return [str, 1];
  const ele = str.slice(0, i);
  const amount = parseInt(str.slice(i), 10);
  return [ele, amount];
};

Этот первый метод, split, ожидает строку в качестве входных данных, соответствующую одному элементу и количеству этих элементов. Например, 'C3', 'H5' и 'Cl' являются допустимыми входными данными.

Сначала он ищет в строке начальный индекс числа, состоящего из одной или нескольких цифр. Если чисел нет, предполагается, что есть только один из этих элементов, и возвращается массив, содержащий имя элемента и 1 (например, ['Cl', 1]).

В противном случае он помещает все перед числом в переменную с именем ele и помещает число в переменную с именем amount. Наконец, он возвращает массив в формате [ele, amount].

const parseMol = input => {
    const matches = input.match(/[A-Z][a-z]?\d*/g);
    if(matches === null) return {error: 'Input could not be parsed. Check input'};
    const parsed = {};
    matches.forEach(match => {
       if(!(split(match)[0] in periodic)) {
         parsed.error = `Element '${split(match)[0]}' not recognised`;
        } else {
            if(parsed[split(match)[0]]) parsed[split(match)[0]] += split(match)[1];
            else parsed[split(match)[0]] = split(match)[1];
        }
    });
  return parsed;
};

Метод parseMol принимает данные, введенные пользователем (например, 'C2H5Cl'), и преобразует их в объект JavaScript (например, {"C": 2, "H": 5, "Cl": 1}).

Он начинается с разбиения входной строки на массив путем сопоставления его с регулярным выражением /[A-Z][a-z]?\d*/g. Для этого выражения требуется заглавная буква, за которой следует необязательная строчная буква, за которой следует ноль или более цифр. Если совпадений не найдено, возвращается объект, содержащий свойство ошибки.

Если совпадения найдены, мы перебираем каждое совпадение. Сначала мы проверяем, действительно ли элемент в совпадении находится в нашей базе данных periodic. Если нет, мы добавляем свойство ошибки к объекту parsed. Обратите внимание, что здесь мы используем метод split, который мы написали выше.

В противном случае мы добавляем amount элементов в совпадении к свойству с этим именем элемента. Причина для оператора if здесь в том, что если свойство уже существует, мы добавляем новую сумму к существующей сумме; в противном случае мы создаем новое свойство. Это так, если пользователь вводит ввод 'C2C1', этот метод вернет {"C": 3} вместо его перезаписи.

К настоящему времени у нас настроен и работает API. Если вы запустите приложение и отправите запрос на один из двух маршрутов, теперь вы должны получить обратно JSON. Теперь поработаем над интерфейсом!

Просмотры файлов

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

/views/partials/header.ejs

Во-первых, я включил ссылки для импорта Bootstrap (библиотека CSS для быстрой стилизации), Font Awesome (библиотека значков для моих значков социальных сетей) и jQuery (библиотека JavaScript, упрощающая манипулирование DOM).

Интересная часть этого файла находится в навигационном меню, где мы используем ejs для динамического обновления наших тегов привязки:

<a class="nav-link <% if(page === 'home') { %> active <% } %> " href="/">Home</a>

Здесь у нас есть условный оператор, настроенный с помощью ejs, так что если переменная page равна home, мы добавляем класс active в тег привязки. Используя Bootstrap, это приводит к заполнению этой навигационной таблички. Если вы помните, мы отправляли эту переменную page в шаблон ejs в файле /routes/index.js, и мы меняли содержимое переменной в зависимости от того, какой маршрут запросил пользователь. Таким образом, мы можем динамически обновлять active навигационную панель в зависимости от того, на какой странице мы находимся!

/views/index.ejs

Одним из мотивов использования шаблонов ejs вместо простых HTML-файлов была возможность включать файлы. Это делает код чище и позволяет повторно использовать определенные элементы, например заголовок, на нескольких страницах без необходимости каждый раз переписывать его. Инклюзив в ejs выглядит так:

<% include ./partials/header %>

Последнее, на что следует обратить внимание в файле /views/index.ejs, — это различные элементы с идентификаторами, к которым мы позже сможем получить доступ с помощью jQuery:

<input type='text' name='input' id='input' class='form-control my-2' placeholder='Enter chemical formula. E.g. C2H5Cl' />
<button id='submitInput' class='btn btn-primary'>Calculate</button>
<button id='helpButton' class='btn btn-info'>Help!</button>
<p id='output' class='lead my-5'></p>
<div id='help' class='text-left' hidden>...

В частности, у нас есть пустой абзац, в который позже будет загружен результат нашего запроса JSON API.

Также обратите внимание, что атрибут div с идентификатором help скрыт. Это сделано для того, чтобы текст справки был невидим при загрузке страницы. Затем мы будем использовать jQuery для включения и выключения этого атрибута при нажатии кнопки справки.

Общедоступные файлы

/public/index.js

Этот файл содержит два метода: getWeight, который отправляет запрос GET на маршрут api/weight API, и toggle, который переключает атрибут hidden для определенного элемента.

const getWeight = input => {
    $.get(`/api/weight/${input}`)
     .then(res => {
         let str;
         if(res.error) str = res.error;
         else{
            str = 'The molecular weight of the compound, ' +
            Object.keys(res)[0] +
            ', is: ' +
            res[input] +
            ' g/mol.';
         }
         $('#output').text(str);
     })
     .catch(err => console.log(err));
};

Для начала метод jQuery get отправляет запрос GET в /api/weight с пользовательским вводом. Этот метод возвращает обещание, которое, если оно отклонено, вызывает метод catch для регистрации ошибки в консоли. Если обещание разрешено, вызывается метод then.

Помните, что маршрут /api/weight возвращает проанализированные входные данные как объект, содержащий его молекулярную массу. Если возникли проблемы, то в строку результата добавляется ошибка str. В противном случае создается строка, содержащая ключ и значение возвращаемого объекта. Наконец, содержимое строки отображается в div с идентификатором output.

const toggle = selector => {
    if($(selector).attr('hidden')) {
        $(selector).removeAttr('hidden');
    } else {
        $(selector).attr('hidden',true);
    }
};

Это простой оператор if, который проверяет, имеет ли данный селектор атрибут hidden. Если это так, то этот атрибут удаляется, если нет, то добавляется.

$(document).ready(() => {
    $('#input').keypress(event => {
        if(event.which == 13) getWeight($('#input').val());
    });
    
    $('#submitInput').click(() => {
        getWeight($('#input').val());
    });
    
    $('#helpButton').click(() => {
        toggle('#help');
    });
});

Наконец, мы настроили три обработчика событий для использования наших новых методов. Первый устанавливает обработчик keypress для ввода с идентификатором input. Если код ключа равен 13 (это клавиша ввода), то значение на входе отправляется по маршруту /api/weight с использованием getWeight.

Точно так же, если нажата кнопка с идентификатором submitInput, значение ввода снова отправляется в API.

Наконец, мы добавляем обработчик click к кнопке с идентификатором helpButton, чтобы при нажатии на нее вызывался метод toggle для элемента div с идентификатором help.

Интерфейс теперь должен быть полностью функциональным, и этот проект завершен!

Опять же, производственную версию можно найти здесь, а исходные файлы — здесь.

Спасибо за кодирование вместе! Если у вас есть какие-либо комментарии или вопросы, пожалуйста, свяжитесь со мной. Если бы я мог сделать что-то лучше, я был бы рад услышать ваши предложения.