Grails JSON Views 教程

Grails 3.2版本中的rest-api profile加入了JSON View插件,JSON View插件主要用于渲染JSON返回内容,类似于GSP,其好处就是将JSON渲染从控制器层移到了视图层,同时JSON View还能定义模板,继承等。

开始使用

创建视图

JSON View视图文件以.gson为扩展名,且文件要放在grails-app/views目录下。
例:person.gson

1
2
3
json.person {
name "bob"
}

返回的JSON值为:

1
{"person":{"name":"bob"}}

创建模版

模版文件需要以_开头,比如你有一个类名叫QueryResult,则其模版文件完整路径为grails-app/views/queryResult/_queryResult.gson
例:Author.groovy

1
2
3
class Author {
String name
}

模版:grails-app/views/author/_author.gson

1
2
3
4
5
6
model {
Author author
}
json {
name author.name
}

也可以简写为:

1
2
3
4
5

@Field Author author
json {
name author.name
}

如果Author类是Domain Object的话,则模版文件可以写成:

1
2
@Field Author author
json g.render(author)

高级用法

自定义字段

1
2
3
4
5
6
7
8
9
model {
Book book
}

json g.render(book, [deep: false, renderNulls: true]) {
authorName book.author?.name
publishDate new Date().time
}

集合渲染

例1:

1
2
3
4
5
6
7
8
model {
Author author
}

json {
name author.name
books g.render(template: '/book/book', collection: author.books, var: 'book')
}

/book/book引用的是grails-app/views/book目录下名为_book.gson的模版文件

例2:
如果返回结果是个集合,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class AuthorController {
static responseFormats = ['json', 'xml']

def index() {
/**
* 这里需要注意res的类型,
* 如果是List类型,则json view中的model名称则为authorList
* 如果是Set类型,则json view中的model名称则为authorSet
*/
def res = Author.list()
respond(res, [view: 'authorList'])
}
}

  • 这里要注意下res的类型

    • 如果是List类型,则json view中的model名称则为authorList
    • 如果是Set类型,则json view中的model名称则为authorSet
  • Grails默认会在grails-app/views/author/路径下查找authorList.gson视图文件

  • 如果需要在其他Controller下引用该视图的话,需要写绝对路径/author/authorList

_author.gson

1
2
3
4
5
6
7
8
9
model {
Author author
}

json {
name author.name
books g.render(template: '/book/book', collection: author.books, var: 'book')
}

authorList.gson

1
2
3
4
5
6
model {
Collection<Author> authorList = []
}

json tmpl.author(authorList)

authorList要指定下默认值[],如果不指定的话会返回[null]这种JSON

渲染参数

你可以通过includesexcludes参数,来包含或排除一些字段,如

1
2
@Field Author author
json g.render(author,[includes:['title'])

你也可以自定义额外输出的内容:

1
2
3
json g.render(author) {
age 30
}

在默认情况下,JSON View不会返回值为null的字段,如:

1
2
3
4
5
6
7
8
9
10
class Author {
String name
Integer age
}

@Field Author author
json g.render(author)

def a = new Author(name: 'charles',age: 12) => {name:'chalres',age: 12}
def b = new Author(name: null,age: 29)=> {age: 29}

这时候你可以设直renderNulls来返回空值字段:

1
2
@Field Author author
json g.render(author,[renderNulls: true])

则b的返回中就变成:

1
2
def b = new Author(name: null,age: 29)=> {name: null,age: 29}

完整的例子

https://github.com/wancaibida/grails-json-views-example

总结

JSON View用下来还是遇到了不少的问题,主要原因是其约定太多且官方的文档也说的不是很清楚,有 些地方还是要一步步调试来研究源代码,但总的来说比传统的在代码中指定JSON格式方便了不少,用好了能节约不少时间与工作量。

参考链接

https://github.com/skyboy101/widget-store-rest-api
http://views.grails.org/1.1.x/

记一个Grails JSON Views的Bug

最近项目用到了3.2版本的Grails,这个版本中引入了一个新特性JSON views,主要作用是将JSON返回内容视为一种视图,类似于GSP。其好处就是可以在视图层定义返回json的格式,而且可以定义bean的JSON模版,比较灵活。

项目中有一个名为QueryResult的类,并设置了QueryResult类的模版,名为_queryresult.gson

文件目录如下:

1
2
3
4
5
6
bean
package...
QueryResult.groovy
views
queryResult
_queryresult.gson

Controller代码:

1
2
3
def list(xxx){
new QueryResult(xxx)
}

项目在本地开发时没有什么问题,但部署到生产环境时,这个方法返回内空始终为空。

项目在本地开发时是在内置的tomcat运行的,而在生产环境中项目是打包成war文件部署在Linux下的Tomcat。猜测问题可能是项目运行方式不同导致。

调试后发现了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
WritableScriptTemplate template
if (Environment.isDevelopmentEnvironmentAvailable()) {
template = attemptResolvePath(path)
if (template == null) {
template = attemptResolveClass(path)
}
} else {
template = attemptResolveClass(path)
if (template == null) {
template = attemptResolvePath(path)
}
}
if (template == null) {
template = NULL_ENTRY
}

代码的基本意思是,如果是在开发环境下,模版内容优先通过文件路径来查找,如果没有找到则通过类名来查找,而在生产环境正好反过来。

模版文件_queryresult.gson编译后会生成名为xxx_queryResult__queryresult_gson.class的java类。

JSON Views插件会通过一系列约定的命名方式来找所需要的视图,最终会查找名为xxx_queryResult__queryResult_gson.class的java类(注意:第二个queryResult中的R是大写的),前面说到开发环境下会先通过文件名称来查找,即

1
2
3
4
5
//实际文件名为:xxx_queryResult__queryresult_gson.class
def templateFile = new File('xxx_queryResult__queryResult_gson.class')
if(templateFile.exist()){
return templateFile;
}

,如果文件存在,则返回。可实际的class文件名为xxx_queryResult__queryresult_gson.class,但我开发环境的文件系统是大小写不敏感的!!,所以对于系统来说xxx_queryResult__queryresult_gson.classxxx_queryResult__queryResult_gson.class是同样的文件名,exist方法最终是通过系统的API来寻找文件的,所以templateFile.exist()这句返回结果为真。

在生产环境是下是优先通过类名来查找的,要查找的类名为xxx_queryResult__queryResult_gson.class,而实际的类名为xxx_queryResult__queryresult_gson.class,而java类名是区分大小写的,所以会导致类找不到。

一旦通过类名查找不成功,则会通过文件路径来查,而Linux的文件系统是大小写敏感的,所以会无法找到名为xxx_queryResult__queryResult_gson.class的文件。

最终把模版文件名重命名为_queryResult.gson,接口就运行正常了。

总结:
严格来说这个BUG是文件系统对大小写处理的差异导制的,所以在开发时要考虑到文件系统对大小写处理不一致的问题。

Intellij远程调试Tomcat

  1. 在Tomcat下新建setenv.sh文件

  2. 运行chmod 755 setenv.sh

  3. 将下面的代码添加到setenv.sh文件中

    1
    2
    export CATALINA_OPTS="-agentlib:jdwp=transport=dt_socket,address=1043,server=y,suspend=n"

  4. Intellij Run/Debug Configurations下新建Tomcat Server Remote
    ![Screen Shot 2017-12-02 at 13.53.42](http://static.w2x.me/2017-12-02-Screen Shot 2017-12-02 at 13.53.42.png)

  5. 在新建的页面先择Starup/Connection标签,然后选择Debug选项
    ![Screen Shot 2017-12-02 at 13.56.20](http://static.w2x.me/2017-12-02-Screen Shot 2017-12-02 at 13.56.20.png)

  6. Port的值修改为1043
    ![Screen Shot 2017-12-02 at 13.58.42](http://static.w2x.me/2017-12-02-Screen Shot 2017-12-02 at 13.58.42.png)

  7. Tomcat启动后,Intellij即可远程Debug

Kubernetes入门

Kubernetes入门

Kubernetes是什么

Kubernetes是用来自动化部署,扩展,管理容器应用的工具.

Kubernetes架构

Kubernetes主要由Master和Node两部分组成.
Architecture

Master

Kubernetes集群包含至少一个Master节点和多个Node节点.Master节点主要用来暴露API,调度部署和管理整个集群.

Node

Node是工作节点,Node可能是物理机器也可能是虚拟机,每个节点上都提供了容器的运行环境,比如说Docker,rkt.

Kubernetes对象

Kubernetes对象持久化在kubernetes系统中,Kubernetes用这些对象来描述集群的状态,比如:

  • 部署的应用
  • 应用的行为,比如重启,升级策略
  • 应用程序可用的资源

描述Kubernetes对象

我们通常会通过.yaml来描述Kuberntes对象,Kubernetes客户端会将yaml文件转成JSON来调用API.
例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
apiVersion: apps/v1beta1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.7.9
ports:
- containerPort: 80

我们可以通过下面的命令来创建Kubernetes对象:

1
kubectl create -f docs/user-guide/nginx-deployment.yaml --record

字段说明

在yaml文件中,你需要声明以下字段:

  • apiVersion - 调用的API版本
  • kind - 对象类型
  • metadata - 用于查找对象的字段,比如名称,UID,命名空间

一些常见的kubernetes对象有:

  • Pod
  • Deployments
  • Service
  • Ingress

Pod对象

Pod对象是最小的布署单元,包含了一个或多个容器.通常我们不会单独使用Pod.
Controller对象可创建多个Pod,常风的Controller对象有:

  • Deployment
  • StatefulSet
  • DaemonSet

Deployments对象

Deployments对象可以创建Pod,在Deployments对象中你只需要描述你需要的状态,Deployments对象会将当前状态改变成你所需要的状态.

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
apiVersion: apps/v1beta1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.7.9
ports:
- containerPort: 80

上面的描述文件创建了3个运行Nginx的Pod对象.

通过下面命令来部署和查看Deployments对象:

1
2
kubectl create -f docs/user-guide/nginx-deployment.yaml --record
kubectl get deployment

Service对象

Service对象将Pod对象抽像成一种服务,并定义了访问这些Pod对象的策略,类似于负载均衡(Loadbalancer).Service对象会通过选择器来选择Pod对象.

例:

1
2
3
4
5
6
7
8
9
10
11
kind: Service
apiVersion: v1
metadata:
name: my-service
spec:
selector:
app: MyApp
ports:
- protocol: TCP
port: 80
targetPort: 9376

Ingress对象

Ingress对象定义了外部请求如何转发到Service,类似于反向代理.
例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: test
annotations:
ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: foo.bar.com
http:
paths:
- path: /foo
backend:
serviceName: s1
servicePort: 80
- path: /bar
backend:
serviceName: s2
servicePort: 80

上面的描述文件定义了Service的访问规则,foo上下文路径会被转发到名为s1的Service,bar上下文路径转发到名为s2的Service.

简而言之:

  • Deployments定义了怎样部署应用(应用image,部署多少实例,启动策略等);
  • Service定义了如何抽像Deployments为服务,类似于负载均衡;
  • Ingress定义了请求如何转发到Service,类似反向代理,如nginx.

Spring Boot YAML配置文件设置字段默认值

SpringBoot中application.yaml如何设置配置项默认值一直困扰了我很久,官方文档也没有写如何设置默认值,今天有时间研究了下,发现只要加个冒号就可以了,如${配置名:默认值}.

application.yml

1
2
3
appconfig:
fileServerAccessKey: ${FILE_SERVER_ACCESS_KEY:default_access_key}
...

配置文件类

1
2
3
4
5
6
@Component
@ConfigurationProperties(prefix = "appconfig")
class Config {
String fileServerSecretKey
...
}

如果项目未设置FILE_SERVER_ACCESS_KEY环境变量,则fileServerSecretKey字段会以default_access_key为默认值.

研究了下源码发现 PropertyPlaceholderHelper.java#L147这个类先会检查配置值中是否包含valueSeparator这个字符串,如果有,则会用valueSeparator来分割字符,从中取出默认值,而valueSeparator默认值就是:.

Linux下启动Java守护进程方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/bin/sh

DESC="Java Service"
NAME=java-service
PIDFILE=/tmp/$NAME.pid
PATH_TO_JAR=~/java-service.jar
PATH_TO_JAVA=/usr/local/jdk1.8.0_131/bin/java
SERVICE_CONFIG='-Dserver.port=8080 -DLOG_PATH=/var/log'
COMMAND="$PATH_TO_JAVA -- $SERVICE_CONFIG -jar $PATH_TO_JAR"


d_start() {
start-stop-daemon --start --quiet --background --make-pidfile --pidfile $PIDFILE --exec $COMMAND
}

d_stop() {
start-stop-daemon --stop --quiet --pidfile $PIDFILE
if [ -e $PIDFILE ]
then rm $PIDFILE
fi
}

case $1 in
start)
echo -n "Starting $DESC: $NAME"
d_start
echo "."
;;
stop)
echo -n "Stopping $DESC: $NAME"
d_stop
echo "."
;;
restart)
echo -n "Restarting $DESC: $NAME"
d_stop
sleep 1
d_start
echo "."
;;
*)
echo "usage: $NAME {start|stop|restart}"
exit 1
;;
esac

exit 0

HPKP(HTTP Public Key Pinning)详解

HPKP是什么

HPKP(HTTP Public Key Pinning)又名公钥打孔,可以通过告知客户端将特定的加密公钥与特定服务器关联,以减少通过伪造证书进行中间人攻击(MITM)的风险.

HPKP原理

HPKP是一种首次信任技术,当客户端第一次访问服务器的时候,服务器通过特定的HTTP头来告知客户端哪些公钥是属于它的,客户端会将该信息存储一段时间,当客户端第二次访问服务端的时候,它会期望当前证书链中至少有一个证书的公钥指纹与通过HPKP已知的公钥指纹相匹配,如果没有找到匹配的公钥指纹,客户端应该警告用户.

生成HPKP

公钥指纹可以通过openssl命令来成成:

1
2
3
4
5
6
7
#!/bin/sh

openssl s_client -connect [YOUR_DOMAIN]:443 -showcerts | awk '/-----BEGIN/{f="cert."(n++)} f{print>f} /-----END/{f=""}'

for c in cert.*; do
openssl x509 <$c -noout -pubkey | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
done

上面的命令会将当前证书链中的所有公钥指纹以base64格式打印出来.

公钥指纹也可以通过在线网站REPORT URI来生成

备份密钥(Backup Key)

什么是备份密钥

备份密钥是不属于当前证书链的公钥指纹

为什么要备份密钥

假设你只对你的子证书(leaf certificate)做了公钥指纹,你发现你的证书私钥被泄露了,这时你不得不更换当前的证书,这时唯一能恢复的办法就是将当前证书切换到备份密钥指向的备份证书.

其他类似备份密钥的方法

备份密钥的缺点也很明显,你需要同时支付至少两份证书的钱.

另一个办法就是你可以拿另一家证书颁发机构(CA)的公钥指纹做为备份密钥,当你要更换证书的时候,你只需要在这家CA申请一份新的证书.当然这么做也有缺点,当这家CA被黑,攻击者可以给自己颁发一份你网站的证书同时又通过你的HPKP验证,应为这份恶意证书也在CA的证书链上.

MDN上建议网站要有一个备份密钥

HPKP has the potential to lock out users for a long time if used incorrectly! The use of backup certificates and/or pinning the CA certificate is recommended.

但在Chrome(59.0.3071.115)下测试,如果HPKP头不包含备份密钥Chrome将忽略该头.

添加HPKP头

1
response.setHeader('Public-Key-Pins', 'pin-sha256="base64+primary=="; pin-sha256="base64+backup=="; max-age=5184000; includeSubDomains')

sha256
公钥的哈希值,base64编码

max-age
在浏览器的存储时间

includeSubDomains
对子域名是否有效

report-uri
验证失败后调用的URL(注:必须不同域名)

总结

用HPKP的网站比较少,就发现github在用.

HPKP缺点也很明显:出错的成本太高,当你不得不切换到备份密钥指向的证书时,如果备份密钥配错了而且你只有一个备份密钥,你的网站将会无法被访问,无法访问的时间取决于max-age的值,比如上面的例子max-age配了两个月,那你的用户将在两个月内无法访问你的网站,这后果是灾难性的.

个人认为HPKP带来的风险大于收益,不建议使用.

外部链接

HTTP Public Key Pinning (HPKP)

HTTP PUBLIC-KEY-PINNING EXPLAINED

Guidance on setting up HPKP

Is HTTP Public Key Pinning Dead?

PostgreSQL JDBC 时间类型存取细节

  • PostgreSQL中默认时区为UTC
  • record表中有一类型为Time without timezone的字段event_time
  • 本机的时区为UTC+8

现象:

  • 在页面提交的时间为 15:00:00+0
  • 到了数据库时间却显示为 23:00:00+0
  • 查询出来后页面显示的时间是 15:00:00+0
  • 控制台打印出来的SQL如下:
1
insert into record (id, event_time) values (nextval ('hibernate_sequence'), '23:00:00.000000 +08:00:00')

原因:
查看源代码,发现在生成insert语句时,JDBC会通过toString方法将Time类型转成String,而问题就出现在Time转String这里,cal对象是取的JVM的默认时区,而JVM默认时区取的应是本机的时区UTC+8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public synchronized String toString(Calendar cal, Time x) {
if(cal == null) {
cal = this.defaultCal;
}

cal.setTime(x);
this.sbuf.setLength(0);
appendTime(this.sbuf, cal, cal.get(14) * 1000000);
if(this.min74) {
this.appendTimeZone(this.sbuf, cal);
}

showString("time", cal, x, this.sbuf.toString());
return this.sbuf.toString();
}

所以15:00:00+0在调用toString方法后返回值是23:00:00.000000 +08:00:00,但是数据库中event_time类型时Time without timezone,所为23后面的时区信息将会被忽略,最终数据库的时间变成了UTC+0 的23:00:00点

而将event_time从数据库中查询出来时,又将其当成了UTC+8的23:00:00,换算成UTC+0时间正好是UTC+0 15:00:00

所以要避免页面时间与数据库时间不统一的办法就是设置JVM的时区

1
-Duser.timezone=UTC

Hibernate学习

占位符(named parameters)对order by无效

1
2
//对于最后两字段sortField和sortOrder设值时是没有效果的
SELECT * FROM t_table WHERE column=:param1 ORDER BY :sortField :sortOrder

isDirty细节

1
2
3
4
5
6
7
8
9
def product = Product.get(xxx)
product.name = 'anotherName'
product.save()
product.isDirty() //true

def product = Product.get(xxx)
product.name = 'anotherName'
product.save(flush:true)
product.isDirty() //false

hibernate 中entity get时会有个loadstate 来记录entity的初始状态,调用isDirty方法将比较当前 entity和loadstate来判断entity是否发生改变,然而当flush:true时,将刷新loadstate状态,从时调用isDirty将反回true.

Session Flush

  • HQL查询会导致session flush
    所以在调用isDirty前进行HQL查询会导致isDirty始终返回false

flush与transaction的联系

flush后Hibernate会生成SQL语句并执行,但只有环绕的transaction提交后才会真正的同步到数据库

Groovy学习

sort方法作用于Set和List时的区别

1
2
3
4
5
6
7
8
def a = new HashSet()
a << [3, 1, 2]

def b = new ArrayList()
b << [2, 1, 3]

assert b.sort().is(b) //true
assert a.sort().is(a) //false

sum and flatten 作用于Collection时的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[].sum() returns null
[].flatten() returns []
//原因在于调用sum时默认initvalue为null,当list为空时直接返回initvalue
private static Object sum(Iterable self, Object initialValue, boolean first) {
Object result = initialValue;
Object[] param = new Object[1];
for (Object next : self) {
param[0] = next;
if (first) {
result = param[0];
first = false;
continue;
}
MetaClass metaClass = InvokerHelper.getMetaClass(result);
result = metaClass.invokeMethod(result, "plus", param);
}
return result;
}

@EqualsAndHashCode注解

当exclude类所有field及property时,会导制所有实例都相等

1
2
3
4
5
6
7
8
9
10
11
@EqualsAndHashCode(excludes = ['x', 'y'])
static class Point {
int x
int y
}

public static void main(String[] args) {
def point0 = new Point(x: 1, y: 1)
def point1 = new Point(x: 2, y: 2)
assert point0 == point1
}

对ORM类慎用该注解,可能会导制关联丢失,当mutable字段变化时,会导制hash值改变,导制无法定位到槽位,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@EqualsAndHashCode
static class Point {
int x
int y
}

public static void main(String[] args) {
def point0 = new Point(x: 1, y: 1)
def point1 = new Point(x: 2, y: 2)
def set = new HashSet()
set << point0
set << point1

assert set.contains(point0)

point0.x = 6
assert set.contains(point0) //false !!!
}