Monday, July 21, 2014

WPF globalization language, using singleton class indexer property (WPF 다국어 작업, 동적으로 해보자)

지난 포스트에 이어...

쉽게 하는 방법

지난 포스트는 쉽게 하는 방법에 대해 쓴 것이다.
단!
요구사항이 프로그램 실행중에 언어를 변경해도 런타임시에 변경되지 않아야 한다는 조건에서다.
왜냐하면 CurrentUICulture는 처음 실행될 때 세팅해 줄 수 있고, 그 이후에는 그 언어에 맞는 리소스 dll 파일을 가져와 바인딩 하기 때문에 런타임시에 변경이 불가능하기 때문이다.

그리고 이러한 요구사항은 아래와 같은 이유로 종종 타협이 되서 런타임시 언어 변경 기능은 빼는 경우가 있다.

1. 규모가 큰 프로그램의 경우 리소스를 사용하는 갯수가 수백 수천개이기 때문에, 일일이 리소스 별로 변경되는 걸 Notify 해 주기가 어렵다. (즉, 바인딩 된 언어 리소스의 Notify가 쉽지 않기 때문에) 라고 실드치는 경우. 이유는 명백하다, 처음부터 다국어를 고려하지 않고 만들었기 때문이다.

2. 크롬이나 기타 윈도우 프로그램 (심지어 윈도우 OS)의 경우도, 언어 변경 후 프로그램을 재 시작하라는 메시지와 함께 재시작 하는걸 당연하게 생각하는 사용자가 있기에 우리 프로그램도 런타임 시에 언어 변경이 고객의 필수(?) 요구사항이 아닌 이상 굳이 그렇게 까지 해야 하나? 라고 반문하는 경우.

그래서 첫 포스팅의 경우 처럼 개발자도 작업하기 쉽고, 언어 변경 후 프로그램 다시 재시작 하는게 크게 어려운 일이 아니기에 쉬운 방법으로 많이들 사용한다.

그럼에도 불구하고!
런타임시에 언어 변경이 아주 불가능한건 아니기 때문에 이번에 한번 소개해 보고자 한다.
이건 c#의 indexer를 PropertyChanged 이벤트를 줄 수 있기 때문에 착안한 것이며, 이걸 Singleton 패턴으로 instance를 관리하여 작성하였다.

프로젝트 환경은 다음과 같다.
OS: Windows 8.1
Framework: .NET Framework 4.5
Development Tool: Visual Studio Ultimate 2013
Language: C#
Project: WPF Class LIbrary


* Source description

1. Single instance (Singleton)
한 언어별 리소스들을 일괄로 관리하기 위해서 Singleton 패턴을 사용하여 하나의 인스턴스로 관리한다. 나중에 일괄로 PropertyChanged할 때 용이하다.


private static volatile LanguageResources instance;
private static object syncRoot = new Object();


public static LanguageResources Instance
{
    get
    {
        if (instance == null)
        {
            lock (syncRoot)
            {
                if (instance == null)
                {
                    instance = new LanguageResources();
                }
            }
        }
        return instance;
    }
}


2. Managed dictionary collection
내부적으로 key-value로 관리할 dictionary 클래스를 사용한다. 리소스 자체가 key-value pair이기 때문에 이만한 collection이 없다. 그리고 dictionary가 변경될 때 마다 PropertyChanged를 걸어준다. "item[]"(Binding.Indexer)을 PropertyName으로 주면 indexer에 바인딩이 걸린 모든 Binding path에 notify가 간다. 5번에서도 설명될 것이지만 각 ViewModel의 PropertyChagned도 여기서 모두 걸어준다.


private Dictionary<stringstring> _resourceDictionary;
public Dictionary<stringstring> ResourceDictionary
{
    set
    {
        _resourceDictionary = value;
        if (_resourceDictionary != null && PropertyChanged != null)
        {
            // Set property name "Binding.IndexerName" for PropertyChanged event
            PropertyChanged(thisnew PropertyChangedEventArgs("Item[]"));
            // call PropertyChanged in registered viewmodels implement INotifyPropertyChanged interface
            foreach (var item in NotifyPropertyChangedDictoionary)
            {
                if (item.Key != null && item.Value != null)
                {
                    foreach (string propertyname in item.Value)
                    {
                        PropertyChanged(item.Key, new PropertyChangedEventArgs(propertyname));
                    }
                }
            }
        }
    }
    get
    {
        return _resourceDictionary;
    }
}

3. Using indexer
인덱서의 경우는 c# 기본적인 것이니 자세한 설명은 생략하고, Singleton 객체에 indexer property를 노출시킨다. 직관적으로 key값을 주면 내부 dictionary에서 검색하여 value를 리턴하는 식으로 만든다. code behind에서 동적으로 세팅해 주는게 편할 수도 있지만, xaml 코드에서 정적으로 binding 하는게 더 강력하다. 왜냐하면 이 indexer에 PropertyChanged가 걸리기 때문이다.


public string this[string key]
{
    get
    {
        string value = key == null ? "" : key;
        if (ResourceDictionary != null && ResourceDictionary.ContainsKey(key) == true)
        {
            value = ResourceDictionary[key];
        }
        else
        {
            value = string.Format(Settings.RESOURCE_NOT_FOUND_MESSAGE, key);
        }
        return value;
    }
}

4. Load resource file (DataContractJsonSerializer)
JSON 포맷으로 되어있는 텍스트 파일을 읽어와서, 최종적으로 dictionary의 key, value collection 형태로 만든다. 단, Serialize 가능하게 만드는 것이 file read, write 할때도 직관적이면서 쉽다. 예제의 경우에는 JSON 포맷으로 텍스트 파일을 만들었으며, DataContractJsonSerializer 클래스를 사용하여 serialize 했다.


private void LoadResource()
{
    string fileStream = "";
    try
    {
        string filepath = string.Format(Settings.LANGUAGE_FILE_PATH, CultureName);
        if (DesignerProperties.GetIsInDesignMode(new DependencyObject()) == false)
        {
            fileStream = File.ReadAllText(filepath);
        }
        else
        {
            fileStream = File.ReadAllText(Settings.TARGET_PROJECT_NAME + Settings.OUTPUT_PATH + filepath);
        }
        DataContractJsonSerializer dcjs = new DataContractJsonSerializer(typeof(Dictionary<stringstring>));
        byte[] fileByte = Encoding.UTF8.GetBytes(fileStream);
        MemoryStream ms = new MemoryStream(fileByte);
        if (ResourceDictionary != null)
        {
            ResourceDictionary.Clear();
            ResourceDictionary = null;
        }
        ResourceDictionary = dcjs.ReadObject(ms) as Dictionary<stringstring>;
    }
    catch
    {
        Debug.Assert(false);
    }
}

5. Support ViewModel binding
사실 언어 리소스는 xaml에 정적 바인딩 해 두고 indexer에서 PropertyChanged로 변경 가능하게 하는게 가장 정석이지만, MVVM으로 만들어 둔 ViewModel의 Property에 PropertyChanged가 될 때 각 Property의 get에서 뭔가 string format 처리라던지 분기 처리를 통한 적절한 리소스를 보여주고 싶을 때가 있을 때도 지원 가능하도록 처리를 해 두었다.


#region NotifyPropertyChangedDictoionary
private Dictionary<INotifyPropertyChangedstring[]> _NotifyPropertyChangedDictoionary = null;
private Dictionary<INotifyPropertyChangedstring[]> NotifyPropertyChangedDictoionary
{
    get
    {
        if (_NotifyPropertyChangedDictoionary == null)
        {
            _NotifyPropertyChangedDictoionary = new Dictionary<INotifyPropertyChangedstring[]>();
        }
        return _NotifyPropertyChangedDictoionary;
    }
    set
    {
        _NotifyPropertyChangedDictoionary = value;
    }
}
#endregion
 
#region SetRegisterNotifyPropertyChanged
public void SetRegisterNotifyPropertyChanged(INotifyPropertyChanged sender, params string[] propertynames)
{
    if (NotifyPropertyChangedDictoionary != null)
    {
        NotifyPropertyChangedDictoionary.Add(sender, propertynames);
    }
}

#endregion


어쨌든 이런 식으로 리소스를 관리하면 런타임시에 언어 변경이 된다.
다만 언어별 텍스트 read, write시에 JSON notation error나
중복된 key 입력 정도만 조심하면 된다.

JSON notation error는 웹 상의 json viewer 같은 걸로 체크해 주면 되고
Dictionary class를 deseriazlize 하기 때문에 언어 파일에 중복키가 들어가게 되면 예외가 발생하게 된다. 이 부분을 조심하자.

* Benefit

1. Language resources changed on run time.

손쉽게 런타임 시에 언어 리소스 변경이 가능하다. 애초에 이 목적으로 만들어졌기 때문에 당연히 장점이 된다.

2. Can view design time.

디자인 타임에 리소스를 확인해 볼 수 있는 기능은 어찌 보면 별거 아니지만
실제 사용해 보면 상당히 편하고 좋은 기능임을 알 수 있다.

3. One time called all PropertyChanged event by indexer

Singleton instance에 CultureName이 변경되면 즉시 설정된 리소스 파일을 로딩하게 되고 그 과정에서 PropertyChanged 이벤트가 걸리게 되기 때문에 indexer를 사용하는 모든 binding된 property는 변경이 (저절로 되는 것처럼) 된다. 자세한 내용은 파일 다운로드해서 보면 알 수 있다.


* How to use LocalizationResource project library

1. Set output directory

Project properties -> Build -> Output
여기에 리소스를 사용할 프로젝트의 output 경로를 적는다.



<당연한 얘기지만 리소스를 빌드하면 dll 파일이 나오고 이걸 사용할 프로젝트에 출력 경로로 세팅해 주는 것이다>
 
2. Add reference library to target project.

다국어를 사용할 프로젝트에 이 라이브러리 프로젝트를 참조시킨다.

 
<역시 당연한 얘기지만 참조를 추가해 주는 건 기본>

3. Edit language resource files.

ko-KR, en-US가 기본으로 있으며 테스트용으로 key-value 값이 몇 개 들어 있다.
사용하고 싶은 key-value 값을 두 파일에 동일하게 추가해서 리소스 파일을 만들어 간다.


<Resources.en-US.txt>
<Resources.ko-KR.txt>
4. Use resource to xaml. You can see resource values design time.

디자인 타임에 실시간으로 리소스의 키를 입력했을 때 값이 확인되는 건 더할 나위 없이 좋은 기능임을 강조하고 싶다. 실제 쓸 때도 static binding으로 세팅만 해 두면 나중에 언어가 변경되더라도 PropertyChanged가 걸려서 언어에 맞는 리소스로 변경되서 보여진다.

<디자인타임에도 리소스가 보이는 걸 볼 수 있다.>
5. To see other property binding, download sample zip file.

Static binding 뿐 아니라 MVVM의 ViewModel binidng 까지 예제가 있으니 다운로드 해서 확인해 보면 된다.

LocalizationResources sample project file

<한국어>

파일 다운로드 방법
- 링크를 눌러 새 창이 뜨면 Ctrl+S 를 누른다.
- 아니면 메뉴에 파일 > 다운로드를 클릭한다.

<English>

How to download zip file.
- Link click, new tab opened -> press key 'Ctrl+s'
- or File > Download menu click.

11 comments:

  1. 굳이 왜 resx를 쓰지 않고 별도로 만드셨는지 잘 이해가 되지 않네요
    ResXFileCodeGenerator를 통해서 만들어진 코드에서 Culture 프로퍼티만 바꿔서 써도 될것같은데요. (resx를 써도 런타임시에도 충분히 변경 가능합니다. / 실제 이전 프로젝트에서 위 방법을 사용했습니다.)
    또 관리 차원에서 resx를 쓰면 아래와 같은 좋은 extension도 쓸 수 있습니다.
    https://visualstudiogallery.msdn.microsoft.com/3b64e04c-e8de-4b97-8358-06c73a97cc68/view/Reviews?sortBy=DateAscending

    ReplyDelete
    Replies
    1. 안녕하세요. 댓글이 달리다니 반갑네요 ㅜㅜ

      사실 resx를 써서 UICulure를 바꾸면 런타임시에 변경되는 라이브러리들은 구현 방법의 차이가 있을 뿐 제가 만드는 방법과 다르지는 않습니다.
      즉 resx에서 리소스를 읽어오고 그걸 list로 관리하고 있다가 UICulture를 바꾸면 list에 있는 걸 차례대로 다른 리소스로 바꾸는 식입니다.
      언급해 주신 ResXFileCodeGenerator 소스를 보면 아실 겁니다.

      라이브러리 가져다 쓰는 사람이야 resx를 만들어 써도 동적으로 변하는 구나... 라고 쉽게 가져다 쓰고 그렇게 받아들이면 그만이지만 그 구현체가 어떻게 되어 있느냐가 더 중요하지 않을까 싶네요.

      사실 제가 runtime 시에 리소스 바뀌게 만들어 본 것 뿐이지 UICulture 변경을 통한 리소스가 바뀌는 걸 고려하진 않았기 때문에 반쪽짜리이긴 하죠.

      어쨌든 언급해 주셔서 감사합니다.

      Delete
  2. This comment has been removed by the author.

    ReplyDelete
  3. 다국어런타임대응이 필요한 WPF 프로젝트가 제기되어 이 블로그에 오게 되었습니다.
    예제프로젝트는 다른 문제없이 빌드가 되었는데 제 프로젝트에서 이용하려고 하니 디자인타임시 오류가 발생하네요.
    LoadResource에서 FileNotFound가 발생합니다.
    디버그출력으로 확인해보니 C:\Windows\System32가 CurrentDirectory로 지정되어있는것 같습니다.
    어떤 방법으로 퇴치가능한지 알려주실수 있으세요?

    그리고 개인적으로 이 방법이 좋을것이라고 생각합니다만 블로그가 오래 되어서 현 시점에서 더 좋은 방법이 없겠는지 알고싶습니다.

    ReplyDelete
    Replies
    1. 안녕하세요. 반갑습니다.

      Design time 오류는 경로 설정이 제대로 안되어 있어서 그런 것 같네요.

      string filepath = string.Format(Settings.LANGUAGE_FILE_PATH, CultureName);
      if (DesignerProperties.GetIsInDesignMode(new DependencyObject()) == false)
      {
      fileStream = File.ReadAllText(filepath);
      }

      Settings.LANGUAGE_FILE_PATH는

      public const string LANGUAGE_FILE_PATH = "LanguageFiles/Resources.{0}.txt";

      요렇게 정의가 되어 있습니다. Resources의 culture name을 따라가게 되어 있어서 이 부분을 확인하시고 적용해 보시면 될 거 같습니다.

      또, LocalizationResources 프로젝트 설정을 보시면 Output path가 WpfApplication1/bin/Debug로 되어 있기 때문에 이 부분도 원하는 path로 변경하셔야 합니다.

      그리고 다국어 처리에서 동적으로 변경이 되어야 하냐? 에 대한 부분을 잘 파악하는게 중요합니다.
      정적으로 해도 상관 없다면 이 이전의 블로그 포스트의 쉬운 방법을 참고하시면 더 편하게 하실 수 있습니다.
      첫 댓글을 다신 JYK님이 얘기한 방법을 써도 좋을 것 같습니다.

      감사합니다.

      Delete
  4. 안녕하세요, 저도 다국어 변경 프로젝트건 검색하다가 여기까지 오게 되었는데요, 혹시 enum과 같이 textbox값이 특정한 값으로 지정이 안되었을경우엔 어떻게 처리를 해줘야할까요?

    ReplyDelete
    Replies
    1. 안녕하세요. 잘 이해가 안되긴 하지만 enum 값으로 넣고 싶다면 int나 string으로 변환해서 넣으면 됩니다. 그 반대도 가능하고요.

      Delete
    2. 안녕하세요, 우선 댓글 감사합니다.
      enum 으로 지정한 값들을 텍스트 값으로 변환해서 보여지는 경우에는 특정한 값으로 지정이 되어 있지 않고, 상황에 따라 바뀌게 되는데 그런 경우에는 어떤식으로 해야 하는지 모르겠어요.예를 들면 XAML 파일에 option이라는 키가 있는데 그 키가 항상 옵션이 아닌 경우를 말씀드려용

      Delete
    3. enum의 모든 값들을 키 값으로 지정하고 하면 되는데, 여전히 이해가 잘 가지 않는 군요.

      키 값을 바꾸는 코드가 있을테니 그 키 값에 맞는 text는 불러와 질 것 같고요.
      변경이 안된다고 하면 PropertyChanged를 한번 더 호출해 주면 변경될 겁니다.

      Delete
    4. 일단 한번 다시 해보겠습니당!!
      그러면 지금 이 코드는 텍스트 파일을 이용해서 하시는데 혹시 resx 파일을 사용하려면 어떻게 하면 될까요? 제가 아직 신입이라 모르는 부분이 많습니다. 작성해주신 글 보고 많이 도움 받아서 이 코드한에서 사용하고 싶습니다.

      Delete
  5. This comment has been removed by the author.

    ReplyDelete