在 C# 中使用基于 JSON 的实体缓存远程数据






4.21/5 (6投票s)
本文将详细介绍如何构建一个基于 JSON 的透明缓存实体框架,该框架具有分支重用功能,可智能地最大程度地减少服务器请求,并以访问 TMDb API 的用例场景为例。
引言
除了以一种干净的方式查询远程 JSON/REST 服务外,您通常还需要一种缓存和索引返回数据的方法。这对于网络服务尤为重要,因为其性质、连接服务的延迟和无状态性,更不用说请求限制,它们往往会返回“块状”数据——即大块数据。在这种情况下,基于 JSON 的系统通常会在单个查询中返回多个嵌套的数据级联。正确高效地处理这个问题存在一些复杂性,其中一些将在本文中讨论。
理解混乱
我们将逐步展示 REST 调用 URL 以及它们的漂亮打印结果。让我们来看看一个节目的数据。正如我所说的,它是块状的。
这是从 themoviedb.org 的 API 获取“火线警告”的一些电视节目信息
“调用:” https://api.themoviedb.org/3/tv/2919?api_key=c83a68923b7fe1d18733e8776bba59bb
{
"id": 2919,
"backdrop_path": "/lgTB0XOd4UFixecZgwWrsR69AxY.jpg",
"created_by": [
{
"id": 1233032,
"credit_id": "525749f819c29531db09b231",
"name": "Matt Nix",
"gender": 2,
"profile_path": "/qvfbD7kc7nU3RklhFZDx9owIyrY.jpg"
}
],
"episode_run_time": [
45
],
"first_air_date": "2007-06-28",
"genres": [
{
"id": 10759,
"name": "Action & Adventure"
},
{
"id": 18,
"name": "Drama"
}
],
"homepage": "http://usanetwork.com/burnnotice",
"in_production": false,
"languages": [
"en"
],
...
就这样继续。看看像 created_by
这样的节点——它们是子对象。再往下(此处省略),还有完整的季!(如果你点击我提供的链接,你将获得所有这些信息。)
这里要记住的是,您需要一种方法来存储这些数据并保留其层次结构。它*已经是 JSON*,因此如果您将其保持为某种 JSON 格式的表示形式,您的工作将进展顺利。
这看起来可能没那么糟糕,但是当它包含*重叠数据*时,这种块状性会变得更加困难。例如,我可能已经执行了查询以获得上述结果,然后我想获取 TMDb 中关于“Matt Nix”(created_by
字段中的人)的所有信息。好吧,我可以这样做,但我没有必要*必须*这样做,因为正如你所看到的,一些信息已经存在了。
{
"id": 1233032,
"credit_id": "525749f819c29531db09b231",
"name": "Matt Nix",
"gender": 2,
"profile_path": "/qvfbD7kc7nU3RklhFZDx9owIyrY.jpg"
}
这是相当多的信息。*也许*这就是我们所需要的一切,也许不是。如果我想要他的 IMDb ID 怎么办?如果我想要他的生日怎么办?我们必须为此去服务器。所以我们使用上面的 id
再发出一个请求。
...
"调用:" https://api.themoviedb.org/3/person/1233032?api_key=c83a68923b7fe1d18733e8776bba59bb
{
"id": 1233032,
"credit_id": "525749f819c29531db09b231",
"name": "Matt Nix",
"gender": 2,
"profile_path": "/qvfbD7kc7nU3RklhFZDx9owIyrY.jpg",
"birthday": "1971-09-04",
"known_for_department": "Writing",
"also_known_as": [],
"biography": "",
"popularity": 0.742,
"adult": false,
"imdb_id": "nm0633180"
}
这是相同的数据,只是内容*更多*。这是在线可查询 JSON 存储库的典型模式。这很棒,只是我们现在该如何处理它?嗯,我们显然希望将它与我们已在创建字段中拥有的数据合并,对吗?嗯,有点像,但不完全是。我们稍后会深入探讨。无论如何,结果是相同的,但我们会聪明地处理它。然而,从本质上讲,我们需要能够在我们这边存储和查询这些信息,这样我们就不必每次都回到服务器,*并且*我们需要一种智能地推迟访问服务器的方法,直到我们真正需要我们想要的数据。换句话说,如果我们已经缓存了一些数据,我们就不想再次访问服务器,但如果我们没有,我们需要透明地获取它并按需将其放入缓存。
显然,第二个问题是寻址,包括远程和本地。我们怎么知道使用上面的“id”来运行第二个查询?我们怎么知道确切地将信息存储在我们的本地存储库的哪个位置,以便以后可以快速访问?
所以基本上,我们必须面对存储和寻址这两个问题。我建议使用 JSON 本身来完成大部分繁重工作,这是一个简单的解决方案。
我将再次回顾我在这里发布的 TMDb 和 Json 代码库,链接在顶部提供。
编写这堆乱七八糟的代码
存储这一团糟
第一个提到的问题是存储,所以我们从这里开始。我们使用 IDictionary<string,object>
来保存我们的 JSON {}
对象。这样做的原因是为了我们有索引以进行更快的查找。同时,我们必须使用 object
作为我们的值,因为它们可以是映射到 JSON 的任意多种类型之一——一个用于 JSON []
数组的 IList<object>
,映射到 JSON 的数字类型,当然还有 string
、bool
和 null
。
我们使用随附的,野心勃勃地命名为“Json”库来将 JSON 文本转换为这种格式,然后再转换回来,并支持查询它。我们还将使用它来执行远程 RPC/REST 调用(在本例中,到 TMDb)。所有这些繁重的工作都集成在一个小小的软件包中。哇。如果您愿意,可以用 NewtonSoft 的产品或其他东西替换它,但要小心,因为我是围绕字典类编排的。如果第三方不使用这些,您的工作就会变得更加困难。
首先,我们需要一个单一的字典来根植我们所有的数据。我们所有的实体都将在这个字典的某个位置下方拥有字典。除非您想要一个混乱的实体构造函数和代码的复杂性,否则您会希望保持一些静态状态。因此,每个实体都需要知道如何找到根,我们使用的方法涉及保持静态状态。这不是线程安全的,所以我们使用 static ThreadLocal<IDictionary<string,object>>
来保存我们的数据。这意味着数据是基于每个线程的。这有优点,例如不需要锁定,并且增加了简单性;也有缺点,例如缓存未命中(我们稍后会讲到)以及在多线程应用程序中内存使用量增加,因为您必须为每个线程保留一个数据存储。前一个缓存未命中问题可以通过使用某种二级缓存机制来缓解,例如这个小小的 Json
库可以做到。后一个内存使用问题在 ASP.NET Web 服务器环境中得到缓解,因为页面运行时间短,并且持久连接倾向于在相同的线程上服务请求到请求,这意味着您通常可以访问来自先前请求的缓存,而不是在每次服务时创建新的缓存。
public static class Tmdb
{
const string _apiUrlBase = "https://api.themoviedb.org/3";
static ThreadLocal<IDictionary<string, object>>
_json=new ThreadLocal<IDictionary<string, object>>(()=>new JsonObject());
public static IDictionary<string,object> Json { get { return _json.Value; } }
public static string ApiKey { get; set; }
public static string Language { get; set; }
}
上面的 JsonObject
只是我们“Json”库提供的一个薄包装器,它包装了一个 Dictionary<string,object>
类。它没有什么特别之处,尽管它有一些特性,例如标准字典不具备的值语义。我们在这里不使用这些。如果你愿意,你可以把它变成一个 Dictionary<string,object>
。
你会注意到,除了 static ThreadLocal<IDictionary<string,object> _json
字段外,我们这里还有其他一些字段。原因是 TMDb 服务,像大多数服务一样,需要一个“API 密钥”。你必须在每次调用服务时提供它,所以你可以在应用程序级别设置它。它不会随用户而改变。Language
是 TMDb 全局接受的另一个参数,在这种情况下,它不会随用户而变化,尽管如果你正在构建一个多语言 Web 应用,你可能需要确保你的 _json
的结构也能适应这种按语言划分的情况。例如,我会通过在根下面放置更多的字典/JSON 对象来做到这一点,这样你就可以在存储/缓存的根部有一个语言,比如 "$.en-US.movie.219" 或者 "/en-US/movie/219"(所有内容都放在其 Iso 639.1 代码或其他下面)。这里不用担心。所提供的解决方案足够灵活,可以适应它,只是需要提前规划。
请注意,此对象上有一个 Json
属性,用于检索我们的根字典。我们所有的实体也将拥有。每个实体都指向其自身数据的 JSON。根类是静态的,并保存所有内容——所有其他仅作为字典存在的对象都存在于整个图/树中的某个位置。我希望这能说得通。
这使得寻址更简单。说到这里
处理这一团糟
任何时候你保存数据,都必须有一种方法能够再次获取它。文件有文件名和路径,关系型数据库有主键。我们有什么?我们有对象的索引器,因为它们都是字典和列表。
...
object o;
// get the "created_by" field from the show's JSON
if (showData.TryGetValue("created_by", out o))
{
// make sure it's a list.
var l = o as IList<object>;
if (null != l)
{
if(0 < l.Count)
{
// get the dictionary for the person
var personData = l[0] as IDictionary<string, object>;
if(null != personData)
{
// now get their name and write it
string name=null;
if (personData.TryGetValue("name", out o))
name = o as string;
Console.WriteLine(name);
}
}
}
}
这是利用索引字段高效遍历树的方法。然而,它对手指不友好。它需要一个包装器。再次,"Json" 库来救援。
// basically works like this should: showData["created_by"][0]["name"]
Console.WriteLine(JsonObject.Get(showData, "created_by", 0,"name") as string);
这将给你完全相同的东西。
或者你可以使用更熟悉的,但效率较低的 Select()
机制来运行 JSON 路径查询。
Console.WriteLine(JsonObject.Select(showData, "$.created_by[0].name").First() as string);
条条大路通“Matt Nix”(在这种情况下)
很好。使用上面某种形式的方法,我们可以指回我们的元素。我更喜欢第二种机制,使用 Get()
,因为最终,它实际上是最简单、最适合这种方式,而且效率很高。
现在,就像关系数据库中的一行有一个 ID,文件有它的路径和名称一样,我们需要一些东西来唯一标识我们的对象,以及一种让我们能够再次访问它们的方法——再次寻址,但我们需要以某种方式存储我们的地址。我们不能原样存储我们上面写的内容。
相反,我们最终会将我们的对象“身份”像这样作为路径段保存在 string[]
数组中,以便轻松传递给 Get()
,或者在 '.'
上使用 string.Join()
转换为 JSON 路径,或者使用 '/'
给出我们一个路径——我们将在 TMDb 中使用这个技巧,以便更容易地将本地数据与远程端点同步。
因为 TMDb 也使用路径来暴露其数据,所以我们将简单地在本地存储中使用几乎相同的路径。例如,电视剧“火线警告”(TMDb ID 2919)位于 /tv/2919,电影“回归”(TMDb 219)位于 /movie/219。
从虚拟上讲,从 TMDb 根目录到达名称“Matt Nix”的 JSON 路径(或者更确切地说,其中之一)是“$.tv.2919.created_by[0].name
”
而节目本身的根是
"<code>$.tv.2919</code>"
或者,使用我们的方法,new string[] { "tv","2919" }
JsonObject.Get(Tmdb.Json,"tv",2919");
我们将这个字符串数组称为 PathIdentity
,许多实体都会有一个,但我们稍后会深入探讨。请记住,上面那个是针对“火线警告”这个节目的。让我们继续。
我们将使用 CreatePath()
而不是 Get()
来获取我们的对象,因为如果我们没有它们,我们想要创建它们。因此在这种情况下,把它们放在一起——这就是实体在创建时基本上所做的事情。
this.Json = JsonObject.CreatePath(Tmdb.Json,this.PathIdentity);
以上代码不仅创建了对象以及任何通向它的对象,它还将自己的存储指针分配给 Tmdb.Json
中的一个节点,但我们稍后会讨论这个问题。目前重要的部分是 PathIdentity
在本地和远程定位我们的对象中都起着关键作用。
下面是远程数据库中该节目的实际 URL。
https://api.themoviedb.org/3/tv/2919?api_key=c83a68923b7fe1d18733e8776bba59bb
我已经包含了 API 密钥,以便您进行测试。所有 API 的根是 https://api.themoviedb.org/3。
使用 PathIdentity 定位对象
现在,当我们获取数据时,我们基本上会将其放置在 Tmdb.Json
下,其“地址”与该对象的 PathIdentity
所示相同。在本例中,对于“火线警告”而言,它是 /tv/2919
。
我们可以通过调用 JsonObject.CreatePath(Tmdb.Json,show.PathIdentity)
在根目录中“创建地址”,它还会返回我们刚刚创建的最内层节点。这基本上只是创建了路径 /tv/2919
("$.tv.2919"
),因为这是 show 从 PathIdentity
返回的,如上所述。如前所述,这还会返回我们位于 2919
的节点。请记住,此方法不是破坏性的——如果路径已经存在,它只会导航它——它不会破坏任何东西。
在内部,我们基本上只是创建相互嵌套的字典。
请记住,这个 PathIdentity
也是远程服务器上的地址。如果不是,我们必须为此拥有一个额外的属性,但这使得它超级简单。还记得上面“火线警告”(2919)的 URL 中包含这个路径标识吗?是的。你可以看到这会走向何方。有了这个 PathIdentity
,我们知道足够的信息来获取我们尚未拥有的数据。
此外,并非所有实体都具有 PathIdentity
。请记住,服务返回的是深度嵌套的数据,因此某些数据只能作为父查询的一部分提供。这些数据没有自己的路径。它不会从服务器自行获取。它本质上代表其父级的一部分。
将这些混乱整合在一起:JSON 支持的实体基类
首先,是最基本的类
// Represents a basic entity in the TmdbApi library
// This object uses a custom form of reference semantics
// for equality comparison - it's Json property is compared.
public abstract class TmdbEntity : IEquatable<TmdbEntity>
{
protected TmdbEntity(IDictionary<string, object> json)
{
Json = json ?? throw new ArgumentNullException(nameof(json));
}
public IDictionary<string, object> Json { get; protected set; }
protected T GetField<T>(string name,T @default=default(T))
{
object o;
if (Json.TryGetValue(name, out o) && o is T)
return (T)o;
return @default;
}
// objects are considered equal if they
// point to the same actual json reference
public bool Equals(TmdbEntity rhs)
{
if (ReferenceEquals(this, rhs))
return true;
if (ReferenceEquals(rhs, null))
return false;
return ReferenceEquals(Json, rhs.Json);
}
public override bool Equals(object obj)
{
return Equals(obj as TmdbEntity);
}
public static bool operator==(TmdbEntity lhs, TmdbEntity rhs)
{
if (object.ReferenceEquals(lhs, rhs)) return true;
if (object.ReferenceEquals(lhs, null)) return false;
return lhs.Equals(rhs);
}
public static bool operator!=(TmdbEntity lhs, TmdbEntity rhs)
{
if (object.ReferenceEquals(lhs, rhs)) return false;
if (object.ReferenceEquals(lhs, null)) return true;
return !lhs.Equals(rhs);
}
public override int GetHashCode()
{
var jo = Json as JsonObject; // should always be but it doesn't *have* to be
if(null!=jo)
{
// we don't want our wrapper's hashcode since
// JsonObject implements value semantics
// So get the "real" dictionary and
// GetHashCode() on that.
return jo.BaseDictionary.GetHashCode();
}
return Json.GetHashCode();
}
}
从上到下
我们首先看到的是构造函数,它接受一个 JSON 对象 (JsonObject
或 IDictionary<string,object>
)。
我们的类将使用这些信息来填充其字段。这本质上是类的初始 JSON 起始状态。有时,此类 JSON 将只包含一个字段——刚好足够的信息从服务器中提取其余部分。通常是
{ "id": 2919 }
或类似。上面指向“火线警告”的 ID,但需要服务器调用才能检索其他任何内容。
接下来是必需的 Json
属性。它只是持有/返回保存您状态的 JsonObject
。
第三件事是 GetField<T>()
,它接受一个名称和一个可选的默认值。它所做的本质上是尝试将值作为指定类型返回,如果不能,则返回指定的默认值。这只是派生类的一个辅助方法。它本身并不关键,但它在派生类中有一个“大哥”,GetCachedField<T>()
,它做更多的事情,因此结合使用 GetField<T>()
和 GetCachedField<T>()
只会使事情更加一致。
其余部分涉及实现我们的相等比较语义。基本上,我们希望如果两个对象的 Json
属性都引用同一个字典——同一个内存位置,则它们是相同的。这是一种一次性要求,因此在 .NET 中实现它有点奇怪——这*通常*是默认行为,但我们不希望我们的实体进行引用相等比较——我们希望我们持有的 Json
中的 JSON 对象成为仲裁者。这样,如果我们的对象都指向 Tmdb.Json
根目录下的同一个内存位置,它们就被认为是相等的。这是我们实现这一目标的一部分。另一个步骤上面已经提到,但我们还没有来得及探索。我们会。
GetHashCode()
中的怪异之处是必要的,因为我们不希望在此类中使用值语义,所以我们正在覆盖 JsonObject
的行为,但无法直接从类外部做到这一点。此外,对象不一定是 JsonObject
,它可以是任何字典,因此我们必须接受其中一个并进行检查。
public class TmdbImage : TmdbEntity
{
public TmdbImage(IDictionary<string,object> json) : base(json) {}
public int Width => GetField("width",0);
public int Height => GetField("height", 0);
public double AspectRatio => GetField("aspect_ratio", 0);
public string Path => GetField<string>("file_path");
public string Language => GetField<string>("iso_639_1");
public double VoteAverage => GetField("vote_average",0d);
public int VoteCount => GetField("vote_count", 0);
public TmdbImageType ImageType {
get {
switch(GetField<string>("image_type"))
{
case "poster":
return TmdbImageType.Poster;
case "backdrop":
return TmdbImageType.Backdrop;
case "logo":
return TmdbImageType.Logo;
}
return TmdbImageType.Unknown;
}
}
// only present for logo images
public string FileType => GetField<string>("file_type");
}
这支持一个 JSON 对象,其基本示例如下
(这个没有我能给你的 URL,因为它们只作为子查询的一部分返回)
{
"aspect_ratio": 0.666666666666667,
"file_path": "/lYqC8Amj4owX05xQg5Yo7uUHgah.jpg",
"height": 3000,
"iso_639_1": null,
"vote_average": 0,
"vote_count": 0,
"width": 2000
}
派生类中的代码足够规范,可以生成,或者可以使用属性和反射使其更自动化。
注意:你可能想知道我们为什么不使用 Expando 对象或其他自动包装工具。我考虑过,但你必须知道当字段不存在时如何按需加载——这本身可以通过将事件连接到底层字典类的访问器来解决,但你还必须知道如何从代表你的 JSON 中获取你自己的远程和本地地址,这并不容易,因为 JSON 没有模式信息。哪些字段是你的键?你如何从它们构建路径?你可以使用 JSON 模式来提供这些,但那样你就必须声明一个模式,这和声明一个包装器一样费力,而且让它实际做你想要的事情的代码要复杂得多。所有道路都通向编写整个 API,无论如何——或者至少是你需要的字段。无论你将“代码”编写为 JSON 模式,还是我们正在做的方式,这都是事实。以这种方式做是同时解决上述所有问题的最简单方法。无论如何,如果你想要,所有实体上的 Json
属性已经通过 C# 中的“dynamic
”支持 Expando 访问,因为 JsonObject
的工作方式。JSON 字段将成为对象上的访问器属性,正如你所期望的那样。
另一个选择是完全不使用实体,而只使用未加工的 JSON,但这会带来许多缺点,但也有一些引人注目的优点。即使您放弃实体,树/图的根植和路径身份概念仍然有用,但您必须想出另一种按需加载和寻址的机制,我们即将讨论。考虑派生类 TmdbCachedEntity
。
public abstract class TmdbCachedEntity : TmdbEntity
{
protected TmdbCachedEntity(IDictionary<string, object> json) : base(json)
{
}
public abstract string[] PathIdentity { get; }
// overload this in a derived class and when called, get your JSON from the remote source.
// if you don't do it, it will be done for you using PathIdentity
protected virtual void Fetch()
{
// in case you forget to override and the
// API doesn't accept a language argument
// all this does is send an extra parameter
FetchJsonLang();
}
// helper method to fetch remote data from a TMDb path and merge it with our data.
protected void FetchJson(string path = null,
Func<object, object> fixupResponse = null, Func<object, object> fixupError = null)
{
var json = Tmdb.Invoke(path ??
string.Join("/", PathIdentity), null, null, fixupResponse, fixupError);
JsonObject.CopyTo(json, Json);
}
// helper method to fetch remote data from a TMDb path and merge it with our data.
// sends the current language
protected void FetchJsonLang(string path = null,
Func<object, object> fixupResponse = null, Func<object, object> fixupError = null)
{
var json = Tmdb.InvokeLang(path ??
string.Join("/", PathIdentity), null, null, fixupResponse, fixupError);
JsonObject.CopyTo(json, Json);
}
// demand loads if a field is not present.
protected T GetCachedField<T>(string name, T @default = default(T))
{
object o;
if (Json.TryGetValue(name, out o) && o is T)
return (T)o;
Fetch();
if (Json.TryGetValue(name, out o) && o is T)
return (T)o;
return @default;
}
// Call this method in your entity's constructor to root it in
// the in memory cache. This is important.
protected void InitializeCache()
{
var path = PathIdentity;
if (null != path)
{
var json = JsonObject.CreatePath(Tmdb.Json, path);
JsonObject.CopyTo(Json, json);
Json = json;
} else
throw new Exception("Error in entity implementation. PathIdentity was not set.");
}
}
从上到下
首先,我们有一个构造函数重载,它将 JSON 数据传递给基类。在你的最派生构造函数中,你需要调用 InitializeCache()
(我们稍后会讲到),但这只能在 PathIdentity
创建之后进行。
这就引出了 PathIdentity
——我们必须在派生类中创建它,这样我们的对象才能定位自己。我们之前已经探讨过它。
接下来,我们有 Fetch()
,它告诉我们的派生类我们应该从服务器获取数据。通常,基类可以很好地处理这个问题,但您可能希望重载它。这就是为什么有 FetchXXXX()
辅助方法,我们即将介绍。
我们有 FetchJson()
,这是一个使用 PathIdentity
向远程端点发出 REST“RPC 调用”的辅助方法;我们还有 FetchJsonLang()
,它完全相同,只是它会同时发送 &language
参数和查询字符串。每个调用本身都委托给适当的 Tmdb.Invoke()
或 Tmdb.InvokeLang()
。这是因为有些调用接受语言参数,而另一些则不接受。即使不接受,您也可以安全地发送语言参数,但最好不要这样做。这两个例程都委托给 JsonRpc.Invoke()
,但会处理 TMDb 服务返回的连接速率限制和特殊错误。
接下来,我们有 GetCachedField<T>()
,它接受一个字段名和一个可选的默认值,并返回该字段。如果字段不存在,或者类型不正确(可能是本地存储被更改了?),它会通过调用 Fetch()
从服务器获取,然后再次尝试获取该值,如果获取不到任何内容,则最终返回 null。这是一种幼稚的处理方式,因为它可能导致当值始终不存在时进行永久性获取,但由于 JsonRpc.Invoke()
支持二级缓存,您可以直接使用它来缓解此问题——这本身不一定是一个大问题。这就是为什么没有更复杂的 null 处理方案(例如插入 DBNull
字段或其他)。无论如何,从最终用户的角度来看,这与 GetField<T>()
的工作方式相同,只是如果它必须获取数据,显然可能会有延迟。
最后,我们有 InitializeCache()
,它的作用是将我们的对象“根植”在 Tmdb.Json
下的某个位置。
它执行以下步骤
- 在 JSON 中创建或导航到指定的路径。这将根据需要创建,产生我们在路径中创建的最终节点。我们在此处传递
PathIdentity
,它将在Tmdb.Json
下的指定路径创建新的IDictionary<string,object>
/JsonObject
。 - 将我们当前持有的任何状态复制到我们刚刚创建的新节点或我们刚刚导航到的节点(来自 #1)。
- 用我们从步骤 #1 创建或导航到的“指针”(引用)替换我们自己的状态的指针。
最后一步很神奇,因为它不仅将我们植根于树中,而且允许我们回收分支,这样我们就不会重复(那么多)状态。更重要的是,通过这个过程,这些被回收的分支被合并在一起,因此无论您收到多少重叠数据中的逻辑副本,您始终只有一个地方可以获取任何缓存项的最完整状态。它就像 Linux 或 Windows 文件系统中的符号链接。另一种看待它的方式是,您将自己的状态“挂载”到根树中的某个位置。就像 POSIX 文件系统一样。另一种思考方式是,您正在将您的树变成一个图,因为一个节点可以有多个父节点。这是一个简单的技巧,但带来了巨大的好处。
让我们看看 TmdbCachedEntity
的一个精简派生类,它具有复杂的(多部分)键和执行辅助获取(除了主要获取方法外,还使用额外的获取方法来获取关联数据)的能力。
// represents a TV episode
public sealed class TmdbEpisode : TmdbCachedEntity
{
public TmdbEpisode(int showId, int seasonNumber,int episodeNumber) :
base(_CreateJson(showId, seasonNumber,episodeNumber))
{
InitializeCache();
}
public TmdbEpisode(IDictionary<string, object> json) : base(json)
{
InitializeCache();
}
static IDictionary<string, object> _CreateJson
(int showId, int seasonNumber, int episodeNumber)
{
var result = new JsonObject();
// add our "key fields" to the json
result.Add("show_id", showId);
result.Add("season_number", seasonNumber);
result.Add("episode_number", episodeNumber);
return result;
}
// our path needs to look like this:
// /tv/{show_id}/season/{season_number}/episode/{episode_number}
public override string[] PathIdentity
=> new string[] {
"tv",
GetField("show_id", -1).ToString(),
"season",
GetField("season_number", -1).ToString(),
"episode",
GetField("episode_number", -1).ToString(),
};
public TmdbShow Show {
get {
int showId = GetField("show_id", -1);
if (-1 < showId)
return new TmdbShow(showId);
return null;
}
}
public TmdbSeason Season {
get {
int showId = GetField("show_id", -1);
if (-1 < showId)
{
int seasonNum = GetField("season_number", -1);
if (-1 < seasonNum)
return new TmdbSeason(showId,seasonNum);
}
return null;
}
}
public int Number => GetField("episode_number", -1);
public string Name => GetCachedField<string>("name");
public DateTime AirDate => Tmdb.DateToDateTime(GetCachedField<string>("air_date"));
public TmdbCrewMember[] Crew
=> JsonArray.ToArray(
GetCachedField<IList<object>>("crew"),
(d)=>new TmdbCrewMember((IDictionary<string,object>)d));
public TmdbCastMember[] GuestStars
=> JsonArray.ToArray(
GetCachedField<IList<object>>("guest_stars"),
(d) => new TmdbCastMember((IDictionary<string, object>)d));
public string ImdbId {
get {
_EnsureFetchedExternalIds();
var d = GetField<IDictionary<string, object>>("external_ids");
if (null != d)
{
object o;
if (d.TryGetValue("imdb_id", out o))
return o as string;
}
return null;
}
}
public string TvdbId {
get {
_EnsureFetchedExternalIds();
var d = GetField<IDictionary<string, object>>("external_ids");
if (null != d)
{
object o;
if (d.TryGetValue("tvdb_id", out o))
return o as string;
}
return null;
}
}
// TODO: figure out what this means and make an enum possibly
public string ProductionCode => GetCachedField<string>("production_code");
public string StillPath => GetCachedField<string>("still_path");
public TmdbCastMember[] Cast {
get {
_EnsureFetchedCredits();
var credits = GetField("credits", (IDictionary<string, object>)null);
if (null != credits)
{
object o;
if (credits.TryGetValue("cast", out o))
{
var l = o as IList<object>;
return JsonArray.ToArray(l,
(d) => new TmdbCastMember((IDictionary<string, object>)d));
}
}
return null;
}
}
void _EnsureFetchedCredits()
{
var credits = GetField<IList<object>>("credits");
if (null != credits) return;
var json = Tmdb.Invoke(string.Concat
("/", string.Join("/", PathIdentity), "/credits"));
if (null != json)
Json["credits"] = json;
}
void _EnsureFetchedExternalIds()
{
var l = GetField<IList<object>>("external_ids");
if (null == l)
{
var json = Tmdb.InvokeLang(string.Concat
("/", string.Join("/", PathIdentity), "/external_ids"));
if (null != json)
Json.Add("external_ids", json);
}
}
...
}
好吧,即使稍微精简一下,也确实有很多东西需要理解。我们将从顶部开始,通常从上到下,但这次为了让事情更清楚,可能会有一些跳跃。
首先,我们有一个熟悉的构造函数,它从一些 JSON 数据初始化。一个值得注意的区别是它调用了 InitializeCache()
,我们上面已经讲过了。这会将对象根植在缓存中,对于所有直接缓存的对象来说,在构造函数中调用此方法非常重要。这会将我们的 Json 属性设置到正确的位置,并确保我们拥有所需的所有可用数据。
我们有一个接受几个整数、一个节目 ID、一个季数和一个剧集编号的第二个构造函数。
如果在关系型数据库中,这三个项将构成主键。在这种范式中,这些是此对象从远程存储中获取其余部分所需的最小信息量。
如果我们运行以下代码,初始 JSON 将如下所示
// burn notice pilot episode
var episode = new TmdbEpisode(2919, 1, 1);
Console.WriteLine(episode.Json);
{
"show_id": 2919,
"season_number": 1,
"episode_number": 1
}
这会生成一个 /tv/2919/season/1/episode/1 的 PathIdentity
用于最终请求 URL
https://api.themoviedb.org/3/tv/2919/season/1/episode/1?api_key=c83a68923b7fe1d18733e8776bba59bb
这是 FetchJson()
和 FetchJsonLang()
用来满足其对更多剧集数据请求的 URL——因为我们的路径身份就是它。Fetch()
将通过 GetCachedField<T>()
自动处理此问题。
请注意,对于构成路径身份的值(如 Number
),我们调用的是 GetField<T>()
而不是 GetCachedField<T>()
。您不能从远程源获取身份字段,因为您需要它们来完成获取,但这会导致堆栈溢出,所以不要这样做。始终对这些字段使用 GetField<T>()
。我们绝不会使用缓存版本来检索 show_id
、season_number
或 episode_number
。
关于命名的小注:Number
和 index 不同。播出顺序可能不同,并且季可以在索引零处有特别节目,但也可能没有,所以季索引可能与季号不匹配。因此,季也有一个 Number
属性。
请注意在 Show
和 Season
属性中,我们如何简单地创建相关类的实例并向其传递 ID。由于 InitializeCache()
的工作方式,节目和季对象可以在本地存储/缓存中定位自己,这意味着它们将立即访问其中已有的任何数据。在大多数情况下,当您检索剧集时,您已经检索了节目和季,因此这些值通常已被缓存,所以即使这些包装器只有 ID,它们在实例化后也可能被指向并与其余数据合并。在它们没有完整数据集的情况下,任何时候请求尚未获取的东西时,就会发生获取,它会获取其余部分,并自动将其复制回存储。
接下来的三个字段很无聊。它们只是直接包装底层 JSON,但请注意 GetField<T>()
与 GetCachedField<T>()
的使用方式;Number
是我们 PathIdentity
的一部分,所以我们绝不能尝试获取它。最后,AirDate
字段以“yyyy-MM-dd
”格式获取一个字符串,并使用辅助方法将其转换为 DateTime
。
接下来事情变得有趣起来。
public TmdbCrewMember[] Crew
=> JsonArray.ToArray(
GetCachedField<IList<object>>("crew"),
(d)=>new TmdbCrewMember((IDictionary<string,object>)d));
这会获取“crew”字段中的 JSON 数组,然后将其传递给 JsonArray.ToArray<T>()
,并提供两个参数。
第一个是我们刚刚从 GetCachedField<
...>("crew")
收到的 JSON 数组,第二个是既接受又返回 System.Object
的 lambda 表达式。它基本上接受一个数据对象,并根据需要从它创建 T 类型的对象。每个对象都来自 JSON 数组,因此它可能是一个字典、一个列表或一些标量 JSON 值。还记得我们的每个实体都接受一个 IDictionary<string,object>
构造函数参数吗?好吧,这里我们将 JSON 数组的每个元素传递给 TmdbCrewMember
的构造函数,它会创建一个该类型的实例以传递给目标数组元素。
下一个属性 GuestStars
做同样的事情,但对于来自“guest_stars”的 TmdbCastMember
。
现在我们来看 ImbdId
属性。
public string ImdbId {
get {
_EnsureFetchedExternalIds();
var d = GetField<IDictionary<string, object>>("external_ids");
if (null != d)
{
object o;
if (d.TryGetValue("imdb_id", out o))
return o as string;
}
return null;
}
}
首先要注意的是,它正在调用 _EnsureFetchedExternalIds()
,这是因为这些数据(如果尚未存在)必须通过单独调用 TMDb 来检索。
{
"id": 223655,
"imdb_id": null,
"freebase_mid": "/m/02vxx4g",
"freebase_id": null,
"tvdb_id": 330913,
"tvrage_id": 574476
}
结果随后存储在剧集 JSON 的“external_ids
”字段中。
这是通过以下例程实现的
void _EnsureFetchedExternalIds()
{
var l = GetField<IList<object>>("external_ids");
if (null == l)
{
var json = Tmdb.InvokeLang(string.Concat
("/", string.Join("/", PathIdentity), "/external_ids"));
if (null != json)
Json.Add("external_ids", json);
}
}
它只是返回该字段,否则从 URL 获取它。注意它对我们的 PathIdentity
做了什么:它在前面添加了“/
”,然后用“/
”连接,然后添加“external_ids
”作为后缀。然后它委托给 Tmdb.Invoke()
进行调用,并将结果存储在 json 的“external_ids
”字段下。请注意,这使得我们本地存储的虚拟路径与远程服务器的实际路径相同。所以,我们再次利用了 TMDb API 寻址的简洁性。
无论如何,在确保数据已获取后,ImdbId
会导航 JSON(我在这里手动完成,因为这是较旧的代码)并返回 imdb_id
字段的结果。
TvdbId
属性和相应的 tvdb_id
字段也发生了同样的事情。
接下来的属性是 ProductionCode
和 StillPath
,它们分别获取 production_code
和 still_path
字段。
现在我们来看 Cast
——我们的一个返回数组的属性,但这个属性也使用单独的查询来获取其数据。
public TmdbCastMember[] Cast {
get {
_EnsureFetchedCredits();
var credits = GetField("credits", (IDictionary<string, object>)null);
if (null != credits)
{
object o;
if (credits.TryGetValue("cast", out o))
{
var l = o as IList<object>;
return JsonArray.ToArray(l,
(d) => new TmdbCastMember((IDictionary<string, object>)d));
}
}
return null;
}
}
单独的查询由一个单独的例程处理,其 URL 如下:
这为我们提供了这些数据
{
"cast": [
{
"character": "Madeline Westen",
"credit_id": "525749f519c29531db09b018",
"gender": 1,
"id": 73177,
"name": "Sharon Gless",
"order": 2,
"profile_path": "/ul7dTg6MxIU72inhxXiMWEJH8MP.jpg"
},
{
"character": "Michael Westen",
"credit_id": "525749f519c29531db09b04c",
"gender": 2,
"id": 52886,
"name": "Jeffrey Donovan",
"order": 0,
"profile_path": "/5i47zZDpnAjLBtQdlqhg5AIYCuT.jpg"
},
{
"character": "Fiona Glenanne",
"credit_id": "525749f519c29531db09afe4",
"gender": 1,
"id": 5503,
"name": "Gabrielle Anwar",
"order": 1,
"profile_path": "/khnEDczzSy6UcbnqZ6Sb4lWxnkE.jpg"
},
{
"character": "Sam Axe",
"credit_id": "525749f519c29531db09b080",
"gender": 2,
"id": 11357,
"name": "Bruce Campbell",
"order": 3,
"profile_path": "/hZ2fW0gpPIBvXxT5suJzaPZQCz.jpg"
}
],
"crew": [
{
"id": 20833,
"credit_id": "525749d019c29531db098a72",
"name": "Jace Alexander",
"department": "Directing",
"job": "Director",
"profile_path": "/nkmQTpXAvsDjA9rt0hxtr1VnByF.jpg"
},
{
"id": 1233032,
"credit_id": "525749d019c29531db098a46",
"name": "Matt Nix",
"department": "Writing",
"job": "Writer",
"profile_path": null
}
],
"guest_stars": [
{
"id": 6719,
"name": "Ray Wise",
"credit_id": "525749cc19c29531db098912",
"character": "",
"order": 0,
"profile_path": "/z1EXC8gYfFddC010e9YK5kI5NKC.jpg"
},
{
"id": 92866,
"name": "China Chow",
"credit_id": "525749cc19c29531db098942",
"character": "",
"order": 1,
"profile_path": "/kUsfftCYQ7PoFL74wUNwwhPgxYK.jpg"
},
{
"id": 17194,
"name": "Chance Kelly",
"credit_id": "525749cc19c29531db09896c",
"character": "",
"order": 2,
"profile_path": "/hUfIviyweiBZk4JKoCIKyuo6HGH.jpg"
},
{
"id": 95796,
"name": "Dan Martin",
"credit_id": "525749cd19c29531db098996",
"character": "",
"order": 3,
"profile_path": "/u24mFuqwEE7kguXK32SS1UzIQzJ.jpg"
},
{
"id": 173269,
"name": "Dimitri Diatchenko",
"credit_id": "525749cd19c29531db0989c0",
"character": "",
"order": 4,
"profile_path": "/vPScVMpccnmNQSsvYhdwGcReblD.jpg"
},
{
"id": 22821,
"name": "David Zayas",
"credit_id": "525749cd19c29531db0989ea",
"character": "",
"order": 5,
"profile_path": "/eglTZ63x2lu9I2LiDmeyPxhgwc8.jpg"
},
{
"id": 1233031,
"name": "Nick Simmons",
"credit_id": "525749cf19c29531db098a17",
"character": "",
"order": 6,
"profile_path": "/xsc2u2QQA6Nu7SvUYUPKFlGl9fw.jpg"
}
],
"id": 223655
}
如您所见,它包含一个 crew
字段和一个 cast
字段。我们将整个内容存储在 credits
下,这再次使我们的层次结构与远程存储库的层次结构同步,因为我们的路径匹配。正如我上面提到的,如果您的远程存储无法以这种方式镜像,您将不得不为您的实体拥有远程和本地身份。我们在这里可以通过利用 TMDb API 的布局来避免这种情况——镜像它很容易,我们正在部分地这样做,但我们必须确保我们项目在本地和远程的路径始终匹配。
无论如何,这是例程,它几乎完全像我们遇到的上一个一样工作,只是稍微简单一些。
void _EnsureFetchedCredits()
{
var credits = GetField<IList<object>>("credits");
if (null != credits) return;
var json = Tmdb.Invoke(string.Concat("/", string.Join("/", PathIdentity), "/credits"));
if (null != json)
Json["credits"] = json;
}
理解 JSON 布局
实际上,我们一直在按需镜像远程存储库中的地址,如果数据尚不存在则按需获取。然而,可能不太清楚的是,我们也在回收分支。
也就是说,您可以通过查询 JSON 数据从多种方式找到“Matt Nix”,但我们通过将所有分支都指向其在 /person/1233032
的“person”节点,最大限度地减少了重复分支的数量——这是因为缓存实体在 InitializeCache()
中修复其缓存的方式,一旦找到一个节点,它就会被重用。这使得我们的数据存储和检索效率更高,因为我们同时最大化了缓存命中率并最小化了重复。
因此,我们的树不再是一棵树,它是一个*图*,因为一个节点可以有多个父节点,这与树不同。
您必须非常小心如何执行此操作,否则您将创建无休止的递归图,这将导致 JSON 序列化失败。幸运的是,由于我们使用路径并且仅在缓存实体中进行分支回收,这种情况应该永远不会发生。另请注意,您无法通过序列化 JSON 来“看到”分支回收。当 JSON 被写入时,任何回收的分支都将在每个位置被写入。这不是最好的,也不是我们真正想要的,但这并不是一个阻碍。
上面可能已经足够让你自己处理实体部分了。虽然,玩玩代码并修修补补确实有帮助。
关注点
在整个代码中,我们一直在调用 Tmdb.Invoke()
的各种变体来处理发送实际的 JSON/REST 调用。它的作用是处理将 API 密钥附加到查询字符串,并且其 Lang
变体也会将语言附加到查询字符串。这些函数及其变体处理查询 API 的各个方面,例如带有页面参数的调用,以及当我们超出限制时处理请求节流。它们都调用 _Invoke()
,后者使用 JsonRpc.Invoke()
建立实际调用。
static object _Invoke(string path,
bool sendLang,
IDictionary<string, object> args,
IDictionary<string, object> payload,
Func<object, object> fixupResult,
Func<object, object> fixupError,
string httpMethod)
{
var url = _apiUrlBase;
if (!path.StartsWith("/"))
url = string.Concat(url, "/");
url = string.Concat(url, path);
if (null == args)
args = new JsonObject();
args["api_key"] = ApiKey;
if (sendLang && !string.IsNullOrEmpty(Language))
args["language"] = Language;
object result = null;
var retryCount = 0;
while (null == result)
{
++retryCount;
try
{
var s = JsonRpc.GetInvocationUrl(url, args);
System.Diagnostics.Debug.WriteLine("Requesting from " + s);
result = JsonRpc.Invoke(s, null/*we already computed the url*/,
payload, null, httpMethod, fixupResult, fixupError, Tmdb.CacheLevel);
if (null == result)
break;
}
catch (JsonRpcException rex)
{
if (retryCount > 11)
{
rex.Json.Add("retry_count_exceeded:", retryCount - 1);
throw;
}
// are we over the request limit?
if (25 == rex.ErrorCode)
{
System.Diagnostics.Debug.WriteLine(rex.Message + ".. throttling " + url);
// wait and try again
Thread.Sleep(RequestThrottleDelay);
}
else if (-39 == rex.ErrorCode)
continue;//malformed or empty json, try again
else
throw;
}
}
return result;
}
这就是网络不可靠的本质,我们不得不处理无效响应和节流等问题,所以上面的这个例程为我们处理了所有这些。有时,在最坏的情况下,这意味着严重的延迟,但有了多级缓存,这并不是一个大问题。
在 Web 服务器环境中使用这种方法
这很有趣:显然,Keep-Alive 连接可能由相同的线程请求服务。这并非总是如此,但由于我们的每个线程 Tmdb.Json
实例的工作方式,这足以对我们有利。本质上,我们被允许将其作为(不可靠的)连接状态。这被称为“险恶的黑客”,但它实际上只是一种偶然的优化。这意味着在页面之间,我们将继续为由该 API 支持的单个用户会话获得缓存命中。这非常好,因为它意味着我们创建 JSON 的实例更少以满足用户的请求,并且我们对远程存储库的访问更少。如果失败,我们将严重依赖二级“按 URL”缓存。在 Web 服务器环境中,最好将 Tmdb.CacheLevel
设置为 JsonRpcCacheLevel.Aggressive
以使其生效。
历史
- 2019年9月7日,星期六 - 初次提交