# 平台开发组件库(简版)

本文介绍V5平台丰富的组件库,建议收藏,在不同应用场景下可以使用平台现有组件开发。

# 环境及编译

完整本地开发环境搭建,详见【开发文档>快速开始>搭建开发环境】章节。

后台开发工具推荐IntelliJ IDEA,其次推荐Spring tools suite、Eclipse for J2EE。

V5最新版本工程使用JDK8,允许并推荐使用JDK8特性(Lambda、Stream等)来提升开发效率、缩减代码量。

V5 V6.1及更早期版本可能需要使用JDK6来编译,具体情况请找熟悉老版本的指导人了解。

# 包引用规范

  • 禁止对JDK自带的sun包的调用:从JDK 1.7开始,Oracle未将以sun开头的类包加载到JVM启动加载的类包中
// Bad code
import sun.misc.BASE64Encoder;
  • 禁止使用Lombok,虽然能让JavaBean足够优雅,但强制要求所有IDE安装Lombok插件,并且DEBUG调用链很麻烦,同时对不熟悉Lombok的开发会有很大困惑,解释代价高。

  • 日志使用Apache Commons logging,所有的Log实例必须由CtpLogFactory创建。完整开发注意事项,详见【开发文档>CTP技术平台>LOG日志】章节。

  • 要求尽量使用com.seeyon.ctp下的平台接口开发,不推荐使用com.seeyon.v3x下的类。

// Not recommended
com.seeyon.v3x.xxx
// Good code
com.seeyon.ctp.xxx
  • 禁止[import *]偷懒式引入大量不必要的包,请显性引用需要的具体类,注意随手清理未被使用的import。
// Bad code
import java.util.*;
// Good code
import java.util.List;
import java.util.Map;
  • 推荐使用Apache common、Google guava等主流开源核心库,能让你在String、集合、并发、缓存等方面代码更加优雅简便。
// Instead of "new ArrayList()"
List<String> list = com.google.common.collect.Lists.newArrayList();
list.add("");
// Instead of "list != null && list.size() > 0"
boolean isNotEmpty = org.apache.commons.collections4.CollectionUtils.isNotEmpty(list);
// Cache of LRU
CacheBuilder.newBuilder().maximumSize(100);

# CTP平台Util工具类

完整平台工具类API,详见【开发文档>Open API>Java Doc】。

本节全部介绍CTP平台Util工具类,主要来自于com.seeyon.ctp.util。

如果你有代码权限,建议开发从ctp-core/src/com/seeyon/ctp/util和seeyon-util/src/com/seeyon/ctp/util两个地方逐个阅读每个工具类,半天时间就能熟悉所有工具类。

  • BeanUtils类对象操作工具,提供将一个Bean对象的属性值复制到另一个Bean对象的能力,也提供copy复制Bean对象能力。
TimeViewInfo v = BeanUtils.clone(viewInfo);
// 将summary中的值同步到hisColSummary中,拷贝共同属性的值
public void save(ColSummary summary) throws BusinessException {
    HisColSummary hisColSummary = new HisColSummary();
    BeanUtils.convert(hisColSummary, summary);
    ......
}
  • 禁止使用SimpleDateFormat转换日期,要求使用com.seeyon.ctp.util.Datetimes来实现日期对象管理,尤其是涉及国际化时区更要用这个工具类。
// Bad code
new SimpleDateFormat().format(date)
// Good code
Date firstTime = Datetimes.getTodayFirstTime();
Date curDate = Datetimes.parse(beginDate, Datetimes.datetimeStyle);
String deadline = Datetimes.format(deadlineDate, Datetimes.datetimeStyle);
  • ParamUtil获取页面form提交Map数据,提供getString、getInt等工具类快速从Map中获取指定key的值
Map<String ,Object> params = (Map<String, Object>) ParamUtil.getJsonParams();
String name = ParamUtil.getString(params, "name", true);
String memo = ParamUtil.getString(params, "memo");
  • Base64工具类用于转码、解码操作,基于RFC 2045协议

注意Base64只是一种二进制的编码方式,常用于网络图片、文件流数据传输,千万不要用来当做加密算法使用。

String encodeStr = Base64.encodeString(str);
String decodeStr = Base64.decode2String(str);
  • FileUtil文件封装工具类,用于获取文件编码、快速复制、创建、删除文件及文件夹操作。
"utf-8".equalsIgnoreCase(FileUtil.detectEncoding(string : full path));
FileUtil.copyFile(File : source,File : newTarget)
  • HttpClientUtil包装了远程调用网络地址的方法,支持get/post两种方式。一定一定一定要设定超时时间,(若未设置)当出现远程地址不通时会出现大量请求挂起从而导致系统拥塞。

如需GET、POST、PUT、DELETE这些更全面的HTTP请求工具,推荐使用org.springframework.web.client.RestTemplate

// 3000是超时时间
 HttpClientUtil h = new HttpClientUtil(3000);
 try {
  h.open("http://news.sina.com.cn", "get");
  int status = h.send();
  h.getResponseBodyAsString("GBK");
 }
 catch (IOException e) {
  logger.error(e.getLocalizedMessage(),e);;
 }
 finally {
  h.close();
 }
 
 HttpClientUtil h = new HttpClientUtil(5000);
 try {
  h.open("http://oa.com/something.do", "post");
  h.addParameter("name", "ta");
  h.addParameter("password", "123456");
  h.setRequestHeader("Cookie", "Language=zh_CN;UserAgent=PC");
  int status = h.send();
  h.getResponseBodyAsString("GBK");
 }
 catch (IOException e) {
  logger.error(e.getLocalizedMessage(),e);;
 }
 finally {
  h.close();
 }
  • HttpSessionUtil.getSessionId(HttpServletRequest)可以方便取出当前用户的SESSIONID

  • IOUtility通用IO流文件操作工具类,提供了流的复制下载、迁移、转字符串、转Byte等操作

private void download(HttpServletResponse response,File toFile) throws Exception{
        InputStream fis =new FileInputStream(toFile);
        // 取得文件名。
        String filename = toFile.getName();
        response.reset();
        // 设置response的Header
        response.addHeader("content-disposition","attachment; filename=" +  filename);
        response.addHeader("Content-Length", "" + toFile.length());
        response.setContentType("application/octet-stream");
        // 以流的形式下载文件。
        IOUtility.copy(fis, response.getOutputStream());
}
  • LightWeightEncoder轻量级转码工具类,在Base64做了一个转码结果位移操作,简单说就是把字符串A简单混淆成字符串B。非加密算法,请勿用于加密场景。
// 混淆
String encStr = LightWeightEncoder.encodeString(str)
// 解除混淆
String decStr = LightWeightEncoder.decodeString(str)
  • ObjectToXMLUtil提供Java对象转换为XML字符的工具方法,基于dom4j实现。XML转换为Java对象请务必调用XXEUtil.safeParseText实现XML外部实体注入(XML External Entity)的防护,再用Dom4j的Document解析实现。

不要使用Xsteam组件,老版本爆出存在远程代码执行漏洞,安全要求高的公司会审计不允许使用Xsteam。

// object to xml
String xml = ObjectToXMLUtil.objectToXML(Object);
// xml to object,务必使用XXEUtil.safeParseText
Document document = XXEUtil.safeParseText(xmlData); 
Element rootElement = document.getRootElement();
  • PropertiesUtil封装了java.util.Properties的常用操作,能够将指定.properties文件转换为Properties对象,以及修改保存.properties文件内容。
  • ReqUtil用于快速提取HttpRequest中的值
public ModelAndView mvc(HttpServletRequest request, HttpServletResponse response) throws Exception {
    String name  = ReqUtil.getString(request, "name", ""defaultValue"");
    // Instead of : Long.valueOf(request.getParameter("id"));
    Long id = ReqUtil.getLong(request, "id");
    ......
}
  • RespUtil提供了sendJsonResponse方法,用于直接向页面输出一个text/json格式的数据流
public static void sendJsonResponse(HttpServletResponse response, Object jsonObj) throws IOException {
        String json = JSONUtil.toJSONString(jsonObj);
        response.setContentType("text/json; charset=utf-8");
        response.setHeader("Cache-Control", "no-cache");
        PrintWriter pw = null;
        try {
            pw = response.getWriter();
            pw.write(json);
  • ServerDetector用于判断当前应用服务器(中间件)型号,比如判断是否为Tomcat、Weblogic、WebSphere、国产东方通、国产金蝶等
/**
     * 判断当前产品是否运行在国产化环境。
     */
    public static boolean isDomestic() {
     return ServerDetector.isApusic() || ServerDetector.isTongWeb() || JDBCAgent.isDMRuntime() || JDBCAgent.isKingBaseesRunTime();
    }
  • Strings工具类提供字符串的常用操作
  • UUIDLong.longUUID()常用于生成一个不重复的数据库主键ID
  • XXEUtil为XML解析相关的实体增加XXE防护的工具类,XML外部实体注入(XML External Entity)是一种注入XML中的攻击方式,我们在将XML解析为Java对象时需要做相关防护,所以XXEUtil.safeParseText(XML)是标配写法。
// Good code
org.dom4j.Document document = XXEUtil.safeParseText(XML);
// Bad code
org.dom4j.Document document = (new SAXReader()).read(path);
  • ZipUtil文件的压缩和解压工具类

# CTP实用辅助类

  • com.seeyon.ctp.common.SystemEnvironment系统环境相关工具类,极其常用,比如你想判断当前环境是否为集群、当前服务器的base目录地址、附件目录地址都可以从这里获取到。用于获取当前服务器的base目录、seeyon上下文目录,临时文件temp目录、日志文件目录、共享附件目录、用户配置的外网地址、当前中间件类型、本次服务器启动时间、单机还是集群模式、获取停用插件列表、直接停止服务、是否为国产化环境(中间件和数据库判断)、Redis是否开启、是否为生产环境。
  • com.seeyon.ctp.common.AppContext系统级上下文工具类,万能类,极其常用:
// 获取到当前登录的人员信息,常广泛用于
和Manager中。PS:实际我们推荐只在Controller、Rest中使用,而Manager和Dao则是在方法上以参数的形式将User传入。
User user = AppContext.getCurrentUser();

// 下面这个方法是对ThreadLocal的包装,此方法常用于在同一个线程栈+调用链很长的类与类共享参数所用。
// 比如:前端发送一个请求到后端,常规的调用链是:前端Form->后端拦截器->Controller->Manager->Dao,那么在Dao层希望获取到当前登录的用户以便SQL只获取当前登录用户的数据。而当前登录用户信息在拦截端能够获取到,那么拦截器就可以通过putThreadContext将用户信息写入,随后Dao层通过getThreadContext就能拿到拦截器传入的信息。
// 拦截器端:将当前用户信息写入到ThreadLocal
AppContext.putThreadContext("CURRENT_USER",User);
// Dao层:由于Dao与拦截器在同一个Thread里面,所以就能获取到拦截器传入的信息
User user = AppContext.getThreadContext("CURRENT_USER");
// 当然,拦截器发现自己写入的线程已经无需使用时,则要主动做下销毁动作,防止不必要的资源开销。
AppContext.removeThreadContext("CURRENT_USER");

// 不推荐使用:获取本次请求的Request或Response对象,本身ServeltRequest和response只能存在于C层,请勿在Manger甚至Dao层滥用。允许在Rest接口中使用。
AppContext.getRawRequest();
AppContext.getRawResponse();

// hasPlugin用于判断指定插件是否存在,常用于没有指定插件时屏蔽相关代码逻辑操作
boolean hasCAP = AppContext.hasPlugin("cap4");
// 伪代码示例:保存一条数据后,需要通知全文检索更新索引。开发需要调用全文检索API,但如果系统没有全文检索插件,API会是Null,如果不做防护就会报空指针异常,此时我们就可以使用hasPlugin做下防护。
if(AppContext.hasPlugin("index")){
    // 只有index插件时,indexApi才会被Spring初始化,也不会出现Null对象
    indexApi.buildIndex(colId,Enum.COLL);
}

// hasResourceCode用于判断当前登录用户是否有指定菜单资源权限
// 我们系统有非常多的菜单资源,每个菜单资源都有一个名称以及唯一表示resourceCode,后台可以配置什么人员可以使用什么菜单,不是所有人都有所有菜单权限。那么在应用中,某些特殊操作要求做判断时,你可能就会用到这个判断。
if(!AppContext.hasResourceCode("F01_newColl")){
    // 提示当前用户没有 新建协同操作权限
}

// 普通用户的resoueceCode从哪里查询:select m.NAME as '菜单国际化key',m.EXT7 as '菜单中文名',m.RESOURCE_CODE as 'resourceCode',m.RESOURCE_NAVURL as '菜单跳转URL' from priv_menu m
system.menuname.BBS 讨论 F114_bbs /bbs.do?method=listBoard
doc.video.square 视频广场 F04_docVideoSquare /doc.do?method=docIndex&openLibType=6

// V8.1开始,管理员的resourceCode从哪里查询:/seeyon/WEB-INF/cfgHome/metadata/entRoleStd或govRoleStd两个文件夹的XML,示例如下(如下XML属性code就是resourceCode):
<menu code="org_tree" sort="1" name="common.org.chart" desc="组织架构图" icon="icon-company" target="mainfrm" plugin="" url="/organization/account.do?method=showTree&amp;from=orgModelManager" ></menu>
<menu code="org_account_setting" sort="2" name="menu.group.orgaAccount.setting" desc="单位管理" icon="icon-company" target="mainfrm" plugin="" url="/organization/account.do?method=viewAccount" ></menu>
  • com.seeyon.ctp.common.constants.ApplicationCategoryEnum系统内所有标准产品模块枚举值,此对象经常用来做应用的分类。另外还有个类似的类:com.seeyon.ctp.common.ModuleType。
  • com.seeyon.ctp.common.flag.SysFlag不同产品线功能切割枚举,这个类的作用很抽象,对于客开无用,对于标准产品研发人员有很大作用。

举例说明:V8.1 CTP平台下发布A6+、A8+企业版、A8+集团版、A8-N企业版、A8-N集团版等5个版本,现在新产品开发了一个“双因子认证”的功能,由于此功能在集团客户才使用,为避免给企业版客户带来解释成本,产品经理要求:“双因子认证”功能只针对A8+和A8-N集团版开放。恰好双因子认证不是一个收费插件,技术上是一个通用组件,为了屏蔽此功能,则可以使用SysFlag的特性。

public enum SysFlag {
    // 登记一条枚举,false表示不开通双因子认证能力
    doublefactorEnable(false, false, true, false, true),
    ......

    SysFlag(Object a6Flag, Object a8enterpriseFlag, Object a8groupFlag, Object a8NenterpriseFlag, Object a8NgroupFlag) {
        ......
    }
}

- com.seeyon.ctp.common.constants.ProductEditionEnum,CTP平台下集成的所有版本枚举信息,A6+、A8+、A8-N都各有枚举注册在其中。不推荐调用此枚举类,如果应用上希望某个功能在A6+不显示,在A8+显示,请使用上一个SysFlag特性。

- com.seeyon.ctp.common.constants.SystemProperties系统级配置集合,Seeyonconfig应用配置器下的配置和/seeyon/WEB-INF/cfgHome/plugin/插件/pluginProperties.xml中注册的信息都会放入SystemProperties。

String prodcutId = SystemProperties.getInstance().getProperty("system.ProductId")

// 调用点,通过直接调用枚举就能知道当前系统是否支持该功能
if(SysFlag.doublefactorEnable.getFlag() == true){
    // 显示双因子认证功能
}

# CTP业务工具类

  • 国际化:后台使用ResourceUtil.getString("xxx")调用;前端JSP使用${ctp:i18n('xxx')}调用;前端js在引用国际化资源库之后,使用$.i18n("xxx")调用。

如果前端Javascript需要调用到指定国际化key,需要在cfgHome/plugin/插件/i18n下维护一个export_to_js.xml文件,开发可以参考别的插件的编写方法。

更多国际化开发内容详见【开发文档>CTP技术平台>国际化】章节!

  • 用户操作日志:用户的重要操作需要写入到平台的操作日志中,注意不是log.info这种写入文本日志,而是使用AppLogManager接口写入到数据库中统一管理。
@Inject
private AppLogManager appLogManager;
appLogManager.insertLog(...);
  • 二维码:平台提供了简易的二维码组件
com.seeyon.ctp.common.barCode.manager.BarCodeManager barCodeManager;
// 保存二维码,二维码会以附件的形式存储到附件目录中
barCodeManager.saveBarCode(...);
// 从附件目录中取出指定二维码文件
barCodeManager.getBarCodeFile(...);
  • 临时异步线程:众所周知,Java提供了new Thread().start()这种很简易开启异步线程的方法,但为了保障平台线程安全可靠,我们要求开发不要主动调用start()方法,而是交给平台线程池来启动。
// Bad code
Thread newT = new xxxThread();
newT.start();

// Good code getDefaultThreadPool()是默认的线程池,常规操作用这个就够了
Thread newT = new xxxThread();
ExecutorServiceFactory.getDefaultThreadPool().submit(newT);

// Good code
ExecutorServiceFactory.getDefaultThreadPool().submit(new Runnable(){...});

// 初始化一个固定数量的线程池,"DemoWorker-" + AppContext.getCurrentTenantId()是这个线程池的标识
ExecutorServiceFactory.setThreadNum("DemoWorker-" + AppContext.getCurrentTenantId(), 1, 10);
ExecutorServiceFactory.getFixedThreadPool("DemoWorker-" + AppContext.getCurrentTenantId()).submit(thread);

// 初始化一个动态扩张的线程池,类似于数据库连接池的概念,默认初始化一个最小值,随后根据写入的线程数量弹性自增
ExecutorServiceFactory.setThreadNum("WF_AutoSkipRunnable_Pool", 30, 50);
ExecutorServiceFactory.setqueueSize("WF_AutoSkipRunnable_Pool",10000);
ExecutorService asynEventExecutors = ExecutorServiceFactory.getCachedThreadPool("WF_AutoSkipRunnable_Pool");
for(){
    asynEventExecutors.execute(thread);
}
  • 常驻异步任务处理:AsynchronousBatchTask,有些动作需要我们开一个常驻后台任务一直运行,比如消息处理器,就要一直留存一个独立线程,有消息过来就处理,没有就做个短休眠。

AsynchronousBatchTask的功能特性是:需要通过Spring Bean管理;通过.addTask(...)方法写入预执行数据到队列中;默认8秒执行一次批量任务,可以重写getIntervalTime()方法来设置默认执行时间,单位S秒;重点是重写doBatch()方法,这个方法用于做数据的批量处理。

在线程堆栈中带"AsynchronousBatchTask"标识的都是常驻异步任务。

// 一个典型的示例
public class TimeViewWorker extends AsynchronousBatchTask<TimeViewEventBean>{
    @Override
    public void doBatch(List<TimeViewEventBean> data){
        // 批量处理消费方,这个自己实现批处理操作
     try {
      timeViewManager.saveTimeViewEventBO(data);
     }catch (BusinessException e) {
      log.error("", e);
     }
    }
}

// 生产方
AppContext.getBean("timeViewWorker").addTask(data);
  • 系统消息:PC右下角的消息盒子、致信消息、移动端的消息均使用UserMessageManager调用发送。消息组件有些潜规则:如果PC在线,移动端就收不到消息。
@Inject
private UserMessageManager userMessageManager;
// 发送系统消息
userMessageManager.sendSystemMessage(...);
// 更新指定消息状态为已读,场景常见于用户收到某个数据消息但一直没打开,过了一段时间这条数据被自动处理了,为了保证最佳体验可以考虑更新状态
userMessageManager.updateSystemMessageStateByUserAndReference(...);
  • 系统全局开关:SystemConfig,这个对象保留的是<key,value>格式,value只有enable和disable两个选项,非0即1。这个变量存储了各种必要的配置,比如是否开启验证码,是否附件加密,是否开启密码强度控制等。

数据来自两个地方:

  1. select * from ctp_config where CONFIG_CATEGORY = 'system_switch'
  2. /seeyon/WEB-INF/cfgHome/spring/spring-config-manager.xml中关于systemConfig的defaultValue默认配置
// 调用方式如下IConfigPublicKey中有所有开关的配置
@Inject
private SystemConfig systemConfig;
boolean canReMove = SystemConfig.ENABLE.equals(systemConfig.get(IConfigPublicKey.CAN_DEL_PENDING));
  • 动态配置管理:configManager,这个对象保留的是<key,value>格式,key有自己自定义,value是字符串并且由自己自定义。本对象的作用是在数据库中提供一个快速存储动态配置参数的地方。你可以把configManager当作一个数据表级别的Properties文件来看。

简单点说,我现在要做一个数据初始化操作,只希望系统安装好第一次启动时才执行这个逻辑。那么,我肯定需要在某个地方存储一下是否执行过初始化的标记,如果是以前,你可能需要自己建张表维护这条数据,非常浪费表空间,那么configManager提供了非常方便的读写操作,减少了多余表的维护。

configManager的表数据存储于select * from ctp_config,大家的少量动态配置数据都可以放入这里面

@Inject
praivate ConfigManager configManager;
init(){
        ConfigItem config = configManager.getConfigItem("dataInit", "enable");
     if(config != null) {
      String enable = config.getConfigValue();
      if("true".equals(enable)) {
       // 未初始化,下面执行初始化操作
       ......

       // 初始化完成后,使用addConfigItem更新状态为已经初始化完成
       configManager.addConfigItem("dataInit", "enable","false");
      }
     }
}
  • 系统枚举组件:没什么好说的,系统内枚举管理使用这个组件,可以open.seeon了解如何使用。
// 注意枚举组件beanname=enumManagerNew
private EnumManager enumManagerNew;
  • Excel解析和下载:FileToExcelManager,将数据导出成Excel或CSV是很常见的动作,平台提供了FileToExcelManager.save这种比较简便实用的导出接口。
@Inject
private FileToExcelManager fileToExcelManager;
......
HttpServletResponse response = ...
try {
    DataRecord dataRecord = new DataRecord();
    dataRecord = getDataRecord(...);
    // 将Excel导出
    fileToExcelManager.save(response, dataRecord.getTitle(), dataRecord);
} catch (Exception e) {
    logger.error(e.getLocalizedMessage(),e);
}

//读取第一个sheet的Excel值
fileToExcelManager.readExcel(file);
  • 平台级异常:BusinessException是CTP平台通用异常,Controller、Manager、Dao中定义异常都默认使用此类,CTP平台级异常路径:com.seeyon.ctp.common.exceptions
public class DemoController extends BaseController {

    public ModelAndView demoUrl(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws BusinessException{
        ......
    }

}
  • 附件组件:AttachmentManager,各功能应用对附件的上传和下载归Attachment模块管,附件组件与平台前端标签一起合并使用,可实现连贯的上传、保存、展现操作。使用附件组件上传的数据,在ctp_attachment表中存储有记录数据。
// 前端JSP,定义附件组件按钮示例
<div id="attFileDomain" class="comp" comp="type:'fileupload',attachmentTrId:'Att',applicationCategory:'6',checkSubReference:false,canFavourite:false,canDeleteOriginalAtts:true,originalAttsNeedClone:true,callMethod:'uploadAttachment',delCallMethod:'uploadAttachment',takeOver:false,noMaxheight:true" attsdata='${attListJSON}'></div>

// 保存附件,支持多种传参形式,建议阅读注释文档,选择准确的调用方式
attachmentManager.create(......);
// 删除数据库中附件数据,但附件文件未做物理删除
attachmentManager.deleteByReference(,);
// 删除数据库中附件数据,同时附件文件也做物理删除
attachmentManager.removeByReference(,);
// 获取附件信息
List<Attachment> atts = attachmentManager.getByReference(,);
  • 上传下载组件
  • 关联文档
<div id="attachmentTR" style="display:none;"></div>
<div class="comp" comp="type:'assdoc',applicationCategory:'30',canDeleteOriginalAtts:true,attachmentTrId:'position1', modids:'1,3,6'" attsdata='${attachmentJSON}'></div>
  • java.net.URLEncoder.encode和java.net.URLDecoder.decode常用于对URL中带中文的字符进行编码和解码,防止出现乱码问题
  • 日志处理统一使用Apache Common Log,LOG引用严格按照如下代码示例执行,日志记录有讲究,请务必理解下面的写法深意。
private static Log LOGGER = CtpLogFactory.getLog(XXXManager.class);

// 记录DEBUG级别日志标准写法LOGGER.isDebugEnabled()打头,想想这样写的好处
if(LOGGER.isDebugEnabled()) {
    LOGGER.debug("The value "+value+" is "+data);
}

try {
    ......
} catch(Exception e) {
    // catch代码只能有两种处理方式:第一种就是向上抛出异常;第二种就是LOG.error(string,throwable);
    // Bad code 直接拼接exception堆栈是错误的写法,这样会导致LOG日志异常信息不明确
    LOGGER.error("全文检索删除会议失败" + e);
    // Good code 下面是标准的异常处理写法
    LOGGER.error("全文检索删除会议失败", e);
}

# 数据库、Hibernate持久层、SQL规范

  • 数据库不要使用触发器、视图、存储过程,这将给标准客户安装部署升级带来实施复杂度。
  • 数据库表一般要求默认一个ID主键(bigint类型UUID,非自增),不允许建立外键。
  • 持久层使用Hibernate,尽量少用Hibernate的一些开箱即用的特性,这些简便的特性在小项目很方便,但在大项目将带来灾难性的问题。
  • Hibernate hbm mapping配置不要有one-to-many这类外键关系。
  • DBAgent是HSQL包装类,JDBCAgent是JDBC SQL包装类,开发无需自己获取Connection,使用这两个包装好的Agent可以对数据库进行增删改查操作。这两个Agent只允许在Dao层使用,不允许在Manager甚至Controller使用。
// Good code
public class OrgDaoImpl implements OrgDao {
    public xxx() {
        List r1 = DBAgent.find(hql,params,flipinfo);
        int r2 = DBAgent.count(hql);
        DBAgent.saveAll(List<Po>);
        DBAgent.get(id);
        DBAgent.delete(x);
    }
}
// Bad code
public class OrgManagerImpl implements OrgManager {
    public xxx() {
        DBAgent.find(hql,params,flipinfo);
    }
}
  • 关于JDBCAgent,开发一定一定一定要注意手动close连接,否则错误的编写方法会导致连接池泄漏。而DBAgent则交由Spring事务管理,无需显性close连接。

自7.1SP1版本,推荐使用JDBCAgent(boolean b1, boolean b2)来创建JDBC连接:

  1. b1为true表示使用原始的数据库Connection构造JDBCAgent;为false则使用Spring管理的数据库连接,推荐默认false
  2. b2为true表示使用完毕之后无需手动close,由平台代理autoClose;为false表示JDBCAgent使用完毕之后手动close,一般close操作放在finally中,推荐默认false
// new JDBCAgent(false则使用Spring管理的数据库连接, false表示显性close连接)
JDBCAgent jdbc = new JDBCAgent(false, false); // 等同于new JDBCAgent();
try {
 jdbc.xxxxx
} catch (Exception e) {
 logger.error("", e);
} finally {
        // 关键代码:new JDBCAgent(false, false)之后务必显性jdbc.close连接,否则会出现连接池泄漏
 jdbc.close();
}
  • 不要使用DBAgent.merge和DBAgent.saveOrUpdate(Hibernate智能代理:不存在的数据执行insert,存在的数据执行update),如果你有一部分数据要保存一部分要更新,则分别调用save和update方法。
  • 批量写入数据使用DBAgent的savePatchAll和updateAll

根据注释,DBAgent.savePatchAll比saveAll性能更好

// Bad code
for(obj){
    DBAgent.save(obj);
}
// Good code
DBAgent.savePatchAll(list<obj>);
  • Manager是业务层,方法命名受事务管理,具体的事务隔离级别详见:/seeyon/WEB-INF/cfgHome/spring/spring-default.xml
  • 慎重使用"From xxxPO"这种简易HQL,不仅存在潜在性能问题,同时当你对查询出来的结果进行set操作时,会自动触发Hibernate的update潜规则。
List<CtpAffair> affair = DBAgent.find("From CtpAffair");
for(affair){
    affair.getTitle(); // 数据库title=张三
    // 巨坑:执行set操作后,set的值将会自动更新到数据库
    affair.setTitle("李四"); // 数据库title=李四
}
  • SQL in超过1000时,在Oracle等数据库上会报错,查询效率也会指数级降低。在必须使用in(超过1000条数据)时,推荐每次分成999条、分多次SQL查询,最后汇总。
List<Long> ids // ids.size() > 1000
List<List<Long>> pages = Lists.partition(ids, 999);
for (List<Long> idList: pages) {
    // idList.size() == 999
    // 伪代码,仅供参考
    DBAgent.find("select id from demo where id in (?)",idList);
}
  • SQL注入是一种危害性极大的安全漏洞,注入源来自开发将SQL条件进行字符串拼接。防止SQL注入的方法:要求所有SQL需要使用预编译的形式开发,条件变量采用占位符的形式写入。
public find(String name){
    // Bad code 存在SQL注入
    DBAgent.find("select id from demo where name = '" + name+"'");
    // Good code 其中name使用":name"或"?"预编译占位符的形式,能防护SQL注入
    DBAgent.find("select id from demo where name = :name",name);
}
  • SQL语句中的Like同样存在SQL注入风险,需要使用平台的SQLWildcardUtil.escape()方法对模糊查询条件进行特殊字符过滤,这样能防护SQL注入
String hql = "select id from demo where name like :name";
// Bad code
params.put("name", "%" + name + "%");
// Good code
params.put("name", "%" + SQLWildcardUtil.escape(name) + "%");
DBAgent.find(hql,params);
  • 如无必要,少用数据库自带的特殊函数,因为我们要兼容多款数据库,尽快使用通用SQL编写代码。如果不同数据库确实有差异,需要针对不同库写不同的SQL代码。
  • 判断当前是什么数据库,使用JDBCAgent下面的包装类,提供了多种方式。
方案一:推荐,使用JDBCAgent.isXXXRuntime()方法
是否为SQLServer数据库:{com.seeyon.ctp.util.JDBCAgent.isSQLServerRuntime()}
是否为Oracle数据库:{com.seeyon.ctp.util.JDBCAgent.isOracleRuntime()}
是否为MySQL数据库:{com.seeyon.ctp.util.JDBCAgent.isMySQLRuntime()}
是否为PostgreSQL数据库:{com.seeyon.ctp.util.JDBCAgent.isPostgreSQLRuntime()}
是否为达梦数据库:{com.seeyon.ctp.util.JDBCAgent.isDMRuntime()}

// 方案二
String dbType = JDBCAgent.getDBType();
if ("microsoft sql server".equals(dbType )){
    ......
}

// 方案三:getDatabaseType()和getDBType()的关系是:getDBType() = getDatabaseType().toLowerCase()
String dbType = JDBCAgent.getDatabaseType();

// 方案四:获取方言
Dialect dialect = JDBCAgent.getDialect();
if (dialect instanceof MySQLDialect) {
    ......
}
  • SQL性能调优:**禁止在for中循环调用SQL,这个是最最最常见的性能问题。**不要偷懒:因为Dao没有提供批量方法就逐个调用,请务必编写批量获取数据的接口。
// Bad code
for(id){
    list.add(DBAgent.get(id));
}
// Good code : 多次SQL查询改为一次SQL查询
list = DBAgent.find("select xx from demo where id in (:ids)");

// Bad code 如果别人的API没有提供批量操作,请一定要通知别人提供批量操作的接口,不要用下面的方式写代码
for(xxx){
    demoApi.update(obj);
}
// Good code 编写批量操作数据的接口
demoApi.getBath(list<obj>);
  • SQL性能调优:禁止使用HQL "From xxPO Where xxx" 或 SQL "SELECT * FROM xxx"这类全量SQL查询命令,请按需查询指定列结果。尤其是字段过多、LOB大字段多、数据量大的表,查询全列性能很差。
// Bad code
DBAgent.find("From CtpAffair Where ...");
// Good code
List<Map> result = DBAgent.find("SELECT new Map(c.id as id,c.name as name) From CtpAffair c Where ...");
  • SQL性能调优:判断某条数据在Database中是否存在请用count。
String hql = "select id from demo where title = ?";
// Bad code :通过find把结果集查询出来,再通过list.size判断数据是否存在,这种方法及其不省电
List result = DBAgent.find(hql);
if(result.size() > 0){
    return true;
}else{
    return false;
}

// Good code , 类似于: select count(id) from Demo ,通过查询结果返回影响行数。
int result = DBAgent.count(hql);
if(result > 0){
    return true;
}else{
    return false;
}
  • SQL性能调优:建表之初就需要预估表数据量,对频繁查询排序的字段增加相应索引,尽量创建多字段组合索引,少创建单一索引,组合索引也能让单字段生效。

影响SQL查询效率的点:SQL条件字段、排序字段,需要针对查询条件字段和排序字段进行索引建立

// 使用explain用来分析SQL性能是基本功,开发必备技能
explain select id from demo where yyy;
  • SQL性能调优:多表关联代码尽量少用HQL(表关联灵活性不够),推荐使用SQL能提升效率,并且有更大的调整空间。
// Not recommended
Select x from CtpAffair ca,ColSummary cs where ca.objectId = cs.id and xxx
// Recommended
Select x from ctp_affair ca left join col_summary cs on cs.id = ca.object_id where xxx
  • SQL性能调优:少使用is not null、is null、<>、or这类匹配条件,这类语句会导致SQL无法走索引
// Bad code:带or不走索引,会导致SQL整体效率低
select id from demo where age > 20 or age < 10;
// Good code:使用union能保证两条SQL都走索引,效率很高
(select id from demo where age > 20) union (select id from demo where age < 10);
  • SQL性能调优:不要让列字段默认值为NULL,如果是空值也推荐尽量赋一个默认值,这样SQL查询效率是提升的。

如果对带有Null的字段进行SQL排序,某些数据库下空值会排在前面,代码还不好修改

// Bad code
demo.setAge(null); // 不推荐默认赋空值
DBAgent.save(demo);
select id from demo where age is null;
// Good code
demo.setAge(-1); // 推荐默认赋一个值
DBAgent.save(demo);
select id from demo where age = -1;
  • SQL性能调优:一条SQL不要关联超过3张表,在每张表有都一定数据量的情况下,表关联后查询效率很低。减少多余表关联的方法:放弃一定的范式规则,在表中增加冗余字段。
// 如果下面这条SQL查询效率很低时,可以想办法在demo表中冗余存储ddemo.object字段
select d.title from demo d left join ddemo dd xxx where dd.object = yyy;
// Good code : 适当冗余存储关联表数据,采用单表查询会明显提升SQL效率
select d.title from demo d where d.ddemoObject = yyy;
  • SQL性能调优:在表数据量足够大的时候,使用一条SQL表联查还不如分成两条SQL查询。
// 假设下面两张表的数据都很大,那么这条SQL在高并发下平均执行时间可能很长,但又无法增加冗余字段解决时,可以分两条SQL查询
select d.id from demo d left join ddemo dd on d.id = dd.dId where dd.object = yyy;

// 分两条SQL反而能让性能提升
List<Long> ids = DBAgent.find("Select dd.id from ddemo dd where dd.object = yyy");
DBAgent.find("Select d.id from demo d where d.ddId in (:ids)",ids);
  • SQL性能调优:SQL查询务必要做分页,CTP的持久层分页对象是FlipInfo。

# Spring Bean、类加载顺序、初始化要求

  • CTP使用Spring来管理Bean对象,默认使用XML配置进行Bean注入,配置文件存放于各插件下:/src/main/webapp/WEB-INF/cfgHome/plugin/插件名称/spring
  • Controller、Manager、Dao都交给Spring管理,允许set方法注入或@Inject注解注入。严禁在成员变量中使用AppContext.getBean()强制注入,严禁在构造函数中强制注入。
public class DemoController extends BaseController {
    // Bad code 不允许这种注入方法,此法会导致demoManager无法按照Spring默认编排的顺序初始化,而造成循环依赖,初始化失败!
    private DemoManager demoManager = (DemoManager)AppContext.getBean("demoManager");
    
    public DemoController(){
        // Bad code,交由Spring管理的Bean尽量不要重写构造函数
        demoManager = (DemoManager)AppContext.getBean("demoManager");
    }

    // Good code 标准的对象注入方式
    private FileManager fileManager;
    public void setFileManager(FileManager fileManager) {
        this.fileManager = fileManager;
    }
    
    // Good code 注解的形式注入,无需Set方法
    @Inject
    protected SpaceApi spaceApi;
  • 注入要求:Controller允许注入Manager,Manager允许注入Dao。严禁Controller注入Dao,或反向注入!
  • Rest类未受Spring管理,也不要注册到Spring XML中管理,所有继承了BaseResource的Rest接口类允许通过AppContext.getBean申明式加载Spring Bean对象。
// 以下写法是被允许的
public class DemoResources extends BaseResource{
  private static final Log LOGGER = CtpLogFactory.getLog(DemoResources.class);
  private WorkflowApiManager wapi = (WorkflowApiManager)AppContext.getBean("wapi");
  private AffairManager affairManager = (AffairManager) AppContext.getBean("affairManager");
  • 禁止在Spring XML中使用init-method,此时所有Bean尚未初始化完成,init-method中如果调用了别的Bean会出现调用失败等问题。
<!-- Bad code 绝对禁止使用init-method -->
<bean name="" class="" init-method="init" ></bean>
  • 有开发场景需要在启动系统过程中做一些初始化操作,比如定时任务初始化,请统一使用SystemInitializer机制,详见open.seeyon。
public class DemoManagerImpl extends AbstractSystemInitializer implements DemoManager {
    // 经典开头:养成记录日志的好习惯
    private Log LOGGER = CtpLogFactory.getLog(DemoManagerImpl.class);
    // 使用注解标准注入Bean
    @Inject
    private OrgManager orgManager;
    @Inject
    private IndexApi indexApi;
    
    // 重写AbstractSystemInitializer下的initialize方法,该方法的执行时间是:系统所有Spring初始化完成之后,根据平台默认任务编排依次执行继承了SystemInitializer的initialize方法
    @Override
    public void initialize() {
        // 此时所有Bean已经初始化完成,可以放心调用所有Bean
        orgManager.xxx
    }
  • 一些操作Bean的实用API都在AppContext中。
// 根据Spring管理的Bean名称获取Bean实例(带bean缓存以优化性能),非singleton的bean不适用
AppContext.getBean(beanName); 
// 根据Spring管理的Bean名称获取Bean实例,不走性能优化缓存,适用于非singleton的bean获取
AppContext.getBeanWithoutCache(beanName);

/* 根据Spring管理的Bean类型获取Bean实例Map,key为bean id或name,value为Bean实例  
*  前面SystemInitializer就是使用此场景,CTP通过getBeanOfType(SystemInitializer.class)找到所有注册了这个Initializer的对象,再for循环执行每个对象的initialize方法   */
Map<String, SystemInitializer> all = AppContext.getBeansOfType(SystemInitializer.class);
for(all){
  systemInitializer.initialize();
}

# 关于循环依赖

因为代理后对初始化的影响,bean之间循环依赖可能会导致FactoryBean is not fully initialized yet异常

需要手动对循环依赖的bean进行处理

比如

OrgDirectManager 依赖 RoleManager 依赖 OrgDirectManager

// 修改OrgDirectManager,不注入,去掉setRoleManager方法,使用Inject注解
    @Inject
    protected RoleManager roleManager;

OrgDirectManager 依赖 SpaceApi 依赖 SpaceManager 依赖 OrgDirectManager

// 修改OrgDirectManager,不注入, 去掉setSpaceApi,使用Inject注解
    import com.seeyon.ctp.util.annotation.Inject;
    @Inject
    protected SpaceApi     spaceApi;

OrgDirectManager 依赖 OrgManager 依赖 OrgDirectManager

// 修改OrgDirectManager,不注入, 去掉setOrgManager,使用Inject注解
    import com.seeyon.ctp.util.annotation.Inject;
    @Inject
    protected OrgManager       orgManager;

# 安全

更多安全开发内容详见【开发文档>快速开始>安全篇】章节!

# 前端

  • 我们的产品需要兼容IE、Chrome、Edge、360、国产化浏览器等各种浏览器,请尽量使用ES5的语法开发,避免高级语法在IE低版本浏览器无法使用。
  • 平台提供启动OA服务时自动压缩js的能力,只需通过XML配置,代码位置在:/ctp-common/src/main/webapp/common/compressconfig/compressconfig.xml,我们常见的all-min.js就在这个里面配置。
<!-- 压缩js示例 -->
<js>
    <inputfile><![CDATA[/messageLinkConstants.js]]></inputfile>
    <inputfile><![CDATA[/main/common/js/jquery-ui.draggable-min.js]]></inputfile>
    <inputfile><![CDATA[/common/js/ui/seeyon.ui.common-debug.js]]></inputfile>
    <inputfile><![CDATA[/common/js/ui/seeyon.ui.dialog-debug.js]]></inputfile>
    <inputfile><![CDATA[/common/js/ui/seeyon.ui.progress-debug.js]]></inputfile>
    <inputfile><![CDATA[/common/SelectPeople/js/orgDataCenter.js]]></inputfile>
    <outputfile isObscure="false"><![CDATA[/portal/portal-min.js]]></outputfile>
</js>
  • 所有js、css、html等静态文件尾部都需要带时间戳,防止浏览器缓存导致前端错误。

原理:主流浏览器在拉取xxx.js、xxx.css静态文件后默认会缓存文件,以减少刷新页面时重复向服务器下载静态文件的麻烦。但如果开发人员修改了js等静态文件,如果没有机制告知浏览器则无法重新下载新的js文件。

解决方法:在引用静态文件的所有地方都加一个后缀时间戳,每次修改了静态文件后,都更新这个时间戳,浏览器就会认为是新文件,随后执行下载更新。

如果你遇到修改客户服务器上的js文件不生效的话,可以尝试修改下引用这个js文件的html、jsp时间戳就能解决问题。

// .jsp中使用{ctp:resSuffix()}标签
<script type="text/javascript" charset="UTF-8" src="${path}/xxx.js${ctp:resSuffix()}"></script>
<link rel="stylesheet" type="text/css" href="${path}/xxx.css${ctp:resSuffix()}"/>

// .html中使用?V=STATIC_SUFFIX,这个"V=STATIC_SUFFIX"在CI构建时会被转换为一个字符串变量。如果是客开则注意自己按照生产态的格式写死字符串变量即可。
<!-- 开发态 -->
<link rel="stylesheet" href="/STATIC_PATH/common/all-min.css?V=STATIC_SUFFIX"/>
<!-- CI构建后,生产态 -->
<link rel="stylesheet" href="/seeyon/common/all-min.css?V=V8_1_20210525210000"/>
  • 开发前端页面时需要引用平台级变量,比如上下文、国际化等变量值,我们针对JSP和HTML提供了相应的解决方案:JSP中引用common_header.jsp和common_footer.jsp实现;Html中引用
// .jsp中
<%@ include file="/WEB-INF/jsp/common/common_header.jsp"%>
<body>
....
<%@ include file="/WEB-INF/jsp/common/common_footer.jsp"%>
<script ...>
</body>

// .html中引入如下请求地址
<head>
创建人:admin
修改人:het