写点什么

Scala 与 Spring:强强联合

  • 2010-08-02
  • 本文字数:8539 字

    阅读完需:约 28 分钟

导言

Scala 是门优秀的编程语言,它将简洁、清晰的语法与面向对象和函数式编程范式无缝融合起来,同时又完全兼容于 Java,这样 Scala 就能使用 Java 开发者所熟知的 Java API 和众多的框架了。在这种情况下,我们可以通过 Scala 改进并简化现有的 Java 框架。此外,Scala 的学习门槛也非常低,因为我们可以轻松将其集成到“众所周知的 Java 世界中”。

本文将介绍如何通过 Scala 整合当今世界最为流行的框架之一 Spring。Spring 不仅支持如依赖注入和面向方面的编程等高效的编程范式,还提供了大量的胶水代码与 Hibernate、Toplink 等框架以及 JEE 环境交互,后者更是可以保证 Scala 能平滑地融入到企业当中,毫无疑问,这是 Spring 的成功所在。

为了清楚地阐释 Scala 与 Spring 的整合原理,本文将使用一个简单的示例应用。这个应用会使用到 Scala、Spring 和 Hibernate/JPA,其领域模型如下图所示:

该领域模型展示了一个简化的社交网络应用:人与人之间可以彼此链接起来。

第一步

后面的讲解都将基于该领域模型。首先介绍如何实现一个泛型 DAO,并通过 Hibernate/JPA 使用 Scala 为 Person 实体实现一个具体的 DAO,该 DAO 的名字为 PersonDao,里面封装了 CRUD 操作。如下所示:

复制代码
val p1 = new Person(“Rod Johnson”)
val p2 = dao.findByName(“Martin Odersky”)
p1.link(p2)
personDao.save(p1)

第二步

接下来介绍如何将 Person 实体转换为一个“内容丰富”的领域对象,在调用 link 方法时,该对象内部会使用 NotificationService 执行额外的逻辑,这个服务会“神奇地”按需注入到对象中。下图展示了这一切:

复制代码
val p1 = Person(“Martin Odersky”) //the omission of the ‘new’ keyword is intentional
val p2 = dao.findByName(“Rod Johnson”)
p1.link(p2) //magic happens here
personDao.save(p1)

第三步

最后,本文将介绍 Spring 是如何从 Scala 的高级概念:特征(traits)中受益的。特征可以将内容丰富的 Person 领域对象转换为羽翼丰满的 OO 类,这个类能够实现所有的职责,包括 CRUD 操作。如下所示:

复制代码
Person(“Martin Odersky”).save

第一步:使用 Scala、Spring 和 Hibernate/JPA 实现 DAO

需求

毫无疑问,DAO 在设计上应该有一个泛型 DAO 和一个针对 Person 实体的具体 DAO。泛型 DAO 中应该包含基本的 CRUD 方法,如 save、remove、findById 和 findAll 等。由于是泛型,因此它处理的是类型而不是具体的实体实现。总的来说,这个泛型 DAO 具有如下的接口定义:

复制代码
trait GenericDao[T] {
def findAll():List[T]
def save(entity:T):T
def remove(entity:T):Unit
def findById(id:Serializable):T
}

Person 实体类的具体 DAO 应该增加一个特定于 Person 实体的 finder 方法:

复制代码
trait PersonDao extends GenericDao[Person] {
def findByName(name:String):List[Person]
//more finders here…
}

我们需要考虑如下具体的实现细节以便利用上 Scala 提供的众多富有成效的特性:

  • 关于集合:虽然底层的 JPA 实现并不知道所谓的 Scala 集合,但 DAO 接口返回的却是 Scala 集合类型(scala.List)而不是 Java 集合。因为 Scala 集合要比 Java 集合强大的多,因此 DAO 方法的调用者非常希望方法能够返回 Scala 集合。这样,我们需要将 JPA 返回的 Java 集合平滑地转换为 Scala 集合。
  • 关于回调:Spring 用于粘合 JPA、JMS 等框架的大多数胶水代码都是基于模板模式,比如 JpaTemplate、JmsTemplate 等。虽然这些模板通过一些便捷的方法在一定程度上隐藏了底层框架的复杂性,但很多时候我们还是不可避免地要直接访问底层的实现类,如 EntityManager、JmsSession 等。在这种情况下,Spring 通过 JpaCallback 等回调类来实现我们的愿望。回调方法 doIn…(…) 唯一的参数就是指向实现类的引用,比如 EntityManager。下面的示例阐述了这种编程模型:
复制代码
jpaTemplate.execute(new JpaCallback() {
public Object doInJpa(EntityManager em) throws PersistenceException {
//… do something with the EntityManager
return null;
}
});

上面的代码有两点值得我们注意:首先,匿名内部回调类的实例化需要大量的样板代码。其次,还有一个限制:匿名内部类 JpaCallback 之外的所有参数都必须是 final 的。如果从 Scala 的视角来看待这种回调模式,我们发现里面充斥的全都是某个“函数”的繁琐实现。我们真正想要的只是能够直接访问 EntityManager 而已,并不需要匿名内部类,而且还得实现里面的 doInJpa(…) 方法,这有点太小题大作了。换句话说,我们只需要下面这一行足矣:

复制代码
jpaTemplate.execute((em:EntityManager) => em.createQuery(…)// etc. );

问题在于如何通过优雅的方式实现这个功能。

  • 关于 getter 和 setter:使用了 Spring bean 的类至少要有一个 setter 方法,该方法对应于特定 bean 的名称。毫无疑问,这些 setter 是框架所需的样板代码,如果不使用构造器注入也能避免这一点岂不美哉?

实现

如果用 Scala 实现泛型与 Person DAO,那么上面提到的一切问题都将迎刃而解,请看:

复制代码
object GenericJpaDaoSupport {
implicit def jpaCallbackWrapper[T](func:(EntityManager) => T) = {
new JpaCallback {
def doInJpa(session:EntityManager ) = func(session).asInstanceOf[Object]}
}
}
import Scala.collection.jcl.Conversions._
class GenericJpaDaoSupport[T](val entityClass:Class[T]) extends JpaDaoSupport with GenericDao[T] {
def findAll():List[T] = {
getJpaTemplate().find("from " + entityClass.getName).toList.asInstanceOf[List[T]]
}
def save(entity:T) :T = {
getJpaTemplate().persist(entity)
entity
}
def remove(entity:T) = {
getJpaTemplate().remove(entity);
}
def findById(id:Serializable):T = {
getJpaTemplate().find(entityClass, id).asInstanceOf[T];
}
}
class JpaPersonDao extends GenericJpaDaoSupport(classOf[Person]) with PersonDao {
def findByName(name:String) = { getJpaTemplate().executeFind( (em:EntityManager) => {
val query = em.createQuery("SELECT p FROM Person p WHERE p.name like :name");
query.setParameter("name", "%" + name + "%");
query.getResultList();
}).asInstanceOf[List[Person]].toList
}
}

使用:

复制代码
class PersonDaoTestCase extends AbstractTransactionalDataSourceSpringContextTests {
@BeanProperty var personDao:PersonDao = null
override def getConfigLocations() = Array("ctx-jpa.xml", "ctx-datasource.xml")
def testSavePerson {
expect(0)(personDao.findAll().size)
personDao.save(new Person("Rod Johnson"))
val persons = personDao.findAll()
expect(1)( persons size)
assert(persons.exists(_.name ==”Rod Johnson”))
}
}

接下来解释上面的代码是如何解决之前遇到的那些问题的:

关于集合

Scala 2.7.x 提供了一个方便的 Java 集合到 Scala 集合的转换类,这是通过隐式转换实现的。上面的示例将一个 Java list 转换为 Scala list,如下代码所示:

  1. 导入 Scala.collection.jcl.Conversions 类的所有方法:
复制代码
import Scala.collection.jcl.Conversions._

这个类提供了隐式的转换方法将 Java 集合转换为对应的 Scala 集合“包装器”。对于 java.util.List 来说,Scala 会创建一个 Scala.collection.jcl.BufferWrapper。
2. 调用 BufferWrapper 的 toList() 方法返回 Scala.List 集合的一个实例。

下面的代码阐述了这个转换过程:

复制代码
def findAll() : List[T] = {
getJpaTemplate().find("from " + entityClass.getName).toList.asInstanceOf[List[T]]
}

总是手工调用“toList”方法来转换集合有些麻烦。幸好,Scala 2.8(在本文撰写之际尚未发布最终版)将会解决这个瑕疵,它可以通过 scala.collection.JavaConversions 类将 Java 转换为 Scala,整个过程完全透明。

关于回调

可以通过隐式转换将 Spring 回调轻松转换为 Scala 函数,如 GenericJpaDaoSupport 对象中所示:

复制代码
implicit def jpaCallbackWrapper[T](func:(EntityManager) => T) = {
new JpaCallback {
def doInJpa(session:EntityManager ) = func(session).asInstanceOf[Object]}
}

借助于这个转换,我们可以通过一个函数来调用 JpaTemplate 的 execute 方法而无需匿名内部类 JPACallback 了,这样就能直接与感兴趣的对象打交道了:

复制代码
jpaTemplate.execute((em:EntityManager) => em.createQuery(…)// etc. );

这么做消除了另一处样板代码。

关于 getter 和 setter

默认情况下,Scala 编译器并不会生成符合 JavaBean 约定的 getter 和 setter 方法。然而,可以通过在实例变量上使用 Scala 注解来生成 JavaBean 风格的 getter 和 setter 方法。下面的示例取自上文的 PersonDaoTestCase:

复制代码
import reflect._
@BeanProperty var personDao:PersonDao = _

@BeanProperty 注解告诉 Scala 编译器生成 setPersonDao(…) 和 getPersonDao() 方法,而这正是 Spring 进行依赖注入所需的。这个简单的想法能为每个实例变量省掉 3~6 行的 setter 与 getter 方法代码。

第二步:按需进行依赖注入的富领域对象

到目前为止,我们精简了 DAO 模式的实现,该实现只能持久化实体的状态。实体本身并没有什么,它只维护了一个状态而已。对于领域驱动设计(DDD)的拥趸来说,这种简单的实体并不足以应对复杂领域的挑战。一个实体若想成为富领域对象不仅要包含状态,还得能调用业务服务。为了达成这一目标,需要一种透明的机制将服务注入到领域对象中,不管对象在何处实例化都该如此。

Scala 与 Spring 的整合可以在运行期轻松将服务透明地注入到各种对象中。后面将会提到,这种机制的技术基础是 DDD,可以用一种优雅的方式将实体提升为富领域对象。

需求

为了说清楚何谓按需的依赖注入,我们为这个示例应用加一个新需求:在调用 Person 实体的 link 方法时,它不仅会链接相应的 Person,还会调用 NotificationService 以通知链接的双方。下面的代码阐述了这个新需求:

复制代码
class Person {
@BeanProperty var notificationService:NotificationService = _
def link(relation:Person) = {
relations.add(relation)
notificationService.nofity(PersonLinkageNotification(this, relation))
}
//other code omitted for readability
}

毫无疑问,在实例化完 Person 实体或从数据库中取出 Person 实体后就应该可以使用 NotificationService 了,无需手工设置。

使用 Spring 实现自动装配

我们使用 Spring 的自动装配来实现这个功能,这是通过 Java 单例类 RichDomainObjectFactory 达成的:

复制代码
public class RichDomainObjectFactory implements BeanFactoryAware {
private AutowireCapableBeanFactory factory = null;
private static RichDomainObjectFactory singleton = new RichDomainObjectFactory();
public static RichDomainObjectFactory autoWireFactory() {
return singleton;
}
public void autowire(Object instance) {
factory.autowireBeanProperties(instance)
}
public void setBeanFactory(BeanFactory factory) throws BeansException {
this.factory = (AutowireCapableBeanFactory) factory;
}
}

通过将 RichDomainObjectFactory 声明为 Spring bean,Spring 容器确保在容器初始化完毕后就设定好了 AutowireCapableBeanFactory:

复制代码
<bean class="org.jsi.di.spring.RichDomainObjectFactory" factory-method="autoWireFactory"/>

这里并没有让 Spring 容器创建自己的 RichDomainObjectFactory 实例,而是在 bean 定义中使用了 factory-method 属性,它会强制 Spring 使用 autoWireFactory() 方法返回的引用,该引用是单例的。这样会将 AutowireCapableBeanFactory 注入到单例的 RichDomainObjectFactory 中。由于可以在同一个类装载器范围内访问单例对象,这样该范围内的所有类都可以使用 RichDomainObjectFactory 了,它能以一种非侵入、松耦合的方式使用 Spring 的自动装配特性。毋庸置疑,Scala 代码也可以访问到 RichDomainObjectFactory 单例并使用其自动装配功能。

在设定完这个自动装配工厂后,接下来需要在代码 / 框架中定义钩子(hook)了。总的来说需要在两个地方定义:

  • ORM 层,它负责从数据库中加载实体
  • 需要“手工”创建新实体的代码中

自动装配 ORM 层中的领域对象

由于文中的示例代码使用了 JPA/Hibernate,因此在实体加载后需要将这些框架所提供的设备挂载到 RichDomainObjectFactory 中。JPA/Hibernate 提供了一个拦截器 API,这样可以拦截和定制实体加载等事件。为了自动装配刚加载的实体,需要使用如下的拦截器实现:

复制代码
class DependencyInjectionInterceptor extends EmptyInterceptor {
override def onLoad(instance:Object, id:Serializable, propertieValues:Array[Object],propertyNames:Array[String], propertyTypes:Array[Type]) = {
RichDomainObjectFactory.autoWireFactory.autowire(instance)
false
}
}

该拦截器需要做的唯一一件事就是将加载的实体传递给 RichDomainObjectFactory 的 autowire 方法。对于该示例应用来说,onLoad 方法的实现保证了每次从数据库中加载 Person 实体后都将 NotificationService 注入其中。
此外,还需要通过 hibernate.ejb.interceptor 属性将拦截器注册到 JPA 的持久性上下文中:

复制代码
<persistence-unit name="ScalaSpringIntegration" transaction-type="RESOURCE_LOCAL">
<provider>org.hibernate.ejb.HibernatePersistence</provider>
<property name="hibernate.ejb.interceptor" value="org.jsi.domain.jpa.DependencyInjectionInterceptor" />
</properties>
<!-- more properties here-->
</persistence-unit>

DependencyInjectionInterceptor 非常强大,每次从数据库中加载实体后它都能将在 Spring 中配置的服务注入其中。那如果我们在应用代码而非 JAP 等框架中实例化实体时又该怎么办呢?

自动装配“手工”实例化的领域对象

要想自动装配应用代码中实例化的实体,最简单也是最笨的办法就是通过 RichDomainObjectFactory 的方式显式进行自动装配。由于这个办法将 RichDomainObjectFactory 类与实体创建代码紧耦合起来,因此并不推荐使用。幸好,Scala 提供了“组件对象”的概念,它担负起工厂的职责,可以灵活实现构造逻辑。

对于该示例应用,我们采用如下方式实现 Person 对象以便“自动”提供自动装配功能:

复制代码
import org.jsi.di.spring.RichDomainObjectFactory._
object Person {
def apply(name:String) = {
autoWireFactory.autowire(new Person(name))
}
}

import 声明会导入 RichDomainObjectFactory 的所有静态方法,其中的 autoWireFactory() 方法会处理 RichDomainObjectFactory 单例对象。
Scala 对象另一个便利的构造手段就是 apply() 方法,其规则是拥有 apply 方法的任何对象在调用时可以省略掉.apply()。这样,Scala 会将对 Person() 的调用转给 Person.apply(),因此可以将自动装配代码放到 apply() 方法中。

这样,无需使用“new”关键字就可以调用 Person() 了,它会返回一个新的实体,返回前所有必要的服务都已经注入进去了,该实体也成为一个“富”DDD 实体了。

现在我们可以使用富领域对象了,它是可持久化的,也能在需要时调用其中的服务:

复制代码
val p1 = Person(“Martin Odersky”)
val p2 = personDao.findUniqueByName(“Rod Johnsen”)
p1.link(p2)
personDao.save(p1)

在继续之前,我们需要解释一下为何要用 Java 而不是 Scala 来实现 RichDomainObjectFactory,原因是由 Scala 处理 static 的方式造成的。Scala 故意没有提供 static 关键字,因为 static 与复合的 OO/ 函数式范式有冲突。Scala 语言所提供的唯一一个静态特性就是对象,其在 Java 中的等价物就是单例。由于 Scala 缺少 static 方法,因此 Spring 没法通过上文介绍的 factory-method 属性获得 RichDomainObjectFactory 这样的工厂对象。这样,我们就没法将 Spring 的 AutowireCapableBeanFactory 直接注入到 Person 对象中了。因此,这里使用 Java 而非 Scala 来利用 Spring 的自动装配功能,它能彻底填充 static 鸿沟。

第三步:使用 Scala traits 打造功能完善的领域对象

到目前为止一切尚好,此外,Scala 还为 OO 纯粹主义者提供了更多特性。使用 DAO 持久化实体与纯粹的 OO 理念有些许冲突。从广泛使用的 DAO/Repository 模式的角度来说,DAO 只负责执行持久化操作,而实体则只维护其状态。但纯粹的 OO 对象不仅有状态,还要有行为。

上文介绍的实体是拥有服务的,这些服务封装了一些行为性职责,但持久化部分并不在其中。为什么不把所有的行为性和状态性职责都赋给实体呢,就像 OO 纯粹主义者所倡导的那样,让实体自己负责持久化操作。事实上,这是习惯问题。但使用 Java 很难以优雅的方式让实体自己去实现持久化操作。这种设计严重依赖于继承,因为持久化方法要在父类中实现。这种方式相当麻烦,也缺少灵活性。Java 从概念上就缺少一个良好设计的根基,没法很好地实现这种逻辑。但 Scala 则不同,因为 Scala 有 traits。

所谓 trait 就是可以包含实现的接口。它类似于 C++ 中多继承的概念,但却没有众所周知的 diamond syndrome 副作用。通过将 DAO 代码封装到 trait 中,该 DAO trait 所提供的所有持久化方法可自动为所有实现类所用。这种方式完美地诠释了 DRY(Don’t Repeat Yourself)准则,因为持久化逻辑只实现一次,在需要的时候可以多次混合到领域类中。

对于该示例应用来说,其 DAO trait 如下代码所示:

复制代码
trait JpaPersistable[T] extends JpaDaoSupport {
def getEntity:T;
def findAll():List[T] = {
getJpaTemplate().find("from " + getEntityClass.getName).toList.asInstanceOf[List[T]]
}
def save():T = {
getJpaTemplate().persist(getEntity)
getEntity
}
def remove() = {
getJpaTemplate().remove(getEntity);
}
def findById(id:Serializable):T = {
getJpaTemplate().find(getEntityClass, id).asInstanceOf[T];
}
//…more code omitted for readability
}

作为一个传统的 DAO,该 trait 继承了 Spring 的 JpaDaoSupport,但它并没有提供 save、update 和 delete 方法(这些方法需要接收一个实体作为参数)转而定义了一个抽象方法 getEntity,需要持久化功能的领域对象得实现这个方法。JpaPersistable trait 在内部实现中使用 getEntity 来保存、更新和删除特定的实体,如下代码片段所示。

复制代码
trait JpaPersistable[T] extends JpaDaoSupport {
def getEntity:T
def remove() = {
getJpaTemplate().remove(getEntity);
}
//…more code omitted for readability
)

实现该 trait 的领域对象只需实现 getEntity 方法即可,该方法的实现仅仅是返回一个自身引用:

复制代码
class Person extends JpaPersistable[Person] with java.io.Serializable {
def getEntity = this
//…more code omitted for readability
}

这就是全部了。所有需要持久化行为的领域对象只需实现 JpaPersistable trait 即可。最后我们得到的是一个包含了状态和行为功能完善的领域对象,完全符合纯粹的 OO 编程的理念:

复制代码
Person(“Martin Odersky”).save

无论你是否为纯粹的 OO 理念的拥护者,这个示例都阐释了 Scala(尤其是 traits 概念)是如何轻松实现纯粹的 OO 设计的。

结论

本文示例介绍了 Scala 与 Spring 是如何实现互补的。Scala 简明、强大的范式(比如函数与特征)再结合 Spring 的依赖注入、AOP 和 Java AP 为我们 I 提供了更广阔的空间,相对于 Java 代码来说,Scala 的实现更具表现力、代码量也更少。
如果具有 Spring 和 Java 基础,Scala 的学习曲线非常低,因为我们只需要学习一门新语言就行,无需再学大量的 API 了。

Scala 和 Spring 所提供的众多功能使得这一组合成为企业采用 Scala 的最佳选择。总之,我们能以极低的代价迁移到更加强大的编程范式上来。

关于作者

Urs Peter 是 Xebia 的高级咨询师,专注于企业级 Java 和敏捷开发。它有 9 年的 IT 从业经历。在整个 IT 职业生涯中,他担任过不同角色,从开发者、软件架构师到 Scrum Master。目前,他在下一代的荷兰铁路信息系统项目中担任 Scrum Master,该项目部分使用 Scala 实现。他还是 Xebia 的一名 Scala 布道师和荷兰 Scala 用户组的活跃分子。

文中所用源代码

感兴趣的读者可以使用 git:git clone git://github.com/upeter/Scala-Spring-Integration.git 在 http://github.com/upeter/Scala-Spring-Integration 上下载完整的源代码并使用 maven 构建。

查看英文原文: Scala & Spring: Combine the best of both worlds


给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2010-08-02 00:0010555
用户头像

发布了 88 篇内容, 共 263.1 次阅读, 收获喜欢 8 次。

关注

评论

发布
暂无评论
发现更多内容

OpenHarmony 3.1 Release正式发布,标准系统全方位升级!

叶落便知秋

ZEGO 最后一公里网络传输的容灾及优化方案

ZEGO即构

后台开发 容灾 最后一公里

深入解析JVM-类加载机制

janyxe

Java JVM 类加载器 双亲委派 类加载机制

Linux驱动开发-编写按键驱动

DS小龙哥

4月月更 Linux驱动

java培训:java流中的异常处理方法分享

@零度

JAVA开发 java流

助力 60+ 市区管理建设,TDengine 联手数字政通打造智慧城市平台

TDengine

数据库 tdengine 时序数据库

大数据培训经典SQL面试题解析

@零度

sql 大数据开发

API 分页探讨:offset 来分页真的有效率吗?

爱好编程进阶

Java 面试 后端开发

1.6 TinkerPop 3.4简述

Geek_古藤模根

图数据库 Gremlin

Android C++系列:C++最佳实践3继承与访问控制

轻口味

c++ android ndk 4月月更

视频画质增强最优解:微帧科技视频超高清引擎

微帧Visionular

计算机视觉 视频增强 电影修复

我,机器学习工程师,决定跑路了

OneFlow

机器学习 深度学习 AI 程序人生 MLOps

1.8图数据库是什么?我为什么要关注它?

Geek_古藤模根

图数据库 Gremlin

自己动手写Docker系列 -- 6.1 ip分配管理

Go Docker 4月月更

GPU底层技术、全球市场格局分析

Finovy Cloud

人工智能 云计算 云服务器 GPU服务器 GPU算力

如何撰写出有效的帮助文档内容?

小炮

帮助文档

最受欢迎的5种编程语言各有什么特点或优点?

源字节1号

软件开发 后端开发 编程语言、

找工作,你被“卷”到了吗?

InfoQ写作社区官方

招聘 就业 热门活动 拉勾招聘

低代码实现探索(四十)前端全局配置

零道云-混合式低代码平台

Pipy 性能基准测试的思考与实践

Flomesh

代理 benchmark Pipy

【直播回顾】OpenHarmony 3.1 Release版本南北向关键能力解读

OpenHarmony开发者

OpenHarmony 直播回放

Apache ShardingSphere 5.1.1 正式发布

SphereEx

Apache 数据库 开源 ShardingSphere SphereEx

大型IM系统有多难?万字长文,搞懂异地多活!

WorkPlus

1.5 本书源代码、样例程序和数据介绍

Geek_古藤模根

[Day15]-[动态规划]鸡蛋掉落

方勇(gopher)

LeetCode 动态规划 数据结构与算法、

大咖实战|Kubernetes自动伸缩实现指南分享

云智慧AIOps社区

Docker 云计算 Kubernetes 容器 云原生

TiUP:TiDBAer 必备利器

PingCAP

TiDB

CentOS 停止维护,一文看懂升级迁移路径

亚马逊云科技 (Amazon Web Services)

Tech 专栏

1.9 术语简介

Geek_古藤模根

图数据库 Gremlin

web前端培训如何使用CSS连接数据库

@零度

CSS 前端开发

源码级别的广播与监听实现

阿Q说代码

spring源码 实战 监听 4月月更 广播

Scala与Spring:强强联合_Java_Urs Peter_InfoQ精选文章