当前位置: 首页 > news >正文

实现领域驱动设计(DDD)系列详解:领域模型的持久化

领域驱动设计主要通过限界上下文应对复杂度,它是绑定业务架构、应用架构和数据架构的关键架构单元。设计由领域而非数据驱动,且为了保证定义了领域模型的应用架构和定义了数据模型的数据架构的变化方向相同,就应该在领域建模阶段率先定义领域模型,再根据领域模型定义数据模型。这就是领域驱动设计与数据驱动设计的根本区别。

一、对象关系映射

如果领域建模采用对象建模范式,存储数据则使用关系数据库,那么领域模型就是面向对象的,数据模型则是面向关系表的。在领域驱动设计中,领域模型一方面充分地表达了系统的领域逻辑,同时还映射了数据模型,作为持久化对象完成数据的读写。要持久化领域模型对象,需要为对象与关系建立映射,即所谓的“对象关系映射”(object relationship mapping,ORM)。当然,这主要针对关系数据库。

对象与关系往往存在“阻抗不匹配”的问题,主要体现为以下3个方面。

  • 类型的阻抗不匹配:例如不同关系数据库对浮点数的不同表示方法,字符串类型在数据库的最大长度约束等,又例如Java等语言的枚举类型本质上仍然属于基本类型,关系数据库中却没有对应的类型来匹配。
  • 样式的阻抗不匹配:领域模型与数据模型不具备一一对应的关系。领域模型是一个具有嵌套层次的对象图结构,数据模型在关系数据库中却是扁平的关系结构,要让数据库能够表示领域模型,就只能通过关系来变通地映射实现。
  • 对象模式的阻抗不匹配:面向对象的封装、继承和多态无法在关系数据库得到直观体现。通过封装可以定义一个高内聚的类来表达一个细粒度的基本概念,但数据表往往不这么设计。数据表只有组合关系,无法表达对象之间的继承关系。既然无法实现继承关系,就无法满足Liskov替换原则,自然也就无法满足多态。

二、解决方式

(一)使用JPA

1.枚举类型

关系数据库的基本类型没有枚举类型。如果领域模型的字段定义为枚举,通常会在数据库中将相应的列定义为smallint类型,然后通过@Enumerated表示枚举的含义。

public enum EmployeeType {Hourly, Salaried, Commission
}
public class Employee {@Enumerated@Column(columnDefinition = "smallint")private EmployeeType employeeType;
}

smallint虽然能够体现值的有序性,但在管理和运维数据库时,查询得到的枚举值却是没有任何业务含义的数字,制造了理解障碍。为此,可将列定义为VARCHAR,而在领域模型中定义枚举

public enum Gender {Male, Female
}
public class Employee {@Enumerated(EnumType.STRING)private Gender gender;
}

注解@Enumerated(EnumType.STRING)可将枚举类型转换为字符串。注意,数据库的字符串应与枚举类型的字符串值以及大小写保持一致。

2.日期类型

处理针对Java的日期和时间类型进行映射要相对复杂一些,因为Java定义了多种日期和时间类型

  • 用以表达数据库日期类型的java.sql.Date类和表达数据库时间类型的java.sql.Timestamp类;
  • Java库用以表达日期、时间和时间戳类型的java.util.Date类或java.util.Calendar类;
  • Java 8引入的新日期类型java.time.LocalDate类与新时间类型java.time.LocalDateTime类。
  • 数据库本身支持java.sql.Date或java.sql.Timestamp类型,若领域模型对象的日期或时间字段属于这一类型,则无须任何配置即可使用,和使用其他基础类型一般自然。

通过columnDefinition属性值,甚至还可以为其设置默认值,例如设置为当期日期:

@Column(name = "START_DATE", columnDefinition = "DATE DEFAULT CURRENT_DATE")
private java.sql.Date startDate;

如果字段定义为java.util.Date或java.util.Calendar类型,可通过@Temporal注解将其映射为日期、时间或时间戳,例如:

@Temporal(TemporalType.DATE)
private java.util.Calendar birthday;
@Temporal(TemporalType.TIME)
private java.util.Date birthday;
@Temporal(TemporalType.TIMESTAMP)
private java.util.Date birthday;

如果字段定义为Java 8新引入的LocalDate或LocalDateTime类型,情况稍显复杂,取决于JPA的版本。

import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.sql.Date;
import java.time.LocalDate;
@Converter(autoApply = true)
public class LocalDateAttributeConverter implements AttributeConverter<LocalDate, Date> {@Overridepublic Date convertToDatabaseColumn(LocalDate locDate) {return locDate == null ? null : Date.valueOf(locDate);}@Overridepublic LocalDate convertToEntityAttribute(Date sqlDate) {return sqlDate == null ? null : sqlDate.toLocalDate();}
}

3.主键类型

关系数据库表的主键列至为关键,通过它可以标注每一行记录的唯一性。

主键还是建立表关联的关键列,通过主键与外键的关系可以间接支持领域模型对象之间的导航,同时也保证了关系数据库的完整性。无论是单一主键还是联合主键,主键作为身份标识(identity),只要能够确保它在同一张表中的唯一性,原则上都可以被定义为各种类型,如BigInt、VARCHAR等。在数据表定义中,只要某个列被声明为PRIMARY KEY,在领域模型对象的定义中,就可以使用JPA提供的@Id注解。

这个注解还可以和@Column注解组合使用:

@Id
@Column(name = "employeeId")
private int id;

主流关系数据库都支持主键的自动生成,JPA提供了@GeneratedValue注解说明了该主键通过自动生成。该注解还定义了strategy属性用以指定自动生成的策略。JPA还定义了@SequenceGenerator与@TableGenerator等特殊的ID生成器。

在建立领域模型时,我们强调从领域逻辑出发考虑领域类的定义。尤其对实体类而言,ID代表的是实体对象的身份标识。

它与数据表的主键有相似之处,例如二者都要求唯一性,但二者的本质完全不同:前者代表业务含义,后者代表技术含义;前者用于对实体对象生命周期的管理与跟踪,后者用于标记每一行在数据表中的唯一性。

领域驱动设计往往建议定义值对象作为实体的身份标识。一方面,值对象类型可以清晰表达该身份标识的业务含义;另一方面,值对象类型的封装也有利于应对未来主键类型可能的变化。

JPA定义了一个特殊的注解@EmbeddedId来建立数据表主键与身份标识值对象之间的映射。例如,为Employee实体对象定义了EmployeeId值对象,则Employee的定义为:

@Entity
@Table(name="employees")
public class Employee extends AbstractEntity<EmployeeId> implements AggregateRoot
<Employee> {@EmbeddedIdprivate EmployeeId employeeId;
}

JPA对主键类有两个要求:相等性比较与序列化支持,即需要主键类实现Serializable接口,并重写Object的equals()与hashcode()方法。值对象的类定义还需要声明Embeddable注解。由于框架需要通过反射创建值对象,因此,如果值对象定义了带参数的构造函数,还需要为其定义默认的构造函数:

@Embeddable
public class EmployeeId implements Identity<String>, Serializable {@Column(name = "id")private String value;private static Random random;static {random = new Random();}// 必须提供默认的构造函数public EmployeeId() {}private EmployeeId(String value) {this.value = value;}@Overridepublic String value() {return this.value;}public static EmployeeId of(String value) {return new EmployeeId(value);}public static Identity<String> next() {return new EmployeeId(String.format("%s%s%s",composePrefix(),composeTimestamp(),composeRandomNumber()));}@Overridepublic boolean equals(Object o) {if (this == o) return true;if (o == null || getClass() != o.getClass()) return false;EmployeeId that = (EmployeeId) o;return value.equals(that.value);}@Overridepublic int hashCode() {return Objects.hash(value);}
}

使用时,可以直接传入EmployeeId对象作为主键查询条件:

Optional<Employee> optEmployee = employeeRepo.findById(EmployeeId.of("emp200109101000001"));
4.样式的阻抗不匹配

样式(schema)的阻抗不匹配,就是对象图与关系表之间的不匹配。要做到二者的匹配,需要做到图结构与表结构之间的互相转换。

在领域模型的对象图中,一个实体组合了另一个实体,由于两个实体都有各自的身份标识,映射到数据库,就可通过主外键关系建立关联。

关联关系包括一对一、一对多、多对一和多对多。

例如,在领域模型中,HourlyEmployee聚合根实体与TimeCard实体之间的关系可以定义为:

@Entity
@Table(name="hourly_employees")
public class HourlyEmployee extends AbstractEntity<EmployeeId> implements AggregateRoot
<HourlyEmployee> {@EmbeddedIdprivate EmployeeId employeeId;@OneToMany // 该注解定义了一对多关系@JoinColumn(name = "employeeId", nullable = false)private List<TimeCard> timeCards = new ArrayList<>();
}
@Entity
@Table(name = "timecards")
public class TimeCard {private static final int MAXIMUM_REGULAR_HOURS = 8;@Id@GeneratedValueprivate String id;private LocalDate workDay;private int workHours;public TimeCard() {}
}

在数据模型中,timecards表通过外键employeeId建立与employees表之间的关联:

CREATE TABLE hourly_employees(employeeId VARCHAR(50) NOT NULL,......PRIMARY KEY(employeeId)
);
CREATE TABLE timecards(id INT NOT NULL AUTO_INCREMENT,employeeId VARCHAR(50) NOT NULL,workDay DATE NOT NULL,workHours INT NOT NULL,PRIMARY KEY(id)
);

如果对象图的实体和值对象之间形成了一对多的关联,由于值对象没有唯一的身份标识,因此它对应的数据模型也没有主键,而将实体表的主键作为外键,由此来表达彼此之间的归属关系。

这时,领域模型仍然通过集合来表达一对多的关联,但使用的注解并非@OneToMany,而是@ElementCollection。

例如,领域模型中的SalariedEmployee聚合根实体与Absence值对象之间的关系可以定义为:

@Embeddable
public class Absence {private LocalDate leaveDate;@Enumerated(EnumType.STRING)private LeaveReason leaveReason;public Absence() {}public Absence(LocalDate leaveDate, LeaveReason leaveReason) {this.leaveDate = leaveDate;this.leaveReason = leaveReason;}
}
@Entity
@Table(name="salaried_employees")
public class SalariedEmployee extends AbstractEntity<EmployeeId> implements AggregateRoot
<SalariedEmployee> {private static final int WORK_DAYS_OF_MONTH = 22;@EmbeddedIdprivate EmployeeId employeeId;@Embeddedprivate Salary salaryOfMonth;@ElementCollection@CollectionTable(name = "absences", joinColumns = @JoinColumn(name = "employeeId"))private List<Absence> absences = new ArrayList<>();public SalariedEmployee() {}
}

@ElementCollection说明了字段absences是SalariedEmployee实体的字段元素,类型为集合;@CollectionTable标记了关联的数据表以及关联的外键。其数据模型的SQL语句如下:

CREATE TABLE salaried_employees(employeeId VARCHAR(50) NOT NULL,......PRIMARY KEY(employeeId)
);
CREATE TABLE absences(employeeId VARCHAR(50) NOT NULL,leaveDate DATE NOT NULL,leaveReason VARCHAR(20) NOT NULL
);

数据表absences没有自己的主键,employeeId列是employees表的主键。注意,在Absence值对象的定义中,无须再定义employeeId字段,因为Absence值对象并不能脱离SalariedEmployee聚合根单独存在。这是聚合对领域模型产生的影响,也可视为聚合的设计约束。

5.对象模式的阻抗不匹配

领域模型要符合面向对象的设计原则,一个重要特征是建立了高内聚松耦合的对象图。

要做到这一点,就需要将具有高内聚关系的概念封装为一个类,通过显式的类型体现领域中的概念。

这样既提高了代码的可读性,又保证了职责的合理分配,避免出现一个庞大的实体类。领域驱动设计更强调这一点,并因此引入了值对象的概念,用以表现那些无须身份标识却又具有内聚知识的领域概念。

因此,一个设计良好的领域模型,往往会呈现出一个具有嵌套层次的对象图模型结构。虽然嵌套层次的领域模型与扁平结构的关系数据模型并不匹配,但通过JPA提供的@Embedded与@Embeddable注解可以非常容易实现这一嵌套组合的对象关系,例如Employee类的address属性和email属性:

@Entity
@Table(name="employees")
public class Employee extends AbstractEntity<EmployeeId> implements AggregateRoot
<Employee> {@EmbeddedIdprivate EmployeeId employeeId;private String name;@Embeddedprivate Email email;@Embeddedprivate Address address;
}
@Embeddable
public class Address {private String country;private String province;private String city;private String street;private String zip;public Address() {}
}
@Embeddable
public class Email {@Column(name = "email")private String value;public String value() {return this.value;}
}

Address类和Email类都是Employee实体的值对象。注意,为了支持JPA框架通过反射创建对象,若为值对象定义了带参的构造函数,需要显式定义默认构造函数。

EmployeeId类的定义与Address类的定义相同,也属于值对象,只是前者由于作为了实体的身份标识,并映射了数据模型的主键,因此应声明为@EmbeddedId注解。无论是Address、Email还是EmployeeId类,在领域对象模型中虽然被定义为独立的类,但在数据模型中,却都是employees表中的列。

其中,Email类仅仅对应表中的一个列,之所以要定义为类,目的是在领域模型中体现电子邮件的领域概念,并有利于封装对邮件地址的验证逻辑;Address类封装了多个内聚的值,体现为country、province等列,以利于维护地址概念的完整性,同时也可以实现对领域概念的复用。创建employees表的SQL脚本如下所示:

CREATE TABLE employees(id VARCHAR(50) NOT NULL,name VARCHAR(20) NOT NULL,email VARCHAR(50) NOT NULL,employeeType SMALLINT NOT NULL,gender VARCHAR(10),currency VARCHAR(10),country VARCHAR(20),province VARCHAR(20),city VARCHAR(20),street VARCHAR(100),zip VARCHAR(10),mobilePhone VARCHAR(20),homePhone VARCHAR(20),officePhone VARCHAR(20),onBoardingDate DATE NOT NULLPRIMARY KEY(id)
);

一个值对象如果在数据模型中被设计为一个独立的表,由于无须定义主键,依附于实体对应的数据表,因此在领域模型中依旧标记为@Embeddable。这既体现了面向对象的封装思想,又表达了一对一或一对多的关系。

SalariedEmployee聚合中的Absence值对象就遵循了这样的设计原则。面向对象的封装思想体现了对细节的隐藏,正确的封装还体现为对职责的合理分配。

遵循“信息专家模式”,无论是针对领域模型中的实体,还是针对值对象,都应该从它们拥有的数据出发,判断领域行为是否应该分配给这些领域模型类。

如HourlyEmployee实体类的payroll(Period)方法、Absence值对象的isIn(Period)与isPaidLeave()方法乃至于Salary值对象的add(Salary)等方法,都充分体现了对领域行为的合理封装,避免了贫血模型的出现:

public class HourlyEmployee extends AbstractEntity<EmployeeId> implements AggregateRoot
<HourlyEmployee> {public Payroll payroll(Period period) {if (Objects.isNull(timeCards) || timeCards.isEmpty()) {return new Payroll(this.employeeId, period.beginDate(), period.endDate(),
Salary.zero());}Salary regularSalary = calculateRegularSalary(period);Salary overtimeSalary = calculateOvertimeSalary(period);Salary totalSalary = regularSalary.add(overtimeSalary);return new Payroll(this.employeeId, period.beginDate(), period.endDate(), totalSalary);}
}
public class Absence {public boolean isIn(Period period) {return period.contains(leaveDate);}public boolean isPaidLeave() {return leaveReason.isPaidLeave();}
}
public class Salary {public Salary add(Salary salary) {throwExceptionIfNotSameCurrency(salary);return new Salary(value.add(salary.value).setScale(SCALE), currency);}public Salary subtract(Salary salary) {throwExceptionIfNotSameCurrency(salary);return new Salary(value.subtract(salary.value).setScale(SCALE), currency);}public Salary multiply(double factor) {return new Salary(value.multiply(toBigDecimal(factor)).setScale(SCALE), currency);}public Salary divide(double multiplicand) {return new Salary(value.divide(toBigDecimal(multiplicand), SCALE, BigDecimal.
ROUND_DOWN), currency);}
}

这充分证明领域模型对象既可以作为持久化对象,搭建起对象与关系表之间的桥梁,又可以体现包含丰富领域行为在内的领域概念与领域知识。

合二者为一体的领域模型对象定义在领域层,可被南向网关的资源库端口与适配器直接访问,无须再定义单独的数据模型对象。前面提到的数据模型,实际上指的是数据库中创建的数据表。

对象模式中的泛化关系(通过继承体现)更为特殊,因为关系表自身不具备继承能力,这与对象之间的关联关系不同。继承体现了“差异式编程”,父类与子类以及子类之间存在属性的差异,但在数据模型中,却可以将父类与子类所有的属性无论差异都放在一张表中,就好似对集合求并集一般。

这种策略在ORM中被称为Single-Table策略。为了区分子类的类型差异,需要在这张单表中额外定义一个列,作为区分子类的标识列,对应的JPA注解为@DiscriminatorColumn。例如,如果Employee存在继承体系,若选择Single-Table策略,整个继承体系映射到employees表中,则它的标识列就是employeeType列。

若子类之间的差异太大,采用Single-Table策略实现继承会让数据表的行数据出现太多不必要的列,又不得不为这些列提供存储空间。要避免这种存储空间的冗余,可采用Joined-Subclass策略实现继承。

继承体系中的父实体与子实体在数据库中都有一个单独的表与之对应,子实体对应的表无须为继承自父实体的属性定义列,而是通过共享主键的方式与之关联。由于Single-Table策略是ORM默认的继承策略,若要采用Joined-Subclass策略,需要在父实体类的定义中显式声明继承策略,如下所示:

@Entity
@Inheritance(strategy=InheritanceType.JOINED)
@Table(name="employees")
public class Employee {}

采用Joined-Subclass策略实现继承时,子实体与父实体在数据模型中的表现实则为一对一的连接关系,这可以认为是为了解决对象关系阻抗不匹配的无奈之举,毕竟用表的连接关系表达类的泛化关系,怎么看怎么觉得别扭。

若领域模型中继承体系的子类较多,这一设计还会影响查询效率,因为它可能牵涉到多张表的连接。如果既不希望产生不必要的数据冗余,又不愿意表连接拖慢查询的速度,则可以采用Table- Per-Class策略。采用这种策略时,继承体系中的每个实体类都对应一个独立的表,与Joined-Subclass策略不同之处在于,父实体对应的表仅包含父实体的字段,子实体对应的表不仅包含了自身的字段,同时还包含了父实体的字段。

这相当于用数据表样式的冗余避免数据的冗余、用单表来避免不必要的连接。如果子类之间的差异较大,那么Table-Per-Class策略明显优于Joined-Subclass策略。

继承的目的绝不仅仅是复用,甚至可以说复用并非它的主要价值,毕竟“聚合/合成优先复用原则”已经成为面向对象设计的金科玉律。

继承的主要价值在于支持多态,以利用Liskov替换原则,使得子类能够替换父类而不改变其行为,并允许定义新的子类来满足功能扩展的需求,保证对扩展是开放的。在Java或C#中,由于受到单继承的约束,定义抽象接口以实现多态更为普遍。

无论是继承多态还是接口多态,都应站在领域逻辑的角度,思考是否需要引入合理的抽象来应对未来需求的变化。

在采用继承多态时,需要考虑对应的数据模型是否能够在对象关系映射中实现继承,并选择合理的继承策略以确定关系表的设计。如果继承多态与接口多态针对领域行为,则与领域模型的持久化无关,也就无须考虑领域模型与数据模型之间的映射。

6.瞬态领域模型

领域服务作为对领域行为的封装,自然无须考虑持久化;如果不是采用事件溯源模式,领域事件也无须考虑持久化。位于聚合内部的实体和值对象需要持久化,否则就无须引入资源库来管理它们的生命周期了。

除此之外,在设计领域模型时,往往会发现存在一些游离在聚合边界外的领域对象,它们拥有自己的属性值,体现了高内聚的领域概念,并遵循“信息专家模式”封装了操作自身信息的领域行为,但却没有身份标识,无须进行持久化,例如与HourlyEmployee聚合根交互的Period类,其作用是体现一个结算周期,作为薪资计算的条件:

public class Period {private LocalDate beginDate;private LocalDate endDate;public Period(LocalDate beginDate, LocalDate endDate) {this.beginDate = beginDate;this.endDate = endDate;}public Period(YearMonth yearMonth) {int year = yearMonth.getYear();int month = yearMonth.getMonthValue();int firstDay = 1;int lastDay = yearMonth.lengthOfMonth();this.beginDate = LocalDate.of(year, month, firstDay);this.endDate = LocalDate.of(year, month, lastDay);}public Period(int year, int month) {if (month < 1 || month > 12) {throw new InvalidDateException("Invalid month value.");}int firstDay = 1;int lastDay = YearMonth.of(year, month).lengthOfMonth();this.beginDate = LocalDate.of(year, month, firstDay);this.endDate = LocalDate.of(year, month, lastDay);}public LocalDate beginDate() {return beginDate;}public LocalDate endDate() {return endDate;}public boolean contains(LocalDate date) {if (date.isEqual(beginDate) || date.isEqual(endDate)) {return true;}return date.isAfter(beginDate) && date.isBefore(endDate);}
}

结算周期提供了成对的起止日期,缺少任何一个日期,就无法正确地进行薪资计算。将beginDate与endDate封装到Period类中,再利用构造函数限制实例的创建,就能避免起止日期任意一个值的缺失。

引入Period类还能封装领域行为,让对象之间的协作变得更加合理。它的类型没有声明@Entity,并不需要持久化,也没有被定义在聚合边界内。为示区别,可将这样的类称为瞬态类(transientclass),由此创建的对象则称为瞬态对象。

对应地,倘若在一个支持持久化的领域类中,需要定义一个无须持久化的字段,可将其称为瞬态字段(transient field)。JPA定义了@Transient注解用以显式声明这样的字段,例如:

@Entity
@Table(name="employees")
public class Employee extends AbstractEntity<EmployeeId> implements AggregateRoot
<Employee> {@EmbeddedIdprivate EmployeeId employeeId;private String firstName;private String middleName;private String lastName;@Transientprivate String fullName;
}

Employee类对应的数据模型定义了firstName、middleName和lastName列。为了调用方便,该类又定义了fullName字段。该值并不需要持久化到数据库中, 因此声明为瞬态字段。瞬态类属于领域模型的一部分。相较于聚合内的实体和值对象,它更加纯粹,无须依赖任何外部框架,属于真正的POJO类;它的设计符合整洁架构思想,即处于内部核心的领域类不依赖任何外部框架。

7.JPA使用注意事项

我们可以使用 JPA 的级联更新实现聚合根的持久化。在实际操作中发现,JPA 并不好用。其实这不是 JPA 的问题,是因为 JPA 做的太多了,JPA 不仅有各种状态转换,还有多对多关系。

如果保持克制就可以使用 JPA 实现 DDD,尝试遵守下面的规则:

  • 不要使用 @ManyToMany 特性,多对多关系太复杂。
  • 只给聚合根配置 Repository 对象。聚合根内有其他内部实体,虽然需要持久化,但不要为它配置Repository对象。
  • 避免造成网状的关系,互相循环依赖。
  • 读写分离。关联等复杂查询,读写分离查询不要给 JPA 做,JPA 只做单个对象的查询,复杂查询可以给mybatis做。

二、领域模型与数据模型

在领域模型内部,聚合是最小的设计单元,资源库是持久化实现的抽象。一个资源库对应一个聚合,故而聚合也是领域模型最小的持久化单元。

当领域模型引入限界上下文与聚合之后,领域模型类与数据表之间就有可能突破类与表之间一一对应的关系。

因此,在遵循领域驱动设计原则实现持久化时,需要考虑领域模型与数据模型之间的关系,而在进行领域建模时,一定是先有领域模型,后有数据模型!

在定义了领域模型之后,将其映射为数据模型时,不能破坏限界上下文和聚合确定的边界。

至于聚合内部的实体和值对象,则不必保证类与表的一对一关系,也不应该将其设计为一对一关系。不能忽视物理边界对架构的影响。

限界上下文以进程为物理边界,确定了与业务架构对应的应用架构。进程内与进程间对领域模型的调用方式迥然不同。菱形对称架构限制了进程内直接调用领域模型的方式,这就为应用架构提供了演进的可能。

在限界上下文与菱形对称架构的基础上,系统的应用架构可以很容易地从单体架构演进到微服务架构。那么,数据架构能无缝演进吗?数据模型以数据库为物理边界,数据表为逻辑边界,由此确定了数据架构。

但是,限界上下文的物理边界无法做到与数据模型物理边界的一对一关系,例如数据库共享架构就破坏了这种关系。

此时就需要逻辑边界的约束力。领域模型必须与数据模型建立映射关系,才能使资源库适配器通过ORM框架进行持久化。

领域模型属于哪一个数据库,领域模型类属于哪一个数据表,类属性属于哪一个数据列,都是通过映射关系来配置和表达的。这种映射关系并不受数据库边界的影响。只要保证数据模型的逻辑边界与限界上下文的逻辑边界保持一致,就能保证数据架构的演进能力,前提是:数据模型需按照领域模型进行设计。

以薪资管理系统为例,员工管理和薪资结算分属两个不同的限界上下文:员工上下文和薪资上下文。

员工上下文关注员工基本信息的管理,薪资上下文需要对各种类型的员工进行薪资结算。

既然限界上下文是领域模型的知识语境,就可以在这两个限界上下文中同时定义员工Employee领域类,在领域设计模型中,体现为不同的聚合。

根据领域模型设计数据模型,就应该为不同限界上下文的员工领域概念建立不同的员工数据表。

考虑到限界上下文物理边界的不同,数据模型存在两种不同的设计方案。

  • 进程内边界,设计为单库多表:所有限界上下文共享同一个数据库,员工上下文的员工领域模型映射为员工表,薪资上下文的员工领域模型各自映射对应员工类型的员工表,表之间由共同的员工ID进行关联。这一方案满足单体架构风格。
  • 进程间边界,设计为多库多表:为不同限界上下文建立不同的数据库,数据表的定义与单库多表一致。这一方案符合微服务架构风格。无论数据模型采用哪一种设计方案,领域模型都几乎不会受到影响,唯一的影响是ORM元数据定义需要修改对库的映射。如图所示的领域模型代码结构不受数据模型设计方案的影响。

在这里插入图片描述

在领域模型中,员工上下文的Employee聚合根实体与薪资上下文的HourlyEmployeeSalariedEmployeeCommissionedEmployee这3个聚合根实体之间存在隐含的员工ID关联。设计数据模型时,这4个聚合根实体对应4张数据主表,它们的id主键都是员工ID,彼此之间的关系如图所示。

在这里插入图片描述
员工领域类的设计充分体现了限界上下文作为领域模型的知识语境,而数据模型与领域模型的对应关系又充分支持了限界上下文对业务能力的纵向切分。领域模型的战略设计与战术设计就是通过限界上下文和聚合的边界有机融合起来的。

三、聚合的持久化

使用JPA实现领域驱动设计的领域模型持久化虽然很方便,但是还是有以下问题:

  • 1.领域模型引入了技术因素,各领域模型增加了@Entity、@Column等与数据库表相关的注解,当设计领域模型时首先肯定没考虑数据库的因素,而是考虑业务因素
  • 2.JPA对多表查询的支持很差,若对报表有很强的需求,使用JPA进行实现需要绕很多弯子

另外,在对mysql这样的关系型数据库时,聚合的持久化也有以下问题:

  • 关系的映射不好处理,层级比较深的对象不好转换。
  • 将数据转换为聚合时会有 n+1 的问题,不好使用关系数据库的联表特性。
  • 全量的数据更新数据库的事务较大,性能低下。

聚合的持久化是 DDD 美好愿景落地的最大拦路虎,这些问题有部分可以被解决而有部分必须取舍。

(一)自己实现Repository

一般一个聚合对应一个资源库,若不使用JPA进行实现,则可以使用mybatis进行实现,那么需要自己实现Repository的功能。

使用 Mybatis Mapper,对 Mapper 再次封装。

class OrderRepository {private OrderMapper orderMapper;private OrderItemMapper orderItemMapper;public Order get(String orderId) {Order order = orderMapper.findById(orderId);order.setOrderItems(orderItemMapper.findAllByOrderId(orderId))return order;}
}

这种做法有一个小点问题,领域对象 Order 中有 orderItems 这个属性,但是数据库中不可能有 Items,一些开发者会认为这里的 Order 和通常数据库使用的 OrderEntity 不是一类对象,于是进行繁琐的类型转换。

类型转换和多余的一层抽象,加大了工作量。

如果使用 Mybatis,其实更好的方式是直接使用 Mapper 作为 Repository 层,并在 XML 中使用动态 SQL 实现上述代码。

还有一个问题是,一对多的关系,发生了移除操作怎么处理呢?

比较简单的方式是直接删除,再存入新的数组即可,也可以实现对象的对比,记录对象的历史版本,有选择的进行新增、删除和更新。完成了这些,恭喜你,你变相实现了JPA的特性。

(二)使用 Spring Data JDBC

Mybatis 就是一个 SQL 模板引擎,而 JPA 做的太多,有没有一个适中的 ORM 来持久化聚合呢?

Spring Data JDBC 就是人们设计出来持久化聚合,从名字来看他不是 JDBC,而是使用 JDBC 实现了部分 JPA 的规范,让你可以继续使用 Spring Data 的编程习惯。

Spring Data JDBC 的一些特点:

  • 没有 Hibernate 中 session 的概念,没有对象的各种状态
  • 没有懒加载,保持对象的完整性
  • 除了 Spring Data 的基本功能,保持简单,只有保存方法、事务、审计注解、简单的查询方法等。
  • 可以搭配 JOOQ 或 Mybatis 实现复杂的查询能力。

需要注意的是,Spring Data JDBC 的逻辑:

  • 如果聚合根是一个新的对象,Spring Data JDBC 会递归保存所有的关联对象。
  • 如果聚合根是一个旧的对象,Spring Data JDBC 会删除除了聚合根之外旧的对象再插入,聚合根会被更新。因为没有之前对象的状态,这是一种不得不做的事情。也可以按照自己策略覆盖相关方法。

(三)使用 Domain Service 变通处理

正是因为和 ORM 一起时候会有各种限制,而抽象一个 Repository 层会带来大的成本,所以有一种变通的方法。

这种方法不使用充血模型、也不让 Repository 来保证聚合的一致性,而是使用领域服务来实现相关逻辑,但会被批评为 DDD lite 或不是 “纯正的 DDD”。

这种编程范式有如下规则:

  • 按照 DDD 四层模型,Application Service 和 Domain Service 分开,Application Service 负责业务编排,不是必须的一层,可以由 UI 层兼任。
  • 一个聚合使用 DomainService 来保持业务的一致性,一个聚合只有一个 Domain Service。Domain Service 内使用 ORM 的各种持久化技术。
  • 除了 Domain Service 不允许其他地方之间使用 ORM 更新数据。
  • 当不被充血模型困住的时候,问题变得更清晰。

DDD 只是手段不是目的,对一般业务系统而言,充血模型不是必要的,我们的目的是让编码和业务清晰。

这里引入两个概念:

  • 业务主体。操作领域模型的拟人化对象,用来承载业务规则,也就是 Domain Service,比如订单聚合可以由一个服务来管理,保证业务的一致性。我们可以命名为:OrderManager.
  • 业务客体。聚合和领域对象,用来承载业务属性和数据。这些对象需要有状态和自己的生命周期,比如 Order、OrderItem。

回归到原始的编程哲学:

程序 = 数据结构 + 算法

业务主体负责业务规则(算法),业务客体负责业务属性和数据(数据结构),那么用不用 DDD 都能让代码清晰、明白和容易处理了。

相关文章:

实现领域驱动设计(DDD)系列详解:领域模型的持久化

领域驱动设计主要通过限界上下文应对复杂度&#xff0c;它是绑定业务架构、应用架构和数据架构的关键架构单元。设计由领域而非数据驱动&#xff0c;且为了保证定义了领域模型的应用架构和定义了数据模型的数据架构的变化方向相同&#xff0c;就应该在领域建模阶段率先定义领域…...

配置sublime的中的C++编译器(.sublime-build),实现C++20

GCC 4.8: 支持 C11 (部分) GCC 4.9: 支持 C11 和 C14 (部分) GCC 5: 完全支持 C14 GCC 6: 支持 C14 和 C17 (部分) GCC 7: 支持 C17 (大部分) GCC 8: 完全支持 C17&#xff0c;部分支持 C20 GCC 9: 支持更多的 C20 特性 GCC 10: 支持大部分 C20 特性 GCC 11: 更全面地支持 C20 …...

Android14 - 前台Service、图片选择器 、OpenJDK 17、其他适配

前台服务 1. 指定前台服务类型 以 Android 14(API 级别 34)或更高版本为目标平台的应用,需要为应用中的每项前台服务指定服务类型,因为系统需要特定类型的前台服务满足特定用例。具体介绍如下: 在Android 10 在 <service> 元素内引入了 android:foregroundServiceT…...

数据恢复教程:如何从硬盘、SD存储卡、数码相机中恢复误删除数据。

您正在摆弄 Android 设备。突然&#xff0c;您意外删除了一张或多张图片。不用担心&#xff0c;您总能找到一款价格实惠的数据恢复应用。这款先进的软件可帮助 Android 用户从硬盘、安全数字 (SD) 或存储卡以及数码相机中恢复已删除的数据。 Android 上数据被删除的主要原因 在…...

谷粒商城实战笔记-47-商品服务-API-三级分类-网关统一配置跨域

文章目录 一&#xff0c;跨域问题1&#xff0c;跨域问题产生的原因2&#xff0c;预检请求3&#xff0c;跨域解决方案3.1 CORS (Cross-Origin Resource Sharing)后端配置示例&#xff08;Spring Boot&#xff09; 3.2 JSONP (JSON with Padding)3.3 代理服务器Nginx代理配置示例…...

stm32平台为例的软件模拟时间,代替RTC调试

stm32平台为例的软件模拟时间&#xff0c;代替RTC调试 我们在开发项目的时候&#xff0c;如果用到RTC&#xff0c;如果真正等待RTC到达指定的时间&#xff0c;那调试时间就太长了。 比如每隔半个小时&#xff0c;存储一次数据&#xff0c;如果要观察10次存储的效果&#xff0…...

《设计模式之美》读书笔记2

从Linux学习应对大型复杂项目的方法&#xff1a; 1、封装与抽象&#xff1a;封装了不同类型设备的访问细节&#xff0c;抽象为统一的文件访问方式&#xff0c;更高层的代码就能基于统一的访问方式&#xff0c;来访问底层不同类型的设备。这样做的好处是&#xff0c;隔离底层设备…...

C++ STL set_difference 用法

一&#xff1a;功能 给定两个集合A&#xff0c;B&#xff1b;计算集合的差集&#xff0c;即计算出那些只包含在A中而不包含在B中的元素。 二&#xff1a;用法 #include <vector> #include <algorithm> #include <iostream>int main() {std::vector<int&…...

【基础算法总结】优先级队列

优先级队列 1.最后一块石头的重量2.数据流中的第 K 大元素4.前K个高频单词4.数据流的中位数 点赞&#x1f44d;&#x1f44d;收藏&#x1f31f;&#x1f31f;关注&#x1f496;&#x1f496; 你的支持是对我最大的鼓励&#xff0c;我们一起努力吧!&#x1f603;&#x1f603; 1…...

python-绝对值排序(赛氪OJ)

[题目描述] 输入 n 个整数&#xff0c;按照绝对值从大到小排序后输出。保证所有整数的绝对值不同。输入格式&#xff1a; 输入数据有多组&#xff0c;每组占一行&#xff0c;每行的第一个数字为 n ,接着是 n 个整数&#xff0c; n0 表示输入数据的结束&#xff0c;不做处理。输…...

成功者的几个好习惯,你具备了几个

每个人都想成为自己领域的佼佼者&#xff0c;然而&#xff0c;成功并非偶然&#xff0c;它往往与一系列良好的习惯紧密相连。这些习惯如同灯塔&#xff0c;指引着成功者在波涛汹涌的大海中稳健前行。 一、设定明确目标 没有明确的目标&#xff0c;就如同航海没有指南针&#…...

centos中zabbix安装、卸载及遇到的问题

目录 Zabbix简介Zabbix5.0和Zabbix7.0的区别监控能力方面模板和 API 方面性能、速度方面 centos7安装Zabbix(5.0)安装zabbix遇到的问题卸载Zabbix Zabbix简介 Zabbix 是一个基于 WEB 界面的提供分布式系统监视以及网络监视功能的企业级的开源解决方案。zabbix 能监视各种网络参…...

php编译安装

一、基础环境准备 # php使用www用户 useradd -s /sbin/nologin -M www二、下载php包 # 下载地址 https://www.php.net/downloads wget https://www.php.net/distributions/php-8.3.9.tar.gz三、配置编译安装 编译安装之前需要处理必要的依赖&#xff0c;在编译配置安装&…...

[K8S] K8S资源控制器Controller Manager(4)

文章目录 1. 常见的Pod控制器及含义2. Replication Controller控制器2.1 部署ReplicaSet 3. Deployment3.1部署Deployment3.2 运行Deployment3.3 镜像更新方式3.4 Deployment扩容3.5 滚动更新3.6 金丝雀发布(灰度发布)3.7 Deployment版本回退3.8 Deployment 更新策略 4. Daemon…...

C#,.NET常见算法

1.递归算法 1.1.C#递归算法计算阶乘的方法 using System;namespace C_Sharp_Example {public class Program{/// <summary>/// 阶乘&#xff1a;一个正整数的阶乘Factorial是所有小于以及等于该数的正整数的积&#xff0c;0的阶乘是1&#xff0c;n的阶乘是n&#xff0…...

KubeSphere介绍及一键安装k8s

KubeSphere介绍 官网地址&#xff1a;https://kubesphere.io/zh/ KubeSphere愿景是打造一个以 Kubernetes 为内核的云原生分布式操作系统&#xff0c;它的架构可以非常方便地使第三方应用与云原生生态组件进行即插即用&#xff08;plug-and-play&#xff09;的集成&#xff0…...

Spring 系列

SpringBoot 实体类&#xff08;Entity&#xff09;层 实体类&#xff08;Entity&#xff09;通常属于模型层&#xff08;Model Layer&#xff09;或领域层&#xff08;Domain Layer&#xff09;。它们代表应用程序中的核心业务数据结构&#xff0c;与数据库表结构紧密对应。在…...

基于opencv[python]的人脸检测

1 图片爬虫 这里的代码转载自&#xff1a;http://t.csdnimg.cn/T4R4F # 获取图片数据 import os.path import fake_useragent import requests from lxml import etree# UA伪装 head {"User-Agent": fake_useragent.UserAgent().random}pic_name 0 def request_pic…...

配置SSH公钥互信

目录 第一台主机&#xff1a;servera&#xff08;172.25.250.101&#xff09; 第一步&#xff1a;查看 . ssh目录下面是否为空 第二步&#xff1a;输入命令ssh-keygen 第三步&#xff1a; 再看查看一下. ssh目录 第四步&#xff1a; 输入命令 ssh-copy-id root172.25.250…...

WEB渗透Web突破篇-SQL注入(MSSQL)

注释符 -- 注释 /* 注释 */用户 SELECT CURRENT_USER SELECT user_name(); SELECT system_user; SELECT user;版本 SELECT version主机名 SELECT HOST_NAME() SELECT hostname;列数据库 SELECT name FROM master..sysdatabases; SELECT DB_NAME(N); — for N 0, 1, 2, ……...

DAY15

数组 冒泡排序 冒泡排序无疑是最为出名的排序算法之一&#xff0c;总共有八大排序 冒泡的代码还是相当简单的&#xff0c;两层循环&#xff0c;外层冒泡轮数&#xff0c;里层依次比较&#xff0c;江湖中人人尽皆知 我们看到嵌套循环&#xff0c;应该马上就可以得到这个算法的…...

pytest结合allure-pytest插件生成测试报告

目录 一、安装allure-pytest插件 二、下载allure 三、生成allure报告 四、效果展示 一、安装allure-pytest插件 二、下载allure 下载之后解压&#xff0c;解压之后还要配置环境变量&#xff08;把allure目录下bin目录配置到系统变量的path路径&#xff09;&#xff0c;下…...

详细解析用户提交咨询

上一篇文章中写到了使用Server-Sent Events (SSE)&#xff0c;并获取message里面的内容。 本篇文章主要是写&#xff0c;具体该如何实现的具体代码&#xff0c;代码见下方&#xff0c;可直接拿 async submitConsult() {this.scrollToBottom();if (!this.$checkLogin()) return;…...

UDP/TCP协议解析

我最近开了几个专栏&#xff0c;诚信互三&#xff01; > |||《算法专栏》&#xff1a;&#xff1a;刷题教程来自网站《代码随想录》。||| > |||《C专栏》&#xff1a;&#xff1a;记录我学习C的经历&#xff0c;看完你一定会有收获。||| > |||《Linux专栏》&#xff1…...

力扣94题(java语言)

题目 思路 使用一个栈来模拟递归的过程&#xff0c;以非递归的方式完成中序遍历(使用栈可以避免递归调用的空间消耗)。 遍历顺序步骤&#xff1a; 遍历左子树访问根节点遍历右子树 package algorithm_leetcode;import java.util.ArrayList; import java.util.List; import…...

JavaScript基础入门:构建动态Web世界的基石

简要介绍JavaScript作为互联网上最流行的编程语言之一&#xff0c;它在构建交互式网页、动态Web应用及服务器后端&#xff08;通过Node.js&#xff09;中的重要性。强调学习JS对于任何想要进入Web开发领域的人来说是不可或缺的。 1. JavaScript是什么&#xff1f; 定义JavaSc…...

01-client-go

想学习K8S源码&#xff0c;可以加 &#xff1a;mkjnnm 1、介绍 client-go 是用来和 k8s 集群交互的go语言客户端库&#xff0c;地址为&#xff1a;https://github.com/kubernetes/client-go client-go 的版本有两种标识方式&#xff1a; v0.x.y (For each v1.x.y Kubernetes…...

WebRTC QoS方法十三.2(Jitter延时的计算)

一、背景介绍 一些报文在网络传输中&#xff0c;会存在丢包重传和延时的情况。渲染时需要进行适当缓存&#xff0c;等待丢失被重传的报文或者正在路上传输的报文。 jitter延时计算是确认需要缓存的时间 另外&#xff0c;在检测到帧有重传情况时&#xff0c;也可适当在渲染时…...

PHP进阶:前后端交互、cookie验证、sql与php

单词&#xff1a;construct 构造 destruct 摧毁 empty 空的 trim 修剪 strip 清除 slash 斜线 special 特殊 char 字符 query 询问 构造方法&#xff08;魔术方法&#xff09; 构造方法是一种特殊的函数&#xff0…...

优思学院|ANOVA方差分析是什么?如何用EXCEL进行计算?

在数据分析、六西格玛管理领域中&#xff0c;ANOVA&#xff08;方差分析&#xff09;是一种基本的统计工具&#xff0c;广泛用于确定三组或三组以上的独立群体之间的平均值是否存在统计学上的显着差异。ANOVA的主要目的在于评估一个或多个因素的影响&#xff0c;通过比较不同样…...

Mindspore框架循环神经网络RNN模型实现情感分类|(三)RNN模型构建

Mindspore框架循环神经网络RNN模型实现情感分类 Mindspore框架循环神经网络RNN模型实现情感分类|&#xff08;一&#xff09;IMDB影评数据集准备 Mindspore框架循环神经网络RNN模型实现情感分类|&#xff08;二&#xff09;预训练词向量 Mindspore框架循环神经网络RNN模型实现…...

深度解读大语言模型中的Transformer架构

一、Transformer的诞生背景 传统的循环神经网络&#xff08;RNN&#xff09;和长短期记忆网络&#xff08;LSTM&#xff09;在处理自然语言时存在诸多局限性。RNN 由于其递归的结构&#xff0c;在处理长序列时容易出现梯度消失和梯度爆炸的问题。这导致模型难以捕捉长距离的依…...

安装好anaconda,打开jupyter notebook,新建 报500错

解决办法&#xff1a; 打开anaconda prompt 输入 jupyter --version 重新进入jupyter notebook&#xff1a; 可以成功进入进行代码编辑...

C++20之设计模式:状态模式

状态模式 状态模式状态驱动的状态机手工状态机Boost.MSM 中的状态机总结 状态模式 我必须承认:我的行为是由我的状态支配的。如果我没有足够的睡眠&#xff0c;我会有点累。如果我喝了酒&#xff0c;我就不会开车了。所有这些都是状态(states)&#xff0c;它们支配着我的行为:…...

数据库安全综合治理方案(可编辑54页PPT)

引言&#xff1a;数据库安全综合治理方案是一个系统性的工作&#xff0c;需要从多个方面入手&#xff0c;综合运用各种技术和管理手段&#xff0c;确保数据库系统的安全稳定运行。 方案介绍&#xff1a; 数据库安全综合治理方案是一个综合性的策略&#xff0c;旨在确保数据库系…...

人工智能:大语言模型提示注入攻击安全风险分析报告下载

大语言模型提示注入攻击安全风险分析报告下载 今天分享的是人工智能AI研究报告&#xff1a;《大语言模型提示注入攻击安全风险分析报告》。&#xff08;报告出品方&#xff1a;大数据协同安全技术国家工程研究中心安全大脑国家新一代人工智能开放创新平台&#xff09; 研究报告…...

【购买源码时有许多需要注意的坑】

购买源码时有许多需要注意的“坑”&#xff0c;这些坑可能会对项目的后续开发和使用造成严重影响。以下是一些需要特别注意的方面&#xff1a; 源码的完整性 编译测试&#xff1a;确保到手的源码能够从头至尾编译、打包、部署和功能测试无误。这一步非常关键&#xff0c;因为只…...

CAS的三大问题和解决方案

一、ABA问题的解决方案 变量第一次读取的值是1&#xff0c;后来其他线程改成了3&#xff0c;然后又被其他线程修改成了1&#xff0c;原来期望的值是第一个1才会设置新值&#xff0c;第二个1跟期望不符合&#xff0c;但是&#xff0c;可以设置新值。 解决方案&#xff1a; &a…...

EDA和统计分析有什么区别

EDA&#xff08;Electronic Design Automation&#xff09;和统计分析在多个方面存在显著的区别&#xff0c;这些区别主要体现在它们的应用领域、目的、方法以及所使用的工具上。 EDA&#xff08;电子设计自动化&#xff09; 定义与目的&#xff1a; EDA是电子设计自动化&…...

CentOS 7 修改DNS

1、nmcli connection show 命令找到设备名称 # nmcli connection show NAME UUID TYPE DEVICE enp4s0 99559edf-4e0a-4bae-a528-6d75065261e9 ethernet enp4s0 2、nmcli connection modify 命令修改dns nmcli connection modif…...

PHP基础语法-Part2

if-else语句、switch语句 与其他语言相同 循环结构 for循环while循环do-while循环foreach循环&#xff0c;搭配数组使用 foreach ($age as $avlue) //只输出值 {xxx; } foreach ($age as $key > $avlue) //键和值都输出 {xxx; }foreach ($age as $key >…...

数据结构门槛-顺序表

顺序表 1. 线性表2. 顺序表2.1 静态顺序表2.2 动态顺序表2.2.1 动态数据表初始化和销毁2.2.2 动态数据表的尾插尾删2.2.3 动态数据表的头插头删2.2.4 动态数据表的中间部分插入删除2.2.5 动态数据表的查询数据位置 3. 总结 1. 线性表 线性表&#xff08;linear list&#xff0…...

软件测试面试准备工作

1、 什么是数据库? 答&#xff1a;数据库是按照某种数据模型组织起来的并存放二级存储器中的数据集合。 2、 什么是关系型数据库? 答&#xff1a;关系型数据库是建立在关系数据库模型基础上的数据库&#xff0c; 借助集合代数等概念和方法处理数据库中的数据。目前主流的关…...

Java面试八股之后Spring、spring mvc和spring boot的区别

Spring、spring mvc和spring boot的区别 Spring, Spring Boot和Spring MVC都是Spring框架家族的一部分&#xff0c;它们各自有其特定的用途和优势。下面是它们之间的主要区别&#xff1a; Spring: Spring 是一个开源的轻量级Java开发框架&#xff0c;最初由Rod Johnson创建&…...

linux对齐TOF和RGB摄像头画面

问题&#xff1a;TOF和RGB画面不对齐 linux同时接入TOF和RGB&#xff0c;两者出图时间是由驱动层控制&#xff08;RGB硬件触发出图&#xff09;&#xff0c;应用层只负责读取数据。 现在两者画面不对齐&#xff0c;发现是开始的时候两者出图数量不一致导致的。底层解决不了&a…...

配置linux客户端免密登录服务端linux主机的root用户

1、客户端与服务端的ip 客户端IP地址服务端IP地址 2、定位客户端&#xff0c;由客户端制作公私钥对 [rootclient ~]# ssh-keygen -t rsa &#xff08;RSA是非对称加密算法&#xff09; # 一路回车 3、定位客户端&#xff0c;将公钥上传到服务器端root账户 [rootc…...

SpringMVC实现文件上传

导入文件上传相关依赖 <!--文件上传--> <dependency><groupId>commons-fileupload</groupId><artifactId>commons-fileupload</artifactId><version>1.3.1</version> </dependency> <dependency><groupId>…...

计算机实验室排课查询小程序的设计

管理员账户功能包括&#xff1a;系统首页&#xff0c;个人中心&#xff0c;学生管理&#xff0c;教师管理&#xff0c;实验室信息管理&#xff0c;实验室预约管理&#xff0c;取消预约管理&#xff0c;实验课程管理&#xff0c;实验报告管理&#xff0c;报修信息管理&#xff0…...

分享几种电商平台商品数据的批量自动抓取方式

在当今数字化时代&#xff0c;电商平台作为商品交易的重要渠道&#xff0c;其数据对于商家、市场分析师及数据科学家来说具有极高的价值。批量自动抓取电商平台商品数据成为提升业务效率、优化市场策略的重要手段。本文将详细介绍几种主流的电商平台商品数据批量自动抓取方式&a…...

mysql面试(五)

前言 本章节从数据页的具体结构&#xff0c;分析到如何生成索引&#xff0c;如何构成B树的索引结构。 以及什么是聚簇索引&#xff0c;什么是联合索引 InnoDB数据结构 行数据 我看各种文档中有好多记录数据结构的&#xff0c;但是这些都是看完就忘的东西。在这里详细讲也没…...