angular-cli/packages/angular/cli/models/command-runner.ts
Charles Lyding 7a72f2fb17 refactor(@angular/cli): avoid aggressive eager command loading
Currently, upon execution `ng` will load all description files AND code for all available commands.  This requires a large amount of unnecessary file access and processing since only at most one command will be executed.  This change limits the loading to only command being executed in the common case and a subset of commands in the event an alias is used.  The help command now loads all commands during its execution which is needed to gather command description information.  Further improvements are possible by only loading the necessary metadata instead of the execution code (and its dependencies) as well.
This change allows for savings of ~250ms per execution.

Examples:
Before -- `./node_modules/.bin/ng version  0.99s user 0.17s system 113% cpu 1.020 total`
After -- `./node_modules/.bin/ng version  0.70s user 0.13s system 110% cpu 0.749 total`

Before -- `./node_modules/.bin/ng g c a  1.91s user 0.30s system 111% cpu 1.996 total`
After -- `./node_modules/.bin/ng g c a  1.62s user 0.27s system 110% cpu 1.715 total`
2019-05-10 15:04:32 -07:00

243 lines
7.1 KiB
TypeScript

/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {
JsonParseMode,
analytics,
isJsonObject,
json,
logging,
schema,
strings,
tags,
} from '@angular-devkit/core';
import * as debug from 'debug';
import { readFileSync } from 'fs';
import { join, resolve } from 'path';
import { parseJsonSchemaToCommandDescription } from '../utilities/json-schema';
import { UniversalAnalytics, getGlobalAnalytics, getSharedAnalytics } from './analytics';
import { Command } from './command';
import { CommandDescription, CommandWorkspace } from './interface';
import * as parser from './parser';
const analyticsDebug = debug('ng:analytics:commands');
// NOTE: Update commands.json if changing this. It's still deep imported in one CI validation
const standardCommands = {
'add': '../commands/add.json',
'analytics': '../commands/analytics.json',
'build': '../commands/build.json',
'config': '../commands/config.json',
'doc': '../commands/doc.json',
'e2e': '../commands/e2e.json',
'make-this-awesome': '../commands/easter-egg.json',
'generate': '../commands/generate.json',
'get': '../commands/deprecated.json',
'set': '../commands/deprecated.json',
'help': '../commands/help.json',
'lint': '../commands/lint.json',
'new': '../commands/new.json',
'run': '../commands/run.json',
'serve': '../commands/serve.json',
'test': '../commands/test.json',
'update': '../commands/update.json',
'version': '../commands/version.json',
'xi18n': '../commands/xi18n.json',
};
export interface CommandMapOptions {
[key: string]: string;
}
/**
* Create the analytics instance.
* @private
*/
async function _createAnalytics(): Promise<analytics.Analytics> {
const config = getGlobalAnalytics();
const maybeSharedAnalytics = getSharedAnalytics();
if (config && maybeSharedAnalytics) {
return new analytics.MultiAnalytics([config, maybeSharedAnalytics]);
} else if (config) {
return config;
} else if (maybeSharedAnalytics) {
return maybeSharedAnalytics;
} else {
return new analytics.NoopAnalytics();
}
}
async function loadCommandDescription(
name: string,
path: string,
registry: json.schema.CoreSchemaRegistry,
): Promise<CommandDescription> {
const schemaPath = resolve(__dirname, path);
const schemaContent = readFileSync(schemaPath, 'utf-8');
const schema = json.parseJson(schemaContent, JsonParseMode.Loose, { path: schemaPath });
if (!isJsonObject(schema)) {
throw new Error('Invalid command JSON loaded from ' + JSON.stringify(schemaPath));
}
return parseJsonSchemaToCommandDescription(name, schemaPath, registry, schema);
}
/**
* Run a command.
* @param args Raw unparsed arguments.
* @param logger The logger to use.
* @param workspace Workspace information.
* @param commands The map of supported commands.
* @param options Additional options.
*/
export async function runCommand(
args: string[],
logger: logging.Logger,
workspace: CommandWorkspace,
commands: CommandMapOptions = standardCommands,
options: { analytics?: analytics.Analytics } = {},
): Promise<number | void> {
// This registry is exclusively used for flattening schemas, and not for validating.
const registry = new schema.CoreSchemaRegistry([]);
registry.registerUriHandler((uri: string) => {
if (uri.startsWith('ng-cli://')) {
const content = readFileSync(join(__dirname, '..', uri.substr('ng-cli://'.length)), 'utf-8');
return Promise.resolve(JSON.parse(content));
} else {
return null;
}
});
let commandName: string | undefined = undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('-')) {
commandName = arg;
args.splice(i, 1);
break;
}
}
let description: CommandDescription | null = null;
// if no commands were found, use `help`.
if (!commandName) {
if (args.length === 1 && args[0] === '--version') {
commandName = 'version';
} else {
commandName = 'help';
}
if (!(commandName in commands)) {
logger.error(tags.stripIndent`
The "${commandName}" command seems to be disabled.
This is an issue with the CLI itself. If you see this comment, please report it and
provide your repository.
`);
return 1;
}
}
if (commandName in commands) {
description = await loadCommandDescription(commandName, commands[commandName], registry);
} else {
const commandNames = Object.keys(commands);
// Optimize loading for common aliases
if (commandName.length === 1) {
commandNames.sort((a, b) => {
const aMatch = a[0] === commandName;
const bMatch = b[0] === commandName;
if (aMatch && !bMatch) {
return -1;
} else if (!aMatch && bMatch) {
return 1;
} else {
return 0;
}
});
}
for (const name of commandNames) {
const aliasDesc = await loadCommandDescription(name, commands[name], registry);
const aliases = aliasDesc.aliases;
if (aliases && aliases.some(alias => alias === commandName)) {
commandName = name;
description = aliasDesc;
break;
}
}
}
if (!description) {
const commandsDistance = {} as { [name: string]: number };
const name = commandName;
const allCommands = Object.keys(commands).sort((a, b) => {
if (!(a in commandsDistance)) {
commandsDistance[a] = strings.levenshtein(a, name);
}
if (!(b in commandsDistance)) {
commandsDistance[b] = strings.levenshtein(b, name);
}
return commandsDistance[a] - commandsDistance[b];
});
logger.error(tags.stripIndent`
The specified command ("${commandName}") is invalid. For a list of available options,
run "ng help".
Did you mean "${allCommands[0]}"?
`);
return 1;
}
try {
const parsedOptions = parser.parseArguments(args, description.options, logger);
Command.setCommandMap(async () => {
const map: Record<string, CommandDescription> = {};
for (const [name, path] of Object.entries(commands)) {
map[name] = await loadCommandDescription(name, path, registry);
}
return map;
});
const analytics = options.analytics || await _createAnalytics();
const context = { workspace, analytics };
const command = new description.impl(context, description, logger);
// Flush on an interval (if the event loop is waiting).
let analyticsFlushPromise = Promise.resolve();
setInterval(() => {
analyticsFlushPromise = analyticsFlushPromise.then(() => analytics.flush());
}, 1000);
const result = await command.validateAndRun(parsedOptions);
// Flush one last time.
await analyticsFlushPromise.then(() => analytics.flush());
return result;
} catch (e) {
if (e instanceof parser.ParseArgumentException) {
logger.fatal('Cannot parse arguments. See below for the reasons.');
logger.fatal(' ' + e.comments.join('\n '));
return 1;
} else {
throw e;
}
}
}