Как я писал модуль обновления на C#

Я пишу программы на C# для фирмы, где их использует несколько сотен человек. Время от времени добавляются новые функции и встаёт проблема обновления версий.

Я решил не искать стандартных громоздких решений, а изобрести свой собственный велосипед для автоматического обновления установленных программ.

image

Честно, мне самому не очень нравятся приложения, которые вечно скачивают обновления, но в моём случае проще автоматизировать этот процесс, чем писать должностные инструкции и заставлять коллег скачивать обновления вручную (а потом бегать по всем этажам и делать это самому).

Цели


Прежде чем начать работу над этим проектом, я сформулировал цели, которым должен удовлетворять будущий модуль авто-обновления.

  1. Обновление должно происходить автоматически при наличии новой версии.
  2. После обновления программа должна автоматически перезапуститься.
  3. После обновления имя программы должно сохраниться прежним.
  4. Модуль должен встраиваться в ехе-файл проекта.

И в чём, казалось бы, проблема? Проверил наличие новой версии. Скачал файл. Запустил. Всё!

Задача становится немного запутанной из-за того, что запущенный ехе-файл не может сам себя удалять или переименовывать, это должен делать другой процесс при закрытой программе.

А для выполнения всех необходимых действий в рамках одной программы:
  • скачать новую версию,
  • удалить старую программу,
  • переименовать скачанный файл
– необходимо несколько раз её перезапускать, чтобы последовательно выполнять перечисленные действия.

Блок-схема


Чтобы прояснить, на каком этапе что нужно делать, я составил блок-схему всего процесса.
По собственному опыту знаю, что на составление схемы требуется час, а экономится день.

image
Схемы я всегда рисую от руки, перерисовывая их по несколько раз, каждый раз добавляются новые детали, да и сам процесс мне очень нравится.

Этапы


На блок-схеме выделены три этапа процесса обновления.

Этап А. Программа запущена в обычном режиме (без ключей).
get up_version
Считываем и проверяем номер версии на сервере.

my_version == up_version?
Если серверная версия совпадает с нашей – пропускаем модуль обновления.

download new.name.exe
Закачиваем новую программу в файл new.name.exe.

% % %% % %
Ожидаем окончание процесса загрузки.

start new.name.exe /u
После окончания загрузки запускаем скачанный файл.

Закрываем программу, чтобы потом её удалить.

Этап Б. Программа запущенна с ключом /u.
del name.exe
Удаляем программу name.exe.

copy new.name.exe name.exe
Копируем new.name.exe в name.exe.

start name.exe /d
Запускаем name.exe с ключом /d.

Закрываем программу, чтобы потом её удалить.

Этап Ц. Программа запущенна с ключом /d:
del new.name.exe
Удаляем временную копию программы new.name.exe

Запускаем основную программу.

Теперь переходим к практической части, как я это всё реализовал в классе на C#.

Основные поля данных


Начнём обзор модуля со знакомства с полями данных, то есть с рабочими константами и переменными.

    // Текущая версия проекта, доступная для всего проекта
    public static string my_version = "1.23";
  
    // Ссылки на txt-файл версии, на exe-файл программы и на сайт
    private string url_version = "http://localhost/version.txt";
    private string url_program = "http://localhost/program.exe";
    private string url_foruser = "http://localhost/index.php";

    private string my_filename;   // Имя файла запущенной программы 
    private string up_filename;  // Имя временного файла для загрузки обновления

    private bool is_download;   // Признак, что началось скачивание обновления
    private bool is_skipped;   // Признак, что обновление не требуется или закончено


Запуск!


Работа модуля начинается с его запуска. В каком месте программы это сделать лучше всего? Я перепробовал разные варианты, и самым удачным мне показался вариант его запуска из файла Program.cs

    static void Main()
    {
        Application.EnableVisualStyles();
        Application.SetCompatibleTextRenderingDefault(false);

        // Инициализация и запуск модуля обновления
        FormUpdate up = new FormUpdate();

        if (up.download())   // Если началось скачивание 
            Application.Run(up); // … ожидаем его окончания

        if (up.skipped())    // Обновление не требуется или закончено
            Application.Run(new Form1()); // … запускаем основную программу
    }


Вся логика модуля обновления выполняется в конструкторе, без визуального отображения. Форма отображается только в процессе скачивания файла.

Метод download() информирует о том, что на этапе «А» началось асинхронное скачивание новой версии программы, в связи с чем нужно отобразить визуальную форму, на которой размещён ProgressBar с текстовым полем, и ждать завершения процесса. Остальные этапы обновления выполняются «молча» и отображение формы пропускается.

Метод skipped() информирует о том, что обновление пропущено (или закончено), программа может быть запущенна в обычном режиме. В ином случае программа должна закрыться для перехода к следующему этапу.

Конструктор


Напишем конструктор класса, который по аргументам командной строки установит, на каком этапе обновления мы находимся, и вызовет соответствующие методы класса.

    private FormUpdater()
    {
    	// Получаем имя запущенной программы (без полного пути)
        my_filename = get_exec_filename (); 

        // Формируем имя временного файла
        up_filename = "new." + my_filename; 

        // Получаем аргументы командной строки
        string [] keys = Environment.GetCommandLineArgs(); 
        
        if (keys.Length < 3)   // Этап А. Аргументов нет – проверим версию на сервере
            do_check_update ();
        else
        {
            if (keys[1] == "/u")  // Этап Б. Запущена новая версия из временного файла
                do_copy_downloaded_program (keys [2]);

            if (keys[1] == "/d")  // Этап Ц. Осталось удалить временный файл.
                do_delete_old_program (keys [2]);
        }
    }


Несколько слов о вспомогательном методе get_exec_filename(). В C# можно получить имя запущенного файла только с полным путём. Для изъятия чистого имени файла я написал свой метод, который разбивает путь на части по символу «\» и возвращает последнюю его часть – искомое имя файла.

    private string get_exec_filename()
    {
        string fullname = Application.ExecutablePath; 
        // Например: D:\Work\Projects\Name.exe
        string[] split = { "\\" };
        string[] parts = fullname.Split(split, StringSplitOptions.None);
        // Получим массив из 4 элементов: D: , Work , Projects , Name.exe

        if (parts.Length > 0)
            return parts[parts.Length - 1]; // Последний элемент = искомое имя файла
        return "";
    }


Остальные методы, которые вызываются из конструктора, будут рассмотрены на соответствующих этапах.

Этап «А»


Метод do_check_update() проверяет наличие обновления на сервере и, в зависимости от результата, либо запускает процесс обновления, либо разрешает запуск основной программы.

    private void do_check_update()
    {
        // получаем номер версии программы на сервере
        string up_version = get_server_version(); 

        if (my_version == up_version) // Если обновление не нужно
        {
            is_download = false; // Пропускаем скачивание
            is_skipped = true;   // Пропускаем модуль обновления
        } else
            do_download_update ();   // Запускаем скачивание новой версии
    }


Метод get_server_version() использует стандартный метод класса WebClient для считывания номера версии.
Если номер версии не считывается, логично предположить, что обновление тоже не удастся скачать, поэтому будем считать, что обновления нет.

    private string get_server_version()
    {
        try {
            WebClient webClient = new WebClient(); 
            return webClient.DownloadString(url_version).Trim();
        } catch {     // Если номер версии не можем получить,
            return my_version;  // то программу даже и не будем пытаться.
        }
    }


Метод do_download_update() отображает экранную форму и запускает асинхронную загрузку обновлённого файла программы.

    private void do_download_update ()
    {
        InitializeComponent();      // Инициализация формы
        label_status.Text = "Скачивается файл: " + url_program;
        download_file (); // Начинаем скачивание
        is_download = true;  // Будем ждать завершение процесса 
        is_skipped = false;   // Основную программу не нужно запускать
    }


Метод download_file() запускает асинхронное скачивание и подключает два события:
для отображения прогресса и для завершения этапа загрузки файла.

    private void download_file ()
    {
        try
        {
            WebClient webClient = new WebClient(); 
            // Создаём обработчики событий продвижения прогресса и его окончания
            webClient.DownloadProgressChanged += new DownloadProgressChangedEventHandler(ProgressChanged);
            webClient.DownloadFileCompleted += new AsyncCompletedEventHandler(Completed);

            // Начинаем скачивание
            webClient.DownloadFileAsync(new Uri(url_program), up_filename);
        }
        catch (Exception ex) 
        {  // В случае ошибки выводим сообщение и предлагаем скачать вручную
            error(ex.Message + " " + filename); 
        }
    }


В случае ошибки вызывается метод error(), который отображает текст ошибки и предлагает скачать файл самостоятельно, в этом случае будет запущен браузер и открыта соответствующая страница. Текст этого метода, думаю, можно опустить.

Из этапа «А» осталось реализовать обработку двух событий:
изменение прогресса скачивания и обработку его окончания.

Метод изменения прогресса написан тривиально.

    private void ProgressChanged(object sender, DownloadProgressChangedEventArgs e)
    {
        progress_download.Value = e.ProgressPercentage;
    }


По завершению скачивания необходимо перейти к этапу «Б» и завершить работу.

    private void Completed(object sender, AsyncCompletedEventArgs e)
    {
        run_program(up_filename, "/u \"" + my_filename + "\"");
        this.Close ();
    }


Обратите внимание, что кроме ключа /u в программу передаётся исходное имя программы, чтобы вновь запущенная программа знала, как переименовывать файл, согласно 3-ей цели: после обновления имя программы должно сохраниться прежним.

Имя файла в параметре командной строки необходимо заключать в кавычки на случай наличия в нём пробелов.

Поля метода is_download, is_skipped в этом методе устанавливать не нужно, так как этап их проверки в файле Program.cs был пройден сразу после запуска скачивания.

Метод для запуска программы может выглядеть следующим образом.

    private void run_program(string filename, string keys)
    {
        try
        {   // Использование системных методов для запуска программы
            System.Diagnostics.Process proc = new System.Diagnostics.Process();
            proc.StartInfo.WorkingDirectory = Application.StartupPath;
            proc.StartInfo.FileName = filename;
            proc.StartInfo.Arguments = keys; // Аргументы командной строки
            proc.Start(); // Запускаем!
        }
        catch (Exception ex)
        {
            error(ex.Message + " " + filename);
        }
    }


Итак, с этапом «А» мы разобрались.

Если что-то показалось запутанным, рекомендую ещё раз просмотреть блок-схему и сопоставить методы модуля с элементами на блок-схеме.

Этап «Б»


Переходим к этапу «Б», он будет значительно проще. Из конструктора вызывается метод do_copy_downloaded_program(string filename), который копирует загруженную версию программы на место старой.

    void do_copy_downloaded_program(string filename)
    {
        try_to_delete_file(filename); // Удаляем файл со старой версией программы
        try
        {   // Копируем скачанный файл в оригинальное имя файла
            File.Copy(my_filename, filename);

            // Запускаем этап «Ц»
            run_program(filename, "/d \"" + my_filename + "\""); 
            is_download = false;  // Форма не отображается
            is_skipped = false;   // Обновление ещё не закончено
        }
        catch (Exception ex)
        {
            error(ex.Message + " " + filename);
        }
    }


Несколько слов о методе try_to_delete_file(string filename). Может оказаться так, что мы пытаемся удалить файл, который ещё заблокирован не до конца завершённым процессом предыдущей программы. Этот метод пытается удалять файл несколько раз подряд в течении нескольких секунд с небольшими задержками.

    private void try_to_delete_file(string filename)
    {
        int loop = 10; // Количество попыток 
        while (--loop > 0 && File.Exists(filename))
            try { 
                File.Delete(filename);
            } catch {
                Thread.Sleep(200); // Небольшая задержка
            }
    }


Этап «Ц»


Остался последний, самый короткий этап «Ц», который удаляет «мусор» и запускает основную программу. Для этой цели из конструктора вызывается метод do_delete_old_program(string filename).

    void do_delete_old_program(string filename)
    {
        try_to_delete_file(filename);
        is_download = false;  // Форма не отображается
        is_skipped = true; // Обновление отработало, запускайте!
    }


Если версия программы на сервере не будет совпадать с записанной версией в текстовом файле, то при каждом запуске будет повторно загружаться «новая» версия. После этого программа всё-таки будет запущена невзирая на различие версий: по блок-схеме на этапе «Ц» версия программы уже не проверяется. Так и должно быть! Дополнительная проверка на этом этапе рискует зациклить процесс скачивания навечно…

Кстати, эту «фишку» можно использовать для запуска программы без проверки наличия обновлений, достаточно её запускать с ключом «/u».

Заключение


Построенный таким образом модуль обновления удовлетворяет всем целям, сформулированным в начале статьи:

  1. Обновление скачивается только при наличии новой версии.
  2. Обновлённая программа автоматически запускается после скачивания.
  3. Предусмотрен механизм сохранения исходного имени файла программы.
  4. Модуль сделан не отдельной программой, а встроен в файл проекта.


Работа модуля продемонстрирована на следующем рисунке.
image

Также могу продемонстрировать работу модуля на собственной программе изучения английских слов на слух. Скачать «старую» версию «Звуковых карточек» можно здесь: http://www.DoubleEnglish.ru/soft/old.ListenCards.exe

Исходный код модуля обновления в тестовом проекте можно скачать здесь:
http://www.fformula.net/docs/updater/updater.zip

Спасибо за внимание.
Волосатов Евгений.
Поделиться публикацией
Комментарии 31
    +23
    А чем технология ClickOnce не устроила? Не проще было использовать проверенное решение? Ваше решение абсолютно не безопасно и ни в коем случае не подходит для банков или любого более-менее серьёзного применения.
      +14
      Зашел оставить или прочитать этот комментарий. ClickOnce прост и в то же время может служить отправной точкой для запуска более сложных сценариев.
        0
        Тоже первая же мысль про ClickOnce.
        +5
        Цифровые сертификаты, цифровые подписи разворачиваемого дистрибутива, подпись разработчика приложения, контроль целостности? Ну можно было сделать намного качественнее и проще, используя публикацию с применением технологии ClickOnce c созданием манифеста развёртывания приложения.
          +2
          Скажу честно, что до сих пор не был знаком с технологией ClickOnce.
          По поводу проверки целостности скачанной программы — это, конечно же, будет сделано, просто решил не загромаждать проект дополнительными возможностями, чтобы сконцентрироваться на основной идее.
          Согласен, что для серьёзных проектов такой вариант не годится, но для внутренних офисных программ внутри корпоративной сети, думаю, вполне сгодится.
            +4
            Я решил не искать стандартных громоздких решений, а изобрести свой собственный велосипед для автоматического обновления установленных программ.
            Скажу честно, что до сих пор не был знаком с технологией ClickOnce.
            Т.е. решение было принято с потолка. FAIL
              0
              Чем вас не устраивает изобретение велосипеда?

              Зато теперь человек знает не понаслышке как это сделать, углубился внутрь процесса, не используя готовое решение.
              Как минимум достойно похвалы.
            0
            А чем вас не устроила Omaha?
              0
              Омаху нужно еще не хило так докрутить.
              Добавить win8, прорубить про недокументированные вызовы к API, реализовать эти API.

              На бумаге всё красиво, на деле есть некоторые шероховатости.
              –4
              Как вариант — использовать NuGet. Поднимаете собственную галерею и вперед…
                +5
                И как это будет работать с UAC?

                Еще можно использовать планировщик заданий для проверки и установки обновлений.
                  +2
                  Скорее всего никак
                    0
                    Кстати это очень удобно! Сейчас поднимаем собственную галлерею как раз. При учете, что можно повершелом ставить из галереи и без студии, получается вообще красота.
                    0
                    Начал отвечать про галерею, потом посмотрел про UAC и поменял коммент.
                    Насчет галереи — развернули собственную, довольно удобно при модульной разработке с учетом того что модули могут писать и партнеры.
                      0
                      Статья понравилась. Велосипед годный. Вышеуказанный замечания по поводу безопасности, конечно, справедливы.

                      Сам бы так не стал реализовывать, если делал бы именно велосипед. Смотрите: у вас апдейтер (который перекопирует файлы) — это сама программа (тот же exe файл). Можно бы было сделать 2 отдельных приложения:

                      1) программа, которая умеет скачивать новую программу и запускать апдейтер
                      2) программа апдейтер, которая не изменяется никогда

                      Таким образом, ключей запуска бы не потребовалось вообще.
                      Если вдруг понадобиться изменить логику апдейтера из пункта 2), то можно в пункте 1 скачивать логику апдейтера в dll. еxе файл же меняться не будет.
                        0
                        У меня уже была реализация с двумя файлами, и всё работало прекрасно в проекте VKInfinity, который уже, к сожалению, закрыт. Но размещать на сайте два файла для запуска приложения без инсталляции — не удобно. Использовать архиватор специально ради этого тоже не гуд. Поэтому я хотел сделать всё именно в одном файле.
                        Спасибо за отзыв!
                          0
                          Мы решили этот вопрос следующим образом: пользователю отдавали только модуль обновления. При запуски он проверял, что основной программы нет и загружал ее. Мы да же больше извращались, так как для работы модуля обновления нужны были сторонние библиотеки (7z), мы их включали в модуль как ресурс, при первом запуске распаковывали в файлы, перезапускали обновлятор и тащили основное приложение, но все это не будет работать с UAC. Планировщик заданий тоже не помог так, как у нас модуль взаимодействовал с пользователем (выдавал уведомления), а программа запущенная из под админа, не взаимодействует с текущим рабочим столом пользователя. Помогла только служба — она решила все наши проблемы.
                        +1
                        Ретро виндовс?
                          +7
                          Насколько я понял, весь ваш алгоритм «засунут» внутрь класса формы?
                            –1
                            Нет, алгоритм расположен в отдельном классе, у него своя форма.
                            См. самый первый рисунок в начале статьи, в правой части видно, что есть две формы:
                            основная Form1.cs и форма для модуля обновления FormUpdater.cs.

                            Также посмотрите листинг файла Program.cs — основная форма запускается только после того, как модуль обновления дал «добро».
                              0
                              Извините, не до конца понял вопрос. Да, алгоритм внутри класса формы, хоть и отдельной, специально для этого созданной.
                            0
                            Делал я когда-то схожий велосипед. Пришел даже к тому, чтобы использовать GDIFF. Но со временем оказалось, что проще скачать инсталятор (например, на основе MSI) в Temp директорию, и запустить его с verb runas и /silent, и тут же закрыть программу.
                              0

                              Полностью согласен,
                              Мой алгоритм — скачиваем дистрибутив локально (%appdata%\projectname)
                              Если меньше 5 мегабайт — то скачиваем и обновляем сразу, если больше скачиваем в бекграунде (последнее время стал использовать BITS) потом запускаем.
                              Для обновления использую bat файл — его можно сгенерировать из программы или сразу положить в дистрибутив, в него дописываю путь программы которую нужно запустить после обновления.
                              Запускаем его в скрытом режиме + verb runas


                              start /wait test.msi /passive
                              notepad
                              Запускаем процесс инсталляции и ждем его завершения, выводим только прогресс инсталляции


                              плюсы решения:
                              можно еще что-нибудь запустить, например патч которые локальные базы подправит/допишет поля и тд.
                              Целостность установки не повреждается, т.е если дописывать какие нибудь дополнительные файлы после инсталляции при ее удалении они останутся в системе, что ни есть гуд.
                              Ну и с переименованием основной программы не нужно заморачиваться — на практике понял что нельзя предусмотреть все ситуации на 100 %.
                              Если нужно удаляю скаченный дистр в том же батнике.

                              0
                              Сделал такой же велосипед для всех своих программ, единственное что я храню на сервере не файл а архив, потому как бывает что обновить надо и дополнительные файлы (шаблоны для отчета, файлы настроек и т.д.). Но большая проблема возникает на компьютерах с ограниченным доступом где в целевой папке запрещается что-либо изменять, ничего не придумал лучше кроме как установить программу в личную папку пользователя.
                                –3
                                P.S. Правда я еще сделал «админку» в некоторых программах для заливки архива с обновлением и изменением текстовика с номером версий на сервер через FTP. Удобнее стало.
                                image
                                  0
                                  Спасибо за идею, я тоже так сделаю, чтобы загрузка обновления с изменением файла версии представляло собой одну транзакцию.
                                +2
                                Евгений, я конечно извиняюсь, но:
                                private string get_exec_filename()
                                    {
                                        string fullname = Application.ExecutablePath; 
                                        // Например: D:\Work\Projects\Name.exe
                                        string[] split = { "\\" };
                                        string[] parts = fullname.Split(split, StringSplitOptions.None);
                                        // Получим массив из 4 элементов: D: , Work , Projects , Name.exe
                                
                                        if (parts.Length > 0)
                                            return parts[parts.Length - 1]; // Последний элемент = искомое имя файла
                                        return "";
                                    }
                                

                                можно заменить на
                                Path.GetFileName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName)
                                

                                , ну и еще половину кода так же.
                                  0
                                  Вот! Я ждал этот комментарий! Большое спасибо за совет.
                                    0
                                    Не за что, если что спрашивайте, с удовольствием отвечу на Ваши вопросы
                                  0
                                  Не сразу понял что это C#.
                                  Почитайте что ли гайды по coding style для языка на котором пишете. (вот один из вариантов — www.rsdn.ru/article/mag/200401/codestyle.XML)
                                  Это просто невыносимо выглядит, вас видимо спасает что вы не в разработке работаете, а где то в «АЙТИ Отделе ООО Рога и Копыта».

                                  Хотя диаграмма у вас очень красиво нарисована (о полезности говорить не буду).
                                    0
                                    UPD: Мне так понравилось делать описание собственных программ, что я пошёл дальше, стал записывать обучающие видео по созданию простеньких программок, что в конечном итоге вылилось мой собственный авторский проект — практические видео-курсы по программированию на C# для начинающих.
                                    Если кому интересно — www.videosharp.info/

                                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                    Самое читаемое