《实现领域驱动设计》— 值对象
2024/1/24 11:02:30
本文主要是介绍《实现领域驱动设计》— 值对象,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
值对象虽然经常被掩盖在实体的阴影之下,但它却是非常重要的DDD部件。值对象的常见例子包括数字,比如3、10和29.51;或者文本字符串,比如“hello world”;或者日期、时间;还有更加详细的对象,比如某人的全名,其中包含姓氏、名字和头衔;再比如货币、颜色、电话号码和邮寄地址等。当然还有更加复杂的值对象。
值对象的优点:值对象用于度量和描述事物,我们可以非常容易地对值对象进行创建、测试、使用、优化和维护。
我们应该尽量使用值对象来建模而不是实体对象,你可能对此非常惊讶。即便一个领域概念必须建模成实体,在设计时也应该更偏向于将其作为值对象容器,而不是子实体容器。这并不是源自于无端的偏好,而是因为我们可以非常容易地对值对象进行创建、测试、使用、优化和维护。
在设计得当的情况下,我们可以对值对象实例进行创建和传递,甚至在使用完之后将其直接扔掉。我们不用担心客户端对值对象的修改。一个值对象的生命周期可长可短,它就像一个无害的过客在系统中来来往往。
那么,我们如何确定一个领域概念应该建模成一个值对象呢?此时我们需要密切关注值对象的特征。
当你只关心某个对象的属性时,该对象便可作为一个值对象。为其添加有意义的属性,并赋予它相应的行为。我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。
值对象的特征
首先,在将领域对象概念建模成值对象时,我们应该将通用语言考虑在内,这是建模值对象的首要原则,该原则属于整个DDD设计。
当你决定一个领域概念是否是一个值对象时,你需要考虑它是否拥有以下特征:
- 它度量或者描述了领域中的一件东西。
- 它可以作为不变量。
- 它将不同的相关属性组合成一个整体概念。
- 当度量和描述改变时,可以用另一个值对象予以替换。
- 它可以和其他值对象进行相等性比较。
- 它不会对协作对象造成副作用。
对于以上特征,将在下面做详细讲解。在使用这些方法分析模型时,你会发现很多领域概念都可以设计成值对象,而不是先前认为的实体对象。
度量或描述
当你的模型中的确存在一个值对象时,不管你是否意识到,它都不应该成为你领域中的一样东西,而只是用于度量或描述领域中某件东西的一个概念。一个人拥有年龄,这里的年龄并不是一个实在的东西,而只是作为你出生了多少年的一种度量。一个人拥有名字,同样这里的名字也不是一个实在的东西,而是描述了如何称呼这个人。
该特征和下面的“概念整体”特征是紧密联系在一起的。
不变性
一个值对象在创建之后便不能改变。在使用C#编程时,我们使用构造函数来创建值对象实例,此时传入的参数包含了该值对象的所有状态所需的数据信息。所传入的参数既可以作为该值对象的直接属性,也可以用于计算出新的属性。
光凭初始化是不能保证值对象的不变性。在值对象初始化之后,任何方法都不能对该对象的属性状态进行修改。在上面的例子中,只有setRatings 和 initialize 方法可以修改对象的状态,而它们只在对象构建过程中才被使用。方法 setRatings 被声明为 private,外界不能直接调用。
此外,BusinessPriority 必须保证除了构造函数之外,其他方法均不能调用 setter 方法。
根据需要,有时我们可以在值对象中维持对实体对象的引用。在这种情况下我们需要谨慎行事。当实体对象的状态发生改变时,引用它的值对象也将发生改变,由此违背了值对象的不变性。因此,在值对象中引用实体时,我们的出发点应该是不变性、表达性和方便性。否则,如果实体对象有可能违背值对象的不变性,那么我们便没有理由在值对象中引用实体对象。在后面会讲到值对象的无副作用特征。
如果你认为一个值对象必须通过行为方法进行改变,那么你得问问自己这是否有必要。在这种情况下可以用其他值对象来替换吗?使用值对象替换可以简化设计。
有时将一个对象设计成不变对象是没有意义的,此时往往意味着该对象应该建模成一个实体对象,见实体。
概念整体
一个值对象可以只处理单个属性,也可以处理一组相关联的属性。在这组相关联的属性中,每一个属性都是整体属性所不可或缺的组成部分,这和简单地将一组属性组装在对象中是不同的。如果一组属性联合起来并不能表达一个整体上的概念,那么这种联合并无多大用处。
在Ward Cunningham的整体值对象模式中提到,值对象{50,000,000美元}具有两个属性,一个是50,000,000 ,一个是美元。单独一个 50,000,000 可能表示另外的意思,而单独一个“美元”更不能表示该值对象。只有当这两者联合起来才是一个表达货币度量的概念整体。因此我们并不希望将来表示 50,000,000 的 Amount 和表示美元的 Currency 看作两个相对独立的属性,比如:
//不正确建模的ThingOfWorth public class ThingOfWorth { public ThingOfWorth() { } private string Name; private decimal Amount; private string Currency; }
在上面的例子中,ThingOfWorth 的客户端必须知道什么时候应该同时使用 Amount 和 Currency ,并且还应该知道如何使用这两个属性,原因在于这两个属性并没有组成一个概念整体。
要正确地表达货币度量,我们不应该将以上两个属性分离开来,而应该将它们建模成一个整体值对象:{50,000,000美元}。
public class MonetaryValue { public decimal Amount { get; private set; } public string Currency { get; private set; } public MonetaryValue(decimal anAmount,string aCurrency) { SetAmount(anAmount); SetCurrency(aCurrency); } }
这并不是说 MonetaryValue 就是完美的,我们还可以用Currency值对象类型来表示货币单位。这里可以将 Currency 属性从 string 类型替换成 Currency 类型。同时,我们还可以使用 Factory 和 Builder 来创建该值对象。
在一个领域中,概念的整体性是非常重要的,因此作为整体值对象的 MonetaryValue 已经不再单单是一个起描述作用的描述属性,而是一个资产属性。一个值对象可以拥有一个或多个描述属性,但是对于持有该值对象实例的对象来说,该值对象便是一个资产属性。
以下是改进后的代码:
//正确建模的ThingOfWorth public class ThingOfWorth { public ThingOfWorth() { } private ThingName Name; //资产属性 public MonetaryValue Worth { get; private set; }//资产属性 }
上面的代码还存在一点变化,ThingOfWorth 中的 Name 和 Worth 同样重要,因此我们用 ThingName 类型取代了原来的 string 类型。虽然用 string 类型在一开始看来已经足够了,但是在随后的迭代中,它将带来问题。围绕着 Name 展开的领域逻辑有可能从 ThingOfWorth 模型中泄漏出去。如下代码所示:
//有客户端处理命名相关逻辑 String name = thingOfWorth.name(); String capitalizedName name.substring(0,1).toUpperCase() + name.substring(1).toLowerCase();
在上面的代码中,客户端自己试图解决 Name 的大小写问题。通过定义 ThingName 类型,我们可以将与 Name 有关的所有逻辑操作集中在一起。以上面的例子来说, ThingName 可以在初始化时对 Name 进行格式化,而不是客户端自身来处理。
值对象的构造函数用于保证概念整体的有效性和不变性。
如果你试图将多个属性加在一个实体上,但这却弱化了各个属性之间的关系,那么此时你便应该考虑将这些相互关联的属性组合在一个值对象中。每个值对象都是一个内聚的概念整体,它表达了通用语言中的一个概念。
可替换性
在你的模型中,如果一个实体所引用的值对象能够正确地表达其当前的状态,那么这种引用关系可以一直维持下去。否则,我们需要将整个值对象替换成一个新的值对象实例。
值对象的可替换性可以通过数字的替换性来理解。假设领域中有一个名为 total 的概念,该概念用整数表示。如果total的当前值被设成了3,但是之后需要重设为4,此时我们并不会将整数3修改成4,而是简单地将total的值重新赋值为4.
int total = 3; //稍后... total = 4;
这种替换值的方法是非常显然的,但是它却向我们展示了很重要的一点。在上例中,我们只是将total的值从3替换成了4。这并不是过度简化,而正是值对象替换工作方式。考虑下面一种更复杂的值对象替换:
FullName name = new FullName ("Vaughn", "Vernon"); //稍后... name = new FullName ("Vaughn", "L", "Vernon");
首先,name通过firstName和lastName进行初始化,随后name变量被替换成了另一个FullName值对象实例,该实例中包含了 firstName、middleName和lastName。这里,我们并没有使用FullName的某个方法来改变其自身的状态,因为这样破坏了值对象的不变性。我们使用了简单的替换将另一个FullName实例的引用重新赋值给了name变量。这种方式的表达性并不强,我们将在下文讲到更好的替换方法。
值对象相等性
在比较两个值对象实例时,我们需要检查这两个值对象的相等性。在整个系统中,有可能存在很多相等的值对象实例,但它们并不表示相同的实例引用。相等性通过比较两个对象的类型和属性来决定。如果两个对象的类型和属性都相等,那么这两个对象也是相等的。进而。=,如果两个或多个值对象实例是相等的,我们便可以用其中一个实例来替换另一个实例。
以下代码测试两个FullName值对象的相等性:
public boolean equals (Object an0bject) { boolean equalObjects = false; if (anObject != null && this.getClass() = anObject.getClass()) { FullName typedobject = (FullName) anObject; equalobjects = this.firstName ().equals (typedObject.firstName()) & ) && this.lastName ().equals (typedObject.lastName ()) return equalobjects; }
思考一下,值对象的哪些特征可以用来支持聚合的唯一标识性。我们需要值对象的相等性,比如在通过实体查询聚合时便会用到。同时,不变性也是重要的。实体的唯一标识是不能改变的,这可以部分地通过值对象的不变性达到。此外,我们还可以从值对象的概念整体特性中得到好处,因为实体的唯一标识是根据通用语言来命名的,并且需要在一个实例中包含所有的可以表示唯一标识的属性。然而,这里我们并不需要值对象的可替换性,因为我们不会替换聚合根的唯一标识。
无副作用行为
一个对象的方法可以设计成一个无副作用函数。这里的函数表示对某个对象的操作,它只用于产生输出,而不会修改对象的状态。由于函数执行的过程中没有状态改变,这样的函数操作也称为无副作用函数。
对于不变的值对象而言,所有的方法都必须是无副作用函数,因为它们不能破坏值对象的不变性。你可以将这种特性看作是不变性的一部分,但是我更倾向于将该特性从不变性中分离出来,因为这样做可以强调出值对象的一大好处。否则,我们可能只会将值对象看成一个属性容器,而忽略了值对象模式的一个功能强大的特性——无副作用函数。
在下面的例子中,通过在一个FullName对象上调用无副作用方法将该对象本身替换成另一个实例:
FullName name = new FullName("Vaughn", "Vernon"); //稍后... name = name.withMiddleInitial("L");
这和先前“可替换性”一节中的例子所产生的结果是一样的,但是代码更具表达性。这个无副作用的 withMiddleInitial() 方法的实现如下:
public FullName withMiddleInitial(String aMiddleNameOrInitial){ if(aMiddleNameOrInitial == null) { throw new IllegalArgumentException( "Must provide a middle name or initial."); String middle = aMiddleNameOrInitial.trim (); if(middle.isEmpty()){ throw new IllegalArgumentException( "Must provide a middle name or initial."); } return new FullName( this.firstName() middle.substring(0,1).toUpperCase(), this.lastName()); }
在上例中,withMiddleInitial() 方法并没有修改值对象的状态,因此它不会产生副作用。该方法通过已有的 firstName 和 lastName ,外加传入的 middlerName 创建了一个新的FullName值对象实例。此外,withMiddleInitial() 方法还捕获到了重要的领域业务逻辑,从而避免了将这些逻辑泄漏到客户端。
当值对象引用实体对象
一个值对象允许对传入的实体对象进行修改吗?如果值对象中的确有方法会修改实体对象,那么该方法还是无副作用的吗?该方法容易测试吗?既容易,也不容易。因此,如果一个值对象方法将一个实体对象作为参数时,最好的方式是,让实体对象使用该方法返回的结果来修改其自身的状态。
然而,这种方式存在一个问题。例如,我们有个实体对象Product,该对象被值对象BusinessPriority所使用:
float priority = businessPriority.priorityOf(product)
这里存在以下问题:
-
- 这里的BusinessPriority值对象不仅依赖于Product,还试图去理解该实体的内部状态。我们应该尽量地使用值对象只依赖于它自身的属性,并且只理解它自身的状态。虽然在有些情况下这并不可行,但这是我们的目标。
- 阅读本段代码的人并不知道使用了Product的哪些部分。这种表达方法并不明确,从而降低了模型的清晰性。更好的方式是只传入需要用到的Product属性。
- 更重要的是,在将实体作为参数的值对象方法中,我们很难看出该方法是否会对实体进行修改,测试也将变得非常困难。因此,即便一个值对象承诺不会修改实体,我们也很难证明这一点。
有了以上的分析,我们需要对以上的值对象进行改进。要增加一个值对象的健壮性,我们传给值对象方法的参数依然应该是值对象。这样我们可以获得更高层次的无副作用行为。要实现这样的目标并不困难:
float priority = businessPriority.priority( product.businessPriorityTotals());
在上例中,我们只需要将Product实体的BusinessPriorityTotals值对象传给priority()方法即可。你可能会认为priority()方法应该返回一个值对象类型,而不是float类型。这是正确的,特别是当priority是通用语言中的正式概念的时候。这种决定来自持续改进模型的结果。
如果你打算使用语言特性提供的基本值对象,而不是使用特定的值对象,那么你便是在欺骗自己的模型了。我们是无法将领域特定的无副作用函数分配给语言提供的基本值对象的。任何领域特有行为都将从值中分离出来。即便编程语言允许我们向基本值对象中添加新的行为,这能够在深层次上捕获领域概念吗?
最小集成化
在所有的DDD项目中,通常存在多个限界上下文,这意味着我们需要找到合适的方法对这些上下文进行继承。当模型概念从上游上下文流入下游上下文时,尽量使用值对象来表示这些概念。这样的好处是可以达到最小化继承,即可以最小化下游模型中用于管理职责的属性数目。使用不变的值对象使得我们做更少的职责假设。
重用限界上下文中的一个例子:上游的“身份与访问上下文”会影响下游的“协作上下文”,如下图所示。在“身份与访问上下文”中,两个聚合分别为User和Role。在“协作上下文”中,我们关心的是一个User是否拥有一个特定的Role,比如Moderator。“协作上下文”使用它的防腐层向“身份与访问上下文”的开放主机服务提出查询。如果这个集成的查询过程表明某个User拥有Moderator角色,协作上下文便会创建一个代表性的Moderator对象。
Moderator和其他Collaborator的子类如下图,这些对象被建模成了值对象。这些值对象的实例通过静态方式创建。这里的重点在于,上游的身份与访问上下文对下游的协作上下文的影响被最小化了。虽然上游上下文需要处理许多属性,但是它传给下游的Moderator却只包含了通用语言中的关键性属性。此外,Moderator并不包含Role聚合中属性,而是通过自身的名字表明一个用户所扮演的角色。我们选择静态创建Moderator的方式,并且没有必要使下游中的值对象与上游保持同步。这种考虑了服务质量(Quantity of Service)的契约可以大大地减轻下游上下文的负担。
当然, 有时下游上下文的对象必须和远程上下文的聚合保持最终一致性。在这种情况下,我们可以在下游上下文中设计一个聚合,因为该聚合实体可以用于维护状态变化。但是,我们应该尽量地避免这种建模方式,在有可能的情况下使用值对象来完成限界上下文之间的集成,这对于许多需要消费标准类型的上下文来说都是适用的。
用值对象表示标准类型
在许多应用程序和系统中,都会使用到标准类型。标准类型是用于表示事物类型的描述性对象。系统中既有表示事物的实体和描述实体的值对象,同时还存在标准类型来区分不同的类型。
假设你的通用语言定义了一个PhoneNumber值对象,同时需要为每个PhoneNumber对象制定一个类型。“这个号码是家庭电话、移动电话、工作电话还是其他类型的电话号码?”不同类型的电话号码类型需要建模成一种类的层级关系吗?为每一个类型创建一个类对于客户端使用来说是非常困难的。此时,你需要使用标准类型来描述不同类型的电话还吗,比如Home、Work或者Other。我们需要一个PhoneNumberType值对象来表示一个PhoneNumber值对象的号码类型。使用标准类型可以避免伪造号码类型。
根据标准化程度,这些类型可能只能用在应用程序级别,也或者可以在不同的系统间共享,更或者可以称为一种国际标准。
标准化程度有时会影响到对标准类型的获取,同时还有可能影响到标准类型在模型中的使用方式。
我们可以将这些概念建模成实体,因为它们在自己的限界上下文中都拥有自己的生命周期。在不考虑创建方式和由什么样的标准组织维护的情况下,在作为消费方的我限界上下文中,我们应该尽可能地将这些概念建模成值对象。这是一种很好的做法,因为这些概念本来就是用来度量和描述事物的,而值对象便是建模度量和描述概念的最佳方式。
为了维护方便,最好是为标准类型创建单独的限界上下文。在这样的上下文中,这些标准类型便是实体了,拥有了持久化的生命周期,并且还含有属性,比如identity、name和description。可能还有其他属性,但是这里的3个属性对于消费上下文来说是常见的。通常来说,我们只会使用其中一个属性,这也是最小化集成的目标。
枚举也是实现标准类型的一种简单方法。枚举提供了一组有限数量的值对象,它是非常轻量的,并且无副作用。但是该值对象的文本描述在什么地方呢?对于此,存在两种答案。通常来说,没有必要为标准类型提供描述信息,只需要名字就足够了。为什么?文本描述通常只会在用户界面层中才能用到,此时可以用一个显示资源和类型名字匹配起来。很多时候用于显示的文本都需要进行本地化(比如在多语言环境中),因此将这种功能放在模型中并不合适。通常来说,在模型中使用标准类型的名字便是最好的方式。另一种答案是,在枚举中已经存在描述信息了,可以调用toString()方法来获得标准类型的文本描述。
一个共享的不变值对象可以从持久化存储中获取,此时可以通过标准类型的领域服务或工厂来获取值对象。我们应该为每组标准类型创建一个领域服务或工厂,如图所示。服务或工厂将按需从持久化存储中获取标准类型,而客户方并不知道这些标准类型是来自数据库的。另外,使用领域服务或工厂还使得我们可以加入不同的缓存机制,由于值对象在数据库中是只读的,并且在整个系统中是不变的,缓存机制也将变得更加简单和安全。
在这种情况下,领域服务或工厂将为每一种标准类型提供静态创建的不变值对象实例。由于是静态的,数据库中标准类型的改变不会自动反映到代码中。如果你希望这两者是同步的,那么你应该创建一些定制化的方案来查询并更新模型的状态。
测试值对象
为了强调测试驱动,在实现值对象之前,让我们先来看看测试。通过模拟客户端对值对象的使用,这些测试可以驱动出对领域模型的设计。
这里,我们所关心的并不仅仅是单元测试的各个方面,而是演示客户端是如何使用我们的领域模型的。在设计领域模型时,从客户端的角度思考有助于捕获关键的领域概念。否则,我们便是在从自己的角度设计模型,而不是业务的角度。
我们可以这么看待这种测试风格:如果我们要为自己的模型编写一个用户手册,我们便可以通过这些测试代码来展示客户端对领域模型的使用。
当然,也不是说我们就不应该编写单元测试,对于团队所要求的所有类型的测试,我们都应该完成。但是,每种测试的关注点是不同的。单元测试和行为测试有它们自己的关注点,而下面的模型则有另外的关注点。
我们创建了一个BusinessPriority类,该类用于衡量每一个待定项的业务价值,它返回的是成本百分比,或者当前待定项与其他特定项的比较成本。同时BusinessPriority还向外提供了开发某个待定项的总价值,或者开发当前待定项与其他待定项的比较价值。此外,该类还提供了当前待定项与其他待定项相比起来的业务优先级。
package com.saasovation.agilepm.domain.model.piroduct; import com.saasovation.agilepm.domain.model.DonmainTest; import java.text.NumberFormat; public class BusinessPriorityTest extendsDomainTest { public BusinessPriorityTest(){ super (); } //帮助方法 private NumberFormat oneDecimal() { return this.decimal(1); } private NumberFormat twoDecimals(){ return this.decimal(2); } private NumberFormat decimal(int aNumberOfDecimals){ NumberFormat fmt = NumberFormat.getInstance(); fmt.setMinimumFractionDigits (aNumberOfDeciImals) fmt.setMaximumFractionDigits (aNumberOfDecimals), return fmt; } public void testCostPercentageCalculation() throws Exception { BusinessPriority businessPriority = new BusinessPriority( new BusinessPriorityRatings (2, 4,1,1)); BusinessPriority businessPriorityCopy = new BusinessPriority (businessPriority); assertEquals (businessPriority, businessPriorityCopy); BusinessPriorityTotals totals = new BusinessPriorityTotals(53, 49, 53 + 49,37,33); float cost = businessPriority.costPercentage (totals); assertEquals (this.oneDecimal().format(cost),"2.7"); assertEquals (businessPriority, businessPriorityCopy); } }
在测试值对象的不变性时,每个测试首先一个BusinessPriority实例,然后通过复制构造函数创建一个与之相等的复制实例。测试的第一个断言保证了复制构造函数所创建的实例和原来的实例是相等的。接下来经过一系列计算和赋值之后,最后再执行一次相等断言。
之后,需要测试优先级、总价值和价值百分比,他们可以使用和以上测试相同的测试模板:
在创建测试时,我们应该保证领域专家能够读懂这些测试,即测试应该具有领域含义。
实现
通常来说,至少都会为值对象创建两个构造函数。
public final class BusinessPriority implements Serializable{ private static final long serialVersionUID = 11L; private BusinessPriorityRatings ratings; public BusinessPriority (BusinessPriorityRattings aRatings){ super (); this.setRatings(aRatings); } public BusinessPriority(BusinessPriority aBusinessPriority){ this (aBusinessPriority.ratings()); }
第一个构造函数接受用于构建对象状态的所有属性参数,它是主要的构造函数。该构造函数首先初始化默认的对象状态,对于基本属性的初始化通过调用私有的setter方法实现。该私有的setter方法向我们展示了一种自委派性。只有主构造函数才能使用自委派性来设置属性值,除此之外,其他方法都不能使用setter方法。由于值对象中的所有setter方法都是私有,消费方是没有机会直接调用这些setter方法的。这是保持值对象不变性的两个主要因素。
第二个构造函数用于将一个值对象复制到另一个新的值对象,即复制构造函数。该构造函数采用浅复制(Shallow Copy)的方式,因为它也是将构造过程委派给主构造函数的,先从原对象中取出各个属性值,再将这些属性值作为参数传给主构造函数。当然,我们也可以采用深复制(Deep Copy)或者克隆的方式,即为每个所引用的属性都创建一份自身的备份。然而,这种方式既复杂,也没有必要。当需要深度复制时,我们才考虑添加该功能。但是对于不变的值对象来说,在不同的实例之间共享属性是不会出现什么问题的。
现在,我们来实现值对象的策略部分:
这些无副作用方法的名字是重要的。虽然所有的方法都返回值对象(因为它们都是CQS查询方法),但没有为方法加上“get”前缀。这种方法使得代码与通用语言保持一致。使用getValuePercentage()只是技术上的用法,但是valuePercentage()则是一种流畅的,可读的语言表达。
下面一组方法包含了标准的equals(),hashCode()和toString()方法:
这里的 equals()方法用于检查不同值对象的相等性。通常来说,在比较相等性时,我们将省略对非null的检查。传入的参数对象必须与当前对象具有相同的类型。在类型相同时,equals()方法会对两个对象所有属性进行比较,当它们之间每组对应的属性都相等时,两个整体值对象则相等。
根据Java标准,equals()方法和hashCode()方法拥有相同的契约,即如果两个对象是相等的,那么它们的hashCode()方法也应该返回相同的结果。
BusinessPriority还剩下几个方法:
无参数构造函数是为一些框架准备的,比如Hibernate。由于该构造函数总是隐藏起来的,我们没有必要担心客户端会使用该构造函数来创建非法对象实例。在构造函数和setter/getter被隐藏的情况下,Hibernate依然可以以工作。这个无参数的构造函数使得Hibernate或其他工具能够对对象进行重建,比如重建保存在持久化存储中的对象实例。
这篇关于《实现领域驱动设计》— 值对象的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-23Springboot应用的多环境打包入门
- 2024-11-23Springboot应用的生产发布入门教程
- 2024-11-23Python编程入门指南
- 2024-11-23Java创业入门:从零开始的编程之旅
- 2024-11-23Java创业入门:新手必读的Java编程与创业指南
- 2024-11-23Java对接阿里云智能语音服务入门详解
- 2024-11-23Java对接阿里云智能语音服务入门教程
- 2024-11-23JAVA对接阿里云智能语音服务入门教程
- 2024-11-23Java副业入门:初学者的简单教程
- 2024-11-23JAVA副业入门:初学者的实战指南