将 Jekyll 的网站部署到七牛 CDN 上

我的个人网站 Dan’s Workspace 是放在日本的 AWS 上, 由于众所周知的原因, 国内访问起来有点吃力.

我这里是通过 Jekyll 搭建的个人网站. 这里考虑一些简单的免费的解决方案.

将一些 google 提供对的静态文件(比如字体, js库什么的)用国内的一些源去替换

通过浏览器抓包, 应该能看到不少请求时到 google 字体库的 http://fonts.googleapis.com, 或者一些 google 的公共资源库 http://ajax.googleapis.com. 国内访问很困难, 不过 360 提供了一个国内的 前端公共库CDN服务

用法也非常简单, 全文搜索一下, 把调用的google的地址做个替换就行了, 别的地方都不变.

把静态文件放到 CDN 上

这里我使用的是 七牛云存储, 新注册的免费体验用户不限时间, 而且对于个人站长来说基本也够用.

首先 注册新用户, 并验证邮箱后重新登录.

之后创建一个空间, 为你的空间起一个名字, 需要全局唯一. 注意下这个空间一定要设置为公开访问的, 不然别人无法访问.

在 空间设置 -> 域名设置 里对七牛的cdn域名进行修改和记录.

然后就是把我们的静态资源文件上传到七牛空间上. 因为 Jekyll 生成的网站整个都是静态的, 所以理论上 可以把整个网站都放上去, 对于体验账号, 只可以上传富媒体文件, 也就是 图片/css/js 之类的, 也就够用了.

一张一张上传肯定要疯, 还在七牛在 developer.qiniu.com 提供了一个同步工具 qrsync, 配置方法也非常简单, 把 src 指向 jekyll build 生成的 _site 文件夹.

上传成功的以后就能在内容管理中就能看到刚刚静态资源文件, 上传会保持原本的目录结构, 方便我们做迁移.

最后, 我们修改原有的资源访问地址, 全文搜索一下, 基本地址应该都在 assets 或者 images 这样的文件夹.

如果之前的访问地址 为 /assets/css/main.css, 那么新的地址应该就是 {cdn地址}/{qrsync前缀}/assets/css/main.css.

这里介绍个偷懒的地方, 直接在 _config.yaml 里新建一个包含cdn地址和qrsync前缀的值:

cdn: “http://7xk6nq.com1.z0.glb.clouddn.com/blog”

那么在全文就可以使用 {{ site.cdn }} 进行使用.

其它 CDN 的解决方案

前面说的也差不多了, 最后介绍一个七牛提供的 开放静态资源 CDN, 有兴趣的前端开发朋友可以去看看 http://www.staticfile.org/

通过 markdown 和 freemarker 渲染邮件

部门要做一个简单的提测邮件模板, 取代每次提测时都要手动发邮件的功能, 我实现其中一部分文字渲染的功能. 通过 markdown 语法对提测内容进行格式化.

之前的邮件模板是

可见非常的丑陋.

由于我自己喜欢用markdown语法写邮件, 对这种table的表格信息非常反感, 决定稍稍修改一下提交的样式.

第一步是解决 java 渲染 markdown 语法的问题, 这个好办, 我用的这个库 markdownpapers-doxia-module, maven 的 repo 在这里.

接下来是解决生成好了的 html 如何美化. 当然你可以自己去写 css, 我前端功底太差, 实在搞不定. 我是从 [MOU][http://25.io/mou/] 这个 markdown 编辑器中导出了一份 css 文件(这里推荐一下这个软件, 真的非常好用, 如果能后同步到 evernote 就更好了).

代码这里就不贴了, 需要的自己下载:

Download ClearnessDark.css Download Clearness.css Download GitHub.css Download GitHub2.css

我处理渲染的逻辑是, 首先把需要提交的数据全部渲染到一个 markdown 文件, 然后再把这个 markdown 文件渲染到 email 正文中. 那么这里需要自己去设计两个文件:

**1. markdown 文件的模板 template/emailTemplate.md **

这个很简单, 把需要用户填写的地方留出来就行了:

# [提测] ${req.projectName!"unknow"}

> **开发人员:** ${req.projectLeader!"unknow"}

> **测试人员:** ${req.qaName!"unknow"}

> **产品经理:** ${req.pmName!"unknow"}

---

## 相关需求

${req.demand!""}

## Release Notes

${req.releaseNotes!""}

## 测试要点

${req.testMainPoints!""}

## 相关信息

* 相关应用: ${req.relatedApps!"unknow"}

* 上线时间: ${req.plannedTime?string["yyyy-MM-dd hh:mm:ss"]}

## 备注

${req.otherRemark!"无"}

** 2. email html 的模板 template/emailTemplate.ftl **

<html>
<head>
    <title></title>

    <meta charset="utf-8">

    <style>
    </style>
</head>

<body>

${content}

</body>
</html>

需要注意的是, 这个 html 中由于 blog 篇幅的限制, 我这里把css的配置删掉了, 由于 email 无法 link 外部的 css文件, 所以需要把前面下载的 css 文件的内容贴到 <style></style> 中间.

下面就是需要需要提交到页面上的数据类

public class TestNoticeBean implements Serializable {

    private String projectName;   //项目名

    private String projectLeader;   //项目负责人

    private String qaName;   //QA负责人

    private String pmName;   //PM负责人

    private String releaseNotes;   //上线要点

    private String demand;   //需求

    private String testMainPoints;    //测试要点

    private String relatedApps;  //相关应用

    private Date plannedTime;     //上线时间

    private String otherRemark;     //其他

}

不管是渲染 markdown 还是最后的渲染 html, 都是使用了 freemarker 内置的方法.

import freemarker.template.Configuration;
import freemarker.template.Template;

import java.io.BufferedWriter;
import java.io.StringWriter;
import java.util.Locale;

public class ClassPathTemplateRender implements TemplateRender {
    private static Configuration config = null;

    public static ClassPathTemplateRender getInstance(){
        return new ClassPathTemplateRender();
    }

    public ClassPathTemplateRender(){
        if(config == null){
            config = new Configuration();
            config.setClassForTemplateLoading(this.getClass(), "/"); //第二个参数指定模板所在的根目录,必须以“/”开头。

            try{
                config.setSetting("datetime_format", "yyyy-MM-dd HH:mm:ss");
                config.setLocale(Locale.CHINA);

            }catch(Exception ex){
                ex.printStackTrace();
            }
        }
    }

    public String render(Object dataModel, String ftlFile) throws Exception {
        StringWriter stringWriter = new StringWriter();
        BufferedWriter writer = new BufferedWriter(stringWriter);
        Template template = config.getTemplate(ftlFile, Locale.CHINA, "UTF-8");
        template.process(dataModel,writer);
        writer.flush();

        return stringWriter.toString();
    }
}

public class FtlUtil {
    public static String renderFile(Object dataModel, String ftlFile)throws Exception{
        String ret = ClassPathTemplateRender.getInstance().render(dataModel, ftlFile);
        return ret;
    }
}

最后的渲染逻辑在下面

Map<String, Object> paramsForMd = new HashMap<String, Object>();
paramsForMd.put("req", testNoticeBean);

String md = FtlUtil.renderFile(paramsForMd, "template/emailTemplate.md");

Reader in = new StringReader(md);
Writer out = new StringWriter();

Markdown markdown = new Markdown();
markdown.transform(in, out);

Map<String, Object> paramsForFtl = new HashMap<String, Object>();
paramsForFtl.put("content", out.toString().replaceAll("<code>", "<pre>").replaceAll("</code>", "</pre>"));
System.out.println(FtlUtil.renderFile(paramsForFtl, "template/emailTemplate.ftl"));

最后那个把 <code> 转成 <pre>, 主要是因为 markdown 渲染后的代码块用的是 <code>, 但是会碰到渲染到 html 时, 换行符就没了, 所以中间又转了一次.

记引入 drools 后的一次线上 OOM 问题的处理

2015-04-15 11:53 接监控组报警, 发现 wedding-mobileapi-web 线上大量 502 error. 这个项目为结婚的移动客户端提供所有业务的接口.

最终排查原因是由于调用 drools 的 StatefulKnowledgeSession 对象没有调用dispose()方法释放内存(先简单类比与 mysql 的 connect最后要close)

首先自我检讨一下, 引用了drools包之后, 只是验证了线上的业务流程, 没有关注到这样的细节. 下面说一下整个问题的排查过程, 供大家一起学习参考.

接警后先上cat 确认问题, 并发现很多dpsf timeout 异常.

这里存在两个疑点:

1. 如果是dpsf timeout造成, 那么应该是大面积的影响, 而不是针对单一业务.
2. 这远远达不到运维说的每小时好几千的量.

于是直接跳板机到线上环境去看nginx日志. 通过这个命令来计数:

cat wedding-mobileapi-web.access.log | grep -e "\s502\s" | wc -l

看到当时的量达到6000+(单机).

当时就想到既然nginx返回错误, 但是业务没有抛异常, 非常大的可能是由于gc导致的stoptheworld. 同时发现还有不少的499 的异常, 这个异常代码大家估计见得很少, 不是标准的http code. 499 是由于客户端在超时的时间范围内无法获得服务器返回, 而主动中断, 那么服务端的nginx会自己抛出499.

从上述几点去考虑, 最大的可能应该就是gc引起的.

于是先找运维dump一台服务器内存. 同时登录到zabbix去查看监控数据.

看到 10:20 左右, 线程数暴增.

Old Gen 也在相同时间点跑满了.

问题基本定位到时内存泄露造成, 下面是修复.

这次变更主要两个方面的修改

1. @纪坤 对于线上502的bugfix
2. @我 这里引用的 drools框架, 我自己怀疑就是我自己造成的. 所以先自查

由于dump出来的内存有3.6G, 拖到本地需要一个多小时, 所以直接肉眼看. 看到这样的代码:

我突然想到drools 文档中的关于 StatefulKnowledgeSession 的使用的一句话, 于是重新查了一下:

After the application finishes using the session, though, it MUST call the dispose() method in order to free the resources and used memory.

好吧, 于是修改代码为

15:00 先找了一台机器上线, 观察了5分钟, 没有啥异常出现, 于是全面上线.

这是上线后的Old Gen, 其中14:00有个骤降, 是由于运维重启应用, 但是可以看重启后依然在飞速上涨. 15:00fix上线, 开始缓慢上涨, 基本与正常现象相同.

16:00 @波总 完成 dump文件的分析:

看到确实是statefulSession导致的泄露, 问题得到确认.

以上是整个事故的发现和解决过程. 从中得到的教训是:

  1. 一定要仔细看文档, 尤其是和内存相关的部分.
  2. 出现问题不要急, 通过现象去分析可能造成的原因, 用排除法去过滤一些干扰. 然后逐一验证.
  3. 擅于使用一些常用的运维工具, 可以帮忙快速确认问题. 尤其推荐zabbix. nagios 这样的监控
  4. 了解一些常用的 linux 命令事半功倍.
mobileapi 项目引入 drools 规则引擎

目前我们最新上线的 mobileapi(用于给手机客户端提供接口服务) 项目已经引入了 JBoss 的 Drools 规则引擎

在介绍项目的上下文之前 我们先看看我们之前代码中存在的各种膈应人的逻辑:

  1. 亲子购物和亲子游乐的 7.2.0 版本, 产品页显示团购推荐模块, 同时隐藏热卖产品模块…
  2. 产品详情页的预约按钮文案, 旅游婚纱显示”咨询有礼”; 幼儿教育和早教中心如果存在”试听”的tag, 则显示”预约试听”; 女士婚纱/旗袍/晚礼服/龙凤褂如滚存在”0元试听”标签则显示”预约试纱”, 默认显示”预约看店”…
  3. 商户页亲子购物/亲子游乐分类在7.2.0版本, 产品推荐模块显示2个产品, 否则显示4个产品…
  4. 旅游婚纱的判断渗透到各个接口中…

于是, 代码中会出现各种各样的 fuck 字眼, 当然大部分都是我写的, 原谅我是个脾气暴躁的死程序员…

// 20141222 对于旅游婚纱产品, 显示为咨询有礼
if (Constants.PCATE_TRAVELWED == productCategoryId) {
    return "咨询有礼";
}
if (CollectionUtils.isNotEmpty(propList)) {
    for (Map<String, Object> pro : propList) {
        if (MapUtils.isEmpty(pro)) {
            continue;
        }
        Object nameIdObj = pro.get("nameId");
        if (nameIdObj != null) {
            int nameId = Integer.valueOf(nameIdObj.toString());
            List<String> value = (List<String>) pro.get("value");
            switch (nameId) {
                case 2776316://幼儿才艺-有无试听
                case 2776116://早教中心-有无试听
                    if (CollectionUtils.isNotEmpty(value) && "有".equals(value.get(0))) {
                        return "预约试听";
                    }
                    return "预约看店";
                case 2541120://女士婚纱-0元试纱
                case 98315://晚礼服-0元试纱
                case 101615://旗袍/龙凤褂-0元试纱
                    if (CollectionUtils.isNotEmpty(value) && "有".equals(value.get(0))) {
                        return "预约试纱";
                    }
                    return "预约看店";
            }
        }
    }
}
// 20141222 过滤旅游婚纱的分类
List<Integer> ignoreProductCategoryIds = new ArrayList<Integer>();
ignoreProductCategoryIds.add(Constants.PCATE_TRAVELWED);

PageModel pagemodel = weddingShopProductService.paginateShopProductsByShopIdAndCategoryFilter(page, limit, shop.getShopId(), null, ignoreProductCategoryIds, WeddingShopProductOrderEnum.UPDATE_DESC);
// 相信你看到这段代码的时候, 已经心中无数草泥马在奔腾....BUT...
// 这里还没有结束, 因为这个筛选结果是排除了旅游婚纱的结果, 但是总数却需要返回包含旅游婚纱的数据.
// 这个逻辑以后直接找产品去, 我也不知道怎么维护, 如果你看到了这个代码, 千万别骂我, 我也他妈的没办法.
// 现在只有我和上帝能看懂这段逻辑, 等你看的时候, 估计只有上帝能看懂了.
// God bless you.
PageModel result = weddingShopProductService.paginateShopProductsByShopIdAndCategoryFilter(page, limit, shop.getShopId(), null, null, WeddingShopProductOrderEnum.UPDATE_DESC);
if (result == null) {
    return null;
} else {
    result.setRecords(pagemodel.getRecords());
    return result;
}
// 收集所有推荐的产品id, 这个productIds 也就是输出的排序
List<Integer> productIds = new LinkedList<Integer>();
for (List<ShopProductRecommendDTO> values : recommendShopProducts.values()) {
    for (ShopProductRecommendDTO value : values) {
        if (productIds.size() < limit && value.getIsUseful()) {
            // 20141222 这里要过滤掉所有的旅游婚纱的分类!! 我操我操 fuckfuckfuck, 这个代码以后谁tm来维护!
            if (value.getProductCategoryId() == Constants.PCATE_TRAVELWED) {
                continue;
            }
            productIds.add(value.getProductId());
        }
    }
}
boolean isHomeDecorate = HomeUtil.isHomeDecorate(shop.getMainCategoryId()) && VersionUtil.compare(context.getVersion(), "6.9.5") >= 0;
for (WeddingShopProductDTO dto : productDtos) {
    if(isHomeDecorate){
        //家装的装修设计分类,需要有version的判断
        List<Map<String,Object>> tags = weddingShopProductService.findAllTagsByProductId(dto.getId());
        productDos.add(new WeddingProductDo(dto, coverPicType, tags, context));
    }
    else{
        productDos.add(new WeddingProductDo(dto, coverPicType));
    }
}

规则引擎的出现非常好的解决了这样将一些复杂的条件判断耦合在业务代码中的难以维护的问题. 通过一组规则(版本/分类/标签等), 为接口的返回提供了一套预判的配置, 比如”最多输出几个? 最少输出几个? 忽略哪几个? 默认是啥?”, 那么接口在处理业务逻辑的时候, 完全可以只通过这个配置处理, 将复杂的判断抽离统一的业务逻辑.

如何使用

  1. 使用eclipse或者idea作为IDE的同学可以去直接下载drools的插件. 官方的库里就有
  2. 对于本地要启动mobileapi项目的, 需要在本地的 %TOMCAT_HOME%/bin/catalian.sh 中加入这么一行代码, 放心加, 这个是生产环境上也有的.
CATALINA_OPTS="$CATALINA_OPTS -Dclient.encoding.override=UTF-8 -Dfile.encoding=UTF-8 -Duser.language=zh -Duser.region=CN"
  1. 参考 之前写的规则, 添加自己新的规则.

  2. 通过 单元测试, 来验证自己的规则已经生效, 并且符合预期.

举个例子

这个需求是: 针对7.2.0版本的客户端及以上版本, 如果商户分类是亲子购物或者亲子游乐, 则显示4个团购推荐, 这4个推荐必须是亲子游乐/幼儿教育/亲子摄影分类的. 如果搜索到的团购不足2个, 则隐藏该模块, 是不是很绕?

于是我们创建一个用于配置的Fact对象:

public class GrouponRecommendConfigFT {

    /** 是否需要推荐团购 */
    private boolean needRecommend;

    private int maxLimit;
    private int minLimit;

    private List<Integer> shopCategoryIdList = Lists.newLinkedList();

    public void addShopCategoryId(int categoryId) {
        this.shopCategoryIdList.add(categoryId);
    }
}

并正对这个业务去定制规则:

declare BabyFunAndShopping
end

rule "默认不输出团购推荐"
    salience 1000

    lock-on-active true

    when
        $config: GrouponRecommendConfigFT()
    then
        $config.setNeedRecommend(false);

end

rule "判断是否为亲子游乐或亲子购物"
    salience 999

    lock-on-active true

    when
        Category(id == Constants.CATE_BABY_SHOPPING || id == Constants.CATE_BABY_FUN)
    then
        insert(new BabyFunAndShopping());

end

rule "只输出两个推荐"
    salience 998

    no-loop true

    when
        $config: GrouponRecommendConfigFT()
        IMobileContext(VersionUtil.compare(version, "7.2.0") >= 0)
        BabyFunAndShopping()
    then
        $config.setNeedRecommend(true);
        $config.setMinLimit(2);
        $config.setMaxLimit(4);
        $config.addShopCategoryId(Constants.CATE_BABY_EDU);
        $config.addShopCategoryId(Constants.CATE_BABY_FUN);
        $config.addShopCategoryId(Constants.CATE_BABY_PHOTO);

end

可以看到, 我们只是针对业务, 去生成一个最终的配置, 最终的接口逻辑就是根据这份配置去做最终的输出.

下面针对这个业务, 定制我们的单元测试来验证规则:

@Test
public void testBabyShopping7_2_0() {
    ShopDTO shop = new ShopDTO();

    Category category = new Category();
    category.setId(Constants.CATE_BABY_SHOPPING);

    MobileContext context = new MobileContext();
    context.setVersion("7.2.0");

    IRuleProcessor ruleProcessor = ruleEngine.getProcessor(Processor.BABY_GROUPON_RECOMMEND);

    List<Object> facts = new ArrayList<Object>();
    facts.add(shop);
    facts.add(category);
    facts.add(context);

    GrouponRecommendConfigFT configFT = (GrouponRecommendConfigFT) ruleProcessor.execute(facts);
    assertTrue(configFT.isNeedRecommend());
    assertEquals(4, configFT.getMaxLimit());
    assertEquals(2, configFT.getMinLimit());
    assertTrue(configFT.getShopCategoryIdList().contains(Constants.CATE_BABY_EDU));
    assertTrue(configFT.getShopCategoryIdList().contains(Constants.CATE_BABY_PHOTO));
    assertTrue(configFT.getShopCategoryIdList().contains(Constants.CATE_BABY_FUN));

}

下一步的设想

  1. 可以通过一个web去动态的修改规则实现规则的动态调整, 而不用停机发布, 可以将规则保存在zookeeper, 或者通过swallow的方式发送给所有的web server. 这一点drools框架是支持的.

  2. 在一些适合的场景也引入规则引擎, 如发红包活动, 防作弊点赞等.

常见问题

  1. 既然drools可以动态加载, 和groovy有点类似, 为啥不直接用groovy?

groovy虽然可以动态加载, 但实际上还是要在里面写一大堆if…else逻辑, 等于是把恶心的逻辑放到了另一个文件, 并没有实现解耦. 而规则引擎更像是邮件的fillter, 配置更灵活. 另一个很重要的原因是, drools 在compile rules的时候, 会通过 RETE 算法进行优化, 效率更高.

  1. 为啥不直接用lion?

还嫌lion不够乱的? 每个api加2个配置, 维护都是一场灾难.

  1. 性能怎样?

前面说到了drools在 RETE 的算法的基础上还做了一写自己的优化, 性能绝对不是问题. 在之前的公司, 使用 drools 做风控判断. 针对3到5各rule, 2000+的qps轻轻松松.

参考文档

Java 计算两个时间点的间隔时间

在做一个需求的时候, 碰到一个非常难办的需求:

用户在设置了宝宝出生日期的时候, 在页面上显示宝宝的年龄. 显示的格式为 “xx年xx个月xx天”.

乍一看很简单, 直接用 getTime() 减出毫秒数, 然后去模除就行了. 但是这个算法存在非常多的问题:

  1. 每个月的天数不一致.
  2. 如果时间跨度中有个闰年, 就非常恶心了.

当然, 大部分情况下有个几天的差距是看不出来的, 但是如果碰到一些比较极端的情况, 比如生日是 2011-01-01 到 2015-01-01 去查看的时候, 由于中间隔了一个2012的闰年, 这个问题就复杂了, 而且由于刚好是同一天, 所以哪怕有一点点差别都能发现是bug.

既然要做成通用的房费类, 那么干脆就做一个精准点的算法: “xx年xx月xx日xx小时xx分钟xx秒”.

基本设想如下, 类似于整数的减法, 只是每一位的进制不同.

比如月份的进制为12, 日的进制和当前所在的月有关, 小时的进制是24, 分钟进制是60.

很明显, 这里最复杂的就是月了. 当月份需要借位的时候, 需要计算 endDate 的上一个月的总天数.

基本思想说完了, 上代码

private static class DateInterval {
    private int year;
    private int month;
    private int day;
    private int hour;
    private int minute;
    private int second;

    private Date start;
    private Date end;
    private Calendar sCal = Calendar.getInstance();
    private Calendar eCal = Calendar.getInstance();

    public DateInterval(Date start, Date end) {
        Assert.isTrue(end.after(start));

        this.start = start;
        this.end = end;

        sCal.setTime(start);
        eCal.setTime(end);

        year = eCal.get(Calendar.YEAR) - sCal.get(Calendar.YEAR);
        month = eCal.get(Calendar.MONTH) - sCal.get(Calendar.MONTH);
        day = eCal.get(Calendar.DAY_OF_MONTH) - sCal.get(Calendar.DAY_OF_MONTH);
        hour = eCal.get(Calendar.HOUR_OF_DAY) - sCal.get(Calendar.HOUR_OF_DAY);
        minute = eCal.get(Calendar.MINUTE) - sCal.get(Calendar.MINUTE);
        second = eCal.get(Calendar.SECOND) - sCal.get(Calendar.SECOND);
    }

    @Override
    public String toString() {
        return MessageFormatter.arrayFormat("{}年{}月{}日{}小时{}分钟{}秒", new Object[] {
                year, month, day, hour, minute, second
        });
    }

    public DateInterval calcInterval() {

        if (month < 0) {
            descYear();
        }
        if (day < 0) {
            descMonth();
        }
        if (hour < 0) {
            descDay();
        }
        if (minute < 0) {
            descHour();
        }
        if (second < 0) {
            descMintue();
        }

        return this;
    }

    private void descYear() {
        year--;
        month += 12;
    }

    private void descMonth() {
        if (--month < 0) {
            descYear();
        }
        // 当天天数要加上结束月的上个月的总天数
        day += calDaysOfLastMonth();
    }

    private void descDay() {
        if (--day < 0) {
            descMonth();
        }
        hour += 24;
    }

    private void descHour() {
        if (--hour < 0) {
            descDay();
        }
        minute += 60;
    }

    private void descMintue() {
        if (--minute < 0) {
            descHour();
        }
        second += 60;
    }

    private int calDaysOfLastMonth() {
        Calendar calendar = Calendar.getInstance();
        calendar.set(Calendar.YEAR, eCal.get(Calendar.YEAR));
        calendar.set(Calendar.MONTH, eCal.get(Calendar.MONTH));
        calendar.add(Calendar.MONTH, -1);
        return calendar.getActualMaximum(Calendar.DATE);
    }
}

这里最复杂的一段就是在借位减法上, 如果借位导致了前一位的值为负值, 那么要再向前借位.

测试代码

public static void main(String[] args) throws ParseException {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    System.out.println(calcDateInterval(sdf.parse("2010-01-01 19:00:01"), sdf.parse("2015-01-01 19:00:00")));
}

返回如下:

4年11月30日23小时59分钟59秒