关于如何开发CRUD应用的文章已然不计其数,我有意再添一篇,试图将重点落在“真正”两字上。 我发现网上很多示例通常会把开发CRUD应用说得很容易,而我想说这种程序的开发其实比大多数人想象的要难。我更不觉得开发CRUD很低端,相反我觉得是那些失真的示例,通过有意无意地隐藏复杂度,向世人传递了一种不实的映像。

我将使用一个常用的系统模块作为示例,即用户管理。“用户”是指被授予访问系统资源的人,我们需要一个UI针对用户对象进行增删改查的操作。这个示例能让我们真正看清一个CRUD应用从设计到开发的整个过程。您同时也能了解一些相关的思考、模式以及困难。

我选择Angular作为UI技术,因为我喜欢它的双向绑定和组件设计,这和我一直以来的开发理念是相符的。当然,开发CRUD应用还涉及许多其他技术,有些太重要了,以至于我们忘记了它们的存在。撇开操作系统和数据库不表,我选择在NodeJS上构建我的应用。您可以先看一下演示,再考虑是否值得花30分钟来阅读我这篇絮絮叨叨的博客。您还可以直接从Github上查阅完整的代码。

先建模

第一个问题,先从哪里入手? 有些人喜欢先绘制UI样例,而另外一些人则选择先设计数据库表。 无论采用哪种方法,您实际上都在进行建模思考。这个阶段,您唯一要考虑的是“用户”应该具有哪些属性。我这里选择用时下流行的JSON去建模。

{"r_user": {"USER_ID": "DH001", "USER_NAME": "VINCEZK", "PASSWORD": "Dark1234", "PWD_STATE": 1, "LOCK": null,"DISPLAY_NAME": "Vincent Zhang", "FAMILY_NAME": "Zhang", "GIVEN_NAME": "Vincent", "MIDDLE_NAME": null},
"r_employee": {"USER_ID": "DH001", "COMPANY_ID": "Darkhouse", "DEPARTMENT_ID": "Development", "TITLE": "Developer", "GENDER": "Male"},
"r_email": [{"EMAIL": "xxxx@hotmail.com", "TYPE": "private", "PRIMARY": 1},{"EMAIL": "xxxx@darkhouse.com", "TYPE": "work", "PRIMARY": 0}
],
"r_address": [{"ADDRESS_ID": 527, "COUNTRY": "China", "CITY": "Shanghai", "POSTCODE": 201202, "ADDRESS_VALUE": "Room #999, Building #99, XXX Road #999", "TYPE": "Current Live", "PRIMARY": 1 },{"ADDRESS_ID": 528, "COUNTRY": "China", "CITY": "Haimen", "POSTCODE": 226126,"ADDRESS_VALUE": "Village LeeZhoo", "TYPE": "Born Place", "PRIMARY": 0}
],
"r_personalization": {"USER_ID": "DH001", "DATE_FORMAT": null, "DECIMAL_FORMAT": null, "TIMEZONE": "UTC+8", "LANGUAGE": "ZH" },
"relationshipWithRole": [{"NAME": "administrator"},{"NAME": "tester"}
]
}

JSON建模的好处是直接方便。您需要的只是一个文本编辑器。自然,你还得有一个模式规划。如上示例中,“ r_user”是将一些相关属性归在一起,我将之称为“relation”。如果某个“relation”具有多个元组,则用数组表示,例如“r_email”和“r_address”。如果实体与实体间存在某种关系,则参考“relationshipWithRole”。

以此JSON模型为基础,我们可以选择向上或向下扩展。向上通UI,向下连数据库。
到底先做哪个呢? 如果您是收钱办事,那么应先做UI部分,以便能尽早核实需求。就我而言,我没有这种压力,因此我会首先处理数据库,这样会节约一些开发时间。

接下来,我们将会面对一个老问题:对象关系映射。我目前的JSON模型是面向对象的,是否应使用相同的对象模式去存储它呢? 我的回答永远是“否”。我们存储数据的最终目的是方便以后查阅分析。而那个时候,我们将主要以“集合”的形式,而非“对象”形式去访问它们。如果我为了开始的便利,以对象形式存储,那么我将在查阅分析时付出更多的代价。所以我的选择一定是关系型数据库,并使用
JSON-On-Relations(简称JOR) 作为对象关系映射框架。

由于“用户”会对应到某个“人”,因此我创建了两个角色“system_user”和“employee”,并将它们分配给个实体“person”。
Entity: Person
在角色“system_user”中,我分配了4个relation,分别是:“r_address”,“r_email”,“r_personalization”,以及“r_user”。每个relation都有对应的基数设置。例如:“r_user”的“[1…1]”表示每个实例在“r_user”中必须具有1个数据项。
Role: system_user
使用JOR的图形建模工具,我可以轻松地创建数据库表并将它们组成“用户”实体。如您所见,它遵循实体关系模型这个听起来有点过时的概念,但实际上,它远比那些试图隐藏数据库的ORM深刻。此外,JOR提供了开箱即用的RESTful API,以满足对实体主要的CRUD操作。

然而就数据建模本身而言,绝非易事。 工具只能帮助您实现它,而无法帮助您设计它。困难点在于如何设计一个兼具可适用性、可扩展性和可重用性的数据模型。有人倡导“渐进式架构(Growing Architecture)”,认为架构一开始可以不用那么好,慢慢会变好。我认为这个理念并不适用于数据建模。精心设计的数据模型对于软件的生命周期至关重要;而粗制滥造的数据模型是不会渐进的。

在我设计“用户”模型时,我开始认为用户必须是一个人。然而这并不确切。 在A2A集成方案中使用的通信用户就不是一个自然人。它只是一种具有访问某些系统资源的凭据。因此,我将“system_user”定义为角色,而不是实体。当将其分配给某个自然人时,该人就具有系统用户这个角色,并以此能访问某些系统资源。如您所见,建模实际上是哲学性很强的思考,特别形而上学。

绘制UI

从架构上看,CRUD应用通常具有3层:数据库、应用服务器和用户界面(UI)。业务逻辑散布在这三层中,您很难消除其中任何一层。应用服务器处于数据库和UI之间,用于处理从UI发来的请求,并将之转换为对数据库的访问请求。没有这个中间层,将会极大加重UI与数据库的通讯成本。与单用户应用相比(例如Office Word),CRUD应用是必须要支持多用户并发访问的

接下来,我将绘制用户界面。取决于您对UI技术的熟悉程度,您既可以用铅笔和纸来绘制UI,也可以利用一些UI建模工具,或直接使用正式的UI开发工具。由于我在数据建模的同时想象了UI,因此我就直接用HTML和CSS来绘制UI,这样我可以节省很多时间。 我的UI共有2个页面:“搜索和列表”页面,以及“详细”页面。
Search&List Page
“搜索和列表”页面除了允许您搜索和列示用户外,您还可以创建一个新用户,或删除一个现有用户。单击User ID链接,或者点击显示/更改操作按钮,将导航到“详细”页面。
Detail Page
“详细”页面显示用户的详细信息。它具有一个固定的抬头,以及5个自由选项卡,用于对信息进行分组。右上角有“编辑/显示”和“保存”按钮。

这两个页面目前还是静态的。 数据是固定的,按钮是禁用的,链接是假的。我只是想先尽快把它画出来,看看是否符合我的预期。在实际项目中,您可能需要将这样的页面给产品经理过目,以检查是否满足需求。

也许有人会问:“为什么你还要人工绘制UI?不是有很多工具可以根据数据模型自动生成UI吗?”我的回答是:就我所知的产品和经历过的项目,我从未见过这种方式真正成功过。也许,许多技术布道师正在宣讲这样的开发方式,例如低代码或无代码,但我不相信这个能成功。

正如我前面所说的三层架构,您无法消除其中的任何一层。每个层都有自己的建模语言来描述相同的实体对象。数据库使用关系代数语言以实现对物理存储的全路径访问。应用服务器层使用面向对象的语言来操作内存中的数据。UI尝试用便于人类理解的语言以使其更加用户友好。由于每个层各有其不同的侧重,我们几乎无法用一个固定的规则使得其他层能基于某层自动产生。我们可以做的是翻译和映射这3种不同层面的建模语言。从数据库到应用服务器,我们使用对象关系映射;从应用服务器到UI,我们使用UI对象映射。

对象到UI的映射

实际上,我还是比较喜欢绘制UI的。尤其是当你有趁手的工具能让你实时观察调整效果。在这里,我使用Bootstrap进行排版,用Angular Server进行实时渲染。当UI看起来不错后,就该做数据绑定和界面逻辑了。使用Angular的Reactive Form让这件事情变得很简单。

无论UI的外观如何,其幕后都对应一个数据对象。这里所说的“数据对象”可以用一种嵌套结构表示。比如抬头下面包含行项目,行项目下面再包含子项目。我的“用户对象”用Angular的FormGroup可表示如下:

this.userForm = this.fb.group({USER_ID: ['DH001', [Validators.required]], LOCK: ['Unlocked'], PWD_STATUS: [''],userBasic: this.fb.group({names: this.fb.group({USER_NAME: ['VINCEZK', [Validators.required]],DISPLAY_NAME: ['Vincent Zhang', [Validators.required]],GIVEN_NAME: ['Vincent'], MIDDLE_NAME: [''], FAMILY_NAME: ['Zhang']}),employee: this.fb.group({TITLE: ['Developer'], DEPARTMENT_ID: ['Development'], COMPANY_ID: ['Darkhouse', [Validators.required]], GENDER: ['Male']})}),emails:  this.fb.array([this.fb.group({EMAIL: ['DH001@hotmail.com'], TYPE: ['private'], PRIMARY: ['1']});this.fb.group({EMAIL: ['DH001@darkhouse.com'], TYPE: ['work'], PRIMARY: ['0']});]),addresses: this.fb.array([this.fb.group({ADDRESS_ID: [''], TYPE: ['Current Live', [Validators.required]],ADDRESS_VALUE: ['Room #999, Building #99, XXX Road #999', [Validators.required]],POSTCODE: ['201202'], CITY: ['Shanghai'], COUNTRY: ['China'], PRIMARY: ['1']})]),userPersonalization: this.fb.group({USER_ID: ['DH001'], LANGUAGE: ['ZH'], TIMEZONE: ['UTC+8'], DECIMAL_FORMAT: [''], DATE_FORMAT: ['']}),userRole: this.fb.array([this.fb.group({NAME: ['administrator'], DESCRIPTION: ['Administrator'],system_role_INSTANCE_GUID: ['391E75B02A1811E981F3C33C6FB0A7C1'],RELATIONSHIP_INSTANCE_GUID: ['06FEB4702A1B11E981F3C33C6FB0A7C1']})])
}); 

Angular引入了FormGroup及其构建器(this.fb)来构建UI数据对象。它不仅可以用来定义对象结构和数值,还可以添加验证器(Validator)。例如,我在属性“USER_ID”上添加了“Validators.required”,以声明它不允许为空。此外,FormGroup还实现了UI(HTML)和对象(JS)之间的双向数值绑定。这意味着在UI上进行的任何数据更改可实时同步到背后的数据对象上,反之亦然。

<div class="col-lg-4 form-group" [formGroup]="userForm"><label for="user_id" class="col-form-label dk-form-label">User ID:</label><input id="user_id" name="user_id" formControlName="USER_ID" type="text" class="form-control">
</div>
<div class="col-lg-4 form-group" [formGroup]="userForm"><label for="lockStatus" class="col-form-label">Lock Status:</label><div id="lockStatus" class="form-control"><span *ngIf="userForm.get('LOCK').value" class="fas fa-lock" > Locked</span><span *ngIf="!userForm.get('LOCK').value" class="fas fa-lock-open"> Unlocked</span></div>
</div>
<div class="col-lg-4 form-group" [formGroup]="userForm"><label for="passwordStatus" class="col-form-label">Password Status:</label><div id="passwordStatus" class="form-control" [ngSwitch]="userForm.get('PWD_STATUS').value"><div *ngSwitchCase=""><span class="badge badge-primary">Initial</span></div><div *ngSwitchCase="1"><span class="badge badge-success">Active</span></div><div *ngSwitchCase="2"><span class="badge badge-warning">Renew</span></div></div>
</div>

从上面的代码片段中,您可以发现FormGroup对象如何通过HTML属性“[formGroup]”和“formControlName”绑定到HTML。有时候,您不想直接显示数值,而是希望做一些转换。就如“lockStatus”和“passwordStatus”,我把其数值替换成易读的描述和图标,以便更直观的向使用者展示。

我的有些属性是多元组的,例如“电子邮件”,“地址”和“用户角色”。它们可以用Angular FormArray来构造。但是在UI中,它们又可以以多种形式展示。您可以选择LIST或TABLE控件,每个控件还有更多的细分。根据这些属性的性质,结合考虑如何“添加”和“删除”等操作,您可能会发现有时艰难决定到底选择那种展示形式。
Email Representation
我为“电子邮件”和“地址”选择LIST控件,主要是因为用户可能有多个电子邮件和地址,但也不会多到哪里去,不会超过10个。因此,以表单而不是表格样式来展示它们显得更为自然。当您点击“添加”按钮时,将会添加一个新的空白表单,以允许用户输入一个新的电子邮件。单击右上角的“X”会删除一个已有的电子邮件。
Role Assignment Representation
但是,对于“用户角色”,我则选择TABLE控件。不仅是由于用户可能会被分配许多角色,更是因为这是一种指派性的属性。也就是说,这里描述的是“用户”实体和“角色”实体之间的关系,而这种关系信息以密集的形式展示会比较好。

请注意,我在“Action”列中只有一个“删除”按钮,并没有“添加”按钮。当用户在最后一行中输入一个角色时,程序自动会执行追加一个空行的操作。这种设计会使UI更加干净自然。但是,它假定用户角色分配动作是无序的,并总是一个一个地去添加。若非这样,这并不是一种推荐的模式。

现在,我的用户界面具有了动态性。至少,页面上的数据是由背后的数据对象提供的,而不是在HTML中硬编码的。但是,FormGroup数据对象和HTML都运行在浏览器中。到目前为止,我们还是处于UI层,并未与应用服务器发生联系。不过您可能已经发现FormGroup对象与我们一开始的JSON数据模型很相似。这是因为它们都是对象模型。回忆一下,我们首先设计了JSON数据模型,然后在绘制UI中构建了FormGroup对象。你会发现,只要都是对象模型,我们就可以把它们轻松地对应起来。

依靠JOR,我只需在浏览器端编写了一个服务调用,就能获取一个“用户”的JSON数据对象。

getUserDetail(userID: string): Observable<Entity | Message[]> {const pieceObject = {ID: { RELATION_ID: 'r_user', USER_ID: userID},piece: {RELATIONS: ['r_user', 'r_employee', 'r_email', 'r_address', 'r_personalization'],RELATIONSHIPS: [{RELATIONSHIP_ID: 'rs_user_role',PARTNER_ENTITY_PIECES: { RELATIONS: ['r_role'] }}]}};return this.http.post<Entity | Message[]>(this.originalHost + `/api/entity/instance/piece`, pieceObject, httpOptions).pipe(catchError(this.handleError<any>('getUserDetail')));
}

上面的服务调用是通过用户ID获取该用户的详细信息。它提交了一个请求,要求提供该用户实体的部分信息。检查“pieceObject”的定义,您不难理解它请求读取以下信息片段,分别是Relaion: “r_user”、“r_employee”、“r_email”、“r_address”、“r_personalization”以及Relationship: “rs_user_role”。服务调用返回的是一个JSON对象,结构大致和我们开始定义的JSON模型一致。

接下来我们要将返回的JSON对象(data)映射到FromGroup对象(userForm)上。这部分编码很简单,唯一需要注意的是数组对象。观察“r_email”、“ r_address”、和“userRole”,我针对它们使用了循环操作将单个FormGroup对象压到对应的FormArray中。

this.userForm = this.fb.group({USER_ID: [data['r_user'][0]['USER_ID'], [Validators.required]],LOCK: [data['r_user'][0]['LOCK']],PWD_STATUS: [data['r_user'][0]['PWD_STATUS']],userBasic: this.fb.group({names: this.fb.group({USER_NAME: [data['r_user'][0]['USER_NAME'], [Validators.required]],DISPLAY_NAME: [data['r_user'][0]['DISPLAY_NAME'], [Validators.required]],GIVEN_NAME: [data['r_user'][0]['GIVEN_NAME']],MIDDLE_NAME: [data['r_user'][0]['MIDDLE_NAME']],FAMILY_NAME: [data['r_user'][0]['FAMILY_NAME']]}),employee: this.fb.group({TITLE: [data['r_employee'][0]['TITLE']],DEPARTMENT_ID: [data['r_employee'][0]['DEPARTMENT_ID']],COMPANY_ID: [data['r_employee'][0]['COMPANY_ID'], [Validators.required]],GENDER: [data['r_employee'][0]['GENDER']]})}),emails:  this.fb.array([]),addresses: this.fb.array([]),userPersonalization: this.fb.group({USER_ID: [data['r_personalization'] ? data['r_personalization'][0]['USER_ID'] : ''],LANGUAGE: [data['r_personalization'] ? data['r_personalization'][0]['LANGUAGE'] : ''],TIMEZONE: [data['r_personalization'] ? data['r_personalization'][0]['TIMEZONE'] : ''],DECIMAL_FORMAT: [data['r_personalization'] ? data['r_personalization'][0]['DECIMAL_FORMAT'] : ''],DATE_FORMAT: [data['r_personalization'] ? data['r_personalization'][0]['DATE_FORMAT'] : '']}),userRole: this.fb.array([])
});const emailArray = this.userForm.get('emails') as FormArray;
data['r_email'].forEach( email => {emailArray.push(this.fb.group({EMAIL: [email['EMAIL'], [Validators.required]],TYPE: [email['TYPE'], [Validators.required]],PRIMARY: [email['PRIMARY']]}));
});const addressArray = this.userForm.get('addresses') as FormArray;
if (data['r_address']) {data['r_address'].forEach( address => {addressArray.push(this.fb.group({ADDRESS_ID: [address['ADDRESS_ID']],TYPE: [address['TYPE'], [Validators.required]],ADDRESS_VALUE: [address['ADDRESS_VALUE'], [Validators.required]],POSTCODE: [address['POSTCODE']],CITY: [address['CITY']],COUNTRY: [address['COUNTRY']],PRIMARY: [address['PRIMARY']]}));});
}const roleArray = this.userForm.get('userRole') as FormArray;
const userRoleRelationship = data['relationships'][0];
if (userRoleRelationship) {userRoleRelationship.values.forEach( value => {const roleInstance = value.PARTNER_INSTANCES[0];roleArray.push(this.fb.group({NAME: [roleInstance['r_role'][0]['NAME']],DESCRIPTION: [roleInstance['r_role'][0]['DESCRIPTION']],system_role_INSTANCE_GUID: [roleInstance['INSTANCE_GUID']],RELATIONSHIP_INSTANCE_GUID: [value['RELATIONSHIP_INSTANCE_GUID']]}));});
}  

到目前为止,我仅完成了从数据库到UI的完整数据流,这仅仅是CRUD中的字母“R”。具体数据流可描述如下:

DB(relations) →JSON-On-Relations(server-side JS) →FormGroup(client-side JS) →UI(HTML).

依靠JOR,我省去了很多数据库和应用服务器上的工作。它使得我将精力集中在建模和UI上。接下来,我将完成字母“U”的操作,即更新用户对象。

UI to Object Mapping

这是一个反向的数据流:

UI(HTML) →FormGroup(client-side JS) →JSON-On-Relations(server-side JS) →DB(relations).

在数学中,我们有很多例子从一个方向上计算很容易,但其逆运算就变得非常困难,例如平方计算和开方计算。“读取(R)”和“更新(U)”也是这样一种关系。与“R”相比,“U”得花更多精力去实现。这些额外的精力主要花在数据校验,错误处理,并发控制,工作保护等等。

但是在着手开发“更新”之前,我们还得先完成编辑模式和显示模式之间的切换。在一些简单的CRUD演示中,您未必会看到有编辑模式与显示模式之分。这就是我前面所说的那些技术布道者所掩盖的复杂度之一。他们只想告诉您使用某些工具开发会有多么的容易,却会常常忽略实际应用中一些非常重要的特性。就像我前面说的那样,CRUD应用是设计给多个用户并发使用的。因此,区分编辑模式和显示模式是非常重要的。这有助于检查否通过必要的权限检查以及并发控制。

有些应用甚至使用不同的UI控件和设计来区分编辑模式和显示模式。通常,这是为了获得更好的用户体验。例如,在显示模式下,下拉框将被替换成普通输入框,这样让UI显得更为简洁。在此示例中,为避免一些复杂度,我会使用相同的UI控件和设计。为此,我需要将“readonly”属性添加到所有可被编辑的UI控件上,并将其值绑定到一个变量上。棘手的是,对于某些控件(如复选框和单选框),它们是不支持“readonly”属性的。因此,我必须对它们特殊照顾。还有一部分是我自找的麻烦,例如,我对LIST和TABLE控件也有一些特殊处理,自动删除无效行以及自动添加新空行。取决于您的UI复杂度,在实现这两个UI状态切换上所耗费的精力可能会有很大的不同。

除了在UI层面上的工作外,还有一些服务端逻辑也需要被关注。从显示模式切换到编辑模式时,将按序执行以下逻辑:

  1. 检查用户是否有权限修改该实例;
  2. 检查是否另一个用户在同时编辑该实例;
  3. 将UI切换成编辑模式。

从编辑模式切换到显示模式时,将按序执行以下逻辑:

  1. 检查该实例是否已经被修改过,如果是,弹出对话框询问是保存修改还是放弃修改;
  2. 释放并发锁,以便其他用户能修改;
  3. 将UI切换成显示模式。

做完两种模式的切换,接下来可着手数据校验相关的开发。校验是无止境的。你可以对单个字段的值进行校验,也可以对多个字段组合后进行校验。为了数据的质量,您可以轻松地想出很多校验规则,但是我们还是需注意投入产出比。过多的数据校验除了增加您的开发成本外,还会破坏性能和用户友好度。这里,我还简单给出一个结论:大多数校验都是关于数据值域的,您可以在客户端去实现,也可以在服务器端实现。

客户端实现校验的成本较低。因此,尽可能使用客户端校验。Angular提供了一些现成的校验函数,例如:required,maxLength,minLength,email等。但是,客户端校验只能涵盖一小部分。大多数情况下,数据校验需根据上下文。而上下文只能在应用服务器端,因此服务端校验是不可避免的。

我的第一个校验逻辑写在“USER_NAME”字段上。USER_NAME必须是唯一的,因此我必须确保用户输入的NAME或者ID在系统范围内是唯一的。还要考虑何时触发校验? 是输入值后马上校验,还是在点保存按钮后? 答案总是越早越好。为此,我需要实现了一个异步校验功能,并将其分配给“USER_NAME”这个FormControl。

const userNameCtrl = this.userForm.get('userBasic.names.USER_NAME') as FormControl;
userNameCtrl.setAsyncValidators(existingUserNameValidator(this.identityService, this.messageService, this.userForm.get('USER_ID').value));
...export function existingUserNameValidator(identityService: IdentityService,messageService: MessageService,userID: string): AsyncValidatorFn {return (control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> => {return timer(500).pipe(switchMap( () => identityService.getUserByUserName(control.value).pipe(map(data => {if (data['r_user'] && data['r_user'][0]['USER_ID'] !== userID) {return {message: messageService.generateMessage('USER', 'USER_NAME_EXISTS', 'E', control.value).msgShortText};} else {return null;}}))));};
}

现在效果看起来不错。当用户在“USER_NAME”字段中输入一些字母后,它立即与服务端进行通讯,以检查已输入值是否存作为用户名已存在。您会发现我在校验函数中设置了“ timer(500)”。这意味着当用户思考时间超过半秒(500毫秒),校验才会触发。作为用户,他可以马上获得反馈,无需执行额外操作。

用户现在是高兴了,唯一的问题是成本。为了实现这样的校验,您需要服务端提供专门的服务调用点。幸运的是,在这个示例中,我可以直接调用JOR提供的服务,而无需任何服务端编码。但是我也可以预见,很多情况下服务端编码是不可避免的。并且这类服务可能仅用于UI校验。这里,我想提两个问题:

问题1:是否所有UI层面上的校验都应在服务端重复实现? 我的回答是:“是”。因为这些校验试图尽早的将错误反馈给用户,但它们无法取代服务端的校验。 它们只负责UI的输入,并不能保证数据从其他渠道(例如API)输入的准确性。无论如何,你都得在服务端再次实施同样的校验。

问题2:是否应将服务端校验尽可能嵌入到数据模型中?我的答案是:“否”。我知道这一次的回答更具争议。众所周知,数据库提供了一些数据一致性检查的功能,例如主键检查和外键检查。为什么不利用这些功能呢? 我的经验告诉我,数据模型在诞生时并不完美,它们在生命周期内经常被调整。这就意味着数据模型中添加的业务逻辑越多,调整它们的成本将会越高。想象一下,您要调整一个外键所要付出的数据转换成本。而如果将这种校验逻辑与数据模型分开,则可以在调整模型时获得更大的灵活性。我并不是建议您完全不用它们,只是建议要三思。
Validation Error
除了在字段旁边显示错误提示外,还有一些实例级别的校验消息需要被显示。并不仅仅是错误消息,还有警告消息,告知消息和成功消息。此外,消息既要有简明扼要的短文本,还得有详尽的原因解释和解决方案的长文本。它得支持多种语言。可能有的消息是需要在客户端维护,有的要在服务端维护。最后,您会发现您需要一个消息框架来满足所有这些需求。因此,我创建了UI-Message,并将之应用于这个示例中。

当所有校验通过后,就可以将数据保存到数据库中了。我需要调用JOR的RESTful API去更改实例。该API要求我们提供一个与建模对象类似的JSON对象。在每个Relation元组上需要添加一个“action”属性,以指示对该元组所要执行的操作。它的值可以是“add”,“delete”, 或者“update”。

Angular FormGroup做得不错,它通过“dirty”属性可以知道哪些字段被修改过,这让我用起来很舒服。这样我们就可以从更改后的FormGroup对象来构建JOR所需要的JSON对象了。从UI对象到服务端对象,再到数据库,这样的映射编码通常既无聊又容易出错。这就解释了为什么ORM总有其生存空间。但我不喜欢的是,它们总爱干一些画蛇添足的事情。实际上,我们需要的只是映射。

_composeChangesToUser() {this.changedUser['ENTITY_ID'] = 'person';this.changedUser['INSTANCE_GUID'] = this.instanceGUID;const userBasicFormGroup = this.userForm.get('userBasic');const userID = this.userForm.get('USER_ID').value;if (userBasicFormGroup.dirty) {const userBasicNamesFormGroup = userBasicFormGroup.get('names') as FormGroup;this.changedUser['r_user'] = this.uiMapperService.composeChangedRelation(userBasicNamesFormGroup, {USER_ID: userID}, this.isNewMode);const userBasicEmployeeFormGroup = userBasicFormGroup.get('employee') as FormGroup;this.changedUser['r_employee'] = this.uiMapperService.composeChangedRelation(userBasicEmployeeFormGroup, {USER_ID: userID}, this.isNewMode);}const userEmailFormArray = this.userForm.get('emails') as FormArray;this.changedUser['r_email'] = this.uiMapperService.composeChangedRelationArray(userEmailFormArray, this.originalUserValue['emails'], {EMAIL: null});const userAddressFormArray = this.userForm.get('addresses') as FormArray;this.changedUser['r_address'] = this.uiMapperService.composeChangedRelationArray(userAddressFormArray, this.originalUserValue['addresses'], {ADDRESS_ID: null});const userPersonalizationFormGroup = this.userForm.get('userPersonalization') as FormGroup;this.changedUser['r_personalization'] = this.uiMapperService.composeChangedRelation(userPersonalizationFormGroup, {USER_ID: userID}, !userPersonalizationFormGroup.get('USER_ID').value);const userRoleFormArray = this.userForm.get('userRole') as FormArray;const relationship = this.uiMapperService.composeChangedRelationship('rs_user_role',[{ENTITY_ID: 'permission', ROLE_ID: 'system_role'}],userRoleFormArray, this.originalUserValue['userRole'], ['NAME', 'DESCRIPTION']);if (relationship) {this.changedUser['relationships'] = [relationship]; }
}

使用JOR提供的“ UiMapperService”,我们可以轻松地从FormGroup对象构建RESTful API所要的JSON对象。“UiMapperService”提供了3种方法:

  1. composeChangedRelation:将FormGroup转换为单元组Relation;
  2. composeChangedRelationArray:将FormArray转换为多元组Relation;
  3. composeChangedRelationship:将FormArray转换为Relationship。

还有一种简单粗暴的更新方法,即先删除原有的,再重新插入。这样开发人员无需费心跟踪哪些字段被改了,每次提交更新请求,执行的是完全覆盖。 这非常适用于简单的实体。除了缺少一些优雅性以及性能损耗外,这种全覆盖的方式还有一些局限性。例如,一个实体的主码可能用了UUID或者流水号,全覆盖的操作可能会导致其主码的变更。

最后的步骤很简单,只需调用RESTful API即可,让JOR帮助您完成与数据库的映射工作。如果保存成功,它会返回一个更新后的对象;若保存失败,它则返回具体的错误消息。在下面的代码片段中,“saveUser”方法通过检查JSON对象是否具有“INSTANCE_GUID”来区分是“更新”操作,还是“新建”操作。分别对应RESTful方法“put”和“post”。

saveUser(user: Entity): Observable<Entity | Message[]> {if (user['INSTANCE_GUID']) {return this.http.put<Entity | Message[]>(this.originalHost + `/api/entity`, user, httpOptions).pipe(catchError(this.handleError<any>('saveUser')));} else {return this.http.post<Entity | Message[]>(this.originalHost + `/api/entity`, user, httpOptions).pipe(catchError(this.handleError<any>('saveUser')));}
}

我们似乎已经攻克了最困难的一条路径,即CRUD中的“U”。然而我们还遗留了一个尾巴,即工作保护。想象一下,当您正在编辑某个对象时,不小心点了浏览器的“返回”按钮,你会期待什么?您希望应用程序通过弹框的方式询问是否真的要退出当前编辑状态。这使我们进入下一个大问题:导航。

导航

导航是很难做的。幸运的是,Angular提供了很大的帮助。我们这个示例只有2页,即使这样,我仍在导航上花费了一番精力。

在着手实现导航之前,我需要先完成“搜索和列表”页面。这个页面没有“更新”的需求,比起制作“详细”页面来,开发它要容易得多。更幸运的是,我还是不需要任何服务端编码,因为JOR已经为我提供了generic query API。

searchUsers(userID: string, userName: string): Observable<UserList[] | Message[]> {const queryObject = new QueryObject();queryObject.ENTITY_ID = 'person';queryObject.RELATION_ID = 'r_user';queryObject.PROJECTION = ['USER_ID', 'USER_NAME', 'DISPLAY_NAME', 'LOCK', 'PWD_STATE'];queryObject.FILTER = [];if (userID) {if (userID.includes('*')) {userID = userID.replace(/\*/gi, '%');queryObject.FILTER.push({FIELD_NAME: 'USER_ID', OPERATOR: 'CN', LOW: userID});} else {queryObject.FILTER.push({FIELD_NAME: 'USER_ID', OPERATOR: 'EQ', LOW: userID});}}if (userName) {if (userName.includes('*')) {userName = userName.replace(/\*/gi, '%');queryObject.FILTER.push({FIELD_NAME: 'USER_NAME', OPERATOR: 'CN', LOW: userName});} else {queryObject.FILTER.push({FIELD_NAME: 'USER_NAME', OPERATOR: 'EQ', LOW: userName});}}queryObject.SORT = ['USER_ID'];return this.http.post<any>(this.originalHost + `/api/query`, queryObject, httpOptions).pipe(catchError(this.handleError<any>('searchObjects')));
}

我只需要编写一个“queryObject”。它由一个目标entity,一个主relation,列表字段,过滤条件,和排序字段构成。由于我只允许在“USER_ID”和“USER_NAME”这两个字段上加过滤条件,因此我为它们编写了一些特殊逻辑。如果用户要进行通配符“*”搜索,则将“*”替换为“%”,因为我的数据库(mysql)仅将“%”识别为通配符。
Search&List Page with Navigation
“搜索和列表”页面具有3个导航路径:
1.单击“用户ID”链接或“显示”按钮将以显示模式导航到详细页面;
2.单击“更改”按钮将以编辑模式导航到详细页面;
3.单击“新建”按钮将以新建模式导航到详细页面。

在网页应用中,导航由URL驱动。我实际上花费了一番心思来设计我的URL。我将“/users”路由到“搜索与列表”页面,将“/users/:userID”路由到“详细”页面。再加一个参数“action”以表示不同的模式。例如:“/users/DH001;action=change”将在编辑模式下导航到用户“DH001”。利用Angular的路由模块,我设计了如下的路由表:

const routes: Routes = [{ path: 'users', component: UserListComponent},{ path: 'users/:userID', component: UserDetailComponent, canDeactivate: [WorkProtectionGuard]},{ path: 'errors', component: ErrorPageComponent },{ path: 'pageNotFound', component: NotFoundComponent },{ path: '**', component: NotFoundComponent }
];

除了前两个路径外,我还有两个用于错误显示和找不到页面的路由。如果在浏览器的地址栏中输入了无效的路径,则会路由到最后一个路径:“pageNotFound”。

注意看第二条路径有一个附加属性“canDeactivate”。我为其定义了工作保护逻辑。当导航离开“详细”页面时,这段逻辑会检查对象是否已经修改过,然后弹出对话框询问:“是否放弃更改?”。

还有一个问题,从“详细”页面返回“搜索和列表”页面时。默认情况下,Angular会触发重新加载,而这并不是我想要的。为了避免这种情况,我实现了自己的RouteReuseStrategy。

// In app.module.ts
providers: [{provide: RouteReuseStrategy, useClass: CustomReuseStrategy}
]// In custom.reuse.strategy.ts
import {ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy} from '@angular/router';export class CustomReuseStrategy implements RouteReuseStrategy {routesToCache: string[] = ['users'];storedRouteHandles = new Map<string, DetachedRouteHandle>();shouldDetach(route: ActivatedRouteSnapshot): boolean {return this.routesToCache.indexOf(route.routeConfig.path) > -1;}store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {this.storedRouteHandles.set(route.routeConfig.path, handle);}shouldAttach(route: ActivatedRouteSnapshot): boolean {return this.storedRouteHandles.has(route.routeConfig.path);}retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {return this.storedRouteHandles.get(route.routeConfig.path);}shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {return future.routeConfig === curr.routeConfig;}
}

该自定义路由策略的主要思想是缓存“搜索和列表”页面(/users)。但是,背后的真正问题是:导航回前一个页面时是否应该重新加载页面?好吧,答案肯定是取决于你的需要。 也正因为有这么多的变数,使得导航实现起来异常的困难。

以这个示例来说,如果我想让“详细”页面上的变动(更改用户名)同时影响到“搜索与列表”页面,那么我就应该在返回时重新加载页面。 基于这种需求,我要做的不是缓存整个页面,而是只缓存搜索条件,并重新执行搜索。 然而,如果用户名的变化会导致搜索结果的变化,由此产生的副作用是修改后的条目可能不出现在列表中了,这样对用户来说并不自然。相反如果不执行搜索,而只是调整列表的显示值,这在逻辑上又说不过去。如果考虑更复杂的情况,不仅是搜索条件,还包括列宽,位置,排序字段等。所有这些考虑加起来会让您发疯。

现在该是处理最后两个字母“C”和“D”了。我只需利用导航来实现新用户的创建。当单击“新建”按钮时,它将导航到路径“/users/;action=new”。在“详细”页面中,我会检查参数“action”的值,如果是“new”,则创建一个空的实例对象。这样,我就可以重用编辑模式的逻辑。

ngOnInit() {this.route.paramMap.pipe(switchMap((params: ParamMap) => {this.action = params.get('action');if (this.action === 'new') {this.isNewMode = true;return this._createNewUser();} else {this.isNewMode = false;return this.identityService.getUserDetail(params.get('userID'));}})).subscribe( data => {if ('ENTITY_ID' in data) {this.instanceGUID = data['INSTANCE_GUID'];this._generateUserForm(<Entity>data);if (this.isNewMode || this.action === 'change') {this._switch2EditMode();} else {this._switch2DisplayMode();}} else {const errorMessages = <Message[]>data;errorMessages.forEach( msg => this.messageService.add(msg));}});
}..._createNewUser(): Observable<Entity> {const userDetail = new Entity();userDetail['ENTITY_ID'] = 'person';userDetail['r_user'] = [{ USER_ID: '', LOCK: 0, PWD_STATUS: '', USER_NAME: '', DISPLAY_NAME: '',GIVEN_NAME: '', MIDDLE_NAME: '', FAMILY_NAME: ''}];userDetail['r_employee'] = [{TITLE: '', DEPARTMENT_ID: '', COMPANY_ID: '', GENDER: ''}];userDetail['r_email'] = [];userDetail['r_personalization'] = [{USER_ID: '', LANGUAGE: '', TIMEZONE: '', DECIMAL_FORMAT: '', DATE_FORMAT: ''}];userDetail['relationships'] = [];return of(userDetail);
}

要删除一个用户,我需要在“搜索和列表”页面上实现一个确认对话框。当用户单击删除按钮时,该确认框会弹出,点击“确认”会执行真正的删除操作。再度依靠JOR,我无需编写任何服务端代码就实现了删除操作。
Delete User
至此,CRUD已全部完成。但是我能自信地说:这个应用可以投入生产使用了吗? 答案是:“否”。因为我们还没有仔细测试过。 尽管在开发过程中,我进行了一些碎片化的测试,但这是远远不够的。 只要想想那么多的按钮、字段、导航路径,以及以不同的顺序组合它们,你就无法自信的说这个应用没有Bug。 您也无法预期真正的用户将如何使用您的应用程序。因此,我们必须仔细有效地对其进行测试。

测试

测试的重要性不言而喻。 但是,如何有效地进行测试已经变成一个有争议的话题。尽管如此,我还是按照自己的方式去做测试。 唯一重要的是提高我对这个应用的自信。

回想一下整个过程,我从没有在服务端写过任何东西,看来我只需要关注UI层。
基于Angular的测试手册,我尝试了它提供的几个测试工具。

首先,我认为我无须编写任何Service tests。我所有的Service都非常简单。在它们被第一次成功调用之后,再无必要花费时间去测试它们了。

接着,我写了一些Component Class tests,并发现这无助于我获得自信。尽管我的大部分代码都位于Component Class中,但首先,我认为没有必要为其编写测试代码以证明这些数据映射和转换逻辑是正确的。我的意思是看看那些简单的“if else”和“loop”语句,我真的需要花费这些力气吗?其次,就算写了这些测试代码并完成了100%的覆盖率,这又说明不了任何问题。我真正关心的是整条数据链路上的逻辑结合在一起是否运行正确。

我还尝试了Component DOM testing。乍一看,我似乎应该投资它。但是,经过一番尝试,我发现这个工具也不值得投入。不仅仅是由于陡峭的学习曲线,更是因为那种花巨大成本去模拟一个虚拟运行时的思想让我觉得匪夷所思。对于我这样的CRUD应用来说,浪费时间去模拟一些技术性很强的东西(例如:HTTP服务,数据库服务等)显得非常不划算。有这个时间去投资一个不可信的虚拟环境,我更愿意搭建一套可信的真实测试环境。我无法臆断是否其他类型的软件项目适用这种mock-up测试理念,但我相信它绝对不适于CRUD类应用软件项目。

最终,我发现适合我的最佳测试工具是E2E测试。尽管Angular团队似乎更推荐Component DOM testing,我对基于Protractor的E2E测试框架感到非常满意。

我在“e2e”文件夹中放置了两个文件:页面对象“user.po.ts”和e2e脚本“user.e2e-spec.ts”。在“user.po.ts”中,我模拟了“搜索和列表”页面中执行的各种操作,例如:导航、单击按钮、输入值,并获取返回结果。

  navigateToSearch() {return browser.get('/users');}fillUserID(userID: string = 'anonymous') {element(by.css('[name="user_id"]')).sendKeys(userID);}clickSearchButton() {element(by.id('search')).click();}getSearchResultList() {return element.all(by.tagName('tr'));}

在“user.e2e-spec.ts”中,我构造了“详细”页面上的操作以形成对应的e2e场景。

describe('Search&List Page', () => {beforeAll(() => {page.navigateToSearch();});it('should list all users when clicking button Search', () => {page.clickSearchButton();page.getSearchResultList().then((list) => expect(list.length).toBeGreaterThan(2));});it('should list a user filtered by userID', () => {page.fillUserID(); // anonymouspage.clickSearchButton();expect(page.getFirstHitUserID()).toEqual('anonymous');});}

有人认为E2E测试既困难又昂贵,我的感觉恰恰相反。您可能也发现它实际上既直观又易于维护。还有人会担心E2E脚本很脆弱。 但就我而言,它实际上很稳健。我在开发过程中经常使用E2E脚本生成测试数据,几乎不需要调整它们。我认为可能有以下几个原因:

  1. 我使用“by.id”来定位HTML元素;
  2. 页面对象和E2E脚本的分离;
  3. 稳定的数据模型和后端服务;
  4. 我一个人完成了从数据模型到UI的开发。

根据不同的软件类型,您应谨慎地选择测试方法和工具。对于CRUD类应用来说,我认为E2E测试是最合适的。这与构建框架、库、算法等不一样,您带来的价值主要是数据的映射和UI外观。有人可能会说UI开发人员和后端开发人员通常是两个不同的人。这也并非总是如此,同样取决于您所开发的软件类型。如果您开发的是企业级数据库应用,则让一个开发人员完成从后端到前端的开发会带来更好的结果。

我对实现高覆盖率并不感兴趣,我仍然会进行了大量的手工测试。例如,当我想测试从一个用户中删除一个角色时,我会先用E2E脚本创建一个用户,然后以手工测试的方式进行角色的删减操作。这会节省我大量的时间。等删除角色的功能稳定后,我会将该操作添置到E2E脚本中。

最重要的是要提升你对软件的信心。

结语

在这篇絮絮叨叨的长文中,我回顾了开发一个CRUD应用的整个过程。正如开始所说,这并不是一个容易的过程。 尽管我利用了很多框架,但如果不想偷工减料的话,仍然需要付出很多精力。每当我开发一个新的CRUD应用(即使在同一堆栈上)时,我总是会产生一些不一样的想法。

在早期,UI是通过服务端呈现的,数据对象也在服务端。因此,我们需要一个强大的会话管理框架来容纳它们。如今,借助Web技术,数据对象可存于客户端,我们开始弱化服务端的会话管理。一直不变的是对象模型与关系模型之间的映射。

尽管在本示例中,我利用JOR避免了服务端程序的开发,我也可以预料在很多情况下它提供的现成RESTful API是无法满足的。现实情况是我们几乎不可避免地要编写服务端逻辑。这是因为一个业务对象是无法独立存在的,它必须与其他对象建立起关系才能使得整个业务系统变得有机。

我的小应用程序还远没到完成的程度。如果您想让它成为一个企业级CRUD应用产品,它还需要拥有以下特性:

  1. 搜索帮助。 例如,“COMPANY”字段最好是一个下拉框。“DEPARTMENT”字段也应该是一个下拉框,其值取决于“COMPANY”的值。“ROLE”字段应提供搜索帮助框,以方便用户进行查询和选择。
  2. 权限检查。 应就实例对象的CRUD操作进行权限划分。
  3. 并发控制。 当一个用户正在编辑某实例时,另一个用户需被告知无法编辑,只能查看该实例。 也可使用ETAG等乐观锁机制。
  4. 多语言支持。 标签、标题、按钮文本等应支持多语言。 并以用户的登录语言来显示它们。

以上仅仅是随便举了几个,还有很多尚未提及,例如响应式网页设计等。不过,我认为好的框架确实会提供很大帮助。 就像我在此例中使用到的框架有:Angular,Bootstrap,JSON-On-Relations和UI-Message。它们都是开源的,很容易获取。

另一样可以提供很大帮助的是模式样例。对于一些成熟的专有平台,除了现成的框架,服务和库之外,它还提供了许多模式样例供参考。有了这些模式样例,您可以既快又好地构建CRUD应用。我相信当我在相同的技术堆栈上构建第二个CRUD应用时,会更快更好。

这也是我写这篇博客的主要目的。 我希望它也可以为您提供一种真正的模式样例,包括一些标准和成本上的参考。

查看全文
如若内容造成侵权/违法违规/事实不符,请联系编程学习网邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!

相关文章

  1. 个人网站搭建(Day 9)— Bootstrap4 相关问题

    我们这个网站项目是基于 django bootstrap 框架搭建的&#xff0c;其中的 bootstrap 是美国Twitter公司推出的基于 HTML、CSS、JavaScript 开发的简洁、直观、强悍的前端开发框架&#xff0c;使得 Web 开发更加快捷。 在django 中使用bootstrap很简单&#xff0c;使用 pip in…...

    2024/5/1 6:33:56
  2. pptp服务器的账号密码格式,PPTP服务器配置选项详解

    导读PPTP服务器配置文件的格式与其它许多Unix程序相似&#xff0c;每一行包含一项配置内容&#xff0c;以配置选项名称开始&#xff0c;后面紧跟参数值或者关键字&#xff0c;它们之间用空格分隔。在读取配置文件时&#xff0c;pptpd进程将忽略空行和每一行“#”后面的注释。PP…...

    2024/4/23 9:59:00
  3. 前端框架UI选择

    最近要做一个企业的OA系统&#xff0c;以前一直使用EasyUI&#xff0c;一切都好&#xff0c;但感觉有点土了&#xff0c;想换成现在流行的Bootstrap为基础的后台UI风格&#xff0c;想满足的条件应该达到如下几个&#xff1a; 1、美观、大方、简洁 2、兼容IE8、不考虑兼容IE6/IE…...

    2024/4/23 20:46:48
  4. devise tree使用_使用Devise和Bootstrap在Rails上设置Angular SPA

    devise tree使用This article was originally published at jessenovotny.com. 本文最初发表在jessenovotny.com上 。 When I started programming my very first Angular single page application (SPA), I noticed the resources for setup and integration with Devise to …...

    2024/4/23 4:13:21
  5. Angular和.NET Core Web API入门应用程序

    下载源160.2 KB 您可以在此处查看此项目的源代码和最新更新 这是Angular/.NET Core Web API入门应用程序&#xff0c;具有添加、编辑和删除客户的基本功能&#xff0c;因此您可以将其用作构建应用程序的起点。它使用以下框架&#xff1a; Angular MaterialBootstrap.NET Core…...

    2024/4/23 20:26:03
  6. Spring Boot(九)Spring Boot中使用Bootstrap和AngularJS

    你好&#xff0c;【程序职场】专注于&#xff1a;Spring Boot &#xff0c;微服务 和 前端APP开发&#xff0c;闲暇之余一起聊聊职场规划&#xff0c;个人成长&#xff0c;还能带你一起探索 副业赚钱渠道&#xff0c;在提升技术的同时我们一起交流 敏捷流程 提高工作效率&#…...

    2024/4/23 13:02:56
  7. angular学习之路18-ngmodule

    1,NgModule简介 NgModules 用于配置注入器和编译器&#xff0c;并帮你把那些相关的东西组织在一起。 NgModule 是一个带有 NgModule 装饰器的类。 NgModule 的参数是一个元数据对象&#xff0c;用于描述如何编译组件的模板&#xff0c;以及如何在运行时创建注入器。 它会标出…...

    2024/4/23 5:39:17
  8. 后台管理UI的选择

    最近要做一个企业的OA系统&#xff0c;以前一直使用EasyUI&#xff0c;一切都好&#xff0c;但感觉有点土了&#xff0c;想换成现在流行的Bootstrap为基础的后台UI风格&#xff0c;想满足的条件应该达到如下几个&#xff1a;1、美观、大方、简洁 2、兼容IE8、不考虑兼容IE6/IE7…...

    2024/4/24 0:21:10
  9. Angular 7 和 .Net Core 2.2——全球天气(第1部分)

    目录 介绍 设置Angular CLI环境 先决条件 npm包管理器 从Visual Studio 2017创建Asp.Net核心Web项目 使用Angular CLI创建天气客户端 天气信息REST API 天气组件 Angular 路由 反应表单(Reactive Form) 在Angular App中使用Bootstrap 4样式 Angular 服务 Angular …...

    2024/4/30 18:06:41
  10. InputStreamWrite:字符流

    两种读取方法如下所示:package cn.zll.demo;import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStreamReader;public class WriteDemo {public static void main(String[] args) throws IOException {…...

    2024/4/26 21:23:55
  11. angular学习之路3-架构

    1&#xff0c; 架构概览 Angular 的基本构造块是 NgModule&#xff0c;它为组件提供了编译的上下文环境。 NgModule 会把相关的代码收集到一些功能集中。Angular 应用就是由一组 NgModule 定义出的。 应用至少会有一个用于引导应用的根模块&#xff0c;通常还会有很多特性模块…...

    2024/4/23 18:08:38
  12. angular学习记录

    1 angular项目文件解析 创建一个angular项目ng new demo&#xff0c;会比较慢&#xff0c;等等 组件可以理解为一段带有业务逻辑和数据的html 2 组件文件的解析 组件控制屏幕上被称为视图的一小片区域。组件通过一些由属性和方法组成 //从angular核心库导入Component装饰…...

    2024/4/23 6:14:35
  13. Angular学习笔记(第四天)

    一、Ionic的使用 1.概述&#xff1a; Ionic是基于angular的移动端的ui组件库&#xff1a; &#xff08;在后续的升级版本中&#xff0c;也在支持以cdn的方式&#xff0c;支持vue、react、js&#xff0c;但是这个新版本手册不够友好&#xff0c;还有些问题&#xff09; ionic…...

    2024/4/26 10:54:49
  14. angular2--下拉更新和上拉加载更多

    效果如下&#xff1a; &#xff08;1&#xff09;页面往下拉做刷新页面操作 &#xff08;2&#xff09;页面往上滑到底部时&#xff0c;加载更多数据## 标题 html <ons-page><ons-toolbar><div class"left"></div><div class"cen…...

    2024/4/26 10:50:52
  15. 【Angular 6】滚动列表组件的封装

    前言 学习应为input和output相结合之过程&#xff0c;这就是写这篇文章的原因。在大屏幕展示web APP中&#xff0c;经常会用到滚动列表。经过几次尝试&#xff0c;确定了一个还不错的思路。 需求 列表表头thead部分静止&#xff0c;而tbody部分向上滚动。tbody部分滚动结束之后…...

    2024/4/21 3:13:22
  16. 微服务的脚手架Jhipster集成angular和vue的使用

    JHipster简介 JHipster或者称Java Hipster&#xff0c;是一个应用代码产生器&#xff0c;能够创建Spring Boot AngularJS的应用。开源项目地址&#xff1a;JHipster/Github。  JHipster使用Node.js和Yeoman产生Java应用代码&#xff0c;使用Maven(Gradle)运行产生的代码&…...

    2024/4/21 3:13:21
  17. Angular-3种创建动态内容的方式

    写在最前&#xff0c;本文提到的“模板”都是ng-template&#xff1b;假设的模态组件也只是实现模态内容&#xff1b;为了缩减文章的篇幅&#xff0c;只保留了重要的部分。完整的例子在线上。在开发过程中&#xff0c;难免会遇到公共组件需要Input模板或input组件的时候&#x…...

    2024/4/21 3:13:21
  18. Angular:nz-table中显示横向滚动条

    首先&#xff0c;可以参考一下ant-design官方文档中对于横向滚动条的用法&#xff08;这里参考1.8.1版本的固定列方法&#xff09;&#xff1a; import { Component } from angular/core;Component({selector: nz-demo-table-fixed-columns,template: <nz-table #nzTable […...

    2024/4/21 3:13:19
  19. 原生、混合、react-native应用对比分析

    原生开发、纯网页开发&#xff08;H5开发&#xff09;/混合开发&#xff08;H5原生&#xff09;、React-Native开发 原生开发是系统自带的app开发方式&#xff0c;也是大部分人最熟悉app开发的技术&#xff0c;如android、ios、wp。H5开发是Html5开发的app&#xff0c;本质上运…...

    2024/4/20 9:47:30
  20. Angular2中echarts的使用(饼图)

    ECharts (以饼图为例) ECharts&#xff0c;一个使用 JavaScript 实现的开源可视化库&#xff0c;可以流畅的运行在 PC 和移动设备上&#xff0c;兼容当前绝大部分浏览&#xff08;IE8/9/10/11&#xff0c;Chrome&#xff0c;Firefox&#xff0c;Safari等&#xff09;&#xff…...

    2024/4/21 3:13:17

最新文章

  1. linux的常见命令

    &#x1f4dd;个人主页&#xff1a;五敷有你 &#x1f525;系列专栏&#xff1a;Linux ⛺️稳中求进&#xff0c;晒太阳 Linux中检查进程是否存在&#xff1a; ps -ef | grep [进程名或进程ID] pgrep -f [进程名|进程ID] pidof [进程名] Linux中检查某个端口是否被…...

    2024/5/2 8:42:47
  2. 梯度消失和梯度爆炸的一些处理方法

    在这里是记录一下梯度消失或梯度爆炸的一些处理技巧。全当学习总结了如有错误还请留言&#xff0c;在此感激不尽。 权重和梯度的更新公式如下&#xff1a; w w − η ⋅ ∇ w w w - \eta \cdot \nabla w ww−η⋅∇w 个人通俗的理解梯度消失就是网络模型在反向求导的时候出…...

    2024/3/20 10:50:27
  3. 自动化标准Makefile与lds

    makefile的自动化&#xff0c;需要使用变量&#xff0c;以及自动变量。 实行命令行与参数的分离。 命令行只与变量打交道&#xff0c;而变量则携带不同的参数&#xff0c;这样&#xff0c;通过修改变量&#xff0c;命令的执行结果不同。 可以简单理解为&#xff0c;命令行是个…...

    2024/4/30 2:45:52
  4. Android Framework学习笔记(2)----系统启动

    Android系统的启动流程 启动过程中&#xff0c;用户可控部分是framework的init流程。init是系统中的第一个进程&#xff0c;其它进程都是它的子进程。 启动逻辑源码参照&#xff1a;system/core/init/main.cpp 关键调用顺序&#xff1a;main->FirstStageMain->SetupSel…...

    2024/5/2 2:35:59
  5. 【外汇早评】美通胀数据走低,美元调整

    原标题:【外汇早评】美通胀数据走低,美元调整昨日美国方面公布了新一期的核心PCE物价指数数据,同比增长1.6%,低于前值和预期值的1.7%,距离美联储的通胀目标2%继续走低,通胀压力较低,且此前美国一季度GDP初值中的消费部分下滑明显,因此市场对美联储后续更可能降息的政策…...

    2024/5/1 17:30:59
  6. 【原油贵金属周评】原油多头拥挤,价格调整

    原标题:【原油贵金属周评】原油多头拥挤,价格调整本周国际劳动节,我们喜迎四天假期,但是整个金融市场确实流动性充沛,大事频发,各个商品波动剧烈。美国方面,在本周四凌晨公布5月份的利率决议和新闻发布会,维持联邦基金利率在2.25%-2.50%不变,符合市场预期。同时美联储…...

    2024/4/30 18:14:14
  7. 【外汇周评】靓丽非农不及疲软通胀影响

    原标题:【外汇周评】靓丽非农不及疲软通胀影响在刚结束的周五,美国方面公布了新一期的非农就业数据,大幅好于前值和预期,新增就业重新回到20万以上。具体数据: 美国4月非农就业人口变动 26.3万人,预期 19万人,前值 19.6万人。 美国4月失业率 3.6%,预期 3.8%,前值 3…...

    2024/4/29 2:29:43
  8. 【原油贵金属早评】库存继续增加,油价收跌

    原标题:【原油贵金属早评】库存继续增加,油价收跌周三清晨公布美国当周API原油库存数据,上周原油库存增加281万桶至4.692亿桶,增幅超过预期的74.4万桶。且有消息人士称,沙特阿美据悉将于6月向亚洲炼油厂额外出售更多原油,印度炼油商预计将每日获得至多20万桶的额外原油供…...

    2024/4/30 18:21:48
  9. 【外汇早评】日本央行会议纪要不改日元强势

    原标题:【外汇早评】日本央行会议纪要不改日元强势近两日日元大幅走强与近期市场风险情绪上升,避险资金回流日元有关,也与前一段时间的美日贸易谈判给日本缓冲期,日本方面对汇率问题也避免继续贬值有关。虽然今日早间日本央行公布的利率会议纪要仍然是支持宽松政策,但这符…...

    2024/4/27 17:58:04
  10. 【原油贵金属早评】欧佩克稳定市场,填补伊朗问题的影响

    原标题:【原油贵金属早评】欧佩克稳定市场,填补伊朗问题的影响近日伊朗局势升温,导致市场担忧影响原油供给,油价试图反弹。此时OPEC表态稳定市场。据消息人士透露,沙特6月石油出口料将低于700万桶/日,沙特已经收到石油消费国提出的6月份扩大出口的“适度要求”,沙特将满…...

    2024/4/27 14:22:49
  11. 【外汇早评】美欲与伊朗重谈协议

    原标题:【外汇早评】美欲与伊朗重谈协议美国对伊朗的制裁遭到伊朗的抗议,昨日伊朗方面提出将部分退出伊核协议。而此行为又遭到欧洲方面对伊朗的谴责和警告,伊朗外长昨日回应称,欧洲国家履行它们的义务,伊核协议就能保证存续。据传闻伊朗的导弹已经对准了以色列和美国的航…...

    2024/4/28 1:28:33
  12. 【原油贵金属早评】波动率飙升,市场情绪动荡

    原标题:【原油贵金属早评】波动率飙升,市场情绪动荡因中美贸易谈判不安情绪影响,金融市场各资产品种出现明显的波动。随着美国与中方开启第十一轮谈判之际,美国按照既定计划向中国2000亿商品征收25%的关税,市场情绪有所平复,已经开始接受这一事实。虽然波动率-恐慌指数VI…...

    2024/4/30 9:43:09
  13. 【原油贵金属周评】伊朗局势升温,黄金多头跃跃欲试

    原标题:【原油贵金属周评】伊朗局势升温,黄金多头跃跃欲试美国和伊朗的局势继续升温,市场风险情绪上升,避险黄金有向上突破阻力的迹象。原油方面稍显平稳,近期美国和OPEC加大供给及市场需求回落的影响,伊朗局势并未推升油价走强。近期中美贸易谈判摩擦再度升级,美国对中…...

    2024/4/27 17:59:30
  14. 【原油贵金属早评】市场情绪继续恶化,黄金上破

    原标题:【原油贵金属早评】市场情绪继续恶化,黄金上破周初中国针对于美国加征关税的进行的反制措施引发市场情绪的大幅波动,人民币汇率出现大幅的贬值动能,金融市场受到非常明显的冲击。尤其是波动率起来之后,对于股市的表现尤其不安。隔夜美国股市出现明显的下行走势,这…...

    2024/4/25 18:39:16
  15. 【外汇早评】美伊僵持,风险情绪继续升温

    原标题:【外汇早评】美伊僵持,风险情绪继续升温昨日沙特两艘油轮再次发生爆炸事件,导致波斯湾局势进一步恶化,市场担忧美伊可能会出现摩擦生火,避险品种获得支撑,黄金和日元大幅走强。美指受中美贸易问题影响而在低位震荡。继5月12日,四艘商船在阿联酋领海附近的阿曼湾、…...

    2024/4/28 1:34:08
  16. 【原油贵金属早评】贸易冲突导致需求低迷,油价弱势

    原标题:【原油贵金属早评】贸易冲突导致需求低迷,油价弱势近日虽然伊朗局势升温,中东地区几起油船被袭击事件影响,但油价并未走高,而是出于调整结构中。由于市场预期局势失控的可能性较低,而中美贸易问题导致的全球经济衰退风险更大,需求会持续低迷,因此油价调整压力较…...

    2024/4/26 19:03:37
  17. 氧生福地 玩美北湖(上)——为时光守候两千年

    原标题:氧生福地 玩美北湖(上)——为时光守候两千年一次说走就走的旅行,只有一张高铁票的距离~ 所以,湖南郴州,我来了~ 从广州南站出发,一个半小时就到达郴州西站了。在动车上,同时改票的南风兄和我居然被分到了一个车厢,所以一路非常愉快地聊了过来。 挺好,最起…...

    2024/4/29 20:46:55
  18. 氧生福地 玩美北湖(中)——永春梯田里的美与鲜

    原标题:氧生福地 玩美北湖(中)——永春梯田里的美与鲜一觉醒来,因为大家太爱“美”照,在柳毅山庄去寻找龙女而错过了早餐时间。近十点,向导坏坏还是带着饥肠辘辘的我们去吃郴州最富有盛名的“鱼头粉”。说这是“十二分推荐”,到郴州必吃的美食之一。 哇塞!那个味美香甜…...

    2024/4/30 22:21:04
  19. 氧生福地 玩美北湖(下)——奔跑吧骚年!

    原标题:氧生福地 玩美北湖(下)——奔跑吧骚年!让我们红尘做伴 活得潇潇洒洒 策马奔腾共享人世繁华 对酒当歌唱出心中喜悦 轰轰烈烈把握青春年华 让我们红尘做伴 活得潇潇洒洒 策马奔腾共享人世繁华 对酒当歌唱出心中喜悦 轰轰烈烈把握青春年华 啊……啊……啊 两…...

    2024/5/1 4:32:01
  20. 扒开伪装医用面膜,翻六倍价格宰客,小姐姐注意了!

    原标题:扒开伪装医用面膜,翻六倍价格宰客,小姐姐注意了!扒开伪装医用面膜,翻六倍价格宰客!当行业里的某一品项火爆了,就会有很多商家蹭热度,装逼忽悠,最近火爆朋友圈的医用面膜,被沾上了污点,到底怎么回事呢? “比普通面膜安全、效果好!痘痘、痘印、敏感肌都能用…...

    2024/4/27 23:24:42
  21. 「发现」铁皮石斛仙草之神奇功效用于医用面膜

    原标题:「发现」铁皮石斛仙草之神奇功效用于医用面膜丽彦妆铁皮石斛医用面膜|石斛多糖无菌修护补水贴19大优势: 1、铁皮石斛:自唐宋以来,一直被列为皇室贡品,铁皮石斛生于海拔1600米的悬崖峭壁之上,繁殖力差,产量极低,所以古代仅供皇室、贵族享用 2、铁皮石斛自古民间…...

    2024/4/28 5:48:52
  22. 丽彦妆\医用面膜\冷敷贴轻奢医学护肤引导者

    原标题:丽彦妆\医用面膜\冷敷贴轻奢医学护肤引导者【公司简介】 广州华彬企业隶属香港华彬集团有限公司,专注美业21年,其旗下品牌: 「圣茵美」私密荷尔蒙抗衰,产后修复 「圣仪轩」私密荷尔蒙抗衰,产后修复 「花茵莳」私密荷尔蒙抗衰,产后修复 「丽彦妆」专注医学护…...

    2024/4/30 9:42:22
  23. 广州械字号面膜生产厂家OEM/ODM4项须知!

    原标题:广州械字号面膜生产厂家OEM/ODM4项须知!广州械字号面膜生产厂家OEM/ODM流程及注意事项解读: 械字号医用面膜,其实在我国并没有严格的定义,通常我们说的医美面膜指的应该是一种「医用敷料」,也就是说,医用面膜其实算作「医疗器械」的一种,又称「医用冷敷贴」。 …...

    2024/4/30 9:43:22
  24. 械字号医用眼膜缓解用眼过度到底有无作用?

    原标题:械字号医用眼膜缓解用眼过度到底有无作用?医用眼膜/械字号眼膜/医用冷敷眼贴 凝胶层为亲水高分子材料,含70%以上的水分。体表皮肤温度传导到本产品的凝胶层,热量被凝胶内水分子吸收,通过水分的蒸发带走大量的热量,可迅速地降低体表皮肤局部温度,减轻局部皮肤的灼…...

    2024/4/30 9:42:49
  25. 配置失败还原请勿关闭计算机,电脑开机屏幕上面显示,配置失败还原更改 请勿关闭计算机 开不了机 这个问题怎么办...

    解析如下&#xff1a;1、长按电脑电源键直至关机&#xff0c;然后再按一次电源健重启电脑&#xff0c;按F8健进入安全模式2、安全模式下进入Windows系统桌面后&#xff0c;按住“winR”打开运行窗口&#xff0c;输入“services.msc”打开服务设置3、在服务界面&#xff0c;选中…...

    2022/11/19 21:17:18
  26. 错误使用 reshape要执行 RESHAPE,请勿更改元素数目。

    %读入6幅图像&#xff08;每一幅图像的大小是564*564&#xff09; f1 imread(WashingtonDC_Band1_564.tif); subplot(3,2,1),imshow(f1); f2 imread(WashingtonDC_Band2_564.tif); subplot(3,2,2),imshow(f2); f3 imread(WashingtonDC_Band3_564.tif); subplot(3,2,3),imsho…...

    2022/11/19 21:17:16
  27. 配置 已完成 请勿关闭计算机,win7系统关机提示“配置Windows Update已完成30%请勿关闭计算机...

    win7系统关机提示“配置Windows Update已完成30%请勿关闭计算机”问题的解决方法在win7系统关机时如果有升级系统的或者其他需要会直接进入一个 等待界面&#xff0c;在等待界面中我们需要等待操作结束才能关机&#xff0c;虽然这比较麻烦&#xff0c;但是对系统进行配置和升级…...

    2022/11/19 21:17:15
  28. 台式电脑显示配置100%请勿关闭计算机,“准备配置windows 请勿关闭计算机”的解决方法...

    有不少用户在重装Win7系统或更新系统后会遇到“准备配置windows&#xff0c;请勿关闭计算机”的提示&#xff0c;要过很久才能进入系统&#xff0c;有的用户甚至几个小时也无法进入&#xff0c;下面就教大家这个问题的解决方法。第一种方法&#xff1a;我们首先在左下角的“开始…...

    2022/11/19 21:17:14
  29. win7 正在配置 请勿关闭计算机,怎么办Win7开机显示正在配置Windows Update请勿关机...

    置信有很多用户都跟小编一样遇到过这样的问题&#xff0c;电脑时发现开机屏幕显现“正在配置Windows Update&#xff0c;请勿关机”(如下图所示)&#xff0c;而且还需求等大约5分钟才干进入系统。这是怎样回事呢&#xff1f;一切都是正常操作的&#xff0c;为什么开时机呈现“正…...

    2022/11/19 21:17:13
  30. 准备配置windows 请勿关闭计算机 蓝屏,Win7开机总是出现提示“配置Windows请勿关机”...

    Win7系统开机启动时总是出现“配置Windows请勿关机”的提示&#xff0c;没过几秒后电脑自动重启&#xff0c;每次开机都这样无法进入系统&#xff0c;此时碰到这种现象的用户就可以使用以下5种方法解决问题。方法一&#xff1a;开机按下F8&#xff0c;在出现的Windows高级启动选…...

    2022/11/19 21:17:12
  31. 准备windows请勿关闭计算机要多久,windows10系统提示正在准备windows请勿关闭计算机怎么办...

    有不少windows10系统用户反映说碰到这样一个情况&#xff0c;就是电脑提示正在准备windows请勿关闭计算机&#xff0c;碰到这样的问题该怎么解决呢&#xff0c;现在小编就给大家分享一下windows10系统提示正在准备windows请勿关闭计算机的具体第一种方法&#xff1a;1、2、依次…...

    2022/11/19 21:17:11
  32. 配置 已完成 请勿关闭计算机,win7系统关机提示“配置Windows Update已完成30%请勿关闭计算机”的解决方法...

    今天和大家分享一下win7系统重装了Win7旗舰版系统后&#xff0c;每次关机的时候桌面上都会显示一个“配置Windows Update的界面&#xff0c;提示请勿关闭计算机”&#xff0c;每次停留好几分钟才能正常关机&#xff0c;导致什么情况引起的呢&#xff1f;出现配置Windows Update…...

    2022/11/19 21:17:10
  33. 电脑桌面一直是清理请关闭计算机,windows7一直卡在清理 请勿关闭计算机-win7清理请勿关机,win7配置更新35%不动...

    只能是等着&#xff0c;别无他法。说是卡着如果你看硬盘灯应该在读写。如果从 Win 10 无法正常回滚&#xff0c;只能是考虑备份数据后重装系统了。解决来方案一&#xff1a;管理员运行cmd&#xff1a;net stop WuAuServcd %windir%ren SoftwareDistribution SDoldnet start WuA…...

    2022/11/19 21:17:09
  34. 计算机配置更新不起,电脑提示“配置Windows Update请勿关闭计算机”怎么办?

    原标题&#xff1a;电脑提示“配置Windows Update请勿关闭计算机”怎么办&#xff1f;win7系统中在开机与关闭的时候总是显示“配置windows update请勿关闭计算机”相信有不少朋友都曾遇到过一次两次还能忍但经常遇到就叫人感到心烦了遇到这种问题怎么办呢&#xff1f;一般的方…...

    2022/11/19 21:17:08
  35. 计算机正在配置无法关机,关机提示 windows7 正在配置windows 请勿关闭计算机 ,然后等了一晚上也没有关掉。现在电脑无法正常关机...

    关机提示 windows7 正在配置windows 请勿关闭计算机 &#xff0c;然后等了一晚上也没有关掉。现在电脑无法正常关机以下文字资料是由(历史新知网www.lishixinzhi.com)小编为大家搜集整理后发布的内容&#xff0c;让我们赶快一起来看一下吧&#xff01;关机提示 windows7 正在配…...

    2022/11/19 21:17:05
  36. 钉钉提示请勿通过开发者调试模式_钉钉请勿通过开发者调试模式是真的吗好不好用...

    钉钉请勿通过开发者调试模式是真的吗好不好用 更新时间:2020-04-20 22:24:19 浏览次数:729次 区域: 南阳 > 卧龙 列举网提醒您:为保障您的权益,请不要提前支付任何费用! 虚拟位置外设器!!轨迹模拟&虚拟位置外设神器 专业用于:钉钉,外勤365,红圈通,企业微信和…...

    2022/11/19 21:17:05
  37. 配置失败还原请勿关闭计算机怎么办,win7系统出现“配置windows update失败 还原更改 请勿关闭计算机”,长时间没反应,无法进入系统的解决方案...

    前几天班里有位学生电脑(windows 7系统)出问题了&#xff0c;具体表现是开机时一直停留在“配置windows update失败 还原更改 请勿关闭计算机”这个界面&#xff0c;长时间没反应&#xff0c;无法进入系统。这个问题原来帮其他同学也解决过&#xff0c;网上搜了不少资料&#x…...

    2022/11/19 21:17:04
  38. 一个电脑无法关闭计算机你应该怎么办,电脑显示“清理请勿关闭计算机”怎么办?...

    本文为你提供了3个有效解决电脑显示“清理请勿关闭计算机”问题的方法&#xff0c;并在最后教给你1种保护系统安全的好方法&#xff0c;一起来看看&#xff01;电脑出现“清理请勿关闭计算机”在Windows 7(SP1)和Windows Server 2008 R2 SP1中&#xff0c;添加了1个新功能在“磁…...

    2022/11/19 21:17:03
  39. 请勿关闭计算机还原更改要多久,电脑显示:配置windows更新失败,正在还原更改,请勿关闭计算机怎么办...

    许多用户在长期不使用电脑的时候&#xff0c;开启电脑发现电脑显示&#xff1a;配置windows更新失败&#xff0c;正在还原更改&#xff0c;请勿关闭计算机。。.这要怎么办呢&#xff1f;下面小编就带着大家一起看看吧&#xff01;如果能够正常进入系统&#xff0c;建议您暂时移…...

    2022/11/19 21:17:02
  40. 还原更改请勿关闭计算机 要多久,配置windows update失败 还原更改 请勿关闭计算机,电脑开机后一直显示以...

    配置windows update失败 还原更改 请勿关闭计算机&#xff0c;电脑开机后一直显示以以下文字资料是由(历史新知网www.lishixinzhi.com)小编为大家搜集整理后发布的内容&#xff0c;让我们赶快一起来看一下吧&#xff01;配置windows update失败 还原更改 请勿关闭计算机&#x…...

    2022/11/19 21:17:01
  41. 电脑配置中请勿关闭计算机怎么办,准备配置windows请勿关闭计算机一直显示怎么办【图解】...

    不知道大家有没有遇到过这样的一个问题&#xff0c;就是我们的win7系统在关机的时候&#xff0c;总是喜欢显示“准备配置windows&#xff0c;请勿关机”这样的一个页面&#xff0c;没有什么大碍&#xff0c;但是如果一直等着的话就要两个小时甚至更久都关不了机&#xff0c;非常…...

    2022/11/19 21:17:00
  42. 正在准备配置请勿关闭计算机,正在准备配置windows请勿关闭计算机时间长了解决教程...

    当电脑出现正在准备配置windows请勿关闭计算机时&#xff0c;一般是您正对windows进行升级&#xff0c;但是这个要是长时间没有反应&#xff0c;我们不能再傻等下去了。可能是电脑出了别的问题了&#xff0c;来看看教程的说法。正在准备配置windows请勿关闭计算机时间长了方法一…...

    2022/11/19 21:16:59
  43. 配置失败还原请勿关闭计算机,配置Windows Update失败,还原更改请勿关闭计算机...

    我们使用电脑的过程中有时会遇到这种情况&#xff0c;当我们打开电脑之后&#xff0c;发现一直停留在一个界面&#xff1a;“配置Windows Update失败&#xff0c;还原更改请勿关闭计算机”&#xff0c;等了许久还是无法进入系统。如果我们遇到此类问题应该如何解决呢&#xff0…...

    2022/11/19 21:16:58
  44. 如何在iPhone上关闭“请勿打扰”

    Apple’s “Do Not Disturb While Driving” is a potentially lifesaving iPhone feature, but it doesn’t always turn on automatically at the appropriate time. For example, you might be a passenger in a moving car, but your iPhone may think you’re the one dri…...

    2022/11/19 21:16:57