CUBA:如何准备上线
“在我电脑上是好的呢!”现在看来,这句话更像是调侃开发人员的一个段子,但是“开发环境与生产环境”之间的矛盾依然存在。作为开发者,你需要记住,你写的应用总会有在生产环境上线的一天。在本文中,我们将讨论一些 CUBA 的特性,能帮助你避免在应用程序上线时遇到问题。
编码准则
使用服务(Services)
几乎每一个 CUBA 应用程序都会实现一些业务逻辑算法。实现业务逻辑的最佳实践就是所有的业务代码都在 CUBA 服务(CUBA Services)中实现。所有其他的类,包括界面控制器、应用程序监听器等等,应当将业务逻辑全部交由服务来代理执行。这个方式的好处是:
1. 对于一个业务逻辑来说,只在一处实现。
2. 可以在不同的地方调用此业务逻辑,并能将其作为开放的 REST 服务。
请注意,业务逻辑中一般会包括条件、循环等。在理想情况下,对服务的调用应该是单线的。比如,我们假设在控制器中有如下代码:
Item item = itemService.findItem(itemDate);
if (item.isOld()) {
itemService.doPlanA(item);
} else {
itemService.doPlanB(item);
}
如果你看到这种代码,可以考虑在 itemService 服务中创建一个单独的方法 processOldItem(Date date) ,将这段分支判断逻辑迁移到服务中实现,因为这段代码看上去更像是业务逻辑的一部分。
由于界面和 API 可能会是不同的团队开发,所以保持业务逻辑只存在一个地方可以避免生产环境中应用程序行为的不一致。
使用无状态
当开发 web 应用程序时,需要时刻记得将会有多个用户同时使用你的程序。在代码中,意味着有些代码会被多个线程同时执行。几乎所有的应用程序组件,比如服务、bean 以及事件监听器都会受到多线程执行的影响。这里的最佳实践就是让组件保持无状态。 也就是说不要引入共享的可变类成员。 使用局部变量并将会话级特定的信息保留在用户之间不共享的应用程序存储中。 例如,可以在用户会话中保留少量可序列化的数据。
如果需要共享更多的数据,可以使用数据库或者专门的内存共享存储,比如 Redis。
使用日志
有时,生产环境会出一些问题。当问题产生时,很难搞清楚到底是什么引起的问题,因为没办法在生产环境调试。所以为了使你自己、团队以及支撑团队将来的工作更加轻松,为了能明白问题怎么发生的,以便能重现,最好在应用程序中添加适量的日志。
此外,日志还扮演了被动监测的角色。在应用程序重启、升级或者重新配置之后,管理员通常通过查看日志确保所有的程序都启动成功。
日志还能帮助解决可能不是在你的应用程序中发生的问题,而是应用程序集成的其他服务中。 例如,要弄清楚为什么支付网关会拒绝某些交易,可能需要记录所有数据,然后在与支撑团队沟通时提供这些数据。
CUBA 使用了经过验证的 slf4j 库作为日志切面和 logback 实现。 只需要向类代码中注入日志工具类,就可以用了:
@Inject
private Logger log;
然后直接调用:
log.info("Transaction for the customer {} has succeeded at {}", customer, transaction.getDate());
请记住,日志消息需要有意义并包含足够的信息以便了解在应用程序中到底发生了什么。
还有,CUBA 自带性能统计日志,所以你可以看到应用程序对服务器资源的消耗情况。当收到用户抱怨应用程序很慢时,这些日志非常有用,用它们能更快的找到性能瓶颈。
异常处理
异常是非常重要的,在应用程序发生错误时,它们能提供非常有价值的信息。因此,第一条规则就是在代码中永远不要忽视任何异常。使用 log.error() 方法创建一个有含义的消息,添加上下文和 stack trace。这个消息是你能用来定位错误的唯一信息。
如果你有编码规范,那么添加一章关于错误处理的章节吧。
我们看一个例子 - 上传一张用户的资料照片至应用程序。这个资料照片会使用上传 API 服务并存储在 CUBA 的文件存储中。
如果不处理异常,代码是这样:
try {
fileUploadingAPI.putFileIntoStorage(uploadField.getFileId(), fd);
} catch (Exception e) {}
如果发生了错误,鬼才会知道。用户会很奇怪,我的照片怎么上不去?
下面这个好一些,但是还不理想:
try {
fileUploadingAPI.putFileIntoStorage(uploadField.getFileId(), fd);
} catch (FileStorageException e) {
log.error (e.getMessage)
}
日志中会保存一条错误信息,但是我们只捕获了特定的异常类。也没有提供有关上下文的信息:文件的名称是什么,谁曾尝试上传文件。 而且,由于没有 stack trace 的信息,因此很难找到异常发生的位置。 还有,用户根本不会知道这里发生了错误。
下面这个应该是一个比较好的方式:
try {
fileUploadingAPI.putFileIntoStorage(uploadField.getFileId(), fd); } catch (FileStorageException e) {
throw new RuntimeException("Error saving file to FileStorage", e);
}
我们知道错误是什么,也没有丢失原始异常,添加了一条有意义的消息文本。 调用方法将收到有关异常的通知。 我们可以将当前用户名以及上传文件名添加到消息中,以增加更多上下文数据。 这是 CUBA Web 模块的示例。
在 CUBA 应用程序中,由于其分布式特性,你可能在 Core 模块和 Web 模块具有不同的异常处理规则。 文档中有一个关于异常处理的特殊部分。 在实现异常处理时,建议先阅读该章节。
环境相关的配置
开发应用程序时,请试试将应用中与环境相关的部分独立出来,然后试用功能开关和配置文件根据环境切换这些部分的功能。
使用恰当的服务实现
CUBA 中的任何服务都包含两个部分:接口(服务 API)及其实现。有时候,服务的实现依赖于部署环境。我们拿文件存储服务作为例子。
在 CUBA 中,可以使用文件存储来保存发送至应用程序的文件,然后可以在服务中使用这些文件。其默认实现是使用服务器上的本地文件系统保存文件。
但是,当你部署应用程序至生产服务器时,如果使用了云环境或者集群部署,这个实现就不一定能正常工作了。
如果要使用针对环境的不同服务实现,CUBA 支持运行时配置,可以根据启动参数或者环境变量使用特定的服务实现。
对于上面这个例子,如果我们想用 Amazon S3 作为生产环境文件存储的实现,可以用下面的方式指定这个 bean:
<beans profile="prod"><bean name="cuba_FileStorage" class="com.haulmont.addon.cubaaws.s3.AmazonS3FileStorage"/></beans>
然后,在设置了下面这个属性时,会自动启用基于 S3 的实现:
spring.profiles.active=prod
因此,当开发 CUBA 应用程序时,尝试将环境相关的服务先定义出来,然后为每种环境启用适当的服务实现。不要写下面这种代码:
If (“prod”.equals(getEnvironment())) {
executeMethodA();
} else {
executeMethodB();
}
此时,需要实现一个单独服务 myService 带有一个方法 executeMethod() 和两个实现类,然后通过配置文件来配置。这样在调用此服务的地方,只有:
myService.executeMethod();
干净、简单、易维护。
将配置参数外部化
如果可能的话,将应用程序的配置提取到属性文件中。 如果参数将来可以修改(即使概率很小),也需要将其外部化。代码中应尽量避免将连接URL、主机名等作为纯字符串,并且千万不要到处复制粘贴。 在代码中更改硬编码(hardcode)值的成本要高得多。 邮件服务器地址、用户的照片缩略图大小、没有网络连接时的重试次数,等等所有这类配置都是需要外部化的属性。 使用配置接口,并在你的类中注入即可获取配置值。
使用运行时配置将环境相关的属性放在单独的文件中。
还是拿支付网关举个例子。 首先,我们不会在开发过程中使用真正的钞票来测试功能。 因此,会有一个用于本地环境的网关存根,一个在网关端用于预发布测试环境的测试 API 和一个用于生产环境的真实网关。 显然,这些环境的网关地址不同。
别这么写代码:
If (“prod”.equals(getEnvironment())) {
gatewayHost = “gateway.payments.com”;
} else if (“test”.equals(getEnvironment())) {
gatewayHost = “testgw.payments.com”;
} else {
gatewayHost = “localhost”;
}
connectToPaymentsGateway(gatewayHost);
而是,定义三个属性文件:dev-app.properties、test-app.properties 以及 prod-app.properties,分别在其中定义 payment.gateway.host.name 的值。之后,在项目中添加一个配置接口:
@Source(type = SourceType.DATABASE)
public interface PaymentGwConfig extends Config {
@Property("payment.gateway.host.name")
String getPaymentGwHost();
}
然后可以在代码中注入该接口并使用:
@Inject
PaymentGwConfig gwConfig;
// 其它代码
connectToPaymentsGateway(gwConfig.getPaymentGwHost());
这样的话,代码会很简单,所有的设置都在属性文件中,如果环境有变,也不需要在代码中查找了。
添加网络超时处理
开发时,要始终认为通过网络进行的服务调用不可靠。当前用于 Web 服务调用的大多数第三方库都基于同步阻塞通信模型。也就是说,如果从主执行线程调用 Web 服务,则应用程序将暂停直到收到响应为止。
即使在单独的线程中执行 Web 服务调用,由于网络超时,该线程也有可能永远无法恢复执行。
有两种类型的超时:连接超时和数据读取超时。
在应用程序中,这些超时类型应分开处理。我们还是看看支付网关的例子。此时,读取超时可能明显长于连接超时。银行交易可以处理很长的时间,数十秒,最多几分钟。但是连接应该很快,那么这里我们可以将连接超时设置为一个稍长的时间,比如10秒。
超时时限的配置可以移至属性文件,这基本上是一个最典型的例子了。为需要通过网络交互的所有服务设置该值。以下是服务bean定义的示例:
<bean id="paymentGwConfig" class="com.global.api.serviceConfigs.GatewayConfig"><property name="connectionTimeout" value="${xxx.connectionTimeoutMillis}"/><property name="readTimeout" value="${xxx.readTimeoutMillis}"/></bean>
在你的代码中,需要包含一段专门用来处理超时的代码。
数据库准则
数据库是几乎所有应用程序的核心。 在部署和更新生产环境时,不破坏数据库是非常重要的。 除此之外,工作负载方面,开发人员工作站上的数据库显然与生产服务器不同。 因此,可能要实施以下描述的一些做法。
生成特定环境的数据库脚本
CUBA 中,我们能生成创建和更新应用程序数据库的 SQL 脚本。在生产环境第一次数据库创建之后,只要数据模型发生改动,CUBA 框架都会生成更新脚本。
文档中有一个章节专门介绍生产环境的数据库更新。请在第一次部署生产环境之前仔细阅读。
最终建议,在更新数据库之前先做备份。这样做能节省很多时间,并且也不用担心出问题。
考虑多租户
如果你的项目是一个多租户项目,请在项目开始时就将多租户的场景设计好。
CUBA 通过扩展插件支持多租户,该扩展会引入一些应用程序数据模型以及数据库查询语句逻辑的改动。比方说,对于租户场景下的实体,会添加 tanantId 这一列。因此,所有查询这些实体的查询语句都被隐式的做了修改,以便能使用该列作为查询条件。也就是说,如果你需要写自定义 SQL 查询,需要考虑此列的存在。
请注意,由于上面提到的特定功能,向在生产环境中运行的应用程序添加多租户功能可能很棘手。 为了简化迁移,请将所有自定义查询保留在同一应用程序层中,最好保留在服务中或单独的数据访问层中。
安全性考虑
对于可以被多个用户访问的应用程序,安全性起着重要的作用。 为避免数据泄漏、未经授权的访问等,需要认真考虑安全性。下面会介绍一些原则,这些原则可以帮助改善系统的安全性。
安全的编码
提到安全性,首先就要写出能阻止问题产生的代码。 可以在此处找到Oracle提供的有关安全编码的参考。 下面我们介绍其中的一些(也许很明显的)建议。
准则 3-2 / INJECT-2: 避免动态 SQL
大家都知道,动态创建的 SQL 语句(带有不受信任的输入)容易受到命令注入的攻击。 在 CUBA 中,你很可能需要执行 JPQL 语句,因此,也请避免使用动态 JPQL。 如果需要添加参数,请使用适当的类和语句语法:
try (Transaction tx = persistence.createTransaction()) {
// get EntityManager for the current transaction
EntityManager em = persistence.getEntityManager();
// create and execute Query
Query query = em.createQuery(
"select sum(o.amount) from sample_Order o where o.customer.id = :customerId");
query.setParameter("customerId", customerId);
result = (BigDecimal) query.getFirstResult();
// commit transaction
tx.commit();
}
准则 5-1 / INPUT-1: 验证输入
在使用前,必须验证来自不受信任来源的输入。 恶意的输入可能会导致问题,无论是通过方法参数还是外部流。 比如整数值溢出和通过在文件名中包含“ ../”序列的目录遍历攻击。 在CUBA中,除了在代码中检查之外,还可以在 GUI 中使用验证器。
以上只是安全编码原理的几个简单例子。 请仔细阅读该指南,能在多方面改善你的代码安全性。
保护个人数据安全
由于法律要求,某些个人信息应受到保护。 在欧洲,有通用数据保护条例(GDPR),在美国的医疗应用中,有健康保险携带和责任法案(HIPAA)要求等,中国近年来对个人信息的保护也上升到了一个新的高度,不少公司因为泄露个人隐私的问题而被调查。因此,在实施项目时要考虑到这一点。
使用 CUBA 可以设置各种权限,并使用角色和访问组限制对数据的访问。 在访问组中,还可以定义各种约束,以防止未经授权访问个人数据。
但是访问权限控制只是确保个人数据安全的一部分。 数据保护标准和行业特定要求还有很多。 在规划应用程序的体系结构和数据模型之前,需要先了解这些要求。
修改或者禁用默认用户和角色
当使用 CUBA 框架创建应用程序时,系统会创建两个默认用户:admin 和 anonymous。最好在系统上到生产环境之后,客户开始用之前修改这两个用户的默认密码。可以手动修改或者在 30-....sql 的初始化脚本中添加 SQL 语句修改。
按照 CUBA 文档的推荐步骤可以很好的配置生产环境需要使用的角色。
如果应用程序需要支持复杂的组织结构,可以考虑在组织机构级别为每个分支机构创建本地管理员,而不是创建多个“超级管理员”。
导出角色至生产环境
在第一次部署之前,通常需要从开发环境或预发布(staging) 环境将角色和访问组复制到生产服务器。 在CUBA中,可以使用内置的管理界面来执行此操作,而不必手动从数据库复制。
导出角色和权限配置可以使用 Administration->Roles 界面内的导出按钮。下载导出的文件之后,可以在生产环境使用该界面进行导入。访问组也有类似的按钮操作。
配置应用程序
生产环境通常与开发环境不同,应用程序配置也会不同。也就是说,需要进行一些其他检查,以确保应用程序在生产时能够平稳运行。
日志配置
确保已针对生产环境正确配置了日志子系统:日志级别已设置为所需级别(通常为 INFO),并且在应用程序重启时不会删除日志。 可以参考文档了解正确的日志设置和有用的 Logger。
如果使用 Docker,请使用 Docker volumes 将日志文件存储在容器外部。
为了进行正确的日志记录分析,可以部署特殊的工具来收集、存储和分析日志。 比如 ELK 家族或者 Graylog。 建议将日志记录软件安装到单独的服务器上,以避免对应用程序造成性能影响。
在群集配置中运行
可以将CUBA应用程序配置为在群集中运行。 如果决定使用此功能,则需要注意你的应用程序架构,否则,可能会有不符合期望的行为出现。 下面几点针对群集环境需要专门调整的最常用功能:
计划任务
如果要在应用程序中执行计划任务,例如每天生成报告或每周发送电子邮件,则可以使用相应的框架内置功能。 但是,请想象一下,如果你是一位同时收到了三封相同营销电子邮件的客户。你会觉得开心吗?如果计划任务在三个群集节点上都执行,则很可能会发生这种情况。 为避免这种情况,最好使用 CUBA 任务计划程序,该程序可以创建单例(singleton)任务。
分布式缓存
缓存是可以提高应用程序性能的东西。有时开发人员会尝试把几乎所有的数据都进行缓存,因为现在内存非常便宜。但是,当应用程序使用集群部署在多台服务器上时,将在服务器之间分配缓存,还会做缓存同步。如果同步过程发生在相对较慢的网络连接上,这可能会增加响应时间。所以在决定添加更多缓存之前(尤其是在集群环境中),需要先进行负载测试并衡量性能。
结论
CUBA 平台简化了开发过程,所以很可能你会提前完成开发并开始考虑投入生产。可是,无论是否使用 CUBA,部署都不简单。如果能在开发的早期阶段就开始思考部署流程并遵循本文所述的简单规则,那么你的应用程序投入生产的过程应该会很顺利,所需的工作量很小,并且不会遇到严重的问题。