太长不看版

◭ 软件运行效果

因为没开图片会员,所以还是不知道图片到怎么导出(可能也不是很难导出吧)

GitHub: https://github.com/sanshiliuxiao/DiaryExport

前言

你的日记 是一个非常好用的日记 APP,开发者受到动漫《你的名字》的启发,从而制作出这款精良日记软件。

本人使用此软件大概两年了。虽然我有使用 APP 的时空旋涡功能,匹配到了一位好友,但大多数情况下,我基本不会和对方互动,只是零星会回复一些简短的话语。日记这种私密的东西,一旦掺杂了要与对面的人分享日记的心情,那往往会在记录的日记中加以掩饰,变得日记不是日记。因此,我个人的想法是:真正的日记笔友,应该是永远不会在现实生活中所接触到,写日记真的就只是为了给自己做记录,不是为了别的什么原因。

话题扯远了,回归主题,就是这样一款优秀的,可作为”交友”的日记软件,可是却迟迟没有推出日记的导出功能???

不怕,借助万能的 GitHub,咱搜索到一个项目: YunYouJun/export-nideriji。这是一个基于 nodejs 的命令行工具,如果作为一名对计算机有了解的人来说,使用起来一点都不困难。感谢 云游君 的项目及其 API 文档。

作为偶尔爱折腾(长期鸽子)的本人,决定使用 CSharp 来重写一遍这个项目,把它演化成一个易于使用的桌面工具。

实现细节

通过查看 export-nideriji 项目的源代码,我们能够知道如下几点:

  1. 如何进行登陆以及维持登陆状态。
  1. 通过本篇日记的 ID, 能够获取到上一篇的日记。

  2. 日记存在被自己删除的情况,数据库有记录,能够获取到数据字段,只是内容显示 deleted

基于上面这几点,就非常清晰的知道如何去导出日记,剩下的事情就是使用另外一种语言去实现这些功能呢。

项目结构

◭ 项目结构
  • 第一个项目:通过命令行方式去导出日记,用于测试使用。 (为了方便,其实我是直接把账号密码写在代码里的,不过使用 git 前,我就删掉了)。

  • 第二个项目:用于获取数据。

  • 第三个项目:用于将获取到的数据,最终导出成文件。

  • 第四个项目: WPF 桌面应用。

数据获取

使用 Flurl.Http 进行登陆和数据的获取,使用 Polly 进行连接失败的再次尝试。他们的使用方式都很简单,直接拷贝文档里面的例子,稍微改改就行。

C# 的泛型和委托是真的好用,下面是一个示例,功能是网络连接失败后,等待几秒后进行尝试的函数。如果请求成功则会返回 data 数据。 由于 T 是泛型语法,那么 data 数据的类型就能够通过传入的 Model Class 进行改变啦。

public class DiaryExport
{
     private List<int> httpStatusCodesWorthRetrying = new List<int>(new[] { 408, 500, 502, 503, 504 });
    public async Task<T> RetryPolicy<T>(Func<Task<T>> request)
    {
        var retryCount = 0;
        T data;
        data = await Policy
            .Handle<FlurlHttpException>(ex =>
            {
                // 这里由于网络断开连接了,所以 Response 为 null 
                if (ex.Call.Response == null)
                {
                    return true;
                }

                return httpStatusCodesWorthRetrying.Contains((int) ex.Call.Response.StatusCode);
            })
            .WaitAndRetryAsync(new[]
            {
                TimeSpan.FromSeconds(1),
                TimeSpan.FromSeconds(3),
                TimeSpan.FromSeconds(6)
            })
            .ExecuteAsync(() =>
            {

                retryCount++;
                return request();
            });
        return data;
    }
}


// 使用
userModel = await RetryPolicy<UserModel>(() => 
    new Url(_baseUrl)
        .AppendPathSegment("/login/")
        .PostUrlEncodedAsync(data)
        .ReceiveJson<UserModel>());

对于 CSharp 这类静态类型语言来说, 获取到 JSON 数据之后,是需要有一个与之对应 Model Class 才能够进行解析的,而且 JSON 里面键值开头是小写,而 Model Class 对应的属性开头是大写。(这属于命名规范,其实不强制。除了第一个字母(大小不分),其余字母是区分大小写的)。

这样看来,确实有些麻烦,不过有一点好处是不需要的数据,可以不写在 Model Class 中。下面是示例:


// json data
{
    "token": "xxx",
    "user_config": {
        "name": "yyy",
        "useremail": "zzz",
        "description": "如果 Model Class 里面没有这个属性,就不会映射进去"
    }
}


// Model Class
public class UserModel
{
    public string Token { get; set; } // 也可以写 token, 但不能写 TokEn tokEn 之类的。
    public UserInfo User_config { get; set; } // User_config 名字要对的上
}

public class UserInfo
{
    public string Name { get; set; }
    public string Useremail { get; set; }
}

至于如何将 JSON 数据转换成 CSharp 的 Model Class, 这里不多赘述, 因为 Flurl.Http 自带 ReceiveJson() 方法进行转换。 如果想用其他库的话,推荐使用 Newtonsoft.josn

数据存储

这里也蛮简单的,网上找几篇博客,拷贝一下代码,改改就能用了。其实导出方式大同小异。


// 省略部分代码,其中 _diaryInfos 为数据源 ,_filePath 为导出路径

public void ExportToJSONFile()
{
    var fileInfo = new FileInfo(_filePath);

    if (fileInfo.Extension == string.Empty)
    {
        _filePath = [email protected]"{_filePath}\ExportDiary.json";
    }

    // 来自 Newtonsoft.json 的函数
    string json = JsonConvert.SerializeObject(_diaryInfos, Formatting.Indented);

    // 不存在文件,新建并写入
    // 存在文件, 覆盖写入
    // 自动关闭文件句柄,防止占用。
    File.WriteAllText(_filePath, json, Encoding.UTF8);
}

桌面应用

这部分其实写起来还蛮有意思的,因为很久没写 WPF,我又忘记了怎么去写了。

这里我稍微用到了点 MVVM 的东西,然而为了偷懒,我真的就只用了一点点。因为导出时,要是没有提示就太难受了,所以就需要在导出的过程中不断的进行提示。

由于在没写 WPF 前,使用 diaryExport.ExportAllDiaryInfo() 得到所有的数据。这就与我上面的需求点矛盾了啊! 于是乎,委托又立了大功。步骤如下:

  1. 使用 WPF 提供的 MVVM 功能,将页面和提示(用一个数组就行)连接起来,数据变化,页面也变化。
  2. 更改 ExportAllDiaryInfo() 方法,使之变成ExportAllDiaryInfo(Action<string> UiTip), 这样就能改变数据,进一步改变页面显示。

选中导出目录的功能可以使用 Ookii.Dialogs.Wpf.NETCore(针对 .NET Core) 或者 Ookii.Dialogs.Wpf(针对 .NET Framework )。 要是我想同时生成这两种不同的程序要怎么办呢??? 有办法的,在项目的属性中进行修改。


<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">

  <PropertyGroup>
      <OutputType>WinExe</OutputType>

      // 这里需要由 TargetFramework 改成 TargetFrameworks

      <TargetFrameworks>netcoreapp3.1;net472;</TargetFrameworks>
    <UseWPF>true</UseWPF>

    </PropertyGroup>

    // 不能这样写,因为 Ookii.Dialogs.Wpf 不适用于 .NET Core
    // <ItemGroup Include="Ookii.Dialogs.Wpf" Version="1.1.0" />

    // 因此可以分别引入不同的包
    // 只要他们的 API 是相同的,那么代码里就无须做任何修改,即可兼容。
    <ItemGroup Condition=" '$(TargetFramework)' == 'net472' ">
        <PackageReference Include="Ookii.Dialogs.Wpf">
            <Version>1.1.0</Version>
        </PackageReference>
    </ItemGroup>

    <ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp3.1'">
      <PackageReference Include="Ookii.Dialogs.Wpf.NETCore">
        <Version>2.1.0</Version>
      </PackageReference>
    </ItemGroup>

    <ItemGroup>
    <ProjectReference Include="..\DiaryExport.Core\DiaryExport.Core.csproj" />
    <ProjectReference Include="..\DiaryExport.ExportDiaryToFile\DiaryExport.ExportDiaryToFile.csproj" />
  </ItemGroup>


</Project>

文章更新

先来看看旧版和新版的代码结构对比,再来分析一下新版的代码究竟做了怎么样的优化。

◭ 代码结构对比图

这次咱终于用上了 WPF 的 MVVM 模式 进行数据绑定,同时也用上了 WPF 中的 Command 命令进行特定功能的执行,从而将绝大部分的代码,给分离到 MainViewModel.cs 文件中。

对于简单的 MVVM 来说,最核心的几行代码如下:


// 如果有更复杂的需求,有一个非常强大的库叫做 prism,真的非常强。

// MainViewModel .cs

public class MainViewModel: INotifyPropertyChanged
{
    // OnPropertyChanged 方法执行会导致 PropertyChanged 事件触发,从而去通知 UI 进行更新
    public event PropertyChangedEventHandler PropertyChanged;

    private string _diaryDate;
    public string DiaryDate
    {
        get { return _diaryDate; }
        set
        {
            _diaryDate = value;
            OnPropertyChanged(nameof(DiaryDate));
        }
    }

    // 对于集合来说,需要使用 ObservableCollection
    private ObservableCollection<string> _exportDiaryStatus = new ObservableCollection<string> { };
    public ObservableCollection<string> ExportDiaryStatus
    {
        get { return _exportDiaryStatus; }
        set
        {
            _exportDiaryStatus = value;
            OnPropertyChanged(nameof(ExportDiaryStatus));
        }
    }

}


// MainWindow.xaml.cs

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        var mainViewModel = new MainViewModel();
        // 把 ViewModel 绑定到 DataContext 上
        this.MainNode.DataContext = mainViewModel;
    }
}

// MainWindow.xaml

// 取了个名 MainNode
<Window x:Class="DiaryExport.MainWindow" Name="MainNode">
</Window>

对于数据库,采用的是轻量级的 SQLite, 至于 ORM 当然选择的是官方出品的 Microsoft.EntityFrameworkCore. 虽然市面上也有很多优秀的 ORM 框架,性能更强,但架不住 官方出品的这个库上手快呀。

不过一般都是使用它的拓展包,这里使用的是 Microsoft.EntityFrameworkCore.Sqlite, 同时还得搭配对应的迁移工具 Microsoft.EntityFrameworkCore.Tools

对应数据库方面的操作都放在 DbServices.cs 文件中, 对于从接口获取数据的操作都放在 ExportDiaryServices.cs

(PS: 当时是想先建立接口,使用接口进行依赖注入,从而方便以后的维护。但其实吧,感觉不是很有必要,直接生成实例注入进去也行。)

另外移除了第三方的 Polly 库,自己手写了一个失败重新尝试的函数,其实就是一个递归,够用就行。因为如果发生了错误,那么 Flurl 是会抛出异常的,这里就是针对异常,进行捕获。再次然后再次去尝试。

public async Task<DiaryModel> GetDiaryPrevById(string id)
{
    try
    {
        var diaryModel = await new Flurl.Url(_baseUrl)
                                    .AppendPathSegment($"/diary/prev/{id}/")
                                    .WithHeader("auth", _loginModel.Token)
                                    .GetAsync()
                                    .ReceiveJson<DiaryModel>();
        if (diaryModel.Diary != null)
        {
            ExportDiaryStatusEvent?.Invoke($"获取上一篇日记成功:id: {diaryModel.Diary.Id}");

        }
        ResetTryCount();
        return diaryModel;
    }
    catch (Exception)
    {
        _currentTryCount++;
        ExportDiaryStatusEvent?.Invoke($"获取上一篇日记失败...正在 {_currentTryCount} 尝试");
        await Task.Delay(_currentTryCount * 1000);

        if (_currentTryCount < _maxTryCount)
        {
            return await GetDiaryPrevById(id);
        }
        ExportDiaryStatusEvent?.Invoke($"获取上一篇日记失败...终止请求");
        ResetTryCount();
        return default(DiaryModel);
    }

}

从上面的例子中还能瞥见异步编程和事件的身影。

async/await 关键字是 C# 异步编程的语法糖,它能够让我们写出很简单的异步代码,它是基于 TAP 模式的异步编程,即 Task-based Asynchronous Pattern。而之前我们多是采用基于事件的异步编程,即 Event-based Asynchronous Pattern。

在 C# 中 事件是由 event 关键字进行修饰的委托,而委托可以理解为一种函数指针,但更安全。执行委托时,其实是在执行某个函数,只不过执行的环境在这个作用域(可以获取到此作用域的信息,参数传入),同时又能够获得那个函数作用域的信息(需要提前放入),最终的结果能够影响那个函数本身的作用域里的数据。

最后一点,学到了一个 Newtonsoft.Json 的一个小技巧。只需要使用 JsonProperty 属性,就可以使把接口处获取到的数据,但其属性命名不规范,转化成规范的 CSharp 的属性命名方式。

public class DiaryInfo
    {
        [JsonProperty("deleteddate")]
        public string Deleteddate { get; set; }
        [JsonProperty("date_word")]
        public string DataWord { get; set; }
        [JsonProperty("createddate")]
        public DateTime Createddate { get; set; }
    }