블로그 이미지
윤영식
Full Stacker, Application Architecter, KnowHow Dispenser and Bike Rider

Publication

Category

Recent Post

2023. 5. 12. 09:59 React/Architecture

Webpack v5.* 기반의 Module Federation 개념을 간단히 정리하고, 환경을 설정해 본다. 

 

Module Federation Concept

Micro Frontend를 위한 컨셉에서 출발

- 참조: https://mobicon.tistory.com/572

개별 빌드 배포

 

Host 모듈 & Remote 모듈

- 모듈: webpack 번들링으로 생성된 js, css, html 파일 묶음

- Host 모듈: 단일 webpack 모듈 -> 개별 번들링된다. 

- Remote 모듈: 단일 webpack 모듈 -> 개별 번들링된다. 

   + 빌드시 호스트/원격 모듈 따로 따로 빌드 관리된다. 원격 모듈은 다른 도메인에서 제공할 수도 있다.

- 컨테이너: 각각 따로 빌드되며 독립적인 애플리케이션이다. 

    + A, B 컨테이너가 존재하면 각자 상호 로딩가능한다. 

- Expose: 컨테이너가 로딩할 원격 모듈 설정

    + 자세한 예: 참조

    + expose 되는 것은 별도의 chunk file 이 생성된다. (즉 해당 chunk file만 로딩해서 사용함)

// Remote App의 webpack.config.js 에서 exposes하기 (App2)
  plugins: [
    // To learn more about the usage of this plugin, please visit https://webpack.js.org/plugins/module-federation-plugin/
    new ModuleFederationPlugin({
      name: 'app2',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
  
// Host App의 webpack.config.js에서 remote app 로딩하기 (App1)
  plugins: [
    new ModuleFederationPlugin({
      name: "app1",
      remotes: {
        app2: "app2@[app2Url]/remoteEntry.js",
      },
      shared: {react: {singleton: true}, "react-dom": {singleton: true}},
    }),
    new ExternalTemplateRemotesPlugin(),
    new HtmlWebpackPlugin({
      template: "./public/index.html",
    }),
  ],
  
// App1에서 index.js 에서 app2Url 설정으로 remote app url 설정 
// You can write your own logic here to determine the actual url
window.app2Url = "http://localhost:3002"
  
// Host App에서 app2의 App 비동기 로딩 (App1)
import React, {Suspense} from "react";
const RemoteApp = React.lazy(() => import("app2/App"));

const App = () => {
  return (
    <div>
      <div style={{
        margin:"10px",
        padding:"10px",
        textAlign:"center",
        backgroundColor:"greenyellow"
      }}>
        <h1>App1</h1>
      </div>
      <Suspense fallback={"loading..."}>
        <RemoteApp/>
      </Suspense>
    </div>)
}


export default App;

App1이 호스트 앱, App2가 리모트 앱

 

- 공유 모듈: 여러 컨테이너에서 같이 사용하는 모듈

    + 예: react, react-dom     

- 호스트 앱: 원격 모듈을 사용하는 컨테이너

    + 리모트 앱이 expose한 원격 모듈을 호스트 앱에서 비동기 로딩해서 사용한다. 

- 리모트 앱: 모듈을 expose하는 컨테이너

 

 

NX 기반 환경설정

NX는 module federation 설정의 Host & Remote App을 생성하고 환경설정에 대해 이해한다. (참조)

 

Portal App & Micro Apps 역할

- Portal App

   + Remote 앱이 되어서 다양한 packages의 모듈을 expose 한다.

- Micro App

   + Host 앱이 되어서 portal의 exposed module을 async loading하여 사용한다.

 

Dashboard App에서 widget을 사용, Portal App 에서 Dashboard App을 사용한다

 

Portal App 모듈과 Micro App 모듈의 분리

- Micro App은 필요한 모듈을 Portal App (remote app) 으로 부터 로딩하여 사용한다. 따라서 Micro App에서 필요한 모듈을 package.json에 설정하여 npm install 하여 로컬에 설치 후 사용하는 것이 아니라, runtime에 로딩하여 사용할 수 있다. 

- Micro App 개발시 참조하는 모듈을 로컬에 설치할 필요없이 개발을 진행할 수 있다. 

- 즉, Micro Frontend의 개념을 적용하여 개발을 진행한다.

 

명령어 예

- host라는 host app이 자동 생성된다

- store 이라는 remote app이 자동 생성된다.

- @nx/react:host 의 명령어에 따라서 module federation 관련한 설정 내역이 자동으로 생성된다. 

nx g @nx/react:host mf/host --remotes=mf/store

mf폴더 밑으로 host, store app 생성

- host app 생성파일들

  + main.ts 에서 bootstrap.tsx를 import 형식: project.json에서 main도 main.ts 로 설정됨 (기존은 main.tsx 하나만 존재)

  + module-federation.config.js 파일 생성: remote 설정

  + webpack.config.<prod>.js 파일들 생성 

  + project.json: serve 의 executor가 @nx/react:module-federation-dev-server 로 변경됨

 

또는 remote app만들 별도로 생성할 수 있다. 

npx nx g @nx/react:remote portal/store

- remote app 생성파일들

  + remote-entry.ts 

  +  main.ts 에서 bootstrap.tsx를 import 형식: project.json에서 main도 main.ts 로 설정됨 (기존은 main.tsx 하나만 존재)

  + module-federation.config.js 파일 생성: exposes 설정

  + webpack.config.<prod>.js 파일들 생성 

  + project.json: serve 의 executor가 @nx/react:module-federation-dev-server 로 변경됨

 

host를 실행하면

  + 관련된 remote도 자동으로 실행된다. (remote는 project.json의 static-server의 port로 자동 실행된다.)

  + 즉, host app과 remote app이 동시에 구동된다. 

nx serve mf-host --open

 

NX 기반 설정파일 이해하기

remote app 설정 파일들

- webpack.config.js

  + withModuleFederation은 node_modules/@nx/react/src/module-federation/with-module-federation.js 위치하고 있고, remote와 shared할 libraries를 자동으로 설정해 준다. 즉, remote빌드시 shared libraries는 external libs로 취급되어 번들파일에 포함되지 않는다. 

  + nx 명령을 통해 생성한 remote app에는 webpack.config.js와 webpack.config.prod.js 파일이 자동 생성 및 설정되어 있다.

const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
const { withModuleFederation } = require('@nx/react/module-federation');

const baseConfig = require('./module-federation.config');

const config = {
  ...baseConfig,
};

// Nx plugins for webpack to build config object from Nx options and context.
module.exports = composePlugins(withNx(), withReact(), withModuleFederation(config));

- module-federation.config.js 파일명은 변경하지 말고 그대로 사용해한다. 

  + shared쪽에 libraryName을 체크하여 singleton, strictVersion, requiredVersion을 설정할 수 있다. (host도 동일)

       > return undefined 이면 nx default 값 사용

       > return false 이면 shared library로 사용하지 않겠다는 의미이다. 

       > version을 명시하는 경우는 host와 remote간에 버전이 서로 틀릴 경우 사용한다. 

module.exports = {
  name: 'mf-store',
  exposes: {
    './Module': './src/remote-entry.ts',
  },
  shared: (libraryName, config) => {
    if (libraryName && libraryName.indexOf('@gv') >= 0) {
      config = { singleton: true, strictVersion: true, requiredVersion: '1.0.0' };
    }
    console.log('--- remote libraryName:', libraryName, config);
    return config;
  },
};

- project.json 

  + serve의 executor가 webpack-dev-server가 이니라, module-federation-dev-server 이다. 이는 host 기동시 remote도 자동 기동해 준다.

 "serve": {
      "executor": "@nx/react:module-federation-dev-server",
      "defaultConfiguration": "development",
      "options": {
        "buildTarget": "mf-store:build",
        "hmr": true,
        "proxyConfig": "apps/mf/store/proxy.conf.json",
        "port": 3001
      },
      "configurations": {
        "development": {
          "buildTarget": "mf-store:build:development"
        },
        "production": {
          "buildTarget": "mf-store:build:production",
          "hmr": false
        }
      }
    },

- remote-entry.ts 파일

  + host에 접근할 micro-frontend 애플리케이션

export { default } from './app/dashboard-app';

 

host app 설정 파일들

- webpack.config.js 개발시 내역은 remote app설정과 동일하다.

- webpack.config.prod.js 에는 remote app의 url path 가 설정된다.

// host의 webpack.config.prod.js 내역

const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
const { withModuleFederation } = require('@nx/react/module-federation');

const baseConfig = require('./module-federation.config');

const prodConfig = {
  ...baseConfig,
  /*
   * Remote overrides for production.
   * Each entry is a pair of a unique name and the URL where it is deployed.
   *
   * e.g.
   * remotes: [
   *   ['app1', 'http://app1.example.com'],
   *   ['app2', 'http://app2.example.com'],
   * ]
   *
   * You can also use a full path to the remoteEntry.js file if desired.
   *
   * remotes: [
   *   ['app1', 'http://example.com/path/to/app1/remoteEntry.js'],
   *   ['app2', 'http://example.com/path/to/app2/remoteEntry.js'],
   * ]
   */
  remotes: [['mf-store', 'http://localhost:3001/']],
};

// Nx plugins for webpack to build config object from Nx options and context.
module.exports = composePlugins(withNx(), withReact(), withModuleFederation(prodConfig), (config) => {
  // Update the webpack config as needed here.
  // e.g. `config.plugins.push(new MyPlugin())`

  // .tsx 에서  import 구문 ordering 경고 문구 발생 해결하기
  // https://github.com/facebook/create-react-app/issues/5372
  const instanceOfMiniCssExtractPlugin = config.plugins.find(
    (plugin) => plugin.constructor.name === 'MiniCssExtractPlugin'
  );

  if (instanceOfMiniCssExtractPlugin) {
    instanceOfMiniCssExtractPlugin.options.ignoreOrder = true;
  }

  return config;
});

- module-federation.config.js host 자신 app의 명칭과 remote app 의 명칭을 설정한다. 

module.exports = {
  name: 'mf-host',
  remotes: ['mf-store'],
  shared: (libraryName, config) => {
    // ref: https://www.angulararchitects.io/en/aktuelles/the-microfrontend-revolution-part-2-module-federation-with-angular/
    if (libraryName && libraryName.indexOf('@gv') >= 0) {
      config = { singleton: true, strictVersion: true, requiredVersion: '1.0.0' };
    }
    console.log('--- host libraryName:', libraryName, config);
    return config;
  },
};

  + node_modules/@nx/react/src/module-federation/with-module-federation.js 소스 내역

      > NX에서 host의 sharedLibraries 에 대해 자동으로 목록을 만들어 준다. 

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.withModuleFederation = void 0;
const tslib_1 = require("tslib");
const utils_1 = require("./utils");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
/**
 * @param {ModuleFederationConfig} options
 * @return {Promise<AsyncNxWebpackPlugin>}
 */
function withModuleFederation(options) {
    return tslib_1.__awaiter(this, void 0, void 0, function* () {
        const { sharedDependencies, sharedLibraries, mappedRemotes } = yield (0, utils_1.getModuleFederationConfig)(options);
        return (config, ctx) => {
            var _a;
            config.output.uniqueName = options.name;
            config.output.publicPath = 'auto';
            config.optimization = {
                runtimeChunk: false,
            };
            config.experiments = Object.assign(Object.assign({}, config.experiments), { outputModule: true });
            config.plugins.push(new ModuleFederationPlugin({
                name: options.name,
                library: (_a = options.library) !== null && _a !== void 0 ? _a : { type: 'module' },
                filename: 'remoteEntry.js',
                exposes: options.exposes,
                remotes: mappedRemotes,
                shared: Object.assign({}, sharedDependencies),
            }), sharedLibraries.getReplacementPlugin());
            return config;
        };
    });
}
exports.withModuleFederation = withModuleFederation;
//# sourceMappingURL=with-module-federation.js.map

 

- remotes.d.ts 는 ts에서 remote app의 모듈을 import 하기위한 definition 파일이다. 

// Declare your remote Modules here
// Example declare module 'about/Module';
declare module 'mf-store/Module';

- project.json 에 implicitDependencies 설정하면 mf-host와 mf-store는 하나의 애플리케이션으로 간주된다. (참조)

{
  "name": "mf-host",
  "$schema": "../../../node_modules/nx/schemas/project-schema.json",
  "sourceRoot": "apps/mf/host/src",
  "projectType": "application",
  "implicitDependencies": ["mf-store"],

 

 

<참조>

https://fe-developers.kakaoent.com/2022/220623-webpack-module-federation/

 

Webpack Module Federation 도입 전에 알아야 할 것들 | 카카오엔터테인먼트 FE 기술블로그

유동식(rich) 실용성 있는 프로그램을 추구합니다. 클래식 기타와 Nutrition 공부를 취미로 삼고 있습니다.

fe-developers.kakaoent.com

https://stackblitz.com/github/webpack/webpack.js.org/tree/main/examples/module-federation?file=README.md 

 

Webpack.js Module Federation Example - StackBlitz

Run official live example code for Webpack.js Module Federation, created by Webpack on StackBlitz

stackblitz.com

NX module federation: https://nx.dev/recipes/module-federation

 

Module Federation and Micro Frontends

How to work with and setup Module Federation with Nx.

nx.dev

연재글: https://www.angulararchitects.io/en/aktuelles/the-microfrontend-revolution-module-federation-in-webpack-5/

 

The Microfrontend Revolution: Module Federation in Webpack 5 - ANGULARarchitects

Module Federation allows loading separately compiled program parts. This makes it an official solution for implementing microfrontends.

www.angulararchitects.io

 

posted by 윤영식
2023. 5. 3. 17:34 React/Architecture

MS 시리즈를 시작한 것이 벌써 2년전이 되어서 nx 최신 버전으로 업데이트를 진행한다. 

 

NX 환경 버전업

nodejs는 최신 LST 버전인 18.16.0 을 사용한다.

$ nvm install 18.16.0
또는 설치되어 있다면
$ nvm use 18.16.0

create-nx-workspace 최신으로 frontend 폴더 밑으로 비어있는 apps, libs 환경을 생성한다. 

$ npx create-nx-workspace@latest frontend

// 옵션 선택
> integrated monorepos
> react
> portal/web (애플리케이션 위치)
> webpack (번들러)
> SCSS (스타일 프리컴파일러)
> No CI

옵션 선택 결과

생성결과 

apps/portal/web 이 생성되었다. 사용하지 않는 web-e2e를 정리한다.  별도의 애플리케이션을 생성하려면 workspace.json 파일을 루트 폴더에 생성한다. 

// workspace.json 내용
{
  "version": 2,
  "projects": {
    "asset-web": "apps/micro-apps/asset",
    "dashboard-web": "apps/micro-apps/dashboard",
    "management-web": "apps/micro-apps/management",
    "portal-web": "apps/portal/web",
    "system-web": "apps/micro-apps/system",
    "user-web": "apps/micro-apps/user"
  },
  "cli": {
    "defaultCollection": "@nrwl/react"
  },
  "generators": {
    "@nrwl/react": {
      "application": {
        "style": "scss",
        "linter": "eslint",
        "babel": true
      },
      "component": {
        "style": "scss"
      },
      "library": {
        "style": "scss",
        "linter": "eslint"
      }
    }
  },
  "defaultProject": "portal-web"
}

애플리케이션 생성 명령어

// 5개의 애플리케이션을 생성하고, SASS, webpack을 선택하여 생성한다. 

nx g @nrwl/react:app micro-apps/dashboard
nx g @nrwl/react:app micro-apps/asset
nx g @nrwl/react:app micro-apps/management
nx g @nrwl/react:app micro-apps/system
nx g @nrwl/react:app micro-apps/user

패키지를 생성한다. 

// SASS, jest, rollup 을 선택한다. 

nx g @nrwl/react:lib web/login/default --publishable --importPath=@gv/web-login-default

 

새로운 패키지와 애플리케이션이 생성된 폴더에 모든 soucre files 을 copy & paste 한다. 

 

버전업 이후 수정사항

React v17 -> v18 업데이트후 변경점. main.tsx 에서 root 생성 방법이 변경되었다.

// React v17
import * as ReactDOM from 'react-dom';
...
ReactDOM.render(
  <Suspense fallback={<GVSpinner isFull />}>
    <GVMicroApp />
  </Suspense>,
  document.getElementById('root')
);

// React v18
import * as ReactDOM from 'react-dom/client';
...
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <Suspense fallback={<GVSpinner isFull />}>
    <GVMicroApp />
  </Suspense>
);

AntD v4 -> v5 로 변경되면서 v5에서 cssinjs 방식을 사용하면서 *.less 방식이 사라졌다. 기본적인 reset.css만을 설정한다. 

// styles.scss 에 reset.css 포함
// AntD reset
@import "~antd/dist/reset.css";

// project.json에 styles.scss 포함 
      "options": {
        "compiler": "babel",
        ...
        "styles": ["apps/micro-apps/dashboard/src/styles.scss"],
        ...
        "webpackConfig": "apps/micro-apps/dashboard/webpack.config.js"
      },

Webpack의 min-css-extract-plugin을 사용하면서 build warning 나오는 import ordering 메세지 제거하기 

// webpack.config.js
module.exports = composePlugins(withNx(), withReact(), (config) => {
  // Update the webpack config as needed here.
  // e.g. `config.plugins.push(new MyPlugin())`

  // .tsx 에서  import 구문 ordering 경고 문구 발생 해결하기
  // https://github.com/facebook/create-react-app/issues/5372
  const instanceOfMiniCssExtractPlugin = config.plugins.find(
    (plugin) => plugin.constructor.name === 'MiniCssExtractPlugin'
  );

  if (instanceOfMiniCssExtractPlugin) {
    instanceOfMiniCssExtractPlugin.options.ignoreOrder = true;
  }

  return config;
});

 

 

Nx를 업데이트하면 기존의 workspace.json 파일을 사용하지 않는다. 그리고 webpack v5.* 버전을 사용한다. webpack v5는 Module Federation을 지원하므로 이에 대한 설정을 진행해 본다. 

 

<참조>

https://mobicon.tistory.com/586

 

[MS-2] React & Nest 기반 애플리케이션 및 Micro Service 통신 설정

React와 Nest를 기반으로 마이크로 서비스를 구축해 본다. 개발 환경은 Nx를 사용한다. Nx 환경구축은 [React HH-2] 글을 참조한다. 목차 Gateway, Dashboard등의 Application 생성 Application에서 사용하는 Library

mobicon.tistory.com

https://webpack.kr/concepts/module-federation/

 

Module Federation | 웹팩

웹팩은 모듈 번들러입니다. 주요 목적은 브라우저에서 사용할 수 있도록 JavaScript 파일을 번들로 묶는 것이지만, 리소스나 애셋을 변환하고 번들링 또는 패키징할 수도 있습니다.

webpack.kr

 

posted by 윤영식
2021. 10. 7. 10:59 React/Architecture

운영환경을 만들경우 번들링 파일간의 충돌을 최소화하기 위해 i18n 파일의 위치를 변경한다. 

  • Backend i18n은 public에 있을 필요가 없다. 
  • Frontend i18n은 위치도 간소화 한다. 

 

i18N 메세지 파일 위치 변경

Backend i18n 변경

apps/gateway/api/src/public/assets/i18n 의 assets 폴더를 apps/gateway/api/src 폴더 밑으로 위치 변경하고, assets/i18n/api 폴더를 assets/i18n 폴더 밑으로 이동한다. 

apps/gateway/api/project.json 에 assets 경로 추가하여 번들링시 포함되도록 한다. 

apps/gateway/api/src/environments/config.json 에서 i18n 위치를 변경한다.

Dashboard, Configuration, Back-Office의 API Backend에도 동일 환경을 적용한다. 특히 app.module.ts에 내역중 i18n고 TypeORM, Exception Filter 내역을 추가한다. 

import { join } from 'path';
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getMetadataArgsStorage } from 'typeorm';

import { GatewayApiAppService, EntitiesModule, AuthModule, AuthMiddleware } from '@rnm/domain';
import { GlobalExceptionFilter, ormConfigService, RolesGuard, TranslaterModule } from '@rnm/shared';

import { environment } from '../environments/environment';
import { DashboardModule } from './dashboard/microservice/dashboard.module';
import { ConfigurationModule } from './configuration/microservice/configuration.module';
import { BackOfficeModule } from './back-office/microservice/back-office.module';
import { AppController } from './app.controller';
import { AuthController } from './auth/auth.controller';
import { UserController } from './user/user.controller';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      exclude: [
        '/api/auth*',
        '/api/gateway*', '/api/dashboard*', '/api/configuration*', '/api/back-office*',
        '/dashboard*', '/configuration*', '/back-office*'
      ],
    }),
    // ORM
    TypeOrmModule.forRoot({
      ...ormConfigService.getTypeOrmConfig(),
      entities: getMetadataArgsStorage().tables.map(tbl => tbl.target)
    }),
    // i18n
    TranslaterModule,
    // TypeORM
    EntitiesModule,
    // MicroService
    DashboardModule,
    ConfigurationModule,
    BackOfficeModule,
    // Auth
    AuthModule
  ],
  controllers: [
    AuthController,
    AppController,
    UserController
  ],
  providers: [
    GatewayApiAppService,
    // Global Exception Filter
    {
      provide: APP_FILTER,
      useClass: GlobalExceptionFilter,
    },
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ]
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    if(!environment || !environment.production) {
      return;
    }

    consumer
      .apply(AuthMiddleware)
      .forRoutes(...[
        { path: '/dashboard*', method: RequestMethod.ALL },
        { path: '/configuration*', method: RequestMethod.ALL },
        { path: '/back-office*', method: RequestMethod.ALL },
        // { path: '/api/*', method: RequestMethod.ALL },
      ]);
  }
}

 

 

Frontend i18n 위치 변경

apps/gateway/web/src/assets/i18n/web/locale-en.json 파일의 위치를 apps/gateway/web/src/assets/i18n/locale-en.json 로 옮긴다. 

apps/gateway/web/src/environments/config.json 파일을 위의 그림처럼 추가하고, i18n, auth 관련 설정을 넣는다. I18N_JSON_PATH 앞에 /dashboard 가 추가된것에 주의 한다. 

// config.json
{
  "AUTH": {
    "SECRET": "iot_secret_auth",
    "EXPIRED_ON": "1d",
    "REFRESH_SECRET": "iot_secret_refresh",
    "REFRESH_EXPIRED_ON": "7d"
  },
  "I18N_LANG": "en",
  "I18N_JSON_PATH": "/dashboard/assets/i18n/"
}

apps/gateway/web/src/environments/environment.ts 파일에 config.json을 import하여 export 한다.

export const environment = {
  production: false,
};

export const config = require('./config.json');

apps/gateway/web/src/app/core/i18n.ts 파일을 libs/ui/src/lib/i18n 폴더 밑으로 옮기고, 내역을 수정한다. 

import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import XHR from 'i18next-xhr-backend';

export function initI18N(config: any) {
  const backendOptions = {
    loadPath: (config.I18N_JSON_PATH || '/assets/i18n/') + 'locale-{{lng}}.json',
    crossDomain: true,
  };
  
  i18next
    .use(XHR)
    .use(initReactI18next)
    .init({
      backend: backendOptions,
      debug: true,
      lng: config.I18N_LANG || 'en',
      fallbackLng: false,
      react: {
        useSuspense: true
      }
    });
}

libs/ui/src/index.ts export를 추가한다. 

export * from './lib/ajax/http.service';
export * from './lib/i18n/i18n';

 

다음으로 apps/gateway/web/src/main.tsx 에서 initI18N을 초기화 한다. 

import * as ReactDOM from 'react-dom';

import { initI18N } from '@rnm/ui';

import App from './app/app';
import { config } from './environments/environment';

initI18N(config);
ReactDOM.render(<App />, document.getElementById('root'));

 

 

개발환경에서 Dashboard Web Dev Server로 연결하기

 

Gateway - Dashboard 로컬 개발시에는 총 4개의 프로세스가 구동되고 상호 연관성을 갖는다. 

  • Gateway API (NodeJS & NestJS), Gateway Frontend (Web Dev Server & React) 로 Gateway하나에 두개의 프로세스가 구동된다. 
  • Dashboard API, Dashboard Frontend 도 두개의 프로세스가 구동된다. 

4개 프로세스간의 관계

개발시에 전체 루틴을 처리하고 싶다면 위와 같은 Proxy 설정이 되어야 한다. 환경 설정을 다음 순서로 진행한다. 

 

Step-1) Gateway Web에서 Gateway API로 Proxy 

apps/gateway/web/proxy.conf.json 환경은 Dashboard, Configuration, Back-Office 모두를 proxy 한다. 그리고 apps/gateway/web/project.json 안에 proxy.conf.json과 포트 9000 을 설정한다. 

 

Step-2) Gateway API에서 Dashboard Web으로 Proxy

apps/gateway/api/src/environments/config.json 에서 REVERSE_ADDRESS가 기존 Dashboard API 의 8001 이 아니라, Dashboard Web의 9001 로 포트를 변경하면 된다. 

 

Step-3) Dashboard Web 에서 Dashboard API로 proxy

Dashboard API로 proxy 하기위해 apps/dashboard/web/proxy.conf.json 파일을 추가한다. api 호출은 dashboard api의 8001로 proxy 한다.

{
  "/dashboard/api/*": {
    "target": "http://localhost:8001",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  }
}

apps/dashboard/web/project.json 에 설정한다. 

  • proxyConfig
  • port
  • baseHref: "/dashboard/"를 설정한다. "/dashboard"로 하면 안된다.

 

Step-4) Dashboard API 변경사항

apps/dashboard/api/src/public/dashboard 하위 내역을 모드 apps/dashboard/api/src/public으로 옮기고, dashboard  폴더를 삭제한다. 

apps/dashboard/api/src/environments/config.json 의 HTTP 포트는 8001 이다. 

 

테스트 

먼저 콘솔에서 gateway, dashboard web을 구동한다. 

$> nx serve gateway-web
NX  Web Development Server is listening at http://localhost:9000/

$> nx serve dashboard-web
>  NX  Web Development Server is listening at http://localhost:9001/

VSCode에서 gateway, dashboard api를 구동한다. 

브라우져에서 http://localhost:9000 을 호출하고, 로그인 해서 dashboard web 의 index.html 이 호출되는지 체크한다. 

proxy통해 dashboard web의 index.html 호출 성공

 

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-11

 

Release ms-11 · ysyun/rnm-stack

fixed i18n config and test env

github.com

 

posted by 윤영식
2021. 10. 2. 21:10 React/Architecture

NestJS과 React에 i18n을 적용하고, config 파일로딩에 대한 리팩토링과 기타 기능들을 추가로 적용한다. 

 

 

NestJS에 i18n 적용

nestjs-i18n 패키지를 사용한다. 

$> yarn add nestjs-i18n

 

i18n message 파일은 json 포멧이고, 이를 위해 apps/gateway/api/src/public/assets/i18n/api 폴더를 생성한다. i18n/api 폴더에는 언어에 맞는 폴더를 생성한다. 

  • nestjs 번들링 배포시 api 서버의 i18n 파일은 public/assets/i18n/api 폴더 하위에 위치한다. 
  • react 번들링 파일의 i18n 파일은 public/assets/i18n/web 폴더 하위에 위치한다.

libs/shared/src/lib/configuration/config.model.ts 의 GatewayConfiguration에 I18N_LANG 을 추가한다.

// config.model.ts
export interface MicroServiceConfiguration {
  REVERSE_CONTEXT?: string;
  REVERSE_ADDRESS?: string;
  HTTP_PORT?: number,
  TCP_HOST?: string;
  TCP_PORT?: number,
  GLOBAL_API_PREFIX?: string;
  AUTH?: AuthConfig;
  I18N_LANG?: string; // <== 요기
  I18N_JSON_PATH?: string; // <== 요기
}

export interface GatewayConfiguration {
  HTTP_PORT?: number,
  DASHBOARD?: MicroServiceConfiguration;
  CONFIGURATION?: MicroServiceConfiguration;
  BACK_OFFICE?: MicroServiceConfiguration;
  AUTH?: AuthConfig;
  I18N_LANG?: string; // <== 요기
  I18N_JSON_PATH?: string; // <== 요기
}

apps/gateway/api/src/environments/config.json 파일에 환경을 설정한다. 

// config.json
{
  "HTTP_PORT": 8000,
  "AUTH": {
    "SECRET": "iot_secret_auth",
    "EXPIRED_ON": "1d",
    "REFRESH_SECRET": "iot_secret_refresh",
    "REFRESH_EXPIRED_ON": "7d"
  },
  "I18N_LANG": "en",
  "I18N_JSON_PATH": "/public/assets/i18n/api/",
  ...
}

i18n 파일을 apps/gateway/api/src/public/assets/i18n/api/en(ko)/message.json 파일을 생성하고, 설정한다. 

{
  "USER_NOT_EXIST": "User {username} with this id does not exist"
}

다음으로 libs/shared 쪽에 libs/shared/src/lib/i18n/translater.service.ts 파일을 생성한다.

// translater.service.ts
import { Injectable } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';

@Injectable()
export class TranslaterService {
  constructor(private readonly i18nService: I18nService) { }

  async message(key: string, message: (string | { [k: string]: any; })[] | { [k: string]: any; }): Promise<string> {
    return this.i18nService.translate(`message.${key}`, { args: message });
  }
}

translater module도 libs/shared/src/lib/i18n/translater.module.ts 파일도 생성한다. 

// translater.module.ts
import { join } from 'path';
import { Module } from '@nestjs/common';
import { I18nModule, I18nJsonParser } from 'nestjs-i18n';
import { loadConfigJson } from '@rnm/shared';
import { TranslaterService } from './translater.service';

const config: any = loadConfigJson();

@Module({
  imports: [
    I18nModule.forRoot({
      fallbackLanguage: config.I18N_LANG,
      parser: I18nJsonParser,
      parserOptions: {
        path: join(__dirname, config.I18N_JSON_PATH),
      },
    })
  ],
  providers: [TranslaterService],
  exports: [TranslaterService]
})
export class TranslaterModule { }


// libs/shared/src/index.ts 안에 export도 추가한다. 
export * from './lib/i18n/translater.service';
export * from './lib/i18n/translater.module';

이제 사용을 해본다.

  • apps/gateway/api/src/app/app.module.ts 에 TranslaterModule을 추가한다.
  • apps/gateway/api/src/app/app.controller.ts 에 Service를 사용한다. translate key로는 [fileName].[jsonKey] 를 넣는다. 
// app.module.ts
import { TranslaterModule } from '@rnm/shared';
@Module({
  imports: [
    ...
    // i18n
    TranslaterModule,
    ...
}


// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { GatewayApiAppService } from '@rnm/domain';
import { TranslaterService } from '@rnm/shared';

@Controller('api/gateway')
export class AppController {
  constructor(
    private readonly appService: GatewayApiAppService,
    private readonly translater: TranslaterService
  ) { }

  @Get()
  getData() {
    return this.translater.message('USER_NOT_EXIST', { username: 'Peter Yun' });
  }
}

Gateway API를 디버깅 시작하고, 호출 테스트한다.  Forbidden 에러가 떨어지면 app.module.ts의 AuthMiddleware 경로에서 잠시 "/api*" 설정을 제거후 테스트 한다. 

// apps/gateway/api/src/app/app.module.ts
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AuthMiddleware)
      .forRoutes(...[
        { path: '/dashboard*', method: RequestMethod.ALL },
        { path: '/configuration*', method: RequestMethod.ALL },
        { path: '/back-office*', method: RequestMethod.ALL },
        // { path: '/api/*', method: RequestMethod.ALL }, <== 요기
      ]);
  }
}

맵핑되어 정보가 나옴

에러 메세지에 대해 Global Exception에 적용해 본다. 

import { Request, Response } from 'express';
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { TranslaterService } from '../i18n/translater.service';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  constructor(private readonly translater: TranslaterService) { }

  // async로 Promise 반환
  async catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    let message = (exception as any).message;
    // key, args가 있으면 translater
    if (message && message.key && message.args) {
      message = await this.translater.message(message.key, message.args);
    }
    ...
  }
}

 

 

React에 i18n 적용

react-i18next를 사용한다.

$> yarn add react-i18next i18next i18next-xhr-backend

i18n 설정을 위해 apps/gateway/web/src/app/core/i18n.ts 파일을 생성한다. 

// i18n.ts
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import XHR from 'i18next-xhr-backend';

const backendOptions = {
  loadPath: '/assets/i18n/web/locale-{{lng}}.json',
  crossDomain: true,
};

i18next
  .use(XHR)
  .use(initReactI18next)
  .init({
    backend: backendOptions,
    debug: true,
    lng: 'en',
    fallbackLng: false,
    react: {
      useSuspense: true
    }
  });

export default i18next;

설정파일을 apps/gateway/web/src/assets/i18n/web/locale-en.json 을 생성한다. 

{
  "LOGIN": {
    "USERNAME": "Username",
    "PASSWORD": "Password"
  }
}

apps/gateway/web/src/app/app.tsx 파일에 i18n 파일을 로딩한다. 

// app.tsx
import { Suspense } from 'react';
import styles from './app.module.scss';
import Login from './login/login';

import './core/i18n';

const Loader = () => (
  <div className={styles.loading}>
    {/* <img src={logo} className="App-logo" alt="logo" /> */}
    <div>loading...</div>
  </div>
);

export function App() {
  return (
    <Suspense fallback={<Loader />}>
      <Login />;
    </Suspense>
  );
}
export default App;

apps/gateway/web/src/app/login/login.tsx 에서 useTranslation() hook을 사용한다. 

import { Row, Col, Form, Input, Button } from 'antd';
// import 
import { useTranslation } from 'react-i18next';
...

function Login() {
  const { t, i18n } = useTranslation();
  return (
    <div className={styles.login_container}>
     ...
              // t를 통해 translation
              <Form.Item
                label={t('LOGIN.USERNAME')}
                name="username"
                rules={[{ required: true, message: 'Please input your username!' }]}
              >
                <Input />
              </Form.Item>

              <Form.Item
                label={t('LOGIN.PASSWORD')}
                name="password"
                rules={[{ required: true, message: 'Please input your password!' }]}
              >
                <Input.Password />
              </Form.Item>
     ...
   </div>
  );
}

 

 

Configuration 리팩토링

NestJS에서 사용하는 config.json 파일을 한번만 로딩하도록 libs/shared/src/lib/configuration/config.service.ts 파일을 리팩토링한다. 

// config.service.ts
import * as fs from "fs";
import { join } from 'path';
import { GatewayConfiguration, MicroServiceConfiguration, OrmConfiguration } from "./config.model";

export const loadConfigJson = (message = '[LOAD] config.json file'): MicroServiceConfiguration | GatewayConfiguration => {
  let config: any = process.env.config;
  if (!config) {
    console.log(`${message}:`, `${__dirname}/environments/config.json`);
    const jsonFile = fs.readFileSync(join(__dirname, 'environments', 'config.json'), 'utf8');
    process.env.config = jsonFile;
    config = JSON.parse(jsonFile);
  } else {
    config = JSON.parse(process.env.config as any);
  }
  return config;
}

export const loadOrmConfiguration = (message = '[LOAD] orm-config.json file'): OrmConfiguration => {
  let config: any = process.env.ormConfig;
  if (!config) {
    console.log(`${message}:`, `${__dirname}/environments/orm-config.json`);
    const jsonFile = fs.readFileSync(join(__dirname, 'environments', 'orm-config.json'), 'utf8');
    process.env.ormConfig = jsonFile;
    config = JSON.parse(jsonFile);
  } else {
    config = JSON.parse(process.env.ormConfig as any);
  }
  return config;
}

 

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-10

 

 

<참조>

- nestjs-i18n 적용하기 

https://github.com/ToonvanStrijp/nestjs-i18n

 

GitHub - ToonvanStrijp/nestjs-i18n: Add i18n support inside your nestjs project

Add i18n support inside your nestjs project. Contribute to ToonvanStrijp/nestjs-i18n development by creating an account on GitHub.

github.com

- react best i18n libraries 

https://phrase.com/blog/posts/react-i18n-best-libraries/

 

Curated List: Our Best of Libraries for React I18n – Phrase

There may be no built-in solution for React i18n, but these amazing libraries will help you manage your i18n projects from start to finish.

phrase.com

- react-i18next 공식 홈페이지

https://react.i18next.com/

 

Introduction

 

react.i18next.com

- i18next의 react 사용예

https://github.com/i18next/react-i18next/blob/master/example/react/src/App.js

 

GitHub - i18next/react-i18next: Internationalization for react done right. Using the i18next i18n ecosystem.

Internationalization for react done right. Using the i18next i18n ecosystem. - GitHub - i18next/react-i18next: Internationalization for react done right. Using the i18next i18n ecosystem.

github.com

 

posted by 윤영식
2021. 9. 30. 19:48 React/Architecture

React로 Login 화면을 개발한다. 

 

 

Gateway의 api server와 web dev server의 연결

Gateway를 개발환경에서 api server를 구동하고, web 화면 테스트를 위하여 web dev server가 구동하면 web dev server의 요청이 api server로 proxy 되어야 한다. 

  • apps/gateway/web/proxy.conf.json 파일을 생성한다.
  • apps/gateway/web/project.json에 "proxyConfig" 위치와 "port"는 7000 번으로 설정한다.
// project.json 일부분 

"serve": {
      "executor": "@nrwl/web:dev-server",
      "options": {
        "buildTarget": "gateway-web:build",
        "hmr": true,
        "proxyConfig": "apps/gateway/web/proxy.conf.json", <== 요기
        "port": 7000  <== 요기
      },
      ...
}

proxy.conf.json 내역

{
  "/gateway/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/api/gateway/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/api/auth/login": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/dashboard/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/dashboard/api/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/configuration/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/configuration/api/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/back-office/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/back-office/api/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  }
}

 

 

Web 패키지들 설치

rnn-stack의 글을 참조한다.

  • antd components 패키지 설치: "yarn add antd @ant-design/icons"
  • React Router 설치: "yarn add react-router react-router-dom"
  • Axios 설치: "yarn add axios"

VSCode의 디버깅 실행 환경파일인 launch.json파일에 Web Dev Server를 수행할 수 있도록 설정한다. VSCode에서 실행보다는 별도 terminal 에서 수행하는 것을 권장하고, 옵션으로 사용한다.

// launch.json 일부 내역 
    {
      "type": "node",
      "request": "launch",
      "name": "Gateway API",
      "program": "${workspaceFolder}/apps/gateway/api/src/main.ts",
      "preLaunchTask": "build:gateway-api",
      "outFiles": ["${workspaceFolder}/dist/apps/gateway/api/**/*.js"]
    },
    // 하기 내역
    {
      "type": "node",
      "request": "launch",
      "name": "Gatewy Web",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "start:gateway-web"],
      "outFiles": ["${workspaceFolder}/dist/apps/gateway/web/**/*.js"]
    },

npm의 script로 "start:gateway-web"을 설정한다.

// package.json 일부 

  "scripts": {
    "build:gateway-api": "nx build gateway-api",
    "start:gateway-api": "nx serve gateway-api",
    "build:gateway-web": "nx build gateway-web",
    "start:gateway-web": "nx serve gateway-web",
    ....
  },

이제 VS Code에서 Gateway web을 실행하고, http://localhost:7000/ 호출한다.

Gateway Web Dev Server 기동

 

 

Login 화면 저작

nx 명령을 이용해 login 컴포넌트를 생성한다.

$> nx g @nrwl/react:component login --project=gateway-web

생성된 파일들

login.tsx 내역

// login.tsx
import { Row, Col, Form, Input, Button } from 'antd';
import { httpService } from '@rnm/ui';
import { LoginDto } from '@rnm/model';
import styles from './login.module.scss';

export function Login() {
  const onFinish = (user: LoginDto) => {
    httpService.post<LoginDto>('/api/auth/login', user).subscribe((result: LoginDto) => {
      console.log('Success:', result);
      // redirect dashboard
      location.href = '/dashboard';
    });
  };

  const onFinishFailed = (errorInfo: any) => {
    console.log('Failed:', errorInfo);
  };

  return (
    <div className={styles.login_container}>
      <div className={styles.center_bg}>
        <Row justify="center" align="middle" className={styles.form_container}>
          <Col span={16} offset={6}>
            <Form
              name="basic"
              layout="vertical"
              labelCol={{ span: 16 }}
              wrapperCol={{ span: 16 }}
              initialValues={{ remember: true }}
              onFinish={onFinish}
              onFinishFailed={onFinishFailed}
              autoComplete="off"
            >
              <Form.Item
                label="Username"
                name="username"
                rules={[{ required: true, message: 'Please input your username!' }]}
              >
                <Input />
              </Form.Item>

              <Form.Item
                label="Password"
                name="password"
                rules={[{ required: true, message: 'Please input your password!' }]}
              >
                <Input.Password />
              </Form.Item>

              <Form.Item wrapperCol={{ span: 16 }}>
                <Button type="primary" htmlType="submit" block>
                  Submit
                </Button>
              </Form.Item>
            </Form>
          </Col>
        </Row>
      </div>
    </div>
  );
}

export default Login;

 

 

Gateway API 서버 호출하기

Web 에서 사용할 라이브러리는 분리하고, API와 Web이 공용하는 부분은 Model에만 국한한다. 따라서 @rnm/model 패키지를 생성한다. 

$> nx g @nrwl/react:lib model --publishable --importPath=@rnm/model

domain/entities/user/user.model.ts 을 libs/model/src/lib/user/user.model.ts 로 copy하고, domain에서는 삭제한다. 그리고 user.model사용하는 클래스를 일괄 수정한다. 

// user.model.ts
export interface User {
  id?: number;
  username: string;
  password?: string;
  email?: string;
  firstName?: string;
  lastName?: string;
  role?: string;
  sub?: string | number;
  currentHashedRefreshToken?: string;
}
export type LoginDto = Pick<User, 'username' | 'password'>;
export type TokenPayload = Omit<User, 'password'>;

export enum UserRole {
  ADMIN = 'ADMIN',
  MANAGER = 'MANAGER',
  CUSTOMER = 'CUSTOMER',
  GUEST = 'GUEST',
}

 

다음으로 libs/ui/src/lib/ajax/http.service.ts 파일을 생성하고, http.service.ts 코드에, error처리 notification을 추가한다.

// http.service.ts 일부
  import { notification } from 'antd';
  
  ...
  private executeRequest<T>(args: RequestArgs): Observable<T> {
    const { method, url, queryParams, payload } = args;
    let request: AxiosPromise<T>;
    switch (method) {
      case HttpMethod.GET:
        request = this.httpClient.get<T>(url, { params: queryParams });
        break;
      case HttpMethod.POST:
        request = this.httpClient.post<T>(url, payload);
        break;
      case HttpMethod.PUT:
        request = this.httpClient.put<T>(url, payload);
        break;
      case HttpMethod.PATCH:
        request = this.httpClient.patch<T>(url, payload);
        break;
      case HttpMethod.DELETE:
        request = this.httpClient.delete<T>(url);
        break;
    }

    return new Observable<T>((observer: Observer<T>) => {
      request
        .then((response: AxiosResponse) => {
          observer.next(response.data);
        })
        .catch((error: AxiosError | Error) => {
          this.abort(true);
          if (axios.isAxiosError(error)) {
            if (error.response) {
              // NestJS의 global-exception.filter.ts의 포멧
              const data: any = error.response?.data || {};
              this.showNotification(`[${data.statusCode}] ${data.error}`, data.message);
              console.log(`[${data.statusCode}] ${data.error}: ${data.message}`);
            }
          } else {
            this.showNotification('Unknow Error', error.message);
            console.log(error.message);
          }
        })
        .finally(() => {
          this.completed = true;
          observer.complete();
        });
      return () => this.abort();
    });
  }

  private showNotification(message: string, description: string): void {
    notification.error({
      message,
      description
    });
  }
}

에러처리는 libs/shared/src/lib/filter/global-exception.filter.ts 의 에러 포멧을 따른다. 

import { Request, Response } from 'express';
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const message = (exception as any).message;

    Logger.error(message, (exception as any).stack, `${request.method} ${request.url}`);

    const name = exception?.constructor?.name || 'HttpException';
    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    switch (name) {
      case 'HttpException':
        status = (exception as HttpException).getStatus();
        break;
      case 'UnauthorizedException':
        status = HttpStatus.UNAUTHORIZED;
        break;
      case 'ForbiddenException':
        status = HttpStatus.FORBIDDEN;
        break;
      case 'QueryFailedError':  // this is a TypeOrm error
        status = HttpStatus.UNPROCESSABLE_ENTITY;
        break;
      case 'EntityNotFoundError':  // this is another TypeOrm error
        status = HttpStatus.UNPROCESSABLE_ENTITY;
        break;
      case 'CannotCreateEntityIdMapError': // and another
        status = HttpStatus.UNPROCESSABLE_ENTITY;
        break;
      default:
        status = HttpStatus.INTERNAL_SERVER_ERROR;
    }

	// 에러 리턴 포멧
    response.status(status).json(
      {
        statusCode: status,
        error: name,
        message,
        method: request.method,
        path: request.url,
        timestamp: new Date().toISOString()
      }
    );
  }
}

 

테스트 진행시 UI가 Nest쪽 패키지를 사용하면 번들링 오류가 발생할 수 있다. 따라서 libs 하위의 패키지들은 향후 API용, WEB용 구분하여 사용하고, model 패키지만 공용으로 사용한다. API용, WEB용을 구분한다면 하기와 같이 별도 폴더로 묶어 관리하는게 좋아 보인다. 

api, web, model 분리

 

Nx 기반 library 생성 명령은 다음과 같다. 

// api library
$>  nx g @nrwl/nest:lib api/shared --publishable --importPath=@rnm/api-shared
$>  nx g @nrwl/nest:lib api/domain --publishable --importPath=@rnm/api-domain

// web library
$> nx g @nrwl/react:lib web/shared --publishable --importPath=@gv/web-shared
$> nx g @nrwl/react:lib web/domain --publishable --importPath=@gv/web-domain
$> nx g @nrwl/react:lib web/ui --publishable --importPath=@gv/web-ui

// model library
$> nx g @nrwl/nest:lib model --publishable --importPath=@gv/model

라이브러 생성 폴더 구조

 

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-9

 

Release ms-9 · ysyun/rnm-stack

[ms-9] added login component and enhanced ajax error notification

github.com

 

 

<참조>

- React 라이브러리 환경 구성

https://mobicon.tistory.com/580

 

[React HH-3] 라이브러리 설정 - Axios, RxJS

React 외에 애플리케이션 개발을 위한 라이브러리를 설치한다. UI Components PrimeReact, EUI, MaterialUI, AntD 검토후 소스레벨 최신으로 반영하고 있고, 다양한 비즈니스 UX 대응 가능한 AntD를 선택한다. //..

mobicon.tistory.com

- location.href와 location.replace 차이점

https://opentutorials.org/module/2919/22904

 

location.href 와 location.replace 차이점 - JavaScript Tips

[출처] [자바스크립트] location.href 와 location.replace 의 차이점.|작성자 왕따짱 location.href location.replace   기능 새로운 페이지로 이동된다. 기존페이지를 새로운 페이지로 변경시킨다.   형태 속

opentutorials.org

 

posted by 윤영식
2021. 9. 30. 13:22 React/Architecture

 

NestJS에서 제공하는 Auth와 Role 기능을 확장해 본다. NestJS는 그외 Configuration, Logging, Filter, Interceptor등 다양한 기능을 확장하여 적용할 수 있도록 한다. 

 

 

Role 데코레이터 추가

Role 체크를 위한 데코레이터를 libs/shared/src/lib/decorator/roles.decorator.ts 를 추가한다.

// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

 

 

Role 가드 추가

request시에 user 정보의 role을 통해 match되는지를 체크하는 가드(guard)를 libs/shared/src/lib/guard/role.guard.ts 추가한다. 

  • 요구하는 roles가 없으면 bypass 한다.
  • user가 없다면 즉, 로그인한 사용자가 아니거나, Login Token이 없다면 Forbidden 에러를 발생시킨다.
// role.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

const matchRoles = (roles: string[], userRoles: string) => {
  return roles.some(role => role === userRoles);
};

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) { }

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles) {
      return true;
    }

    const req = context.switchToHttp().getRequest() as any;
    const user = req.user;
    if (!user) {
      throw new ForbiddenException('User does not exist');
    }
    return matchRoles(requiredRoles, user.role);
  }
}

 

로그인후 express의 request에 user 객체 할당

로그인을 하면 사용자 정보가 Token에 담긴다. @Role 데코레이터를 체크하기 전에 Token 정보를 기반으로 user 정보를 추출한다. 

  • 로그인 토큰: LOGIN_TOKEN

libs/domain/src/lib/auth/auth.middleware.ts 파일을 생성하고, 쿠키의 LOGIN_TOKEN에서 user정보를 얻는다.

import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { verify } from 'jsonwebtoken';

import { loadConfigJson } from '@rnm/shared';
const config: any = loadConfigJson();

@Injectable()
export class AuthMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    if (req.user) {
      next();
      return;
    }
    
    const accessToken = req?.cookies?.LOGIN_TOKEN;
    let user;
    try {
      user = verify(
        accessToken,
        config?.AUTH?.SECRET,
      );
    } catch (error) {
      throw new ForbiddenException('Please register or sign in.');
    }

    if (user) {
      req.user = user;
    }
    next();
  }
}

request에 user를 할당하는 미들웨어와 Role Guard를 apps/gateway/api/src/app/app.module.ts 에 설정한다. 

  • RolesGuard 등록
  • AuthMiddleware path들 등록
// app.module.ts
import { join } from 'path';
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getMetadataArgsStorage } from 'typeorm';

import { GatewayApiAppService, EntitiesModule, AuthModule, AuthMiddleware } from '@rnm/domain';
import { GlobalExceptionFilter, ormConfigService, RolesGuard } from '@rnm/shared';

import { DashboardModule } from './dashboard/microservice/dashboard.module';
import { ConfigurationModule } from './configuration/microservice/configuration.module';
import { BackOfficeModule } from './back-office/microservice/back-office.module';
import { AppController } from './app.controller';
import { AuthController } from './auth/auth.controller';
import { UserController } from './user/user.controller';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      exclude: [
        '/api/auth*',
        '/api/gateway*', '/api/dashboard*', '/api/configuration*', '/api/back-office*',
        '/dashboard*', '/configuration*', '/back-office*'
      ],
    }),
    // ORM
    TypeOrmModule.forRoot({
      ...ormConfigService.getTypeOrmConfig(),
      entities: getMetadataArgsStorage().tables.map(tbl => tbl.target)
    }),
    EntitiesModule,
    // MicroService
    DashboardModule,
    ConfigurationModule,
    BackOfficeModule,
    // Auth
    AuthModule
  ],
  controllers: [
    AuthController,
    AppController,
    UserController
  ],
  providers: [
    GatewayApiAppService,
    // Global Exception Filter
    {
      provide: APP_FILTER,
      useClass: GlobalExceptionFilter,
    },
    // 1) Role Guard 등록
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ]
})
export class AppModule implements NestModule {
  // 2) Auth Middleware 등록
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AuthMiddleware)
      .forRoutes(...[
        { path: '/dashboard*', method: RequestMethod.ALL },
        { path: '/configuration*', method: RequestMethod.ALL },
        { path: '/back-office*', method: RequestMethod.ALL },
        { path: '/api*', method: RequestMethod.ALL },
      ]);
  }
}

 

 

Role 사용하기

user 테이블에 Role이 저장되어있다. 

user.model.ts 소스에 UserRole enum을 추가한다. 

// user.model.ts
export interface User {
  id?: number;
  username: string;
  password?: string;
  email?: string;
  firstName?: string;
  lastName?: string;
  role?: string;
  sub?: string | number;
  currentHashedRefreshToken?: string;
}
export type LoginDto = Pick<User, 'username' | 'password'>;
export type TokenPayload = Omit<User, 'password'>;

// User Role
export enum UserRole {
  ADMIN = 'ADMIN',
  MANAGER = 'MANAGER',
  CUSTOMER = 'CUSTOMER',
  GUEST = 'GUEST',
}

apps/gateway/api/src/app/user/user.controller.ts 안에 @Roles을 적용한다. 

// user.controller.ts
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';

import { JwtAuthGuard, UserService } from '@rnm/domain';
import { User, UserRole } from '@rnm/model';
import { Roles } from '@rnm/shared';

@Controller('api/gateway/user')
export class UserController {
  constructor(
    private readonly service: UserService
  ) { }

  @UseGuards(JwtAuthGuard)
  @Post()
  @Roles(UserRole.ADMIN, UserRole.MANAGER) // <== 요기
  async create(@Body() data: User): Promise<User> {
    const savedUser = await this.service.create(data);
    if (!savedUser) {
      return;
    }
    return savedUser;
  }
  ....
 }

 

이후 열심히 사용해 보자.

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-8

 

Release ms-8 · ysyun/rnm-stack

[ms-8] added role guard for authorization

github.com

 

 

<참조>

- NestJS Authorization: https://docs.nestjs.kr/security/authorization

 

네스트JS 한국어 매뉴얼 사이트

네스트JS 한국, 네스트JS Korea 한국어 매뉴얼

docs.nestjs.kr

- JWT Role based authentication: https://github.com/rangle/jwt-role-based-authentication-examples

 

GitHub - rangle/jwt-role-based-authentication-examples: Implement the same backend using graphql, nestjs and deno.

Implement the same backend using graphql, nestjs and deno. - GitHub - rangle/jwt-role-based-authentication-examples: Implement the same backend using graphql, nestjs and deno.

github.com

 

posted by 윤영식
2021. 9. 30. 13:22 React/Architecture

Login Auth Token이 만료되었을 때 Refresh Token을 통하여 다시 Auth Token을 생성토록한다. 

  • Refresh Token을 서버에 저장한다. 다른 기기에서 로그인하면 기존 로그인 기기의 Refresh Token과 비교하여 틀리므로 여러 기기의 로그인을 방지한다. 
  • 서버에 여러개의 Refresh Token을 저장할 수 있다면 기기 제한을 할 수 있겠다. 또한 변조된 refresh token의 사용을 막을 수 있다.

 

User Entity 업데이트

libs/domain/src/lib/entities/user/user.entity.ts 에 refreshToken 컬럼을 추가한다. 

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';

@Entity('user_iot')
export class UserEntity {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ unique: true, length: 50 })
  username!: string;

  @Column()
  password!: string;

  @Column({ length: 255 })
  email!: string;

  @Column({ name: 'first_name', length: 100 })
  firstName!: string;

  @Column({ name: 'last_name', length: 100 })
  lastName!: string;

  @Column({ default: 'GUEST' })
  role!: string;

  @CreateDateColumn({ name: 'created_at', select: false })
  createdAt?: Date;

  @CreateDateColumn({ name: 'updated_at', select: false })
  updatedAt?: Date;

  // refresh token 저장
  @Column({
    name: 'current_hashed_refresh_token',
    nullable: true
  })
  currentHashedRefreshToken?: string;

}

 

 

User Service에 refreshToken 매칭

Cookie의 REFRESH_TOKEN이 서버에 저장된 값과 맞으면 해당 user정보를 반환하는 코드를 libs/domain/src/lib/entities/user/user.service.ts 에 추가한다.

// user.service.ts 일부

  async getUserIfRefreshTokenMatches(refreshToken: string, id: number): Promise<User | undefined> {
    const user = await this.findOneById(id);

    const isRefreshTokenMatching = await bcryptCompare(
      refreshToken,
      user.currentHashedRefreshToken as string
    );

    if (isRefreshTokenMatching) {
      return user;
    }
    return;
  }

 

 

JWT Refresh Strategy와 Guard 추가

Guard에서 사용할 Refresh Strategy를 libs/domain/src/lib/auth/strategies/jwt-refresh.strategy.ts 파일 생성후 추가한다.

import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

import { UserService } from '@rnm/domain';
import { loadConfigJson } from '@rnm/shared';
import { TokenPayload, User } from '@rnm/model';

const config: any = loadConfigJson();

@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh-token') {
  constructor(
    private readonly userService: UserService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
        return request?.cookies?.REFRESH_LOGIN_TOKEN;
      }]),
      secretOrKey: config?.AUTH?.REFRESH_SECRET,
      passReqToCallback: true,
    });
  }

  async validate(request: Request, payload: TokenPayload): Promise<User | undefined> {
    const refreshToken = request.cookies?.REFRESH_LOGIN_TOKEN;
    return this.userService.getUserIfRefreshTokenMatches(refreshToken, payload.id as number);
  }
}

Refresh Guard도 libs/domain/src/lib/auth/guards/jwt-auth-refresh.guard.ts 파일 생성하고 추가한다. 

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') { }

파일 추가후에는 항시 libs/domain/src/index.ts 안에 export를 해야 한다. 

export * from './lib/constants/core.contant';
export * from './lib/entities/user/user.entity';
export * from './lib/entities/user/user.service';
export * from './lib/entities/entity.module';

export * from './lib/models/request.model';

export * from './lib/auth/auth.service';
export * from './lib/auth/auth.middleware';
export * from './lib/auth/auth.module';
export * from './lib/auth/guards/local-auth.guard';
export * from './lib/auth/guards/jwt-auth.guard';
export * from './lib/auth/guards/jwt-auth-refresh.guard'; // <== 요기

export * from './lib/auth/strategies/local.strategy';
export * from './lib/auth/strategies/jwt.strategy';
export * from './lib/auth/strategies/jwt-refresh.strategy'; // <== 요기

export * from './lib/service/gateway/api/service/gateway-api-app.service';
export * from './lib/service/dashboard/api/service/dashboard-api-app.service';
export * from './lib/configuration/api/service/configuration-api-app.service';
export * from './lib/service/back-office/api/service/backoffice-api-app.service';

 

 

RefreshToken과 AuthToken을 Cookie에 실어 보내기

두가지 Token을 response cookie에 실어 보내기위해 먼저 cookie 생성하는 코드를  libs/domain/src/lib/auth/auth.service.ts 에 추가한다. 

// auth.service.ts 일부
  getCookieWithJwtAccessToken(payload: TokenPayload, hasAuthorization = false) {
    const token = this.jwtService.sign(payload, {
      secret: config?.AUTH?.SECRET || 'iot_app',
      expiresIn: config?.AUTH?.EXPIRED_ON || '1d'
    });
    if (hasAuthorization) {
      return [`LOGIN_TOKEN=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.EXPIRED_ON}`, `Authorization=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.EXPIRED_ON}`];
    } else {
      return [`LOGIN_TOKEN=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.EXPIRED_ON}`];
    }
  }

  getCookieWithJwtRefreshToken(payload: TokenPayload) {
    const token = this.jwtService.sign(payload, {
      secret: config?.AUTH?.REFRESH_SECRET || 'io_app_refresh',
      expiresIn: config?.AUTH?.REFRESH_EXPIRED_ON || '7d'
    });
    const cookie = `REFRESH_LOGIN_TOKEN=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.REFRESH_EXPIRED_ON}`;
    return {
      cookie,
      token
    }
  }

  getCookiesForLogOut() {
    return [
      'LOGIN_TOKEN=; HttpOnly; Path=/; Max-Age=0',
      'REFRESH_LOGIN_TOKEN=; HttpOnly; Path=/; Max-Age=0'
    ];
  }

로apps/gateway/api/src/app/auth/auth.controller.ts 안에 하기 로직을 추가한다.

  • 로그인 했을 때 해당 Cookie를 등록한다. 
  • 로그아웃할 때 해당 Cookie 내용을 삭제한다. 
  • Auth Token (Forbidden)오류 발생시 RefreshToken을 통해 Auth Token 재생성한다. 
import { Controller, Get, HttpCode, Post, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common';

import { AuthService, JwtAuthGuard, LocalAuthGuard, JwtRefreshGuard, RequestWithUser, UserService } from '@rnm/domain';
import { TokenPayload } from '@rnm/model';

@Controller('api/auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly userService: UserService
  ) { }

  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Req() req: RequestWithUser): Promise<any> {
    const { user } = req;
    if (user) {
      const payload: TokenPayload = { username: user.username, sub: user.id, email: user.email, role: user.role };
      const accessTokenCookie = this.authService.getCookieWithJwtAccessToken(payload);
      const {
        cookie: refreshTokenCookie,
        token: refreshToken
      } = this.authService.getCookieWithJwtRefreshToken(payload);
      const loginUsernameCookie = this.authService.getCookieWithLoginUsername(payload);

      await this.userService.setCurrentRefreshToken(refreshToken, user.id);

      // 반드시 req.res로 쿠키를 설정
      req.res.setHeader('Set-Cookie', [...accessTokenCookie, refreshTokenCookie, loginUsernameCookie]);
      return {
        payload,
        accessTokenCookie,
        refreshTokenCookie
      };
    } else {
      throw new UnauthorizedException({
        error: 'User does not exist'
      });
    }
  }

  @UseGuards(JwtAuthGuard)
  @Post('logout')
  @HttpCode(200)
  async logout(@Req() req: RequestWithUser, @Res() res): Promise<any> {
    await this.userService.removeRefreshToken(req.user.id);
    res.setHeader('Set-Cookie', this.authService.getCookiesForLogOut());
  }

  @UseGuards(JwtAuthGuard)
  @Get()
  authenticate(@Req() req: RequestWithUser) {
    const user = req.user;
    return user;
  }

  // Refresh Guard를 적용한다.
  @UseGuards(JwtRefreshGuard)
  @Get('refresh')
  refresh(@Req() request: RequestWithUser) {
    const accessTokenCookie = this.authService.getCookieWithJwtAccessToken(request.user);

    request.res.setHeader('Set-Cookie', accessTokenCookie);
    return request.user;
  }
}

 

 

테스트하기 

Postman으로 테스트를 하면 accessTokenCookie가 나온다. 

로그인 결과값

accessTokenCookie를 복사하여 다른 명령 전송시에 Headers에 Cookie를 등록하여 사용한다. 

복사한 accessTokenCookie 사용

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-7

 

Release ms-7 · ysyun/rnm-stack

[ms-8] added role guard for authorization

github.com

 

 

<참조>

- Refresh Token 만들기
   소스: https://github.com/mwanago/nestjs-typescript

   문서: https://wanago.io/2020/09/21/api-nestjs-refresh-tokens-jwt/

 

API with NestJS #13. Implementing refresh tokens using JWT

It leaves quite a bit of room for improvement. In this article, we look into refresh tokens.

wanago.io

 

posted by 윤영식
2021. 9. 27. 18:04 React/Architecture

Micro Service의 앞단 Gateway에서 모든 호출에 대한 인증/인가를 처리한다. 먼저 JWT기반 인증에 대한 설정을 한다. 

  • passwort jwt 설정

 

JWT 처리를 위한  패키지 설치

passport를 통해 JWT를 관리한다.

  • userId/password 기반은 passport-local을 사용
  • JWT 체크 passport-jwt 사용
  • 추가적으로 http security를 위해 express middleware인 helmet 적용
$> yarn add @nestjs/jwt @nestjs/passport passport passport-local passport-jwt helmet
$> yarn add -D @types/passport-local @types/passport-jwt @types/express

 

Passport Local 적용

  • libs/domain/src/lib/auth/strategies 폴더 생성
  • local.strategy.ts 파일 생성
// local.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException({
        error: 'Incorrect username and password'
      });
    }
    return user;
  }
}

 

Username/Password 체크하기

libs/domain/src/lib/auth/  폴더 생성하고, auth.service.ts 파일을 생성한다. 

  • validateUser: LocalStrategy에서 호출한다. username/password 로그인 유효성을 login 호출 이전에 체크한다.
  • login: validate user인 경우 사용자 정보를 통한 webtoken 생성
// auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

import { User } from '../entities/user/user.model';
import { UserService } from '../entities/user/user.service';
import { bcryptCompare } from '../utilties/bcrypt.util';

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
  ) { }

  async validateUser(username: string, pass: string): Promise<any> {
    const user: any = await this.userService.findOne(username) || {};
    if (user && pass === user.password) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(loginUser: User): Promise<any> {
    const user = await this.userService.findOne(loginUser.username);
    if (user) {
      const payload = { username: user.username, sub: user.id, email: user.email, role: user.role };
      return {
        access_token: this.jwtService.sign(payload),
      };
    } else {
      throw new UnauthorizedException({
        error: 'There is no user'
      });
    }
  }
}

토큰 생성확인은 https://jwt.io/ 에서 할 수 있다. 

 

libs/domain/src/lib/auth/auth.module.ts 파일을 생성하고, config.json파일에 AUTH 프로퍼티를 추가한다. 

  • JwtModule을 등록한다. 
  • secret은 반드시 별도의 환경설정 파일에서 관리한다. 
  • AuthService도 등록한다.
  • User 정보를 read하기 위해 EntitiesModule도 imports 에 설정한다.
// apps/gateway/api/src/environments/config.json
{
  "HTTP_PORT": 8000,
  "AUTH": {
    "SECRET": "iot_secret_auth",
    "EXPIRED_ON": "1d"
  },
  ...
}

// auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';

import { GatewayConfiguration, loadConfigJson } from '@rnm/shared';
import { EntitiesModule } from '../entities/entity.module';

import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';

const config: GatewayConfiguration = loadConfigJson();

@Module({
  imports: [
    PassportModule,
    JwtModule.register({
      secret: config.AUTH?.SECRET,
      signOptions: { expiresIn: config.AUTH?.EXPIRED_ON },
    }),
    EntitiesModule
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule { }

 

Passport JWT 적용

로그인이 성공하면 jsonwebtoken 을 생성하고, 이후 request(요청)에 대해 JWT를 체크하는 환경설정을 한다.

  • libs/domain/src/lib/auth/strategies/jwt.strategy.ts 파일  생성
    • request header의 Bearer Token 체크 => Cookie 사용으로 변경 (master branch소스 참조)
    • 확인하는 secret 설정
  • AuthModule에 등록한다.
// jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';

import { loadConfigJson } from '@rnm/shared';
const config: any = loadConfigJson();

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      // jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      // Cookie를 사용한다
      jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
        return request?.cookies?.LOGIN_TOKEN;
      }]),
      ignoreExpiration: false,
      secretOrKey: config?.AUTH?.SECRET
    });
  }

  async validate(user: any): Promise<any> {
    // return { id: payload.sub, username: payload.username };
    return user;
  }
}

 

Password 암호화

암호화 모듈 설치

$> yarn add bcrypt
$> yarn add -D @types/bcrypt

암호화 유틸리티를 생성한다. libs/domain/src/lib/utilties/bcrypt.util.ts 파일 생성

  • 사용자 생성시 패스워드
  • 입력 패스워드를 DB의 암호화된 패스워드와 비교한다.
import * as bcrypt from 'bcrypt';

// 사용자의 패스워드 암호화
export const bcryptHash = (plainText: string, saltOrRounds = 10): Promise<string> => {
  return bcrypt.hash(plainText, saltOrRounds);
}

// 입력 패스워드와 DB 패스워드 비교
export const bcryptCompare = (plainText: string, hashedMessage: string): Promise<boolean> => {
  return bcrypt.compare(plainText, hashedMessage);
}

libs/domain/src/lib/auth/auth.service.ts 의 validateUser에서 암호화된 password를 체크토록 수정한다.

// auth.service.ts
import { bcryptCompare } from '../utilties/bcrypt.util';

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
  ) { }

  async validateUser(username: string, pass: string): Promise<any> {
    const user: any = await this.userService.findOne(username) || {};
    // 암호화된 패스워드를 입력 패스워드와 같은지 비교
    const isMatch = await bcryptCompare(pass, user.password);
    if (user && isMatch) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
  ...
}

 

 

로그인 하기

  • apps/gateway/api/src/app/auth/auth.controller.ts 파일을 신규 생성.
  • apps/gateway/api/src/app/app.module.ts 설정
    • "auth/login" API에 대해 static server에 exclude 설정
    • AuthModule imports에 설정
    • AuthController 등록
// auth.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';

import { AuthService, LocalAuthGuard, User } from '@rnm/domain';

@Controller()
export class AuthController {
  constructor(
    private readonly authService: AuthService
  ) { }

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Body() user: User): Promise<Response> {
    return this.authService.login(user);
  }
}

// app.module.ts
@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      exclude: [
        '/auth/*',
        '/api/gateway*', '/api/dashboard*', '/api/configuration*', '/api/back-office*',
        '/dashboard*', '/configuration*', '/back-office*'
      ],
    }),
    // ORM
    TypeOrmModule.forRoot({
      ...ormConfigService.getTypeOrmConfig(),
      entities: getMetadataArgsStorage().tables.map(tbl => tbl.target)
    }),
    EntitiesModule,
    // MicroService
    DashboardModule,
    // Auth
    AuthModule
  ],
  controllers: [
    AppController,
    AuthController,
    UserController
  ],
  providers: [GatewayApiAppService]
})
export class AppModule { }

 

UseGuard에서 username/password는  LocalAuthGuard를 등록한다. 이를 위해 libs/domain/src/lib/auth/guards/local-auth.guard.ts 파일 생성한다.

import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';

import { JwtAuthGuard, User, UserService } from '@rnm/domain';

@Controller('api/gateway/user')
export class UserController {
  constructor(
    private readonly service: UserService
  ) { }

  @UseGuards(JwtAuthGuard)
  @Post()
  create(@Body() data: User): Promise<User> {
    return this.service.create(data);
  }

  @UseGuards(JwtAuthGuard)
  @Put(':id')
  updateOne(@Param('id') id: number, @Body() data: User): Promise<any> {
    return this.service.updateOne(id, data);
  }

  @UseGuards(JwtAuthGuard)
  @Get()
  findAll(): Promise<User[]> {
    return this.service.findAll();
  }

  @UseGuards(JwtAuthGuard)
  @Get(':username')
  findOne(@Param('username') username: string): Promise<User | undefined> {
    return this.service.findOne(username);
  }

  @UseGuards(JwtAuthGuard)
  @Delete(':id')
  deleteOne(@Param('id') id: string): Promise<any> {
    return this.service.deleteOne(id);
  }
}
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') { }

User 생성하는 apps/gateway/api/src/app/user/user.controller.ts 에도 @UseGuards 를 JWT 토큰 체크하는 Guard로 등록한다. 이를 위하여 libs/domain/src/lib/auth/guards/jwt-auth.guard.ts 파일을 생성한다. 

import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // Add your custom authentication logic here
    // for example, call super.logIn(request) to establish a session.
    return super.canActivate(context);
  }

  handleRequest(err: any, user: any, info: any, context: any, status?: any) {
    // You can throw an exception based on either "info" or "err" arguments
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

그리고 apps/gateway/api/src/app/user/user.controller.ts 에 @UseGuards를 "JwtAuthGuard"로 등록한다. 

import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';

import { JwtAuthGuard, User, UserService } from '@rnm/domain';

@Controller('api/gateway/user')
export class UserController {
  constructor(
    private readonly service: UserService
  ) { }

  @UseGuards(JwtAuthGuard)
  @Post()
  create(@Body() data: User): Promise<User> {
    return this.service.create(data);
  }

  @UseGuards(JwtAuthGuard)
  @Put(':id')
  updateOne(@Param('id') id: number, @Body() data: User): Promise<any> {
    return this.service.updateOne(id, data);
  }

  @UseGuards(JwtAuthGuard)
  @Get()
  findAll(): Promise<User[]> {
    return this.service.findAll();
  }

  @UseGuards(JwtAuthGuard)
  @Get(':username')
  findOne(@Param('username') username: string): Promise<User | undefined> {
    return this.service.findOne(username);
  }

  @UseGuards(JwtAuthGuard)
  @Delete(':id')
  deleteOne(@Param('id') id: string): Promise<any> {
    return this.service.deleteOne(id);
  }
}

Postman으로 테스트 한다. 

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-6

 

Release ms-6 · ysyun/rnm-stack

[ms-6] add typeorm and jwt for auth

github.com

주의: 소스가 계속 업데이트되고 있기에 master branch를 참조해도 된다.

 

 

<참조>

- NestJS에 passport 기반 JWT 적용하기 

https://docs.nestjs.kr/security/authentication

 

네스트JS 한국어 매뉴얼 사이트

네스트JS 한국, 네스트JS Korea 한국어 매뉴얼

docs.nestjs.kr

- passport local 환경설정

http://www.passportjs.org/packages/passport-local/

 

passport-local

Local username and password authentication strategy for Passport.

www.passportjs.org

- passport-jwt 환경설정

https://www.passportjs.org/packages/passport-jwt/

 

passport-jwt

Passport authentication strategy using JSON Web Tokens

www.passportjs.org

- password 암호화

https://wanago.io/2020/05/25/api-nestjs-authenticating-users-bcrypt-passport-jwt-cookies/

 

API with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies

1. API with NestJS #1. Controllers, routing and the module structure2. API with NestJS #2. Setting up a PostgreSQL database with TypeORM3. API with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies4. API with NestJS #4. Error handling

wanago.io

posted by 윤영식
2021. 9. 27. 16:23 React/Architecture

Micro Service들이 멀티 Database를 사용할 경우 또는 Database Schema에 대한 주도권이 없으며 단지 연결하여 사용하는 입장의 Frontend Stack 개발자일 경우 Prisma보다는 TypeORM을 사용하는 것이 좋아보인다. 

 

 

TypeORM 설치 및 환경설정

nestjs 패키지와 typeorm 그리고 postgresql 패키지를 설치한다. 

$> yarn add @nestjs/typeorm typeorm pg

.env를 읽는 방식이 아니라 별도의 configuration json 파일에서 환경설정토록 한다. 

apps/gateway/api/src/environments/ 폴더에 orm-config.json 과 orm-config.prod.json 파일을 생성한다. 

  • synchronized는 반드시 개발시에만 true로 사용한다.
// orm-config.json
{
  "HOST": "localhost",
  "PORT": 5432,
  "USER": "iot",
  "PASSWORD": "1",
  "DATABASE": "rnm-stack",
  "ENTITIES": ["libs/domain/src/lib/entities/**/*.entity.ts"],
  "MODE": "dev",
  "SYNC": true
}

// Production 환경에서 사용
// orm-config.prod.json
{
  "HOST": "localhost",
  "PORT": 5432,
  "USER": "iot",
  "PASSWORD": "1",
  "DATABASE": "rnm-stack",
  "ENTITIES": ["libs/domain/src/lib/entities/**/*.entity.ts"],
  "MODE": "production",
  "SYNC": false
}

dev와 prod간의 config 스위칭을 위하여 apps/gateway/api/project.json 안에 replacement  문구를 추가한다. 

// project.json 일부내역
"fileReplacements": [
{
  "replace": "apps/gateway/api/src/environments/environment.ts",
  "with": "apps/gateway/api/src/environments/environment.prod.ts"
},
{
  "replace": "apps/gateway/api/src/environments/config.ts",
  "with": "apps/gateway/api/src/environments/config.prod.ts"
},
{
  "replace": "apps/gateway/api/src/environments/orm-config.ts",
  "with": "apps/gateway/api/src/environments/orm-config.prod.ts"
}
]

 

libs/shared/src/lib/configuration/ 폴더에 orm-config.service.ts 파일을 생성하고, orm-config.json 파일을 값을 다루도록 한다.

// orm-config.service.ts
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { loadOrmConfiguration } from './config.service';

class OrmConfigService {
  constructor(private env: { [k: string]: any }) { }

  ensureValues(keys: string[]) {
    keys.forEach(k => this.getValue(k, true));
    return this;
  }

  getPort() {
    return this.getValue('PORT', true);
  }

  isProduction() {
    const mode = this.getValue('MODE', false);
    return mode !== 'dev';
  }

  getTypeOrmConfig(): TypeOrmModuleOptions {
    const config: TypeOrmModuleOptions = {
      type: 'postgres',
      host: this.getValue('HOST'),
      port: parseInt(this.getValue('PORT')),
      username: this.getValue('USER'),
      password: this.getValue('PASSWORD'),
      database: this.getValue('DATABASE'),
      entities: this.getValue('ENTITIES'),
      synchronize: this.getValue('SYNC'),
    };
    return config;
  }

  private getValue(key: string, throwOnMissing = true): any {
    const value = this.env[key];
    if (!value && throwOnMissing) {
      throw new Error(`config error - missing orm-config.${key}`);
    }
    return value;
  }

}

/**
 * Singleton Config
 */
const ormEnv: any = loadOrmConfiguration();
const ormConfigService = new OrmConfigService(ormEnv)
  .ensureValues([
    'HOST',
    'PORT',
    'USER',
    'PASSWORD',
    'DATABASE'
  ]);

export { ormConfigService };

apps/gateway/api/src/app/app.module.ts 에서 해당 configuration을 설정토록한다. @nestjs/typeorm의 모듈을 사용한다.

// app.module.ts
import { join } from 'path';
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getMetadataArgsStorage } from 'typeorm';

import { GatewayApiAppService, EntitiesModule } from '@rnm/domain';
import { ormConfigService } from '@rnm/shared';

import { DashboardModule } from './dashboard/microservice/dashboard.module';
import { AppController } from './app.controller';
import { UserController } from './user/user.controller';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      exclude: [
        '/api/gateway*', '/api/dashboard*', '/api/configuration*', '/api/back-office*',
        '/dashboard*', '/configuration*', '/back-office*'
      ],
    }),
    // ORM 환경을 설정한다. orm-config.json에 설정했던 entities의 내용을 등록한다. 
    TypeOrmModule.forRoot({
      ...ormConfigService.getTypeOrmConfig(),
      entities: getMetadataArgsStorage().tables.map(tbl => tbl.target)
    }),
    EntitiesModule,
    // MicroService
    DashboardModule,
  ],
  controllers: [
    AppController,
    UserController
  ],
  providers: [GatewayApiAppService]
})
export class AppModule { }

 

 

TypeORM사용 패턴

typeorm은 두가지 패턴을 선택적으로 사용할 수 있다.

  • Active Record: BeanEntity를 상속받아 entity내에서 CRUD 하기. (작은 서비스유리)
  • Data Mapper: Model은 별도이고, Respository가 DB와 연결하고, CRUD를 별도 서비스로 만든다. (큰 서비스유리)

Data Mapper 패턴을 사용하기 위해 libs/domain/src/lib/entities/user/ 폴더하위에 user.entity.ts, user.model, user.service.ts 파일을 생성한다. 

  • user.entity.ts: table schema
  • user.model.ts: interface DTO
  • user.service.ts: CRUD 
// user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity('user_iot')
export class UserEntity {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  username!: string;

  @Column()
  password!: string;

  @Column()
  email!: string;

  @Column()
  firstName!: string;

  @Column()
  lastName!: string;

  @Column({ default: false })
  isActive!: boolean;

  // USER, ADMIN, SUPER
  @Column({ default: 'USER' })
  role!: string;
}


// user.model.ts
export interface User {
  id: number;
  username: string;
  password: string;
  email?: string;
  firstName: string;
  lastName: string;
  isActive: boolean;
  role: string;
}


// user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { UserEntity } from './user.entity';
import { User } from './user.model';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(UserEntity) private repository: Repository<UserEntity>
  ) { }

  create(data: User): Promise<User> {
    return this.repository.save(data);
  }

  updateOne(id: number, data: User): Promise<any> {
    return this.repository.update(id, data);
  }

  findAll(): Promise<User[]> {
    return this.repository.find();
  }

  findOne(username: string): Promise<User | undefined> {
    // findOne이 객체라는 것에 주의
    return this.repository.findOne({ username });
  }

  deleteOne(id: string): Promise<any> {
    return this.repository.delete(id);
  }
}

libs/domain/src/lib/entities/  폴더에 entity.module.ts 생성하고, user.entity.ts를 등록한다.  entity.module.ts에는 user.entity외에 계속 추가되는 entity들을 forFeature로 등록한다. 

// entity.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user/user.service';
import { UserEntity } from './user/user.entity';

@Module({
  imports: [
    TypeOrmModule.forFeature([UserEntity])
  ],
  providers: [UserService],
  exports: [UserService]
})
export class EntitiesModule { }

entity.module.ts을 사용하기 위해 apps/gateway/api/src/app/app.module.ts 파일에 등록한다. 

//app.module.ts 
import { TypeOrmModule } from '@nestjs/typeorm';
import { EntitiesModule } from '@rnm/domain';
import { ormConfigService } from '@rnm/shared';

@Module({
  imports: [
    ...
    // ORM
    TypeOrmModule.forRoot(ormConfigService.getTypeOrmConfig()),
    EntitiesModule,
    ...
  ],
  ...
})
export class AppModule { }

 

User CRUD 컨트롤러 작성 및 테스트

사용자 CRUD를 위한 controller를 작성한다. apps/gateway/api/src/app/user/ 폴더를 생성하고, user.controller.ts 파일을 생성한다. 

// user.controller.ts
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { User, UserService } from '@rnm/domain';

@Controller('api/gateway/user')
export class UserController {
  constructor(
    private readonly service: UserService
  ) { }

  @Post()
  create(@Body() data: User): Promise<User> {
    return this.service.create(data);
  }

  @Put(':id')
  updateOne(@Param('id') id: number, @Body() data: User): Promise<any> {
    return this.service.updateOne(id, data);
  }

  @Get()
  findAll(): Promise<User[]> {
    return this.service.findAll();
  }

  @Get(':username')
  findOne(@Param('username') username: string): Promise<User | undefined> {
    return this.service.findOne(username);
  }

  @Delete(':id')
  deleteOne(@Param('id') id: string): Promise<any> {
    return this.service.deleteOne(id);
  }
}

gateway를 start하면 dev모드에서 synchronized: true에서 "user_iot"  테이블이 자동으로 생성한다. 

Postman으로 호출을 해본다. 

  • POST method 선택
  • Body에 request json 입력
  • JSON 형식 선택
  • "Send" 클릭

 

 

<참조>

- TypeORM 사용형태

https://aonee.tistory.com/77

 

TypeORM 개념 및 설치 및 사용방법

👉 Typeorm 공식문서 ORM 개요 Object-relational mapping, 객체-관계 매핑 객체와 관계형 데이터베이스의 데이터를 자동으로 매핑(연결)해준다. 객체 지향 프로그래밍은 클래스를 사용하고, 관계형 데이

aonee.tistory.com

- TypeORM & Observable 로 변경 사용하는 방법

https://www.youtube.com/watch?v=Z6kw_aJHJLU 

- Nx에서 typeorm 로딩시 에러 이슈

https://github.com/nrwl/nx/issues/1393

 

tsconfig target incompatibility with NestJS and TypeORM module · Issue #1393 · nrwl/nx

Prerequisites I am running the latest version I checked the documentation and found no answer I checked to make sure that this issue has not already been filed I'm reporting the issue to the co...

github.com

 

posted by 윤영식
2021. 9. 24. 19:48 React/Architecture

MS-4/5/6 글을 통해 Gateway의 공통 기능 구현을 위한 설정을 적용한다.

  • Login 할때 사용자 정보는 ORM 기반으로 처리한다.
  • 인증을 JWT 기반으로 처리한다.
  • Login 화면을 React 기반 구현한다.

 

NestJS에 Prisma ORM 설정

gateway-api의 공통 기능은 다음과 같고, JWT처리를 위한 사용자 정보 조회를 위해 Prisma ORM을 적용한다. Micro Service도 Prisma를 사용할 것이다.

  • api gateway: TCP 통신으로 micro service의 API를 연결한다. 
  • http server: gateway의 공통 기능중 Login을 서비스한다.
  • reverse proxy: Login이 성공하면 dashboard micro service로 이동한다. (configuration, back-office)
  • auth server: JWT 기반으로 token을 발행하고, 요청에 대한 모든 Authorization(인가, 권한)을 체크한다.

 

Step-1) 설치 및 환경 설정

auth server 기능에서 사용자 정보 데이터처리를 위해 ORM으로 Prisma를 사용을 위해 패키지를 (v3.1.1) 설치한다.

$> yarn add -D prisma
$> yarn add @prisma/client
$> yarn add -D ts-node

전체 애플리케이션을 위한 초기 prisma 환경을 생성한다.

$> npx prisma init

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver (Preview) or mongodb (Preview).
3. Run prisma db pull to turn your database schema into a Prisma schema.
4. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

자동 생성된 .env 파일에 설정을 추가한다. 

# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server (Preview) and MongoDB (Preview).
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
#DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"

# POSTGRES
POSTGRES_USER=iot
POSTGRES_PASSWORD=1
POSTGRES_DB=rnm-stack

# Nest run locally
DB_HOST=localhost
# Nest run in docker, change host to database container name
# DB_HOST=postgres
DB_PORT=5432
DB_SCHEMA=public

# Prisma database connection
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DB_HOST}:${DB_PORT}/${POSTGRES_DB}?schema=${DB_SCHEMA}&sslmode=prefer

VS Code의 extension을 설치한다. 

VSCode의 prisma extension

 

Step-2) schema.prisma 설정

VSCode extension이 설치되면 schema.prisma의 내용이 다음과 같이 highlighting된다. 

schema.prisma 파일 안에 Prisma 방식의 스키마를 정의한다.

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
  // previewFeatures = []
}

// generator dbml {
//   provider = "prisma-dbml-generator"
// }

model User {
  id        Int   @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  email     String   @unique
  password  String
  firstname String?
  lastname  String?
  posts     Post[]
  role      Role
}

model Post {
  id        Int   @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  published Boolean
  title     String
  content   String?
  author    User?    @relation(fields: [authorId], references: [id])
  authorId  Int?
}

enum Role {
  ADMIN
  MANAGER
  USER
}

schema.prisma를 통해 Migrate SQL과 Prisma Client 파일을 자동 생성한다. Prisma Client 파일은 구현 코드에서 사용된다. (참조)

 

 

Step-3) schema 설정을 통해 sql 생성하기 

명령을 수행하면 prisma/migrations sql이 자동 실행된다. 생성된 sql을 통해 table schema를 업데이트한다. 변경점이 있으면 날짜별로 update 할 수 있는 table schema가 자동으로 생성된다. 

$> npx prisma migrate dev --create-only --name=iot

또한 DB Schema에 _prisma_migrations 테이블이 자동생성된다. 

rnm-stack 의 public에 migrations 테이블 자동 생성

테이블을 자동 생성하고 싶다. prisma db push 명령을 사용한다. (참조)

$> npx prisma db push

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "rnm-stack", schema "public" at "localhost:5432"
🚀  Your database is now in sync with your schema. Done in 77ms
✔ Generated Prisma Client (3.1.1) to ./node_modules/@prisma/client in 61ms

Post, User 테이블 자동 생성

 

Step-4) Prisma Studio 사용하기

prisma는 내장 웹기반 studio를 제공한다. 테이블을 선택하여 조작할 수 있다.

$> npx prisma studio

 

 

NestJS 에서 PrismaClient  사용하기

Step-1) PrismaClient 생성

schema에 생성되었으면 다음으로 코드에서 Prisma 접근을 위해 PrismaClient를 생성해야 한다. (참조)

  • "npx prisma migrate dev" 명령으로 수행할 경우는 "npx prisma generate"이 필요없다. 
  • "npx prisma migrate dev --create-only" 일 경우만 수행한다.
  • schema.prisma 변경시 마다 다시 실행해 주어야 한다. (참조)

$> npx prisma generate
✔ Generated Prisma Client (3.1.1) to ./node_modules/@prisma/client in 181ms
You can now start using Prisma Client in your code. Reference: https://pris.ly/d/client
```
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
```

prisma client는 기본적으로 node_modules/.prisma/client 폴더 밑에 생성된다.

node_modules/.prisma/client 폴더

 

 

Step-2) schema.prisma가 변경이 발생할 경우

이제 "PrismaClient"를 import해서 사용할 수 있는 상태가 되었다. 만일 테이블 변경이 발생한다면 아래와 같이 수행한다. 

  • schema.prisma 파일 내역 수정
  • "npx prisma migrate" 명령 실행
  • migrations 폴더에 있는 migration sql을 database에 적용한다. 

참조: https://www.prisma.io/blog/prisma-migrate-ga-b5eno5g08d0b

 

 

Step-3) NestJS 서비스 생성

사용자 정보는 공통이므로 libs/domain 에 생성한다. (참조)

  • libs/shared/src/lib밑으로 prisma 폴더를 생성하고, prisma-client.service.ts파일을 생성한다. 
  • index.ts에 export를 추가한다. 

// prisma-client.service.ts 
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'
import { PrismaClient } from '@prisma/client'

@Injectable()
export class PrismaClientService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect()
  }

  async onModuleDestroy() {
    await this.$disconnect()
  }
}

// index.ts
export * from './lib/configuration/config.model';
export * from './lib/configuration/config.service';
export * from './lib/prisma/prisma-client.service';

apps/gateway/api/src/app/app.module.ts에 PrismaClientService를 추가한다.

// /apps/gateway/api/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';

import { GatewayApiAppService } from '@rnm/domain';
import { PrismaClientService } from '@rnm/shared'; <== 요기

import { AppController } from './app.controller';
import { DashboardModule } from './dashboard/dashboard.module';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      exclude: [
        '/api/gateway*', '/api/dashboard*',
        '/dashboard*', '/configuration*', '/back-office*'
      ],
    }),
    DashboardModule
  ],
  controllers: [AppController],
  providers: [GatewayApiAppService, PrismaClientService] <== 요기
})
export class AppModule { }

gateway/api/src/app/app.controller.ts에 테스트 코드로 POST로 user를 생성하는 코드를 작성한다. 

// apps/gateway/api/src/app/app.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';

import { GatewayApiAppService } from '@rnm/domain';
import { PrismaClientService } from '@rnm/shared';

import { Role, User as UserModel } from '@prisma/client';

@Controller('api/gateway')
export class AppController {
  constructor(
    private readonly appService: GatewayApiAppService,
    private readonly dbService: PrismaClientService
  ) { }

  @Get()
  getData() {
    return this.appService.getData();
  }

  @Post('user')
  async createUser(@Body() userData: {
    email: string,
    password: string,
    firstname: string,
    lastname; string,
    role: Role
  }): Promise<UserModel> {
    const { email, password, firstname, lastname, role } = userData;
    return this.dbService.user.create({
      data: {
        email,
        password,
        firstname,
        lastname,
        role: !role ? Role.USER : role
      }
    });
  }
}

VSCode에서 Debug창에서 Gateway를 실행하고, 디버깅을 한다.

28, 29줄에 breakpoint 

 

Postman을 실행하여 User 를 생성해 본다.

  • 새로운 Request를 만들고 POST를 선택
  • Body에 JSON 타입을 선택
  • request 값을 넣고, http://localhost:8000/api/gateway/user 호출

DB툴로 User insert가 되었는지 확인 또는 prisma studio에서 확인

test@test.com 사용자가 insert 성공!

 

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-4

 

<참조>

- Primsa on NestJS 기반 개발

https://www.prisma.io/nestjs

 

NestJS Database & Prisma | Type-safe ORM for SQL Databases

Prisma is a next-generation ORM for Node.js & TypeScript. It's the easiest way to build NestJS apps with MySQL, PostgreSQL & SQL Server databases.

www.prisma.io

- Prisma Migrate 순서

https://www.prisma.io/blog/prisma-migrate-ga-b5eno5g08d0b

 

Prisma Migrate is Production Ready - Hassle-Free Database Migrations

Prisma Migrate is ready for use in production - Database schema migration tool with declarative data modeling and auto-generated, customizable SQL migrations

www.prisma.io

- Primsa, JWT on NestJS StartKit

https://github.com/fivethree-team/nestjs-prisma-starter

 

GitHub - fivethree-team/nestjs-prisma-starter: Starter template for NestJS 😻 includes GraphQL with Prisma Client, Passport-JW

Starter template for NestJS 😻 includes GraphQL with Prisma Client, Passport-JWT authentication, Swagger Api and Docker - GitHub - fivethree-team/nestjs-prisma-starter: Starter template for NestJS 😻...

github.com

- Prisma의 다양한 DB 예제

https://github.com/prisma/prisma-examples

 

GitHub - prisma/prisma-examples: 🚀 Ready-to-run Prisma example projects

🚀 Ready-to-run Prisma example projects. Contribute to prisma/prisma-examples development by creating an account on GitHub.

github.com

posted by 윤영식
2021. 9. 23. 20:45 React/Architecture

micro service인 dashboard와 gateway간의 테스트 환경을 VS Code에 만들어 본다. 

 

VS Code 디버깅환경 설정

Step-1) package.json에 script 등록

각 애플리케이션의 build 스크립트를 등록한다. 

// package.json 
  "scripts": {
    "start": "nx serve",
    "build": "nx build",
    "test": "nx test",
    "build:gateway-api": "nx build gateway-api",
    "start:gateway-api": "nx serve gateway-api",
    "build:dashboard-api": "nx build dashboard-api",
    "start:dashboard-api": "nx serve dashboard-api",
    "build:configuration-api": "nx build configuration-api",
    "start:configuration-api": "nx serve configuration-api",
    "build:back-office-api": "nx build back-office-api",
    "start:back-office-api": "nx serve back-office-api"
  },

 

Step-2) .vscode 폴더안의 tasks.json 추가

tasks.json은 vscode의 디버깅 실행 환경설정파일인 launch.json에서 사용한다. 

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "build:dashboard-api",
      "type": "shell",
      "command": "npm run build:dashboard-api"
    },
    {
      "label": "build:configuration-api",
      "type": "shell",
      "command": "npm run build:configuration-api"
    },
    {
      "label": "build:back-office-api",
      "type": "shell",
      "command": "npm run build:back-office-api"
    },
    {
      "label": "build:gateway-api",
      "type": "shell",
      "command": "npm run build:gateway-api"
    }
  ]
}

 

Step-3) .vscode 폴더안의 launch.json 추가

tasks.json의 설정내용은 "preLaunchTask"에 설정한다.

  • task.json의 build 를 먼저 실행한다. 빌드하면 루트폴더의 dist 폴더에 js, map이 생성된다. 
  • 이후 main.ts 소스에 breakpoint를 찍으면 디버깅을 할 수 있다. 
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Dashboard API",
      "program": "${workspaceFolder}/apps/dashboard/api/src/main.ts",
      "preLaunchTask": "build:dashboard-api",
      "outFiles": ["${workspaceFolder}/dist/apps/dashboard/api/**/*.js"]
    },
    {
      "type": "node",
      "request": "launch",
      "name": "Gateway API",
      "program": "${workspaceFolder}/apps/gateway/api/src/main.ts",
      "preLaunchTask": "build:gateway-api",
      "outFiles": ["${workspaceFolder}/dist/apps/gateway/api/**/*.js"]
    },
    {
      "type": "node",
      "request": "launch",
      "name": "Configuration API",
      "program": "${workspaceFolder}/apps/configuration/api/src/main.ts",
      "preLaunchTask": "build:configuration-api",
      "outFiles": ["${workspaceFolder}/dist/apps/configuration/api/**/*.js"]
    },
    {
      "type": "node",
      "request": "launch",
      "name": "Back-Office API",
      "program": "${workspaceFolder}/apps/back-office/api/src/main.ts",
      "preLaunchTask": "build:back-office-api",
      "outFiles": ["${workspaceFolder}/dist/apps/back-office/api/**/*.js"]
    }
  ]
}

.vscode안의 launch.json과 tasks.json 파일

 

Gateway와 Micro Service간의 TCP 통신 테스트

Step-1) gateway의 dashboard.controller.ts에서 호출

  • dashboard.controller.ts에서 서비스호출
  • dashboard.service.ts 에서 breakpoint
// apps/gateway/api/src/app/dashboard/dashboard.controller.ts 
import { Controller, Get } from '@nestjs/common';

import { Observable } from 'rxjs';
import { DashboardService } from './dashboard.service';

@Controller('api/dashboard')
export class DashboardController {
  constructor(
    private readonly dashboardService: DashboardService
  ) { }

  @Get('sum')
  accumulate(): Observable<{ message: number, duration: number }> {
    return this.dashboardService.sum();
  }
}

// apps/gateway/api/src/app/dashboard/dashboard.service.ts 
import { Injectable, Inject } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices/client";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";

@Injectable()
export class DashboardService {
  constructor(@Inject("DASHBOARD") private readonly client: ClientProxy) { }

  sum(): Observable<{ message: number, duration: number }> {
    const startTs = Date.now();
    const pattern = { cmd: 'dashboard-sum' };
    const payload = [1, 2, 3];
    return this.client
      .send<number>(pattern, payload)
      .pipe(
        map((message: number) => ({ message, duration: Date.now() - startTs }))
      );
  }
}

14줄 breakpoint

 

Step-2) Micro Service 인 dashboard에서 요청처리

  • 요청에 대한 sum 처리후 observable 로 반환
  • MessagePattern 데코레이터 적용
import { Controller, Get } from '@nestjs/common';
import { MessagePattern } from '@nestjs/microservices';

import { DashboardApiAppService } from '@rnm/domain';
import { from, Observable } from 'rxjs';

@Controller()
export class AppController {
  constructor(private readonly appService: DashboardApiAppService) { }

  @Get()
  getData() {
    return this.appService.getData();
  }

  @MessagePattern({ cmd: 'dashboard-sum' })
  accumulate(data: number[]): Observable<number> {
    console.log('calling sum from dashboard....');
    const sum = data[0] + data[1] + data[2];
    return from([sum]);
  }
}

20줄 breakpoint

 

Step-3) Dashboard, Gateway 서버 실행

디버깅으로 이동한다.

  • Dashboard API 실행
  • Gateway API 실행

실행결과

 

브라우져에서 호출

  • http://localhost:8000/api/dashboard/sum

디버깅 실행 영상

https://www.youtube.com/watch?v=UDWPnJdQUhI 

소스

https://github.com/ysyun/rnm-stack/releases/tag/ms-3

 

Release ms-3 · ysyun/rnm-stack

[ms-3] add debug config in vscode

github.com

 

posted by 윤영식
2021. 9. 20. 19:37 React/Architecture

React Nest를 기반으로 마이크로 서비스를 구축해 본다. 개발 환경은 Nx를 사용한다. Nx 환경구축은 [React HH-2] 글을 참조한다.

 

목차

  • Gateway, Dashboard등의 Application 생성
  • Application에서 사용하는 Library 생성
  • Gateway <-> MicroService Application간 통신 설정
    • api 호출: reverse proxy & tcp 설정
    • static assets 호출: static server 설정

 

Application 생성

4개의 카테고리에 각각 api, web 애플리케이션을 생성한다. 

  • gateway, dashboard, configuration, back-office
    • api, web

 

Step-1) 사전 준비환경 설치

글로벌 환경 설치를 한다. nvm 설치는 [React HH-2] 글을 참조한다.

$> npm i -g @angular/cli@latest 
$> npm i -g @nrwl/cli@latest 
$> npm i -g yarn@latest

node 버전은 nvm을 사용하여 14.18.0 를 사용한다. nvm 버전을 자동으로 설정하고 싶다면 

1) .zshrc파일에 다음 내용을 첨부한다. (Gist 코드 참조)

2) 작업 폴더에 .nvmrc 파일을 만든다.

// 코드: https://gist.github.com/ysyun/845ddd32467bb50af654e1e4605a4b50
// .zshrc
autoload -Uz add-zsh-hook
load-nvmrc() {
    local _CUR_NODE_VER="$(nvm version)"
    local _NVMRC_PATH="$(nvm_find_nvmrc)"

    if [[ -n "${_NVMRC_PATH}" ]]; then
        local _NVMRC_NODE_VER="$(nvm version "$(cat "${_NVMRC_PATH}")")"

        if [[ "${_NVMRC_NODE_VER}" == 'N/A' ]]; then
            local compcontext='yn:yes or no:(y n)'
            vared -cp "Install the unmet version ($(cat "${_NVMRC_PATH}")) in nvm (y/n) ?" _ANSWER
            if [[ "${_ANSWER}" =~ '^y(es)?$' ]] ; then
                nvm install
            fi
        elif [[ "${_NVMRC_NODE_VER}" != "${_CUR_NODE_VER}" ]]; then
            nvm use
        fi
    elif [[ "${_CUR_NODE_VER}" != "$(nvm version default)" ]]; then
        echo -e "Reverting to the default version in nvm"
        nvm use default
    fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc

// 작업 폴더에 .nvmrc 파일을 생성하고, 버전을 기입힌다. 아래 내용 참조
// .nvmrc
14.18.0

 

Step-2) NX 개발환경 생성

create-nx-workspace@latest를 이용하고, .nvmrc 파일을 생성한다. .zshrc가 적용되고, rnm-stack 폴더에 진입하면 자동으로 14.18.0 버전이 적용된다. rnm은 React Nest Micro-service의 약어이다.

$> npx create-nx-workspace@latest
✔ Workspace name (e.g., org name)     · rnm-stack
✔ What to create in the new workspace · react
✔ Application name                    · gateway/web
✔ Default stylesheet format           · scss
✔ Use Nx Cloud? (It's free and doesn't require registration.) · No


$> cd rnm-stack
$> echo "14.18.0" > .nvmrc

다음은 api 애플리케이션을 Nest기반으로 생성한다.

// nest 제너레이터 설치
$> yarn add -D @nrwl/nest@latest

// api 생성
$> nx g @nrwl/nest:app gateway/api

다음으로 루트폴더에 있는 workspace.json 파일을 각 api와 web폴더에 project.json 파일을 생성하여 설정을 옮긴다. 이후 생성되는 애플리케이션들은 자동으로 project.json 파일로 생성된다. 

// workspace.json
{
  "version": 2,
  "projects": {
    "gateway-api": "apps/gateway/api",  <== project.json 파일의 위치 지정
    "gateway-web": "apps/gateway/web",
    "gateway/web-e2e": "apps/gateway/web-e2e"
  },
  ... 생략 ...
  "defaultProject": "gateway-web"
}

project.json 파일들

 

테스트 "nx serve gateway-web" 을 수행하면 정상적으로 dev server가 수행된다.

$> nx serve gateway-web

> nx run gateway-web:serve
>  NX  Web Development Server is listening at http://localhost:4200/

나머지 dashboard, configuration, back-office도 생성한다.

// react application
$> nx g @nrwl/react:app dashboard/web
$> nx g @nrwl/react:app configuration/web
$> nx g @nrwl/react:app back-office/web

// nest application
$> nx g @nrwl/nest:app dashboard/api
$> nx g @nrwl/nest:app configuration/api
$> nx g @nrwl/nest:app back-office/api

전체 생성 애플리케이션과 workspace.json 내역

 

Libraries 생성

다음으로 libs 폴더 하위로 라이브러리들을 생성한다. @rnm/page, @rnm/ui, @rnm/domain, @rnm/shared 로 import하여 사용한다.  

  • page
  • ui
  • domain
  • shared
$> nx g @nrwl/react:lib page --importPath=@rnm/page --publishable
$> nx g @nrwl/react:lib ui --importPath=@rnm/ui --publishable
$> nx g @nrwl/nest:lib domain --importPath=@rnm/domain --publishable
$> nx g @nrwl/nest:lib shared --importPath=@rnm/shared --publishable

libs 폴더밑으로 4개 라이브러리 생성

 

 

Micro Service Communication Channel 설정

다음은 애플리케이션간의 연결을 위한 통신부분을 설정한다. NestJS 기반으로 microservice 생성을 위해 패키지를 설치한다. NestJS버전을 7.6.18 버전으로 업데이트 한다. (현재 8.*은 microservice 버그가 있다. 작동불능)

$> yarn add @nestjs/microservices


// package.json 에서 nestjs의 모든 패키지의 버전을 업데이트 한다. 
  "dependencies": {
    "@nestjs/common": "^7.6.18", <== 요기
    "@nestjs/core": "^7.6.18", <== 요기
    "@nestjs/microservices": "^7.6.18", <== 요기
    "@nestjs/platform-express": "^7.6.18", <== 요기
    "@nestjs/serve-static": "^2.2.2",
    "core-js": "^3.6.5",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "react-router-dom": "5.2.0",
    "reflect-metadata": "^0.1.13",
    "regenerator-runtime": "0.13.7",
    "rxjs": "~6.6.3",
    "tslib": "^2.0.0"
  },
  "devDependencies": {
    "@nestjs/schematics": "^7.3.1", <== 요기, 7최신이 7.3.1
    "@nestjs/testing": "^7.6.18", <== 요기
    ...
  }
  
  
  // nestjs 모든 패키지의 업데이트 버전을 재설치한다.
  $> yarn

 

Step-1) Micro Service 설정

  • dashboard, configuration, back-office 3가지 micro service
    • http server & static server
      • dashboard: 8001, configuration: 8002, back-office: 8002 HTTP port를 사용한다.
    • api server
      • dashboard: 8100, configuration: 8200, back-office: 8300 TCP port를 사용한다.

apps/dashboard/api/src 하위로 public 폴더를 하위로 dashboard 폴더를 추가로 생성한 후 index.html 파일을 생성한다.

  • apps/dashboard/api/src/environments폴더에 config.json 파일을 생성한다.
  • apps/dashboard/api/project.json 에  "assets" 파트에 설정을 추가한다. 
    "assets": ["apps/dashboard/api/src/environments", "apps/dashboard/api/src/public"]

libs/shared/src/lib 밑에 configuration 폴더를 생성하고, config.service.ts 와 config.model.ts을 생성한다. config.json 파일을 읽기위한 용도이다. index.ts에  export도 설정한다.

// config.service.ts
import * as fs from "fs";
import { join } from 'path';
import { GatewayConfiguration, MicroServiceConfiguration } from "./config.model";

export const loadMicroServiceConfiguration = (message = '[LOAD] config.json file'): MicroServiceConfiguration => {
  console.log(`${message}:`, `${__dirname}/environments/config.json`);
  const jsonFile = fs.readFileSync(join(__dirname, 'environments', 'config.json'), 'utf8');
  return JSON.parse(jsonFile);
}

export const loadGatewayConfiguration = (message = '[LOAD] config.json file'): GatewayConfiguration => {
  console.log(`${message}:`, `${__dirname}/environments/config.json`);
  const jsonFile = fs.readFileSync(join(__dirname, 'environments', 'config.json'), 'utf8');
  return JSON.parse(jsonFile);
}


// config.model.ts 
export interface MicroServiceConfiguration {
  REVERSE_CONTEXT?: string;
  REVERSE_ADDRESS?: string;
  HTTP_PORT?: number,
  TCP_HOST?: string;
  TCP_PORT?: number,
  GLOBAL_API_PREFIX?: string;
}

export interface GatewayConfiguration {
  HTTP_PORT?: number,
  GLOBAL_API_PREFIX?: string;
  DASHBOARD?: MicroServiceConfiguration;
  CONFIGURATION?: MicroServiceConfiguration;
  BACK_OFFICE?: MicroServiceConfiguration;
}


// index.ts
export * from './lib/configuration/config.model';
export * from './lib/configuration/config.service';

apps/dashboard/api/src/main.ts 파일을 수정한다. http server를 위한 listen port를 설정하고, microservice client가 연결토록 TCP로 연결 준비를 한다. loadMicroServiceCofiguration 함수를 "@rnm/shared" 패키지로 부터 위치 투명하게 (마치 node_modules에 설치된 것처럼) import 할 수 있다.

import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport, MicroserviceOptions } from '@nestjs/microservices';

import { loadMicroServiceConfiguration } from '@rnm/shared';

import { AppModule } from './app/app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Load config.json file
  const config = loadMicroServiceConfiguration();

  // // Setup tcp server for api
  const options: MicroserviceOptions = {
    transport: Transport.TCP,
    options: { host: config.TCP_HOST, port: config.TCP_PORT || 8100 }
  };
  app.connectMicroservice(options);
  app.startAllMicroservices();

  // // Setup http server for web
  const httpPort = config.HTTP_PORT || process.env.HTTP_PORT || 8001;
  const globalPrefix = config.GLOBAL_API_PREFIX || '/api';
  app.setGlobalPrefix(globalPrefix);
  await app.listen(httpPort, () => {
    Logger.log(`Listening at http://localhost:${httpPort}${globalPrefix}`);
  });
}

bootstrap();

확인 & 테스트 

// LISTEN 포트 확인
$> netstat -an |grep 8100
tcp4       0      0  *.8100                 *.*                    LISTEN
$> netstat -an |grep 8001
tcp46      0      0  *.8001                 *.*                    LISTEN

// gateway 실행
$> nx serve dashboard-api

// other console
$> curl -X GET "http://localhost:8001/api" 
{"message":"Welcome to dashboard/api in libs"}

configuration, back-office도 동일하게 적용한다.

 

 

Step-2) Micro Service의 HTTP port가 static file 서비스토록 설정

@nestjs/serve-static (v2.2.2) 패키지를 설치한다. (참조)

$> yarn add @nestjs/serve-static

 

apps/dashboard/api/src/app/app.module.ts 에 static 환경을 설정한다. rootPath는 public 폴더를 만든다. exclude는 global prefix인 api를 제외한다. public 폴더에 index.html 을 만들어 본다. 향후 react기반 SPA 번들파일을 위치할 것이다. 

import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';

import { DashboardApiAppService } from '@rnm/domain';

import { AppController } from './app.controller';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      exclude: ['/api*', '/dashboard/api*'],
    }),
  ],
  controllers: [AppController],
  providers: [DashboardApiAppService],
})
export class AppModule { }

// index.html
dashboard home

재실행 후, dashboard context path로 브라우져에서 확인한다. public/dashboard/assets/images 밑으로 샘플 이미지 복사

// public/dashboard/index.html 내역

<html>
  <head>
    <base href="dashboard" />
  </head>
  <body>
    welcome to dashboard! <img src="/dashboard/assets/images/dashboard.png" />
  </body>
</html>

 

Step-3) Micro Service의 응답을 위한 TCP MessagePattern 데코레이터 생성

apps/dashboard/api/src/app/app.controller.ts 에서 TCP 요청에 대한 응답 코드를 작성한다. TCP는 MessagePattern 데코레이터를 통해 응답하고 반환은 Primitive type, Promise, Observable 다 가능하다. 만일 Observable로 하면 gateway 측에서 Observable로 대응할 수 있다. 

  • { cmd: 'dashboard-sum' } 은 gateway로부터 오는 Key이다.
import { Controller, Get } from '@nestjs/common';
import { MessagePattern } from '@nestjs/microservices';

import { DashboardApiAppService } from '@rnm/domain';
import { from, Observable } from 'rxjs';

@Controller()
export class AppController {
  constructor(private readonly appService: DashboardApiAppService) { }

  @Get()
  getData() {
    return this.appService.getData();
  }

  @MessagePattern({ cmd: 'dashboard-sum' })
  accumulate(data: number[]): Observable<number> {
    console.log('calling sum from dashboard....');
    const sum = data[0] + data[1] + data[2];
    return from([sum]);
  }
}

테스트는 Gateway까지 설정후 진행한다.

 

Step-4) Gateway 설정

  • gateway
    • http server & static server
    • reverse proxy
    • api client

config.json 파일을 apps/gateway/api/src/environment  폴더밑으로 생성하고 하기와 같이 설정한다.

{
  "HTTP_PORT": 8000,
  "DASHBOARD": {
    "REVERSE_CONTEXT": "dashboard",
    "REVERSE_ADDRESS": "http://127.0.0.1:8001",
    "HTTP_PORT": 8001,
    "TCP_HOST": "0.0.0.0",
    "TCP_PORT": 8100,
    "GLOBAL_API_PREFIX": "/api"
  },
  "CONFIGURATION": {
    "REVERSE_CONTEXT": "configuration",
    "REVERSE_ADDRESS": "http://127.0.0.1:8002",
    "HTTP_PORT": 8002,
    "TCP_HOST": "0.0.0.0",
    "TCP_PORT": 8200,
    "GLOBAL_API_PREFIX": "/api"
  },
  "BACK_OFFICE": {
    "REVERSE_CONTEXT": "back-office",
    "REVERSE_ADDRESS": "http://127.0.0.1:8003",
    "HTTP_PORT": 8003,
    "TCP_HOST": "0.0.0.0",
    "TCP_PORT": 8300,
    "GLOBAL_API_PREFIX": "/api"
  }
}

apps/gateway/api/src/main.ts는 http server를 설정한다.

  • global prefix를 설정하지 않고, 각 controller에서 설정한다.
  • gateway api 호출은 "api/gateway"
  • dashboard api 호출은 "api/dashboard". 다른 microservice도 동일하다.
// gateway의 main.ts
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';

import { loadGatewayConfiguration } from '@rnm/shared';

import { AppModule } from './app/app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Load config.json file
  const config = loadGatewayConfiguration();

  const httpPort = config.HTTP_PORT || process.env.HTTP_PORT || 8000;
  await app.listen(httpPort, () => {
    Logger.log(`Listening at http://localhost:${httpPort}`);
  });
}

bootstrap();


// app.controller.ts
@Controller('api/gateway') <== 요기
export class AppController { ... }


// dashboard.controller.ts
@Controller('api/dashboard') <== 요기
export class DashboardController { ... }

apps/gateway/api/src/app/app.module.ts에서는 static file 서비스 폴더를 지정한다. reverse proxy를 위해 아래 3개의 exclude를 추가한다. web context처럼 사용될 것이다.

  • dashboard
  • configuration
  • back-office
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';

import { GatewayApiAppService } from '@rnm/domain';

import { AppController } from './app.controller';
import { DashboardModule } from './dashboard/dashboard.module';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      exclude: [
        '/api/gateway*', '/api/dashboard*',
        '/dashboard*', '/configuration*', '/back-office*'
      ],
    }),
    DashboardModule
  ],
  controllers: [AppController],
  providers: [GatewayApiAppService]
})
export class AppModule { }

gateway, back-office, configuration, dashboard등에서 사용하는 api service 를 domain library로 옮기고, 명칭을 수정하여 import해서 사용한다. 예로 GatewayApiAppService, DashboardApiAppService와 같다. api service중에 라이브러리성으로 옮겨도 될 만한 것들을 domain 라이브러리 밑으로 작성한다. 예로 여러 곳에서 호출되는 공통 api의 경우 또는 업무적인 묶음단위로 api를 관리하고 싶을 경우이다. 해당 api를 라이브러리로 빼는 이유는 향후 다른 UI에서 사용하거나 또는 UI가 바뀌어도 업무적인 api에 변경이 없을 경우를 가정한 것이다.

 

Step-5) Gateway에 Micro Service쪽으로 reverse proxy 설정하기

micro service 인 dashboard, configuration, back-office는 자신의 http web server로써 static file을 서비스하고, tcp를 통해 api를 전송한다. 개발시에는 gateway를 구동하지 않고, micro service만 실행한다면 http를 통해 api 서비스 추가하면 될 것이다.

Gateway에서 reverse proxy되는 3개 context

 

http-proxy-middleware를 설치한다. 

$>  yarn add http-proxy-middleware

 

다음으로 apps/gateway/api/src/app 폴더 밑으로 dashboard 폴더를 생성하고,  dashboard용 controller, service, module과 dashboard.proxy.ts 파일들을 생성한다.

  • NestMiddleware를 상속받아 use를 구현한다.
// dashboard.proxy.ts ==> dashboard-proxy.middleware.ts 로 추후 명칭 변경됨
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NestMiddleware, Logger } from '@nestjs/common';
import { loadGatewayConfiguration } from '@rnm/shared';
import { createProxyMiddleware } from 'http-proxy-middleware';

export class DashboardReverseProxyMiddleware implements NestMiddleware {
  private config = loadGatewayConfiguration();
  private proxyOptions = {
    target: this.config.DASHBOARD.REVERSE_ADDRESS,
    secure: false,
    onProxyReq: (proxyReq: any, req: any, res: any) => {
      Logger.debug(`[DashboardReverseProxyMiddleware]: Proxying ${req.method} request originally made to '${req.url}'`);
    },
  };
  private proxy: any = createProxyMiddleware(this.proxyOptions);

  use(req: any, res: any, next: () => void) {
    this.proxy(req, res, next);
  }
}

dashboard.module.ts에 다음과 같이 dashboard에 TCP로 접근하는 Client설정을 한다. 그리고 ProxyMiddleware를 설정한다.

import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';

import { loadGatewayConfiguration } from '@rnm/shared';

import { DashboardController } from './dashboard.controller';
import { DashboardReverseProxyMiddleware } from './dashboard.proxy';
import { DashboardService } from './dashboard.service';

const config = loadGatewayConfiguration();

@Module({
  imports: [
    // dashboard쪽 api 호출을 위한 TCP Client Proxy 등록
    ClientsModule.register([
      {
        name: "DASHBOARD",
        transport: Transport.TCP,
        options: {
          host: config.DASHBOARD.TCP_HOST,
          port: config.DASHBOARD.TCP_PORT
        }
      }
    ])
  ],
  controllers: [DashboardController],
  providers: [DashboardService],
})
export class DashboardModule implements NestModule {
  // middleware 적용
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(DashboardReverseProxyMiddleware)
      .forRoutes({ path: config.DASHBOARD.REVERSE_CONTEXT, method: RequestMethod.ALL });
  }
}

전체 코드 준비는 되었고, 다음 글에서는 MS Code에서 Debugging을 통해 위의 코드를 테스트해 본다.

 

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-2

 

 

<참조>

- 마이크로 서비스 설정 예제
https://github.com/danmt/microservices-basics

 

GitHub - danmt/microservices-basics: This project intention is to show microservices basic concepts in NestJs

This project intention is to show microservices basic concepts in NestJs - GitHub - danmt/microservices-basics: This project intention is to show microservices basic concepts in NestJs

github.com

- reverse proxy 설정 예제

https://github.com/baumgarb/reverse-proxy-demo

 

GitHub - baumgarb/reverse-proxy-demo: Reverse proxy demo with NestJS and http-proxy-middleware

Reverse proxy demo with NestJS and http-proxy-middleware - GitHub - baumgarb/reverse-proxy-demo: Reverse proxy demo with NestJS and http-proxy-middleware

github.com

- http-proxy-middleware 패키지

https://github.com/chimurai/http-proxy-middleware

 

GitHub - chimurai/http-proxy-middleware: The one-liner node.js http-proxy middleware for connect, express and browser-sync

:zap: The one-liner node.js http-proxy middleware for connect, express and browser-sync - GitHub - chimurai/http-proxy-middleware: The one-liner node.js http-proxy middleware for connect, express a...

github.com

 

posted by 윤영식
2021. 9. 20. 19:36 React/Architecture

애플리케이션이 계속 증가하고, 작은 변경에 기민하게 대응할 수 있고, 팀별 기술셋의 다양성을 수용하는 환경에서 Micro Service 개발/운영 환경을 구축해 본다. 

 

Micro Service

모노리틱환경의 단점을 극복한다. (참조)

  • 작은 사이즈의 번들링 및 배포
  • 지속적인 애플리케이션 추가
  • 점점 늘어나는 접속자의 대응

Gateway Pattern 적용

  • gateway 역할
    • api gateway: micro service의 api server와 TCP로 연결한다. 또는 중간에 Queue(Redis, RabbitMQ, Kafka)등을 두어서 구성해도 된다.
    • http server: login UI부분을 gateway에서 담당하고 JWT를 사용한다.
    • reverse proxy: micro service에 대한 web bundling file에 대한 reverse proxy 역할을 한다.
    • auth server: login시 auth 역할을 한다. 
  • micro service 역할
    • api server: api는 TCP로 응답한다. 
    • http server: reverse proxy의 static 파일 대응을 한다. 

 

가용성을 위하여 gateway를 두개를 두어 용도별로 운영할 수 있다. 

docker 기반으로 운영

 

 

BFF 패턴

Backend For Frontend 패턴 방식으로 개발한다. 

  • Server는 NodeJS 기반으로 구성한다. 
  • Client/Server 개발은 TypeScript로 통일한다.
  • CSR(Client Side Rendering), SSR(Server Side Rendering)을 사용한다. 

참조: https://giljae.medium.com/back-end-for-front-end-pattern-bff-4b73f29858d6 

 

 

 

개발 폴더 구성

멀티 애플리케이션을 mono repo에서 개발할 수 있는 폴더 구조를 정의한다. 

  • apps 폴더
    • 4개의 애플리케이션이 존재한다.
  • libs 폴더
    • page: 애플리케이션에 단위 화면 page를 개발하는 것이 아니라. page단위 별도 구성하고, application에서 조합한다.
    • ui: 모든 애플리케이션에서 사용하는 공통 ui
    • domain: core 업무에 대한 모든 내역들, 변경에 대해 유연하게 대처하기 위해 hexagonal architect방식으로 구성한다.
    • shared: common 라이브러리를 적용한다.

 

application의 libs 라이브러리 참조 방식

  • api: api server역할이므로 주로 none visible한 라이브러리 참조
  • web: ui 애플리케이션이므로 page, ui 라이브러도 참조

 

애플리케이션과 라이브러리 참조 관계

 

 

운영 폴더 구성

gateway와 micro services를 배포하는 폴더 구조를 정의한다. 

  • bin: start/stop shell
  • config: 환경파일
  • lib: server 구동 번들링 파일
  • public: static, web 번들링 파일들
  • logs: 서버의 로그 파일

 

 

<참조>

- 마이크로 서비스 아키텍쳐 패턴 그리고 장/단점 (강추)

https://medium.com/design-microservices-architecture-with-patterns/monolithic-to-microservices-architecture-with-patterns-best-practices-a768272797b2

 

Monolithic to Microservices Architecture with Patterns & Best Practices

In this article, we’re going to learn how to Design Microservices Architecture with using Design Patterns, Principles and the Best…

medium.com

 

- 헥사고날 아키텍쳐

https://engineering-skcc.github.io/microservice%20inner%20achitecture/inner-architecture-2/

 

마이크로서비스 내부아키텍처 - 2회 : 클린 아키텍처와 헥사고널 아키텍처

마이크서비스 내부 아키텍처에 대해 살펴보자. 3회에 걸쳐서 기존 아키텍처의 문제점 및 이를 개선하고자 했던 아키텍처 변화양상과 이를 반영한 어플리케이션 구조에 대해 알아 보겠다.

engineering-skcc.github.io

 

- BFF 패턴

https://giljae.medium.com/back-end-for-front-end-pattern-bff-4b73f29858d6

 

Back-end for Front-end Pattern (BFF)

소프트웨어 구성의 진화

giljae.medium.com

 

posted by 윤영식
2021. 9. 8. 19:10 React/Start React

서버 사이드 렌더링을 위하여 Next.JS를 적용해 본다. Micro Frontend 전략을 활용하여 구성해 본다. 

  • apps 밑에는 multi application이 존재한다.
  • libs 밑에는 base 패키지들이 있다. base 패키지는 계속 추가될 수 있다.
  • feature는 view와 domain이 존재한다.
    • view: 업무 UI 표현
    • domain: 업무 api 호출(react-query or graphql query) 및 결과 data model, custom Hook 기반
  • page에서 view와 domain을 조합한다.
    • SSR에서 Next.JS 기반으로 진행시 feature의 domain을 통해 데이터를 다룬다. 

 

멀티 애플리케이션에서의 단위 업무화면 구성방식

 

 

NextJS 개념

최신것이 항상 좋은 것이 아니다. SPA 프레임워크의 CSR(Client Side Rendering)만으로 개발하다가 예전의 JSP, ASP같은 SSR(Server Side Rendering)의 이점이 있었다. NextJS는 CSR, SSR 뿐만아니라 SSG(Static Site Generation)도 지원을 한다. 

 

  • CSR
  • SSR
  • SSG

 

SSG에 대한 개념

 

SSG를 위한 getStaticProps, getStaticPaths

  • getStaticProps와 getStaticPaths는 async이다.
  • getStaticProps, getStaticPaths에서 데이터를 가져오는 domain에 위치한 api를 호출한다. 
  • getStaticPaths는 URL path에 따라 데이터가 변경될 때를 대비한다. 

 

SSR를 위한 getServerSideProps

  • 서버에 요청때 마다 데이터를 맵핑하여 응답한다. 

 

Use Case

  • Data가 즉각적으로 변화하는 Dashboard에는 CSR을 적용
  • eCommerce 같은 경우 제품의 변경이 수시로 일어나지 않을 때 SSG 적용
  • NodeJS위에 Isomorphic Application으로 간다. 

 

Frontend 개발 영역의 변경

  • NodeJS 기반위에 페이지 개발의 주도권을 Frontend에서 갖는다.
  • Backend API를 조합하는 역할을 수행한다. 

Frontend 개발영역의 확장. (출처: D2 참조글)

 

Rendering on the Web 위치

Rendering on the Web 출처

 

NextJS기반 애플리케이션은 이전 블로그에서 tube-ssr 애플리케이션을 생성하였다. 이를 기반으로 다음 글에서 로그인 화면을 만들어 보자 

 

 

<참조>

> Vercel의 NextJS 소개 영상

https://www.youtube.com/watch?v=eMwTEo6AjDc 

> CSR, SSR, SSG 의 흐름

https://wonit.tistory.com/361

 

[정직하게 배워보는 Next.js] (번외) 웹의 발전 과정으로 보는 CSR 그리고 SSG와 SSR

해당 블로그 시리즈는 정직하게 배워보는 Next js 시리즈로써 총 8부작으로 이루어져 있습니다. Next.js공식 홈페이지에서 이야기하는 내용을 최대한 이해하기 쉽고 직관적이게 구성하였습니다. 0.

wonit.tistory.com

 

> SPA의 SSR 지원

https://d2.naver.com/helloworld/7804182

 

> NextJS 기반 실습 시리즈

https://velog.io/@mskwon/next-js-page-static-generation

 

Next JS Page Static Generation

Next JS에서는 각 페이지에 대해서 pre-rendering을 위해 SSR(서버 사이드 렌더링) 또는 SG(정적 생성) 옵션을 제공한다. 각각 페이지 컴포넌트에서 getServerSideProps, getStaticProps/getStaticPaths 함수 expo

velog.io

 

posted by 윤영식
2021. 9. 7. 17:49 React/Start React

 props를 통해 하위 컴포넌트에 전파하는 것이 아니라 글로벌하게 접근할 수 있는 상태 저장소 사용을 위해 recoil을 사용한다. recoil 사용이유는 간결하며 Hook스타일 개발에 적합해 보인다.

 

Concept & API

1) Flexible shared state: 유연하게 상태를 공유해 보자

2) Derived data and queries: 파생으로 데이터를 만들고 조회할 수 있다.

3) App-wide state observation: 애플리케이션 전체에 걸쳐 변경을 관찰할 수 있다.

 

atom: 상태의 단위이다. 

useRecoilState: useState와 같이 read/write 가능

useSetRecoilState/useResetRecoilState: write-only

useRecoilValue: read-only, useRecoilValue(atom | selector)

selector: 파생상태를 갖거나, Async호출을 하고 싶을 경우

사용예) recoil-paint 소스

 

 

설치 및 설정

recoil을 설치한다. 

$> npm install recoil

ESLint에 recoil hook 을 설정한다. 루트의 .eslintrc.json 파일에 useRecoilCallback 을 설정한다. 

{
  "root": true,
  "ignorePatterns": ["**/*"],
  "plugins": ["@nrwl/nx"],
  "overrides": [
    {
      "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
      "rules": {
        "@nrwl/nx/enforce-module-boundaries": [
          "error",
          {
            "enforceBuildableLibDependency": true,
            "allow": [],
            "depConstraints": [
              {
                "sourceTag": "*",
                "onlyDependOnLibsWithTags": ["*"]
              }
            ]
          }
        ],
        // 해당 하위 설정
        "react-hooks/exhaustive-deps": [
          "warn",
          {
            "additionalHooks": "useRecoilCallback"
          }
        ]
      }
    },
    {
      "files": ["*.ts", "*.tsx"],
      "extends": ["plugin:@nrwl/nx/typescript"],
      "rules": {}
    },
    {
      "files": ["*.js", "*.jsx"],
      "extends": ["plugin:@nrwl/nx/javascript"],
      "rules": {}
    }
  ]
}

apps/tube-csr/src/main.tsx 에 RecoilRoot를 설정한다. 

import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom';

import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';
import { RecoilRoot } from 'recoil';

import App from './app/app';

const queryClient = new QueryClient();

ReactDOM.render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <RecoilRoot>
        <App />
      </RecoilRoot>
      <ReactQueryDevtools />
    </QueryClientProvider>
  </StrictMode>,
  document.getElementById('root')
);

 

state 패키지 구성 및 async 적용하기

libs/state 패키지를 생성한다. 

$> nx g @nrwl/react:lib state

UPDATE workspace.json
CREATE libs/state/project.json
CREATE libs/state/.eslintrc.json
CREATE libs/state/.babelrc
CREATE libs/state/README.md
CREATE libs/state/src/index.ts
CREATE libs/state/tsconfig.json
CREATE libs/state/tsconfig.lib.json
UPDATE tsconfig.base.json
CREATE libs/state/jest.config.js
CREATE libs/state/tsconfig.spec.json
CREATE libs/state/src/lib/state.module.scss
CREATE libs/state/src/lib/state.spec.tsx
CREATE libs/state/src/lib/state.tsx

 

core, state, api의 사용관계도. 원칙으로 상호 참조는 할 수 없고, 단방향 사용만 가능하다.

core와 api 패키지는 최하단에 위치하고, app(application)은 core,state, api모두를 이용한다. state는 core, api를 이용한다.

조건)

  - state는 selector를 통해 state 변경이 요구될 때만 aync ajax 호출이 있는 경우 api 패키지를 접근한다. 

  - api는 기본적으로 모든 ajax call을 처리한다.

사용 관계도

 

State Custom Hook 을 만들어 기존의 useDogApi (기존 useDogList를 Api postfix로 명칭 변경)를 호출토록 한다. libs/state/src/lib 폴더에 dog.state.tsx를 만들고 다음과 같이 리팩토링한다. 

  • api 패키지의 useDogApi()를 호출한다. 
  • atom을 생성한다: dog 목록을 저장
  • useEffect안에서 render이후 설정한다. useEffect를 사용하지 않으면 에러가 발생한다.

without useEffect

import { useEffect } from 'react';
import { atom, useRecoilState } from 'recoil';

import { useDogApi } from '@rnn-stack/api';

export const dogAtom = atom<string[]>({
  key: 'dogAtom',
  default: [],
});

export function useDogState(): [isLoading: boolean, dog: string[], error: Error | null] {
  const { isLoading, data, error } = useDogApi();
  const [dog, setDog] = useRecoilState(dogAtom);

  useEffect(() => {
    setDog(data?.message || []);
  }, [data?.message, setDog]);

  return [isLoading, dog, error];
}

react-query의 반환값을 같이 넘긴다. useDogState Hook을 apps/tube-csr/src/app/dog.tsx에 적용한다. 즉, 기존 useDogApi 코드를 리팩토링한다. 

import { List } from 'antd';
import { useDogState } from '@rnn-stack/state';

function DogList() {
  // recoil을 사용한 custom hook 적용
  const [isLoading, dog, error] = useDogState();

  if (isLoading || !dog) {
    return <span>loading...</span>;
  }

  if (error) {
    return <span>Error: {error.message}</span>;
  }

  return (
    <List
      header={<div>Header</div>}
      footer={<div>Footer</div>}
      bordered
      dataSource={dog}
      renderItem={(item: string) => <List.Item>{item}</List.Item>}
    />
  );
}

export default DogList;

위의 경우는 api호출후 서버 state에 결과를 저장하는 과정으로 react-query를 사용해도 되지만 예로 작성해 보았다. UI에 대한 state 저장은 소개 영상을 참조한다. 

https://www.youtube.com/watch?v=_ISAA_Jt9kI&t=1s 

 

 

소스: https://github.com/ysyun/rnn-stack/releases/tag/hh-5

 

 

 

<참조> 

> 한글: https://recoiljs.org/ko/

 

Recoil

A state management library for React.

recoiljs.org

> Awesome Recoil: https://github.com/nikhil-malviya/awesome-recoil

 

GitHub - nikhil-malviya/awesome-recoil: A curated list of Recoil libraries, code snippets, best practices, benchmarks and resour

A curated list of Recoil libraries, code snippets, best practices, benchmarks and resources. - GitHub - nikhil-malviya/awesome-recoil: A curated list of Recoil libraries, code snippets, best practi...

github.com

> 2021 libraries & React state mangement

https://dev.to/sfeircode/my-go-to-react-libraries-for-2021-4k1

 

My go-to React libraries for 2021

I’ve been working on React apps for more than 3 years now and I’ve used tons of libraries to build va...

dev.to

https://dev.to/workshub/state-management-battle-in-react-2021-hooks-redux-and-recoil-2am0

 

State Management Battle in React 2021: Hooks, Redux, and Recoil

Introduction: Over the years, the massive growth of React.JS has given birth to different...

dev.to

> State Management 분류

   Flux: Redux, zustand

   Atomic: Jotai, recoil

   Proxy: mobx, valtio, overmind

https://velog.io/@gtah2yk/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%83%81%ED%83%9C%EC%97%90-%EA%B4%80%ED%95%9C-%EC%A0%95%EB%A6%AC-%EA%B8%80

 

프론트엔드 상태에 관한 정리 글

프론트엔드 개발에서 짜증나는건 상태관리 부분이다.몇 가지 좋은 글이 많아서 요약겸 정리를 하면 좋을 것 같다 생각이 든다.https://leerob.io/blog/react-state-management리액트 상태 관리의 역사를 다루

velog.io

 

> Zustand vs Redux vs Jotai vs Recoil
소스: https://github.com/redhwannacef/youtube-tutorials/blob/main/react-state-management/README.md 

https://www.youtube.com/watch?v=BaEWIbxZKco 

posted by 윤영식
2021. 9. 3. 15:22 React/Start React

react-query를 사용해 본다. 기능

  • 캐싱 기능: fresh, fetching, stale, inactive, delete 상태
    • stale 상태가 받은 데이터를 캐싱한 상태이다
  • QueryClientProvider 설정을 통해 useQuery 훅에 QueryClient를 전달한다.
  • 조회
    • const { data, isLoading, status, error, isFetching } = useQuery(queryKey, queryFunction, options) 호출
    • queryKey는 문자열, 배열이 가능하고 캐싱하는 키로 사용한다.
    • queryFuction은 promise를 반환한다.
    • isLoading: 캐시데이터 없음. 데이터 요청중 true
    • isFetching: 캐시데이터 유무와 상관 없음. 데이터 요청중 true
  • 생성/수정/삭제
    • const { mutate } = useMutation(queryFunction, options);
    • mutate(data, callbacks);
  • 캐싱 무효화
    • const queryClient = useQueryClient();
    • queryClient.invalidateQuries(key);

 

 

설치 및 설정

react-query를 설치한다. 

$> npm install react-query

apps/tube-csr/src/main.tsx에 react-query의 provider를 설정한다. 그리고 ReactQuery의 DevTool도 설치한다. 

// main.tsx
import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom';

import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';

import App from './app/app';

const queryClient = new QueryClient();

ReactDOM.render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools />
    </QueryClientProvider>
  </StrictMode>,
  document.getElementById('root')
);

 ReactQuery DevTool

 

Custom Hook 만들기 및 api 패키지 구성

useQuery를 통해 custom Hook을 만들 수 있다. useQuery 사용시 Typescript에 대한 타입정의를 하자. api 패키지를 별도로 생성하고, dog.api.ts 파일을 생성한다. api custom Hook과 type 파일을 별도로 모아 둔다

$> nx g @nrwl/react:lib api

libs/api/src/lib 폴더 밑에 dog.api.ts 파일을 생성하고, react-query 의 custom Hook을 만든다. 

// dog.api.ts

import { useQuery } from 'react-query';

import { httpService } from '@rnn-stack/core';

export function useDogList() {
  return useQuery<{ message: string[]; status: string }, Error>('dog-list', () =>
    httpService
      .get('https://dog.ceo/api/breeds/list')
      .toPromise()
      .then((response: any) => response)
  );
}

dog.api.ts 파일을 libs/api/src/index.ts 에 export 추가한다. 

export * from './lib/api';

export * from './lib/api/dog.api';

 

이제 apps/tube-csr/src/app/dog.tsx 에서 사용해 본다. 기존 코드는 주석 처리하고 useDogList 라는 custom Hook을 사용한다. 

useState, useEffect 사용하지 않고 custom Hook을 별도로 작성하였다.

  • 코드가 간결해 졌다.
  • 역할 분리가 잘 되었다.
  • api는 여러곳에서 공통으로 사용하므로 위치투명하게 api 패키지로 이동하였다.
    • import { useDogList } from "@rnn-stack/api";
// dog.tsx
import { List } from 'antd';
import { useDogList } from '@rnn-stack/api';

function DogList() {
  // const [dogList, setDogList] = useState<string[]>([]);
  // useEffect(() => {
    // httpService.get('https://dog.ceo/api/breeds/list').subscribe((response: any) => {
    //   console.log('axios observable response:', response);
    //   setDogList(response.message);
    // });
  // }, []);

  const { isLoading, data, error } = useDogList();

  if (isLoading || !data) {
    return <span>loading...</span>;
  }

  if (error) {
    return <span>Error: {error.message}</span>;
  }

  return (
    <List
      header={<div>Header</div>}
      footer={<div>Footer</div>}
      bordered
      dataSource={data.message}
      renderItem={(item: string) => <List.Item>{item}</List.Item>}
    />
  );
}

export default DogList;

 

현재는 각 라이브러리의 쓰임세와 구조에 대해서 보고 있다. Production에 사용하려면 좀 더 다음어야 할 것으로 보인다. 다음은 상태관리를 위하여 recoil을 적용해 보자.

 

소스: https://github.com/ysyun/rnn-stack/releases/tag/hh-4

 

<참조>

>ReactQuery에 대한 장점을 잘 설명준다. 

https://swoo1226.tistory.com/216

 

[React-Query] 리액트로 비동기 다루기

react에서 비동기를 다루는 방법은 다양하다. javascript 언어니까 당연히 Promise, async & await으로 처리가 가능하다. redux를 사용하고 있다면, redux saga, thunk 등 다양한 미들웨어가 제공된다. 하지만 이..

swoo1226.tistory.com

 

> react-query & typescript: https://tkdodo.eu/blog/react-query-and-type-script

 

>  react hook & rxjs: https://nils-mehlhorn.de/posts/react-hooks-rxjs

 

React Hooks vs. RxJS

Here's why React Hooks are not reactive programming and how you can use RxJS knowledge from Angular in React

nils-mehlhorn.de

 

> hooks: https://twitter.com/tylermcginnis/status/1169667360795459584

  • useState: Persist value between renders, trigger re-render
  • useRef: Persist value between renders, no re-render
  • useEffect: Side effects that run after render
  • useReducer: useState in reducer pattern
  • useMemo: Memoize value between renders
  • useCallBack: Persist ref equality between renders

 

> hook lifecycle: https://medium.com/doctolib/how-to-replace-rxjs-observables-with-react-hooks-436dc1fbd324

 

How to replace RxJS observables with React hooks

As we saw in one of our previous articles, Doctolib is trying to help our new joiners to ramp up faster on our stack. One of the hardest…

medium.com

 

posted by 윤영식
2021. 8. 28. 12:24 React/Start React

React 외에 애플리케이션 개발을 위한 라이브러리를 설치한다.

 

 

 

 

 

 

UI Components

PrimeReact, EUI, MaterialUI, AntD 검토후 소스레벨 최신으로 반영하고 있고, 다양한 비즈니스 UX 대응 가능한 AntD를 선택한다.

// UI Component
$> yarn add antd

// Icon
$> yarn add @ant-design/icons

AntD의 스타일을 apps/tube-csr/src/styles.scss에 import 하고, app.tsx에 AntD의 Button 컴포넌트를 테스트 해본다. styles.scss는 글로벌 스타일로 apps/tube-csr/project.json 환경파일에 설정되어 있다.

// styles.scss
@import 'antd/dist/antd.css';


// app.tsx
import { Button } from 'antd';

import styles from './app.module.scss';

export function App() {
  return (
    <div className={styles.app}>
      <Button type="primary">Primary Button</Button>
      <Button>Default Button</Button>
      <Button type="dashed">Dashed Button</Button>
      <br />
      <Button type="text">Text Button</Button>
      <Button type="link">Link Button</Button>
    </div>
  );
}

export default App;

테스트 서버를 수행하고 확인해 본다. 

$> nx serve tube-csr

 

 

라우터 설치

CSR의 Router를 위한 react-router, react-router-dom을 설치한다.

$> yarn add react-router react-router-dom

 

 

Ajax를 위한 라이브러리 설치

Client/Server 모두 사용할 수 있는 Axios 를 설치하고 rxjs를 통해 api 를 제어할 것이다. 

$> yarn add axios

 

테스트 프로그램 작성

// dog.tsx
import { List } from 'antd';
import Axios, { AxiosResponse } from 'axios';
import { useEffect, useState } from 'react';

function DogList() {
  const [dogList, setDogList] = useState<string[]>([]);

  useEffect(() => {
    Axios.get('https://dog.ceo/api/breeds/list').then((result: AxiosResponse) => {
      setDogList(result.data.message);
    });
  }, []);

  return (
    <List
      header={<div>Header</div>}
      footer={<div>Footer</div>}
      bordered
      dataSource={dogList}
      renderItem={(item: string) => <List.Item>{item}</List.Item>}
    />
  );
}

export default DogList;


// app.tsx 에 추가
<DogList />

axios의 처리에 대해 rxjs 라이브러리를 같이 사용해 본다. 

$> yarn add rxjs

axios와 rxjs의 Observable을 사용한 라이브러리를 구현한다. 라이브러리는 NX의 libs 폴더 밑으로 생성한다. 

$> nx g @nrwl/react:lib core

// 실행을 하면 루트의 libs/core 밑으로 package가 생성된다.

libs/core/src밑으로 ajax 폴더를 생성하고 http.service.ts 파일을 생성한다. 

  • Axios와 Observable을 조합
  • Axios cancel 적용
// libs/core/src/lib/ajax/http.service.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios, {
  AxiosError,
  AxiosInstance,
  AxiosPromise,
  AxiosRequestConfig,
  AxiosResponse,
  CancelTokenSource,
} from 'axios';
import { Observable, Observer } from 'rxjs';

// sample url: https://jsonplaceholder.typicode.com/users
interface RequestArgs {
  method: HttpMethod;
  url: string;
  queryParams?: any;
  payload?: any;
}

enum HttpMethod {
  GET = 'GET',
  POST = 'POST',
  PUT = 'PUT',
  PATCH = 'PATCH',
  DELETE = 'DELETE',
}

export class HttpService {
  private httpClient!: AxiosInstance;
  private cancelTokenSource!: CancelTokenSource;
  private options!: AxiosRequestConfig;
  private completed!: boolean;

  get<T>(url: string, queryParams?: any, options: AxiosRequestConfig = {}): Observable<T> {
    this.setOptions(options);
    return this.executeRequest<T>({ method: HttpMethod.GET, url, queryParams });
  }

  post<T>(url: string, payload: any, options: AxiosRequestConfig = {}): Observable<T> {
    this.setOptions(options);
    return this.executeRequest<T>({
      method: HttpMethod.POST,
      url,
      payload,
    });
  }

  put<T>(url: string, payload: any, options: AxiosRequestConfig = {}): Observable<T> {
    this.setOptions(options);
    return this.executeRequest<T>({
      method: HttpMethod.PUT,
      url,
      payload,
    });
  }

  patch<T>(url: string, payload: any, options: AxiosRequestConfig = {}): Observable<T> {
    this.setOptions(options);
    return this.executeRequest<T>({
      method: HttpMethod.PATCH,
      url,
      payload,
    });
  }

  delete<T>(url: string, options: AxiosRequestConfig = {}): Observable<T> {
    this.setOptions(options);
    return this.executeRequest<T>({
      method: HttpMethod.DELETE,
      url,
    });
  }

  cancel(forcely = false): void {
    if (!this.completed || forcely) {
      this.cancelTokenSource.cancel(`${this.options.url} is aborted`);
    }
  }

  private setOptions(options: AxiosRequestConfig = {}): void {
    if (this.options) {
      this.options = { ...this.options, ...options };
    } else {
      this.options = options;
    }
    this.cancelTokenSource = axios.CancelToken.source();
    this.httpClient = axios.create({ ...options, cancelToken: this.cancelTokenSource.token });
    this.completed = false;
  }

  private executeRequest<T>(args: RequestArgs): Observable<T> {
    const { method, url, queryParams, payload } = args;
    let request: AxiosPromise<T>;
    switch (method) {
      case HttpMethod.GET:
        request = this.httpClient.get<T>(url, { params: queryParams });
        break;
      case HttpMethod.POST:
        request = this.httpClient.post<T>(url, payload);
        break;
      case HttpMethod.PUT:
        request = this.httpClient.put<T>(url, payload);
        break;
      case HttpMethod.PATCH:
        request = this.httpClient.patch<T>(url, payload);
        break;
      case HttpMethod.DELETE:
        request = this.httpClient.delete<T>(url);
        break;
    }

    return new Observable<T>((observer: Observer<T>) => {
      request
        .then((response: AxiosResponse) => {
          observer.next(response.data);
        })
        .catch((error: AxiosError | Error) => {
          this.cancel();
          observer.error(error);
          if (axios.isAxiosError(error)) {
            console.log(error.code);
            if (error.response) {
              console.log(error.response.data);
              console.log(error.response.status);
              console.log(error.response.headers);
            }
          } else {
            console.log(error.message);
          }
        })
        .finally(() => {
          this.completed = true;
          observer.complete();
        });
    });
  }
}

export const httpService = new HttpService();

다음으로 apps/tube-csr/src/app/dog.tsx에 테스트 코드를 입력한다. httpService를 import할 때 from 구문이 Local file system에 있다하더라도 마치 node_modules에서 import하는 것처럼 위치투명성을 보장한다. 이는 루트에 위치한 tsconfig.base.json 파일에 lib 생성할 때 자동 등록된다. 

// dog.tsx

import { useEffect, useState } from 'react';

import { List } from 'antd';
// import Axios, { AxiosResponse } from 'axios';

import { httpService } from '@rnn-stack/core';

function DogList() {
  const [dogList, setDogList] = useState<string[]>([]);

  useEffect(() => {
    httpService.get('https://dog.ceo/api/breeds/list').subscribe((response: any) => {
      console.log('axios observable response:', response);
      setDogList(response.message);
    });
    // Axios.get('https://dog.ceo/api/breeds/list').then((result: AxiosResponse) => {
    //   setDogList(result.data.message);
    // });
  }, []);

  return (
    <List
      header={<div>Header</div>}
      footer={<div>Footer</div>}
      bordered
      dataSource={dogList}
      renderItem={(item: string) => <List.Item>{item}</List.Item>}
    />
  );
}

export default DogList;

tsconfig.base.json

{
  "compileOnSave": false,
  "compilerOptions": {
    "rootDir": ".",
    "sourceMap": true,
    "declaration": false,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "importHelpers": true,
    "target": "es2015",
    "module": "esnext",
    "lib": ["es2017", "dom"],
    "skipLibCheck": true,
    "skipDefaultLibCheck": true,
    "baseUrl": ".",
    "paths": {
      "@rnn-stack/core": ["libs/core/src/index.ts"]   <== 요놈
    }
  },
  "exclude": ["node_modules", "tmp"]
}

다음 포스팅에서 계속 진행...

 

소스: https://github.com/ysyun/rnn-stack/releases/tag/hh-3 

 

Release hh-3 · ysyun/rnn-stack

hh-3 add libraries and http.service.ts

github.com

 

 

posted by 윤영식
2021. 8. 25. 15:40 React/Start React

NXAngular/CLI를 확장하여 Typescript기반의 멀티 애플리케이션 및 노드 패키지개발을 위한 환경을 제공한다. 또한 Plugin 기반으로 React, NextJS, NestJS와 같은 프레임워크와 노드환경 확장을 통해 FullStack개발을 지원한다. 

 

목적

  • Case1: SPA/CSR의 React 애플리케이션 생성 + NestJS 기반 노드서버 생성
  • Case2: SSR을 위한 NextJS기반 애플리케이션 생성
  • Case1과 2의 두가지 애플리케이션에 대해 비교 테스트 진행하여 성능 이점과 차이점을 비교한다.

 

로컬에 새로운 환경 구성하기

NodeJS기반 테스트 환경 구축시 NodeJS버전을 변경하며 사용할 수 있도록 Local PC에 nvm (Node Version Manager)를 설치한다. Windows는 관련 링크를 참조하여 설치한다.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash

NodeJS LTS버전을 nvm을 통해 설치하고 사용 설정한다. 

$> nvm install v14.18.0
$> nvm use 14.18.0

소스 폴더로 이동을 하면 지정한 node version으로 nvm을 통해 switching 하고 싶다면 폴더에 .nvmrc 파일을 생성하고 "14.18.0" 버전을 명시하고, 로그인 사용자 .zshrc 또는 .bashrc 에 하기 사항을 설정한다. Gist 소스 참조

#! /usr/bin/env zsh

# Ref: https://github.com/creationix/nvm#calling-nvm-use-automatically-in-a-directory-with-a-nvmrc-file
# place this after nvm initialization!

autoload -Uz add-zsh-hook

# Function: load-nvmrc
load-nvmrc() {
    local _CUR_NODE_VER="$(nvm version)"
    local _NVMRC_PATH="$(nvm_find_nvmrc)"

    if [[ -n "${_NVMRC_PATH}" ]]; then
        local _NVMRC_NODE_VER="$(nvm version "$(cat "${_NVMRC_PATH}")")"

        if [[ "${_NVMRC_NODE_VER}" == 'N/A' ]]; then
            local compcontext='yn:yes or no:(y n)'
            vared -cp "Install the unmet version ($(cat "${_NVMRC_PATH}")) in nvm (y/n) ?" _ANSWER
            if [[ "${_ANSWER}" =~ '^y(es)?$' ]] ; then
                nvm install
            fi
        elif [[ "${_NVMRC_NODE_VER}" != "${_CUR_NODE_VER}" ]]; then
            nvm use
        fi
    elif [[ "${_CUR_NODE_VER}" != "$(nvm version default)" ]]; then
        echo -e "Reverting to the default version in nvm"
        nvm use default
    fi
}

add-zsh-hook chpwd load-nvmrc
load-nvmrc

 

NX 개발환경 구성을 위한 글로벌 패키지를 설치한다. Typescript는 v4.3.5 이상을 사용한다.

$> npm i -g @angular/cli@latest
$> npm i -g @nrwl/cli@latest
$> npm i -g yarn@latest

 

NX 개발환경 생성하기

npx 명령으로 개발환경 생성. RNN Stack에서 RNN은 React NestJS NextJS 을 합친 것이다. React Application의 명칭은 "tube-csr" 이다.

반드시 latest 버전으로 설치한다. 

$> npx create-nx-workspace@latest

선택하기
  ✔ Workspace name (e.g., org name)     · rnn-stack
  ✔ What to create in the new workspace · react
  ✔ Application name                    · tube-csr
  ✔ Default stylesheet format           · scss
  ✔ Use Nx Cloud? (It's free and doesn't require registration.) · No
   
$> cd rnn-stack

 

"tube-csr" 애플리케이션을 위한 Node서버로 NestJS 플로그인를 설치하고, "tube-api" 이름으로 서버를 생성한다.

Nx의 NestJS 플러그인 설치
$> yarn add -D @nrwl/nest@latest

생성
$> nx generate @nrwl/nest:app tube-api

 

다음으로 NX의 NextJS 플러그인을 설치한다. NextJS Application은 "tube-ssr" 이다.

설치
$> yarn add -D @nrwl/next@latest

생성
$> nx generate @nrwl/next:app tube-ssr
또는
$> nx g @nrwl/next:app tube-ssr

✔ Which stylesheet format would you like to use? · scss

 

NextJS 애플리케이션을 설치하면 sass-node버전을 v5.0.0이 설치된다. v4.14.1로 변경 사용한다. 5.0으로 사용시 컴파일 오류 발생하여 향후 패치되면 버전 업그레이드 함.

$> npm uninstall node-sass
$> npm install -D node-sass@4.14.1

tube-ssr 애플리케이션의 개발 서버 포트는 4300 으로 변경한다. workspace.json의 "tube-ssr"의 serve options설정에 포트 정보를 수정한다.

 

 

Nx 환경파일 재구성

애플리케이션과 라이브러리를 많이 만들다 보면 rnn-stack/workspace.json 파일안에 설정이 계속 추가된다. 가독성을 위하여 애플리케이션(라이브러리 포함) Nx의 환경설정을 프로젝트별 별도 파일로 분리한다. 분리후에는 애플리케이션이나 라이브러리 생성시 자동으로 "project.json" 파일로 분리가 된다.

 

각 애플리케이션 root 폴더에 "project.json" 파일을 생성하고 workspace.json의 프로젝트별 설정 정보를 옮긴다. workspace.json에는 애플리케이션의 위치정보로 수정하면 컨벤션에 의해 project.json을 인지한다.

workspace.json은 애플리케이션 위치를 표현한다. 

 

테스트하기

"tube"라는 React 애플리케이션을 Dev Server기반으로 실행하고, "realtime"이라는 NextJS 프렘워크기반 노드 서버를 실행한다. 

  • React Application: http://localhost:4200/ 
  • NextJS Application: http://localhost:4300/
React Single Page Application
$> nx serve tube-csr

NextJS Application with Server
$> nx serve tube-ssr

 

Prettier 코드 포멧터 설정하기

MS Code 편집기를 기준으로 prettier를 설정한다. 

  • rnn-stack 루트 폴더에 .prettierrc파일을 생성한다.
  • MS Code를 위한 Prettier Plugin을 설치한다. 
  • .vscode/settings.json 에 prettier 옵션을 설정한다. settings.json 파일이 존재하지 않다면 생성후 설정한다.
  • .vscode/extensions.json의 recommandation으로 prettier를 설정한다.

.prettierrc 내역

{
    "printWidth": 120,
    "singleQuote": true,
    "useTabs": false,
    "tabWidth": 2,
    "semi": true,
    "bracketSpacing": true
}

. vscode/settings.json 내역

{
    "editor.formatOnPaste": true,
    "editor.formatOnType": true,
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "[typescript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[scss]": {
        "editor.suggest.insertMode": "replace",
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[html]": {
        "editor.suggest.insertMode": "replace",
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    }
}

.vscode/extensions.json 내역

{
  "recommendations": [
    "esbenp.prettier-vscode",
    "nrwl.angular-console",
    "firsttris.vscode-jest-runner",
    "dbaeumer.vscode-eslint"
  ]
}

 

소스

https://github.com/ysyun/rnn-stack/releases/tag/hh-2

 

참조

posted by 윤영식
2021. 8. 25. 10:48 React/Start React

리액트 프로젝트를 위해 조사해봐야할 항목에 대해서 정리해 본다. 하기 구성에 대해 소스레벨의 준비과정을 작성해 본다.

 

개발환경

  • micro-frontend를 지원하는 환경
  • mono-repo기반 multi application을 개발할 수 있는 환경
  • Nx.dev for React
    • 멀티 애플리케이션 개발 및 애프리케이션별 번들링
    • 라이브러리의 위치 투명성 보장 및 배포

 

구조 설계

  • 1Layer: 애플리케이션
    • Layout: Feature 를 다루는 영역을 분할
    • Feature: UI + Data-Access 를 조합하여 업무 단위 화면을 구성
  • 2Layer: 업무 라이브러리
    • UI: Layout, Feature의 부분을 담당하는 업무 컴포넌트
    • Data-Access: API 호출 및 Data Caching
    • Data-Type: Frontend - Backend간 데이터 타입 및 vadliation
    • State: UI 상태 저장 및 전파
  • 3Layer: 공용 라이브러
    • Component: 사용자 정의 요소 컴포넌트 및 랩퍼
    • Chart: 차트 및 랩퍼
    • Utils: 유틸리티들
  • 4Layer: package.json 
    • AntD, PrimeReact, MaterialUI, Boostrap와 같은 CSS + Component Framework
    • Redux, Mobx, Recoil 과 같은 state
    • moment, date-fns 같은 date
    • immer 같은 immutable state 
    • react-use 같은 Hooks
    • react-query 같은 data api
    • rxjs 같은 reactiv library

 

고려 사항

  • Elagant Code Base 유지
    • Code Convention
    • TSLint
    • Typescript
  • Unit Test Code 
    • commit 전에 Unit Test 수행
  • Loose Coupling을 위한 UI Biz Feature, UI Componnet, Library 관계 정립

 

 

to be continue...

posted by 윤영식
2021. 8. 5. 09:29 React/Architecture

NX + Angular 기반 개발시 엔터프라이즈 애플리케이션 개발방법에 대한 글을 정리한다. Micro Frontend 개발방법이라기 보다는 애플리케이션을 개발하며 지속적으로 확장할 필요가 있을 때 관심사들을 어떻게 분리하여(Bounded Context) 개발할 수 있을지 보여준다.

 

 

https://indepth.dev/the-shell-library-patterns-with-nx-and-monorepo-architectures/

 

Variations of the Shell Library pattern with Nx & Angular Monorepos

Compare shell library patterns to pick the right one for your Angular monorepo. Nrwl feature shell, Manfred Steyer shell and Composite shell libraries.

indepth.dev

https://connect.nrwl.io/app/books/enterprise-angular-monorepo-patterns

 

Nrwl Connect

Directly leverage Nrwl's Angular expertise. Ship more features in less time.

connect.nrwl.io

 

 

nx.dev를 이용한 라이브러리 관리

Nx는 하나의 레파지토리에서 멀티 애플리케이션과 라이브러리를 개발할 수 있는 환경을 제공한다. @angular/cli를 확장한 형태로 Angular, React, NodeJS를 함께 개발하고 별개로 번들링 배포할 수 있다. Nx 설치는 이전 글을 참조한다.

 

workspace/libs 하위로 폴더를 구분하여 업무를 개발할 수 있고, workspace/apps 의 애플리케이션에서 libs의 내용을 통합하여 사용하는 방법을 제시한다. 

  • application
    • application 안에서 sub-domain을 import하거나, routing 한다.
    • sub-domain을 통합하는 방식이다. 
    • 애플리케이션, web, desktop(using Eletron), mobile(Ionic)을 이용하여 sub-domain을 사용한다.
  • sub-domain
    • 업무 도메인이다.
    • sub-domain은 Bounded Context 이다.
    • Bounded Context는 업무 논리적으로 분리될 수 있는 단위이다.
    •  feature-shell
      • 업무 도메인의 페이지를 통합하고 routing을 설정한다. 
      • sub-domain 안에 1개 존재한다. 
      • shell은 sub-domain을 접근토록 하는 entry point 모듈이다.
      • shell orachestrate the routes of "Bounded Context"
    • feature-<bizName>
      • 일반적으로 웹 화면(Page) 하나라고 정의하자.
      • sub-domain 안에는 feature-<bizName>가 여러개 존재할 수 있다.

 

Nx applications과 libraries 폴더 구조

NX Workspace 의 apps, libs 폴더 구성 예

 

Feature-shell에서 sub-doamin(Bounded Context) 라우팅하기

shell에서 Bounded Context 즉, sub-domain을 routing한다.

 

애플리케이션에서 Feature-shell 라우팅하기. 애플리케이션에서 feature-shell을 조합하여 사용한다.

애플리케이션에서 feature-shell  라우팅

 

 

NX에서 Library 분류 및 개발 방법

nx workspace의 libs/ 밑으로 라이브러리를 개발할 때 다음과 같이 분류하여 개발한다. 

  • Feature-Shell library
    • 업무 sub-domain을 통합하여 애플리케이션에서 사용할 수 있도록 한다.
  • UI library
    • presentation component로써 버튼, 모달같은 업무 로직이 없는 컴포넌트들
  • Shared library
    • Data Access library
      • REST API 서비스
      • WebSocket 통신
      • Ngrx/Store 기반 State 관리
    • Utils library
      • 유틸리티 서비스

 

NX에서 tag 구분 keywords

  • scope
    • sub-domain 
    • grouping folder 
  • type
    • feature-shell
    • ui
    • data-cess
  • platform
    • desktop
    • mobile

예) scope:shared, type:feature,platform:desktop

 

각 라이브러리의 특성에 맞게 libs/ 밑으로 폴더/폴더 형태의 grouping folder 구조로 개발한다. 

예로 booking 폴더 밑에 feature-shell libary가 존재

 

feature-shell의 모듈 명칭은 "feature-shell" 으로 한다.

nx를 이용하여 library 생성시 옵션들

  • --directory=<grouping folder name>
  • --routing: forChild routing 자동 설정
  • --lazy: application에 lazy routing 자동 설정, 반드시 parent-module을 지정함.
  • --parent-module=<application module path and file name>: lazy 옵션 설정시 lazy routing 설정할 module 위치를 지정함.
  • --tags=<tag>,<tag>: nx.json에 포함됨, nx.json에서 직접 수정 및 추가 가능 예) --tags=scope:shared,type:feature
  • --publishable: 특별히 npm registry에 publish 할 수 있는 library를 만들고 싶을 때 사용한다. ng-packagr 기반이다.

//Command
nx g lib feature-<sub-domain name> --routing --directory=<grouping folder name> --lazy --parent-module=apps/<application>/src/app/app.module.ts

//Sample
$ nx g lib feature-shell --routing --directory=admin --lazy --parent-module=apps/app-container/src/app/app.module.ts
? Which stylesheet format would you like to use? SASS(.scss)  [ http://sass-lang.com   ]
CREATE libs/admin/feature-shell/README.md (162 bytes)
CREATE libs/admin/feature-shell/tsconfig.lib.json (459 bytes)
CREATE libs/admin/feature-shell/tslint.json (276 bytes)
CREATE libs/admin/feature-shell/src/index.ts (50 bytes)
CREATE libs/admin/feature-shell/src/lib/admin-feature-shell.module.ts (367 bytes)
CREATE libs/admin/feature-shell/src/lib/admin-feature-shell.module.spec.ts (432 bytes)
CREATE libs/admin/feature-shell/tsconfig.json (138 bytes)
CREATE libs/admin/feature-shell/jest.config.js (405 bytes)
CREATE libs/admin/feature-shell/tsconfig.spec.json (258 bytes)
CREATE libs/admin/feature-shell/src/test-setup.ts (30 bytes)
UPDATE angular.json (15807 bytes)
UPDATE nx.json (1098 bytes)
UPDATE tsconfig.json (863 bytes)

 

 

품질 및 일관성 유지 방법

  • Single Version을 apps와 libs에 적용한다. 
    • package.json에서 관리한다. 
  • Code Formatting은 prettier를 사용한다.
  • Library Dependencies 관리
    • library 순환 참조는 안된다. 
    • library가 application을 import하거나 의존하지 않는다. 
    • npm run dep-graph 또는 npm run affected:dep-graph 수행하면 라이브러리 의존관계도가 자동생성되어 웹브라우져에서 확인할 수 있다.
    • MS Code Editor의 Plugin으로 "Nx Console"을 설치하면 Editor에서 바로 수행해 볼 수도 있다.

의존성 그래프를 웹에서 확인

 

Nx Console이 Left Menu 하단에 N> 아이콘으로 있고, 명령어 목록을 클릭한다.

 

 

Nx Schematics 관리

Nx에 Built-in 된 schematics

  • ngrx: Ngrx/store 기반으로 reducer, action, selector, facade를 자동 생성함
  • node: Express node application의 경우 
  • jest: unit test
  • cypress: E2E test
  • etc..

Custom schematic 만들기

  • 개발자들이 샘플코드를 copy & paste하지 않고 schematic을 통해 업무 개발을 위한 파일을 생성토록할 때 사용한다.
  • nx g workspace-schematic <custom-schematic-name> 명령을 수행하면 workspace/tools/ 폴더 밑에 schematic파일이 생성됨.
    • 예제 파일 생성: nx g workspace-schematic data-access-lib 수행
    • 예제 파일 수행: npm run workspace-schematic data-access-lib <name>

index.ts에서 개발함

//index.ts에 ngrx를 추가로 적용한 예제

import { chain, externalSchematic, Rule } from '@angular-devkit/schematics';
import * as path from 'path';

export default function (schema: any): Rule {
    if (!schema.name.startsWith('data-access-')) {
        throw new Error(`Data-access lib names should start with 'data-access-'`);
    }
    const stateName = schema.name.replace('data-access-', '');
    return chain([
        externalSchematic('@nrwl/workspace', 'lib', {
            name: schema.name,
            tags: 'type:data-access',
        }),
        externalSchematic('@nrwl/schematics', 'ngrx', {
            name: stateName,
            module: path.join('libs', schema.name, 'src', 'lib', `${schema.name}.module.ts`),
        }),
    ]);
}

 

관련소스: github.com/ysyun/blog-5730-micro-demo.git

 

 

참조

https://indepth.dev/the-shell-library-patterns-with-nx-and-monorepo-architectures/

 

Variations of the Shell Library pattern with Nx & Angular Monorepos

Compare shell library patterns to pick the right one for your Angular monorepo. Nrwl feature shell, Manfred Steyer shell and Composite shell libraries.

indepth.dev

NX에서 Eletron, Ionic, NativeScript 통합 개발하기

https://nstudio.io/xplat/fundamentals/architecture

 

nstudio | xplat xplat/fundamentals/architecture - cross platform tools for Nx workspaces

Passionate about implementing creative solutions for you. Technology, Consulting, Audio/Video Production. Web/Mobile apps, Angular/NativeScript plugins, Vue/NativeScript, product development, team training and help with project features/objectives.

nstudio.io

 

posted by 윤영식
prev 1 2 next