NGMsoftware

NGMsoftware
로그인 회원가입
  • 매뉴얼
  • 학습
  • 매뉴얼

    학습


    C# C# .NET 매크로 프로그램 만들기. (매크로 중지 1부)

    페이지 정보

    본문

    안녕하세요. 엔지엠소프트웨어입니다. 오늘은 매크로 프로그램을 실행했다가 중지하는 기능을 만들어 볼께요. 기존 엔지엠 매크로 6에서는 각각의 스크립트를 플레이할 때 Thread를 사용했었어요. 스레드를 사용하다보니 여러가지 문제점들이 있었고, 이런 문제들을 해결하기 위해 코드를 계속 수정하다보니 전체적으로 성능이 저하되는 결과를 초래했습니다. 성능을 개선하고, Abnormal Error들을 처리하다보니 코드가 많이 지저분해졌어요. 더 큰 문제는 너무 많은 부분을 개발했기 때문에 스레드 처리 부분을 개선하기가 불가능했습니다.

     

    새로운 매크로 에디터는 스레드가 아닌 타스크(Task)를 사용했습니다. 타스크도 스레드지만, 관리가 용이하고 안정성면에서 더 뛰어나기 때문에 설계 단계부터 여러가지 테스트를 거쳐서 기본적인 구조를 잡아두었습니다. 스크립트를 플레이하는 내용을 보면, 기존과 많은 부분이 달라진것을 알 수 있습니다. 지금은 더 많이 개선되었지만, 전체적인 내용은 다음에 알아보기로 하고 일단은 실행중인 스크립트를 어떻게 중지할 지 알아보겠습니다.

     

    아래와 같이 모델 프로젝트에 스크립트 폴더를 만들고, 실행(Play), 중지(Stop), 일시 중지(Pause) 모델을 추가했습니다.

    smBYA8L.jpeg

     

     

    모델은 일단 실행만 구현되어 있고, 실행중인 스크립트를 중지하는건 스크립트 편집기의 버튼을 사용했어요. 일단, 스크립트 실행을 먼저 구현한 이유는 매인 스크립트안에 서브 스크립트가 타스크로 실행되고, 매인 스크립트와 매인 스크립트가 가진 서브 스크립트까지 정상적으로 종료되는지 테스트하기 위함입니다. 스크립트 실행, 중지, 일시 중지는 기본적으로 스크립트 이름을 선택하거나 실제 스크립트 파일을 선택할 수 있어야 합니다.

    using Ai.Interface;
    using System.ComponentModel;
    
    namespace Ai.Model.Action.Script
    {
        [Serializable]
        public class BaseModel : ActionModel
        {
            [LocalizedCategory("Action")]
            [LocalizedDisplayName("ScriptName")]
            [LocalizedDescription("ScriptName")]
            [Browsable(true)]
            [DefaultValue(null)]
            [TypeConverter(typeof(TypeConverter.ScriptConverter))]
            public string? ScriptName
            {
                get { return Path.GetFileName(SelectScriptFile); }
                set { SelectScriptFile = value?.Split(Ai.Definition.SystemSpliter).LastOrDefault(); }
            }
    
            [LocalizedCategory("Action")]
            [LocalizedDisplayName("ScriptFile")]
            [LocalizedDescription("ScriptFile")]
            [Browsable(true)]
            [DefaultValue(null)]
            [Editor(typeof(TypeEditor.OpenFileSelectorEditor), typeof(System.Drawing.Design.UITypeEditor))]
            public string? SelectScriptFile { get; set; }
    
            public override string? Excute(IPlayer player)
            {
                return null;
            }
        }
    }

     

    스크립트 이름을 선택할 수 있도록 하려면, 기본 스크립트 폴더안에 모든 파일을 가져와서 목록으로 표시해야 합니다. 이 기능을 GUI 형태로 구현하려면 PropertyGrid 컨트롤의 TypeConverter를 만들어야 합니다. 기본 구조는 아래와 같습니다. 대부분의 TypeConverter가 동일한 구조를 가져서 하나로 만들어도 될텐데요. 이 부분은 좀 더 분석해보고, 리펙토링 시점에 하나로 합칠지 고민해봐야겠습니다.

    using System.ComponentModel;
    
    namespace Ai.Model.TypeConverter
    {
        public class ScriptConverter : System.ComponentModel.TypeConverter
        {
            private List<string>? _items;
            private string? _filterKey = string.Empty;
    
            public override bool GetStandardValuesSupported(ITypeDescriptorContext? context)
            {
                return true;
            }
    
            public override StandardValuesCollection? GetStandardValues(ITypeDescriptorContext? context)
            {
                _filterKey = context?.PropertyDescriptor?.GetValue(context.Instance)?.ToString();
                SetData(context);
                return new StandardValuesCollection(_items);
            }
    
            public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
            {
                if (sourceType == typeof(string))
                    return true;
    
                return base.CanConvertFrom(context, sourceType);
            }
    
            public override object? ConvertFrom(ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object value)
            {
                if (value is string)
                {
                    if (value != null)
                        return value.ToString();
    
                    return value;
                }
                return null;
            }
    
            private void SetData(ITypeDescriptorContext? context)
            {
                _items = new Data.Factory().GetScripts(context, _filterKey);
            }
        }
    }

     

    핵심은 _items 맴버 변수에 내용을 채워넣어야 합니다. 이 부분은 코드의 마지막 SetData 메소드에서 구현합니다. 그리고, TypeConverter의 GetStandardValues 메소드를 오버라이드해서 목록을 내보내야 합니다. 위 코드가 기본 구조는 잡혀 있는 상태고, GetScripts 메소드만 구현하면 됩니다. 전체 내용은 아래와 같습니다.

            internal List<string>? GetScripts(ITypeDescriptorContext context, string? filterKey)
            {
                var form = System.Windows.Forms.Application.OpenForms.Cast<System.Windows.Forms.Form>().First() as Ai.Interface.IClient;
    
                if (form != null)
                {
                    string? scriptDirectory = (form.Option as Ai.Model.Client.OptionModel)?.ScriptDirectory ?? Path.Combine(System.Windows.Forms.Application.StartupPath, Ai.Definition.ScriptDirectory);
    
                    if (scriptDirectory.EndsWith(@"\"))
                        scriptDirectory = scriptDirectory[0..^1];
    
                    if (!string.IsNullOrEmpty(scriptDirectory))
                    {
                        var items = Directory.GetFiles(scriptDirectory, string.IsNullOrEmpty(filterKey) ? "*" : filterKey + Ai.Definition.ScriptExtension, SearchOption.AllDirectories)
                            .Select(s => $"{s.Replace(scriptDirectory, string.Empty)[1..]}{Ai.Definition.SystemSpliter}{s}").ToList();
    
                        items.Sort();
                        return items;
                    }
                }
    
                return null;
            }

     

    스크립트도 파일이기 때문에 옵션의 기본 폴더에서 모든 파일을 가져오면 됩니다. 테스트용 스크립트는 아래와 같이 제작했습니다.

    8p4PkuR.jpeg

     

     

    실행 액션으로 실행할 스크립트는 아래와 같은데요. 단순 클릭 4개가 윈도우 바탕화면에 아이콘을 순차적으로 클릭합니다.

    j73xV3A.jpeg

     

     

    다시 매인 스크립트로 돌아와서 중지 버튼을 클릭하기 위해 핸들을 추가했습니다. 이 핸들은 윈도우 바탕화면입니다.

    b7q7qjT.jpeg

     

     

    비활성으로 동작하게 해야하기 때문에 장치 입력 방법을 PostMessage로 변경했습니다.

    SEYN2rC.jpeg

     

     

    이제 매크로를 실행하고, 중지를 눌러볼텐데요. 중지를 눌렀을 때 처리할 내용을 코딩하지는 않아서 중지가 되지 않습니다. 스레드 기반으로 만들어진 NGM 매크로 6은 Thread를 중지시키기 위해 Abort 메소드를 사용했습니다. Abort는 스레드를 강제로 종료시키기 때문에 여러가지 문제가 있었는데요. 이 문제를 해결하기 위해서 Task와 CancellationTokenSource를 사용하겠습니다.

     

    코딩하기에 앞서 몇가지 고려해야 할 사항이 있습니다. 매인 스크립트 안에 스레드로 새 스크립트가 동작한다고 생각 해보세요. 이 때 매인 스크립트가 완료되면 서브 스크립트도 같이 완료되거나 중지되어야 합니다. 시나리오상 매인 스크립트가 종료되기 전 서브 스크립트가 완료되면 문제가 되지 않습니다. 하지만, 서브가 동작중에 매인이 완료되면 서브 스크립트가 완료될 때까지 계속 동작하는 문제가 있습니다. 이런 경우 서브를 제어할 수 있는 매인 스크립트가 이미 완료되었기 때문에 중지가 불가능합니다. 그래서, 실행 액션으로 스크립트를 동작시키면 매인 스크립트에 종속되도록 처리할 필요가 있습니다.

     

    매인에서 비동기로 여러개의 서브 스크립트를 동작시킬 때 이들을 관리할 매니저가 필요합니다. 이 역할을 부모 플레이어가 수행합니다. 따라서, 아래와 같이 부모 플레이어 속성과 자식 플레이어 목록을 모두 만들어줘야 합니다.

            public Ai.Interface.IPlayer? Parent { get; }
    
            public bool HasParent { get { return Parent != null; } }
    
            public List<IPlayer>? Children { get; set; }

     

    이제 타스크로 동작중인 서브 스크립트들을 중지시키기 위해 CancellationTokenSource를 이용해봅시다. 이 클래스는 단순하게 취소 요청이 왔는지를 체크할 수 있는 속성이 하나 있습니다. CancellationTokenSource은 최상위 플레이어에 한번만 인스턴스화 하고, 모든 서브 스크립트들은 이 인스턴스의 메모리 주소를 복사해서 가지고 있어야 합니다. 중복해서 처리할 필요가 없기 때문에 이런 디자인을 유지하는게 좋습니다.

     

    플레이어의 생성자에서 CancellationTokenSource를 인스턴스화 시킵니다. 그리고, CancellationTokenSource의 토큰을 맴버 변수에 추가합니다. 참고로, 주의할 점이 있습니다. 플레이어를 디자인할 때 항상 루트 플레이어로 생성되도록 강제해야 합니다. 만약, 서브 스크립트도 이 생성자로 만들게되면 CancellationTokenSource 인스턴스가 복사되는게 아니라 새로 만들어져서 작업을 취소할 수 없게됩니다. 이렇게 디자인을 유지해야 부모가 하위 스크립트들을 모두 취소시킬 수 있습니다.

            public Player(Manager manager, IScriptView script)
            {
                Parent = null;
                Manager = manager;
                Script = script;
                CTS = new CancellationTokenSource();
                CT = CTS.Token;
            }

     

    아래는 프라이빗 한정자를 가진 생성자입니다. 내부에서 서브 스크립트를 복사하기 위한 생성자입니다. 프라이빗 한정자라서 외부에서는 생성할 수 없는 제약이 생깁니다.

            private Player(Ai.Interface.IPlayer player, Ai.Interface.IScriptView? script = null)
            {
                Parent = player;
                this.Manager = player.Manager;
                this.Script = script == null ? player.Script : script;
                this.MainHandles = player.MainHandles;
                this.ControlHandles = player.ControlHandles;
                this.DeviceInputType = player.DeviceInputType;
                this.MultiHandleDelay = player.MultiHandleDelay;
                this.CTS = player.CTS;
                this.CT = player.CT;
    
                if (Parent != null)
                {
                    if (Parent.Children == null)
                        Parent.Children = new List<IPlayer>();
    
                    Parent.Children.Add(this);
                }
            }

     

    이제 스크립트를 실행하는 Run 메소드에서 취소 요청이 있는지 체크하고, 취소 요청이 있으면 반복 루틴을 빠져나가도록 코드를 추가해줍니다. 스크립트에 추가한 모든 액션을 하나씩 실행 해줍니다. 반복기에서 아래와 같이 캔슬레이션 토큰에 요청이 있는지 검사합니다.

                _taskList.Add(scriptId, Task.Run(() =>
                {
                    if (CT.IsCancellationRequested) return;

     

    액션의 반복 루틴에서도 동일하게 추가 해줍니다.

                    for (int i = 0; i < actions.Count(); i++)
                    {
                        if (CT.IsCancellationRequested) return;

     

    CancelationTokenSource는 Task가 마지막에 인자로 받습니다. 대부분의 타스크가 취소 요청을 처리할 수 있기 때문입니다. 그런데, 타스크 내부에서 취소 요청을 검사하고, 반복 루틴을 탈출하는데 왜 굳이 타스크의 인자로 사용되지도 않는 토큰을 넘기고 있을까요? 그리고, 타스크에 CancelationToken을 넘기지 않는것과 어떤 차이점이 있을까요? 아마도 이 부분이 궁금하신 분들이 많을겁니다.

    _taskList.Add(scriptId, Task.Run(() =>
    {
        // foreach action execute!
    }, CT);

     

    타스크도 결국엔 스레드인데요. 스레드풀에 스레드를 요청해서 하나를 할당 받습니다. 그런데, 가용할 스레드가 없으면 할당 될때까지 대기하게 됩니다. 이 때 타스크에 CancelationToken을 넘겨주면 스레드 요청 대기 상태에서도 취소가 가능해집니다. 만약, 이런 시나리오가 발생하지 않는다면 굳이 타스크에 넘길 필요는 없을겁니다. 참고로, 엔지엠 매크로에는 지연이라는 대기 상태가 있습니다. 지연도 타스크를 사용하기 때문에 지연 상태도 취소하려면 아래와 같이 처리할 수 있습니다.

    currentTask.Wait(Ai.Common.Helper.Delay(action.RandomMinDelay, action.BeforeDelay, action.UseRandomDelay), CT);

     

    이제 매크로를 실행해볼까요? 아래 동영상처럼 매크로를 실행하면 실행 액션에서 윈도우 바탕화면 아이콘을 1초에 하나씩 순차적으로 클릭합니다. 또다시 매크로를 실행하고 중지를 클릭 해보세요. 순차적으로 마우스 클릭이 동작하다가 멈추는것을 확인할 수 있습니다.

     

     

    오늘은 좀 복잡한 내용이었는데요. 엔지엠 6 매크로보다 안정적으로 운영하기 위해서 많은 부분들이 변경되었습니다. 실행중인 스레드를 강제로 종료하는건 위험 부담이 큽니다. 그리고, 스레드 처리 표준에서도 Abort는 사용하지 말라고 되어 있더라고요. 스레드를 안전하게 처리하려면 작업이 완료된 후 취소 요청을 보내고, 취소된 후 다음 처리를 하도록 가이드하고 있습니다. 그래서, 차세대 엔지엠 매크로는 타스크와 취소 요청으로 비동기 처리를 변경했습니다. 앞으로 더 많은 부분들이 바뀔수도 있는데요. 이번에는 Abort는 배제하고 만들어야 할거 같아요.

     

    시나리오상 서브 스크립트는 무조건 부모 스크립트에 종속되고, 개별 조작이 불가능합니다. 매인 스크립트가 완료될 때 실행중인 서브 스크립트도 모두 종료되어야 합니다. 만약, 동시에 실행되는 스크립트를 개별 조작하려면 각각 스크립트를 만들어서 실행해야 합니다.

     

    개발자에게 후원하기

    MGtdv7r.png

     

    추천, 구독, 홍보 꼭~ 부탁드립니다.

    여러분의 후원이 빠른 귀농을 가능하게 해줍니다~ 답답한 도시를 벗어나 귀농하고 싶은 개발자~

    감사합니다~

    • 네이버 공유하기
    • 페이스북 공유하기
    • 트위터 공유하기
    • 카카오스토리 공유하기
    추천0 비추천0

    댓글목록

    등록된 댓글이 없습니다.