Escribir un monolito de pila completa con Angular Universal + NestJS + PostgreSQL

¡Hola, Habr!


En este artículo, crearemos una plantilla de monolito lista para usar que se puede utilizar como base para una nueva aplicación fullstack como esqueleto para la funcionalidad de suspensión.



Este artículo será útil si:



  • Desarrollador de pila completa para principiantes;
  • Una startup que escribe un MVP para probar una hipótesis.


Por qué elegí una pila así:



  • Angular: tengo mucha experiencia con él, me encanta la arquitectura estricta y Typecript listo para usar, proviene de .NET
  • NestJS: el mismo idioma, la misma arquitectura, API REST de escritura rápida, la capacidad de cambiar a Serverless en el futuro (más barato que una máquina virtual)
  • PostgreSQL: voy a alojar en Yandex.Cloud, como mínimo es un 30% más barato que MongoDB


Precio de Yandex



Antes de escribir un artículo, busqué en Habré artículos sobre un caso similar y encontré lo siguiente:





A partir de aquí, no describe "copiado y pegado" ni proporciona enlaces a qué más necesita ser finalizado.



Tabla de contenido:



1. Cree una aplicación Angular y agregue la biblioteca de componentes ng-zorro

2. Instale NestJS y resuelva problemas con SSR

3. Cree una API en NestJS y conéctese al frente

4. Conecte la base de datos PostgreSQL





1. Angular



Angular-CLI SPA- :



npm install -g @angular/cli


Angular :



ng new angular-habr-nestjs


, :



cd angular-habr-nestjs
ng serve --open


Aplicación SPA Angular Static



. NG-Zorro:



ng add ng-zorro-antd


:



? Enable icon dynamic loading [ Detail: https://ng.ant.design/components/icon/en ] Yes
? Set up custom theme file [ Detail: https://ng.ant.design/docs/customize-theme/en ] No
? Choose your locale code: ru_RU
? Choose template to create project: sidemenu


app.component , :



NG-Zorro conectado



, src/app/pages/welcome, NG-Zorro:





// welcome.component.html
<nz-table #basicTable [nzData]="items$ | async">
  <thead>
  <tr>
    <th>Name</th>
    <th>Age</th>
    <th>Address</th>
  </tr>
  </thead>
  <tbody>
  <tr *ngFor="let data of basicTable.data">
    <td>{{ data.name }}</td>
    <td>{{ data.age }}</td>
    <td>{{ data.address }}</td>
  </tr>
  </tbody>
</nz-table>


// welcome.module.ts
import { NgModule } from '@angular/core';

import { WelcomeRoutingModule } from './welcome-routing.module';

import { WelcomeComponent } from './welcome.component';
import { NzTableModule } from 'ng-zorro-antd';
import { CommonModule } from '@angular/common';

@NgModule({
  imports: [
    WelcomeRoutingModule,
    NzTableModule, //   
    CommonModule //    async
  ],
  declarations: [WelcomeComponent],
  exports: [WelcomeComponent]
})
export class WelcomeModule {
}


// welcome.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { share } from 'rxjs/operators';

@Component({
  selector: 'app-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss']
})
export class WelcomeComponent implements OnInit {
  items$: Observable<Item[]> = of([
    {name: '', age: 24, address: ''},
    {name: '', age: 23, address: ''},
    {name: '', age: 21, address: ''},
    {name: '', age: 23, address: ''}
  ]);

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
  }

  //     ,  
  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('/api/items').pipe(share());
  }
}

interface Item {
  name: string;
  age: number;
  address: string;
}


:



Placa de identificación NG-Zorro





2. NestJS



NestJS , Angular Universal (Server Side Rendering) .



ng add @nestjs/ng-universal


, SSR :



npm run serve


:) :



TypeError: Cannot read property 'indexOf' of undefined
    at D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\utils\setup-universal.utils.js:35:43
    at D:\Projects\angular-habr-nestjs\dist\server\main.js:107572:13
    at View.engine (D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\utils\setup-universal.utils.js:30:11)
    at View.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\view.js:135:8)
    at tryRender (D:\Projects\angular-habr-nestjs\node_modules\express\lib\application.js:640:10)
    at Function.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\application.js:592:3)
    at ServerResponse.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\response.js:1012:7)
    at D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\angular-universal.module.js:60:66
    at Layer.handle [as handle_request] (D:\Projects\angular-habr-nestjs\node_modules\express\lib\router\layer.js:95:5)
    at next (D:\Projects\angular-habr-nestjs\node_modules\express\lib\router\route.js:137:13)


, server/app.module.ts liveReload false:



import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false
    })
  ]
})
export class ApplicationModule {}


, - Ivy :



// tsconfig.server.json
{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "./out-tsc/server",
    "target": "es2016",
    "types": [
      "node"
    ]
  },
  "files": [
    "src/main.server.ts"
  ],
  "angularCompilerOptions": {
    "enableIvy": false, //  
    "entryModule": "./src/app/app.server.module#AppServerModule"
  }
}


ng run serve SSR .



SSR angular + NestJS



! SSR , devtools .



extractCss: true, styles.js, styles.css:



// angular.json
...
"architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/browser",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "aot": true,
            "assets": [
              "src/favicon.ico",
              "src/assets",
              {
                "glob": "**/*",
                "input": "./node_modules/@ant-design/icons-angular/src/inline-svg/",
                "output": "/assets/"
              }
            ],
            "extractCss": true, //  
            "styles": [
              "./node_modules/ng-zorro-antd/ng-zorro-antd.min.css",
              "src/styles.scss"
            ],
            "scripts": []
          },
...


app.component.scss:



// app.component.scss
@import "~ng-zorro-antd/ng-zorro-antd.min.css"; //  

:host {
  display: flex;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.app-layout {
  height: 100vh;
}
...


, SSR , SSR, CSR (Client Side Rendering). :



import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: '/welcome' },
  { path: 'welcome', loadChildren: () => import('./pages/welcome/welcome.module').then(m => m.WelcomeModule) }
];

@NgModule({
  imports: [RouterModule.forRoot(routes, {initialNavigation: 'enabled', scrollPositionRestoration: 'enabled'})], //  initialNavigation, scrollPositionRestoration
  exports: [RouterModule]
})
export class AppRoutingModule { }


  • initialNavigation: 'enabled' , SSR
  • scrollPositionRestoration: 'enabled' .



    3. NestJS



server items:



cd server
nest g module items
nest g controller items --no-spec


// items.module.ts
import { Module } from '@nestjs/common';
import { ItemsController } from './items.controller';

@Module({
  controllers: [ItemsController]
})
export class ItemsModule {
}


// items.controller.ts
import { Controller } from '@nestjs/common';

@Controller('items')
export class ItemsController {}


. items :



// server/src/items/items.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';

class Item {
  name: string;
  age: number;
  address: string;
}

@Controller('items')
export class ItemsController {

  //      Angular 
  private items: Item[] = [
    {name: '', age: 24, address: ''},
    {name: '', age: 23, address: ''},
    {name: '', age: 21, address: ''},
    {name: '', age: 23, address: ''}
  ];

  @Get()
  getAll(): Item[] {
    return this.items;
  }

  @Post()
  create(@Body() newItem: Item): void {
    this.items.push(newItem);
  }
}


GET Postman:



OBTENER solicitudes para el apish de NestJS



, ! , GET items api, server/main.ts NestJS:



// server/main.ts
import { NestFactory } from '@nestjs/core';
import { ApplicationModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.setGlobalPrefix('api'); //  
  await app.listen(4200);
}
bootstrap();


. welcome.component.ts :



// welcome.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { share } from 'rxjs/operators';

@Component({
  selector: 'app-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss']
})
export class WelcomeComponent implements OnInit {
  items$: Observable<Item[]> = this.getItems(); //   

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
  }

  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('/api/items').pipe(share());
  }
}

interface Item {
  name: string;
  age: number;
  address: string;
}


, SSR, :



Jerking apiha en ssr



SSR :



// welcome.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { share } from 'rxjs/operators';

@Component({
  selector: 'app-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss']
})
export class WelcomeComponent implements OnInit {
  items$: Observable<Item[]> = this.getItems(); //   

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
  }

  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('http://localhost:4200/api/items').pipe(share()); //       SSR  
  }
}

interface Item {
  name: string;
  age: number;
  address: string;
}


( SSR, ), :



  • @nguniversal/common:


npm i @nguniversal/common


  • app/app.module.ts SSR:


// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { IconsProviderModule } from './icons-provider.module';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzMenuModule } from 'ng-zorro-antd/menu';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NZ_I18N } from 'ng-zorro-antd/i18n';
import { ru_RU } from 'ng-zorro-antd/i18n';
import { registerLocaleData } from '@angular/common';
import ru from '@angular/common/locales/ru';
import {TransferHttpCacheModule} from '@nguniversal/common';

registerLocaleData(ru);

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    TransferHttpCacheModule, // 
    AppRoutingModule,
    IconsProviderModule,
    NzLayoutModule,
    NzMenuModule,
    FormsModule,
    HttpClientModule,
    BrowserAnimationsModule
  ],
  providers: [{ provide: NZ_I18N, useValue: ru_RU }],
  bootstrap: [AppComponent]
})
export class AppModule { }


app.server.module.ts:



// app.server.module.ts
import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';

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

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    ServerTransferStateModule, // 
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}


. SSR, , .



¡Sin solicitud, datos disponibles!





4. PostgreSQL



PostgreSQL, TypeORM :



npm i pg typeorm @nestjs/typeorm


: PostgreSQL .



server/app.module.ts:



// server/app.module.ts
import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';
import { ItemsController } from './src/items/items.controller';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false
    }),
    TypeOrmModule.forRoot({ //    
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'admin',
      database: 'postgres',
      entities: ['dist/**/*.entity{.ts,.js}'],
      synchronize: true
    })
  ],
  controllers: [ItemsController]
})
export class ApplicationModule {}


:



  • type: ,
  • host port:
  • username password:
  • database:
  • entities: ,


, Item :



// server/src/items/item.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm/index';

@Entity()
export class ItemEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @CreateDateColumn()
  createDate: string;

  @Column()
  name: string;

  @Column()
  age: number;

  @Column()
  address: string;
}




// items.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ItemEntity } from './item.entity';
import { ItemsController } from './items.controller';

@Module({
  imports: [
    TypeOrmModule.forFeature([ItemEntity]) //  -    
  ],
  controllers: [ItemsController]
})
export class ItemsModule {
}


, , :



// items.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ItemEntity } from './item.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm/index';

interface Item {
  name: string;
  age: number;
  address: string;
}

@Controller('items')
export class ItemsController {

  constructor(@InjectRepository(ItemEntity)
              private readonly itemsRepository: Repository<ItemEntity>) { //  
  }

  @Get()
  getAll(): Promise<Item[]> {
    return this.itemsRepository.find();
  }

  @Post()
  create(@Body() newItem: Item): Promise<Item> {
    const item = this.itemsRepository.create(newItem);
    return this.itemsRepository.save(item);
  }
}


Postman:



POST a apiha con base



. , DBeaver:



Registros en la base de datos



! , :



Aplicación fullstack de trabajo



! fullstack , .



P.S. :





:






All Articles