В первой части этой серии мы обсудили, что такое разделение задач, как оно помогает нам поддерживать код в сопровождении, и привели пример веб-сервиса, который смешивает несколько задач в своей реализации. Мы начали рефакторинг этой службы, чтобы выделить отдельные проблемы в их собственные функции, и теперь мы продолжим эту работу, сосредоточившись на извлечении конкретных проблем HTTP.

Отказ от ответственности. Утилиты, описанные в этой статье, включены в самые популярные (и не очень) веб-фреймворки. Цель этой статьи — не изобретать велосипед, а объяснить принципы разработки инструментов, которые мы используем каждый день.

После реализации нашей функции регистрации более высокого порядка мы упростили getCourse’s реализацию. Давайте сделаем то же самое со спецификацией теста, удалив также logger dependency.

import { COURSE_NOT_FOUND, PREMIUM_RESTRICTED } from 'src/common/messages';
import getCourseFactory from 'src/api/course/getCourse'
describe('getCourse', () => {
    let userService
    let courseService
    const courseId = '1'
    // let logger -- we no longer need a logger variable
    beforeEach(() => {
        userService = { currentUser: jest.fn(() => ({ premium: false })) }
        courseService = { findById: jest.fn(() => ({ premium: false })) }
        
        // We don't need to instantiate a fake logger object
        // logger = { info: jest.fn() }
    })
    
    describe('given the course does not exist', () => {
    
        it('should return a 404 response indicating the course does not exist', () => {
            const sut = getCourseFactory(courseService, userService)
            const response = {
                status: jest.fn(() => response)
                json: jest.fn()
            }
            const request = { 
                params: jest.fn(() => courseId)
            }
            courseService.findById.mockReturnValueOnce(null)
            
            sut(request, response)
            expect(courseService.findById)
            .toHaveBeenCalledWith(courseId)
            expect(response.status).toHaveBeenCalledWith(400)
            expect(response.json).toHaveBeenCalledWith({ message: COURSE_NOT_FOUND })
        })
    })
    
    describe('given the course is premium and the user is not premium', () => {
    
        it('should return a 401 response indicating the user is not authorized to access this resource', () => {
            // implement test setup and assertions
        })
})
// rest of the test cases to complete the coverage of this function

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

Теперь давайте перейдем ко второй проблеме, HTTP. Все наши веб-сервисы должны описывать, что они хотят ответить на запрос клиента. Иногда эти ответы будут довольно простыми, т. е. код состояния и полезная нагрузка JSON:

response.status(401).json({ message: PREMIUM_RESTRICTED });

В других случаях им потребуются специальные заголовки или другой тип контента:

// A hypothetical response to the action of creating a new course
response.status(200)
    .append('Cache-Control', 'max-age=3600')
    .append('Content-Type', 'text/html')
    .append('Content-Language', 'es')
    .append('Content-Length', viewContent.length)
    .send(viewContent)

По мере того, как мы вызываем больше методов response API, наша сервисная функция становится все более связанной с выбранной нами веб-платформой. Если наш код тесно связан с фреймворком, его становится трудно заменить в вопиющей ситуации (потеря официальной поддержки, столкновение с недостатками дизайна и т. д.). Связывание противоположно модульности и, следовательно, разделению задач.

Чтобы разделить эти две вещи, мы должны отделить спецификацию ответа от задачи создания такого объекта. Подход к достижению этой цели заключается в описании ответа веб-службы с помощью простого объекта JavaScript:

import { COURSE_NOT_FOUND, PREMIUM_RESTRICTED } from 'src/common/messages';
function getCourseServiceFactory(courseService, userService) {
    return function getCourse(request) {
        const course = courseService.findById(request.params('id'));
        const currentUser = userService.getCurrentUser();
        if (!course) {
            return {
                status: 404,
                type 'json',
                body: { message: COURSE_NOT_FOUND }
            }
        }
        if (course.premium && !currentUser.premium) {
            return {
                status: 401,
                type: 'json',
                body: { message: PREMIUM_RESTRICTED }
            }
        }
        return {
            status: 200,
            type: 'json',
            body: course
        }
    }
}

В этой версии getCourse служба не знает response API, предоставляемого веб-платформой. Он основан на предположении, что что-то еще позаботится о построении правильного HTTP-ответа на основе спецификации, описанной возвращаемым объектом. Что же это за что-то еще? Это будет функция более высокого порядка, которая знает, как интерпретировать объект спецификации и действовать соответственно. Это упрощенная версия того, чем может быть эта функция:

// withLogging implementation
function withResponseBuilder(fn) {
    return (request, response) => {
        const result = fn(request, response);
        response.status(result.status)
            .append('Content-Type', getContentTypeFromSpec(result.type))
            .send(encodeResponseBody(result.body))
            
        return result
    }
}
const enhancedGetCourse = withResponseBuilder(withLogging(getCourse, 'info', (request) => `Trying to access course with id ${request.params('id')}`))

С точки зрения большого масштаба, наш объект спецификации ответа является примером предметно-ориентированного языка , а функция withResponseBuilder работает как интерпретатор первого. Интерпретатор DSL представляет собой сложную часть программного обеспечения. Язык спецификации ответа может включать в себя множество конструкций, таких как специальные заголовки, настраиваемые средства визуализации тела и т. д. Наша новейшая функция высшего порядка — это всего лишь фасад более сложного механизма. Для получения дополнительных примеров определения поведения как данных вы можете проверить проект node-machine и, более конкретно, sails machine-as-action.

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

// getCourse.spec.js
// code hidden to save space 
    
it('should return a 404 response indicating the course does not exist', () => {
    const sut = getCourseFactory(courseService, userService)
    const request = { 
        params: jest.fn(() => courseId)
    }
    courseService.findById.mockReturnValueOnce(null)
    const response = sut(request)
    expect(courseService.findById).toHaveBeenCalledWith(courseId)
    expect(response).toEqual({
        status: 400,
        body: { message: COURSE_NOT_FOUND }
    })
})

Работа, необходимая для проверки ответа getCourse, шла от имитации объекта ответа и утверждения вызовов методов по этой имитации к простой проверке, удовлетворяет ли возвращаемый объект нашим ожиданиям. Поскольку мы реализуем больше сервисов на нашей платформе, это упрощение сэкономит нам много времени.

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