Jelajahi Sumber

新增英文日志输出

17491 5 jam lalu
induk
melakukan
a6c3404f32

+ 2 - 1
.gitignore

@@ -3,5 +3,6 @@
 classes
 bin
 out
+logs/
 *.db
-*.iml
+*.iml

+ 0 - 0
.lock


+ 46 - 0
skills/java-client-file-logging/SKILL.md

@@ -0,0 +1,46 @@
+---
+name: java-client-file-logging
+description: Add or review lightweight, dependency-free file logging for Java 8 desktop clients. Use when a Swing, JavaFX, or packaged Java client needs daily UTF-8 logs beside its JAR, structured records for login/scan/business events, and explicit user-close or uncaught-exception records without adopting a logging framework.
+---
+
+# Java Client File Logging
+
+Use this pattern for a small desktop client where operational support needs inspectable local logs and adding Logback or Log4j is disproportionate. Copy [assets/AppLog.java](assets/AppLog.java), change its package and `APP_NAME`, then integrate it before the UI or worker threads start.
+
+## Integration Workflow
+
+1. Call `AppLog.init()` as the first operation in `main`, before creating Swing/JavaFX components, network clients, or executors.
+2. Mark confirmed operator shutdown immediately before `System.exit(0)` with `AppLog.markUserClose()`.
+3. Log business boundaries rather than incidental control flow:
+   - `login(userId, method, success)` for every completed or rejected login.
+   - `scan(type, code)` immediately after a non-empty scan is accepted.
+   - `work(action, code, success)` after each MES/device/API operation resolves.
+4. Run the packaged JAR from its intended release folder and verify the log appears in `<jar-directory>/logs/<app-name>-yyyy-MM-dd.log`.
+
+Keep user-facing messages and diagnostics separate: log stable actions and identifiers, not display text or request payloads. Never log passwords, tokens, session IDs, or raw sensitive responses.
+
+## Expected Records
+
+The template writes one line per event in this shape:
+
+```text
+[2026-07-14 11:00:05.358][INFO][AWT-EventQueue-0] Login succeeded, method=badge-scan, user=10001
+```
+
+It appends to one file per calendar day, writes UTF-8, includes the thread name, serializes writes with a lock, and flushes each event. The shutdown hook writes either a confirmed user-close record or the uncaught exception and stack trace.
+
+## Placement Rules
+
+- Derive the base directory from the running code source. For a JAR this is its parent folder; in an IDE it falls back to `user.dir`.
+- Use the exact same package/import convention as the target application.
+- Preserve any existing default uncaught-exception handler when the host application or framework owns it. The supplied template installs one because it is intended for standalone clients; chain to the existing handler if one must remain active.
+- Keep `SimpleDateFormat` accesses inside the logging lock, as it is not thread-safe.
+- Treat logging failure as non-fatal. The client must remain usable when the log directory is read-only or unavailable.
+
+## Review Checklist
+
+- Initialization happens before any meaningful client activity.
+- All `System.exit` paths that mean an intentional close call `markUserClose()`.
+- Login, scans, and externally visible operation outcomes are each recorded once.
+- Values passed to log methods cannot inject new lines; the helper normalizes CR/LF.
+- The release layout leaves the JAR directory writable, or the deployment owner accepts that logging is best-effort.

+ 4 - 0
skills/java-client-file-logging/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "Java Client File Logging"
+  short_description: "Add durable file logs to Java desktop clients"
+  default_prompt: "Use $java-client-file-logging to add client-side file logging to this Java desktop application."

+ 88 - 0
skills/java-client-file-logging/assets/AppLog.java

@@ -0,0 +1,88 @@
+package com.example.client.util;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.PrintStream;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Lightweight local logging for a standalone Java desktop client.
+ * Change APP_NAME and package before use.
+ */
+public final class AppLog {
+    private static final String APP_NAME = "client";
+    private static final Object LOCK = new Object();
+    private static final SimpleDateFormat TIME = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
+    private static PrintStream out;
+    private static volatile String exitReason;
+    private static volatile String uncaughtThread;
+    private static volatile Throwable uncaughtException;
+
+    private AppLog() { }
+
+    public static void init() {
+        synchronized (LOCK) {
+            if (out != null) return;
+            try {
+                File code = new File(AppLog.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+                File base = code.isFile() ? code.getParentFile() : new File(System.getProperty("user.dir"));
+                File dir = new File(base, "logs");
+                if (!dir.exists() && !dir.mkdirs()) return;
+
+                String day = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+                out = new PrintStream(new FileOutputStream(new File(dir, APP_NAME + "-" + day + ".log"), true), true, "UTF-8");
+                Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
+                    public void uncaughtException(Thread thread, Throwable exception) {
+                        exitReason = "UNCAUGHT_EXCEPTION";
+                        uncaughtThread = thread.getName();
+                        uncaughtException = exception;
+                    }
+                });
+                Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
+                    public void run() {
+                        if ("USER_CLOSE".equals(exitReason)) {
+                            log("INFO", "Client exited, reason=user-close", null);
+                        } else if ("UNCAUGHT_EXCEPTION".equals(exitReason)) {
+                            log("ERROR", "Client exited, reason=uncaught-exception, thread=" + clean(uncaughtThread), uncaughtException);
+                        }
+                    }
+                }, APP_NAME + "-log-shutdown"));
+            } catch (Exception ignored) {
+                // Logging is best-effort and must not stop client startup.
+            }
+        }
+    }
+
+    public static void login(String userId, String method, boolean success) {
+        log(success ? "INFO" : "WARN", "Login " + (success ? "succeeded" : "failed")
+                + ", method=" + clean(method) + ", user=" + clean(userId), null);
+    }
+
+    public static void scan(String type, String code) {
+        log("INFO", "Scan, type=" + clean(type) + ", code=" + clean(code), null);
+    }
+
+    public static void work(String action, String code, boolean success) {
+        log(success ? "INFO" : "WARN", "Operation " + (success ? "succeeded" : "failed")
+                + ", action=" + clean(action) + ", code=" + clean(code), null);
+    }
+
+    public static void markUserClose() {
+        exitReason = "USER_CLOSE";
+    }
+
+    private static String clean(String value) {
+        return value == null ? "" : value.replace('\r', ' ').replace('\n', ' ');
+    }
+
+    private static void log(String level, String message, Throwable exception) {
+        synchronized (LOCK) {
+            if (out == null) return;
+            out.println("[" + TIME.format(new Date()) + "][" + level + "]["
+                    + Thread.currentThread().getName() + "] " + message);
+            if (exception != null) exception.printStackTrace(out);
+            out.flush();
+        }
+    }
+}

+ 26 - 4
src/com/mes/ui/LoginFarme.java

@@ -4,6 +4,7 @@ import com.alibaba.fastjson2.JSONObject;
 import com.mes.component.MesRadio;
 import com.mes.component.MesWebView;
 import com.mes.component.MyDialog;
+import com.mes.util.AppLog;
 import com.mes.util.Base64Utils;
 import com.mes.util.HttpUtils;
 
@@ -11,6 +12,8 @@ import javax.swing.*;
 import java.awt.*;
 import java.awt.event.ActionEvent;
 import java.awt.event.ActionListener;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
 import java.time.LocalDateTime;
 
 public class LoginFarme extends JFrame {
@@ -26,7 +29,14 @@ public class LoginFarme extends JFrame {
         setTitle("MES系统客户端:"+MesClient.mes_gw+" - "+MesClient.mes_gw_des);
 
         ImageIcon bg = new ImageIcon(MesClient.class.getResource("/background.png"));
-        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+        setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
+        addWindowListener(new WindowAdapter() {
+            @Override
+            public void windowClosing(WindowEvent e) {
+                AppLog.markUserClose();
+                System.exit(0);
+            }
+        });
         JLabel imgLabel = new JLabel(bg);//将背景图放在标签里。
         getLayeredPane().add(imgLabel, new Integer(Integer.MIN_VALUE));//注意这里是关键,将背景标签添加到jfram的LayeredPane面板里。
         imgLabel.setBounds(0,0,bg.getIconWidth(), bg.getIconHeight());//设置背景标签的位置
@@ -95,6 +105,7 @@ public class LoginFarme extends JFrame {
         String user_str = userNameTxt.getText().toString();
         String password_str = userPasswordTxt.getText().toString();
         if(user_str.equalsIgnoreCase("")||password_str.equalsIgnoreCase("")) {
+            AppLog.login(user_str, "password", false);
             JOptionPane.showMessageDialog(MesClient.mesClientFrame,"用户名或密码不能为空","提示窗口", JOptionPane.INFORMATION_MESSAGE);
             return;
         }
@@ -104,14 +115,16 @@ public class LoginFarme extends JFrame {
         String url = "http://"+MesClient.mes_server_ip+":8980/js/a/login?__ajax=json&username="+username+"&password="+password+"&validCode=&__sid=";
         String loginResult = HttpUtils.sendRequest(url);
         if(loginResult.equalsIgnoreCase("false")) {
+            AppLog.login(user_str, "password", false);
             JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录异常,请检查网络或联系网络管理员","提示窗口", JOptionPane.INFORMATION_MESSAGE);
             return;
         }else {
             JSONObject retObj = JSONObject.parseObject(loginResult);
             if(retObj.get("result")!=null&&retObj.get("result").toString().equalsIgnoreCase("true")) {
                 //检查用户权限是否可登录界面
-                checkUserAuthority(retObj);
+                checkUserAuthority(retObj, "password");
             }else {
+                AppLog.login(user_str, "password", false);
                 //ret = "msg save error";
                 //ret = false;
                 JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录失败,用户名或密码错误","提示窗口", JOptionPane.INFORMATION_MESSAGE);
@@ -126,30 +139,34 @@ public class LoginFarme extends JFrame {
         String scanContent = JOptionPane.showInputDialog(null, "请扫码工牌二维码");
         System.out.println("scanContent="+scanContent);
         if(scanContent!=null&&!scanContent.equalsIgnoreCase("")) {
+            AppLog.scan("badge", scanContent);
             String url = "http://"+MesClient.mes_server_ip+":8980/js/a/mes/mesLogin/login?__login=true&__ajax=json&username="+scanContent;
             String loginResult = HttpUtils.sendRequest(url);
             System.out.println("loginResult="+loginResult);
             if(loginResult.equalsIgnoreCase("false")) {
+                AppLog.login(scanContent, "badge-scan", false);
                 JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录异常,请检查网络或联系网络管理员","提示窗口", JOptionPane.INFORMATION_MESSAGE);
                 return;
             }else {
                 JSONObject retObj = JSONObject.parseObject(loginResult);
                 if(retObj.get("result")!=null&&retObj.get("result").toString().equalsIgnoreCase("true")) {
                     //检查用户权限是否可登录界面
-                    checkUserAuthority(retObj);
+                    checkUserAuthority(retObj, "badge-scan");
                 }else {
+                    AppLog.login(scanContent, "badge-scan", false);
                     JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录失败,用户名或密码错误","提示窗口", JOptionPane.INFORMATION_MESSAGE);
                     return;
                 }
             }
         }else {
+            AppLog.login("", "badge-scan", false);
             JOptionPane.showMessageDialog(MesClient.mesClientFrame,"扫码内容错误","提示窗口", JOptionPane.INFORMATION_MESSAGE);
             return;
         }
     }
 
     //检查用户权限是否可登录界面
-    public static void checkUserAuthority(JSONObject retObj) {
+    public static void checkUserAuthority(JSONObject retObj, String method) {
         //设置登录用户名
         JSONObject userObj = JSONObject.parseObject(retObj.get("user").toString());
         String user_id = userObj.getString("id").toString();
@@ -166,10 +183,12 @@ public class LoginFarme extends JFrame {
                 JSONObject authObjTmp = JSONObject.parseObject(authorityObj.get("data").toString());
                 MesClient.mes_auth = Integer.parseInt(authObjTmp.getString("auth").toString());
                 if(MesClient.mes_auth==0) {
+                    AppLog.login(user_id, method, false);
                     //无权限登录
                     JOptionPane.showMessageDialog(MesClient.mesClientFrame,"您无权登录该工位","提示窗口", JOptionPane.INFORMATION_MESSAGE);
                     return;
                 }else if(MesClient.mes_auth==1||MesClient.mes_auth==2) {
+                    AppLog.login(user_id, method, true);
 
                     // 获取等于所处时间-当前小时
                     LocalDateTime now = LocalDateTime.now();
@@ -208,9 +227,12 @@ public class LoginFarme extends JFrame {
                     new MyDialog(MesClient.mesClientFrame,"提示",lmsg);*/
                 }
 
+            }else {
+                AppLog.login(user_id, method, false);
             }
 
         }else {
+            AppLog.login(user_id, method, false);
             JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录失败,用户名或密码错误","提示窗口", JOptionPane.INFORMATION_MESSAGE);
             return;
         }

+ 22 - 3
src/com/mes/ui/MesClient.java

@@ -10,6 +10,7 @@ import com.mes.component.MesRadio;
 import com.mes.component.MesWebView;
 import com.mes.component.MyDialog;
 import com.mes.netty.NettyClient;
+import com.mes.util.AppLog;
 import com.mes.util.DateLocalUtils;
 import com.mes.util.HttpUtils;
 import com.mes.util.JdbcUtils;
@@ -26,6 +27,8 @@ import java.awt.event.ActionEvent;
 import java.awt.event.ActionListener;
 import java.awt.event.MouseAdapter;
 import java.awt.event.MouseEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
@@ -164,6 +167,7 @@ public class MesClient extends JFrame {
     public static String programNoB = "";
 
     public static void main(String[] args) {
+        AppLog.init();
         if (LockUtil.getInstance().isAppActive() == true){
 //            JOptionPane.showMessageDialog(null, "已有一个程序在运行,程序退出");
             return;
@@ -542,7 +546,14 @@ public class MesClient extends JFrame {
     public MesClient() {
         setIconImage(Toolkit.getDefaultToolkit().getImage(MesClient.class.getResource("/bg/logo.png")));
         setTitle("MES系统客户端:"+mes_gw + "- " + mes_gw_des);
-        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+        setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
+        addWindowListener(new WindowAdapter() {
+            @Override
+            public void windowClosing(WindowEvent e) {
+                AppLog.markUserClose();
+                System.exit(0);
+            }
+        });
         setBounds(0, 0, 1024, 768);
 
         JMenuBar menuBar = new JMenuBar();
@@ -623,6 +634,7 @@ public class MesClient extends JFrame {
                         String qret = "OK";
                         Boolean sendret = DataUtil.sendQuality(nettyClient,sn,qret,user20,"A");
                         if(!sendret){
+                            AppLog.work("quality-submit-A", sn, false);
                             MesClient.setMenuStatus("上传MES失败,请重试",-1);
                         }
                     }
@@ -646,6 +658,7 @@ public class MesClient extends JFrame {
                         String qret = "OK";
                         Boolean sendret = DataUtil.sendQuality(nettyClient,sn,qret,user20,"B");
                         if(!sendret){
+                            AppLog.work("quality-submit-B", sn, false);
                             MesClient.setMenuStatus("上传MES失败,请重试",-1);
                         }
                     }
@@ -1251,13 +1264,17 @@ public class MesClient extends JFrame {
         String scanBarcodeTitle = "请扫物料:"+bindMaterialResp.getMaterialTitle();
         String scanBarcode = JOptionPane.showInputDialog(null, scanBarcodeTitle);
         if(scanBarcode!=null&&!scanBarcode.equalsIgnoreCase("")) {
+            AppLog.scan("material-batch", scanBarcode);
 
             JSONObject retObj = DataUtil.saveBindMaterail(scanBarcode,bindMaterialResp.getCraft(),bindMaterialResp.getMaterialId(),bindMaterialResp.getType());
-            if(retObj.get("result")!=null&&retObj.get("result").toString().equalsIgnoreCase("true")) {
+            boolean success = retObj != null && retObj.get("result") != null
+                    && retObj.get("result").toString().equalsIgnoreCase("true");
+            AppLog.work("material-bind", scanBarcode, success);
+            if(success) {
                 MesClient.setMenuStatus("扫物料:"+bindMaterialResp.getMaterialTitle()+"成功",0);
                 updateMaterailData();
             }else{
-                if(retObj.get("result")==null){
+                if(retObj == null || retObj.get("result")==null){
                     MesClient.setMenuStatus("请求失败,请重试",-1);
                 }else{
                     if(retObj.get("result").toString().equalsIgnoreCase("false")){
@@ -1326,6 +1343,7 @@ public class MesClient extends JFrame {
             product_sn2.setText(sn);
         }
 
+        AppLog.scan("workpiece-" + page, sn);
         MesClient.setMenuStatus("扫码成功",0);
 
         //刷新界面
@@ -1334,6 +1352,7 @@ public class MesClient extends JFrame {
         // 查询工件质量
         Boolean sendret = DataUtil.checkQuality(nettyClient,sn,user20,page);
         if(!sendret){
+            AppLog.work("quality-check-" + page, sn, false);
             MesClient.setMenuStatus("消息发送失败,请重试",1);
         }
 

+ 4 - 0
src/com/mes/ui/MesRevice.java

@@ -4,6 +4,7 @@ import com.mes.netty.ProtocolParam;
 import com.mes.component.MyDialog;
 import com.mes.util.CommonUtils;
 import com.mes.util.ErrorMsg;
+import com.mes.util.AppLog;
 
 import javax.swing.*;
 import java.awt.*;
@@ -19,6 +20,7 @@ public class MesRevice {
 
                 String oprno = ProtocolParam.getOprno(mes_msg).trim();
                 String sn = ProtocolParam.getSn(mes_msg).trim();
+                AppLog.work("quality-check", sn, true);
 
                 MesClient.lastpage = 0;
                 if(oprno.equals(MesClient.mes_gw+"A") || oprno.equals(MesClient.mes_gw+"C") || oprno.equals(MesClient.mes_gw+"E")){
@@ -57,6 +59,7 @@ public class MesRevice {
                 }
 
             }else {
+                AppLog.work("quality-check", ProtocolParam.getSn(mes_msg).trim(), false);
                 if(MesClient.scan_type == 1){
                     MesClient.check_quality_result = false;
                 }else{
@@ -115,6 +118,7 @@ public class MesRevice {
     public static void updateResultRevice(String processMsgRet,String mes_msg){
         try{
             String sn = ProtocolParam.getSn(mes_msg).trim();
+            AppLog.work("quality-submit", sn, processMsgRet.equalsIgnoreCase("OK"));
             if(processMsgRet.equalsIgnoreCase("OK")) {
                 if(sn.equals(MesClient.product_sn2.getText())){
                     MesClient.pxstatus2.setText("B:提交成功");

+ 3 - 0
src/com/mes/ui/PlcUtil.java

@@ -5,6 +5,7 @@ import com.github.xingshuangs.iot.protocol.s7.service.S7PLC;
 import com.mes.util.CommonUtils;
 import com.mes.util.DateLocalUtils;
 import com.mes.util.JdbcUtils;
+import com.mes.util.AppLog;
 
 import java.util.ArrayList;
 
@@ -162,6 +163,7 @@ public class PlcUtil {
                 Boolean sendret = DataUtil.sendQuality(MesClient.nettyClient,MesClient.product_sn.getText(),"OK",MesClient.user20,"A");
 //                Boolean sendret = DataUtil.sendQuality(MesClient.product_sn.getText(),"OK",MesClient.user20,"A");
                 if(!sendret){
+                    AppLog.work("quality-submit-A", MesClient.product_sn.getText(), false);
                     MesClient.pxstatus1.setText("A:结果上传MES失败");
                     MesClient.tjStatusa = 1;
                 }else{
@@ -229,6 +231,7 @@ public class PlcUtil {
 //                Boolean sendret = DataUtil.sendQuality(MesClient.product_sn2.getText(),"OK",MesClient.user20,"B");
                 Boolean sendret = DataUtil.sendQuality(MesClient.nettyClient,MesClient.product_sn2.getText(),"OK",MesClient.user20,"B");
                 if(!sendret){
+                    AppLog.work("quality-submit-B", MesClient.product_sn2.getText(), false);
                     MesClient.pxstatus2.setText("B:结果上传MES失败");
                     MesClient.tjStatusb = 1;
                 }else{

+ 85 - 0
src/com/mes/util/AppLog.java

@@ -0,0 +1,85 @@
+package com.mes.util;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.PrintStream;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/** Lightweight local logging for the standalone MES desktop client. */
+public final class AppLog {
+    private static final String APP_NAME = "mesclient-op060B-P02";
+    private static final Object LOCK = new Object();
+    private static final SimpleDateFormat TIME = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
+    private static PrintStream out;
+    private static volatile String exitReason;
+    private static volatile String uncaughtThread;
+    private static volatile Throwable uncaughtException;
+
+    private AppLog() { }
+
+    public static void init() {
+        synchronized (LOCK) {
+            if (out != null) return;
+            try {
+                File code = new File(AppLog.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+                File base = code.isFile() ? code.getParentFile() : new File(System.getProperty("user.dir"));
+                File dir = new File(base, "logs");
+                if (!dir.exists() && !dir.mkdirs()) return;
+
+                String day = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+                out = new PrintStream(new FileOutputStream(new File(dir, APP_NAME + "-" + day + ".log"), true), true, "UTF-8");
+                Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
+                    public void uncaughtException(Thread thread, Throwable exception) {
+                        exitReason = "UNCAUGHT_EXCEPTION";
+                        uncaughtThread = thread.getName();
+                        uncaughtException = exception;
+                    }
+                });
+                Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
+                    public void run() {
+                        if ("USER_CLOSE".equals(exitReason)) {
+                            log("INFO", "Client exited, reason=user-close", null);
+                        } else if ("UNCAUGHT_EXCEPTION".equals(exitReason)) {
+                            log("ERROR", "Client exited, reason=uncaught-exception, thread=" + clean(uncaughtThread), uncaughtException);
+                        }
+                    }
+                }, APP_NAME + "-log-shutdown"));
+            } catch (Exception ignored) {
+                // Logging is best-effort and must not stop client startup.
+            }
+        }
+    }
+
+    public static void login(String userId, String method, boolean success) {
+        log(success ? "INFO" : "WARN", "Login " + (success ? "succeeded" : "failed")
+                + ", method=" + clean(method) + ", user=" + clean(userId), null);
+    }
+
+    public static void scan(String type, String code) {
+        log("INFO", "Scan, type=" + clean(type) + ", code=" + clean(code), null);
+    }
+
+    public static void work(String action, String code, boolean success) {
+        log(success ? "INFO" : "WARN", "Operation " + (success ? "succeeded" : "failed")
+                + ", action=" + clean(action) + ", code=" + clean(code), null);
+    }
+
+    public static void markUserClose() {
+        exitReason = "USER_CLOSE";
+    }
+
+    private static String clean(String value) {
+        return value == null ? "" : value.replace('\r', ' ').replace('\n', ' ');
+    }
+
+    private static void log(String level, String message, Throwable exception) {
+        synchronized (LOCK) {
+            if (out == null) return;
+            out.println("[" + TIME.format(new Date()) + "][" + level + "]["
+                    + Thread.currentThread().getName() + "] " + message);
+            if (exception != null) exception.printStackTrace(out);
+            out.flush();
+        }
+    }
+}