關於實現log4j2日誌脫敏的一種方案
情景
最近公司嚴格要求日誌脫敏,對於敏感欄位,諸如身份證號、手機號、銀行卡號等使用者資訊進行掩碼,保證日誌中沒有明文。
專案程式碼中列印日誌的地方形如:
logger.info("idCard:{},phone:{},mobile:{},name:{}", idCard, phone, mobile, name);
相信很多javaer都是這麼做的,現在要對日誌進行掩碼,怎麼做?難道一行一行去改?
當然不行!
這種情況當然是…debug了!
DEBUG的重要性
第一次遇上這種問題,很多人可能手足無措,那麼是時候祭出終極大招:DEBUG。
從logger.info()進去,發現呼叫了AbstractLogger.logIfEnabled()方法,繼續往下走到AbstractLogger.logMessage(),這一方法有下面兩行程式碼:
final Message msg = messageFactory.newMessage(message, p0, p1, p2, p3);
logMessageSafely(fqcn, level, marker, msg, msg.getThrowable());
首先看下AbstractLogger的私有屬性messageFactory是怎麼來的:
public AbstractLogger() {
this.name = getClass().getName();
this.messageFactory = createDefaultMessageFactory();
this .flowMessageFactory = createDefaultFlowMessageFactory();
}
public AbstractLogger(final String name) {
this(name, createDefaultMessageFactory());
}
public AbstractLogger(final String name, final MessageFactory messageFactory) {
this.name = name;
this.messageFactory = messageFactory == null ? createDefaultMessageFactory() : narrow(messageFactory);
this.flowMessageFactory = createDefaultFlowMessageFactory();
}
其三個構造方法,對於messageFactory都使用了createDefaultMessageFactory()方法
private static MessageFactory2 createDefaultMessageFactory() {
try {
final MessageFactory result = DEFAULT_MESSAGE_FACTORY_CLASS.newInstance();
return narrow(result);
} catch (final InstantiationException | IllegalAccessException e) {
throw new IllegalStateException(e);
}
}
預設來自靜態屬性DEFAULT_MESSAGE_FACTORY_CLASS的例項:
public static final Class<? extends MessageFactory> DEFAULT_MESSAGE_FACTORY_CLASS =
createClassForProperty("log4j2.messageFactory", ReusableMessageFactory.class,
ParameterizedMessageFactory.class);
仔細看看這個靜態方法:
private static Class<? extends MessageFactory> createClassForProperty(final String property,
final Class<ReusableMessageFactory> reusableParameterizedMessageFactoryClass,
final Class<ParameterizedMessageFactory> parameterizedMessageFactoryClass) {
try {
final String fallback = Constants.ENABLE_THREADLOCALS ? reusableParameterizedMessageFactoryClass.getName()
: parameterizedMessageFactoryClass.getName();
final String clsName = PropertiesUtil.getProperties().getStringProperty(property, fallback);
return LoaderUtil.loadClass(clsName).asSubclass(MessageFactory.class);
} catch (final Throwable t) {
return parameterizedMessageFactoryClass;
}
}
直接從系統屬性log4j2.messageFactory獲取了工廠類全名(真的如此嗎?)
看到這裡以為要大功告成了,目前看上去是需要實現工廠類的介面,再將其配置給log4j2.messageFactory屬性即可。於是就有了工廠類如下:
public class DesensitizedParameterizedMessageFactory implements MessageFactory {
@Override
public Message newMessage(Object message) {
return null;
}
@Override
public Message newMessage(String message) {
return null;
}
@Override
public Message newMessage(String message, Object... params) {
return null;
}
}
為啥非要叫XXParameterizedMessageFactory,具體細節請自行debug。
現在工廠有了,產品呢?
回到AbstractLogger.logMessage()方法,工廠類messageFactory產生的產品是Message,開啟看下,(肯定)是個介面。這裡我們只看由ParameterizedMessageFactory產生的ParameterizedMessage。
關注其幾個重要私有屬性:
//訊息模板,本例子中是idCard:{},phone:{},mobile:{},name:{}
private String messagePattern;
//引數陣列,本例子中是陣列{idCard, phone, mobile, name}
private transient Object[] argArray;
//格式化後的具體訊息
private String formattedMessage;
//特殊處理異常物件
private transient Throwable throwable;
//這個很重要,是標識位{}在messagePattern中的位置
private int[] indices;
//當前indices的位置,表示使用到第幾個標識位了
private int usedCount;
我們是希望在格式化的時候,就對敏感欄位進行掩碼,因此重點關注org.apache.logging.log4j.message.ParameterizedMessage#formatTo這個方法,debug進去後執行了org.apache.logging.log4j.message.ParameterFormatter#formatMessage2這個方法:
static void formatMessage2(final StringBuilder buffer, final String messagePattern,
final Object[] arguments, final int argCount, final int[] indices) {
if (messagePattern == null || arguments == null || argCount == 0) {
buffer.append(messagePattern);
return;
}
int previous = 0;
for (int i = 0; i < argCount; i++) {
buffer.append(messagePattern, previous, indices[i]);
previous = indices[i] + 2;
recursiveDeepToString(arguments[i], buffer, null);
}
buffer.append(messagePattern, previous, messagePattern.length());
}
如上,我們看到在這裡對arguments陣列進行了迴圈,將其值最終append到目標StringBuilder,如果我們在這裡能判斷模板包含了什麼敏感字串,就可以在這裡使用不同的掩碼方式對欄位進行處理。
怎麼獲取當前是哪個字讀在append了呢?
前面提到的indices這個時候就可以用了,那麼我們可以從模板messagePattern截取出當前要append這段字串:
String word = messagePattern.substring(previous, indices[i]);
為了能達到掩碼的目的,我們加了一個列舉類來完成對不同欄位進行掩碼操作:
public enum DesensitizedWords {
idCard("idCard", 6, 3),
phone("phone", 3, 4),
mobile("mobile", 3, 4),
name("name", 0, 1),
;
private String word;
private int front;
private int tail;
DesensitizedWords(String word, int front, int tail) {
this.word = word;
this.front = front;
this.tail = tail;
}
public static String desensitize(String word, String val) {
for (DesensitizedWords item : DesensitizedWords.values()) {
if (word.contains(item.word)) {
return hide(val, item.front, item.tail, '*');
}
}
return val;
}
public static String hide(String src, int front, int tail, char replace) {
if (null == src)
return src;
int len = src.length();
if (front > len || tail > len) {
return src;
}
StringBuilder builder = new StringBuilder();
if (front > 0) {
builder.append(src.substring(0, front));
} else {
front = 0;
}
String tailStr = "";
if (tail > 0) {
tailStr = src.substring(src.length() - tail, src.length());
} else {
tail = 0;
}
int padding = len - front - tail;
if (padding > 0) {
for (int i = 0; i < padding; i++) {
builder.append(replace);
}
}
builder.append(tailStr);
return builder.toString();
}
}
然後將org.apache.logging.log4j.message.ParameterFormatter#formatMessage2方法重寫(實際上由於ParameterFormatter是final並且是package可見的,因此我們將org.apache.logging.log4j.message.ParameterizedMessage整個類copy一份為DesensitizedParameterizedMessage,並且將ParameterFormatter作為DesensitizedParameterizedMessage的內部類,並重寫formatMessage2方法如下):
static void formatMessage2(final StringBuilder buffer, final String messagePattern,
final Object[] arguments, final int argCount, final int[] indices) {
if (messagePattern == null || arguments == null || argCount == 0) {
buffer.append(messagePattern);
return;
}
int previous = 0;
for (int i = 0; i < argCount; i++) {
String word = messagePattern.substring(previous, indices[i]);
buffer.append(messagePattern, previous, indices[i]);
previous = indices[i] + 2;
StringBuilder builder = new StringBuilder();
recursiveDeepToString(arguments[i], builder, null);
buffer.append(DesensitizedWords.desensitize(word, builder.toString()));
}
buffer.append(messagePattern, previous, messagePattern.length());
}
通過上面的改寫(copy),我們已經將最基本的類創建出來,現在要考慮如何使用他們,前文有講到使用系統屬性log4j2.messageFactory來配置工廠類,實際上並沒有起作用,再次debug後發現,我們需要在resources下加一個log4j2.component.properties檔案來配置這個屬性才能生效,檔案配置如下:
log4j2.messageFactory=com.X.Xx.DesensitizedParameterizedMessageFactory
記得將工廠類產生的產品改為DesensitizedParameterizedMessage喲。
效果
測試用例
String idCard = "210002197812129527";
String name = "周星星";
String phone = "14700000000";
String mobile = "14700000000";
logger.info("明文資料:{},{},{},{}", idCard, phone, mobile, name);
logger.info("idCard:{},phone:{},mobile:{},name:{}", idCard, phone, mobile, name);
logger.error("error-idCard:{},error-phone:{},error-mobile:{},error-name:{}", idCard, phone, mobile, name);
logger.warn("warn-idCard:{},warn-phone:{},warn-mobile:{},warn-name:{}", idCard, phone, mobile, name);
logger.debug("debug-idCard:{},debug-phone:{},debug-mobile:{},debug-name:{}", idCard, phone, mobile, name);
效果如下
2017-12-06 10:48:39.490 [http-bio-8080-exec-1:563179] [DemoController.java:83] - [INFO] 明文資料:210002188012120359,14700000000,14700000000,周星星
2017-12-06 10:48:39.491 [http-bio-8080-exec-1:563180] [DemoController.java:84] - [INFO] idCard:210002*********359,phone:147****0000,mobile:147****0000,name:**星
2017-12-06 10:48:39.491 [http-bio-8080-exec-1:563180] [DemoController.java:85] - [ERROR] error-idCard:210002*********359,error-phone:147****0000,error-mobile:147****0000,error-name:**星
2017-12-06 10:48:39.492 [http-bio-8080-exec-1:563181] [DemoController.java:86] - [WARN] warn-idCard:210002*********359,warn-phone:147****0000,warn-mobile:147****0000,warn-name:**星
2017-12-06 10:48:39.493 [http-bio-8080-exec-1:563182] [DemoController.java:87] - [DEBUG] debug-idCard:210002*********359,debug-phone:147****0000,debug-mobile:147****0000,debug-name:**星
原始碼
終於到了大家喜歡的環節(沒有做裁剪,自行操作)。
DesensitizedParameterizedMessage.java
import org.apache.logging.log4j.message.Message;
import org.apache.logging.log4j.util.StringBuilderFormattable;
import java.text.SimpleDateFormat;
import java.util.*;
public class DesensitizedParameterizedMessage implements Message, StringBuilderFormattable {
// Should this be configurable?
private static final int DEFAULT_STRING_BUILDER_SIZE = 255;
/**
* Prefix for recursion.
*/
public static final String RECURSION_PREFIX = ParameterFormatter.RECURSION_PREFIX;
/**
* Suffix for recursion.
*/
public static final String RECURSION_SUFFIX = ParameterFormatter.RECURSION_SUFFIX;
/**
* Prefix for errors.
*/
public static final String ERROR_PREFIX = ParameterFormatter.ERROR_PREFIX;
/**
* Separator for errors.
*/
public static final String ERROR_SEPARATOR = ParameterFormatter.ERROR_SEPARATOR;
/**
* Separator for error messages.
*/
public static final String ERROR_MSG_SEPARATOR = ParameterFormatter.ERROR_MSG_SEPARATOR;
/**
* Suffix for errors.
*/
public static final String ERROR_SUFFIX = ParameterFormatter.ERROR_SUFFIX;
private static final long serialVersionUID = -665975803997290697L;
private static final int HASHVAL = 31;
// storing JDK classes in ThreadLocals does not cause memory leaks in web apps, so this is okay
private static ThreadLocal<StringBuilder> threadLocalStringBuilder = new ThreadLocal<>();
private String messagePattern;
private transient Object[] argArray;
private String formattedMessage;
private transient Throwable throwable;
private int[] indices;
private int usedCount;
public DesensitizedParameterizedMessage(final String messagePattern, final Object[] arguments, final Throwable throwable) {
this.argArray = arguments;
this.throwable = throwable;
init(messagePattern);
}
public DesensitizedParameterizedMessage(final String messagePattern, final Object... arguments) {
this.argArray = arguments;
init(messagePattern);
}
/**
* Constructor with a pattern and a single parameter.
*
* @param messagePattern The message pattern.
* @param arg The parameter.
*/
public DesensitizedParameterizedMessage(final String messagePattern, final Object arg) {
this(messagePattern, new Object[]{arg});
}
public DesensitizedParameterizedMessage(final String messagePattern, final Object arg0, final Object arg1) {
this(messagePattern, new Object[]{arg0, arg1});
}
private void init(final String messagePattern) {
this.messagePattern = messagePattern;
final int len = Math.max(1, messagePattern == null ? 0 : messagePattern.length() >> 1); // divide by 2
this.indices = new int[len]; // LOG4J2-1542 ensure non-zero array length
final int placeholders = ParameterFormatter.countArgumentPlaceholders2(messagePattern, indices);
initThrowable(argArray, placeholders);
this.usedCount = Math.min(placeholders, argArray == null ? 0 : argArray.length);
}
private void initThrowable(final Object[] params, final int usedParams) {
if (params != null) {
final int argCount = params.length;
if (usedParams < argCount && this.throwable == null && params[argCount - 1] instanceof Throwable) {
this.throwable = (Throwable) params[argCount - 1];
}
}
}
@Override
public String getFormat() {
return messagePattern;
}
@Override
public Object[] getParameters() {
return argArray;
}
@Override
public Throwable getThrowable() {
return throwable;
}
@Override
public String getFormattedMessage() {
if (formattedMessage == null) {
final StringBuilder buffer = getThreadLocalStringBuilder();
formatTo(buffer);
formattedMessage = buffer.toString();
}
return formattedMessage;
}
private static StringBuilder getThreadLocalStringBuilder() {
StringBuilder buffer = threadLocalStringBuilder.get();
if (buffer == null) {
buffer = new StringBuilder(DEFAULT_STRING_BUILDER_SIZE);
threadLocalStringBuilder.set(buffer);
}
buffer.setLength(0);
return buffer;
}
@Override
public void formatTo(final StringBuilder buffer) {
if (formattedMessage != null) {
buffer.append(formattedMessage);
} else {
if (indices[0] < 0) {
ParameterFormatter.formatMessage(buffer, messagePattern, argArray, usedCount);
} else {
ParameterFormatter.formatMessage2(buffer, messagePattern, argArray, usedCount, indices);
}
}
}
public static String format(final String messagePattern, final Object[] arguments) {
return ParameterFormatter.format(messagePattern, arguments);
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final DesensitizedParameterizedMessage that = (DesensitizedParameterizedMessage) o;
if (messagePattern != null ? !messagePattern.equals(that.messagePattern) : that.messagePattern != null) {
return false;
}
if (!Arrays.equals(this.argArray, this.argArray)) {
return false;
}
//if (throwable != null ? !throwable.equals(that.throwable) : that.throwable != null) return false;
return true;
}
@Override
public int hashCode() {
int result = messagePattern != null ? messagePattern.hashCode() : 0;
result = HASHVAL * result + (argArray != null ? Arrays.hashCode(argArray) : 0);
return result;
}
public static int countArgumentPlaceholders(final String messagePattern) {
return ParameterFormatter.countArgumentPlaceholders(messagePattern);
}
public static String deepToString(final Object o) {
return ParameterFormatter.deepToString(o);
}
public static String identityToString(final Object obj) {
return ParameterFormatter.identityToString(obj);
}
@Override
public String toString() {
return "ParameterizedMessage[messagePattern=" + messagePattern + ", stringArgs=" +
Arrays.toString(argArray) + ", throwable=" + throwable + ']';
}
static class ParameterFormatter {
/**
* Prefix for recursion.
*/
static final String RECURSION_PREFIX = "[...";
/**
* Suffix for recursion.
*/
static final String RECURSION_SUFFIX = "...]";
/**
* Prefix for errors.
*/
static final String ERROR_PREFIX = "[!!!";
/**
* Separator for errors.
*/
static final String ERROR_SEPARATOR = "=>";
/**
* Separator for error messages.
*/
static final String ERROR_MSG_SEPARATOR = ":";
/**
* Suffix for errors.
*/
static final String ERROR_SUFFIX = "!!!]";
private static final char DELIM_START = '{';
private static final char DELIM_STOP = '}';
private static final char ESCAPE_CHAR = '\\';
private static ThreadLocal<SimpleDateFormat> threadLocalSimpleDateFormat = new ThreadLocal<>();
private ParameterFormatter() {
}
static int countArgumentPlaceholders(final String messagePattern) {
if (messagePattern == null) {
return 0;
}
final int length = messagePattern.length();
int result = 0;
boolean isEscaped = false;
for (int i = 0; i < length - 1; i++) {
final char curChar = messagePattern.charAt(i);
if (curChar == ESCAPE_CHAR) {
isEscaped = !isEscaped;
} else if (curChar == DELIM_START) {
if (!isEscaped && messagePattern.charAt(i + 1) == DELIM_STOP) {
result++;
i++;
}
isEscaped = false;
} else {
isEscaped = false;
}
}
return result;
}
static int countArgumentPlaceholders2(final String messagePattern, final int[] indices) {
if (messagePattern == null) {
return 0;
}
final int length = messagePattern.length();
int result = 0;
boolean isEscaped = false;
for (int i = 0; i < length - 1; i++) {
final char curChar = messagePattern.charAt(i);
if (curChar == ESCAPE_CHAR) {
isEscaped = !isEscaped;
indices[0] = -1; // escaping means fast path is not available...
result++;
} else if (curChar == DELIM_START) {
if (!isEscaped && messagePattern.charAt(i + 1) == DELIM_STOP) {
indices[result] = i;
result++;
i++;
}
isEscaped = false;
} else {
isEscaped = false;
}
}
return result;
}
static int countArgumentPlaceholders3(final char[] messagePattern, final int length, final int[] indices) {
int result = 0;
boolean isEscaped = false;
for (int i = 0; i < length - 1; i++) {
final char curChar = messagePattern[i];
if (curChar == ESCAPE_CHAR) {
isEscaped = !isEscaped;
} else if (curChar == DELIM_START) {
if (!isEscaped && messagePattern[i + 1] == DELIM_STOP) {
indices[result] = i;
result++;
i++;
}
isEscaped = false;
} else {
isEscaped = false;
}
}
return result;
}
static String format(final String messagePattern, final Object[] arguments) {
final StringBuilder result = new StringBuilder();
final int argCount = arguments == null ? 0 : arguments.length;
formatMessage(result, messagePattern, arguments, argCount);
return result.toString();
}
static void formatMessage2(final StringBuilder buffer, final String messagePattern,
final Object[] arguments, final int argCount, final int[] indices) {
if (messagePattern == null || arguments == null || argCount == 0) {
buffer.append(messagePattern);
return;
}
int previous = 0;
for (int i = 0; i < argCount; i++) {
String word = messagePattern.substring(previous, indices[i]);
buffer.append(messagePattern, previous, indices[i]);
previous = indices[i] + 2;
StringBuilder builder = new StringBuilder();
recursiveDeepToString(arguments[i], builder, null);
buffer.append(DesensitizedWords.desensitize(word, builder.toString()));
}
buffer.append(messagePattern, previous, messagePattern.length());
}
static void formatMessage3(final StringBuilder buffer, final char[] messagePattern, final int patternLength,
final Object[] arguments, final int argCount, final int[] indices) {
if (messagePattern == null) {
return;
}
if (arguments == null || argCount == 0) {
buffer.append(messagePattern);
return;
}
int previous = 0;
for (int i = 0; i < argCount; i++) {
buffer.append(messagePattern, previous, indices[i]);
previous = indices[i] + 2;
recursiveDeepToString(arguments[i], buffer, null);
}
buffer.append(messagePattern, previous, patternLength);
}
static void formatMessage(final StringBuilder buffer, final String messagePattern,
final Object[] arguments, final int argCount) {
if (messagePattern == null || arguments == null || argCount == 0) {
buffer.append(messagePattern);
return;
}
int escapeCounter = 0;
int currentArgument = 0;
int i = 0;
final int len = messagePattern.length();
for (; i < len - 1; i++) { // last char is excluded from the loop
final char curChar = messagePattern.charAt(i);
if (curChar == ESCAPE_CHAR) {
escapeCounter++;
} else {
if (isDelimPair(curChar, messagePattern, i)) { // looks ahead one char
i++;
// write escaped escape chars
writeEscapedEscapeChars(escapeCounter, buffer);
if (isOdd(escapeCounter)) {
// i.e. escaped: write escaped escape chars
writeDelimPair(buffer);
} else {
// unescaped
writeArgOrDelimPair(arguments, argCount, currentArgument, buffer);
currentArgument++;
}
} else {
handleLiteralChar(buffer, escapeCounter, curChar);
}
escapeCounter = 0;
}
}
handleRemainingCharIfAny(messagePattern, len, buffer, escapeCounter, i);
}
private static boolean isDelimPair(final char curChar, final String messagePattern, final int curCharIndex) {
return curChar == DELIM_START && messagePattern.charAt(curCharIndex + 1) == DELIM_STOP;
}
private static void handleRemainingCharIfAny(final String messagePattern, final int len,
final StringBuilder buffer, final int escapeCounter, final int i) {
if (i == len - 1) {
final char curChar = messagePattern.charAt(i);
handleLastChar(buffer, escapeCounter, curChar);
}
}
private static void handleLastChar(final StringBuilder buffer, final int escapeCounter, final char curChar) {
if (curChar == ESCAPE_CHAR) {
writeUnescapedEscapeChars(escapeCounter + 1, buffer);
} else {
handleLiteralChar(buffer, escapeCounter, curChar);
}
}
private static void handleLiteralChar(final StringBuilder buffer, final int escapeCounter, final char curChar) {
writeUnescapedEscapeChars(escapeCounter, buffer);
buffer.append(curChar);
}
private static void writeDelimPair(final StringBuilder buffer) {
buffer.append(DELIM_START);
buffer.append(DELIM_STOP);
}
private static boolean isOdd(final int number) {
return (number & 1) == 1;
}
private static void writeEscapedEscapeChars(final int escapeCounter, final StringBuilder buffer) {
final int escapedEscapes = escapeCounter >> 1; // divide by two
writeUnescapedEscapeChars(escapedEscapes, buffer);
}
private static void writeUnescapedEscapeChars(int escapeCounter, final StringBuilder buffer) {
while (escapeCounter > 0) {
buffer.append(ESCAPE_CHAR);
escapeCounter--;
}
}
private static void writeArgOrDelimPair(final Object[] arguments, final int argCount, final int currentArgument,
final StringBuilder buffer) {
if (currentArgument < argCount) {
recursiveDeepToString(arguments[currentArgument], buffer, null);
} else {
writeDelimPair(buffer);
}
}
static String deepToString(final Object o) {
if (o == null) {
return null;
}
if (o instanceof String) {
return (String) o;
}
final StringBuilder str = new StringBuilder();
final Set<String> dejaVu = new HashSet<>(); // that's actually a neat name ;)
recursiveDeepToString(o, str, dejaVu);
return str.toString();
}
private static void recursiveDeepToString(final Object o, final StringBuilder str, final Set<String> dejaVu) {
if (appendSpecialTypes(o, str)) {
return;
}
if (isMaybeRecursive(o)) {
appendPotentiallyRecursiveValue(o, str, dejaVu);
} else {
tryObjectToString(o, str);
}
}
private static boolean appendSpecialTypes(final Object o, final StringBuilder str) {
if (o == null || o instanceof String) {
str.append((String) o);
return true;
} else if (o instanceof CharSequence) {
str.append((CharSequence) o);
return true;
} else if (o instanceof StringBuilderFormattable) {
((StringBuilderFormattable) o).formatTo(str);
return true;
} else if (o instanceof Integer) { // LOG4J2-1415 unbox auto-boxed primitives to avoid calling toString()
str.append(((Integer) o).intValue());
return true;
} else if (o instanceof Long) {
str.append(((Long) o).longValue());
return true;
} else if (o instanceof Double) {
str.append(((Double) o).doubleValue());
return true;
} else if (o instanceof Boolean) {
str.append(((Boolean) o).booleanValue());
return true;
} else if (o instanceof Character) {
str.append(((Character) o).charValue());
return true;
} else if (o instanceof Short) {
str.append(((Short) o).shortValue());
return true;
} else if (o instanceof Float) {
str.append(((Float) o).floatValue());
return true;
}
return appendDate(o, str);
}
private static boolean appendDate(final Object o, final StringBuilder str) {
if (!(o instanceof Date)) {
return false;
}
final Date date = (Date) o;
final SimpleDateFormat format = getSimpleDateFormat();
str.append(format.format(date));
return true;
}
private static SimpleDateFormat getSimpleDateFormat() {
SimpleDateFormat result = threadLocalSimpleDateFormat.get();
if (result == null) {
result = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ"