Currency Converter v2 — теперь с кофеином!
Педро Ламас (Pedro Lamas)
Учитывая отзывы пользователей о приложении Currency Converter v2, пришло время его немного усовершенствовать!
Вот лишь некоторые комментарии, полученные нами от пользователей:
- Конвертация валют выполняется слишком медленно.
- Чрезмерный трафик данных в приложении/необходимость кэширования валютных курсов.
- Некоторые валюты не поддерживаются.
- Неточные результаты/устаревшие данные о валютных курсах.
Эти комментарии означают, что нам нужен более эффективный источник данных и некоторый механизм кэширования…
Включаю кофеварку и… поехали!
Bing — быть или не быть? Вот в чем вопрос…
В первой версии Currency Converter для конвертации валют использовался поисковик Bing. О результатах вы уже прочитали выше.
В текущей версии в качестве источника данных мы выбрали MSN Money, поскольку он содержит более актуальные и точные данные и работает с любыми валютами.
Запустите Internet Explorer 8.0+ и перейдите на страницу https://moneycentral.msn.com/investor/market/exchangerates.aspx. Здесь выводятся актуальные валютные курсы для доллара США.
На этой странице есть вся необходимая информация для перевода любой валюты в доллары США и наоборот. Кроме того, можно конвертировать валюту X в доллары США, а затем в валюту Y.
Так почему бы не получить все эти данные одним запросом, кэшировать их, а затем использовать для конвертации валют офлайн?
Как и прежде, для извлечения необходимых данных со страницы HTML мы воспользуемся регулярными выражениями. Для этого откройте Internet Explorer Developer Tools (нажмите <F12>), выберите «Select element by click» (Выбор элемента по щелчку) (<Ctrl> + <B>) и щелкните «Argentine Peso» (Аргентинский песо). Страница будет выглядеть примерно так:
Используя приведенную выше информацию, мы сможем увидеть шаблон в коде:
HTML
<tr>
<td>CURRENCY</td>
<td style=”text-align:right”><a SOMETHING>VALUE_IN_USD</a></td>
<td style=”text-align:right”><a SOMETHING>VALUE_PER_USD</a></td>
</tr>
Зная шаблон, мы сможем создать приведенное ниже регулярное выражение:
C#
private static Regex _resultRegex =
new Regex("<tr><td>(?<currency>[^<>]+)</td><td style=""text-align:right"">.*?>(?<value>[0-9.,]+)</a></td></tr>");
Применив это регулярное выражение к нужному HTML-коду, мы получим все соответствующие строки, включая наименование валюты и обменный курс для доллара США.
Пора кодировать
Теперь, когда мы знаем, как извлечь все валютные курсы с одного URL, настало время внести изменения в код, чтобы воспользоваться новыми данными.
Как и в предыдущей статье, мы воспользуемся шаблоном MVVM и покажем процесс кодирования с низшего (Model) до самого верхнего (View) уровня шаблона.
Изменения на уровне Model
Для того чтобы воспользоваться полученными и кэшированными валютными курсами, мы должны внести следующие изменения в нашу модель:
- задать для каждой валюты сохранение ее курса и последнего обновления;
- пометить одну валюту как базовую (доллар США), присвоив ей обменный курс 1,0 (на случай конвертации долларов в доллары);
- добавить в службу операцию «Обновить валютные курсы».
А вот и полная модель (изменения выделены желтым):
C#
using System;
public interface ICurrencyExchangeService
{
ICurrency[] Currencies { get; }
ICurrency BaseCurrency { get; }
void ExchangeCurrency(double amount, ICurrency fromCurrency, ICurrency toCurrency, Action<ICurrencyExchangeResult> callback);
void UpdateCachedExchangeRates(Action<CachedExchangeRatesUpdateResult> callback, object state);
}
public interface ICurrency
{
string Name { get; }
double CachedExchangeRate { get; set; }
DateTime CachedExchangeRateUpdatedOn { get; set; }
}
public interface ICurrencyExchangeResult
{
Exception Error { get; }
string ExchangedCurrency { get; }
double ExchangedAmount { get; }
}
public interface ICachedExchangeRatesUpdateResult
{
Exception Error { get; }
object State { get; }
}
Теперь в ICurrencyExchangeService есть новое свойство BaseCurrency, которому присвоено значение экземпляра валюты «US Dollar», а также метод UpdateCachedExchangeRates для обновления всех валютных курсов.
Для ICurrency добавилось два новых свойства: CachedExchangeRate для хранения обменного курса валюты и CachedExchangeRateUpdatedOn для даты последнего обновления.
Также был добавлен новый интерфейс ICachedExchangeRatesUpdateResult, возвращающий исключение при асинхронном выполнении метода ICurrencyExchangeService.UpdateCachedExchangeRates.
Посмотрим, как реализован интерфейс:
Прежде всего, нужно отметить, что у нас появился абстрактный класс CurrencyBase. Тем самым мы расширяем класс MsnMoneyCurrency, добавляя отдельное свойство Id для хранения числового идентификатора валюты, получаемого с MSN Money.
Затем добавился метод MsnMoneyV2CurrencyExchangeService, который является прямой реализацией ICurrencyExchangeService.
Обратите внимание, что в отличие от метода BingCurrencyExchangeService из предыдущей версии, метод MsnMoneyV2CurrencyExchangeService не расширяет класс CurrencyExchangeServiceBase, а только запрашивает онлайновые данные в методе UpdateCachedExchangeRates и не при каждом вызове метода ExchangeCurrency.
Ниже приведен код для этих классов:
C#
public class MsnMoneyV2CurrencyExchangeService : ICurrencyExchangeService
{
private const string MsnMoneyUrl = "<a href='https://moneycentral.msn.com/investor/market/exchangerates.aspx?selRegion=1&selCurrency=1";'>https://moneycentral.msn.com/investor/market/exchangerates.aspx?selRegion=1&selCurrency=1";</a>
#region Static Globals
private static Regex _resultRegex = new Regex(@"<tr><td>(?<currency>[^<>]+)</td><td style=""text-align:right"">.*?>(?<value>[0-9.,]+)</a></td></tr>");
private static ICurrency[] _currencies = new ICurrency[]
{
//The currencies exposed by MSN Money will go here
};
#endregion
#region Properties
public ICurrency[] Currencies
{
get
{
return _currencies;
}
}
public ICurrency BaseCurrency
{
get;
protected set;
}
#endregion
public MsnMoneyV2CurrencyExchangeService()
{
BaseCurrency = Currencies.First(x => x.Name == "US Dollar");
}
public void ExchangeCurrency(double amount, ICurrency fromCurrency, ICurrency toCurrency, bool useCachedExchangeRates, Action<ICurrencyExchangeResult> callback, object state)
{
if (useCachedExchangeRates)
{
try
{
ExchangeCurrency(amount, fromCurrency, toCurrency, callback, state);
return;
}
catch
{
}
}
UpdateCachedExchangeRates(result =>
{
if (result.Error != null)
{
callback(new CurrencyExchangeResult(result.Error, state));
return;
}
try
{
ExchangeCurrency(amount, fromCurrency, toCurrency, callback, state);
}
catch (Exception ex)
{
callback(new CurrencyExchangeResult(ex, state));
}
}, state);
}
private void ExchangeCurrency(double amount, ICurrency fromCurrency, ICurrency toCurrency, Action<ICurrencyExchangeResult> callback, object state)
{
var fromExchangeRate = fromCurrency.CachedExchangeRate;
var toExchangeRate = toCurrency.CachedExchangeRate;
var timestamp = DateTime.Now;
if (fromCurrency == BaseCurrency)
fromExchangeRate = 1.0;
else
{
if (timestamp > fromCurrency.CachedExchangeRateUpdatedOn)
timestamp = fromCurrency.CachedExchangeRateUpdatedOn;
}
if (toCurrency == BaseCurrency)
toExchangeRate = 1.0;
else
{
if (timestamp > toCurrency.CachedExchangeRateUpdatedOn)
timestamp = toCurrency.CachedExchangeRateUpdatedOn;
}
if (fromExchangeRate > 0 && toExchangeRate > 0)
{
var exchangedAmount = amount / fromExchangeRate * toExchangeRate;
callback(new CurrencyExchangeResult(toCurrency, exchangedAmount, timestamp, state));
}
else
throw new Exception("Conversion not returned!");
}
public void UpdateCachedExchangeRates(Action<CachedExchangeRatesUpdateResult> callback, object state)
{
var request = HttpWebRequest.Create(MsnMoneyUrl);
request.BeginGetResponse(ar =>
{
try
{
var response = (HttpWebResponse)request.EndGetResponse(ar);
if (response.StatusCode == HttpStatusCode.OK)
{
string responseContent;
using (var streamReader = new StreamReader(response.GetResponseStream()))
{
responseContent = streamReader.ReadToEnd();
}
foreach (var match in _resultRegex.Matches(responseContent).Cast<Match>())
{
var currencyName = match.Groups["currency"].Value.Trim();
var currency = Currencies.FirstOrDefault(x => string.Compare(x.Name, currencyName, StringComparison.InvariantCultureIgnoreCase) == 0);
if (currency != null)
{
currency.CachedExchangeRate = double.Parse(match.Groups["value"].Value, CultureInfo.InvariantCulture);
currency.CachedExchangeRateUpdatedOn = DateTime.Now;
}
}
callback(new CachedExchangeRatesUpdateResult(ar.AsyncState));
}
else
{
throw new Exception(string.Format("Http Error: ({0}) {1}",
response.StatusCode,
response.StatusDescription));
}
}
catch (Exception ex)
{
callback(new CachedExchangeRatesUpdateResult(ex, ar.AsyncState));
}
}, state);
}
}
Он работает следующим образом: при вызове метода ExchangeCurrency мы передаем параметр (useCachedExchangeRates), который диктует методу использовать (или не использовать!) кэшированные ранее валютные курсы.
Затем выполняется конвертация валюты и возвращаются результаты. Если операция генерирует исключение или если мы запретили использовать кэшированные валютные курсы, вызывается метод UpdateCachedExchangeRates для обновления валютных курсов и выполнения конвертации с новыми данными.
С моделью на этом все!
ViewModel
Мы полностью сохранили ViewModel предыдущей версии, но добавили новую функциональность. Ниже приведен код:
C#
public class MainViewModel : INotifyPropertyChanged
{
//Full previous code
#region Properties
[IgnoreDataMember]
public ICurrencyExchangeResult Result
{
get
{
return _result;
}
protected set
{
if (_result == value)
return;
_result = value;
RaisePropertyChanged("Result");
RaisePropertyChanged("ExchangedCurrency");
RaisePropertyChanged("ExchangedAmount");
RaisePropertyChanged("ExchangedTimeStamp");
}
}
[IgnoreDataMember]
public string ExchangedTimeStamp
{
get
{
if (_result == null)
return string.Empty;
return string.Format("Data freshness:\n{0} at {1}",
_result.Timestamp.ToShortDateString(),
_result.Timestamp.ToShortTimeString());
}
}
[DataMember]
public CurrencyCachedExchangeRate[] CurrenciesCachedExchangeRates
{
get
{
return Currencies
.Select(x => new CurrencyCachedExchangeRate()
{
CurrencyIndex = Array.IndexOf(Currencies, x),
CachedExchangeRate = x.CachedExchangeRate,
CachedExchangeRateUpdatedOn = x.CachedExchangeRateUpdatedOn
})
.ToArray();
}
set
{
foreach (var currencyData in value)
{
if (currencyData.CurrencyIndex >= Currencies.Length)
continue;
var currency = Currencies[currencyData.CurrencyIndex];
currency.CachedExchangeRate = currencyData.CachedExchangeRate;
currency.CachedExchangeRateUpdatedOn = currencyData.CachedExchangeRateUpdatedOn;
}
}
}
#endregion
//Full previous code
public void ExchangeCurrency()
{
if (Busy)
return;
BusyMessage = "Exchanging amount...";
_currencyExchangeService.ExchangeCurrency(_amount, _fromCurrency, _toCurrency, true, CurrencyExchanged, null);
}
public void UpdateCachedExchangeRates()
{
if (Busy)
return;
BusyMessage = "Updating cached exchange rates...";
_currencyExchangeService.UpdateCachedExchangeRates(ExchangeRatesUpdated, null);
}
private void CurrencyExchanged(ICurrencyExchangeResult result)
{
InvokeOnUiThread(() =>
{
Result = result;
BusyMessage = null;
if (result.Error != null)
{
if (System.Diagnostics.Debugger.IsAttached)
System.Diagnostics.Debugger.Break();
else
MessageBox.Show("An error has ocorred!", "Error", MessageBoxButton.OK);
}
});
}
private void ExchangeRatesUpdated(ICachedExchangeRatesUpdateResult result)
{
InvokeOnUiThread(() =>
{
BusyMessage = null;
Save();
if (result.Error != null)
{
if (System.Diagnostics.Debugger.IsAttached)
System.Diagnostics.Debugger.Break();
else
MessageBox.Show("An error has ocorred!", "Error", MessageBoxButton.OK);
}
});
}
private void InvokeOnUiThread(Action action)
{
var dispatcher = System.Windows.Deployment.Current.Dispatcher;
if (dispatcher.CheckAccess())
action();
else
dispatcher.BeginInvoke(action);
}
#region Auxiliary Classes
public class CurrencyCachedExchangeRate
{
[DataMember]
public int CurrencyIndex { get; set; }
[DataMember]
public double CachedExchangeRate { get; set; }
[DataMember]
public DateTime CachedExchangeRateUpdatedOn { get; set; }
}
#endregion
}
Прежде всего, вы, наверное, заметили новое свойство «только для чтения» ExchangedTimeStamp, которое передает в интерфейс строку данных с информацией о том, когда были получены используемые данные о валюте. Интерфейс получает уведомление о том, что значение этого свойства изменяется при изменении свойства Result.
Ниже мы видим еще одно новое свойство CurrenciesCachedExchangeRates, в котором хранятся кэшированные валютные курсы. Для того чтобы заставить его работать, у нас есть вспомогательный класс CurrencyCachedExchangeRate, в котором хранится валютный индекс, валютный курс и метка времени обновления.
Благодаря методу UpdateCachedExchangeRates пользователи могут принудительно вручную обновлять кэшированные валютные курсы.
Функции обратного вызова CurrencyExchanged и ExchangeRatesUpdated используют метод InvokeOnUiThread для проверки правильности выполнения своего кода в потоке UI.
View
Мы внесли два простых изменения в MainPage.xaml (наш главный View): была добавлена область экрана, отображающая метку времени для результата конвертации, и пункт меню для полного обновления валютных курсов.
Чтобы внести первое изменение, добавьте простую текстовую область TextArea внизу StackPanel и создайте ее привязку к свойству ExchangedTimeStamp из ViewModel:
XAML
<StackPanel x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
<TextBlock Margin="12,0,0,-5" Style="{StaticResource PhoneTextSubtleStyle}">Amount</TextBlock>
<TextBox InputScope="TelephoneNumber" Text="{Binding Amount, Mode=TwoWay, ValidatesOnExceptions=True, NotifyOnValidationError=True}" />
<TextBlock Margin="12,10,0,-5" Style="{StaticResource PhoneTextSubtleStyle}">From</TextBlock>
<toolkit:ListPicker ItemsSource="{Binding Currencies}" SelectedItem="{Binding FromCurrency, Mode=TwoWay}" FullModeHeader="FROM CURRENCY" Style="{StaticResource CurrencyListPicker}" />
<TextBlock Margin="12,10,0,-5" Style="{StaticResource PhoneTextSubtleStyle}">To</TextBlock>
<toolkit:ListPicker ItemsSource="{Binding Currencies}" SelectedItem="{Binding ToCurrency, Mode=TwoWay}" FullModeHeader="TO CURRENCY" Style="{StaticResource CurrencyListPicker}" />
<StackPanel>
<TextBlock Style="{StaticResource PhoneTextGroupHeaderStyle}" Text="{Binding ExchangedCurrency}"></TextBlock>
<TextBlock Margin="25, 0, 0, 0" Style="{StaticResource PhoneTextTitle1Style}" Text="{Binding ExchangedAmount}"></TextBlock>
<TextBlock Style="{StaticResource PhoneTextSubtleStyle}" Text="{Binding ExchangedTimeStamp}" TextWrapping="Wrap" TextAlignment="Right"></TextBlock>
</StackPanel>
</StackPanel>
Что касается пункта меню для обновления валютных курсов, добавьте новый элемент ApplicationBarMenuItem в коллекцию MenuItems, задайте подходящий текст и добавьте обработчик для события щелчка:
XAML
<phone:PhoneApplicationPage.ApplicationBar>
<shell:ApplicationBar IsVisible="True" IsMenuEnabled="True">
<shell:ApplicationBarIconButton IconUri="/Images/appbar.money.usd.png" Text="Exchange" Click="ExchangeIconButton_Click" />
<shell:ApplicationBar.MenuItems>
<shell:ApplicationBarMenuItem Text="update exchange rates" Click="UpdateExchangeRatesMenuItem_Click" />
<shell:ApplicationBarMenuItem Text="about" Click="AboutMenuItem_Click" />
</shell:ApplicationBar.MenuItems>
</shell:ApplicationBar>
</phone:PhoneApplicationPage.ApplicationBar>
Теперь осталось реализовать метод UpdateExchangeRatesMenuItem_. Для этого щелкните обработчик событий в MainPage.xaml.cs:
C#
private void UpdateExchangeRatesMenuItem_Click(object sender, EventArgs e)
{
var viewModel = DataContext as MainViewModel;
if (viewModel == null)
return;
Dispatcher.BeginInvoke(() =>
{
viewModel.UpdateCachedExchangeRates();
});
}
Заключение
В результате мы получили приложение, по качеству не уступающее используемому источнику данных. Благодаря новому (и более качественному) источнику данных и нескольким простым изменениям кода, наш Currency Converter стал работать как никогда быстро.
И как раз вовремя — кофе готов!
Об авторе
Педро Ламас (Pedro Lamas) родом из Португалии. Педро имеет статус .NET Senior Developer и работает в компании-партнере Microsoft DevScope, используя все мощные возможности платформы Microsoft .NET для разработчиков.
Педро также работает администратором сети PocketPT.net, крупнейшего сообщества Windows Phone в Португалии, оказывая активную поддержку разработчикам под Windows Phone, и выступает в качестве докладчика на мероприятиях Microsoft в Португалии, посвященных разработке на базе Windows Phone.