Bonjour, Habr!
Dans cet article, nous allons créer un modèle monolithique prêt à l'emploi qui peut être utilisé comme base pour une nouvelle application fullstack en tant que squelette pour la fonctionnalité de suspension.
Cet article vous sera utile si vous:
Pourquoi j'ai choisi une telle pile:
- Angular: J'ai beaucoup d'expérience avec ça, j'aime l'architecture stricte et Typescript prêt à l'emploi, vient de .NET
- NestJS: le même langage, la même architecture, l'API REST à écriture rapide, la possibilité de passer à Serverless dans le futur (moins cher qu'une machine virtuelle)
- PostgreSQL: je vais héberger dans Yandex.Cloud, au minimum c'est 30% moins cher que MongoDB
Avant d'écrire un article, j'ai cherché sur Habré des articles sur un cas similaire, et j'ai trouvé ce qui suit:
- Angular et SEO: comment se faire des amis?
- Firebase + Angular Universal = l'impossible est possible
- Se faire des amis angulaires avec Google (Angular Universal)
- Incroyable angulaire
À partir de là, il ne décrit pas «copié et collé» ou fournit des liens vers ce qui doit être finalisé.
Table des matières:
1. Créez une application Angular et ajoutez la bibliothèque de composants ng-zorro
2. Installez NestJS et résolvez les problèmes avec SSR
3. Créez une API dans NestJS et connectez-vous à l'avant
4. Connectez la base de données PostgreSQL
1. Angular
Angular-CLI SPA- :
npm install -g @angular/cli
Angular :
ng new angular-habr-nestjs
, :
cd angular-habr-nestjs
ng serve --open
. 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 , :
, 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;
}
:
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 , 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 { }
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:
, ! , 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, :
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, , .
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:
. , DBeaver:
! , :
! fullstack , .
P.S. :
- Ng-Zorro , Angular Material. - ;
- , , . , "" MVP , ;
- http://localhost:4200/api