2020. 11. 4. 10:49ㆍweb tech
javascript, Node.js를 이용해서 CLI(Command Line Interface) 프로그램을 개발해보려고 합니다.
git 이나 npm 모두 git clone , git add 같이 커맨드 창에서 명령어로 실행하는 것이 모두 CLI 프로그램 입니다.
npm 모듈을 전역적으로 설치할 수 있고, 터미널 명령을 이요해 시스템 어느 곳에서나 설치된 모듈을 참조할 수 있습니다.
npm에서 어떤 모듈을 설치하면(window에서 설치한다고 가정) \user\AppData/Roaming/npm 에 전역 모듈을 설치하고 package.json은 해당 모듈을 처리하고 bin 필드를 찾습니다.
* AppData는 윈도우OS에서 프로그램의 데이터 및 설정 내용을 저장하기 위해 존재하는 디렉토리
* bin 필드는 사용자가 해당 명령을 실행할 때 실행해야하는 모듈의 파일인 객체.
bin 필드가 안 비어 있으면 npm은 사용자가 해당 명령을 사용하도록 npm 폴더 내에 필요 파일을 만듭니다!
디렉토리 구조
cliproject
|-- bin \ cliproject
|-- lib \
|-- dist \
|-- package.json
bin 폴더에는 확장자가 없이 보통 실행할 커맨드 이름과 프로젝트의 이름과 동일하게 만든다(일반적으로).
.cmd 확장자 파일도 이전 파일과 함께 생성되어 node.js에서만 .js 파일이 실행되도록 한다.
두 json파일의 key값을 비교 판별해주는 cli 프로그램을 만들어보겠습니다.
npm init 후 위의 디렉토리 구조로 폴더 생성!
/* json-key-discriminator 이란 cli 프로그램을 만들예정입니다! */
mkdir json-key-discriminator
cd json-key-discriminator
/* npm 초기화 */
npm init
mkdir bin
mkdir .gitignore
mkdir .npmignore
생성된 프로젝트로 들어가 npm 초기화 한 후에 위에서 설명한 bin 폴더와 git에서 무시하는 .gitignore과 npm에서 무시하는 .npmignore을 생성합니다.
bin 폴더에는 모든 실행 파일이 들어가 있어야 합니다. (CLI가 구동되는 코드.)
json-key-discriminator이란 이름으로 만들어야 하지만 커맨드라인에서 사용하기에 길어서 앞 글자를 따서 jkd 로 생성하겠습니다.
$ jkd --help
$ jkd -f 어쩌구 -s 저쩌구
위와 같이 약어로 커맨드 명령어 이름으로 지정되도록 하겠습니다.
** 주의 **
실제 프로그램을 구동하는 명령어는
$ node ./dist/cli.js
입니다.
jkd 라는 명령어로 실행시키기 위해서는 모듈을 전역적으로 설치해야 합니다.
npm install -g PACKAGE_NAME (저는 json-key-discriminator)
이나
npm install -g ./
해당 명령어로 npm에 전역 설치를 해줘야 npm registry에서 모듈을 사용할 수 있게 게시됩니다!
이제 전역 설치후 터미널에서 jkd 라고 명령을 자유롭게 실행할 수 있습니다.
CLI 개발 시 많이 사용하는 라이브러리
1. commander
process argv에 직접 커맨드 명령어를 담아서 실행시킬 수 있지만 commander이라는 cli 생성 라이브러리를 만들면 편리하게 개발 할 수 있습니다.
2. chalk
커맨드에 색칠을 해주는 라이브러리. console로 찍으면 하이라이트 할 수 있습니다.
3. jest
굳이 jest를 쓰지 않아도 되지만, 개발 시 테스트를 많이 해보는 게 좋을 텐데, jest를 이용해서 단위테스트를 진행했습니다.
npm install commander
npm install chalk
npm install jest
* node.js의 process 모듈
정말 간단한 라이브러리지만 node.js의 모듈을 몇 가지 사용해야 했습니다. 그 중 커맨드라인에 사용자의 입력을 시스템이 받아야하는데 node.js의 모듈인 process를 사용합니다.
process 모듈은 실행 중인 프로세스에 대한 정보를 포함하는 변수를 제공합니다.
process 모듈에 argv(argument vetor)라는 속성에서 실행 매개변수를 배열에 받습니다.
// node 실행
$ node
// node의 process 확인
> process
// node의 process의 실행 매개변수 확인
> process.argv
>
아래와 같이 CLI 프로그램에서 해당 flag(-f)와 flag의 값(firstValue)을 사용자가 입력하면 아래 그림 처럼 들어감
$ jdk -f firstValue
첫 번째 값은 default로 들어가는데 node 인터프리터의 경로이고, 그 다음부터는 CLI 명령어 이후 추가 된 공백으로 구분된 텍스트들이 요소로 들어갑니다. ( jdk , -f , firstValue , 추가적인 옵션이랑 값들이 더 올 수 있습니다! )
json-key-discriminator 만들기 ( 두 JSON 파일의 key값을 구별해주는 CLI프로그램)
우선, 저는 require로 모듈을 가져오는 게 좀 보기 싫어서 babel로 트랜스파일해서 사용하도록 했습니다!
npm install -D @babel/cli @babel/core @babel/preset-env
이제 바벨을 이용해 import 구문을 사용할 수 있습니다. 기본적으로 빌드하면 생성되는 /dist의 실행 파일에서 프로그램을 구동시켜줍니다!! ( babel에 대한 주제는 아니므로 자세한 설명 없이 넘어가겠습니다 )
프로젝트 디렉토리 & package.json
전체 디렉토리 구조는 다음과 같고 꽤나 심플한 코드 입니다.
exam
|-- exam.json
|-- lang-en.json
|-- lang-kr.json
|-- test.json
exam은 제가 임의로 지정한 폴더. 테스트랑 json파일들을 넣은 폴더들.. 편하신 대로 네이밍하시면 됩니다
test
|-- unit
|---- cli.test.js
실제 두 json파일의 키 값을 비교하는 로직을 테스트하는 unit test코드 입니다. 보통은 기능 단위로 쪼개서 파일을 나눠주는 게 좋은데 cli라는 파일로 한 번에 넣은 게 좀 아쉬운 점이라고 생각합니다.
extract.js -> extract.test.js
validation.js -> validation.test.js
이런 식으로 명확하게 쪼개서 기능 단위로 파일을 쪼개는 게 더 좋았을 것 같네요! 간단한 기능이라 나누지 않았는데 묵직한 기능들이 많아지면 쪼개는 게 좋지 않을까 합니다!!
dist
babel로 빌드하면 현재 src의 있는 파일들( index.js , cli.js )가 빌드되어 그대로 생성됩니다!
require보다 import 구문이 편리해서 사용한 것 밖에 없는데...최신 JS문법을 많이 사용하실 거면 babel을 더 공부하셔서
package.json
bin : package.json의 bin속성 주면, 실행할 수 있는 패키지를 만들 수 있다.
그러면, 패키지 설치 시 npm은 bin 항목에 기술된 파일의 링크를 가져오게 된다.
license : 라이센스를 지정합니다. 패키지 사용을 허용하는 방법과 제한 사항을 알 수 있게 합니다.
repository : 실제 프로젝트 코드의 주소를 지정합니다. 보통? git 주소로 나타내고, npm 패키지에 해당 주소로
나오게 됩니다.
더 자세하고 다양한 속성들이 있는데 , HEROPYT Tech 에서 더 자세한 속성을 넣어서 풍부하게 설정할 수 있으니 참고하면 좋겠습니다! ( front end 참고할 좋은 글들이 많이 있습니다! )
프로젝트 구동 코드
디렉토리에서 실제 구동하는 코드는 /dist/cli.js 코드 입니다.
import 구문이라던가 사용해서 실제 동작하기 위해서는 build한 파일에서 실행시켜야 합니다.
시나리오
npm run build
--> 빌드하면 /dist/cli.js , /dist/index.js라 빌드 되고
--> /dist/cli.js 를 다음과 같이 실행 : $ node ./dist/cli.js -f lang-en -s lang-kr -d exam
실행 로직
src/index.js
// extract key values
import fs from 'fs';
import path from 'path';
import chalk from 'chalk';
let bothContainArray = [];
let onlyInMain = [];
let onlyInCompare = [];
function extractKeyValues(jsonFile) {
let keyArray = [];
for (let key in jsonFile) {
keyArray.push(key);
}
return keyArray;
}
function checkHasFileInFolder(dirJsonFiles, fileName) {
let addFormatFileName = fileName.concat('.json');
let check = false;
for (const el of dirJsonFiles) {
if (addFormatFileName === el) {
check = true;
}
}
return check;
}
// 로컬에서 파일 패스까지를 리턴함.
function getDirPath(folder) {
const root = process.cwd();
return path.join(root, folder);
}
// 사용자들의 json파일들 array return
function getJsonFiles(dirPath) {
// json이 있는 dirPath 가져오기. 현재 project는 exam에 json파일들 있음
const files = fs.readdirSync(dirPath);
return files;
}
function readFile(dirPath, readFile) {
const readFilePath = dirPath.concat('\\' + readFile + '.json');
return JSON.parse(fs.readFileSync(readFilePath).toString('utf-8'));
}
function compareKeyValuesArray(mainArray, compareArray) {
mainArray.forEach(el => {
compareArray.includes(el) ? bothContainArray.push(el) : onlyInMain.push(el);
})
compareArray.forEach(el => {
if (!bothContainArray.includes(el)) { onlyInCompare.push(el) };
})
}
// chalk로 highlight
function printDifference(dirName, mainFileName, compareFileName, mainArray, compareArray) {
console.log('--- compare two json key ---');
mainArray.forEach(el => {
console.log(`it's a key value only in ${dirName}/`.concat(chalk.blue(`${mainFileName}.json`)), chalk.red(el));
});
compareArray.forEach(el => {
console.log(`it's a key value only in ${dirName}/`.concat(chalk.green(`${compareFileName}.json`)), chalk.red(el));
})
}
function checkHasFolderInRoot(inputFileName) {
const root = process.cwd();
const rootFiles = getJsonFiles(root);
let isRightFileName = false;
rootFiles.forEach(file => {
if (file === inputFileName) {
isRightFileName = true
}
})
return isRightFileName;
}
export function start(dirName, mainFileName, compareFileName) {
let hasFolderInRoot = checkHasFolderInRoot(dirName);
if (!hasFolderInRoot) {
console.error(chalk.red(`${dirName} is not in root... Please enter folder name in root!`))
} else {
let dirPath = getDirPath(dirName);
let dirJsonFiles = getJsonFiles(dirPath);
let hasMainFileInFolder = checkHasFileInFolder(dirJsonFiles, mainFileName);
let hasCompareFileInFolder = checkHasFileInFolder(dirJsonFiles, compareFileName);
let mainArray;
let compareArray;
if (!hasMainFileInFolder) {
console.error(chalk.red(`${dirName}/${mainFileName}.json is not in the folder. Please enter json file name correctly.`))
} else {
mainArray = extractKeyValues(readFile(dirPath, mainFileName));
}
if (!hasCompareFileInFolder) {
console.error(chalk.red(`${dirName}/${compareFileName}.json is not in the folder. Please enter json file name correctly.`))
} else {
compareArray = extractKeyValues(readFile(dirPath, compareFileName));
}
if (hasMainFileInFolder && hasCompareFileInFolder) {
compareKeyValuesArray(mainArray, compareArray);
printDifference(dirName, mainFileName, compareFileName, onlyInMain, onlyInCompare);
}
}
}
+ 약간의 코드 설명
(클린 코드로 혼쭐을 내주고 싶으시다면 피드백 주시면 감사히 받겠습니다! 그래도 긴 코드가 아니라 충분히 따라가며 보실 수 있을 거라 생각합니다:) )
- process.cwd() 는 현재 디렉토리의 경로를 불러옵니다.
function getDirPath(folderName) {
const root = process.cwd();
return path.join(root, folderName);
}
- fs.readFileSync에 file경로를 넣어주면 ' ..//..//exam//lang-en.json ' 을 버퍼로 받아와서 string으로 변환 후(utf-8로 인코딩 타입으로 변화) 다시 JSON으로 파싱해서 리턴합니다!
function readFile(dirPath, readFile) {
const readFilePath = dirPath.concat('\\' + readFile + '.json');
return JSON.parse(fs.readFileSync(readFilePath).toString('utf-8'));
}
조금 민망할 수도 있지만, 실제 파일을 읽어오고, 두 json파일의 키 값을 비교하는 로직은 이게 전부입니다.
node.js 의 fs 모듈과 path 모듈을 불러와 파일을 읽어오고 붙히고 여타 예외 처리를 했습니다.
- start()는 프로그램을 트리거(trigger)하는 코드로 모듈로 export 해줘야 다른 파일에서 import 해서 사용할 수 있습니다.
start( 검사할 json들이 있는 폴더 이름(root에 있어야 함 ) , 검사할 첫 번째 json 파일 이름 , 검사할 두 번째 json 파일 이름 )
start 함수에서 위에 정의한 기능들을 수행합니다. start의 인자 값은 CLI 프로그램에서 받을 값들입니다.
(혼동이 있을 수 있는데.. 네이밍을 mainFileName은 first , compareFileName은 second 로 CLI에서 flag를 줬습니다..헷갈리셨다면 죄송합니다.. )
$ jkd -d exam -f lang-en -s lang-kr
이렇게 CLI를 실행하면 start( exam , lang-en , lang-kr ) 이렇게 들어가서 로직을 수행합니다!
다음으로 commander.js를 이용해서 CLI 프로그램을 정의하겠습니다.
src/cli.js
"use strict"
import program from 'commander';
import { start } from "./index";
program.on('--help', () => {
console.log(' json-key-discriminator CLI');
console.log(' Usage: jkd [Option]');
console.log(' Option: ');
console.log(' -h: Display this help message');
console.log(' -d: Name of the folder in the project directory');
console.log(' -f: first name of json file');
console.log(' -s: second name of json file');
})
program
.version('1.0.0')
.description('this is a simple JSON key value discriminator.')
.option('-d , --dir <type>', 'directory folder name (required)')
.option('-f ,--first <type>', 'first json name - main file (required)')
.option('-s ,--second <type>', 'second json name - compare file (required)')
.action((options) => {
start(options.dir, options.first, options.second)
})
// 해당되는 command가 없을 경우 실행되는 command
program.command('*', { noHelp: true }).action(() => {
console.log('cannot find commander.');
program.help();
})
program.parse(process.argv);
CLI를 구성하는 코드 입니다. program 이란 이름으로 commander 라이브러리를 불러와서 사용합니다.
보통의 CLI 프로그램은 어떻게 사용할 지 알려주는 --help 플래그가 있습니다.
program.on('--help', () => {
console.log(' json-key-discriminator CLI');
console.log(' Usage: jkd [Option]');
console.log(' Option: ');
console.log(' -h: Display this help message');
console.log(' -d: Name of the folder in the project directory');
console.log(' -f: first name of json file');
console.log(' -s: second name of json file');
})
--help 기본적으로 -h 는 제공되는데 program.on('플래그' , callback) 으로 해당 플래그(flag)를 입력 받으면
사용자들에게 사용법과 option들을 제공받습니다. 최대한 자세하게 적어 주도록 권고하는데...조금 더 사용성 좋게 수정하는 게 좋을 것 같네요.
program
.version('1.0.0')
.description('this is a simple JSON key value discriminator.')
.option('-d , --dir <type>', 'directory folder name (required)')
.option('-f ,--first <type>', 'first json name - main file (required)')
.option('-s ,--second <type>', 'second json name - compare file (required)')
.action((options) => {
start(options.dir, options.first, options.second)
})
program에서 이제 여러 option을 직접 추가 해주어야 합니다.
.version .option 현재 프로그램의 버전 정보 추가
.description => 현재 프로그램에 대한 설명을 추가할 수 있습니다.
.option => 사용자들이 CLI에서 입력을 하면 어느 flag가 어떤 값인 지 알아야 합니다.
보통
.option( '-f , --flag' , '해당 flag에 대한 설명' )
.option(' -f --flag <type>' , '해당 flag에 대한 설명')
.option(' -f --flag [type]' , '해당 flag에 대한 설명')
- : 단축옵션
-- : 옵션
<>: 필수 값
[] : 선택 값
.action => 이제 까지는 사용자가 명령어를 이해하는 데 도움이 되는 부분만 추가해줬다면 실제 명령이 사용될 때
실행되는 로직이 있어야 합니다. .action으로 실행될 로직을 넣어줄 수 있습니다.
실행 함수에서 options라는 인자로 flag의 옵션의 값을 받을 수 있습니다.
options.dir 은 --dir <type>의 값을 가져옵니다.
jkd -d test
위의 -d , --dir의 <type> 은 test로 가져오게 되는 것입니다.
마찬가지로 options.first / options.second 는 -f, --first의 공백 후 다음 값과 -s , --second의 공백 후 다음 값으로 받습니다.
저는 ./src/index.js에서 export한 start()를 import 해서 action에서 실행시켜주었습니다!
그럼 파라미터 값들을 사용자가 플래그에 맞게 작성해주면 해당 값들을 받아서 프로그램을 구동시킵니다.👌
npm에 배포 전 테스트
npm에 배포 하지 전에 local에서 테스트를 할 수 있습니다.
$ cd ..
$ mkdir jkd-test
$ cd jkd-test
$ npm init
// jkd-test에서 만든 라이브러리 설치!
$ npm install ../json-key-discriminator
jkd-test라는 폴더를 만들어 npm init 후 json-key-discriminator를 설치해서 테스트 해볼 수 있습니다.
npm에 배포하기
자 이제 드디어! npm에 배포해보겠습니다👏
npm에 배포하는 게 거창하고 무서워 보였는데.. 생각보다 엄청 빨르고 손 쉽게 된다는 걸 알았습니다. (물론 최대한 검증하고 안정성을 확보한 코드를.. 올려야 하겠지만요..🤣)
우선 www.npmjs.com/ npm사이트에 들어가 로그인을 계정이 없다면 생성해 줍니다!
다음 생성된 계정으로 커맨드창에서 로그인 후 배포해보겠습니다.
npm login
배포하려는 앱 루트에서 npm login하면 Username / Password / Eamil 을 입력하라고 합니다.
방금 회원가입 및 로그인 한 계정을 입력하면 로그인 완료됬음을 알리는 라인이 나옵니다.
그리고
npm publish
npm publish 하면 package.json의 정보로 npm에 배포됩니다. 라이브러리 이름은! npm에서 유일해야 합니다.
npm에 json-key-discriminator가 있다면 package.json의 "name"에서 다름 이름으로 바꿔줘야 합니다!!
npm 재배포(버전 업 후 배포)
추가적으로 잘못되는 부분을 npm 배포 후 발견하거나 유지 보수할 부분이 생기게 됩니다.
그렇다면
package.json 에서
{ "version" : "1.0.0"}
에서 version을 "1.0.1" 이런 식으로 자기가 생각한 버전에 맞춰 수정한 후
npm login 후 npm publish 해서 배포해주면 version이 바뀌었다면 수정된 코드로 재배포 되고 version이 그대로면
수정된 코드로 배포되지 않습니다.🤷♂️
<참고>
medium.com/jspoint/creating-cli-executable-global-npm-module-5ef734febe32
medium.com/jspoint/making-cli-app-with-ease-using-commander-js-and-inquirer-js-f3bbd52977ac
heropy.blog/2019/01/31/node-js-npm-module-publish/