typescript - opprett DTOer, BOer og DAOer for NestJs REST API
Jeg vil gjerne begynne å lage REST API-er med NestJs, og jeg er ikke sikker på hvordan jeg skal sette opp skalerbare lagkommuikasjonsobjekter.
Så fra dokumentasjonen om hvordan man kommer i gang, kommer jeg opp med en UsersController som håndterer HTTP-forespørsler og -svar, en UsersService som håndterer logikken mellom controlleren og databaseadgang, og en UsersRepository som er ansvarlig for databasehåndteringen.
Jeg bruker TypeORM-pakken som tilbys av NestJs, så databasemodellen min ville være
@Entity('User')
export class UserEntity extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
username: string;
@Column()
passwordHash: string;
@Column()
passwordSalt: string;
}
men som du kanskje vet må denne modellen kobles til andre modeller og omvendt fordi du ikke vil sende passordinformasjonen tilbake til klienten. Jeg vil prøve å beskrive API-strømmen min med et enkelt eksempel:
Kontrollere
Først har jeg et kontrollendepunkt for GET /users/:id og POST /users.
@Get(':id')
findById(@Param() findByIdParamsDTO: FindByIdParamsDTO): Promise<UserDTO> {
// find user by id and return it
}
@Post()
create(@Body() createUserBodyDTO: CreateUserBodyDTO): Promise<UserDTO> {
// create a new user and return it
}
Jeg satte opp DTOene og vil validere forespørselen først. Jeg bruker class-validator-pakken som tilbys av NestJs og opprettet en mappe kalt RequestDTOs. Å finne noe etter id eller slette noe etter id via URL-parametere er gjenbrukbart, så jeg kan legge dette i en delt mappe for andre ressurser som grupper, dokumenter osv.
export class IdParamsDTO {
@IsUUID()
id: string;
}
POST-forespørselen er bruker spesifikk
export class CreateUserBodyDTO {
@IsString()
@IsNotEmpty()
username: string;
@IsString()
@IsNotEmpty()
password: string;
}
Nå blir kontrollerens inndata validert før forretningslogikken kjøres. For responsene har jeg laget en mappe kalt ResponseDTOs, men for øyeblikket inneholder den bare databasebrukeren uten passordinformasjonen.
export interface UserDTO {
id: string;
username: string;
}
Tjenester
Tjenesten trenger den samlede informasjonen fra parametrene og innholdet.
public async findById(findByIdBO: FindByIdBO): Promise<UserBO> {
// ...
}
public async create(createBO: CreateBO): Promise<UserBO> {
// ...
}
GET-forespørselen trenger bare ID-en, men det kan fortsatt være bedre å lage en BO fordi du kanskje vil bytte fra streng-ID-er til heltall senere. "Finn etter id" BO-en er gjenbrukbar, jeg flyttet den til den delte katalogen
export interface IdBO {
id: string;
}
For opprettelsen av brukeren opprettet jeg mappen RequestBOs
export interface CreateBO {
username: string;
password: string;
}
Nå for ResponseBOs vil resultatet være
export interface UserBO {
id: string;
username: string;
}
og som du vil merke er dette det samme som UserDTO. Så en av dem ser ut til å være overflødig?
Arkiver
Til slutt satte jeg opp DAO-ene for repositoryene. Jeg kunne bruke det auto-genererte bruker-repositoryet og ville håndtere databasemodellen min som jeg nevnte ovenfor. Men da måtte jeg håndtere det innenfor service-forretningslogikken min. Når jeg oppretter en bruker, måtte jeg gjøre det innenfor servicen og kun kalle usermodel.save-funksjonen fra repositoryet.
Ellers kunne jeg opprette RequestDAOs
Den delte..
export interface IdDAO {
id: string;
}
Og POST DAO
export interface CreateDAO {
username: string;
password: string;
}
Med det kunne jeg opprette en databasebruker innenfor mitt repository og kartlegge databaseresponser med ResponseDAOs, men dette ville alltid være hele databasebrukeren uten passordinformasjonen. Ser ut til å skape en stor overhead igjen.
Jeg vil gjerne vite om min tilnærming med å bruke 3 forespørsels- og 3 responsgrensesnitt er altfor mye og kan forenkles. Men jeg vil gjerne beholde et fleksibelt lag fordi jeg mener at disse lagene bør være høyt uavhengige... På den annen side ville det være en enorm mengde modeller der ute.
Takk på forhånd!
Svar #1
Jeg håndterer dette ved å ha en enkelt klasse for å representere en bruker (internt og eksternt) med class-transformer-biblioteket (anbefalt av NestJs) for å håndtere forskjellene mellom den eksponerte brukeren og den interne brukeren uten å definere to klasser.
Her er et eksempel som bruker brukermodellen din:
Definere Brukerklassen
Siden denne brukerklassen lagres i databasen, lager jeg vanligvis en baseklasse for alle feltene som hvert databaseobjekt forventes å ha. La oss si:
export class BaseDBObject {
// this will expose the _id field as a string
// and will change the attribute name to `id`
@Expose({ name: 'id' })
@Transform(value => value && value.toString())
@IsOptional()
// tslint:disable-next-line: variable-name
_id: any;
@Exclude()
@IsOptional()
// tslint:disable-next-line: variable-name
_v: any;
toJSON() {
return classToPlain(this);
}
toString() {
return JSON.stringify(this.toJSON());
}
}
Neste vil brukeren vår utvide denne grunnleggende klassen:
@Exclude()
export class User extends BaseDBObject {
@Expose()
username: string;
password: string;
constructor(partial: Partial<User> = {}) {
super();
Object.assign(this, partial);
}
}
Jeg bruker noen få dekoratører her fra class-transformer-biblioteket for å endre denne interne brukeren (med alle databasefeltene intakte) når vi eksponerer klassen utenfor serveren vår.
@Expose- vil eksponere attributtet hvis standardinnstillingen for klassen er å ekskludere@Exclude- vil ekskludere egenskapen hvis klasse-standarden er å eksponere@Transform- endrer attributtnavnet når det 'eksporteres'
Dette betyr at etter å ha kjørt classToPlain-funksjonen fra class-transformer, vil alle reglene vi definerte på den gitte klassen bli anvendt.
Kontrollere
NestJs har en dekoratør du legger til for å sørge for at klasser du returnerer fra kontrollerendepunkter vil bruke classToPlain-funksjonen for å transformere objektet, og returnere resultatobjektet med alle private felt utelatt og transformasjoner (som å endre _id til id)
@Get(':id')
@UseInterceptors(ClassSerializerInterceptor)
async findById(@Param('id') id: string): Promise<User> {
return await this.usersService.find(id);
}
@Post()
@UseInterceptors(ClassSerializerInterceptor)
async create(@Body() createUserBody: CreateUserBodyDTO): Promise<User> {
// create a new user from the createUserDto
const userToCreate = new User(createUserBody);
return await this.usersService.create(userToCreate);
}
Tjenester
@Injectable()
export class UsersService {
constructor(@InjectModel('User') private readonly userModel: Model<IUser>) { }
async create(createCatDto: User): Promise<User> {
const userToCreate = new User(createCatDto);
const createdUser = await this.userModel.create(userToCreate);
if (createdUser) {
return new User(createdUser.toJSON());
}
}
async findAll(): Promise<User[]> {
const allUsers = await this.userModel.find().exec();
return allUsers.map((user) => new User(user.toJSON()));
}
async find(_id: string): Promise<User> {
const foundUser = await this.userModel.findOne({ _id }).exec();
if (foundUser) {
return new User(foundUser.toJSON());
}
}
}
Fordi vi internt alltid bruker User-klassen, konverterer jeg dataene som returneres fra databasen til en User-klasseinstans.
Jeg bruker @nestjs/mongoose, men egentlig, etter å ha hentet brukeren fra databasen, er alt det samme for både mongoose og TypeORM.
Advarsler
Med @nestjs/mongoose kan jeg ikke unngå å lage IUser-grensesnittet for å sende til mongo Model-klassen siden den forventer noe som utvider mongodb Document
export interface IUser extends mongoose.Document {
username: string;
password: string;
}
Når man GETter en bruker, vil API-et returnere denne transformerte JSON-en:
{
"id": "5e1452f93794e82db588898e",
"username": "username"
}
Her er koden for dette eksempelet i et GitHub-depot.
Oppdater
Hvis du vil se et eksempel som bruker typegoose for å eliminere grensesnittet også (basert på dette blogginnlegget), se her for en modell, og her for basismodellen
