javascript - วิธีการยกเลิกข้อผิดพลาด HTTP จากคำสั่งถัดไปที่ล้มเหลวในซากาของ NestJS CQRS?
ฉันกำลังใช้สูตร NestJS CQRS เพื่อจัดการปฏิสัมพันธ์ระหว่างสองเอนทิตี: User และ UserProfile สถาปัตยกรรมคือเซิร์ฟเวอร์ NestJS API Gateway + เซิร์ฟเวอร์ NestJS สำหรับแต่ละไมโครเซอร์วิส (User, UserProfile, ฯลฯ)
ฉันได้ตั้งค่าการโต้ตอบพื้นฐานผ่านโมดูล User และ UserProfile บน API Gateway พร้อมซาก้า/อีเวนต์/คำสั่งของตัวเองแล้ว:
- เมื่อมีการสร้าง ผู้ใช้ จะมีการสร้าง โปรไฟล์ผู้ใช้
- เมื่อการสร้าง โปรไฟล์ผู้ใช้ ล้มเหลว ผู้ใช้ ที่สร้างมาก่อนหน้านี้จะถูกลบ
รายละเอียด:
ในโมดูลผู้ใช้ คำสั่ง CreateUser จะกระตุ้นเหตุการณ์ UserCreated ซึ่งถูกดักจับโดยซาก้า User ซึ่งจะกระตุ้นคำสั่ง CreateUserProfile (จากโมดูล UserProfile)
หากกรณีหลังล้มเหลว จะมีการสร้าง event UserProfileFailedToCreate และถูกจับโดย saga ของ UserProfile ซึ่งจะกระตุ้น command DeleteUser (จาก module User)
ทุกอย่างทำงานได้ดี
หากคำสั่ง CreateUser ล้มเหลว ฉัน resolve(Promise.reject(new HttpException(error, error.status)) ซึ่งแสดงให้ผู้ใช้ปลายทางเห็นว่ามีบางอย่างผิดพลาดระหว่างการสร้างผู้ใช้
ปัญหาของฉันคือฉันไม่สามารถเลียนแบบพฤติกรรมเดียวกันสำหรับคำสั่ง CreateUserProfile ได้ เนื่องจากคำสัญญาของคำขอ HTTP ได้ถูกแก้ไขแล้วจากคำสั่งแรก อย่างชัดเจน
ดังนั้นคำถามของผมคือ: มีวิธีใดบ้างที่จะทำให้คำสั่งล้มเหลวถ้าคำสั่งถัดไปล้มเหลวในซาก้า? ผมเข้าใจว่าการร้องขอ HTTP แยกออกจากคำสั่งถัดไปที่ถูกกระตุ้นโดยซาก้าอย่างสิ้นเชิง แต่ผมอยากรู้ว่ามีใครเคยลองเล่นกับอีเวนต์หรือสิ่งอื่นใดที่นี่เพื่อจำลองการไหลของข้อมูลนี้หรือไม่?
หนึ่งในเหตุผลที่ฉันใช้ CQRS นอกเหนือจากการมีโค้ดที่สะอาดกว่าสำหรับการโต้ตอบข้อมูลระหว่างไมโครเซอร์วิส คือเพื่อให้สามารถย้อนกลับการกระทำของ repository ในกรณีที่คำสั่งใด ๆ ในโซ่ล้มเหลว ซึ่งทำงานได้ดี แต่ฉันต้องการวิธีเพื่อแจ้งให้ผู้ใช้ปลายทางทราบว่าโซ่นั้นเกิดปัญหาและถูกย้อนกลับ
ผู้ควบคุมผู้ใช้.ts
@Post('createUser')
async createUser(@Body() createUserDto: CreateUserDto): Promise<{user: IAuthUser, token: string}> {
const { authUser } = await this.authService.createAuthUser(createUserDto);
// this is executed after resolve() in CreateUserCommand
return {user: authUser, token: this.authService.createAccessTokenFromUser(authUser)};
}
บริการผู้ใช้.ts
async createAuthUser(createUserDto: CreateUserDto): Promise<{authUser: IAuthUser}> {
return await this.commandBus
.execute(new CreateAuthUserCommand(createUserDto))
.catch(error => { throw new HttpException(error, error.status); });
}
สร้างคำสั่งผู้ใช้.ts
async execute(command: CreateAuthUserCommand, resolve: (value?) => void) {
const { createUserDto } = command;
const createAuthUserDto: CreateAuthUserDto = {
email: createUserDto.email,
password: createUserDto.password,
phoneNumber: createUserDto.phoneNumber,
};
try {
const user = this.publisher.mergeObjectContext(
await this.client
.send<IAuthUser>({ cmd: 'createAuthUser' }, createAuthUserDto)
.toPromise()
.then((dbUser: IAuthUser) => {
const {password, passwordConfirm, ...publicUser} = Object.assign(dbUser, createUserDto);
return new AuthUser(publicUser);
}),
);
user.notifyCreated();
user.commit();
resolve(user); // <== This makes the HTTP request return its reponse
} catch (error) {
resolve(Promise.reject(error));
}
}
UserSagas.ts
authUserCreated = (event$: EventObservable<any>): Observable<ICommand> => {
return event$
.ofType(AuthUserCreatedEvent)
.pipe(
map(event => {
const createUserProfileDto: CreateUserProfileDto = {
avatarUrl: '',
firstName: event.authUser.firstName,
lastName: event.authUser.lastName,
nationality: '',
userId: event.authUser.id,
username: event.authUser.username,
};
return new CreateUserProfileCommand(createUserProfileDto);
}),
);
}
คำสั่งสร้างโปรไฟล์ผู้ใช้.ts
async execute(command: CreateUserProfileCommand, resolve: (value?) => void) {
const { createUserProfileDto } = command;
try {
const userProfile = this.publisher.mergeObjectContext(
await this.client
.send<IUserProfile>({ cmd: 'createUserProfile' }, createUserProfileDto)
.toPromise()
.then((dbUserProfile: IUserProfile) => new UserProfile(dbUserProfile)),
);
userProfile.notifyCreated();
userProfile.commit();
resolve(userProfile);
} catch (error) {
const userProfile = this.publisher.mergeObjectContext(new UserProfile({id: createUserProfileDto.userId} as IUserProfile));
userProfile.notifyFailedToCreate();
userProfile.commit();
resolve(Promise.reject(new HttpException(error, 500)).catch(() => {}));
}
}
UserProfileSagas.ts
userProfileFailedToCreate = (event$: EventObservable<any>): Observable<ICommand> => {
return event$
.ofType(UserProfileFailedToCreateEvent)
.pipe(
map(event => {
return new DeleteAuthUserCommand(event.userProfile);
}),
);
}
คำสั่งลบผู้ใช้.ts
async execute(command: DeleteAuthUserCommand, resolve: (value?) => void) {
const { deleteAuthUserDto } = command;
try {
const user = this.publisher.mergeObjectContext(
await this.client
.send<IAuthUser>({ cmd: 'deleteAuthUser' }, deleteAuthUserDto)
.toPromise()
.then(() => new AuthUser({} as IAuthUser)),
);
user.notifyDeleted();
user.commit();
resolve(user);
} catch (error) {
resolve(Promise.reject(new HttpException(error, error.status)).catch(() => {}));
}
}
คำตอบ #1
ในแง่ของ DDD การสร้าง User และ UserProfile ของคุณถือเป็น ธุรกรรมทางธุรกิจ - กลุ่มของการดำเนินการ/กฎทางธุรกิจที่ต้องสอดคล้องกัน - ซึ่งครอบคลุมหลายไมโครเซอร์วิส
ในกรณีนั้น การคืนฐานข้อมูล User ก่อนที่ UserProfile จะถูกสร้าง หมายความว่าคุณกำลังส่งข้อมูลในสถานะที่ไม่สอดคล้องกัน ซึ่งไม่ได้ผิดโดยตรง แต่คุณควรจัดการกับสิ่งนี้อย่างเหมาะสมในฝั่งไคลเอนต์หากคุณทำแบบนั้น
ฉันเห็นสามวิธีที่เป็นไปได้ในการจัดการกับสถานการณ์นี้:
คุณปล่อยให้ Sagas ทำงานจนกว่าพวกมันจะดำเนินคำสั่งที่บ่งบอกว่าการทำธุรกรรมทางธุรกิจได้สิ้นสุดลง จากนั้นจึงแก้ไขผลลัพธ์สำหรับลูกค้าโดยระบุว่าสำเร็จหรือไม่สำเร็จ (เช่น ในรายละเอียดข้อผิดพลาดคุณสามารถรายงานขั้นตอนใดที่สำเร็จและขั้นตอนใดที่ไม่สำเร็จ) ดังนั้นคุณยังไม่แก้ไขใน
CreateAuthUserCommandหากการสร้าง
UserProfileอาจใช้เวลานาน (บางครั้งอาจต้องให้ผู้ควบคุมตรวจสอบด้วยตนเอง) คุณอาจต้องการแก้ไขUserในCreateAuthUserCommandและต่อมาจึงให้ไคลเอนต์สมัครรับเหตุการณ์ที่เกี่ยวข้องกับ UserProfile คุณต้องมีกลไกสำหรับเรื่องนั้น แต่จะทำให้ไคลเอนต์แยกออกจากการทำธุรกรรมที่กำลังรัน และยังสามารถทำสิ่งอื่นได้ด้วยอีกทางเลือกหนึ่ง คุณสามารถแบ่งธุรกรรมทางธุรกิจออกเป็นสองส่วนที่ลูกค้าส่งคำขอต่างหาก: หนึ่งเพื่อสร้าง/ส่งคืน
Userที่ผ่านการรับรองตัวตน และอีกส่วนเพื่อส่งคืนUserProfileที่สร้างขึ้น แม้ว่าจะดูเหมือนว่าUser+UserProfileอยู่ในบริบทจำกัดเดียวกัน แต่ความจริงที่ว่าพวกมันอยู่ในไมโครเซอร์วิสคนละตัวอาจบ่งชี้ว่าพวกมันไม่ได้อยู่ในบริบทเดียวกัน (ในกรณีนี้ผมคิดว่าไมโครเซอร์วิสตัวแรกจริงๆ แล้วสำหรับการยืนยันตัวตน และอีกตัวสำหรับ UserProfiles ซึ่งบ่งชี้บริบทจำกัดที่ต่างกันสำหรับผม) แนวปฏิบัติที่ดีที่สุดคือให้ไมโครเซอร์วิสดำเนินการบริบทจำกัดที่แยกเป็นของตัวเอง
(หมายเหตุ: ตอบคำถามเก่าในความหวังว่าจะเป็นประโยชน์ต่อผู้อื่น)