
开发人员在构建使用大型数据集的应用程序时必须解决的一个常见挑战是如何创建可维护且可扩展的用户编辑体验。他们可能面临数十万甚至数百万条记录,在这些记录上执行 CRUD 操作,需要将这些记录发送到服务器并保存到数据库。
他们希望确保不会执行对服务器的冗余调用。
乍一看,我们似乎可以利用ngrx/store
功能来处理此类场景。商店通常是应用程序范围的单例服务,可用于覆盖复杂的组件交互场景。
在这种情况下,我们正在考虑增强一个组件,该组件不会充分利用 NgRx Store 的优势,并且我们也在寻找 Redux 范围之外的一些特定功能 – 保留所有操作的可访问时间历史记录并能够操纵历史并由此产生的状态。我们认为依靠一个全面的服务来处理状态是适合这个目的的。服务如何具体地将操作减少到某种状态将取决于实现,如果您选择的话,甚至可以在内部使用存储。
在本文中,我们描述了我们的团队如何应对挑战,以最大限度地减少更新记录时发送到服务器的 HTTP 请求数量。我们还将研究捆绑操作如何允许具有附加功能的更灵活的客户端应用程序。
在我们的 Angular 客户端应用程序中,我们在表 UI 中向用户呈现数据集,其中每个表行映射到数据中的一条记录。通过 CRUD 操作,虽然用户可以添加或删除,但编辑尤其是逐个单元格进行。因此,在没有任何额外工作的情况下,您的组件需要对每个编辑做出反应并向服务器发送仅包含此单个单元格信息的 HTTP 请求。
想象一下,您有一个包含 20 列的表,并且用户对单行中的所有单元格执行编辑 – 这将总共向服务器发出 20 个用于单个记录更新的请求。假设您的数据层是基于记录的,这些 API 调用也将导致 20 条数据记录更新。这 19 太多了,您可以将其乘以应用程序的用户数量,并得出后端额外负载的结论。
更糟糕的是,如果用户进行了 5 次编辑,然后改变了主意并将更改恢复为原始值,这仍然意味着 10 个 HTTP 请求被发送到服务器而根本没有任何更改!另一个缺点是处理每个单独的操作(编辑、添加、删除)并将它们分成请求可能会很乏味。
这不是我们希望在Ignite UI for Angular库中提供的体验。
为了解决这些问题,我们实现了一项捆绑更新的服务,这将使我们能够减少对服务器的 HTTP 请求数量。事务服务是一个可注入的中间件(通过Angular 的 DI ),组件可以使用它来累积更改,而不会立即影响底层数据,就像实现工作单元模式时所做的那样。
您可以add
事务并commit
或clear
所有更改。作为奖励 – 由于它保留了详细的日志,因此它还可以撤消和重做操作。它还有一个挂起的会话(稍后详细介绍),用于记录所有添加的更改,一旦会话结束,将它们作为一个单元添加到日志中。我们有几个实现,下面我们将介绍所有可能的功能。
每次执行操作(事务)时,它都会添加到事务日志和撤消堆栈中。然后,事务日志中的所有更改都会按记录累积。从那时起,该服务将维护一个聚合状态,该状态仅包含唯一记录的添加/更新/删除操作。
该服务的示例界面可能类似于:
// transaction.service.ts
export interface TransactionService {
readonly enabled: boolean;
/** Event fired when transaction state has changed */
onStateUpdate?: EventEmitter<void>;
/** Add transaction */
add(transaction: Transaction, originalRecord?: any): void;
/** Returns aggregated changes from all transactions */
getAggregatedChanges(mergeValueWithOriginal: boolean): Transaction[];
/** Applies all transactions over the provided data */
commit(data: any[]): void;
/** Clears all transactions */
clear(): void;
/** Remove the last transaction */
undo(): void;
/** Apply back the last undone transaction */
redo(): void;
/** Start recording transactions into a pending state */
startPending(): void;
/** Stop recording and add pending state as single transaction */
endPending(): void;
}
可以在此处找到来自我们源代码的更详细的接口, Transaction 接口如下所示:
// transaction.ts
export interface Transaction {
id: any;
type: TransactionType;
newValue: any;
}
服务的实现用@Injectable装饰,以便可以将它们提供给组件、管道等。当组件通过其类型和令牌注入服务时,您可以根据需要提供不同的事务服务实现。请注意,如果未使用 @Inject 显式指定,则该类型将用作标记。
将服务注入组件后,要执行“添加”操作,您将创建一个新事务并添加它,如下所示:
// grid-base.component.ts
const transaction: Transaction = { id: transactionId, type: TransactionType.ADD, newValue: data }; this.transactions.add(transaction);
对于现有记录,add 方法接受一个可选参数 – 对原始记录的引用。
事务的 id 与要更新的记录和更新类型(添加、更新或删除)相匹配。对于每个事务的newValue
,我们使用部分对象来更改快照。这使得更改的内容非常清楚 – 例如, { name: “John NotDoe”}
表示仅记录的 name 属性已被修改。这还允许服务使用Object.assign
轻松累积状态,并在需要时将其应用到原始状态。
调用撤消,将更改移至重做堆栈,并在调用重做时移回撤消堆栈。聚合状态会相应地更新,并且与所有操作一样,服务会发出onStateUpdate
以使使用组件知道它们应该更新其视觉状态。
说到视觉更新,对于这些更新,我们使用了自定义@Pipe
Pipe——这样通过事务应用的更改是无缝的,并且仍然可以链接进一步的转换(例如过滤、排序等,具体取决于组件的功能)。在我们的实现中,我们使用纯管道,这意味着它仅在 Angular 检测到输入值发生更改时才执行。在我们的例子中,由于事务不操作数据,因此我们定义了一个额外的数字参数pipeTrigger
,当事务状态发生变化时,组件可以增加该参数以触发管道。
以下是此类管道的简化示例:
// component.pipes.ts
@Pipe({
name: 'tableTransaction',
pure: true
})
export class TableTransactionPipe implements PipeTransform {
constructor(private transactions: TransactionService) { }
transform(collection: any[], pipeTrigger: number) {
if (collection && this.transactions.enabled) {
const changes: Transaction[] = this.transactions.getAggregatedChanges(true);
// Update the data source with the aggregated changes and return the result
} else {
// If there are not any transactions, return the original collection
return collection;
}
}
}
这些事务为我们提供了在网格组件中实现两个单独的编辑功能的机会。我们将仔细研究它们的规范,因为您可能会发现它们与您的用例相关,并且您可以在组件中实现类似的行为或重用它们。
我们的网格模块提供了一个非常基本的服务实现,仅具有待处理的会话功能,但这对于行编辑来说仍然足够了!通过使用startPending()
和endPending()
行编辑可以将多个每单元格操作合并为一个更改。这意味着编辑单个记录的多个单元格会创建一个事务,并且您可以仅处理行编辑事件。这也更符合ORM (数据层)所具有的基于记录的 API。
由于累积状态是部分对象,我们还可以使用该服务来检查哪个单元格已被编辑,并围绕它构建 UI。如下图所示,我们更新了两个单元格(斜体),并且当前正在编辑该记录的“OrderDate”单元格。
取消会结束挂起的会话,而“完成”将创建一个事务(如果当前提供的服务实现了该事务)。
说到实现,Ignite UI for Angular 包还附带了成熟的事务服务实现 ( IgxTransactionService
),具有完整的事务、撤消和重做支持。您需要做的就是提供它作为基本内置的直接替代品,并且您已经启用了批量编辑!
当特定行有未保存的更改时,我们再次使用服务中的每行状态来向用户提供 UI 提示。最后两行下方是新添加的(全部斜体),一行已删除,一行有已编辑的单元格。所有具有未保存更改的行在网格的左边缘都有一个修改指示器。
单元格和行编辑模式都可以与事务一起使用。主要区别在于,当单元格退出编辑模式时,单元格编辑会添加到事务日志中。当我们进行行编辑时,只有在整行退出编辑模式后才会添加事务。在这两种情况下,网格的聚合状态是统一的 – 由所有更新、添加和删除的行组成。
这会自动解决处理每个单元格编辑的问题,因为这些编辑现在累积在一条记录下。作为奖励,用户可以多次更新同一个单元格,根据需要撤消和重做,而无需向后端发送冗余请求。实际同步到服务器的时间取决于您的应用程序的用例,我们接下来将讨论选项。
现在让我们进入本文的实际部分。
要使用您的组件实现批量编辑,您需要提供事务服务实现。如上所述,Ignite UI for Angular 包附带了一个 ( IgxTransactionService
),但您也可以替换您自己的。
在此示例中,我们将演示如何在自定义表控件中使用事务服务。因此,第一步是将其导入到您的组件中,如下所示:
// my-table.component.ts
import { Component } from "@angular/core";
import { IgxTransactionService } from "igniteui-angular";
@Component({
providers: [IgxTransactionService],
selector: "my-table",
...
})
export class MyTableComponent { }
然后我们将定义我们的表格组件模板:
// my-table.component.html
<tr *ngFor="let record of (data | gridTransaction:pipeTrigger)">
<!-- <td> cell templates -->
</tr>
注入的服务实例作为transactions
属性公开在表上,我们可以使用它的 API 来绑定我们的操作。为了实现这一点,我们需要绑定到每次更新记录时组件发出的事件,而不是将更新保存到我们的数据中,我们应该将其添加为如上所述的事务。
// my-table.component.ts
public get undoEnabled(): boolean {
return this.table.transactions.canUndo;
}
public get redoEnabled(): boolean {
return this.table.transactions.canRedo;
}
public undo() {
this.table.transactions.undo();
}
public redo() {
this.table.transactions.redo();
}
public commit() {
this.table.transactions.commit(this.data);
}
public discard() {
this.table.transactions.clear();
}
撤消和重做按钮基于canUndo
和canRedo
启用,如果相应的撤消/重做堆栈有条目,则返回它们。我们还可以通过commit
方法提交更改,或者使用clear
方法丢弃所有更改。
您可以仔细查看与我们在批量编辑文档中本节中使用的示例类似的示例。在探索 Angular 网格的 Ignite UI 如何使用事务服务时,请注意, IgxGrid
为其模块中提供的服务 ( IgxGridTransaction
) 使用特定的注入令牌,我们需要通过在父组件上提供事务服务来覆盖它等级。您可以在StackBlitz上找到完整的示例。
这些示例提供了有关事务的外观以及如何管理它们的信息,但我们所做的所有更改都存储在本地。当我们有一个服务器来发送交易时,我们的解决方案会是什么样子?
为了在 Angular 应用程序中处理和发送事务,我们将创建一个服务来封装数据访问,以便将其与表示层分离。示例中的服务将充分利用 Angular 的HttpClient语义方法来匹配我们的 REST 端点。本示例中的IgxGrid
绑定到包含城市的数据集,并且该服务将具有一个commitCities
方法,该方法接受要发送的事务数组。
我们可以重用上一章的示例,只扩展commit
方法的功能:
// grid-batch-editing.component.ts
public commit() {
let addResult: { [id: number]: City};
this.cityService.commitCities(this.grid.transactions.getAggregatedChanges(true))
.subscribe(res => {
if (res) {
addResult = res;
}
},
err => this.errors = err,
() => {
// all done, commit transactions
this.grid.transactions.commit(this.data);
if (!addResult) {
return;
}
// update added records IDs with ones generated from backend
for (const id of Object.keys(addResult)) {
const item = this.data.find(x => x.CityID === parseInt(id, 10));
item.CityID = addResult[id].CityID;
}
this.data = [...this.data];
}
);
}
Transaction 服务的getAggregatedChanges
方法将当前聚合状态作为事务返回,而可选参数将更改快照与事务值的原始记录合并(大多数 ORM 更新需要完整记录)。我们调用commitCities
方法,为其提供交易。
一旦服务完成,我们就在网格服务中提交事务——这会更新客户端数据并清除日志,从而使网格删除未保存的标记。虽然特定于应用程序,但记录 ID 通常由数据层自动生成,并且 ADD 操作的端点返回更新的实体。我们添加了一个片段,保存 ADD 调用的返回结果以及提交后对数据的更新。这样就完成了客户端与服务器端数据的同步。最后一位重新创建数据数组引用以触发数据网格的OnPush
更改检测策略。
现在让我们看看该服务如何处理这些事务。此时,我们可以使用三种不同的方法将它们发送到后端。
第一个选项是在单个请求中发送所有事务并在服务器上处理它们。第二个选项是迭代服务中的所有事务,并为每个事务执行单独的 HTTP 请求,匹配标准的每记录RESTful API 。
我们将在此处实现的第三个选项假设您的 REST 端点支持多个记录。这使我们能够按类型对所有交易进行分组,并针对每种交易类型向后端发送单独的请求,最多 3 个请求。
将更改类型与相应的HttpClient
谓词方法相匹配delete
、使用post
添加(取决于后端支持的 HTTP 规范,添加也可以通过PUT 谓词完成)、使用put
进行更新,然后我们提取所需的数据格式(ID 或原始值)基于端点要求:
// city.service.ts
commitCities(transactions: Transaction[]): Observable<any> {
const requests: Observable<Object>[] = [];
const updates = transactions.filter(x => x.type === 'update');
const adds = transactions.filter(x => x.type === 'add');
const deletes = transactions.filter(x => x.type === 'delete');
if (deletes.length) {
requests.push(this.http.delete(this.updateURL, {
params: {
ids: deletes.map(t => t.id)
}
}));
}
if (adds.length) {
requests.push(this.http.post(this.updateURL, adds.map(t => t.newValue)));
}
if (updates.length) {
requests.push(this.http.put(this.updateURL, updates.map(t => t.newValue)));
}
return merge(...requests);
}
我们检查每种类型的请求是否有任何事务,如果是这种情况,我们将相应的请求推送到requests
数组。这保证了不会向后端发送多余的空请求。
我们将所有可能的请求合并到一个Observable中,以便我们可以在组件中订阅它,并在处理所有事务后执行逻辑。
当我们将批量更新实现与不使用事务服务的解决方案进行比较时,我们发现事务服务帮助我们大大减少了发送到后端的请求数量。它还可以通过提供更改状态的视觉指示来增强编辑用户体验,并为撤消/重做等附加功能提供可能性。
事务服务方法不仅可以使用Angular Grid 的 Ignite UI ,还可以在使用自定义组件时提升下一个应用程序的编辑体验。