프로그램을 짜다 보면 종종 명령행 인수의 구문을 분석할 필요가 생깁니다. 편리한 유틸리티가 없다면 함수로 넘어오는 문자열 배열을 직접 분석하게 됩니다. 여러 가지 휼륭한 유틸리티가 있지만 내 사정에 딱 맞는 유틸리티가 없다면 직접 짜야합니다. 새로 짤 유틸리티를 Args라 부르겠습니다.
Args 사용법이 간단합니다. Args 생성자에 (입력으로 들어온) 인수 문자열과 형식 문자열을 넘겨 Args 인스턴스를 생성한 후 Args 인스턴스에다 인수 값을 질의합니다.
try {
const arg = new Args("l,p#,d*", args);
const logging = arg.getBoolean("l");
const port = arg.getInt("p");
const directory = arg.getStrging("d");
executeApplication(logging, port, directory);
} catch (e) {
console.error(`Argument error: ${e}`);
}
Typescript의 Iterator는 원문의 ListIterator에서 제공하는
previous
,hasNext
등의 메서드를 제공하지 않습니다. 하여 커스텀 Iterator Class을 만들어서 사용했습니다.
class Args {
private marshalers: Map<string, ArgumentMarshaler>;
private argsFound: Set<string>;
private currentArgument: ListIterator<string>;
constructor(schema: string, args: string[]) {
this.marshalers = new Map();
this.argsFound = new Set();
this.parseSchema(schema);
this.parseArgumentStrings(args);
}
private parseSchema(schema: string) {
for (const element of schema.split(",")) {
if (element.length > 0) {
this.parseSchemaElement(element);
}
}
}
private parseSchemaElement(element: string) {
const elementId = element.charAt(0);
const elementTail = element.slice(1);
this.validateSchemaElementId(elementId);
if (elementTail.length === 0) {
this.marshalers.set(elementId, new BooleanArgumentMarshaler());
} else if (elementTail === "*") {
this.marshalers.set(elementId, new StringArgumentMarshaler());
} else if (elementTail === "#") {
this.marshalers.set(elementId, new NumberArgumentMarshaler());
} else if (elementTail === "##") {
this.marshalers.set(elementId, new BigIntArgumentMarshaler());
} else if (elementTail === "[*]") {
this.marshalers.set(elementId, new StringArrayArguemntMarshaler());
} else {
throw new ArgsException(ErrorCode.INVALID_ARGUMENT_FORMAT, elementTail, elementId);
}
}
private validateSchemaElementId(elementId: string) {
if (!/^\w$/i.test(elementId)) {
throw new ArgsException(ErrorCode.INVALID_ARGUMENT_NAME, null, elementId);
}
}
private parseArgumentStrings(argsList: string[]) {
this.currentArgument = new ListIterator<string>(argsList);
while (this.currentArgument.hasNext()) {
const { value: argString } = this.currentArgument.next();
if (argString.startsWith("-")) {
this.parseArgumentCharacters(argString.slice(1));
} else {
this.currentArgument.previous();
break;
}
}
}
private parseArgumentCharacters(argChars: string) {
for (let i = 0; i < argChars.length; i += 1) {
this.parseArgumentCharacter(argChars[i]);
}
}
private parseArgumentCharacter(argChar: string) {
const m = this.marshalers.get(argChar);
if (m === undefined) {
throw new ArgsException(ErrorCode.UNEXPECTED_ARGUMENT, null, argChar);
}
this.argsFound.add(argChar);
try {
m.set(this.currentArgument);
} catch (e) {
if (e instanceof ArgsException) {
e.setErrorArgumentId(argChar);
}
throw e;
}
}
has(arg: string): boolean {
return this.argsFound.has(arg);
}
nextArgument(): number {
return this.currentArgument.nextIndex();
}
getBoolean(arg: string) {
return BooleanArgumentMarshaler.getValue(this.marshalers.get(arg));
}
getString(arg: string) {
return StringArgumentMarshaler.getValue(this.marshalers.get(arg));
}
getNumber(arg: string) {
return StringArgumentMarshaler.getValue(this.marshalers.get(arg));
}
getBigInt(arg: string) {
return BigIntArgumentMarshaler.getValue(this.marshalers.get(arg));
}
getStringArray(arg: string) {
return StringArrayArguemntMarshaler.getValue(this.marshalers.get(arg));
}
}
class ListIterator<T> implements Iterator<T> {
private readonly list: T[];
private index: number;
constructor(list: T[]) {
this.list = [...list];
this.index = 0;
}
next() {
if (this.hasNext()) {
const value = this.list[this.index];
this.index += 1;
return { value, done: false };
}
return { value: undefined, done: true };
}
hasNext() {
return this.index < this.list.length;
}
previous() {
if (this.hasPrevious()) {
this.index -= 1;
const value = this.list[this.index];
return { value, done: false };
}
return { value: undefined, done: true };
}
hasPrevious() {
return this.index > 0;
}
nextIndex() {
return this.index;
}
}
여기저기 뒤적일 필요 없이 위에서 아래로 코드가 읽힌다는 사실에 주목합니다. 아래는 ArgumentMarshaler
인터페이스와 파생 클래스입니다.
interface ArgumentMarshaler {
set(currentArgument: Iterator<string>): void;
}
class BooleanArgumentMarshaler implements ArgumentMarshaler {
private booeanVaule: boolean = false;
set(currentArgument: Iterator<string>) {
this.booeanVaule = true;
}
static getValue(am?: ArgumentMarshaler) {
if (am !== undefined && am instanceof BooleanArgumentMarshaler) {
return am.booeanVaule;
}
return false;
}
}
class StringArgumentMarshaler implements ArgumentMarshaler {
private stringValue: string = "";
set(currentArgument: Iterator<string>) {
this.stringValue = currentArgument.next().value;
if (this.stringValue === undefined) {
throw new ArgsException(ErrorCode.MISSING_STRING);
}
}
static getValue(am?: ArgumentMarshaler) {
if (am !== undefined && am instanceof StringArgumentMarshaler) {
return am.stringValue;
}
return "";
}
}
class NumberArgumentMarshaler implements ArgumentMarshaler {
private numberValue: number;
set(currentArgument: Iterator<string>) {
const { value } = currentArgument.next();
if (value === undefined) {
throw new ArgsException(ErrorCode.MISSING_NUMBER);
}
this.numberValue = Number(value);
if (Number.isNaN(this.numberValue)) {
throw new ArgsException(ErrorCode.INVALID_NUMBER);
}
}
static getValue(am?: ArgumentMarshaler) {
if (am !== undefined && am instanceof NumberArgumentMarshaler) {
return am.numberValue;
}
return 0;
}
}
class BigIntArgumentMarshaler implements ArgumentMarshaler {
private bigIntValue: BigInt;
set(currentArgument: Iterator<string>) {
const { value } = currentArgument.next();
if (value === undefined) {
throw new ArgsException(ErrorCode.MISSING_BIGINT);
}
try {
this.bigIntValue = BigInt(value);
} catch {
throw new ArgsException(ErrorCode.INVALID_BIGINT);
}
}
static getValue(am?: ArgumentMarshaler) {
if (am !== undefined && am instanceof BigIntArgumentMarshaler) {
return am.bigIntValue;
}
return 0n;
}
}
class StringArrayArguemntMarshaler implements ArgumentMarshaler {
private stringArrayValue: string[];
set(currentArgument: Iterator<string>) {}
static getValue(am?: ArgumentMarshaler) {
if (am !== undefined && am instanceof StringArrayArguemntMarshaler) {
return am.stringArrayValue;
}
return [];
}
}
한 가지가 눈에 거슬릴지 모르겠습니다. 바로 오류 코드 상수를 정의하는 부분입니다.
class ArgsException extends Error {
private errorArgumentId: string;
private errorParameter: string;
private errorCode: ErrorCode;
static fromMessage(message: string) {
const argsException = new ArgsException();
argsException.message = message;
return argsException;
}
constructor(errorCode?: ErrorCode, errorParameter?: string, errorArgumentId?: string) {
super();
this.errorArgumentId = errorArgumentId ?? "\0";
this.errorParameter = errorParameter ?? null;
this.errorCode = errorCode ?? ErrorCode.OK;
this.message = this.makeErrorMessage();
}
getErrorArgumentId() {
return this.errorArgumentId;
}
setErrorArgumentId(errorArgumentId: string) {
this.errorArgumentId = errorArgumentId;
}
getErrorParameter() {
return this.errorParameter;
}
setErrorParameter(errorParameter: string) {
this.errorParameter = errorParameter;
}
getErrorCode() {
return this.errorCode;
}
setErrorCode(errorCode: ErrorCode) {
this.errorCode = errorCode;
}
private makeErrorMessage() {
switch (this.errorCode) {
case ErrorCode.OK:
return "TILT: Should not get here.";
case ErrorCode.UNEXPECTED_ARGUMENT:
return `Argument -${this.errorArgumentId} unExpected.`;
case ErrorCode.MISSING_STRING:
return `Could not find string parameter for -${this.errorArgumentId}.`;
case ErrorCode.INVALID_NUMBER:
return `Argument -${this.errorArgumentId} expects an number but was '${this.errorParameter}'.`;
case ErrorCode.MISSING_NUMBER:
return `Could not find number parameter for -${this.errorArgumentId}.`;
case ErrorCode.INVALID_BIGINT:
return `Argument -${this.errorArgumentId} expects a BigInt but was '${this.errorParameter}'.`;
case ErrorCode.MISSING_BIGINT:
return `Could not find BigInt parameter for -${this.errorArgumentId}.`;
case ErrorCode.INVALID_ARGUMENT_NAME:
return `'${this.errorArgumentId}' is not a valid argument name.`;
case ErrorCode.INVALID_ARGUMENT_FORMAT:
return `'${this.errorParameter}' is not a valid argument format.`;
default:
return this.message;
}
}
}
enum ErrorCode {
OK,
INVALID_ARGUMENT_FORMAT,
UNEXPECTED_ARGUMENT,
INVALID_ARGUMENT_NAME,
MISSING_STRING,
MISSING_NUMBER,
INVALID_NUMBER,
MISSING_BIGINT,
INVALID_BIGINT,
}
이처럼 단순한 개념을 구현하는데 코드가 너무 많이 필요해 놀랄지도 모르겠습니다. 한 가지 이유는 타입스크립트를 사용해 정적인 타입 시스템을 만족시키려 했기 때문입니다. 자바스크립트로 동적 타입 시스템을 사용했다면 프로그램이 훨씬 작아졌을 것입니다.
class Args {
private schema: string;
private args: string[];
private valid: boolean = true;
private unExpectedArguments: Set<string> = new Set();
private booleanArgs: Map<string, boolean> = new Map();
private stringArgs: Map<string, string> = new Map();
private numberArgs: Map<string, number> = new Map();
private argsFound: Set<string> = new Set();
private currentArgument: number;
private errorArgumentId: string = "\0";
private errorParameter: string = "TILT";
private errorCode: ErrorCode = ErrorCode.OK;
constructor(schema: string, args: string[]) {
this.schema = schema;
this.args = args;
this.valid = this.parse();
}
private parse(): boolean {
if (this.schema.length === 0 && this.args.length === 0) {
return true;
}
this.parseSchema();
try {
this.parseArguments();
} catch (e) {}
return this.valid;
}
private parseSchema(): boolean {
for (const element of this.schema.split(",")) {
if (element.length > 0) {
const trimmedElement = element.trim();
this.parseSchemaElement(trimmedElement);
}
}
return true;
}
private parseSchemaElement(element: string) {
const elementId = element[0];
const elementTail = element.slice(1);
this.validateSchemaElementId(elementId);
if (this.isBooleanSchemaElement(elementTail)) {
this.parseBooleanSchemaElement(elementId);
} else if (this.isStringSchemaElement(elementTail)) {
this.parseStringSchemaElement(elementId);
} else if (this.isNumberSchemaElement(elementTail)) {
this.parseNumberSchemaElement(elementId);
} else {
throw new Error(`Argument: ${elementId} has invalid format: ${elementTail}.`);
}
}
private validateSchemaElementId(elementId: string) {
if (/^\w$/i.test(elementId)) {
throw new Error(`Bad character: ${elementId} in Args format: ${this.schema}`);
}
}
private parseBooleanSchemaElement(elementId: string) {
this.booleanArgs.set(elementId, false);
}
private parseNumberSchemaElement(elementId: string) {
this.numberArgs.set(elementId, 0);
}
private parseStringSchemaElement(elementId: string) {
this.stringArgs.set(elementId, "");
}
private isStringSchemaElement(elementTail: string): boolean {
return elementTail === "*";
}
private isBooleanSchemaElement(elementTail: string): boolean {
return elementTail.length === 0;
}
private isNumberSchemaElement(elementTail: string): boolean {
return elementTail === "#";
}
private parseArguments(): boolean {
for (this.currentArgument = 0; this.currentArgument < this.args.length; this.currentArgument += 1) {
const arg = this.args[this.currentArgument];
this.parseArgument(arg);
}
return true;
}
private parseArgument(arg: string) {
if (arg.startsWith("-")) {
this.parseElements(arg);
}
}
private parseElements(arg: string) {
for (let i = 1; i < arg.length; i += 1) {
this.parseElement(arg[i]);
}
}
private parseElement(argChar: string) {
if (this.setArgument(argChar)) {
this.argsFound.add(argChar);
} else {
this.unExpectedArguments.add(argChar);
this.errorCode = ErrorCode.UNEXPECTED_ARGUMENT;
this.valid = false;
}
}
private setArgument(argChar: string): boolean {
if (this.isBooleanArg(argChar)) {
this.setBooleanArg(argChar, true);
} else if (this.isStringArg(argChar)) {
this.setStringArg(argChar);
} else if (this.isNumberArg(argChar)) {
this.setNumberArg(argChar);
} else {
return false;
}
return true;
}
private isNumberArg(argChar: string): boolean {
return this.numberArgs.has(argChar);
}
private setNumberArg(argChar: string) {
this.currentArgument += 1;
let parameter: number = null;
if (this.currentArgument >= this.args.length) {
this.valid = false;
this.errorArgumentId = argChar;
this.errorCode = ErrorCode.MISSING_NUMBER;
throw new this.ArgsException();
}
parameter = Number(this.args[this.currentArgument]);
if (Number.isNaN(parameter)) {
this.valid = false;
this.errorArgumentId = argChar;
this.errorParameter = this.args[this.currentArgument];
this.errorCode = ErrorCode.INVALID_NUMBER;
throw new this.ArgsException();
}
this.numberArgs.set(argChar, parameter);
}
private setStringArg(argChar: string) {
this.currentArgument += 1;
if (this.currentArgument >= this.args.length) {
this.valid = false;
this.errorArgumentId = argChar;
this.errorCode = ErrorCode.MISSING_NUMBER;
throw new this.ArgsException();
}
this.stringArgs.set(argChar, this.args[this.currentArgument]);
}
private isStringArg(argChar: string): boolean {
return this.stringArgs.has(argChar);
}
private setBooleanArg(argChar: string, value: boolean) {
this.booleanArgs.set(argChar, value);
}
private isBooleanArg(argChar: string): boolean {
return this.booleanArgs.has(argChar);
}
cardinality(): number {
return this.argsFound.size;
}
usage(): string {
if (this.schema.length > 0) {
return `-[${this.schema}]`;
} else {
return "";
}
}
errorMessage(): string {
switch (this.errorCode) {
case ErrorCode.OK:
throw new Error("TILT: Should not get here.");
case ErrorCode.UNEXPECTED_ARGUMENT:
return this.unexpectedArgumentMessage();
case ErrorCode.MISSING_STRING:
return `Could not find string parameter for -${this.errorArgumentId}.`;
case ErrorCode.INVALID_NUMBER:
return `Argument -${this.errorArgumentId} expects an number but was '${this.errorParameter}'.`;
case ErrorCode.MISSING_NUMBER:
return `Could not find number parameter for -${this.errorArgumentId}.`;
default:
return "";
}
}
private unexpectedArgumentMessage(): string {
let message = "Argument(s) -";
for (const c of this.unExpectedArguments) {
message += c;
}
message += " unexpected.";
return message;
}
private falseIfNull(b?: boolean): boolean {
return Boolean(b);
}
private zeroIfNull(i?: number): number {
return i ?? 0;
}
private blankIfNull(s?: string): string {
return s ?? "";
}
getString(arg: string): string {
return this.blankIfNull(this.stringArgs.get(arg));
}
getNumber(arg: string): number {
return this.zeroIfNull(this.numberArgs.get(arg));
}
getBoolean(arg: string): boolean {
return this.falseIfNull(this.booleanArgs.get(arg));
}
has(arg: string): boolean {
return this.argsFound.has(arg);
}
isValid(): boolean {
return this.valid;
}
private ArgsException = class extends Error {};
}
enum ErrorCode {
OK,
UNEXPECTED_ARGUMENT,
MISSING_STRING,
MISSING_NUMBER,
INVALID_NUMBER,
}
위 코드는 명백히 미완성입니다. 인스턴스 변수 개수만도 압도적입니다. 처음부터 지저분한 코드를 짜려는 생각은 없었습니다. 실제로도 코드를 어느 정도 손보려고 애썼습니다. 함수 이름이나 변수 이름을 선택한 방식, 어설프지만 나름대로 구조가 있다는 사실 등이 노력의 증거입니다. 하지만 어느 순간 프로그램은 손을 벗어났습니다.
코드는 조금씩 엉망이 되어갔습니다. 첫 버전은 이만큼 엉망이지는 않았습니다.
class Args {
private schema: string;
private args: string[];
private valid: boolean = true;
private unExpectedArguments: Set<string> = new Set();
private booleanArgs: Map<string, boolean> = new Map();
private numberOfArguments: number = 0;
constructor(schema: string, args: string[]) {
this.schema = schema;
this.args = args;
this.valid = this.parse();
}
isValid(): boolean {
return this.valid;
}
private parse(): boolean {
if (this.schema.length === 0 && this.args.length === 0) {
return true;
}
this.parseSchema();
this.parseArguments();
return this.unExpectedArguments.size === 0;
}
private parseSchema(): boolean {
for (const element of this.schema.split(".")) {
this.parseSchemaElement(element);
}
return true;
}
private parseSchemaElement(element: string) {
if (element.length === 1) {
this.parseBooleanSchemaElement(element);
}
}
private parseBooleanSchemaElement(element: string) {
const c = element[0];
if (/^\w$/i.test(element)) {
this.booleanArgs.set(c, false);
}
}
private parseArguments(): boolean {
for (const arg of this.args) {
this.parseArgument(arg);
}
return true;
}
private parseArgument(arg: string) {
if (arg.startsWith("-")) {
this.parseElements(arg);
}
}
private parseElements(arg: string) {
for (let i = 1; i < arg.length; i += 1) {
this.parseElement(arg[i]);
}
}
private parseElement(argChar: string) {
if (this.isBoolean(argChar)) {
this.numberOfArguments += 1;
this.setBooleanArg(argChar, true);
} else {
this.unExpectedArguments.add(argChar);
}
}
private setBooleanArg(argChar: string, value: boolean) {
this.booleanArgs.set(argChar, value);
}
private isBoolean(argChar: string) {
return this.booleanArgs.has(argChar);
}
cardinality(): number {
return this.numberOfArguments;
}
usage(): string {
if (this.schema.length > 0) {
return `-[${this.schema}]`;
} else {
return "";
}
}
errorMessage(): string {
if (this.unExpectedArguments.size > 0) {
return this.unexpectedArgumentMessage();
}
return "";
}
private unexpectedArgumentMessage(): string {
let message = "Argument(s) -";
for (const c of this.unExpectedArguments) {
message += c;
}
message += " unexpected.";
return message;
}
getBoolean(arg: string): boolean {
return this.booleanArgs.get(arg);
}
}
위 코드도 불평거리가 많겠지만 나름대로 괜찮은 코드입니다. 간결하고 단순하며 이해하기도 쉽습니다. 하지만 코드를 잘 살펴보면 나중에 엉망으로 변해갈 씨앗이 보입니다. 코드가 점차 지저분해진 이유가 분명히 드러납니다.
뒤에 나올 코드는 위 코드에 string
과 number
라는 인수 유형 두 개만 추가햇을 뿐이라는 사실에 주목합니다. 인수 유형 두 개만 더했을 뿐인데 코드가 엄청나게 지저분해졌습니다. 유지와 보수가 수월했던 코드가 버그와 결함이 숨어있을지도 모른다는 상당히 의심스러운 코드로 뒤바뀌었습니다.
먼저 string
인수를 추가해 다음 코드를 얻었습니다.
class Args {
private schema: string;
private args: string[];
private valid: boolean = true;
private unExpectedArguments: Set<string> = new Set();
private booleanArgs: Map<string, boolean> = new Map();
private stringArgs: Map<string, string> = new Map();
private argsFound: Set<string> = new Set();
private currentArgument: number;
private errorArgument: string = "\0";
private errorCode = ErrorCode.OK;
constructor(schema: string, args: string[]) {
this.schema = schema;
this.args = args;
this.valid = this.parse();
}
private parse(): boolean {
if (this.schema.length === 0 && this.args.length === 0) {
return true;
}
this.parseSchema();
this.parseArguments();
return this.valid;
}
private parseSchema(): boolean {
for (const element of this.schema.split(".")) {
if (element.length > 0) {
const trimmedElement = element.trim();
this.parseSchemaElement(trimmedElement);
}
}
return true;
}
private parseSchemaElement(element: string) {
const elementId = element[0];
const elementTail = element.slice(1);
this.validateSchemaElementId(elementId);
if (this.isBooleanSchemaElement(elementTail)) {
this.parseBooleanSchemaElement(elementId);
} else if (this.isStringSchemaElement(elementTail)) {
this.parseStringSchemaElement(elementId);
}
}
private validateSchemaElementId(elementId: string) {
if (/^\w$/i.test(elementId)) {
throw new Error(`Bad character: ${elementId} in Args format: ${this.schema}`);
}
}
private parseStringSchemaElement(elementId: string) {
this.stringArgs.set(elementId, "");
}
private isStringSchemaElement(elementTail: string): boolean {
return elementTail === "*";
}
private isBooleanSchemaElement(elementTail: string): boolean {
return elementTail.length === 0;
}
private parseBooleanSchemaElement(elementId: string) {
this.booleanArgs.set(elementId, false);
}
private parseArguments(): boolean {
for (this.currentArgument = 0; this.currentArgument < this.args.length; this.currentArgument += 1) {
const arg = this.args[this.currentArgument];
this.parseArgument(arg);
}
return true;
}
private parseArgument(arg: string) {
if (arg.startsWith("-")) {
this.parseElements(arg);
}
}
private parseElements(arg: string) {
for (let i = 1; i < arg.length; i += 1) {
this.parseElement(arg[i]);
}
}
private parseElement(argChar: string) {
if (this.setArgument(argChar)) {
this.argsFound.add(argChar);
} else {
this.unExpectedArguments.add(argChar);
this.valid = false;
}
}
private setArgument(argChar: string): boolean {
let set: boolean = true;
if (this.isBooleanArg(argChar)) {
this.setBooleanArg(argChar, true);
} else if (this.isStringArg(argChar)) {
this.setStringArg(argChar);
} else {
set = false;
}
return set;
}
private setStringArg(argChar: string) {
this.currentArgument += 1;
if (this.currentArgument < this.args.length) {
this.stringArgs.set(argChar, this.args[this.currentArgument]);
} else {
this.valid = false;
this.errorArgument = argChar;
this.errorCode = ErrorCode.MISSING_NUMBER;
throw new Error();
}
}
private isStringArg(argChar: string): boolean {
return this.stringArgs.has(argChar);
}
private setBooleanArg(argChar: string, value: boolean) {
this.booleanArgs.set(argChar, value);
}
private isBooleanArg(argChar: string): boolean {
return this.booleanArgs.has(argChar);
}
cardinality(): number {
return this.argsFound.size;
}
usage(): string {
if (this.schema.length > 0) {
return `-[${this.schema}]`;
} else {
return "";
}
}
errorMessage(): string {
if (this.unExpectedArguments.size > 0) {
return this.unexpectedArgumentMessage();
}
switch (this.errorCode) {
case ErrorCode.OK:
throw new Error("TILT: Should not get here.");
case ErrorCode.MISSING_STRING:
return `Could not find string parameter for -${this.errorArgument}.`;
default:
return "";
}
}
private unexpectedArgumentMessage(): string {
let message = "Argument(s) -";
for (const c of this.unExpectedArguments) {
message += c;
}
message += " unexpected.";
return message;
}
getBoolean(arg: string): boolean {
return this.falseIfNull(this.booleanArgs.get(arg));
}
private falseIfNull(b?: boolean): boolean {
return Boolean(b);
}
getString(arg: string): string {
return this.blankIfNull(this.stringArgs.get(arg));
}
private blankIfNull(s?: string): string {
return s ?? "";
}
has(arg: string): boolean {
return this.argsFound.has(arg);
}
isValid(): boolean {
return this.valid;
}
}
enum ErrorCode {
OK,
MISSING_STRING,
}
보다시피 코드는 통제를 벗어나기 시작했습니다. 여기저기 눈에 거슬리지만 아직은 엉망이라 부르기 어렵습니다. 여기다 number
인수 유형을 추가하니 코드는 완전히 엉망이 되어버렸습니다.
추가할 인수 유형이 적어도 두 개는 더 있었는데 그러면 코드가 훨씬 더 나빠지리라는 사실이 저명했습니다. 그래서 기능을 더 이상 추가하지 않기로 결정하고 리펙터링을 시작했습니다. string
인수와 number
인수 유형을 추가한 경혐에서 새 인수 유형을 추가하려면 주요 지점 세 곳에다 코드를 추가해야 한다는 사실을 깨달았습니다.
Map
을 선택하기 위해 스키마 요소의 구문을 분석했습니다.인수 유형은 다양하지만 모두가 유사한 메서드를 제공하므로 클래스 하나가 적합하다고 판단했습니다. 그래서 ArgumentMarshaler
라는 개념이 탄생했습니다.
프로그램을 망치는 가장 좋은 방법 중 하나는 개선이라는 이름 아래 구조를 크게 뒤집는 행위입니다. 어떤 프로그램 그저 그런 개선에서 결코 회복하지 못합니다. 개선 전과 똑같은 프로그램을 돌리기가 아주 어렵기 떄문입니다. 그래서 테스트 주도 개발(Test-Driven Development, TDD)이라는 기법을 사용했습니다. TDD는 언제 어느 때라도 시스템이 돌아가야 한다는 원칙을 따릅니다.
시스템의 자잘한 변경을 가하기 시작했습니다. 코드를 변경할 떄마다 시스템 구조는 조금씩 ArgumentMarsharler
개념에 가까워 졌습니다. 또한 변경 후에도 시스템은 변경 전과 다름없이 돌아갔습니다. 가장 먼저 코드 ArgumentMarshaler
클래스의 골격을 추가했습니다.
class ArgumentMarshaler {
private booleanValue: boolean = false;
setBoolean(value: boolean) {
this.booleanValue = value;
}
getBoolean() {
return this.booleanValue;
}
}
class BooleanArgumentMarshaler extends ArgumentMarshaler {}
class StringArgumentMarshaler extends ArgumentMarshaler {}
class NumberArgumentMarshaler extends ArgumentMarshaler {}
당연히 위와 같은 변경은 아무 문제도 일으키지 않습니다. 다음으로 코드를 최소로 건드리는, 가장 단순한 변경을 가했습니다. 구체적으로 boolean
인수를 저장하는 Map
에서 boolean
인수 유형을 ArgumentMarshaler
유형으로 바꿨습니다.
private booleanArgs: Map<string, ArgumentMarshaler> = new Map();
// ...
privaet parseBooleanSchemaElement(elementId: string) {
this.booleanArgs.set(elementId, new BooleanArgumentMarshaler());
}
// ...
private setBooleanArg(argChar: string, value: boolean) {
this.booleanArgs.get(argChar).setBoolean(value);
}
// ...
getBoolean(arg: string): boolean {
return this.falseIfNull(this.booleanArgs.get(arg).getBoolean());
}
앞서 세 인수 유형을 추가하려면 세 곳(parse
, get
, set
)을 변경해야 한다고 말했는데, 위에서 수정한 부분과 정확히 일치한다는 사실에 주목합니다. 불행히도 사소한 변경이었으나 몇몇 테스트 케이스가 실패하기 시작했습니다. getBoolean
함수를 자세히 살펴봅니다. y
라는 인수가 없는데 args
로 y
를 넘긴다면 booleanArgs.get('y')
는 undefined
를 반환하고 예기치 못한 상황이 발생할 수 있습니다.
이제 undefined
인지 확인할 객체는 boolean
이 아니라 ArgumentMarshaler
입니다. 가장 먼저 getBoolean
함수에서 falseNull
을 제거했습니다.
getBoolean(arg: string): boolean {
return this.booleanArgs.get(arg).getBoolean();
}
다음으로는 함수를 두 행으로 쪼갠 후 ArgumentMarshaler
를 argumentMarshaler
라는 독자적인 변수에 저장했습니다. 그렇지만 긴 변수 이름이 싫었습니다. 유형 이름과 중복이 심했고 함수가 길어졌습니다. 그래서 argumentMarshaler
를 am
으로 줄였습니다. 그런다음 undefined
를 점검했습니다.
getBoolean(arg: string): boolean {
const am = this.booleanArgs.get(arg);
return am !== undefined && am.getBoolean();
}
string
인수를 추가하는 과정은 boolean
인수와 매우 유사합니다. Map
을 변경한 후 parse
, set
, get
함수를 고쳤습니다.
class Args {
// ...
private stringArgs: Map<string, ArgumentMarshaler> = new Map();
// ...
private parseStringSchemaElement(elementId: string) {
this.stringArgs.set(elementId, new StringArgumentMarshaler());
}
private setStringArg(argChar: string) {
this.currentArgument += 1;
if (this.currentArgument < this.args.length) {
this.stringArgs.get(argChar).setString(this.args[this.currentArgument]);
} else {
this.valid = false;
this.errorArgument = argChar;
this.errorCode = ErrorCode.MISSING_STRING;
throw new Error();
}
}
getString(arg: string): string {
const am = this.stringArgs.get(arg);
return am === undefined ? "" : am.getString();
}
}
class ArgumentMarshaler {
private booleanValue: boolean = false;
private stringValue: string;
setBoolean(value: boolean) {
this.booleanValue = value;
}
getBoolean() {
return this.booleanValue;
}
setString(s: string) {
this.stringValue = s;
}
getString() {
return this.stringValue ?? "";
}
}
일단 각 인수 유형을 처리하는 코드를 모두 ArgumentMarshaler
클래스에 넣고 나서 ArgumentMarshlaer
파생 클래스를 만들어 코드를 분리할 생각입니다. 그러면 프로그램 구조를 조금씩 변경하는 동안에도 시스템의 정상 동작을 유지하기 쉬워집니다. 다음으로 number
인수 기능을 ArgumentMarshaler
로 옯겼습니다.
class Args {
// ...
private numberArgs: Map<string, ArgumentMarshaler> = new Map();
// ...
private parseNumberSchemaElement(elementId: string) {
this.numberArgs.set(elementId, new NumberArgumentMarshaler());
}
// ...
private setNumberArg(argChar: string) {
this.currentArgument += 1;
let parameter = null;
try {
parameter = this.args[this.currentArgument];
this.numberArgs.get(argChar).setNumber(Number(parameter));
} catch {
this.valid = false;
this.errorArgument = argChar;
this.errorCode = ErrorCode.MISSING_NUMBER;
throw new Error();
}
}
// ...
getNumber(arg: string): number {
const am = this.numberArgs.get(arg);
return am === undefined ? 0 : am.getNumber();
}
}
class ArgumentMarshaler {
// ...
private numberValue: number;
// ...
setNumber(i: number) {
this.numberValue = i;
}
getNumber() {
return this.numberValue;
}
}
이제 모든 논리를 ArgumentMarshaler
로 옮겼으니 파생 클래스를 만들어 기능을 분산할 차례입니다.
class Args {
// ...
private setBooleanArg(argChar: string, value: boolean) {
this.booleanArgs.get(argChar).set("true");
}
private setStringArg(argChar: string) {
this.currentArgument += 1;
if (this.currentArgument < this.args.length) {
this.stringArgs.get(argChar).set(this.args[this.currentArgument]);
} else {
this.valid = false;
this.errorArgument = argChar;
this.errorCode = ErrorCode.MISSING_STRING;
throw new Error();
}
}
private setNumberArg(argChar: string) {
this.currentArgument += 1;
let parameter = null;
if (this.currentArgument < this.args.length) {
try {
parameter = this.args[this.currentArgument];
this.numberArgs.get(argChar).set(parameter);
} catch (e) {
this.valid = false;
this.errorArgument = argChar;
this.errorCode = ErrorCode.INVALID_NUMBER;
throw new Error();
}
} else {
this.valid = false;
this.errorArgument = argChar;
this.errorCode = ErrorCode.MISSING_NUMBER;
throw new Error();
}
}
// ...
getBoolean(arg: string): boolean {
const am = this.booleanArgs.get(arg);
return am !== undefined && (am.get() as boolean);
}
getString(arg: string): string {
const am = this.stringArgs.get(arg);
return am === undefined ? "" : (am.get() as string);
}
getNumber(arg: string): number {
const am = this.numberArgs.get(arg);
return am === undefined ? 0 : (am.get() as number);
}
}
abstract class ArgumentMarshaler {
abstract set(s: string): void;
abstract get(): any;
}
class BooleanArgumentMarshaler extends ArgumentMarshaler {
private booleanValue: boolean = false;
set(s: string) {
this.booleanValue = true;
}
get() {
return this.booleanValue;
}
}
class StringArgumentMarshaler extends ArgumentMarshaler {
private stringValue: string = "";
set(s: string) {
this.stringValue = s;
}
get() {
return this.stringValue;
}
}
class NumberArgumentMarshaler extends ArgumentMarshaler {
private numberValue: number = 0;
set(s: string) {
this.numberValue = Number(s);
if (Number.isNaN(this.numberValue)) {
throw new Error();
}
}
get() {
return this.numberValue;
}
}
다음응로 알고리즘 처음에 나오는 맵 세 개를 없앴습니다. 그러면 전체 시스템이 훨씬 더 일반적으로 변합니다. ArgumentMarshaler
로 맵을 만들어 원래 맵을 교체하고 관련 메서드를 변경합니다.
class Args {
// ...
private marshalers: Map<string, ArgumentMarshaler> = new Map();
// ...
private parseSchemaElement(element: string) {
const elementId = element[0];
const elementTail = element.slice(1);
this.validateSchemaElementId(elementId);
if (this.isBooleanSchemaElement(elementTail)) {
this.marshalers.set(elementId, new BooleanArgumentMarshaler());
} else if (this.isStringSchemaElement(elementTail)) {
this.marshalers.set(elementId, new StringArgumentMarshaler());
} else if (this.isNumberSchemaElement(elementTail)) {
this.marshalers.set(elementId, new NumberArgumentMarshaler());
} else {
throw new Error(`Argument: ${elementId} ahs invalid format: ${elementTail}.`);
}
}
private parseBooleanSchemaElement(elementId: string) {
this.marshalers.set(elementId, new BooleanArgumentMarshaler());
}
private parseStringSchemaElement(elementId: string) {
this.marshalers.set(elementId, new StringArgumentMarshaler());
}
private parseNumberSchemaElement(elementId: string) {
this.marshalers.set(elementId, new NumberArgumentMarshaler());
}
// ...
private setArgument(argChar: string): boolean {
const m = this.marshalers.get(argChar);
try {
if (m instanceof BooleanArgumentMarshaler) {
this.setBooleanArg(m);
} else if (m instanceof StringArgumentMarshaler) {
this.setStringArg(m);
} else if (m instanceof NumberArgumentMarshaler) {
this.setNumberArg(m);
} else {
return false;
}
} catch (e) {
this.valid = false;
this.errorArgument = argChar;
throw e;
}
return true;
}
private setBooleanArg(m: BooleanArgumentMarshaler) {
m.set("true");
}
private setStringArg(m: StringArgumentMarshaler) {
this.currentArgument += 1;
if (this.currentArgument < this.args.length) {
m.set(this.args[this.currentArgument]);
} else {
this.errorCode = ErrorCode.MISSING_STRING;
throw new Error();
}
}
private setNumberArg(m: NumberArgumentMarshaler) {
this.currentArgument += 1;
if (this.currentArgument < this.args.length) {
try {
const parameter = this.args[this.currentArgument];
m.set(parameter);
} catch (e) {
this.errorCode = ErrorCode.INVALID_NUMBER;
throw e;
}
} else {
this.errorCode = ErrorCode.MISSING_NUMBER;
throw new Error();
}
}
// ...
getBoolean(arg: string): boolean {
const am = this.marshalers.get(arg);
return am !== undefined && typeof am.get() === "boolean" && am.get();
}
getString(arg: string): string {
const am = this.marshalers.get(arg);
return am === undefined || typeof am.get() !== "string" ? "" : am.get();
}
getNumber(arg: string): number {
const am = this.marshalers.get(arg);
return am === undefined || typeof am.get() !== "number" ? 0 : am.get();
}
}
이제 전체 그림을 한 번 더 돌아볼 차례입니다.
enum ErrorCode {
OK,
MISSING_STRING,
MISSING_NUMBER,
INVALID_NUMBER,
}
class Args {
private schema: string;
private args: string[];
private valid: boolean = true;
private unExpectedArguments: Set<string> = new Set();
private marshalers: Map<string, ArgumentMarshaler> = new Map();
private argsFound: Set<string> = new Set();
private currentArgument: number;
private errorArgumentId: string = "\0";
private errorParameter: string = "TILT";
private errorCode = ErrorCode.OK;
constructor(schema: string, args: string[]) {
this.schema = schema;
this.args = args;
this.valid = this.parse();
}
private parse(): boolean {
if (this.schema.length === 0 && this.args.length === 0) {
return true;
}
this.parseSchema();
this.parseArguments();
return this.valid;
}
private parseSchema(): boolean {
for (const element of this.schema.split(",")) {
if (element.length > 0) {
const trimmedElement = element.trim();
this.parseSchemaElement(trimmedElement);
}
}
return true;
}
private parseSchemaElement(element: string) {
const elementId = element[0];
const elementTail = element.slice(1);
this.validateSchemaElementId(elementId);
if (this.isBooleanSchemaElement(elementTail)) {
this.marshalers.set(elementId, new BooleanArgumentMarshaler());
} else if (this.isStringSchemaElement(elementTail)) {
this.marshalers.set(elementId, new StringArgumentMarshaler());
} else if (this.isNumberSchemaElement(elementTail)) {
this.marshalers.set(elementId, new NumberArgumentMarshaler());
} else {
throw new Error(`Argument: ${elementId} ahs invalid format: ${elementTail}.`);
}
}
private validateSchemaElementId(elementId: string) {
if (!/^\w$/i.test(elementId)) {
throw new Error(`Bad character: ${elementId} in Args format: ${this.schema}`);
}
}
private isBooleanSchemaElement(elementTail: string): boolean {
return elementTail.length === 0;
}
private isStringSchemaElement(elementTail: string): boolean {
return elementTail === "*";
}
private isNumberSchemaElement(elementTail: string) {
return elementTail === "#";
}
private parseArguments(): boolean {
for (this.currentArgument = 0; this.currentArgument < this.args.length; this.currentArgument += 1) {
const arg = this.args[this.currentArgument];
this.parseArgument(arg);
}
return true;
}
private parseArgument(arg: string) {
if (arg.startsWith("-")) {
this.parseElements(arg);
}
}
private parseElements(arg: string) {
for (let i = 1; i < arg.length; i += 1) {
this.parseElement(arg[i]);
}
}
private parseElement(argChar: string) {
if (this.setArgument(argChar)) {
this.argsFound.add(argChar);
} else {
this.unExpectedArguments.add(argChar);
this.valid = false;
}
}
private setArgument(argChar: string): boolean {
const m = this.marshalers.get(argChar);
try {
if (m instanceof BooleanArgumentMarshaler) {
this.setBooleanArg(m);
} else if (m instanceof StringArgumentMarshaler) {
this.setStringArg(m);
} else if (m instanceof NumberArgumentMarshaler) {
this.setNumberArg(m);
} else {
return false;
}
} catch (e) {
this.valid = false;
this.errorArgumentId = argChar;
throw e;
}
return true;
}
private setBooleanArg(m: BooleanArgumentMarshaler) {
m.set("true");
}
private setStringArg(m: StringArgumentMarshaler) {
this.currentArgument += 1;
if (this.currentArgument < this.args.length) {
m.set(this.args[this.currentArgument]);
} else {
this.errorCode = ErrorCode.MISSING_STRING;
throw new Error();
}
}
private setNumberArg(m: NumberArgumentMarshaler) {
this.currentArgument += 1;
if (this.currentArgument < this.args.length) {
try {
const parameter = this.args[this.currentArgument];
m.set(parameter);
} catch (e) {
this.errorCode = ErrorCode.INVALID_NUMBER;
throw e;
}
} else {
this.errorCode = ErrorCode.MISSING_NUMBER;
throw new Error();
}
}
cardinality(): number {
return this.argsFound.size;
}
usage(): string {
if (this.schema.length > 0) {
return `-[${this.schema}]`;
} else {
return "";
}
}
errorMessage(): string {
if (this.unExpectedArguments.size > 0) {
return this.unexpectedArgumentMessage();
}
switch (this.errorCode) {
case ErrorCode.OK:
throw new Error("TILT: Should not get here.");
case ErrorCode.UNEXPECTED_ARGUMENT:
return this.unexpectedArgumentMessage();
case ErrorCode.MISSING_STRING:
return `Could not find string parameter for -${this.errorArgumentId}.`;
case ErrorCode.INVALID_NUMBER:
return `Argument ${this.errorArgumentId} expects an number but was ${this.errorParameter}`;
case ErrorCode.MISSING_NUMBER:
return `Could not number parameter for ${this.errorArgumentId}`;
default:
return "";
}
}
private unexpectedArgumentMessage(): string {
let message = "Argument(s) -";
for (const c of this.unExpectedArguments) {
message += c;
}
message += " unexpected.";
return message;
}
getBoolean(arg: string): boolean {
const am = this.marshalers.get(arg);
return am !== undefined && typeof am.get() === "boolean" && am.get();
}
getString(arg: string): string {
const am = this.marshalers.get(arg);
return am === undefined || typeof am.get() !== "string" ? "" : am.get();
}
getNumber(arg: string): number {
const am = this.marshalers.get(arg);
return am === undefined || typeof am.get() !== "number" ? 0 : am.get();
}
has(arg: string): boolean {
return this.argsFound.has(arg);
}
isValid(): boolean {
return this.valid;
}
}
abstract class ArgumentMarshaler {
abstract set(s: string): void;
abstract get(): any;
}
class BooleanArgumentMarshaler extends ArgumentMarshaler {
private booleanValue: boolean = false;
set(s: string) {
this.booleanValue = true;
}
get() {
return this.booleanValue;
}
}
class StringArgumentMarshaler extends ArgumentMarshaler {
private stringValue: string = "";
set(s: string) {
this.stringValue = s;
}
get() {
return this.stringValue;
}
}
class NumberArgumentMarshaler extends ArgumentMarshaler {
private numberValue: number = 0;
set(s: string) {
this.numberValue = Number(s);
if (Number.isNaN(this.numberValue)) {
throw new Error();
}
}
get() {
return this.numberValue;
}
}
열심히 고쳤지만 결과는 다소 실망스럽습니다. 구조만 조금 나아졌을 뿐입니다. 첫머리에 나오는 변수는 그대로 남아있으며, setArgument
에는 유형을 일일이 확인하는 보기 싫은 코드도 그대로 남아있습니다. 게다가 모든 set
함수는 정말로 흉합니다. 오류 처리 코드도 마찬가지입니다.
setArgument
함수에서 유형을 일일이 확인하는 코드를 없애고, ArgumentMarshaler.set
만 호출하면 충분하게 만들고 싶습니다. 즉, setNumberArg
, setStringArg
, setBooleanArg
을 해당 ArgumentMarshaler
파생 클래스로 내려야 한다는 뜻입니다. 하지만 문제가 있습니다.
setNumberArg
를 자세히 살펴보면 args
와 countArgument
라는 인스턴스 변수 두 개가 쓰입니다. setNumberArg
를 NumberArgumentMarshaler
로 내리려면 args
와 currnetArgument
를 인수로 넘겨야 한다는 말입니다. 그러면 코드가 지저분해집니다. 인수를 하나만 남기는 편이 낫습니다. 다행스럽게도 해결책은 간단합니다. args
배열을 list
로 변환한 후 iterator
를 set
함수로 전달하면 됩니다.
class Args {
// ...
private currentArgument: ListIterator<string>;
// ...
private parseArguments(): boolean {
for (this.currentArgument = new ListIterator<string>(this.args); this.currentArgument.hasNext(); ) {
const { value: arg } = this.currentArgument.next();
this.parseArgument(arg);
}
return true;
}
// ...
private setStringArg(m: StringArgumentMarshaler) {
if (this.currentArgument.hasNext()) {
const { value: parameter } = this.currentArgument.next();
m.set(parameter);
} else {
this.errorCode = ErrorCode.MISSING_STRING;
throw new Error();
}
}
private setNumberArg(m: NumberArgumentMarshaler) {
let parameter = null;
if (this.currentArgument.hasNext()) {
try {
const { value } = this.currentArgument.next();
parameter = value;
m.set(parameter);
} catch (e) {
this.errorParameter = parameter;
this.errorCode = ErrorCode.INVALID_NUMBER;
throw e;
}
} else {
this.errorCode = ErrorCode.MISSING_NUMBER;
throw new Error();
}
}
}
class ListIterator<T> implements Iterator<T> {
private readonly list: T[];
private index: number;
constructor(list: T[]) {
this.list = [...list];
this.index = 0;
}
next() {
if (this.hasNext()) {
const value = this.list[this.index];
this.index += 1;
return { value, done: false };
}
return { value: undefined, done: true };
}
hasNext() {
return this.index < this.list.length;
}
previous() {
if (this.hasPrevious()) {
this.index -= 1;
const value = this.list[this.index];
return { value, done: false };
}
return { value: undefined, done: true };
}
hasPrevious() {
return this.index > 0;
}
nextIndex() {
return this.index;
}
}
이제는 set
함수를 적절한 파생 클래스로 내려도 괜찮아졌습니다. 리펙터링을 하다 보면 코드를 넣었다 뺏다 하는 사례가 아주 흔합니다. 단계적으로 조금씩 변경하며 매번 테스트를 돌려야 하므로 코드를 여기저기 옮길 일이 많아집니다. 리펙터링은 루빅 큐브 맞추기와 비슷합니다. 큰 목표를 하나 이루기 위해 자잘한 단계를 수없이 거칩니다. 각 단계를 거쳐야 다음 단계가 가능합니다.
class Args {
// ...
private setArgument(argChar: string): boolean {
const m = this.marshalers.get(argChar);
if (m === null) {
return false;
}
try {
m.set(this.currentArgument);
return true;
} catch (e) {
this.valid = false;
this.errorArgumentId = argChar;
throw e;
}
return true;
}
// ...
}
interface ArgumentMarshaler {
set(currentArgument: ListIterator<string>): void;
get(): any;
}
class BooleanArgumentMarshaler implements ArgumentMarshaler {
// ...
set(currentArgument: ListIterator<string>) {
this.booleanValue = true;
}
//...
}
class StringArgumentMarshaler implements ArgumentMarshaler {
// ...
set(currentArgument: ListIterator<string>) {
if (!currentArgument.hasNext()) {
// ErrorCode.MISSING_STRING
throw new Error();
}
this.stringValue = currentArgument.next().value;
}
// ...
}
class NumberArgumentMarshaler implements ArgumentMarshaler {
// ...
set(currentArgument: ListIterator<string>) {
if (!currentArgument.hasNext()) {
// ErrorCode.MISSING_NUMBER;
throw new Error();
}
const { value } = currentArgument.next();
this.numberValue = Number(value);
if (Number.isNaN(this.numberValue)) {
// ErrorCode.INVALID_NUMBER
throw new Error();
}
}
// ...
}
이제부터 새로운 인수 유형을 추가하는 일은 매우 쉽습니다. 변경할 코드는 아주 적으며 나머지 시스켐에 영향을 미치지 않습니다. BigInt
유형을 제대로 받아들이는지 확인할 테스트 케이스부터 추가합니다.
function testSimpleDoublePresent() {
const args = new Args("x##", ["-x", "9007199254740991"]);
expect(args.isValid()).toBeTrue();
expect(args.cardinality()).toBe(1);
expect(args.has("x")).toBeTrue();
expect(args.getBigInt("x")).toBe(9007199254740991n);
}
이제 스키마 구문분석 코드를 정리하고 ##
감지 코드를 추가합니다.
enum ErrorCode {
// ...
MISSING_BIGINT,
INVALID_BIGINT,
}
class Args {
// ...
private parseSchemaElement(element: string) {
const elementId = element[0];
const elementTail = element.slice(1);
this.validateSchemaElementId(elementId);
if (this.isBooleanSchemaElement(elementTail)) {
this.marshalers.set(elementId, new BooleanArgumentMarshaler());
} else if (this.isStringSchemaElement(elementTail)) {
this.marshalers.set(elementId, new StringArgumentMarshaler());
} else if (this.isNumberSchemaElement(elementTail)) {
this.marshalers.set(elementId, new NumberArgumentMarshaler());
} else if (this.isBigIntSchemaElement(elementTail)) {
this.marshalers.set(elementId, new BigIntArgumentMarshaler());
} else {
throw new Error(`Argument: ${elementId} ahs invalid format: ${elementTail}.`);
}
}
// ...
private isBigIntSchemaElement(elementTail: string) {
return elementTail === "##";
}
// ...
getBigInt(arg: string): BigInt {
const am = this.marshalers.get(arg);
return am === undefined || typeof am.get() !== "bigint" ? 0n : am.get();
}
// ...
}
class BigIntArgumentMarshaler implements ArgumentMarshaler {
private bigIntValue: BigInt = 0n;
set(currentArgument: ListIterator<string>) {
if (!currentArgument.hasNext()) {
// ErrorCode.MISSING_BIGINT;
throw new Error();
}
try {
const { value } = currentArgument.next();
this.bigIntValue = BigInt(value);
} catch {
// ErrorCode.INVALID_BIGINT
throw new Error();
}
}
get() {
return this.bigIntValue;
}
}
혅재까지 예외 코드는 아주 흉할뿐더러 사실상 Args 클래스에 속하지도 않습니다. 게다가 ParseException
은 Args 클래스에 속하지 않습니다. 그러므로 모든 예외를 하나로 모아 ArgsException
클래스를 만든 후 독자 모듈로 옮깁니다.
export default class ArgsException extends Error {
private errorArgumentId: string;
private errorParameter: string;
private errorCode: ErrorCode;
static fromMessage(message: string) {
const argsException = new ArgsException();
argsException.message = message;
return argsException;
}
constructor(errorCode?: ErrorCode, errorParameter?: string, errorArgumentId?: string) {
super();
this.errorArgumentId = errorArgumentId ?? "\0";
this.errorParameter = errorParameter ?? null;
this.errorCode = errorCode ?? ErrorCode.OK;
this.message = this.makeErrorMessage();
}
getErrorArgumentId() {
return this.errorArgumentId;
}
setErrorArgumentId(errorArgumentId: string) {
this.errorArgumentId = errorArgumentId;
}
getErrorParameter() {
return this.errorParameter;
}
setErrorParameter(errorParameter: string) {
this.errorParameter = errorParameter;
}
getErrorCode() {
return this.errorCode;
}
setErrorCode(errorCode: ErrorCode) {
this.errorCode = errorCode;
}
private makeErrorMessage() {
switch (this.errorCode) {
case ErrorCode.OK:
return "TILT: Should not get here.";
case ErrorCode.UNEXPECTED_ARGUMENT:
return `Argument -${this.errorArgumentId} unExpected.`;
case ErrorCode.MISSING_STRING:
return `Could not find string parameter for -${this.errorArgumentId}.`;
case ErrorCode.INVALID_NUMBER:
return `Argument -${this.errorArgumentId} expects an number but was '${this.errorParameter}'.`;
case ErrorCode.MISSING_NUMBER:
return `Could not find number parameter for -${this.errorArgumentId}.`;
case ErrorCode.INVALID_BIGINT:
return `Argument -${this.errorArgumentId} expects a BigInt but was '${this.errorParameter}'.`;
case ErrorCode.MISSING_BIGINT:
return `Could not find BigInt parameter for -${this.errorArgumentId}.`;
case ErrorCode.INVALID_ARGUMENT_NAME:
return `'${this.errorArgumentId}' is not a valid argument name.`;
case ErrorCode.INVALID_FORMAT:
return `'${this.errorParameter}' is not a valid argument format.`;
default:
return this.message;
}
}
}
export enum ErrorCode {
OK,
INVALID_FORMAT,
INVALID_ARGUMENT_NAME,
UNEXPECTED_ARGUMENT,
MISSING_STRING,
MISSING_NUMBER,
MISSING_BIGINT,
INVALID_NUMBER,
INVALID_BIGINT,
}
class Args {
private schema: string;
private argsList: string[];
private marshalers: Map<string, ArgumentMarshaler> = new Map();
private argsFound: Set<string> = new Set();
private currentArgument: ListIterator<string>;
constructor(schema: string, args: string[]) {
this.schema = schema;
this.argsList = [...args];
this.parse();
}
private parse() {
this.parseSchema();
this.parseArguments();
}
private parseSchema() {
for (const element of this.schema.split(",")) {
if (element.length > 0) {
this.parseSchemaElement(element.trim());
}
}
}
private parseSchemaElement(element: string) {
const elementId = element[0];
const elementTail = element.slice(1);
this.validateSchemaElementId(elementId);
if (elementTail.length === 0) {
this.marshalers.set(elementId, new BooleanArgumentMarshaler());
} else if (elementTail === "*") {
this.marshalers.set(elementId, new StringArgumentMarshaler());
} else if (elementTail === "#") {
this.marshalers.set(elementId, new NumberArgumentMarshaler());
} else if (elementTail === "##") {
this.marshalers.set(elementId, new BigIntArgumentMarshaler());
} else {
throw new ArgsException(ErrorCode.INVALID_FORMAT, elementTail, elementId);
}
}
private validateSchemaElementId(elementId: string) {
if (!/^\w$/i.test(elementId)) {
throw new ArgsException(ErrorCode.INVALID_ARGUMENT_NAME, null, elementId);
}
}
private parseArguments() {
this.currentArgument = new ListIterator<string>(this.argsList);
while (this.currentArgument.hasNext()) {
const { value: arg } = this.currentArgument.next();
this.parseArgument(arg);
}
}
private parseArgument(arg: string) {
if (arg.startsWith("-")) {
this.parseElements(arg);
}
}
private parseElements(arg: string) {
for (let i = 1; i < arg.length; i += 1) {
this.parseElement(arg[i]);
}
}
private parseElement(argChar: string) {
if (this.setArgument(argChar)) {
this.argsFound.add(argChar);
} else {
throw new ArgsException(ErrorCode.UNEXPECTED_ARGUMENT, null, argChar);
}
}
private setArgument(argChar: string): boolean {
const m = this.marshalers.get(argChar);
if (m === null) {
return false;
}
try {
m.set(this.currentArgument);
return true;
} catch (e) {
if (e instanceof ArgsException) {
e.setErrorArgumentId(argChar);
}
throw e;
}
}
// ...
}
class StringArgumentMarshaler implements ArgumentMarshaler {
// ...
set(currentArgument: ListIterator<string>) {
if (!currentArgument.hasNext()) {
throw new ArgsException(ErrorCode.MISSING_STRING);
}
this.stringValue = currentArgument.next().value;
}
// ...
}
class NumberArgumentMarshaler implements ArgumentMarshaler {
// ...
set(currentArgument: ListIterator<string>) {
if (!currentArgument.hasNext()) {
throw new ArgsException(ErrorCode.MISSING_NUMBER);
}
const { value } = currentArgument.next();
this.numberValue = Number(value);
if (Number.isNaN(this.numberValue)) {
throw new ArgsException(ErrorCode.INVALID_NUMBER, value);
}
}
// ...
}
class BigIntArgumentMarshaler implements ArgumentMarshaler {
// ...
set(currentArgument: ListIterator<string>) {
if (!currentArgument.hasNext()) {
throw new ArgsException(ErrorCode.MISSING_BIGINT);
}
let parameter: string | null = null;
try {
parameter = currentArgument.next().value;
this.bigIntValue = BigInt(parameter);
} catch {
throw new ArgsException(ErrorCode.MISSING_BIGINT, parameter);
}
}
// ...
}
눈여겨볼 코드는 ArgsException
의 errorMessage
메서드입니다. 기존 Args
에 속했던 이 메서드는 명백히 SRP(Single Responsibility Principle) 위반이었습니다. Args 클래스가 오류 메시지 형식까지 처리하는 클래스가 아니기 때문입니다. 하지만 그렇다고 ArgsException
클래스가 오류 메시지 형식을 처리해야 옳은걸까요?
솔직히 말해, 이것은 절충안입니다. ArgsException
에게 맡겨서는 안 된다고 생각하는 독자라면 새로운 클래스가 필요합니다. 하지만 미리 깔끔하게 만들어진 오류 메시지로 얻는 장점은 무시하기 어렵습니다.
그저 돌아가는 코드만으로는 부족합니다. 나쁜 코드보다 더 오랫동안 더 심각하게 개발 프로젝트에 악영향을 미치는 요인도 없습니다. 물론 나쁜 코드도 깨끗한 코드로 개선할 수 있습니다. 하지만 비용이 엄청나게 많이 듭니다. 반면 처음부터 코드를 깨끗하게 유지하기란 상대적으로 쉽습니다. 그러므로 코드는 언제나 최대한 깔끔하고 단순하게 정리합니다.