Menulis monolit tumpukan penuh dengan Angular Universal + NestJS + PostgreSQL

Halo, Habr!


Pada artikel ini, kami akan membuat template monolit siap pakai yang dapat digunakan sebagai dasar untuk aplikasi fullstack baru sebagai kerangka untuk fungsionalitas gantung.



Artikel ini akan berguna jika Anda:



  • Pengembang fullstack pemula;
  • Sebuah startup yang menulis MVP untuk menguji hipotesis.


Mengapa saya memilih tumpukan seperti itu:



  • Angular: Saya memiliki banyak pengalaman dengan itu, saya suka arsitektur yang ketat dan Ketik di luar kotak, berasal dari .NET
  • NestJS: bahasa yang sama, arsitektur yang sama, REST API penulisan cepat, kemampuan untuk beralih ke Tanpa Server di masa mendatang (lebih murah daripada mesin virtual)
  • PostgreSQL: Saya akan menjadi tuan rumah di Yandex.Cloud, minimal 30% lebih murah dari MongoDB


Harga Yandex



Sebelum menulis artikel, saya mencari artikel tentang Habré tentang kasus serupa, dan menemukan yang berikut:





Dari sini tidak dijelaskan "disalin dan ditempel" atau menyediakan tautan ke apa lagi yang perlu diselesaikan.



Daftar Isi:



1. Buat aplikasi Angular dan tambahkan pustaka komponen ng-zorro

2. Instal NestJS dan selesaikan masalah dengan SSR

3. Buat API di NestJS dan sambungkan ke depan

4. Hubungkan database PostgreSQL





1. Angular



Angular-CLI SPA- :



npm install -g @angular/cli


Angular :



ng new angular-habr-nestjs


, :



cd angular-habr-nestjs
ng serve --open


Aplikasi SPA Statis Sudut



. 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 , :



Terhubung NG-Zorro



, 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;
}


:



Papan nama 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:



DAPATKAN permintaan untuk kera 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, :



Menyentak apiha di 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, , .



Tidak ada permintaan, data tersedia!





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 ke apiha dengan basis



. , DBeaver:



Rekaman dalam database



! , :



Bekerja aplikasi fullstack



! fullstack , .



P.S. :





:






All Articles