๐Ÿ’ช ๊ฐ•๋ ฅํ•œ Express.js: Node.js ํ”„๋กœ์ ํŠธ ๊ตฌ์„ฑ์„ ์œ„ํ•œ OOP ๋ฐฉ์‹[TypeScript ์ฃผ์—ฐ]

23455 ๋‹จ์–ด typescriptnodeexpressjsjavascript
๊ฒฝ๊ณ ! ์ด ๊ธฐ์‚ฌ๋Š” ๊ฒฝํ—˜์ด ์—†๋Š” Node.js ๊ฐœ๋ฐœ์ž๊ฐ€ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. ์ฝ”๋“œ๋Š” ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ ๊ฒƒ์ด ์•„๋‹ˆ๋ฏ€๋กœ ๋‹ค์Œ ๊ธฐ์ˆ ์„ ์‚ฌ์šฉํ•˜๋Š” ๋ฐ ๋”ฐ๋ฅด๋Š” ์œ„ํ—˜์€ ์‚ฌ์šฉ์ž์˜ ์ฑ…์ž„์ž…๋‹ˆ๋‹ค.

๋ชฉ์ฐจ


  • Intro
  • Divide it into layers
  • Add some OOP
  • Under the hood
  • Example

  • ์†Œ๊ฐœ

    Well, I like Express.js for its minimalism and beginner-friendliness - this framework is really easy to use. But when code grows, you need a way to organize it somehow. Unfortunately, Express.js doesn't provide any convenient way to do it, so we developers must organize it by ourselves.

    ๋ ˆ์ด์–ด๋กœ ๋‚˜๋ˆ„์–ด

    For convenience, let's divide our server application into separate layers.


  • ์ปจํŠธ๋กค๋Ÿฌ - ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ ํŠน์ • ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์‹ ํ•˜์—ฌ ์„œ๋น„์Šค ๊ณ„์ธต์œผ๋กœ ์ „๋‹ฌํ•˜๋Š” ์„œ๋ฒ„ ์žฅ์น˜
  • ์„œ๋น„์Šค - ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง, ์ฆ‰ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ๋ฐ ์กฐ์ž‘์„ ๋‹ด๋‹นํ•˜๋Š” ์ฝ”๋“œ ์กฐ๊ฐ
  • ๋ชจ๋ธ - ORM
  • ์— ์˜ํ•ด ์ž˜ ๊ตฌ์„ฑ๋œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ๋ฐ์ดํ„ฐ

    ์ผ๋ถ€ OOP ์ถ”๊ฐ€

    Imagine there's a controller that is responsible for authenticating a user. It has to provide login logic and some other.

    class AuthController extends Controller {
        path = '/auth'; // The path on which this.routes will be mapped
        routes = [
            {
                path: '/login', // Will become /auth/login
                method: Methods.POST,
                handler: this.handleLogin,
                localMiddleware: []
            },
            // Other routes...
        ];
    
        constructor() {
            super();
        };
    
        async handleLogin(req: Request, res: Response, next: NextFunction): Promise<void> {
            try {
                const { username, password } = req.body;    // Get credentials from client
                const userService = new UserService(username, password);
                const result = await userService.login();   // Use login service
                if (result.success) {
                    // Send success response
                } else {
                    // Send error response
                }
            } catch(e) {
                // Handle error
            }
        };
        // Other handlers...
    }
    

    As you can see, routes now look like an array of objects with the following properties:

    • path
    • method : HTTP method
    • handler : particular handler for the path
    • localMiddleware : an array of middleware that is mapped to path of each route

    Also, login logic is encapsulated into the service layer, so in the handler, we just pass the data to the UserService instance, receive the result, and send it back to the client.

    ํ›„๋“œ

    import { Response, Request, NextFunction, Router, RequestHandler } from 'express';
    
    // HTTP methods
    export enum Methods {
        GET = 'GET',
        POST = 'POST',
        PUT = 'PUT',
        DELETE = 'DELETE'
    };
    
    // Route interface for each route in `routes` field of `Controller` class.
    interface IRoute {
        path: string;
        method: Methods;
        handler: (req: Request, res: Response, next: NextFunction) => void | Promise<void>;
        localMiddleware: ((req: Request, res: Response, next: NextFunction) => void)[]
    };
    
    export default abstract class Controller {
        // Router instance for mapping routes
        public router: Router = Router();
        // The path on which this.routes will be mapped
        public abstract path: string;
        // Array of objects which implement IRoutes interface
        protected abstract readonly routes: Array<IRoute> = [];
    
        public setRoutes = (): Router => {
        // Set HTTP method, middleware, and handler for each route
        // Returns Router object, which we will use in Server class
            for (const route of this.routes) {
                for (const mw of route.localMiddleware) {
                    this.router.use(route.path, mw)
                };
                switch (route.method) {
                    case 'GET':
                        this.router.get(route.path, route.handler);
                        break;
                    case 'POST':
                        this.router.post(route.path, route.handler);
                        break;
                    case 'PUT':
                        this.router.put(route.path, route.handler);
                        break;
                    case 'DELETE':
                        this.router.delete(route.path, route.handler);
                        break;
                    default:
                        // Throw exception
                };
            };
            // Return router instance (will be usable in Server class)
            return this.router;
        };
    };
    

    Well, everything seems pretty trivial. We have a Router instance which we use as an "engine" for every instance of a class that will be inherited from the abstract Controller class.

    Another good idea is to look at how the Server class is implemented.

    class Server {
        private app: Application;
        private readonly port: number;
    
        constructor(app: Application, database: Sequelize, port: number) {
            this.app = app;
            this.port = port;
        };
    
        public run(): http.Server {
            return this.app.listen(this.port, () => {
                console.log(`Up and running on port ${this.port}`)
            });
        };
    
        public loadGlobalMiddleware(middleware: Array<RequestHandler>): void {
            // global stuff like cors, body-parser, etc
            middleware.forEach(mw => {
                this.app.use(mw);
            });
        };
    
        public loadControllers(controllers: Array<Controller>): void {
            controllers.forEach(controller => {
                // use setRoutes method that maps routes and returns Router object
                this.app.use(controller.path, controller.setRoutes());
            });
        };
    
        public async initDatabase(): Promise<void> {
            // ...
        }
    }
    

    And in index.js :

    const app = express();
    const server = new Server(app, db, PORT);
    
    const controllers: Array<Controller> = [
        new AuthController(),
        new TokenController(),
        new MatchmakingController(),
        new RoomController()
    ];
    
    const globalMiddleware: Array<RequestHandler> = [
        urlencoded({ extended: false }),
        json(),
        cors({ credentials: true, origin: true }),
        // ...
    ];
    
    Promise.resolve()
        .then(() => server.initDatabase())
        .then(() => {
            server.loadMiddleware(globalMiddleware);
            server.loadControllers(controllers);
            server.run();
        });
    

    ์˜ˆ์‹œ

    I used this organizing practice in my recent project, source code of which you can find here: https://github.com/thedenisnikulin/chattitude-app-backend

    ์ด ๊ธฐ์‚ฌ๋ฅผ ์ฝ์–ด ์ฃผ์…”์„œ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค :).

    ์ข‹์€ ์›นํŽ˜์ด์ง€ ์ฆ๊ฒจ์ฐพ๊ธฐ