/*
Copyright (c) 2025 WuJingrun(吴京润)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package f_log.output
import std.convert.Parsable
import std.collection.concurrent.ArrayBlockingQueue
import std.env
import std.fs.{File, Directory, OpenMode, exists, rename, removeIfExists}
import std.io.{SeekPosition, OutputStream}
import std.sync.{Timer, Mutex, AtomicOptionReference}
import std.time.DateTime
import stdx.compress.zlib.{WrapType, CompressLevel, CompressOutputStream}
import f_base.*
import f_io.*// 必须导入这个包mmap才生效
import f_time.TimeUnit
internal import f_log.exception.LogException
public class RotatableFileParams <: Hashable & Equatable<RotatableFileParams> {
public var path = "${env.getWorkingDirectory()}/logs/${env.getCommand()}.log"
public var fileSize = Int64.Max
public var timeunit = TimeUnit.DAY
public var compress = LogFileCompressFormat.NonCompression
public operator func ==(that: RotatableFileParams): Bool {
this.path == that.path && this.fileSize == that.fileSize &&
this.timeunit.toString() == that.timeunit.toString() && this.compress == that.compress
}
private var h = 0
public func hashCode(): Int64 {
if (h == 0){
h = HashBuilder().append(path).append(fileSize).append(timeunit.toString()).append(compress).build()
}
h
}
}
protected class RotatableFile <: OutputStream & Resource {
private let queue = ArrayBlockingQueue<() -> Unit>(1)
private var rotateTimer = ZERO_TIMER
private let fileSize: Int64
private let timeunit: TimeUnit
private let compress: LogFileCompressFormat
private let directory: String
private let file = AtomicOptionReference<File>()
public init(url: String, params: RotatableFileParams) {
let fileInfo = url["file://".size..]
var idx = fileInfo.indexOf("?") ?? fileInfo.size
let path = fileInfo[0..idx]
let (directory, file) = createDirIfNeed(path)
this.directory = directory
this.file.store(file)
if (idx < fileInfo.size) {
idx = fileInfo.indexOf("rotateSize=") ?? -1
if (idx > 0) {
let end = fileInfo.indexOf("&", idx) ?? fileInfo.size
fileSize = Int64.parse(fileInfo[idx + "rotateSize=".size..end])
} else {
fileSize = params.fileSize
}
idx = fileInfo.indexOf("rotateDuration=") ?? -1
if (idx > 0) {
let end = fileInfo.indexOf("&", idx) ?? fileInfo.size
let duration = fileInfo[idx + "rotateDuration=".size..end]
timeunit = TimeUnit.tryParse(duration).getOrThrow {LogException("rotateDuration=${duration}")}
} else {
timeunit = params.timeunit
}
idx = fileInfo.indexOf("compressFormat=") ?? fileInfo.size
if (idx > 0) {
let end = fileInfo.indexOf("&", idx) ?? fileInfo.size
let format = fileInfo[idx + "compressFormat=".size..end]
compress = compressFormat(format)
} else {
compress = params.compress
}
} else {
fileSize = params.fileSize
timeunit = params.timeunit
compress = params.compress
}
rotateTimer = createRotateTimer()
}
public init(params: RotatableFileParams) {
this.timeunit = params.timeunit
this.fileSize = params.fileSize
this.compress = params.compress
let (directory, file) = createDirIfNeed(params.path)
this.directory = directory
this.file.store(file)
rotateTimer = createRotateTimer()
}
public func isClosed(): Bool {
file.load()?.isClosed() ?? true
}
@When[os != "Linux"]
public func close(): Unit {
file.load()?.close()
}
@When[os == "Linux"]
public func close(): Unit {
if(let Some(mm) <- this.mmapf){
mm.setLength()
mm.close()
}else{
file.load()?.close()
}
}
@When[os == "Linux"]
private var mmapf = None<RotatableMMapFile>
@When[os == "Linux"]
protected func mmap() {
let file = this.file.load().getOrThrow()
let len = file.length
let mm = file.mmap(0, fileSize)
mm.writeOffset = len
let rmmf = RotatableMMapFile(this, mm)
this.mmapf = rmmf
rmmf
}
protected prop path: String {
get() {
(this.file.load()?.info.path.toString()).getOrThrow()
}
}
private func createRotateTimer() {
spawn {
while (let fn <- queue.remove()) {
fn()
}
}
let timer = Timer.after(timeunit.since(datetime: timeunit.trim(DateTime.now()))) {
rotateFileIfNeed(0)
this.timeunit.since(datetime: timeunit.trim(DateTime.now()))
}
env.atExit {timer.cancel()}
timer
}
private static func createDirIfNeed(path: String) {
let directory = path[0..(path.lastIndexOf("/") ?? path.size)]
if (!exists(directory)) {
Directory.create(directory, recursive: true)
}
(directory, File(path, Append))
}
public static func compressFormat(format: String): LogFileCompressFormat {
match (format) {
case "None" => LogFileCompressFormat.NonCompression
case "Deflate(BestSpeed)" | "DeflateFormat(BestSpeed)" => LogFileCompressFormat.Deflate(BestSpeed)
case "Deflate(DefaultCompression)" | "Deflate(Default)" | "Deflate" | "DeflateFormat"
| "DeflateFormat(DefaultCompression)" | "DeflateFormat(Default)" => LogFileCompressFormat.Deflate(
DefaultCompression)
case "Deflate(BestCompression)" | "DeflateFormat(BestCompression)" => LogFileCompressFormat.Deflate(
BestCompression)
case "GZip(BestSpeed)" | "GZipFormat(BestSpeed)" => LogFileCompressFormat.GZip(BestSpeed)
case "GZip(DefaultCompression)" | "GZip(Default)" | "GZip" | "GZipFormat" | "GZipFormat(DefaultCompression)"
| "GZipFormat(Default)" => LogFileCompressFormat.GZip(DefaultCompression)
case "GZip(BestCompression)" | "GZipFormat(BestCompression)" => LogFileCompressFormat.GZip(BestCompression)
case _ => throw IllegalArgumentException(
"unsupported compress config ${format}. " +
" The supported compressing configs are None, Deflate(BestSpeed), DeflateFormat(BestSpeed)," +
" Deflate(DefaultCompression), DeflateFormat(Default)," +
" DeflateFormat(DefaultCompression), DeflateFormat(Default), Deflate, DeflateFormat," +
" Deflate(BestCompression), DeflateFormat(BestCompression)," +
" GZip(BestSpeed), GZipFormat(BestSpeed)," +
" GZip(DefaultCompression), GZipFormat(Default), GZip, GZipFormat," +
" GZipFormat(DefaultCompression), GZipFormat(Default)" +
" GZip(BestCompression), GZipFormat(BestCompression).")
}
}
public static func computeBytes(bytes: String): Int64 {
let b = bytes.trimAscii()
var unit = b'b'
var ne = bytes.size
var bu = bytes[ne - 1]
if (bu == b'b' || bu == b'B') {
ne--
bu = bytes[ne - 1]
if ((bu >= b'a' && bu <= b'z') || (bu >= b'A' && bu <= b'Z')) {
ne--
unit = bu
}
} else if ((bu >= b'a' && bu <= b'z') || (bu >= b'A' && bu <= b'Z')) {
ne--
unit = bu
}
Int64.tryParse(bytes[0..ne]).getOrThrow {
throw IllegalArgumentException(
"${b} is illegal bytes description, bytes description supports an integer number follows with r'k' 'kbr' 'mr' 'mbr' 'gr' 'gbr' 'tr' 'tbr' 'er' 'eb' or their upper cases")
} * 1024 ** match (unit) {
case b'b' | b'B' => 0
case b'k' | b'K' => 1
case b'm' | b'M' => 2
case b'g' | b'G' => 3
case b't' | b'T' => 4
case b'p' | b'P' => 5
case b'e' | b'E' => 6
case b'z' | b'Z' | b'y' | b'Y' => throw IllegalArgumentException("${b} is overflow for Int64")
case _ => throw IllegalArgumentException("${unit} is illegal byte unit")
}
}
private static func genTime(timeunit: TimeUnit, current: DateTime): String {
match (timeunit) {
case DAY => current.addDays(-1).format("yyyyMMdd")
case HOUR => current.addHours(-1).format("yyyyMMddHH")
case WEEK => current.addWeeks(-1).format("yyyyMMddWW")
case MINUTE => current.addMinutes(-1).format("yyyyMMddHHmm")
case MONTH => current.addMonths(-1).format("yyyyMM")
case YEAR => current.addYears(-1).format("yyyy")
case SECOND => current.addSeconds(-1).format("yyyyMMddHHMMss")
case _ => current.addNanoseconds(-1).format("yyyyMMddHHmmssSSS")
}
}
private let fileLock = Mutex()
func rotateFileIfNeed(appended: Int64) {
synchronized(fileLock) { //此处不能使用双重检查锁,否则可能导致一个线程试图向另一个线程已经重命名的文件导致写失败
let current = timeunit.trim(DateTime.now())
let (need, file) = needRotate(current, appended)
if (need) {
let originPath = file.info.path.toString()
try {
let time = genTime(timeunit, current)
let path = "${originPath}.${time}"
try {
this.close()
} catch (e: Exception) {
e.printStackTrace()
}
try {
rename(originPath, to: path, overwrite: false)
} catch (e: Exception) {
e.printStackTrace()
}
compressAndDeleteOld(path)
} catch (e: Exception) {
e.printStackTrace()
}
if (!exists(originPath)) {
this.file.store(File(originPath, Append))
@When[os == "Linux"]
let _ = if(let Some(mm) <- this.mmapf){
mm.mmap = this.file.load().getOrThrow().mmap(0, this.fileSize)
mm.mmap.writeOffset = 0
}
}
}
}
}
private func needRotate(current: DateTime, appended: Int64) {
let file = this.file.load().getOrThrow()
let fileInfo = file.info
(fileInfo.creationTime < current || fileInfo.size + appended >= fileSize, file)
}
private func closeFile(file: ?File): Unit {
if (let Some(f) <- file) {
if (f.isClosed()) {
return
}
try {
f.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private func doCompress(path: String, ext: String, wrap: WrapType, level: CompressLevel) {
var f = None<File>
var compressedFile = None<File>
try {
f = File(path, OpenMode.Read)
let fsize = f?.length ?? 0
if (fsize == 0) {
return
}
compressedFile = File("${path}${ext}", OpenMode.Write)
let compressedOutput = CompressOutputStream(
compressedFile.getOrThrow(),
wrap: wrap,
compressLevel: level
)
try {
f?.seek(Begin(0))
var buf = Array<Byte>(if (fsize >= 1024 ** 2) {
1024 ** 2
} else {
fsize
}, repeat: 0)
var readLen = 0
do {
readLen = f?.read(buf) ?? 0
if (readLen > 0) {
compressedOutput.write(buf[0..readLen])
}
} while (readLen == buf.size)
} catch (e: Exception) {
e.printStackTrace()
} finally {
try {
compressedOutput.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
closeFile(f)
closeFile(compressedFile)
removeIfExists(path)
}
}
private func compressAndDeleteOld(path: String) {
queue.add(
{
=> match (compress) {
case Deflate(level) => doCompress(path, ".lz", DeflateFormat, level)
case GZip(level) => doCompress(path, ".gz", GzipFormat, level)
case NonCompression => return
}
})
}
public func write(buffer: Array<Byte>): Unit {
rotateFileIfNeed(buffer.size)
file.load()?.write(buffer)
}
public func flush(): Unit {
file.load()?.flush()
}
}
public enum LogFileCompressFormat <: ToString & Hashable & Equatable<LogFileCompressFormat> {
| NonCompression
| Deflate(CompressLevel)
| GZip(CompressLevel)
private func eq(x: CompressLevel, y: CompressLevel): Bool {
match((x, y)){
case (BestCompression, BestCompression) => true
case (BestSpeed, BestSpeed) => true
case (DefaultCompression, DefaultCompression) => true
case _ => false
}
}
public operator func ==(that: LogFileCompressFormat): Bool {
match((this, that)){
case (NonCompression, NonCompression) => true
case (Deflate(x), Deflate(y)) => eq(x, y)
case (GZip(x), GZip(y)) => eq(x, y)
case _ => false
}
}
private func compressLevelString(level: CompressLevel){
match(level){
case BestCompression => "BestCompression"
case BestSpeed => "BestSpeed"
case DefaultCompression => "DefaultCompression"
}
}
public func toString(): String {
match(this){
case NonCompression => "NonCompression"
case Deflate(x) => 'Deflate(${compressLevelString(x)})'
case GZip(x) => 'GZip(${compressLevelString(x)})'
}
}
public func hashCode(): Int64 {
toString().hashCode()
}
}