Dependency Injection (DI) & Services

← Về MOC · Trước: Luồng khởi động (Startup flow)

1. Khái niệm nền

Dependency Injection là gì?

Dependency Injection (tiêm phụ thuộc) là kỹ thuật mà một đối tượng không tự tạo các đối tượng nó cần (dependency – phụ thuộc), mà được truyền vào từ bên ngoài, thường qua constructor (hàm khởi tạo). Nhờ vậy dễ thay bản hiện thực (desktop/web, thật/giả lập khi test) mà không sửa code dùng nó.

Trong VS Code, gần như mọi chức năng được tổ chức thành service (dịch vụ): IFileService (đọc ghi file), IConfigurationService (cài đặt), ILogService (ghi log), IEditorService (mở editor)… Phần lớn service định nghĩa ở lớp platform (Kiến trúc phân lớp (Layers)). Mã cốt lõi của DI nằm ở src/vs/platform/instantiation/.

2. Một service gồm 2 phần

Theo wiki Source Code Organization: (1) interface của service và (2) service identifier (định danh service). Định danh là cần thiết vì TypeScript dùng structural typing (so khớp kiểu theo cấu trúc, không theo tên), nên lúc chạy không thể dựa vào tên interface. Định danh là một decorator (hàm trang trí, cú pháp @Tên), đặt trùng tên với interface.

Ví dụ thật: ITimelineService

// trích src/vs/workbench/contrib/timeline/common/timeline.ts
export interface ITimelineService {
	readonly _serviceBrand: undefined;   // "nhãn" để đánh dấu đây là service
	readonly onDidChangeProviders: Event<TimelineProvidersChangeEvent>;
	readonly onDidChangeTimeline: Event<TimelineChangeEvent>;
	// ... các phương thức khác
}
 
// Định danh service – cùng tên với interface
export const ITimelineService = createDecorator<ITimelineService>(TIMELINE_SERVICE_ID);

Vì sao interface và hằng số trùng tên được?

TypeScript tách không gian tên kiểu (type) và không gian tên giá trị (value). ITimelineService vừa là kiểu (interface) vừa là giá trị (decorator) mà không xung đột.

createDecorator hoạt động thế nào?

// trích src/vs/platform/instantiation/common/instantiation.ts (thêm chú thích)
/** The *only* valid way to create a {{ServiceIdentifier}}. */
export function createDecorator<T>(serviceId: string): ServiceIdentifier<T> {
	// Mỗi serviceId chỉ có 1 định danh duy nhất
	if (_util.serviceIds.has(serviceId)) {
		return _util.serviceIds.get(serviceId)!;
	}
	// Decorator chỉ dùng được trên THAM SỐ (3 đối số: target, key, index)
	const id = function (target: Function, key: string, index: number) {
		if (arguments.length !== 3) {
			throw new Error('@IServiceName-decorator can only be used to decorate a parameter');
		}
		// Ghi nhớ: tham số thứ `index` của class `target` cần service `id`
		storeServiceDependency(id, target, index);
	} as ServiceIdentifier<T>;
	id.toString = () => serviceId;
	_util.serviceIds.set(serviceId, id);
	return id;
}

Tóm lại: khi viết @ILogService trước tham số constructor, decorator ghi thông tin “class này, tham số thứ mấy, cần service nào” vào chính class. Lúc tạo đối tượng, InstantiationService đọc thông tin đó và truyền service tương ứng vào.

3. Dùng (consume) một service

// trích src/vs/workbench/contrib/timeline/common/timelineService.ts
export class TimelineService extends Disposable implements ITimelineService {
	declare readonly _serviceBrand: undefined;
 
	constructor(
		@ILogService private readonly logService: ILogService,
		@IViewsService protected viewsService: IViewsService,
		// ... thêm service khác
	) {
		super();
	}
}

Quy tắc thứ tự tham số

Theo .github/copilot-instructions.md: nếu constructor cần tham số thường (không phải service), chúng phải đứng trước các tham số service.

// Ví dụ minh họa (tự viết)
class SqlPreviewWidget {
	constructor(
		private readonly container: HTMLElement,          // tham số thường – đứng trước
		private readonly connectionName: string,          // tham số thường
		@ILogService private readonly logService: ILogService,               // service – đứng sau
		@IConfigurationService private readonly configService: IConfigurationService,
	) { }
}
 
// Tạo đối tượng: chỉ truyền tham số thường, service được DI tự điền
const widget = instantiationService.createInstance(SqlPreviewWidget, containerEl, 'BRAVO_DB');

4. Cung cấp (provide) một service

Cách chuẩn là registerSingleton (singleton – chỉ có một thể hiện duy nhất):

// trích src/vs/workbench/contrib/timeline/browser/timeline.service.contribution.ts
registerSingleton(ITimelineService, TimelineService, InstantiationType.Delayed);

InstantiationType có 2 giá trị (src/vs/platform/instantiation/common/extensions.ts):

Giá trịÝ nghĩa
EagerTạo service ngay khi có ai khai báo phụ thuộc vào nó – tốn chi phí hơn
DelayedTạo service khi nó thực sự được dùng lần đầu – cách được khuyến nghị

registerSingleton chỉ thêm [id, descriptor] vào một mảng; mảng này được đọc khi workbench khởi động (xem bước 6 trong Luồng khởi động (Startup flow)).

5. ServicesAccessor và invokeFunction

Trong hàm xử lý lệnh (command handler) không có constructor, ta lấy service qua accessor:

// trích src/vs/workbench/contrib/timeline/browser/timeline.contribution.ts (thêm chú thích)
return async (accessor, arg) => {
	// Lấy TẤT CẢ service cần dùng NGAY ĐẦU hàm, trước mọi `await`
	const timelineService = accessor.get(ITimelineService);
	const viewsService = accessor.get(IViewsService);
	const uri = getResourceForCommand(arg, accessor.get(IEditorService), accessor.get(IListService));
	if (uri) {
		await timelineService.setUri(uri);   // sau await KHÔNG dùng accessor nữa
	} else {
		await viewsService.openView(TimelinePaneId, true);
	}
};

Lỗi kinh điển: dùng accessor sau await

accessor chỉ hợp lệ đồng bộ trong lúc invokeFunction đang chạy. Gọi accessor.get(...) sau await là bug. Repo có rule ESLint riêng code-no-accessor-after-await (.eslint-plugin-local/) để bắt lỗi này.

Ngoài ra, hướng dẫn chính thức yêu cầu: phụ thuộc service phải khai báo trong constructor, không được lấy qua IInstantiationService ở thời điểm khác.

6. Tổng kết luồng DI

flowchart LR
  A["createDecorator('fooService')<br/>→ IFooService"] --> B["registerSingleton(IFooService, FooService, Delayed)"]
  B --> C["Workbench.initServices()<br/>đọc getSingletonServiceDescriptors()"]
  C --> D["InstantiationService"]
  D -->|"createInstance(MyClass)"| E["constructor(@IFooService foo)"]

7. Liên hệ thực tế

Nếu bạn quen ASP.NET Core: createDecorator + interface tương tự interface IFooService, registerSingleton(..., Delayed) tương tự services.AddSingleton<IFooService, FooService>() (khởi tạo lười), còn @IFooService trong constructor tương tự constructor injection trong .NET.

8. Bài tập

  1. Tìm trong src/vs/platform/log/common/log.ts dòng createDecorator của ILogService.
  2. Mở src/vs/workbench/electron-browser/desktop.main.ts, tìm dòng serviceCollection.set(ILogService, logService). Bản hiện thực ở desktop là class nào? (Đáp án: NativeLogService, tạo ngay trong initServices() – một trong số ít service được đặt thủ công thay vì registerSingleton, vì cần có trước khi workbench khởi động.)

Liên kết