Конфигурационные объекты используются практически в любом приложении:
Ошибки в конфигурации особенно опасны, поскольку часто проявляются не сразу, а в процессе выполнения программы. Библиотека Vest позволяет строить декларативные, масштабируемые и переиспользуемые схемы проверки конфигурационных объектов.
Валидация конфигураций в Vest отличается несколькими важными особенностями:
Простейшая схема валидации конфигурации:
import { create, test, enforce } from 'vest';
const configSuite = create((config = {}) => {
test('host', 'Host обязателен', () => {
enforce(config.host).isNotBlank();
});
test('port', 'Port должен быть числом', () => {
enforce(config.port).isNumber();
});
test('secure', 'Secure должен быть boolean', () => {
enforce(config.secure).isBoolean();
});
});
Проверка:
const result = configSuite({
host: 'localhost',
port: 3000,
secure: true
});
console.log(result.hasErrors());
Большинство конфигураций содержат обязательные поля.
test('apiKey', 'API key обязателен', () => {
enforce(config.apiKey).isNotBlank();
});
test('timeout', 'Timeout обязателен', () => {
enforce(config.timeout).isNumber();
});
test('database', 'Database config обязателен', () => {
enforce(config.database).isObject();
});
Конфигурации часто содержат ограничения.
test('port', 'Недопустимый порт', () => {
enforce(config.port)
.greaterThan(0)
.lessThanOrEquals(65535);
});
test('timeout', 'Timeout вне диапазона', () => {
enforce(config.timeout)
.greaterThanOrEquals(100)
.lessThanOrEquals(30000);
});
Многие конфигурации используют фиксированные наборы значений.
const environments = [
'development',
'testing',
'staging',
'production'
];
test('env', 'Некорректное окружение', () => {
enforce(environments.includes(config.env)).isTruthy();
});
const levels = ['debug', 'info', 'warn', 'error'];
test('logLevel', 'Недопустимый log level', () => {
enforce(levels.includes(config.logLevel)).isTruthy();
});
Конфигурации редко бывают плоскими.
const config = {
server: {
host: 'localhost',
port: 3000
},
database: {
url: 'mongodb://localhost',
poolSize: 10
}
};
const configSuite = create((config = {}) => {
test('server.host', 'Host обязателен', () => {
enforce(config.server?.host).isNotBlank();
});
test('server.port', 'Некорректный port', () => {
enforce(config.server?.port)
.isNumber()
.greaterThan(0);
});
test('database.url', 'Database URL обязателен', () => {
enforce(config.database?.url).isNotBlank();
});
});
Часто конфигурация содержит списки.
const config = {
servers: [
{ host: 'localhost', port: 3000 },
{ host: 'api.local', port: 4000 }
]
};
test('servers', 'Servers должен быть массивом', () => {
enforce(config.servers).isArray();
});
config.servers?.forEach((server, index) => {
test(
`servers[${index}].host`,
'Host обязателен',
() => {
enforce(server.host).isNotBlank();
}
);
test(
`servers[${index}].port`,
'Некорректный port',
() => {
enforce(server.port)
.isNumber()
.greaterThan(0);
}
);
});
Некоторые поля зависят друг от друга.
const configSuite = create((config = {}) => {
test('ssl.enabled', 'SSL enabled должен быть boolean', () => {
enforce(config.ssl?.enabled).isBoolean();
});
if (config.ssl?.enabled) {
test('ssl.cert', 'SSL certificate обязателен', () => {
enforce(config.ssl?.cert).isNotBlank();
});
test('ssl.key', 'SSL key обязателен', () => {
enforce(config.ssl?.key).isNotBlank();
});
}
});
Vest хорошо подходит для сложных зависимостей.
const configSuite = create((config = {}) => {
if (config.auth?.type === 'jwt') {
test('auth.secret', 'JWT secret обязателен', () => {
enforce(config.auth.secret).isNotBlank();
});
}
if (config.auth?.type === 'oauth') {
test('auth.clientId', 'Client ID обязателен', () => {
enforce(config.auth.clientId).isNotBlank();
});
test('auth.clientSecret', 'Client Secret обязателен', () => {
enforce(config.auth.clientSecret).isNotBlank();
});
}
});
Крупные конфигурации удобно разбивать на блоки.
const databaseSuite = create((db = {}) => {
test('url', 'Database URL обязателен', () => {
enforce(db.url).isNotBlank();
});
test('poolSize', 'Некорректный pool size', () => {
enforce(db.poolSize)
.greaterThan(0)
.lessThanOrEquals(100);
});
});
const serverSuite = cre ate (( server = {}) => {
test('host', 'Host обязателен', () => {
enforce(server.host).isNotBlank();
});
test('port', 'Port обязателен', () => {
enforce(server.port).isNumber();
});
});
const appSuite = create((config = {}) => {
databaseSuite(config.database);
serverSuite(config.server);
});
Vest поддерживает selective validation.
const configSuite = create((config = {}, changedField) => {
only(changedField);
test('host', 'Host обязателен', () => {
enforce(config.host).isNotBlank();
});
test('port', 'Port обязателен', () => {
enforce(config.port).isNumber();
});
});
Использование:
configSuite(config, 'host');
Некоторые проверки можно пропускать.
import { skipWhen } from 'vest';
const configSuite = create((config = {}) => {
skipWhen(config.env === 'development', () => {
test('monitoring.url', 'Monitoring URL обязателен', () => {
enforce(config.monitoring?.url).isNotBlank();
});
});
});
Иногда конфигурацию необходимо проверять через внешние сервисы.
const configSuite = create((config = {}) => {
test(
'api.url',
'API endpoint недоступен',
async () => {
const response = await fetch(config.api.url);
enforce(response.ok).isTruthy();
}
);
});
test('services', 'Имена сервисов должны быть уникальны', () => {
const names = config.services.map(service => service.name);
const unique = new Set(names);
enforce(unique.size).equals(names.length);
});
Vest особенно полезен при проверке env-переменных.
const envSuite = create((env = process.env) => {
test('NODE_ENV', 'NODE_ENV обязателен', () => {
enforce(env.NODE_ENV).isNotBlank();
});
test('PORT', 'PORT должен быть числом', () => {
enforce(Number(env.PORT)).isNumber();
});
test('DATABASE_URL', 'DATABASE_URL обязателен', () => {
enforce(env.DATABASE_URL).isNotBlank();
});
});
Часто конфигурации требуют предварительной обработки.
function normalizeConfig(rawConfig) {
return {
...rawConfig,
port: Number(rawConfig.port),
secure: rawConfig.secure === 'true'
};
}
const normalized = normalizeConfig(rawConfig);
configSuite(normalized);
Крупные проекты требуют унификации.
function validateUrl(value, field) {
test(field, `${field} должен быть URL`, () => {
enforce(value).matches(/^https?:\/\//);
});
}
Использование:
validateUrl(config.apiUrl, 'apiUrl');
const featureSuite = create((features = {}) => {
Object.entries(features).forEach(([key, value]) => {
test(key, `${key} должен быть boolean`, () => {
enforce(value).isBoolean();
});
});
});
Некоторые параметры могут конфликтовать.
test(
'cache',
'Redis и memory cache нельзя использовать одновременно',
() => {
const invalid =
config.redis?.enabled &&
config.memoryCache?.enabled;
enforce(invalid).isFalsy();
}
);
Vest позволяет строить правила динамически.
const fields = [
'host',
'port',
'username',
'password'
];
const configSuite = create((config = {}) => {
fields.forEach(field => {
test(field, `${field} обязателен`, () => {
enforce(config[field]).isNotBlank();
});
});
});
const config = {
microservices: {
auth: {
retries: 3,
timeout: 5000
},
payments: {
retries: 5,
timeout: 10000
}
}
};
Object.entries(config.microservices).forEach(
([name, service]) => {
test(
`${name}.retries`,
'Retries должен быть положительным',
() => {
enforce(service.retries)
.isNumber()
.greaterThan(0);
}
);
test(
`${name}.timeout`,
'Timeout должен быть положительным',
() => {
enforce(service.timeout)
.isNumber()
.greaterThan(0);
}
);
}
);
const result = configSuite(config);
console.log(result.getErrors());
Пример результата:
{
host: ['Host обязателен'],
port: ['Port должен быть числом']
}
const result = configSuite(config);
const errors = Object.entries(result.getErrors())
.map(([field, messages]) => ({
field,
messages
}));
console.log(errors);
Некоторые настройки должны генерировать предупреждения, а не ошибки.
import { warn } from 'vest';
const configSuite = create((config = {}) => {
warn('timeout', 'Слишком большой timeout', () => {
enforce(config.timeout)
.lessThanOrEquals(30000);
});
});
const microserviceSuite = create((config = {}) => {
test('name', 'Service name обязателен', () => {
enforce(config.name).isNotBlank();
});
test('host', 'Host обязателен', () => {
enforce(config.host).isNotBlank();
});
test('port', 'Некорректный port', () => {
enforce(config.port)
.isNumber()
.greaterThan(0)
.lessThanOrEquals(65535);
});
test('healthcheck.path', 'Healthcheck path обязателен', () => {
enforce(config.healthcheck?.path)
.isNotBlank();
});
if (config.auth?.enabled) {
test('auth.token', 'Auth token обязателен', () => {
enforce(config.auth.token).isNotBlank();
});
}
if (config.rateLimit?.enabled) {
test('rateLimit.maxRequests', 'Max requests обязателен', () => {
enforce(config.rateLimit.maxRequests)
.greaterThan(0);
});
}
});
config/
├── validation/
│ ├── databaseSuite.js
│ ├── serverSuite.js
│ ├── authSuite.js
│ ├── cacheSuite.js
│ └── index.js
import { create } from 'vest';
import { databaseSuite } from './databaseSuite';
import { serverSuite } from './serverSuite';
import { authSuite } from './authSuite';
export const configSuite = create((config = {}) => {
databaseSuite(config.database);
serverSuite(config.server);
authSuite(config.auth);
});
Каждый раздел конфигурации должен валидироваться отдельно:
Повторяющиеся проверки лучше выносить:
function validatePositiveNumber(value, field) {
test(field, `${field} должен быть положительным`, () => {
enforce(value)
.isNumber()
.greaterThan(0);
});
}
Неправильно:
enforce(config.port > 1000).isTruthy();
Правильно:
enforce(config.port).isNumber();
enforce(config.port).greaterThan(1000);
Асинхронные проверки:
Их обычно используют только для действительно критичных проверок.
Хорошая практика — хранить сообщения отдельно:
export const messages = {
requiredHost: 'Host обязателен',
invalidPort: 'Некорректный port'
};
Использование:
test('host', messages.requiredHost, () => {
enforce(config.host).isNotBlank();
});
Плохо:
enforce(config.poolSize).lessThan(200);
Лучше:
enforce(config.poolSize)
.greaterThanOrEquals(1)
.lessThanOrEquals(100);
Часто конфигурация проверяется при старте приложения.
const result = configSuite(appConfig);
if (result.hasErrors()) {
console.error(result.getErrors());
process.exit(1);
}
Vest можно использовать в автоматизированных пайплайнах.
const result = deploymentSuite(config);
if (result.hasErrors()) {
throw new Error(
JSON.stringify(result.getErrors(), null, 2)
);
}
Vest допускает создание кастомных matcher-функций.
enforce.extend({
isSemver(value) {
return /^\d+\.\d+\.\d+$/.test(value);
}
});
Использование:
test('version', 'Некорректная версия', () => {
enforce(config.version).isSemver();
});
const raw = fs.readFileSync('./config.json', 'utf8');
const parsed = JSON.parse(raw);
const result = configSuite(parsed);
import yaml from 'js-yaml';
const raw = fs.readFileSync('./config.yml', 'utf8');
const parsed = yaml.load(raw);
const result = configSuite(parsed);
При росте приложения полезны следующие подходы:
Vest хорошо сочетается с интерфейсами.
interface ServerConfig {
host: string;
port: number;
secure: boolean;
}
const suite = create((config: ServerConfig) => {
test('host', 'Host обязателен', () => {
enforce(config.host).isNotBlank();
});
test('port', 'Некорректный port', () => {
enforce(config.port)
.isNumber()
.greaterThan(0);
});
});
Плохо:
config.server.host
Лучше:
config.server?.host
Неправильно:
enforce(Number(config.port))
.greaterThan(0);
Лучше:
const normalizedPort = Number(config.port);
enforce(normalizedPort)
.greaterThan(0);
Плохо:
create(() => {
// 1000 строк проверок
});
Лучше:
databaseSuite();
serverSuite();
cacheSuite();
authSuite();
Плохо:
enforce(config.database.url)
Лучше:
enforce(config.database?.url)