using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Threading; namespace JJServiceManager { /// /// 서비스 로그 파일을 실시간으로 읽음 (tail). /// - 파일 지정: 그 파일을 계속 추적 /// - 폴더 지정: 폴더 안에서 가장 최근에 수정된 파일을 자동으로 추적 (날짜별 로그에 유리) /// - 읽기 전용으로만 열며, 파일을 수정·삭제하지 않음 /// public sealed class LogTailer : IDisposable { private const int PollMs = 500; private const int FolderScanEvery = 4; // 폴더 모드: 2초마다 최신 파일 확인 private static readonly TimeSpan SwitchIdle = TimeSpan.FromSeconds(5); // 지금 파일이 이만큼 멈춰 있어야 새 파일로 전환 private const int InitialBytes = 256 * 1024; // 처음 열 때 끝에서부터 읽을 양 private const int InitialMaxLines = 1000; // 처음 열 때 보여줄 최대 줄 수 private const int MaxReadPerTick = 4 * 1024 * 1024; /// 파일별 읽기 상태 (폴더 모드에서 파일을 오가도 이어서 읽음) private sealed class FileState { public string Path; public long Position; public int BomLength; public Encoding Encoding; // null = 아직 판단 전 (영문만 나온 상태) public Decoder Decoder; public string Partial = ""; public DateTime PartialSince; } private readonly ServiceEntry _entry; private readonly Action, LogLevel> _output; private readonly object _sync = new object(); private readonly Dictionary _states = new Dictionary(StringComparer.OrdinalIgnoreCase); private Timer _timer; private int _busy; private int _tick; private volatile bool _disposed; private FileState _current; private string _lastProblem; public event Action FileChanged; public string CurrentFile => _current?.Path; public LogTailer(ServiceEntry entry, Action, LogLevel> output) { _entry = entry; _output = output; } public void Start() { _timer = new Timer(_ => Tick(), null, 0, PollMs); } /// 처음부터 다시 읽음 (인코딩·경로 변경 시) public void Reload() { lock (_sync) { _states.Clear(); _current = null; _lastProblem = null; } } private void Tick() { if (_disposed) return; if (Interlocked.Exchange(ref _busy, 1) == 1) return; try { lock (_sync) Poll(); } catch (Exception ex) { Problem("로그를 읽는 중 오류: " + ex.Message); } finally { Interlocked.Exchange(ref _busy, 0); } } private void Poll() { if (_current == null || (_entry.LogIsFolder && (_tick++ % FolderScanEvery == 0))) { var path = ResolvePath(); if (path == null) { _current = null; return; } if (_current == null || !string.Equals(path, _current.Path, StringComparison.OrdinalIgnoreCase)) { SwitchTo(path); return; } } ReadNew(_current); } private void SwitchTo(string path) { // 이전 파일에 남은 내용을 마저 읽고 넘어감 if (_current != null && File.Exists(_current.Path)) ReadNew(_current); _lastProblem = null; FileChanged?.Invoke(path); if (_states.TryGetValue(path, out var known)) { _current = known; Emit(new[] { $"──────── 파일 전환: {path} ────────" }, LogLevel.Notice); ReadNew(known); } else { var st = new FileState { Path = path }; _states[path] = st; _current = st; Emit(new[] { $"──────── 파일: {path} ────────" }, LogLevel.Notice); ReadInitial(st); } } private string ResolvePath() { var p = _entry.LogPath; if (string.IsNullOrWhiteSpace(p)) { Problem("로그 경로가 지정되지 않았습니다."); return null; } if (_entry.LogIsFolder) { if (!Directory.Exists(p)) { Problem("로그 폴더를 찾을 수 없습니다: " + p); return null; } var newest = new DirectoryInfo(p) .EnumerateFiles() .OrderByDescending(f => f.LastWriteTimeUtc) .FirstOrDefault(); if (newest == null) { Problem("로그 폴더에 파일이 없습니다: " + p); return null; } // 여러 파일에 동시에 기록되는 폴더에서 파일이 계속 바뀌지 않도록, // 지금 보는 파일이 잠시 멈춰 있을 때만 새 파일로 넘어감 if (_current != null && !string.Equals(newest.FullName, _current.Path, StringComparison.OrdinalIgnoreCase)) { var cur = new FileInfo(_current.Path); if (cur.Exists && DateTime.UtcNow - cur.LastWriteTimeUtc < SwitchIdle) return _current.Path; } return newest.FullName; } if (!File.Exists(p)) { Problem("로그 파일을 찾을 수 없습니다: " + p); return null; } return p; } private static FileStream OpenRead(string path) => new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); // ───────────────────────── 읽기 ───────────────────────── private void ReadInitial(FileState st) { using var fs = OpenRead(st.Path); PrepareEncoding(st, fs); long len = fs.Length; long start = Math.Max(st.BomLength, len - InitialBytes); if (IsUtf16(st.Encoding) && ((start - st.BomLength) % 2 == 1)) start++; bool fromMiddle = start > st.BomLength; var text = ReadRange(st, fs, start, len); var lines = SplitLines(text, out st.Partial); st.PartialSince = DateTime.UtcNow; // 중간부터 읽었으면 첫 줄은 잘렸을 수 있으므로 버림 if (fromMiddle && lines.Count > 0) lines.RemoveAt(0); if (lines.Count > InitialMaxLines) lines = lines.GetRange(lines.Count - InitialMaxLines, InitialMaxLines); Emit(lines, LogLevel.Info); } private void ReadNew(FileState st) { FileInfo fi; try { fi = new FileInfo(st.Path); fi.Refresh(); } catch { return; } if (!fi.Exists) { if (!_entry.LogIsFolder) Problem("로그 파일이 사라졌습니다: " + st.Path); _states.Remove(st.Path); if (ReferenceEquals(st, _current)) _current = null; return; } long len = fi.Length; if (len < st.Position) { Emit(new[] { "──────── 파일 크기가 줄어들어 처음부터 다시 읽습니다 ────────" }, LogLevel.Notice); st.Position = 0; st.Partial = ""; st.Encoding = null; st.Decoder = null; st.BomLength = 0; } if (len == st.Position) { FlushStalePartial(st); return; } using var fs = OpenRead(st.Path); if (st.Position == 0) PrepareEncoding(st, fs); long start = Math.Max(st.Position, st.BomLength); if (len - start > MaxReadPerTick) { Emit(new[] { "──────── 로그가 너무 빠르게 늘어나 일부를 건너뜁니다 ────────" }, LogLevel.Notice); start = len - MaxReadPerTick; if (IsUtf16(st.Encoding) && ((start - st.BomLength) % 2 == 1)) start++; st.Partial = ""; st.Decoder = st.Encoding?.GetDecoder(); } var text = st.Partial + ReadRange(st, fs, start, len); var lines = SplitLines(text, out var rest); if (rest != st.Partial) st.PartialSince = DateTime.UtcNow; st.Partial = rest; Emit(lines, LogLevel.Info); } /// [start, end) 구간을 읽어 문자열로. 읽은 만큼 Position 이동 private string ReadRange(FileState st, FileStream fs, long start, long end) { if (end <= start) { st.Position = Math.Max(st.Position, start); return ""; } fs.Seek(start, SeekOrigin.Begin); var buf = new byte[end - start]; int read = ReadFully(fs, buf); st.Position = start + read; if (read == 0) return ""; // 자동 인코딩: 한글 등 0x80 이상 바이트가 처음 나올 때 판단 if (st.Encoding == null && HasHighByte(buf, read)) { st.Encoding = LooksLikeUtf8(buf, read) ? new UTF8Encoding(false) : NonUtf8Encoding(buf, read); st.Decoder = st.Encoding.GetDecoder(); } if (st.Encoding == null) return Encoding.ASCII.GetString(buf, 0, read); // 아직 영문만 있음 st.Decoder ??= st.Encoding.GetDecoder(); var chars = new char[st.Decoder.GetCharCount(buf, 0, read)]; int n = st.Decoder.GetChars(buf, 0, read, chars, 0); return new string(chars, 0, n); } /// BOM 확인 및 인코딩 설정 private void PrepareEncoding(FileState st, FileStream fs) { st.BomLength = 0; st.Encoding = null; st.Decoder = null; var head = new byte[4]; fs.Seek(0, SeekOrigin.Begin); int n = ReadFully(fs, head); Encoding bomEnc = null; if (n >= 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF) { bomEnc = new UTF8Encoding(false); st.BomLength = 3; } else if (n >= 2 && head[0] == 0xFF && head[1] == 0xFE) { bomEnc = new UnicodeEncoding(false, false); st.BomLength = 2; } else if (n >= 2 && head[0] == 0xFE && head[1] == 0xFF) { bomEnc = new UnicodeEncoding(true, false); st.BomLength = 2; } var setting = (_entry.LogEncoding ?? "auto").ToLowerInvariant(); if (bomEnc != null) st.Encoding = bomEnc; // BOM이 있으면 항상 BOM을 따름 else if (setting == "utf-8") st.Encoding = new UTF8Encoding(false); else if (setting == "cp949") st.Encoding = Encoding.GetEncoding(949); // 자동 + BOM 없음 → 내용을 보고 판단 (ReadRange) st.Decoder = st.Encoding?.GetDecoder(); } /// 줄바꿈 없이 끝난 마지막 줄이 2초 넘게 그대로면 그대로 표시 private void FlushStalePartial(FileState st) { if (st.Partial.Length > 0 && (DateTime.UtcNow - st.PartialSince).TotalSeconds > 2) { Emit(new[] { st.Partial }, LogLevel.Info); st.Partial = ""; } } private static List SplitLines(string text, out string rest) { var parts = text.Split('\n'); rest = parts[^1]; var list = new List(parts.Length); for (int i = 0; i < parts.Length - 1; i++) list.Add(parts[i].TrimEnd('\r', '\0')); return list; } /// UTF-8이 아니면 CP949(한국어)로 올바르게 읽히는지 먼저 확인, 아니면 시스템 기본 코드페이지 private static Encoding NonUtf8Encoding(byte[] buf, int count) { try { var strict = Encoding.GetEncoding(949, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); // 끝에서 잘린 2바이트 문자는 판단에서 제외 int n = count; if (n > 0 && buf[n - 1] >= 0x80) n--; strict.GetCharCount(buf, 0, n); return Encoding.GetEncoding(949); } catch { } try { return Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.ANSICodePage); } catch { return Encoding.GetEncoding(949); } } private static bool HasHighByte(byte[] b, int count) { for (int i = 0; i < count; i++) if (b[i] >= 0x80) return true; return false; } private static bool LooksLikeUtf8(byte[] b, int count) { int i = 0; // 앞부분이 잘린 멀티바이트 문자는 건너뜀 while (i < count && (b[i] & 0xC0) == 0x80) i++; while (i < count) { byte c = b[i]; int need; if (c < 0x80) { i++; continue; } else if ((c & 0xE0) == 0xC0) need = 1; else if ((c & 0xF0) == 0xE0) need = 2; else if ((c & 0xF8) == 0xF0) need = 3; else return false; if (i + need >= count) return true; // 끝에서 잘린 문자는 판단 제외 for (int k = 1; k <= need; k++) if ((b[i + k] & 0xC0) != 0x80) return false; i += need + 1; } return true; } private static bool IsUtf16(Encoding e) => e != null && (e.CodePage == 1200 || e.CodePage == 1201); private static int ReadFully(Stream s, byte[] buf) { int total = 0; while (total < buf.Length) { int n = s.Read(buf, total, buf.Length - total); if (n <= 0) break; total += n; } return total; } private void Problem(string message) { if (message == _lastProblem) return; // 같은 메시지는 한 번만 _lastProblem = message; Emit(new[] { message }, LogLevel.Warning); } private void Emit(IList lines, LogLevel level) { if (lines.Count == 0 || _disposed) return; _output(lines, level); } public void Dispose() { _disposed = true; _timer?.Dispose(); _timer = null; } } }