관리 메뉴

Mini

24. 10. 1. 개발일지 // 미들웨어, 프론트 컨트롤러, static serving, 동적렌더링 본문

JS/boostCamp

24. 10. 1. 개발일지 // 미들웨어, 프론트 컨트롤러, static serving, 동적렌더링

Mini_96 2024. 10. 1. 23:25

* 미들웨어 구현

  • 미들웨어 타입은 reqHandler 또는 ErrorHandler가 가능.
  • ## type, inteface 차이점 쉬운맛
    1) 원시성 데이터 사용
    type: 가능, interface: 불가능
    2) 튜플 사용
    type: 가능, interface: 불가능
    3) interface  취약점
    중복 선언하면 동일 실행 환경에서는
    모두 합쳐지는 특징이 있어서
    의도하지 않고 다른개발자가 같은 이름이 있는
    인퍼페이스를 선언하면 에러 메세지가 뜨지 않는다

    결론 가능하면 type을 더 많이 쓰는것이 좋다.
    버전이 올라가면서 interface가 성능이 조금더 좋다는
    이슈가 사라졌다. 고로 type을 조금더 선호하자
/**
 * Represents an HTTP request handler function.
 */
type RequestHandler = (req: Request, res: Response, next: (err?: Error) => void) => void;

/**
 * Represents an error handling middleware function.
 */
type ErrorHandler = (err: Error, req: Request, res: Response, next: (err?: Error) => void) => void;

/**
 * Represents a middleware function, which can be either a RequestHandler or an ErrorHandler.
 */
type Middleware = RequestHandler | ErrorHandler;
  • 서버에 middlewares 배열을두고
class Server {
    private server: net.Server;
    private router?: Router;
    private staticDirectory?: string;
    private middlewares: Middleware[] = [];
  • 길이만큼 돌려준다.
  • 에러가 있으면 에러도 같이 넘겨준다.
  • 에러가 없으면, 등록된 미들웨어 실행
private runMiddleware(
    middlewares: Middleware[],
    index: number,
    err: Error | null,
    req: Request,
    res: Response,
): void {
    if (index < 0 || index >= middlewares.length) return;

    const nextMiddleware = middlewares[index];
    const next = (e?: Error) => this.runMiddleware(middlewares, index + 1, e || null, req, res);

    if (err) {
        // 에러가 있고, 다음에 실행할 미들웨어가 에러 처리기인경우 에러처리 미들웨어 실행
        if (this.isErrorHandler(nextMiddleware)) {
            (nextMiddleware as ErrorHandler)(err, req, res, next);
        } else {
            // 에러가 있고, 다음에 실행할 미들웨어가 에러 처리기가 아니면 그 다음 미들웨어를 찾는다
            this.runMiddleware(middlewares, index + 1, err, req, res);
        }
    } else {
        /**
         * as로 타입강제지정, 등록된 함수 실행
         * 아래코드와 같은기능
         * if (this.isRequestHandler(nextMiddleware)) {
         *     nextMiddleware(req, res, next);
         */
        (nextMiddleware as RequestHandler)(req, res, next); 
        
    }
}
  • test용 로거 미들웨어
export const logger = (req : Request, res : Response, next) => {
    const uid = randomUUID();
    console.log(`[${uid}] [${req.method}] [${req.path}]`);
    next();
}

 

* logger color 구현

import {Request} from "../was/request";
import {Response} from "../was/response";
import {randomUUID} from "node:crypto";

const colors = {
    green: '\x1b[32m',
    cyan: '\x1b[36m',
    red: '\x1b[31m',
    yellow: '\x1b[33m',
    reset: '\x1b[0m',
}
const methodColorMap = {
    get: colors.green,
    post: colors.cyan,
    put: colors.yellow,
    delete: colors.red
}

export const logger = (req : Request, res : Response, next) => {
    const coloredMethod = method => {
        return `${methodColorMap[method.toLowerCase()]}${method}${colors.reset}`
    }

    const uid = randomUUID();
    console.log(`[${uid}] [${coloredMethod(req.method)}] [${req.path}]`);
    next();
}

 

* 미들웨어 개선 router -> skip

  • 정보가 많은 스프링쪽을 따라서 구현하고자함.
// 기본 미들웨어 타입 정의
type BaseMiddleware = RequestHandler | ErrorHandler;

// 확장된 미들웨어 인터페이스
interface MiddlewareExtension {
    __path?: string;
}

// 최종 미들웨어 타입 (교차 타입 사용)
type Middleware = BaseMiddleware & MiddlewareExtension;
/**
 * 인자1개 -> 첫번째인자를 Middleware 취급
 * 인자 2개 -> 첫번째인자는 path, 두번째인자는 middleware
 * @param path
 * @param fn
 */
public use (path: string | Middleware, middleware?: Middleware) {
    if (typeof(path) === "string" && middleware) {
        middleware.__path = path;
    } else if (typeof path === "function") {
        middleware = path;
    } else {
        throw new Error("Usage: use(path, fn) or use(fn)");
    }

    this.middlewares.push(middleware);
};

 

* 프론트 컨트롤러 도입 v1

  • 설계

앞단에서 요청을 받아서 맞는 컨트롤러를 호출해줌. 유사 router?

  • 인터페이스 설계
import {Request} from "../../was/request";
import {Response} from "../../was/response";

export interface ControllerV1 {

    process(req : Request , res : Response) : void
}
  • 회원가입 컨트롤러
    • viewPath를 받아서, 해당하는 html파일을 render 해준다.
import {ControllerV1} from "../ControllerV1";
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import * as path from "path";

export class MemberFormControllerV1 implements ControllerV1{

    public async process(req : Request , res : Response) : Promise<void>{
        const viewPath : string = path.join(process.cwd(), 'dist','views','new-form.html');
        await res.forward(req,res,viewPath);
    }

}
  • forward 함수 (기존의 sendfile과 유사)
public async forward(req: Request, res: Response, viewPath : string): Promise<void> {
    try {
        const stats = await stat(viewPath);
        const file = await fs.promises.readFile(viewPath);
        const ext = path.extname(viewPath).slice(1);
        const mimeType = COMMON_MIME_TYPES[ext] || 'application/octet-stream';
        const maxAge = cachePolicy[ext] || 'public, max-age=3600';
        res.setCacheControl(maxAge);

        // ETag 생성
        const etag : string = createHash('md5').update(file).digest('hex');

        // Cache-Control, ETag, Last-Modified 설정
        res.header('ETag', `${etag}`)
            .header('Last-Modified', stats.mtime.toUTCString());

        // 조건부 요청 처리
        if (req.headers.get('if-none-match') === etag) {
            res.header('Content-Type', mimeType).status(304).send();
            return;
        }

        // 내용이 바뀐경우에만, 새로운 body를 보내준다.
        res.render(file,mimeType);


    } catch (error) {
        console.error('File read error:', error);
        res.status(404).send('File Not Found');
    }

}
  • 프론트 컨트롤러 (핵심)
    • 맵에 < url , 컨트롤러>를 담는다.
    • reqURI에 맞춰서 해당하는 컨트롤러의 process를 실행한다.
import {ControllerV1} from "./ControllerV1";
import {MemberFormControllerV1} from "./controller/MemberFormControllerV1";
import {Response} from "../../was/response";
import {Request} from "../../was/request";

export class FrontControllerServletV1 {

    private readonly urlPatterns:string;
    private controllerMap : Map<String,ControllerV1> = new Map<String, ControllerV1>

    constructor() {
        this.urlPatterns = "/front-controller/v1/";
        this.controllerMap.set(this.urlPatterns+"members/new-form", new MemberFormControllerV1());
    }

    public service(req : Request, res : Response){
        console.log('FrontControllerV1.service');

        const reqURI : string = req.path;
        const controller = this.controllerMap.get(reqURI);
        if(!controller){
            res.status(404).send();
            return;
        }

        controller.process(req,res);
    }

}

결과

 

* static serving fix

  • 참고 : favicon은 req.url = "/favicon"으로 요청한다.
  • 문제 : 프론트 컨트롤러 도입후 favicon, html 내부의 js에 대한 응답이 없음을 발견
  • 해결 : 미들웨어 도입
import {Request} from "../was/request";
import {Response} from "../was/response";
import * as path from "node:path";
import * as fs from "node:fs";


export const staticServe = (req : Request, res : Response) => {
    const mimeType = {
        ".ico": "image/x-icon",
        ".html": "text/html",
        ".js": "text/javascript",
        ".css": "text/css",
        ".png": "image/png",
        ".jpg": "image/jpeg",
        ".eot": "appliaction/vnd.ms-fontobject",
        ".ttf": "aplication/font-sfnt",
    }
    const ext = path.parse(req.path).ext
    const publicPath = path.join(process.cwd(), 'dist','views');

    if (Object.keys(mimeType).includes(ext)) {
        fs.readFile(`${publicPath}${req.path}`, (err, data) => {
            if (err) {
                res.status(404).send();
                return;
            }

            res.status(200).render(data);
        })
        return;
    }
}
async function main() {
    const app = new Server();

    app.use(logger);
    app.use(staticServe);
  • server.ts 수정
    • 미들웨어 돌린후 프론트 컨트롤러 실행 (추후 개선 필요)
private async handleRequest(headers: string, body: Buffer, socket: net.Socket): Promise<void> {
    const req = new Request(headers, body);
    const res = new Response(socket);

    this.runMiddleware(this.middlewares, 0, null, req, res);

    const frontControllerServletV1 = new FrontControllerServletV1();
    if(req.path.startsWith("/front-controller/v1"))
        frontControllerServletV1.service(req,res);
}

 

* memberListController 추가

  • 하려고 봤더니
  • 동적 렌더링 구현이 먼저다.
  • 일단 정적 html으로 test 한다.

 

  • 프론트 컨트롤러 맵에 < url, controller >추가
export class FrontControllerServletV1 {

    private readonly urlPatterns:string;
    private controllerMap : Map<String,ControllerV1> = new Map<String, ControllerV1>

    constructor() {
        this.urlPatterns = "/front-controller/v1/";
        this.controllerMap.set(this.urlPatterns+"members/new-form", new MemberFormControllerV1());
        this.controllerMap.set(this.urlPatterns+"members", new MemberListControllerV1());
    }
  • member리스트 컨트롤러 구현
import {ControllerV1} from "../ControllerV1";
import {Request} from "../../../was/request";
import {Response} from "../../../was/response";
import * as path from "path";

export class MemberListControllerV1 implements ControllerV1{

    public async process(req : Request , res : Response) : Promise<void>{
        const viewPath : string = path.join(process.cwd(), 'dist','views','members.html');
        await res.forward(req,res,viewPath);
    }
}

결과

 

 

* 동적렌더링 구현

  • gpt 답변
  • 추정 : 용빼는 재주가 있는것은 아니고, ejs 내의 <% %> 등을 조작해서, 안에 data를 직접 집어넣는 방식으로 추정.
/**
 * 간소화된 렌더 함수
 * @param template 템플릿 파일 이름 (확장자 제외)
 * @param data 템플릿에 전달할 데이터 객체
 * @returns 렌더링된 HTML 문자열
 */
function render(template: string, data: Record<string, any>): string {
    const viewsFolder = path.join(process.cwd(), 'views');
    const filePath = path.join(viewsFolder, `${template}.ejs`);
    let content = fs.readFileSync(filePath, 'utf8');

    // <%= ... %> 형식의 표현식 처리
    content = content.replace(/<%=\s*(.+?)\s*%>/g, (match, p1) => {
        const value = evaluateExpression(p1, data);
        return escapeHtml(String(value));
    });

    // <% ... %> 형식의 JavaScript 코드 처리
    content = content.replace(/<%([\s\S]+?)%>/g, (match, p1) => {
        return evaluateCode(p1, data);
    });

    return content;
}

function evaluateExpression(expression: string, data: Record<string, any>): any {
    const keys = Object.keys(data);
    const values = Object.values(data);
    const func = new Function(...keys, `return ${expression};`);
    return func(...values);
}

function evaluateCode(code: string, data: Record<string, any>): string {
    const keys = Object.keys(data);
    const values = Object.values(data);
    const func = new Function(...keys, `
    let __output = '';
    const __append = (s) => __output += s;
    ${code}
    return __output;
  `);
    return func(...values);
}

function escapeHtml(str: string): string {
    const escapeChars: Record<string, string> = {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#39;'
    };
    return str.replace(/[&<>"']/g, match => escapeChars[match]);
}
  • 찾아보니 ejs 모듈을 사용해서 render하면 된다.
import * as ejs from 'ejs';
import * as fs from 'fs';
import * as path from 'path';

interface Member {
    id: number;
    loginId: string;
    name: string;
}

interface PageData {
    title: string;
    members: Member[];
}

function renderEjsTemplate(templatePath: string, data: PageData): string {
    // 템플릿 파일 읽기
    const template = fs.readFileSync(templatePath, 'utf-8');

    // EJS를 사용하여 템플릿 렌더링
    const html = ejs.render(template, data, {
        filename: templatePath // 이를 통해 include 기능 등을 사용할 수 있습니다.
    });

    return html;
}

// 사용 예시
const pageData: PageData = {
    title: '회원 목록',
    members: [
        { id: 1, loginId: 'user1', name: '홍길동' },
        { id: 2, loginId: 'user2', name: '김철수' },
        { id: 3, loginId: 'user3', name: '이영희' }
    ]
};

const templatePath = path.join(__dirname, 'views', 'members.ejs');
const renderedHtml = renderEjsTemplate(templatePath, pageData);
console.log(renderedHtml);

export { renderEjsTemplate };

결과

 

* 프론트 컨트롤러 V2 구현

  • 문제 : 모든 컨트롤러에 뷰로 이동하는 부분의 코드가 중복

 

  • 설계

  • 인터페이스 설계
    • 리턴타입만 MyView로 변경
import {Request} from "../../was/request";
import {Response} from "../../was/response";
import {MyView} from "../MyView";

export interface ControllerV2 {

    process(req : Request , res : Response) : MyView
}
  • MyView는 render를 담당한다.
    • todo : 동적렌더링
      • html 인경우 -> 그냥반환
      • ejs인경우 -> 동적렌더링후 html 반환
import {Response} from "../was/response";
import {Request} from "../was/request";

export class MyView {
    private readonly viewPath : string;

    constructor(viewPath : string) {
        this.viewPath = viewPath;
    }

    public async render(req : Request, res : Response) : Promise<void> {
        await res.forward(req,res,this.viewPath);
    }
}
public process(req : Request , res : Response) : MyView{
    const viewPath : string = path.join(process.cwd(), 'dist','views','new-form.html');
    return new MyView(viewPath);
}
  • 프론트 컨트롤러 v2
    • view를 return 하도록 수정
import {Response} from "../../was/response";
import {Request} from "../../was/request";
import {ControllerV2} from "./ControllerV2";
import {MemberFormControllerV2} from "./controller/MemberFormControllerV2";
import {MemberListControllerV2} from "./controller/MemberListControllerV2";


export class FrontControllerServletV2 {

    private readonly urlPatterns:string;
    private controllerMap : Map<String,ControllerV2> = new Map<String, ControllerV2>

    constructor() {
        this.urlPatterns = "/front-controller/v2/";
        this.controllerMap.set(this.urlPatterns+"members/new-form", new MemberFormControllerV2());
        this.controllerMap.set(this.urlPatterns+"members", new MemberListControllerV2());
    }

    public service(req : Request, res : Response){
        const reqURI : string = req.path;
        if(!this.controllerMap.has(reqURI)){
            res.status(404).send();
            return;
        }

        const controller : ControllerV2= this.controllerMap.get(reqURI);
        const view = controller.process(req,res);
        view.render(req,res);
    }
}

server도 수정
v2도 잘됨. 구조만 바꾼거임.