Magia dos Triggers: Automatizando Tarefas com C# (Baseado em GitHub Actions)

A Ideia
Um dia desses eu estava criando um processo de deploy com Github Actions e me lembrei de um sistema de ITSM que trabalhei que permitia a execução de arquivos de scripts baseado em triggers do sistema.
E seguindo essa lógica de alteração em sistemas de arquivo que o Github Actions tem, comecei a pensar em algumas possibilidades:
- Automatizar backups em horários específicos.
- Realizar upload automáticos de arquivos em determinada pasta
- Limpeza Automática de Disco
- Etc.
Então, abri meu Visual Studio e comecei a criação desse um mini projeto: O JobExecutor (criativo, né?)
A ideia, na verdade é bem simples: teria um arquivo onde nós armazenariamos as triggers ligadas ao script a ser executado.
Por enquanto, como é apenas uma prova de conceito, decidi usar somente dois tipos de trigger:
- CronExpression: Que é uma forma de você informar periodicidades e é definido por uma string que tem esse formato:
* * * * *
- FileWatcher: Para que seja executado assim que algum arquivo ou pasta sejam alterados.
Para armazenar esses dados, escolhi o JSON e ficou nesse formato aqui:
1{ 2 "triggers": [ 3 { 4 5 "type": "FileWatcher", 6 "scriptFileName": "PATH\\TO\\FILE.ps1", 7 "watchedPath": "PATH\\TO\\WATCHED\\FILES" 8 }, 9 { 10 "type": "CronExpression", 11 "scriptFileName": "PATH\\TO\\FILE.ps1", 12 "CronExpression": "0 2 * * *" // Vai rodar todos os dias às 2hrs da manhã 13 } 14 ] 15}
Se você é atento, percebeu que nos dois scriptFileName
eu estou colocando arquivos ps1
que são arquivos de código Poweshell.
Isso é porque pretendo enviar parâmetros para que o usuário possa ter mais detalhes sobre a ação e tomar decisões sobre elas e os scrips de Powershell são completos e simples de se usar.
As estruturas de código
Para esse projeto estou usando C#, e como ele é fortemente tipado, encontrar estruturas diferentes dentro do mesmo array seria um problema para ele. E como não podemos também esperar que o usuário coloque todos os parâmetros que ele não vai precisar, não podemos simplesmente converter de JSON pra objeto diretamente.
Então, criei quatro classes:
A primeira tem a inteção de ser a classe "pai", contendo o que todas terão:
1public class Trigger 2{ 3 public string ScriptFileName { get; set; } 4 public string Type { get; set; } 5}
A CronJobTrigger é uma implementação da Trigger.cs e vai adicionar somente o CronExpression
:
1public class CronJobTrigger : Trigger 2{ 3 public string CronExpression { get; set; } 4}
A FileWatcherJobTrigger será também uma implementação da Trigger.cs, e vai ter o caminho que vai ser vigiado.
1public class FileWatcherJobTrigger : Trigger 2{ 3 public string WatchedPath { get; set; } 4}
E por fim, a que vai representar o JSON como um todo:
1public class TriggerConfig 2{ 3 public Trigger[] Triggers { get; set; } 4}
Convertendo os diferente tipos de Trigger
Como o FileWatcher e o CronExpression tem parâmetros diferentes, não posso simplemente convertê-los diretamente, já que o conversor vai usar só o tipo Trigger
e ignorar os outros campos.
Então precisei criar um conversor.
Estamos usando o conversor do Newtonsoft
1// TriggerConverter.cs 2using Newtonsoft.Json.Converters; 3using Newtonsoft.Json.Linq; 4using Newtonsoft.Json; 5 6namespace JobExecutor.Structs; 7 8public class TriggerConverter : CustomCreationConverter<Trigger> 9{ 10 public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) 11 { 12 var jsonObject = JObject.Load(reader); 13 Trigger trigger; 14 15 if (jsonObject["CronExpression"] != null) 16 { 17 trigger = new CronJobTrigger(); 18 } 19 else if (jsonObject["WatchedPath"] != null) 20 { 21 trigger = new FileWatcherJobTrigger(); 22 } 23 else 24 { 25 throw new JsonSerializationException("Unknown trigger type"); 26 } 27 28 serializer.Populate(jsonObject.CreateReader(), trigger); 29 return trigger; 30 } 31}
Aqui, quando o conversor for passar de item por item ele vai verificar se o campo CronExpression
está preenchido, e caso sim, vai definir o registro como do tipo CronJobTrigger
.
O mesmo se aplica para o FileWatcherJobTrigger
e o campo WatchedPath
.
Caso nenhum dos dois seja verdade, dispara uma Exception.
O Programa
Pra começar, precisamos capturar o arquivo de triggers e ler o conteúdo dele:
1class Program 2{ 3 private static List<Trigger> triggers; 4 private static string configPath = "PATH\\TO\\triggers.json"; 5 6 static void Main(string[] args) 7 { 8 LoadTriggers(); 9 Console.ReadLine(); 10 } 11 12 private static void LoadTriggers() 13 { 14 triggers = ReadTriggerConfig().Triggers.ToList(); 15 } 16 17 private static TriggerConfig ReadTriggerConfig() 18 { 19 20 var settings = new JsonSerializerSettings 21 { 22 // Adicionamos o nosso conversor aqui 23 Converters = new List<JsonConverter> { new TriggerConverter() } 24 }; 25 26 // Aqui, lemos o arquivo com um stream 27 using var stream = new StreamReader(configPath); 28 var json = stream.ReadToEnd(); 29 30 return JsonConvert.DeserializeObject<TriggerConfig>(json, settings); 31 } 32}
Com as triggers armazenadas na propriedade triggers
, podemos criar o método que vai processar as triggers e executá-las.
1private static void InitializeTriggers() 2{ 3 foreach (var trigger in triggers) 4 { 5 switch (trigger.Type) 6 { 7 case "CronExpression": 8 SetupCronJob(trigger as CronJobTrigger); 9 break; 10 case "FileWatcher": 11 SetupFileWatcher(trigger as FileWatcherJobTrigger); 12 break; 13 default: 14 throw new NotImplementedException($"Trigger type {trigger.Type} is not implemented."); 15 } 16 } 17}
Depois disso, vamos implementar o SetupCronJob
.
Criando o Setup do CronExpression
A expressão Cron é umna string que funciona assim:
Com 5 caracteres:
* * * * *
- - - - -
| | | | |
| | | | +----- Dia da Semana(0 - 6)
| | | +------- Mês (1 - 12)
| | +--------- Dia do Mês (1 - 31)
| +----------- Hora (0 - 23)
+------------- Minuto (0 - 59)
Com 6 Catacteres
* * * * * *
- - - - - -
| | | | | |
| | | | | +--- Dia da Semana (0 - 6)
| | | | +----- Mês (1 - 12)
| | | +------- Dia do Mês (1 - 31)
| | +--------- Hora (0 - 23)
| +----------- Minuto (0 - 59)
+------------- Segundo (0 - 59)
Com ele você pode dizer "qualquer valor" com um *
, usar uma ,
pra definir vários valores ou até um range de valores com um -
.
Exemplos:
1- "0 12 * * *" # Todos os dias às 12:00 2- "5 0 * 8 *" # Às 00:05, todos os dias de Agosto 3- "15 14 1 * *" # Todo dia 1º às 14:15 4- "*/5 * * * * *" # À cada 5 segundos
Sabendo disso, precisamos converter isso em um agendamento no nosso código. Pra isso, vamos usar a lib NCrontab,
Vamos usar o método Parse
do CrontabSchedule
e em seguida armazenar a próxima execução.
Vai ficar assim:
1var schedule = CrontabSchedule.Parse(trigger.CronExpression, new CrontabSchedule.ParseOptions() { IncludingSeconds = true }); 2var nextRun = schedule.GetNextOccurrence(DateTime.Now);
E depois, vamos criar um System.Threading.Timer
para agendar a execução do método que irá rodar o script e então reagendar o job novamente.
O método completo fica assim:
1private static void SetupCronJob(CronJobTrigger trigger) 2{ 3 var schedule = CrontabSchedule.Parse(trigger.CronExpression, new CrontabSchedule.ParseOptions() { IncludingSeconds = true }); 4 var nextRun = schedule.GetNextOccurrence(DateTime.Now); 5 var timer = new Timer( 6 (e) => { 7 // Executa o arquivo 8 ExecuteScript(trigger); 9 10 // Reagenda o job 11 SetupCronJob(trigger); 12 }, 13 null, 14 (long)(nextRun - DateTime.Now).TotalMilliseconds, 15 Timeout.Infinite); 16 }
Criando o Setup do FileWatcher
Para verificar as alterações no caminho indicado, nós vamos usar o FileSystemWatcher
.
1var watcher = new FileSystemWatcher 2{ 3 Path = trigger.WatchedPath, // Caminho do arquivo pego na trigger 4 Filter = "*", // Monitoraremos todos os arquivos 5 IncludeSubdirectories = true, // Incluiremos o monitoramento de subdiretórios 6 EnableRaisingEvents = true, // Permitiremos a execução de eventos 7}; 8 9// Define os eventos que devem ser notificados 10watcher.NotifyFilter = NotifyFilters.LastWrite 11 | NotifyFilters.FileName 12 | NotifyFilters.DirectoryName 13 | NotifyFilters.Attributes; 14
Agora, vamos criar o método que vai executar o script e adicioná-lo aos métodos watcher.Changed
, watcher.Created
, watcher.Deleted
e watcher.Renamed
do Watcher.
O Evento de edição pode (e vai) disparar mais de um evento
Changed
então, vamos criar um cache para isso.
1private static Dictionary<string, DateTime> scriptExecutionCache = new Dictionary<string, DateTime>(); 2 3 4private static void SetupFileWatcher(FileWatcherJobTrigger trigger) 5{ 6 var watcher = new FileSystemWatcher 7 { 8 Path = trigger.WatchedPath, 9 Filter = "*", 10 IncludeSubdirectories = true, // Incluiremos o monitoramento de subdiretórios 11 EnableRaisingEvents = true, // Permitiremos a execução de eventos 12 }; 13 14 watcher.NotifyFilter = NotifyFilters.LastWrite 15 | NotifyFilters.FileName 16 | NotifyFilters.DirectoryName 17 | NotifyFilters.Attributes; 18 19 void OnChange(object sender, FileSystemEventArgs e) 20 { 21 // Verifica se pode executar 22 if (ScriptCanBeRunned(e.FullPath)) 23 { 24 return; 25 } 26 27 // Executa o Script 28 ExecuteScript(trigger); 29 30 // Registra a ultima execução 31 CacheScriptExecution(e.FullPath); 32 } 33 34 watcher.Changed += OnChange; 35 watcher.Created += OnChange; 36 watcher.Deleted += OnChange; 37 watcher.Renamed += OnChange; 38} 39 40 41private static void CacheScriptExecution(string path) 42{ 43 scriptExecutionCache[path] = DateTime.Now; 44} 45 46private static bool ScriptCanBeRunned(string path) 47{ 48 if (!scriptExecutionCache.ContainsKey(path)) 49 { 50 return false; 51 } 52 53 var lastExecution = scriptExecutionCache[path]; 54 55 // Caso tenha executado há mais de 1 segundo, retorna `true` 56 return lastExecution.AddSeconds(1) > DateTime.Now; 57}
O método ExecuteScript
Ele, na verdade, é bem simples.
Só vai verificar a existência e extensão do arquivo e executálo usando o Process
.
1private static void ExecuteScript(Trigger trigger) 2{ 3 if (!File.Exists(trigger.ScriptFileName)) 4 { 5 Console.WriteLine($"File not found: {trigger.ScriptFileName}"); 6 return; 7 } 8 9 if (!trigger.ScriptFileName.EndsWith(".ps1")) 10 { 11 throw new NotImplementedException($"Script type {trigger.ScriptFileName} is not implemented."); 12 13 } 14 15 var startInfo = new ProcessStartInfo() 16 { 17 FileName = "powershell.exe", 18 Arguments = $"-ExecutionPolicy Bypass -File \"{trigger.ScriptFileName}\"" 19 }; 20 21 Process.Start(startInfo); 22}
Depois disso, você só precisará criar o seu script e criar uma trigger para ele.
Caso não saiba fazer scripts com Powershell, leia esse artigo: about_Scripts.
Nesse exemplo, vou criar uma trigger que vai rodar à cada 5 segundos e executar um script que mostra o horário atual.
A trigger
1{ 2 "Triggers": [ 3 { 4 "ScriptFileName": "PATH\\TO\\script.ps1", 5 "Type": "CronExpression", 6 "CronExpression": "*/5 * * * * *" 7 } 8 ] 9}
O script:
1Add-Type -AssemblyName System.Windows.Forms 2$now = Get-Date 3[System.Windows.Forms.MessageBox]::Show($now.ToString("HH:mm:ss"), "Current Time")
Resultado:
Melhorias
Uma coisa interessante que pode ser feita é: passar parâmetros para o script.
Ex: Quando um arquivo for adicionado em uma pasta em específica, enviamos para o script o nome do evento e o nome do arquivo.
Passando argumentos
Pra isso, vamos alterar o ExecuteScript
pra receber esses parâmetros:
1// Recebe os parâmetros como uma lista de strings 2private static void ExecuteScript(Trigger trigger, params string[] parameters) 3{ 4 if (!File.Exists(trigger.ScriptFileName)) 5 { 6 Console.WriteLine($"File not found: {trigger.ScriptFileName}"); 7 return; 8 } 9 10 if (!trigger.ScriptFileName.EndsWith(".ps1")) 11 { 12 throw new NotImplementedException($"Script type {trigger.ScriptFileName} is not implemented."); 13 } 14 15 var startInfo = new ProcessStartInfo() 16 { 17 FileName = "powershell.exe", 18 // Adiciona os parametros no final dos argumentos 19 Arguments = $"-ExecutionPolicy Bypass -File \"{trigger.ScriptFileName}\" {string.Join(' ', parameters)}", 20 }; 21 22 Process.Start(startInfo); 23}
OnChange:
Nele, nós passamos os parâmetros nomeados nesse formado : -{KEY} "{VALUE}"
cmo abaixo
1void OnChange(object sender, FileSystemEventArgs e) 2{ 3 Console.WriteLine($"File {e.Name} {e.ChangeType}"); 4 if (ScriptCanBeRunned(e.FullPath)) 5 { 6 return; 7 } 8 9 ExecuteScript( 10 trigger, 11 $"-EventType \"{e.ChangeType}\"", 12 $"-Name \"{e.Name}\"", 13 $"-FullPath \"{e.FullPath}\""); 14 15 CacheScriptExecution(e.FullPath); 16}
E no script, nós receberemos assim:
1param ( 2 [string]$EventType, 3 [string]$Name, 4 [string]$FullPath 5) 6 7Add-Type -AssemblyName System.Windows.Forms 8 9[System.Windows.Forms.MessageBox]::Show("EventType: $EventType`nName: $Name`nFullPath: $FullPath")
Resultado:
Conclusão
Ainda há muito que pode ser melhorado nesse projeto como:
- [x] Criar o arquivo
triggers.json
caso ele não exista. - [x] Adicionar um
FileSystemWatcher
no arquivotriggers.json
para atualizar as triggers. - [ ] Capturar o estado da máquina para poder passar em argumentos para os scripts (Ex: "MemoryUsage" ou "BatteryCharge")
- [ ] Implementar Argumentos para os Jobs de CronExpression
- [ ] Implementar novos tipos de trigger como baseadas em Eventos do Windows, Emails e etc.
- [ ] Transformar em serviço
- [ ] Adicionar sistema de logs
- [ ] Criar interface para adicionar triggers e scripts
E se você se sentir à vontade, pode me ajudar com a implementação. Ele já está lá no Github: