0%

Entity Framework Core (EF Core) 是一個提供了實體、關係映射的架構,通過它們,可以創建映射到資料庫的類型,使用 LINQ 創建資料庫查詢、新增和更新物件,把它們寫入資料庫。

Entity Framework 經過多年的改變,Entity Framework Core (EF Core) 已完全重寫了。不僅可以在 Windows 上使用,也可以在 Linux 和 Mac 上使用。他支持關聯式資料庫和 NoSQL 數據存儲。

這裡要使用 EF Core 建立一個簡單的模型讀寫來自 Oracle 資料庫 DEPT 與 EMP 資料表的資料,所以這裡會有 Dept 與 Emp 兩個類型映射到 Oracle 資料庫的這兩個資料表。把資料寫入資料庫,讀取、更新和刪除它們。

我使用 Visual Studio Code 開發,所以你的機器必須安裝 .NET Core SDK。你如果要使用 EF Core 的反向工程,則還必須安裝Entity Framework Core 工具參考 - .NET CLI

反向工程可以幫你把資料庫的資料表(Table)產生 C# 的類型與映射,開始先不談反向工程,以免失焦及複雜化,後面再來補充說明。

開啟 VS Code 建立一個專案。

$ > dotnet --version
3.1.201

$ > dotnet new console --name OracleEFCoreSample
The template "Console Application" was created successfully.

Processing post-creation actions...
Running 'dotnet restore' on OracleEFCoreSample\OracleEFCoreSample.csproj...
I:\u01\projects\OracleEFCoreSample\OracleEFCoreSample.csproj 的還原於 238.4 ms 完成。

Restore succeeded.

$ > cd OracleEFCoreSample

$ OracleEFCoreSample> dotnet run
Hello World!

這就開始了,直接使用 VS Code 打開 OracleEFCoreSample 專案目錄,首先要安裝專案需要的 NuGet Packages,可以到 NuGet Gallery 尋找,注意不要裝錯版本。

$ OracleEFCoreSample> dotnet add package Oracle.EntityFrameworkCore --version 2.19.70

$ OracleEFCoreSample> dotnet add package Microsoft.EntityFrameworkCore.Design --version 2.2.6

$ OracleEFCoreSample> dotnet add package Microsoft.EntityFrameworkCore.Relational --version 2.2.6

$ OracleEFCoreSample> dotnet add package Microsoft.Extensions.Configuration.Json --version 3.1.4

現在專案目錄下的 OracleEFCoreSample.csproj 檔案應該如下:

OracleEFCoreSample.csproj
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="2.2.6" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.4" />
<PackageReference Include="Oracle.EntityFrameworkCore" Version="2.19.70" />
</ItemGroup>

</Project>

一切就緒,首先在專案目錄下開一個 JSON 檔案 appsettings.json, 將專案程式的設定放在此,目前要放的是 Oracle 資料庫的 ConnectionString。

appsettings.json
1
2
3
4
5
6
7
{
"Data": {
"DefaultConnection": {
"ConnectionString": "User Id=xxxx;Password=xxxxxxxx;Data Source=10.11.xx.xxx:1522/xxxx.xxx.com.tw;Connection Timeout=600;min pool size=0;connection lifetime=18000;PERSIST SECURITY INFO=True;"
}
}
}

創建模型

這裡要定義兩個實體類型 Dept 與 Emp,分別映射到 Oracle 資料庫 DEPT 與 EMP 資料表。

在專案目錄下建立一個子目錄 Models 來擺放 Dept.cs 與 Emp.cs。

Models/Dept.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System;
using System.Collections.Generic;

namespace OracleEFCoreSample.Models
{
public partial class Dept
{
public Dept()
{
Emp = new HashSet<Emp>();
}

public int Deptno { get; set; }
public string Dname { get; set; }
public string Loc { get; set; }

public virtual ICollection<Emp> Emp { get; set; }
}
}

在 Oracle 資料庫中,DEPT 與 EMP 有 FOREIGN KEY Constraint 的關係,在這個類型中也可以表現出這種 Master-Detail 關係,如果無此需求,可以省略。其實我們只需要第 13、14 與 15 行三個 Deptno、Dname 與 Loc 屬性就可以。

這裡這個 Dept 類型除了三個基本屬性外,Emp 可以把屬於這個部門的員工放進來。

Models/Emp.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System;
using System.Collections.Generic;

namespace OracleEFCoreSample.Models
{
public partial class Emp
{
public Emp()
{
InverseMgrNavigation = new HashSet<Emp>();
}

public int Empno { get; set; }
public string Ename { get; set; }
public string Job { get; set; }
public int? Mgr { get; set; }
public DateTime? Hiredate { get; set; }
public double? Sal { get; set; }
public double? Comm { get; set; }
public int? Deptno { get; set; }

public virtual Dept DeptnoNavigation { get; set; }
public virtual Emp MgrNavigation { get; set; }
public virtual ICollection<Emp> InverseMgrNavigation { get; set; }
}
}

EMP 資料表有兩個 FOREIGN KEY Constraint,一個是 Deptno 參照到 DEPT 的 Deptno,另外一個是 Mgr 參照到自己的 Empno。一樣你如果不需要這些關聯,可以只要 13 ~ 20 行的 8 個屬性就可以。型別後面的 ? 號,表示這是個 Nullable value types。

除了 8 個基本屬性外, DeptnoNavigation 可以把部門資料放進來,MgrNavigation 可以把主管資料放進來,而如果是主管,可以把部屬資料放入 InverseMgrNavigation。

這兩個型別只是單純的 C# 類別型態定義,與 Oracle 資料庫資料表還看不出映射關係,EF Code 使用了三個概念來定義模型 : 約定、注釋和流利 API(Fluent API)。

按照約定,有些事情會自動發生。例如,用 Id 前綴命名 int 或 Guid 類型的屬性,會將該屬性映射到資料庫的主鍵。例如 EmpId 屬性會自動映射到資料表的主鍵 EmpId。但我們的 Oracle 資料表主鍵通常不會遵守這種規則,所以這裡要用另外一種映射到資料表的約定 : 使用上下文的屬性名 (Fluent API)。

創建上下文 (Context)

這裡要創建另一個 DemoContext 類,透過這個類實現了與 Oracle 資料庫表 DEPT 與 EMP 的關係。這個類派生自基類 DbContext。DemoContext 類定義了 DbSet 與 DbSet 的 Dept 與 Emp 屬性。

透過 DbContext 派生類的 OnModelCreating 方法使用流利 API。Entity 方法返回一個 EntityTypeBuilder,允許自定義實體,把它映射到特定的資料庫表名(Table)、定義鍵(Key)和索引(Indexes)。

EntityTypeBuilder 定義了一個 Property 方法來配置屬性。Property 方法返回一個 PropertyBuilder,它允許用最大長度、需要的設置和 SQL 類型配置屬性。

要定義一對多映射,EntityTypeBuilder 定義了映射方法。方法 HasMany 與 WithOne 結合,HasMany 需要與 WithOne 鏈接起來。

方法 HasOne 需要和 WithMany 或 WithOne 鏈接起來。鏈接 HasOne 與 WithMany,會定義一對多關聯;鏈接 HasOne 與 WithOne,定義一對一關係。

Models/DemoContext.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
using System;
using System.IO;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;

namespace OracleEFCoreSample.Models
{
public partial class DemoContext : DbContext
{
public virtual DbSet<Dept> Dept { get; set; }
public virtual DbSet<Emp> Emp { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseOracle(GetConnectionString(), options =>
options.UseOracleSQLCompatibility("11")
);
}
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasAnnotation("ProductVersion", "2.2.6-servicing-10079")
.HasAnnotation("Relational:DefaultSchema", "DEMO");

modelBuilder.Entity<Dept>(entity =>
{
entity.HasKey(e => e.Deptno)
.HasName("SYS_C0036617");

entity.ToTable("DEPT");

entity.HasIndex(e => e.Deptno)
.HasName("SYS_C0036617")
.IsUnique();

entity.Property(e => e.Deptno).HasColumnName("DEPTNO");

entity.Property(e => e.Dname)
.HasColumnName("DNAME")
.HasColumnType("VARCHAR2")
.HasMaxLength(14);

entity.Property(e => e.Loc)
.HasColumnName("LOC")
.HasColumnType("VARCHAR2")
.HasMaxLength(13);
});

modelBuilder.Entity<Emp>(entity =>
{
entity.HasKey(e => e.Empno)
.HasName("EMP_PK");

entity.ToTable("EMP");

entity.HasIndex(e => e.Empno)
.HasName("EMP_PK")
.IsUnique();

entity.HasIndex(e => e.Ename)
.HasName("EMP_IDX1");

entity.Property(e => e.Empno).HasColumnName("EMPNO");

entity.Property(e => e.Comm)
.HasColumnName("COMM")
.HasColumnType("FLOAT");

entity.Property(e => e.Deptno)
.HasColumnName("DEPTNO");

entity.Property(e => e.Ename)
.HasColumnName("ENAME")
.HasColumnType("VARCHAR2")
.HasMaxLength(10);

entity.Property(e => e.Hiredate)
.HasColumnName("HIREDATE")
.HasColumnType("DATE");

entity.Property(e => e.Job)
.HasColumnName("JOB")
.HasColumnType("VARCHAR2")
.HasMaxLength(9);

entity.Property(e => e.Mgr).HasColumnName("MGR");

entity.Property(e => e.Sal)
.HasColumnName("SAL")
.HasColumnType("FLOAT");

entity.HasOne(d => d.DeptnoNavigation)
.WithMany(p => p.Emp)
.HasForeignKey(d => d.Deptno)
.HasConstraintName("SYS_C0036621");

entity.HasOne(d => d.MgrNavigation)
.WithMany(p => p.InverseMgrNavigation)
.HasForeignKey(d => d.Mgr)
.HasConstraintName("SYS_C0036620");
});
}

private static string GetConnectionString()
{
var configurationBuilder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json");
IConfiguration config = configurationBuilder.Build();
string connectionString = config["Data:DefaultConnection:ConnectionString"];
return connectionString;
}
}
}

DemoContext 是資料庫映射的關鍵,聰明的你就對照著資料庫慢慢斟酌,GetConnectionString 方法則會從 appsettings.json 抓取 Oracle 資料庫的 ConnectionString。 這會用在第 19 行。

同時要注意第 20 行,因為我們的資料庫大部分還是 11g,所以要在 options 中加上 UseOracleSQLCompatibility(“11”),否則會有很多地方會掛掉。 建議趕快升級到 12c(以上)。

讀取資料庫

可以讀取資料庫了。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System;
using System.Linq;
using OracleEFCoreSample.Models;

namespace OracleEFCoreSample
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello Tainan! Hi 台南");

using (var db = new DemoContext())
{
Console.WriteLine("===== Departments ==========");
foreach (var dept in db.Dept)
{
Console.WriteLine($"{dept.Deptno} {dept.Dname} {dept.Loc}");
}

Console.WriteLine("===== Employees ==========");
foreach (var emp in db.Emp)
{
Console.WriteLine($"{emp.Empno} {emp.Ename} {emp.Job} {emp.Mgr} {emp.Hiredate?.ToString("yyyy-MM-ddTHH:mm:ss")} {emp.Sal} {emp.Comm} {emp.Deptno}");
}
}
}
}
}
$ OracleEFCoreSample> dotnet run
Hello Tainan! Hi 台南
===== Departments ==========
10 會計部 紐約
20 RESEARCH DALLAS
30 SALES 台南
40 OPERATIONS BOSTON
70 資訊部 台南 B4
60 開發部 台南
===== Employees ==========
7839 KING PRESIDENT 1981-11-17T00:00:00 5000 10
7698 BLAKE MANAGER1 7839 1981-05-01T00:00:00 2850 101 30
7782 陳瑞 MANAGER 7902 1981-06-09T00:00:00 2400 10
7566 陳賜珉 MANAGER 7839 1981-04-02T00:00:00 2975 20
7788 SCOTT ANALYST 7566 1982-12-09T00:00:00 45300 20
7902 FORD ANALYST 7566 1981-12-03T00:00:00 3000 20
7369 SMITH CLERK 7902 1980-12-17T00:00:00 8001 20
7499 ALLEN SALESMAN 7698 1981-02-20T00:00:00 1600 303 30
7608 馬小九 ANALYST 7788 2010-06-28T00:00:00 1000 100 40
7654 葉習? SALESMAN 7698 1981-09-28T00:00:00 1250 1400 30
7844 ??? SALESMAN 7698 1981-09-08T00:00:00 1500 30
7876 ADAMS CLERK 7788 1983-01-12T00:00:00 1100 20
7900 JAMES CLERK 7698 1981-12-03T00:00:00 94998 30
7934 楊? CLERK 7902 1982-01-23T00:00:00 1500 10
9006 李逸君 ANALYST 7788 2001-05-07T00:00:00 66666 70
7607 ??? 分析師 7788 2008-03-24T00:00:00 45000 100 70
7609 蔡大一 分析師 7788 2010-06-28T00:00:00 60000 70
9011 文英蔡 總鋪師 7788 2018-08-28T00:00:00 77778 180 40
8907 牸? ANALYST 7566 1982-12-09T00:00:00 9002 10

模型建立好,讀取資料就這麼簡單,加入 LINQ 看看。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
using (var db = new DemoContext())
{
var employees = db.Emp
.Where(e => e.Deptno == 10 || e.Deptno == 30)
.OrderBy(e => e.Deptno)
.ThenBy(e => e.Empno);

foreach (var e in employees)
{
Console.WriteLine($"{e.Empno} {e.Ename} {e.Job} {e.Mgr} {e.Hiredate?.ToString("yyyy-MM-ddTHH:mm:ss")} {e.Sal} {e.Comm} {e.Deptno}");
}
}

現在將 Master-Detail 資料關聯起來,DEPT 對 EMP 是一對多的關係。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using (var db = new DemoContext())
{
var employees = db.Emp.ToList();

foreach (var dept in db.Dept)
{
dept.Emp = employees.Where(d => d.Deptno == dept.Deptno).ToList();
}

var department = db.Dept.Where(d => d.Deptno == 20).FirstOrDefault();
Console.WriteLine($"=== {department.Deptno} {department.Dname} {department.Loc} ===");

foreach (var e in department.Emp)
{
Console.WriteLine($"{e.Empno} {e.Ename} {e.Job} {e.Mgr} {e.Hiredate?.ToString("yyyy-MM-ddTHH:mm:ss")} {e.Sal} {e.Comm} {e.Deptno}");
}
}
$ OracleEFCoreSample> dotnet run
Hello Tainan! Hi 台南
=== 20 RESEARCH DALLAS ===
7566 陳賜珉 MANAGER 7839 1981-04-02T00:00:00 2975 20
7788 SCOTT ANALYST 7566 1982-12-09T00:00:00 45300 20
7902 FORD ANALYST 7566 1981-12-03T00:00:00 3000 20
7369 SMITH CLERK 7902 1980-12-17T00:00:00 8001 20
7876 ADAMS CLERK 7788 1983-01-12T00:00:00 1100 20

主管與部屬關係。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using (var db = new DemoContext())
{
var employees = db.Emp.ToList();

var managers = employees.GroupBy(e => e.Mgr)
.Select(grp => grp.FirstOrDefault())
.Select(e => new { Manager = e.Mgr ?? e.Empno });

Emp manager = null;
foreach (var m in managers)
{
manager = employees.Where(e => e.Empno == m.Manager).FirstOrDefault();
manager.InverseMgrNavigation = employees.Where(e => e.Mgr == manager.Empno).ToList();
}

manager = employees.Where(e => e.Empno == 7788).FirstOrDefault();
Console.WriteLine($"=== {manager.Empno} {manager.Ename} ===");
foreach (var e in manager.InverseMgrNavigation)
{
Console.WriteLine($"{e.Empno} {e.Ename} {e.Job} {e.Mgr} {e.Hiredate?.ToString("yyyy-MM-ddTHH:mm:ss")} {e.Sal} {e.Comm} {e.Deptno}");
}
}
$ OracleEFCoreSample> dotnet run
Hello Tainan! Hi 台南
=== 7788 SCOTT ===
7608 馬小九 ANALYST 7788 2010-06-28T00:00:00 1000 100 40
7876 ADAMS CLERK 7788 1983-01-12T00:00:00 1100 20
9006 李逸君 ANALYST 7788 2001-05-07T00:00:00 66666 70
7607 ??? 分析師 7788 2008-03-24T00:00:00 45000 100 70
7609 蔡大一 分析師 7788 2010-06-28T00:00:00 60000 70
9011 文英蔡 總鋪師 7788 2018-08-28T00:00:00 77778 180 40

新增紀錄

Add 方法把 Emp 物件添加到 DemoContext 上下文中,還沒有寫入資料庫中,SaveChanges 方法把 Emp 物件寫入資料庫。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using (var db = new DemoContext())
{
db.Emp.Add(new Emp
{
Empno = 9588,
Ename = "C#範例",
Job = "ANALYST",
Mgr = 7566,
Hiredate = DateTime.Now,
Sal = 23000,
Comm = 100,
Deptno = 40
}
);
int records = db.SaveChanges();
Console.WriteLine($"{records} records saved to database.");
}

還可以用 AddRange 一次寫入多個物件。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using (var db = new DemoContext())
{
db.Emp.AddRange(
new Emp
{
Empno = 9681,
Ename = "C#範例2",
Job = "ANALYST",
Mgr = 7566,
Hiredate = DateTime.Now,
Sal = 13000,
Comm = 130,
Deptno = 10
},
new Emp
{
Empno = 9682,
Ename = "C#範例3",
Job = "ANALYST",
Mgr = 7566,
Hiredate = DateTime.Now,
Sal = 33000,
Comm = 330,
Deptno = 20
}
);
int records = db.SaveChanges();
Console.WriteLine($"{records} records saved to database.");
}

更新紀錄

Program.cs
1
2
3
4
5
6
7
8
9
10
11
using (var db = new DemoContext())
{
int records = 0;
var employee = db.Emp.Where(e => e.Empno == 9588).FirstOrDefault();
if (employee != null)
{
employee.Comm = 500;
records = db.SaveChanges();
}
Console.WriteLine($"{records} records updated.");
}

刪除紀錄

Program.cs
1
2
3
4
5
6
7
8
9
10
11
using (var db = new DemoContext())
{
int records = 0;
var employee = db.Emp.Where(e => e.Empno == 9588).FirstOrDefault();
if (employee != null)
{
db.Emp.Remove(employee);
records = db.SaveChanges();
}
Console.WriteLine($"{records} records deleted from database.");
}

可以用 RemoveRange 刪除多筆資料。

Program.cs
1
2
3
4
5
6
7
using (var db = new DemoContext())
{
var employees = db.Emp.Where(e => e.Empno == 9681 || e.Empno == 9682);
db.Emp.RemoveRange(employees);
int records = db.SaveChanges();
Console.WriteLine($"{records} records deleted from database.");
}

物件關係映射工具,如 EF Core,並不適用於所有場景。例如一次刪除很多資料或所有的資料就沒有那麼高效能,使用單個 SQL 語句可以刪除所有資料,會比每一個紀錄使用一個 DELETE 語句效能高得多。

反向工程 (Reverse Engineering)

在反向工程之前,您必須安裝 CLI 工具。

$ > dotnet tool install --global dotnet-ef

然後在你的專案目錄下執行反向工程,下面會產生 DEPT 與 EMP 的反向工程。 它會在專案目錄下產生子目錄 Models 與 Dept.cs、Emp.cs 與 ModelContext.cs。 先前例子的 Dept.cs、Emp.cs 與 DemoContext.cs 有經過修正,可以對照看看,尤其是 Emp.Sal 屬性的型別。

$ OracleEFCoreSample> dotnet ef dbcontext scaffold "User Id=xxxx;Password=xxxxxxxx;Data Source=10.11.xx.xxx:1522/xxxx.xxx.com.tw;Connection Timeout=600;min pool size=0;connection lifetime=18000;PERSIST SECURITY INFO=True;" Oracle.EntityFrameworkCore --table EMP --table DEPT -o Models -f

下面則是 Schema 的所有資料表。

$ OracleEFCoreSample> dotnet ef dbcontext scaffold "User Id=xxxx;Password=xxxxxxxx;Data Source=10.11.xx.xxx:1522/xxxx.xxx.com.tw;Connection Timeout=600;min pool size=0;connection lifetime=18000;PERSIST SECURITY INFO=True;" Oracle.EntityFrameworkCore -o Models -f

上回用 Electron 與 RabbitMQ 寫了可以釘選在 Windonws Desktop 托盤上的應用程式 桌面應用程式 Electron-RabbitMQ Notification

有各種管道可以發送訊息,現在就來看看如何在 Apache Kafka 消費成功或失敗時發送即時訊息到這個 Notification 應用程式。

這是一個 Apache Kafka 消費者,這裡用 Node.js,所以可以用 Node.js 的 RabbitMQ 的程式庫 amqplib,但只要發送訊息到 RabbitMQ,可以直接使用 GraphQL API 會比較單純。

這裡會送到 RabbitMQ 的 Exchange electron.notification.admin,Routing Key 則是 dba。你可想成,這裡要將訊息發送給 admin 群組下的子群組 dba。 下面的 Queue 則綁定了這個 Exchange 與 Routing Key。

kafka2rabbit-api-notification.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
const kafka = require('kafka-node');
const fetch = require('node-fetch');

const Consumer = kafka.Consumer;
const client = new kafka.KafkaClient({kafkaHost: '10.11.xx.xxx:9093'});
const offset = new kafka.Offset(client);

const topic = 'testing';
const partition = 0;

offset.fetchLatestOffsets([topic], function (err, offsets) {
if (err) return err;

let lastestOffset = offsets[topic][partition];
console.log(`lastest Offset: ${lastestOffset}`);

consumer = new Consumer(
client,
[
{
topic: topic,
partition: partition,
offset: lastestOffset - 1
}
],
{
//groupId: "demo-employees-group-1",
fromOffset: true,
autoCommit: true
}
);

consumer.on('message', function (message) {
console.log(message);

let data = {
title: 'Kafka Consumer',
content: `Kafka Topic: ${message.topic}`,
description: [
`Offset: ${message.offset}`,
`highWaterOffset: ${message.highWaterOffset}`,
`timestamp: ${message.timestamp}`
],
created: (new Date()).toString(),
from: 'Apache Consumer',
link: {
text: 'Go Kafka',
url: 'http://10.11.xx.xxx:3030'
},
};

sendToExchange(data);
});

consumer.on('error', function(err) {
console.log(err);

let data = {
title: 'Kafka Consumer Error',
content: `Kafka Topic: ${err.message}`,
created: (new Date()).toString(),
from: 'Apache Consumer',
type: 'urgent',
link: {
text: 'Go Kafka',
url: 'http://10.11.xx.xxx:3030'
},
};

sendToExchange(data);
});

consumer.on('offsetOutOfRange', function(err) {
console.log(err);
});
});

function sendToExchange(value) {
const url = "http://10.11.xx.xxx:x000/v1/graphql";
const exchange = "electron.notification.admin";
const routingKey = "dba";
const query = `mutation sendToExchange(
$exchange: String!,
$routingKey: String,
$message: String!) {
sendToExchange(exchange: $exchange, routingKey: $routingKey, message: $message)
}
`;

let message = JSON.stringify({ ...value, created: (new Date()).toString() });

fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
query: query,
variables: { exchange, routingKey, message }
})
})
.then(r => r.json())
.then(data => {
console.log(`data returned: ${JSON.stringify(data)}`);
})
.catch(err => console.log(err.message));
}

  • 第 11 行 fetchLatestOffsets 方法可以抓取到這個 Topic 的最後 Offset,我們可以從這個 Offset 開始消費,這會跟第 28 行的 fromOffset 設定為 false 是一樣的,會從新進來的消息開始消費。

  • 第 23 行的 offset 現在設為 lastestOffset - 1,所以會從上次已消費的最後一筆開始,可視你的需求調整。

  • 第 28 行 fromOffset 因為我們有提供 offset,所以這裡要設為 true。

  • 第 52、70 行當 Apache Kafka 消費成功或失敗時發送訊息。

  • 第 78 行是發送訊息到 RabbitMQ API 的函式 sendToExchange。

  • 第 80 行發送的 RabbitMQ Exchange electron.notification.admin

  • 第 81 行定義 Routing Key dba

這裡的 Apache Kafka Topic testing 每小時會從 Oracle 資料庫發送訊息到這個 Apache Kafka Topic,消費完成後再轉發通知到 RabbitMQ 的 Exchange electron.notification.admin

看起來我們好像繞了一圈,你如要用微服務架構 (Microservices),這就是不可少的架構與技術。

這是一個 Windows 作業系統下的 Desktop 應用程式,可以實際上線應用的即時訊息通知應用。這是在 Windows 10 下開發的,用到的軟體版本比較新,Windows 10 以下沒有測試過,也沒有壓力測試。這是 Windows 作業系統的 Desktop 應用程式,無法用在行動裝置下運作。

應用程式啟動後,會釘選在 Windows 右下角的托盤(Tray)上。

我的 Windows 還沒有合法版權,有個浮水印,有點模糊。當有通知 (Notification) 時,Windows 的右下角會馬上顯示通知。

如果還沒打開應用程式的顯示畫面,可以按托盤上的圖示 通知訊息會儲存在客戶端的 IndexDB 資料庫,你可以查看,也可以清除不再需要的訊息。操作很簡單,不須多做描述。

安裝

應用程式放在 XXX000(\XXXXXFS01)Y:\INSTALL\Electron\Buzzer-win32-x64-v101.zip,複製到你的硬碟,然後解壓縮。

可以直接執行 Buzzer.exe。 這裡將建立一個桌面捷徑,可以從桌面啟動。

通常托盤上的應用程式都會在使用者登入時自動執行。 按 Windows 標誌鍵 + R 開啟執行視窗,輸入 shell:startup,然後選取 [確定]。

這會開啟 [啟動] 資料夾。

將剛剛產生的桌面捷徑拖拉到這個目錄中,下次你重新登入後,這個應用程式就會自動啟動。加入啟動目錄後,你可以從工作管理員中確認。

發送訊息

首先發送一個最簡單的訊息,先從瀏覽器開始。將下列程式碼複製到瀏覽器執行。

將第 9 行的 queue 改為你的登入帳號,不要通通送到我這裡,並確認你的 Buzzer 應用程式已啟動。

sendSimple.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
let data =
{
title: '簡單的通知標頭',
content: '這是從瀏覽器發出的簡單訊息 ༼ つ ◕_◕ ༽つ。',
};

function producer(value) {
const url = "http://10.11.xx.xxx:x000/v1/graphql";
const queue = "electron.notification.7x0x0x4x";
const query = `mutation sendToQueue(
$queue: String!,
$message: String!) {
sendToQueue(queue: $queue, message: $message)
}
`;

let message = JSON.stringify({ ...value, created: (new Date()).toString() });

fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
query: query,
variables: { queue, message }
})
})
.then(r => r.json())
.then(data => {
console.log(`data returned: ${JSON.stringify(data)}`);
})
.catch(err => console.log(err.message));
}

producer(data);

Windows 視窗右下角應該馬上會出現通知訊息。

打開應用程式視窗,訊息也會同時儲存在客戶端。這裡客戶端使用的是 IndexDB。

應用程式架構

送出簡單的訊息,再看更多的範例之前,先來看看應用程式的架構。

除了 Web App 可從瀏覽器透過 GraphQL API 發送訊息到 RabbitMQ,也可從 Oracle PL/SQL、任何有安裝 Curl 程式的裝置與任何的軟體語言,透過 GraphQL API 發送訊息到 RabbitMQ。也可以直接透過任何語言的 AMQP 程式庫連結 RabbitMQ 發送訊息。客戶端的 Electron 則直接透過 Node.js 的 amqplib 程式庫直接連結 RabbitMQ 消費訊息(Consume)。Electron 應用程式一啟動就回連結 RabbitMQ。架構不是很複雜,但可以創造無限的應用空間。

之前的文章 RabbitMQ with C# 有介紹過 RabbitMQ,可以參考一下它的基本的運作方式。

發送訊息範例

除了先前簡單的發送通知,以下則提供不同的發送範例。發出通知的基本訊息資料結構可以如下。

data.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
title: '歡迎使用即時訊息通知',
content: '這是使用 Node.js Electron 開發的應用程序,是一個 Desktop 桌面應用程序。',
link: {
text: '瞭解更多 Electron ...',
url: 'https://www.electronjs.org/'
},
description: [
'使用 Node.js、Electron 與 React 開發。',
'使用 RabbitMQ 即時訊息代理。',
'可以從瀏覽器透過 GraphQL API 發送訊息。',
'可以使用 Oracle PL/SQL 發送訊息。',
'可以從作業系統使用 Node.js 或 Curl 發送訊息。',
'訊息使用 IndexDB 儲存在使用者端。'
],
created: (new Date()).toString(),
from: 'Hello Tainan',
type: 'info'
}

除了 title 與 content 是必須的,其他都是可選的。

  • link 可以設定對外的連結,將會開啟預設的瀏覽器。
  • description 是陣列,可以是一些條列式的訊息。
  • type 則只有 ‘urgent’ 會有不同的標頭圖示。

透過 GraphQL API

這是專門為 RabbitMQ 發送訊息的 GraphQL API,所有管道都可以透過此 API 發送訊息,首先是 Web App 從瀏覽器發送範例,APEX 就是此類型。

  • 從 Web App 發送給特定的消費者

記得修改第 24 行的 queue 改為接收者的 AD 登入帳號。第 28 行直接發送到 RabbitMQ 的 Queue,也就是特定的消費者。

sendToQueueApi.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
const fetch = (typeof window === 'undefined') ? require('node-fetch') : window.fetch;

let data =
{
title: 'React 函式庫 宣告式',
content: 'React 讓實作互動式的使用者界面變得一點也不痛苦。你只要在你的應用程式中為每個情境設計一個簡單的 view,React 就會在資料變更時有效率的自動更新並 render 有異動的元件。',
link: {
text: '讀取更多 React 函式庫細節',
url: 'https://zh-hant.reactjs.org/'
},
description: [
'宣告式的 view 讓你更容易預測你的程式的行為,同時也較為容易除錯。',
'首先實作一個擁有 state 的獨立 component,然後組合他們建立複雜的使用者介面。',
'這是透過 GraphQL API 傳送的'
],
created: (new Date()).toString(),
from: 'GraphQL API'
};

producer(data);

function producer(value) {
const url = "http://10.11.xx.xxx:x000/v1/graphql";
const queue = "electron.notification.7x0x0x4x";
const query = `mutation sendToQueue(
$queue: String!,
$message: String!) {
sendToQueue(queue: $queue, message: $message)
}
`;

let message = JSON.stringify({ ...value, created: (new Date()).toString() });

fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
query: query,
variables: { queue, message }
})
})
.then(r => r.json())
.then(data => {
console.log(`data returned: ${JSON.stringify(data)}`);
})
.catch(err => console.log(err.message));
}
  • 從 Web App 發送給所有的消費者

第 26 行直接發送到 RabbitMQ 的 Exchange electron.notification, 則可以發送給所有的綁定這個 Exchange 的 Queue。預設上,這個 Electron 應用程式的所有 Queue 都會綁定到這個 Exchange electron.notification

sendToExchangeApi.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const fetch = (typeof window === 'undefined') ? require('node-fetch') : window.fetch;

let data =
{
title: 'React 函式庫 宣告式',
content: 'React 讓實作互動式的使用者界面變得一點也不痛苦。你只要在你的應用程式中為每個情境設計一個簡單的 view,React 就會在資料變更時有效率的自動更新並 render 有異動的元件。',
link: {
text: '讀取更多 React 函式庫細節',
url: 'https://zh-hant.reactjs.org/'
},
description: [
'宣告式的 view 讓你更容易預測你的程式的行為,同時也較為容易除錯。',
'首先實作一個擁有 state 的獨立 component,然後組合他們建立複雜的使用者介面。',
'這是透過 GraphQL API 傳送的'
],
created: (new Date()).toString(),
from: 'GraphQL API'
};

producer(data);

function producer(value) {
const url = "http://10.11.xx.xxx:x000/v1/graphql";
const exchange = "electron.notification";
const routingKey = "";
const query = `mutation sendToExchange(
$exchange: String!,
$routingKey: String,
$message: String!) {
sendToExchange(exchange: $exchange, routingKey: $routingKey, message: $message)
}
`;

let message = JSON.stringify({ ...value, created: (new Date()).toString() });

fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
query: query,
variables: { exchange, routingKey, message }
})
})
.then(r => r.json())
.then(data => {
console.log(`data returned: ${JSON.stringify(data)}`);
})
.catch(err => console.log(err.message));
}
  • 從 Node.js 發送

以上兩個範例也可以從 Node.js 發送。首先安裝 npm node-fetch 程式庫,程式碼則不需更改。程式碼的第一行會自動判斷不同的環境使用不同的 fetch 程式庫。JavaScript 與 Node.js 可共用程式碼。

sendToQueueApi.js
1
2
const fetch = (typeof window === 'undefined') ? require('node-fetch') : window.fetch;
...
sendToQueueApi.js
$ node sendToQueueApi
data returned: {"data":{"sendToQueue":"true"}}
  • 從 Oracle PL/SQL 發送

從 Oracle 發送訊息最麻煩的就是 JSON 的組合,如何將資料組合成 JSON 格式,常常讓我非常沮喪,盡快將 Oracle 資料庫升級是解決的方法。這裡則是範例,希望能有些幫助。

首先送到單一的消費者。你需要修改的主要在 32 ~ 49 行之間,當然也不要忘了第 9 行的 l_queue。

oracle_pls_sendtoqueue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
-- Publish to RabbitMQ Queue
--
declare
l_http_request Utl_Http.req;
l_http_response Utl_Http.resp;
l_url varchar2(255) := 'http://10.11.xx.xxx:x000/v1/graphql';
l_buffer varchar2(32767);
l_item varchar2(1024);
l_queue varchar2(32) := 'electron.notification.7x0x0x4x';
l_messages varchar2(32767);
l_query varchar2(32767);

function message(empno_in in number)
return varchar2
is
rec emp%rowtype;
l_title varchar2(100);
l_content varchar2(1024);
l_description varchar2(1024);
l_link varchar2(1024);
l_link_text varchar2(100);
l_link_url varchar2(100);
l_type varchar2(10) := 'urgent';
l_from varchar2(20) := 'Oracle Lx00 Demo';
l_message varchar2(1024);
begin
select * into rec
from emp
where empno = empno_in;


l_title := '這是員工 ' || rec.ename || ' 資料';

l_content := '員工: ' || rec.empno || ' Name: ' || rec.ename || ' Job: ' || rec.job ||
' Hiredate: ' || to_char(sysdate,'yyyy-mm-dd"T"hh24:mi:ss') || ' Salary: ' || rec.sal ||
' Comm: ' || rec.comm || ' Department: ' || rec.deptno;

l_description := '['||
'\"員工: ' ||rec.empno||' '||rec.ename||' '||rec.job||'\",'||
'\"薪資: ' ||(rec.sal + nvl(rec.comm,0))||'\",'||
'\"到職日: ' ||to_char(rec.hiredate,'yyyy-mm-dd"T"hh24:mi:ss')||'\"'||
']';

l_link_text := '讀取更多';
l_link_url := 'http://10.11.26.201:8080/ords/demo/employee/' || rec.empno;
l_link := '{'||
'\"text\":' ||'\"'||l_link_text||'\",'||
'\"url\":' ||'\"'||l_link_url||'\"'||
'}';

l_message := '"{'||
'\"title\":' ||'\"'||l_title||'\",'||
'\"content\":' ||'\"'||l_content||'\",'||
'\"description\":' ||l_description||','||
'\"link\":' ||l_link||','||
'\"created\":'||'\"'||to_char(sysdate,'yyyy-mm-dd"T"hh24:mi:ss')||'\",'||
'\"from\":' ||'\"'||l_from||'\",'||
'\"type\":' ||'\"'||l_type||'\"'||
'}"';

return l_message;
exception
when no_data_found
then
return '"{}"';
end;
-- main body
begin
for rec in (select rownum, empno from emp where empno = 7788)
loop
l_item := message(rec.empno);
l_messages := l_messages||case rec.rownum when 1 then '' else ',' end||l_item;
end loop;

l_query := '{"query":"mutation sendToQueue($queue: String!, $message: String!)' ||
' {\n sendToQueue(queue: $queue, message: $message)\n}",' ||
'"variables":{"queue":"' || l_queue || '","message":' || l_messages ||'}}';

dbms_output.put_line(l_query);

l_http_request := utl_http.begin_request(l_url, 'POST','HTTP/1.1');
utl_http.set_header(l_http_request, 'user-agent', 'mozilla/4.0');
utl_http.set_header(l_http_request, 'content-type', 'application/json; charset=utf-8');
utl_http.set_header(l_http_request, 'Content-Length', lengthb(l_query));
utl_http.set_body_charset(l_http_request, charset => 'utf-8');
utl_http.write_text(l_http_request, l_query);
l_http_response := utl_http.get_response(l_http_request);

-- process the response from the HTTP call
begin
loop
utl_http.read_line(l_http_response, l_buffer);
dbms_output.put_line(l_buffer);
end loop;
utl_http.end_response(l_http_response);
exception
when utl_http.end_of_body
then
utl_http.end_response(l_http_response);
end;
end;
/

以下則是送到 RabbitMQ Exchange,這可以發送到多個消費者。

oracle_pls_sendtoexchange
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
-- Publish to RabbitMQ Exchange 
--
declare
l_http_request Utl_Http.req;
l_http_response Utl_Http.resp;
l_url varchar2(255) := 'http://10.11.xx.xxx:x000/v1/graphql';
l_buffer varchar2(32767);
l_item varchar2(1024);
l_exchange varchar2(32) := 'electron.notification';
l_routing_key varchar2(32) := '';
l_messages varchar2(32767);
l_query varchar2(32767);

function message(empno_in in number)
return varchar2
is
rec emp%rowtype;
l_title varchar2(100);
l_content varchar2(1024);
l_description varchar2(1024);
l_link varchar2(1024);
l_link_text varchar2(100);
l_link_url varchar2(100);
l_type varchar2(10) := 'info';
l_from varchar2(20) := 'Oracle Lx00 Demo';
l_message varchar2(1024);
begin
select * into rec
from emp
where empno = empno_in;

l_title := '這是員工 ' || rec.ename || ' 資料';

l_content := '員工: ' || rec.empno || ' Name: ' || rec.ename || ' Job: ' || rec.job ||
' Hiredate: ' || to_char(sysdate,'yyyy-mm-dd"T"hh24:mi:ss') || ' Salary: ' || rec.sal ||
' Comm: ' || rec.comm || ' Department: ' || rec.deptno;

l_description := '['||
'\"員工: ' ||rec.empno||' '||rec.ename||' '||rec.job||'\",'||
'\"薪資: ' ||(rec.sal + nvl(rec.comm,0))||'\",'||
'\"到職日: ' ||to_char(rec.hiredate,'yyyy-mm-dd"T"hh24:mi:ss')||'\"'||
']';

l_link_text := '讀取更多';
l_link_url := 'http://10.11.xx.xxx:x0x0/ords/demo/employee/' || rec.empno;
l_link := '{'||
'\"text\":' ||'\"'||l_link_text||'\",'||
'\"url\":' ||'\"'||l_link_url||'\"'||
'}';

l_message := '"{'||
'\"title\":' ||'\"'||l_title||'\",'||
'\"content\":' ||'\"'||l_content||'\",'||
'\"description\":' ||l_description||','||
'\"link\":' ||l_link||','||
'\"created\":'||'\"'||to_char(sysdate,'yyyy-mm-dd"T"hh24:mi:ss')||'\",'||
'\"from\":' ||'\"'||l_from||'\",'||
'\"type\":' ||'\"'||l_type||'\"'||
'}"';

return l_message;
exception
when no_data_found
then
return '"{}"';
end;
-- main body
begin
for rec in (select rownum, empno from emp where empno = 7788)
loop
l_item := message(rec.empno);
l_messages := l_messages||case rec.rownum when 1 then '' else ',' end||l_item;
end loop;

l_query := '{"query":"mutation sendToExchange($exchange: String!, $routingKey: String, $message: String!)'||
' {\n sendToExchange(exchange: $exchange, routingKey: $routingKey, message: $message)\n}",'||
'"variables":{"exchange":"'||l_exchange||'","routingKey":"'||l_routing_key||'","message":'|| l_messages||'}}';

--dbms_output.put_line(l_query);

l_http_request := utl_http.begin_request(l_url, 'POST','HTTP/1.1');
utl_http.set_header(l_http_request, 'user-agent', 'mozilla/4.0');
utl_http.set_header(l_http_request, 'content-type', 'application/json; charset=utf-8');
utl_http.set_header(l_http_request, 'Content-Length', lengthb(l_query));
utl_http.set_body_charset(l_http_request, charset => 'utf-8');
utl_http.write_text(l_http_request, l_query);
l_http_response := utl_http.get_response(l_http_request);

-- process the response from the HTTP call
begin
loop
utl_http.read_line(l_http_response, l_buffer);
dbms_output.put_line(l_buffer);
end loop;
utl_http.end_response(l_http_response);
exception
when utl_http.end_of_body
then
utl_http.end_response(l_http_response);
end;
end;
/
  • 從有安裝 Curl 的裝置發送
curl
1
2
3
curl 'http://10.11.xx.xxx:x000/v1/graphql' \
-H 'Content-Type: application/json' \
--data '{"query":"mutation sendToQueue($queue: String!, $message: String!) {sendToQueue(queue: $queue, message: $message)}","variables":{"queue":"electron.notification.7x0x0x4x","message":"{\"title\":\"歡迎使用即時訊息通知\",\"content\":\"這是使用 Node.js Electron 開發的應用程序,是一個 Desktop 桌面應用程序。\",\"created\":\"Fri May 22 2020 11:26:13 GMT+0800 (GMT+08:00)\"}"}}'

透過 GraphQL API 幾乎可以包含所有的發送。也可使用各程式語言的 AMQP 程式庫直接連結 RabbitMQ Server。

直接使用程式語言 AMQP 程式庫

以下是 Node.js 的範例,C# 則請參考 RabbitMQ with C#

使用 Node.js amqplib 程式庫

首先我將一些基礎的工作抽象化,這包含了 RabbitMQ 開始的 Connection 等初始化工作,這個抽象化物件包含了,訊息的製造(Producer)與訊息的消費(Consumer)方法。其餘的就比較簡單了。

這裡會用到兩個 npm Node.js 程式包,amqplib 與 node-uuid。amqplib 是 RabbitMQ 的驅動程式,node-uuid 則會用來將每個送來的訊息設定一個唯一的編號,這可以用來追蹤訊息的流動。

shell
$ yarn add amqplib

$ yarn add node-uuid

以下是抽象層程式碼。

rabbitmq.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
const EventEmitter = require('events').EventEmitter;
const amqp = require("amqplib");
const uuid = require('node-uuid');

class RabbitMQ extends EventEmitter {
constructor() {
super();
this.url = "10.11.xx.xxx";
this.channel = null;
this.exchange = "electron.notification";

this.sendToQueue = this.sendToQueue.bind(this);
this.publish = this.publish.bind(this);
this.receive = this.receive.bind(this);
this.initialize = this.initialize.bind(this);

this.initialize().then(() => this.emit('ready', {
publish: this.publish,
sendToQueue: this.sendToQueue,
receive: this.receive
}));
}

initialize() {
return amqp
.connect(`amqp://${this.url}`)
.then(conn => {
process.once('SIGINT', function () {
console.log('amqp connection close.');
conn.close();
});

return conn.createChannel()
})
.then(channel => {
this.channel = channel;
})
.catch(console.warn)
};

sendToQueue(queue, content) {
return this.channel.sendToQueue(
queue,
new Buffer.from(content),
{
persistent: true,
headers: {
messageId: uuid.v4(),
api: "Electron-sendToQueue-v1",
firstPublish: Date.now()
}
}
);
}

publish(exchange, routingKey, content) {
return this.channel.publish(
exchange,
routingKey,
new Buffer.from(content),
{
persistent: true,
headers: {
messageId: uuid(),
api: "Electron-publish-v1",
firstPublish: Date.now()
}
}
);
}

receive(queue, routingKey, callback) {
return this.channel.assertQueue(queue)
.then(q => {
return this.channel.bindQueue(queue, this.exchange, routingKey)
})
.then(() => {
return this.channel.consume(queue, msg => {
let data;
if (msg) {
// console.log(msg.properties.headers);
try {
data = msg.content.toString();
callback(null, data);
} catch (err) {
console.log(msg.content.toString());
callback(err);
}
this.channel.ack(msg);
} else {
callback(new Error('amqp consume error.'));
}
});
})
.catch(console.warn);
}
}

module.exports = new RabbitMQ();

這個抽象層使用了 EventEmitter,所產生的實例會是一個可觀察物件 (Observable),第 44 行除了送出訊息本身外,還加了一個訊息標頭,可用來追蹤訊息的流程 (45 ~ 52)。

要注意的是第 72 行 receive 方法,這是一個消費者(Consume),第 73 行如果 queue 已存在,則返回 queue, 如果不存在則會新建一個 RabbitMQ 的 queue。

第 75 行預設會將這個 queue 綁定到 electron.notification Exchange。所以如果第一次使用這個 Electron 應用程式,就會以登入的 AD 帳號產生一個 RabbitMQ 的 queue,例如,electron.notification.7x0x0x4x,預設綁定到 RabbitMQ electron.notification Exchange。這也就是說每個 electron.notification.xxxxxxxx queue 都會是 electron.notification Exchange 的成員,送到 Exchange electron.notification 的訊息都會轉發送到每個成員。

註解的第 81 行,則是額外加入的訊息標頭。

其他的就簡單了。 送給特定的人:

sendToQueue.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const rabbit = require('./rabbitmq');

let message = JSON.stringify(
{
title: '請假單已核准',
content: '請注意這是流程簽核系統所發出通知 編號 : 202005182717332',
description: [
'請假者: 7x0x0x4x 林小明',
'職務代理人: 8x0x0x1x 陳大華',
'假別: 01 特別假',
'請假日期: 2020-05-18 04:00 PM~2020-05-18 04:30 PM',
'請假時數: .5',
'請假事由: 因事'
],
created: (new Date()).toString(),
from: 'Send To Queue',
link: {
text: '讀取更多',
url: 'http://xxxxxf3n06.xxx.com.tw:7x8x/pls/yx2x/f?p=109:2'
},
}
);

rabbit.on('ready', ({ sendToQueue }) => {
sendToQueue('electron.notification.7x0x0x4x', message);
});

送給所有人:

publish.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const rabbit = require('./rabbitmq');

let message = JSON.stringify(
{
title: '歡迎使用即時訊息通知',
content: '這是使用 Node.js Electron 開發的應用程序,是一個 Desktop 桌面應用程序。',
link: {
text: '瞭解更多 Electron ...',
url: 'https://www.electronjs.org/'
},
description: [
'使用 Node.js、Electron 與 React 開發。',
'使用 RabbitMQ 即時訊息代理。',
'可以從瀏覽器透過 GraphQL API 發送訊息。',
'可以使用 Oracle PL/SQL 發送訊息。',
'可以從作業系統使用 Node.js 或 Curl 發送訊息。',
'訊息使用 IndexDB 儲存在使用者端。'
],
created: (new Date()).toString(),
from: 'Hello Tainan',
type: 'info'
}
);

rabbit.on('ready', ({ publish }) => {
publish('electron.notification', '', message);
});

其他的程式語言可參考 RabbitMQ Tutorials

群組

我沒有在這個 Electron 應用程式中設定群組,我不想讓應用程式架構太複雜。但這並不表示我們不能在這個訊息通知有群組的功能。

這可用 RabbitMQ 來達成,甚至群組還可以細分子群組。

建立 electron.notification.hrs Exchange。注意這個 Exchange type 是 direct。

這個 electron.notification.9x0x0x2x 則有 3 個 Exchange Bindings,下列送出的訊息這個 Queue 都會收到:

  • 直接送給 Queue electron.notification.9x0x0x2x。
  • 送給 Exchange electron.notification,Routing Key 是 ‘’。
  • 送給 Exchange election.notification.hrs, Routing Key 是 ‘’。
  • 送給 Exchange election.notification.hrs, Routing Key 是 ‘admin’。
publish.js
1
2
3
4
...
rabbit.on('ready', ({ publish }) => {
publish('electron.notification.hrs', 'admin', message);
});

所以上面送出的訊息意思就是,只有群組 hrs 下的子群組 admin 才會收到訊息。

群組成員可以用 RabbitMQ 管理介面查到。

RabbitMQ 功能強大,又提供友善的管理介面,有銀行業的加持,可以善加利用。希望這個 Electron 應用程式能引起各位的應用思考,常常聽到同仁有分散式資料整合的困擾, GraphQL API 與 RabbitMQ 創造了無限的空間。

使用 APEX 建立一個使用者發送訊息的 UI 介面,你就可以利用這個 Electron 應用程式,創造一個對談應用程式。

原始程式碼

這裡是程式碼,有興趣的可以看看,我使用的編輯器是 Visual Studio Code。Visual Studio Code 也是用 Electron 開發的。

source
$ git clone z:\dba\git\depot\electron-react-rabbit.git your_directory

$ cd your-directory

$ yarn install

$ yarn start

開發環境是 Node.js + Election + React + Babel.js + Webpack。

開發完打包:

source
$ yarn package

它會產生 out 目錄,裡面就是最後上線的程式碼。也就是我們之前解壓縮後的目錄。

祝 健康快樂, Coding 愉快。

之前曾經使用 NW.js 與 Vue.js 開發一套可離線應用的桌面應用程序,資料存在客戶端的 localStorege 中,這裡我則用 Electron 搭配 React 建立了一個基本的開發架構,有興趣使用此架構的同仁可以很快的開始。

這裡客戶端我使用 React 程式庫取代了 Vue.js,因為我可以用同樣的 React 元件,透過 React Native 就可打造原生移動裝置的應用程序。React Native 僅僅需要使用一種編程語言 JavaScript ,同一個開發團隊就可以建構 Android、iOS 應用程序。其他的跨平台解決方案 PhoneGap、Cordova 和 Ionic 雖然也是可行,但在用戶體驗與性能上表現卻不佳。React Native 的亮點就在於它的性能無損,與使用 Objective-C、Swift 或 Java 建構的原生移動應用程序相比,性能沒有明顯的差異。

近年來,原生移動開發變得越來越昂貴,因此能夠開發跨平台應用程序的工程師越來愈搶手,React Native 僅僅使用單一主流框架就可以開發桌面、Web 以及移動應用程序,值得讓我們思考與組織架構的調整。開發團隊使用同一種技術可以簡化協同合作和共享,因此生產力也會提升。

這裡我們則先用 Electron 與 React 來建立桌上型應用程序。除了基本的架構範例可以作為專案的開始範本,我也建立了一個基本的展示範例,除了使用 Electron、React 外加 IndexDB 將資料存在客戶端。我將此兩個範例都打包成 git Repository,因此你除了要安裝 Visual Studio Code 外,也需要安裝 Git。

  • electron-react-quickstart.git
  • electron-react-idb-sample.git

這裡就從第一個基本架構開始。

Quick Start

首先用 Git 複製範例,打開 Visual Studio Code, 開啟一個終端螢幕,我電腦的 Z: 是指向 XXX111(\XXXXXFS01)。

I:\u01\projects>git clone Z:\DBA\Git\Depot\electron-react-quickstart.git

然後用 Visual Studio Code 開啟目錄 electron-react-quickstart 開始安裝程式庫。這裡我要用 Yarn 來取代 npm 管理 JavaScript 套件。 Yarn 是 Facebook 與 Exponent、 Google、Tilde 所合作開發的套件管理工具,其功能與 npm 相同,但 npm 最為人詬病的是安裝速度很慢、安裝相依套件沒有順序性,而 Yarn 不但解決了這些問題,還加入了方便的 Cache 機制使套件均只要下載一次,多個專案若使用相同套件時不必重新下載。 官方也表示 Yarn 是快速、安全、可靠的,且能支援多系統。

所以首先要先安裝 Yarn。可以測試一下:

I:\u01\projects\electron-react-quickstart>yarn --version
1.22.4

在你的 electron-react-quickstart 目錄下開始安裝所需要的程式庫:

I:\u01\projects\electron-react-quickstart>yarn install 

安裝完成就可以開啟應用程序:

I:\u01\projects\electron-react-quickstart>yarn start 

這裡是基本應用程序的畫面:

以下則是專案的目錄結構:

這個專案的程式碼都在 src 目錄中。使用 webpack 為開發及打包工具。簡單的來看看這幾段程式碼,可以從 package.json 的設定找到這個專案程序的入口點:

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "fire-sale",
"productName": "FireSale",
"version": "1.0.0",
"description": "My Electron application description",
"main": ".webpack/main",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "echo \"No linting configured\""
},
...

第 6 行 “main”: “.webpack/main” 是這個專案程序的入口點,但這是經過 webpack 轉譯後的入口點,真正的入口點是對照到 src 目錄下的 main.js 程式檔。

package.json
54
55
56
57
58
59
60
"entryPoints": [
{
"html": "./src/index.html",
"js": "./src/renderer.js",
"name": "main_window"
}
]

package.json 的第 56,57 行則是 Electron 的習慣性基本的命名程式檔,簡單的看一下 Electron 的基本架構:

The main process

Main process 有幾個重要職責。它可以回應應用程序生命週期事件,例如啟動,退出,準備退出,進入後台,進入前台等等,Main process 還負責與本機操作系統 API 進行通信。 如果要顯示一個對話框以打開或保存文件,就得從 Main process 進行操作。

Renderer processes

Main process 可以使用 Electron 的 Browser-Window 模塊創建和銷毀 Renderer process。 Renderer process 可以加載網頁以顯示客戶端的 GUI。 每個進程都利用 Chronium 的多進程架構,並在其自己的線程(Thread)上運行。 然後,這些頁面可以加載其他 JavaScript 文件並在此線程中執行代碼。 與普通網頁不同,您可以從 Renderer process 訪問所有 Node API,從而可以使用本機模塊和較低級別的系統功能溝通。

main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const { app, BrowserWindow } = require('electron');
const path = require('path');

if (require('electron-squirrel-startup')) {
app.quit();
}

const createWindow = () => {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true
},
show: false,
});

mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);

mainWindow.once('ready-to-show', () => {
mainWindow.show();
});

// mainWindow.webContents.openDevTools();
};

app.on('ready', createWindow);

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});

第 18 行的 MAIN_WINDOW_WEBPACK_ENTRY 是 webpack 特殊的全域變數,預設值是在前面 package.json 中設定的 “./src/index.html”,index.html 則會自動載入 “./src/renderer.js”

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello Tainan!</title>

</head>
<body>
<div id="application">
<div class="loading">Loading…</div>
</div>
</body>
</html>
renderer.js
1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react';
import { render } from 'react-dom';
import './index.css';

render(
<div>
<h1>💖 Hello World! Hello 台南!!</h1>
<p>Welcome to your Electron application.</p>
</div>,
document.getElementById('application')
);

console.log('👋 此消息使用 webpack 透過 "renderer.js" 發出 👀');

這裡使用 React 與 ReactDOM 來渲染客戶端的 GUI,這裡使用 render 方法創建客戶端的 UI,其中使用了 React 的 JSX。JSX 是 JavaScript 的語法擴展,與 XML 有點類似。雖然不用 JSX 也可以建構 React 組件,但是 JSX 能夠使 React 更易於閱讀和維護。JSX 初看可能有些奇怪,但功能非常強大,深得廣大開發者的青睞。

第 3 行使用 import 直接載入 css 檔案,這是因為透過 webpack 的轉譯,可以不用透過 html 的 link 標籤,webpack 會將 css 直接轉譯為 javascript,最終則與其它程式檔打包成為一個單一的程式檔,這通常會命名為 bundle.js。

最後我們設計的應用程序必須打包讓使用者更容易安裝使用,webpack 將負責此工作。

I:\u01\projects\electron-react-quickstart>yarn run package

完成後在專案目錄下會產生一個 out 子目錄:

可以從檔案總管中直接執行 FireSale.exe,這個執行檔名稱也是在 package.json 中 “productName”: “FireSale” 定義的:

因為這是 Electron 應用程序,沒有 Web 伺服器,所以你必須將整個目錄打包成 zip 檔,讓使用下載解壓縮後直接執行 exe 檔。

下次再來探索另外一個範例 electron-react-idb-sample.git,將可以學到更多的 Electron 與 React。並看看如何將資料儲存在客戶端。

祝 Coding 愉快,身體健康。

.NET Core 支援依賴注入(Dependency Injection, DI)的設計模式,這是一種用於在類(Class)及其依賴項之間實現控制反轉(IoC)的技術。

依賴項注入允許從類的外部注入依賴項,因此注入依賴項的類只需要知道一個協定(通常是 C# 介面,Interface)。這個類可以遵守這個介面的協議而獨立於其物件的創建。

依賴注入並不一定需要依賴注入容器(Dependency Injection Container),但使用容器有助於管理依賴項。當依賴注入容器管理的服務列表越來越長時,就可以看到容器的優點。ASP.NET Core 和 Entity Framework Core 使用 Microsoft.Extensions.DependencyInjection 作為容器來管理所有的依賴項,以此管理數百個服務。所以王智民老師才會特別講解依賴注入。

儘管依賴注入和依賴注入容器在非常小的應用程序中會增加複雜性,但是一旦應用程序變得更大,需要多個服務,依賴注入就會顯示它的效益,降低應用程序的複雜性,並促進非緊密綁定的實現(鬆散耦合)。

到此,觀念還是很抽象,這裡就從一個不使用依賴注入的小應用程式開始;隨後將它轉換為使用依賴注入並使用注入依賴容器的應用程序。

使用沒有依賴注入的服務

開啟 VS Code,首先建立一個 .NET Core Console 專案

$ dotnet new console --name MyDI

使用 VS Code 切換到新建立的 .NET Core 專案目錄 MyDI。開啟一個終端螢幕輸入 dotnet run 測試一下。我們就可以開始了。

$ dotnet run
Hello World!

先在專案目錄下開兩個子目錄 Services 與 Controllers 分別擺放不同功能型態的程式碼。在 Services 下建立 GreetingService.cs,在 Controllers 下是 HomeController.cs。 程式碼都很短,不要用複製貼上,自己 Coding,享受一下 Coding 的樂趣。

這個 GreetingService 類定義了返回字符串的 Greet 方法。

Services/GreetingService.cs
1
2
3
4
5
6
7
namespace MyDI.Services
{
public class GreetingService
{
public string Greet(string name) => $"Hello Tainan, 哈囉,{name}";
}
}

HomeController 類使用了這個 GreetingService 服務。

Controllers/HomeController.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
using MyDI.Services;

namespace MyDI.Controllers
{
public class HomeController
{
public string Hello(string name)
{
var service = new GreetingService();
return service.Greet(name);
}
}
}

現在看看 Program 類的 Main( ) 方法。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using System;
using MyDI.Controllers;

namespace MyDI
{
class Program
{
static void Main(string[] args)
{
var controller = new HomeController();
string result = controller.Hello("Emily");
Console.WriteLine(result);
}
}
}

現在跑跑看。

$ dotnet run

Hello Tainan, 哈囉,Emily

這有甚麼問題嗎? HomeController 與 GreetingService 是緊密耦合的。要用不同的實現取代 HomeController 中的 GreetingService 並不容易。

現在用另一個 SayHelloService 服務來取代原先的 GreetingService 看看會需要動到哪些程式碼。

在目錄 Services 下建立 SayHelloService 類,這裡用 SayHello 方法返回一個字符串。

Services/SayHelloService.cs
1
2
3
4
5
6
7
namespace MyDI.Services
{
public class SayHelloService
{
public string SayHello(string name) => $"Say 哈囉,{name}, Hello Tainan!";
}
}

要用 SayHelloService 取代 GreetingService 現在必需要直接修改 HomeController。

Controllers/HomeController.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
using MyDI.Services;

namespace MyDI.Controllers
{
public class HomeController
{
public string Hello(string name)
{
var service = new SayHelloService();
return service.SayHello(name);
}
}
}

這裡修改了第 9 與 10 行,改使用 SayHelloService 服務。

$ dotnet run

Say 哈囉,Emily, Hello Tainan!

在這個簡單的例子中看起來也還好,並不是很複雜。 但這裡的服務都只是返回字符串的簡單服務,一般的應用程序通常更複雜,要修改的也更多,有些還會有一些相互的牽扯,最怕的是有些還不知道藏在哪裡,要等到出問題才會知道。現在改用依賴注入,看看會有甚麼不同。

使用依賴注入

現在要讓 HomeController 獨立於 GreetingService 與 SayHelloService 的實現,當要變更服務時,不用再直接修改 HomeController 類。為此,我們必須訂立一個協定,讓這些服務遵守,這裡要用一個 IGreetingService 介面(Interface)來定義 HomeController 所需要的功能,IGreetingService 介面就是一個協定。在專案目錄下建一個子目錄 Interfaces 擺放介面程式碼 IGreetingService.cs

Interfaces/IGreetingService.cs
1
2
3
4
5
6
7
namespace MyDI.Interfaces
{
public interface IGreetingService
{
string Greet(string name);
}
}

在這個 IGreetingService 介面中,目前只有一個協議 Greet 方法。現在 GreetingService 與 SayHelloService 都必須遵守這個協定,也就是都要實現這個 Greet 方法。

Services/GreetingService.cs
1
2
3
4
5
6
7
8
9
using MyDI.Interfaces;

namespace MyDI.Services
{
public class GreetingService : IGreetingService
{
public string Greet(string name) => $"Hello Tainan, 哈囉,{name}";
}
}
Services/SayHelloService.cs
1
2
3
4
5
6
7
8
9
10
11
using MyDI.Interfaces;

namespace MyDI.Services
{
public class SayHelloService : IGreetingService
{
public string Greet(string name) => $"Say 哈囉,{name}, Hello Tainan!";

public string SayHello(string name) => $"Say 哈囉,{name}, Hello Tainan!";
}
}

SayHelloService 原先並沒有 Greet 方法,因此必須實作,這裡將舊的 SayHello( ) 方法留著,萬一有人使用了緊密耦合。

HomeController 現在只需要對一個物件的引用,該物件會實現介面 IGreetingService 中的所有協議。這個物件會用 HomeController 的建構式函式(Constructor)注入,分配給私有字段(private filed),通過方法 Hello 來使用。

Controllers/HomeController.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using MyDI.Interfaces;

namespace MyDI.Controllers
{
public class HomeController
{
private readonly IGreetingService _greetingService;

public HomeController(IGreetingService greetingService) {
_greetingService = greetingService ?? throw new ArgumentNullException(nameof(greetingService));
}

public string Hello(string name) => _greetingService.Greet(name);
}
}

第 10 行是 HomeController 的建構函式,參數型態是 IGreetingService 介面,因此只要能實現 IGreetingService 介面的物件都可以由此注入。HomeController 則透過 Hello 方法來使用這個注入物件的 Greet 方法。 

在這個實現中,HomeController 類利用了控制反轉(Inversion of Control, IoC)的設計原理。HomeController 並沒有像以前那樣直接實例化 GreetingService 或 SayHelloService。相反的,定義由 HomeController 使用具體類的控件從外部注入,也就是透過 HomeController 的建構函式從外部注入。換句話說,控制是反轉的。HomeController 不能直接決定自己要用哪個服務,而是由外界決定 HomeController 該用哪個服務。反轉控制也被稱為好萊塢原則:不要給我們打電話,我們會給你打電話(don’t call us, we’ll call you)。

HomeController 類並沒有依賴 IGreetingService 介面的具體實作,而是可以使用實現了介面 IGreetingService 的任何類。所以這裡可以使用 GreetingService 與 SayHelloService 類,因這兩個類都具體實作了 IGreetingService 介面。

現在,需要從外部注入依賴項,將具體的實現傳遞給 HomeController 類的建構函式。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using MyDI.Controllers;
using MyDI.Services;

namespace MyDI
{
class Program
{
static void Main(string[] args)
{
var controller = new HomeController(new GreetingService());
string result = controller.Hello("Emily");
Console.WriteLine(result);
}
}
}

第 11 行使用建構函式注入的依賴注入模式實現了控制反轉的設計原則。這稱為建構函式注入,因為介面是在建構函式中注入的。所以這裡需要注入一個依賴項物件來創建一個 HomeController 實例。

$ dotnet run

Hello Tainan, 哈囉,Emily

現在注入不同的服務,更改第 11 行注入不同的依賴項:

Program.cs
1
2
3
...
var controller = new HomeController(new SayHelloService());
...
$ dotnet run

Say 哈囉,Emily, Hello Tainan!

到此,我們已完成了依賴注入的改寫。這個範例程序非常小,唯一需要注入的是一個實現協定 IGreetingService 介面的類。 這個類在實例化的同時實例化了 HomeController。

到目前為止我們已經實現了依賴注入,但還沒有使用容器來管理這些依賴項。在實際應用程序中,需要處理許多介面與實現,還需要共享實例,最好的方法還是使用依賴注入容器來管理所有的依賴項。

使用 .NET Core DI 容器

依賴注入不一定需要使用依賴注入容器,但容器有助於管理依賴項,這裡要使用 Microsoft.Extensions.DependencyInjection 作為容器來管理所有依賴項。

首先需要安裝 Microsoft.Extensions.DependencyInjection。

$ dotnet add package Microsoft.Extensions.DependencyInjection

在依賴注入容器中,可以在應用程式中有一個位置,在其中定義甚麼協定(介面 Interface)映射到哪個特定的實現(具體的類 Class)上。還可以指定是應該將服務作為一個單例(Singleton)來使用,還是應該在每次使用時創建一個新實例(Transient)。

首先在 Program 類中定義 RegisterService 方法。在這裡,實例化一個新的 ServiceCollection 物件,ServiceCollection 就在 Microsoft.Extensions.DependencyInjection 名稱空間中,使用 ServiceCollection 的擴展方法 AddSingleton 與 AddTransient 來註冊 DI 容器需要知道的類型,範例中我們將 GreetingService 與 HomeController 都註冊在容器中,這樣允許從容器中檢索 HomeController。

當請求 IGreetingService 介面時,會實例化 GreetingService 類。HomeController 本身沒有實現介面。通過這個 DI 容器配置,當請求 HomeController 時,DI 容器會時例化 HomeController。

DI 容器配置還定義了服務的生命週期,使用 AddSingleton 與 AddTransient 方法指定服務的生命週期信息。對於 GreetingService,使用 AddSingleton 註冊,因此請求 IGreetingService 時總是返回相同的實例。這和 HomeController 不同, HomeControler,使用 AddTransient 註冊,每次請求檢索 HomeController 時,則都會創建一個新的實例。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;
using Microsoft.Extensions.DependencyInjection;
using MyDI.Controllers;
using MyDI.Services;
using MyDI.Interfaces;

namespace MyDI
{
class Program
{
static void Main(string[] args)
{
using (ServiceProvider container = RegisterServices())
{
var controller = container.GetRequiredService<HomeController>();
string result = controller.Hello("Emily");
Console.WriteLine(result);
}
}

static ServiceProvider RegisterServices() {
var services = new ServiceCollection();
services.AddSingleton<IGreetingService, GreetingService>();
services.AddTransient<HomeController>();
return services.BuildServiceProvider();
}
}
}

第 21 行新增靜態方法 RegisterService( )。
第 23、24 行註冊 GreetingService 與 HomeController。
第 25 行調用 BuildServiceProvider,這會返回一個 ServiceProvider 物件,該物件可以用來訪問已註冊的服務。

再來要修改 Main 方法來調用 RegisterService 方法以便在容器中註冊。

第 13 行允許直接訪問 ServiceProvider 的 Dispose 方法,所以這裡使用 using 語句。
第 15 行調用 ServiceProvider 的 GetRequiredService 方法來取得對 HomeController 實例的引用。

啟動應用程序時,在 GetRequiredService 方法的請求下,DI 容器將創建 HomeController 類的實例。HomeController 的建構函式需要一個實現 IGreetingService 的物件。這個介面 IGreetingService 也有在容器中註冊,在此它會返回 GreetingService 物件。GreetingService 類有一個預設的建構函式,因此容器可以創建一個實例,並將該實例傳遞給 HomeController 的建構函式。

$ dotnet run

Hello Tainan, 哈囉,Emily

現在改註冊另外一個 SayHelloService 服務。

Program.cs
1
2
3
4
5
6
7
8
9
 ...
static ServiceProvider RegisterServices() {
var services = new ServiceCollection();
// services.AddSingleton<IGreetingService, GreetingService>();
services.AddSingleton<IGreetingService, SayHelloService>();
services.AddTransient<HomeController>();
return services.BuildServiceProvider();
}
...
$ dotnet run

Say 哈囉,Emily, Hello Tainan!

如果同時註冊 GreetingService 與 SayHelloService 那又會如何? 如果多次將相同的介面協議添加到服務集合(ServiceCollection)中,最後一個註冊的介面協議就會從容器中獲得介面。

ASP.NET Core Startup

ASP.NET Core 提供內建服務容器 IServiceProvider。服務會在應用程式的 Startup.ConfigureServices 方法中註冊。

ASP.NET Core 的應用程式啟動使用 Startup 類別,其依慣例命名為 Startup。 Startup 類別可設定服務和應用程式的要求管線。

Startup 類別選擇性地包含 ConfigureServices 方法來設定應用程式的服務,服務透過依賴項注入 (DI),在應用中使用 ConfigureServices 來註冊和使用。

另外 Startup 類也包含 Configure 方法來建立應用程式的要求處理管線(Pipeline,Middlewares)。

瞭解了依賴注入 (Dependency Injection) 與依賴注入容器,應該可以更理解王智民老師的教育訓練課程中的有關 MVC 控制器內依賴注入。

祝,Coding 快樂!

終於開始今年的技術性教育訓練了,有了新的學習標的總是讓我很興奮,有幸王智民老師有了精闢的講解,收穫良多。

我是老派的軟體工程師,也因為工作環境,文書編輯器一直是必備的工具,有時需要用到 GUI 介面的開發工具光安裝就讓人頭痛。 Oracle SQL Developer 如非必要總不會打開,至今 Oracle SQL Developer 對我還是很陌生,SQL Plus 是我的最愛。

安裝一個 Visual Studio 需要 20-50 GB 的可用空間,喔! 也許有人的硬碟就爆掉了,在 Windows 環境 C 硬碟千萬要留大一些,如果可以選擇就不要將軟體工具裝在 C 槽。這裡是使用 Visual Studio Code 開發 .NET 專案的簡介,希望能對各位有些幫助。

這裡就用 Visual Studio Code 將王智民老師第一堂課的程式碼走一次,但我使用的是較新的版本 .NET Core 3.1。因為 .NET Core 2.2 已於去年就 EOL 了。

安裝

首先要安裝 Visual Studio Code.Net Code SDK。 記得要安裝 SDK 版。

這裡有一些快速指引 Visual Studio Code Working with C#,最好也把 VS Code 的兩個 C# Extensions 安裝起來:

安裝完成後,開啟 VSCode,打開一個終端視窗或命令提示字元輸入:

1
dotnet --version

如果一卻正常會返回 .NET Code 的版本訊息,那我們就可以開始了。

往後我們常會用到 donnet 指令,例如建立解決方案、建立專案、安裝 NuGet Packages、或顯示本機的一些 .NET 設定例如:

1
dotnet nuget locals all --list

可以顯示本機安裝 NuGet Packages 的目錄。

解決方案與專案設定

我們先在電腦裡面用一個空的資料夾建立 TrainSample 的 .NET 解決方案。然後使用 VSCode 打開解決方案資料夾 TrainSample,開啟一個終端視窗,我們需要用 dotnet 初始化解決方案,確定位於 TrainSample 目錄中:

1
dotnet new sln

範本 “Solution File” 建立成功後,目錄下會產生一個 TrainSample.sln 檔案。

接著我們要在這個解決方案目錄下產生專案 MyWeb。你可以使用 dotnet new –help 查詢 SDK 所提供的各類專案的基本初始範本,這裡我們會建立一個空的 web 基本範本:

1
dotnet new web --name MyWeb 

這會在解決方案目錄下產生 MyWeb 專案目錄,接下來我們就要切換到 MyWeb 目錄,開始我們的程式旅途了。

這是 MyWeb 專案目錄初始的目錄架構,包含專案的設定檔 MyWeb.csproj 及基本範例的程式碼,就從這裡開始。首先啟動專案看看這個範例的結果。

1
dotnet run 

啟動後有一些訊息

打開瀏覽器看看結果:

這裡當執行 dotnet run 時,其實 .NET 事先會執行 dotnet build 打包的工作,會產生編譯後的執行檔,可以在專案目錄的 bin 子目錄下發現 MyWeb.exe、MyWeb.dll 等檔案,你可以從檔案總管中直接打開 MyWeb.exe。

首先看一下專案設定檔 MyWeb.csproj。

1
2
3
4
5
6
7
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>

</Project>

目前就這樣,紀錄的東西不多,往後如果專案需要安裝 Packages,將會記錄在這裡。 第一堂課所需要 Packages 都已內含不用再另行安裝。

我們就先把未來會用到的 Oracle Package 安裝起來看看。我們可以到 NuGet Gallery 尋找需要的 Packages。

這裡需要 .NET Core 的版本。

將指令複製到你的 VS Code 終端螢幕:

1
dotnet add package Oracle.ManagedDataAccess.Core --version 2.19.60

安裝成功後將會更新 MyWeb.csproj,多了一個 ItemGroup。

1
2
3
<ItemGroup>
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="2.19.60" />
</ItemGroup>

可以觀察一下安裝 Package 的訊息。

  Writing C:\Users\76070049\AppData\Local\Temp\tmpBD4F.tmp
info : 正在將套件 'Oracle.ManagedDataAccess.Core' 的 PackageReference 新增至專案 'I:\C#\TrainSample\MyWeb\MyWeb.csproj'。
info : 正在還原 I:\C#\TrainSample\MyWeb\MyWeb.csproj 的封裝...
info : GET https://api.nuget.org/v3-flatcontainer/oracle.manageddataaccess.core/index.json
info : OK https://api.nuget.org/v3-flatcontainer/oracle.manageddataaccess.core/index.json 710 毫秒
info : GET https://api.nuget.org/v3-flatcontainer/oracle.manageddataaccess.core/2.19.60/oracle.manageddataaccess.core.2.19.60.nupkge.2.19.60.nupkg .2.19.60.nupkg 720 毫秒
info : OK https://api.nuget.org/v3-flatcontainer/oracle.manageddataaccess.core/2.19.60/oracle.manageddataaccess.core.2.19.60.nupkg 720 毫秒
info : 正在安裝 Oracle.ManagedDataAccess.Core 2.19.60。 \MyWeb.csproj'。
info : 套件 'Oracle.ManagedDataAccess.Core' 與專案 'I:\C#\TrainSample\MyWeb\MyWeb.csproj' 中的所有架構相容。
info : 已將套件 'Oracle.ManagedDataAccess.Core' 版本 '2.19.60' 的 PackageReference 新增至檔案 'I:\C#\TrainSample\MyWeb\MyWeb.csproj'。
info : 正在認可還原...
info : 正在將資產檔案寫入磁碟。路徑: I:\C#\TrainSample\MyWeb\obj\project.assets.json
log : I:\C#\TrainSample\MyWeb\MyWeb.csproj 的還原於 3.98 sec 完成。

注意這裡會直接上網下載安裝,如果你無法安裝,有可能是你的電腦被禁止上網,注意最近資訊部的 SSL 政策

有沒有發覺 Packages 的原始碼並沒有安裝在專案目錄中,這與 Node.js 不同,你可以用先前提到的 dotnet nuget locals all –list 查詢 NuGet Packages 的目錄。

開始

當我們下了 dotnet run,.NET 會搜尋有 Main 方法的程式碼,這就是專案的入口點,慣例都會是 Program.cs。修改之前你可以看一下,比較特別的是使用泛型 webBuilder.UseStartup<Startup>( ); 來啟動 Web Server。

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace MyWeb
{
public class Program
{
public static void Main(string[] args)
{
MyOutput("(Main) Application start...");

CreateHostBuilder(args).Build().Run();

MyOutput("(Main) Application end...");
}

public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});

public static void MyOutput(string message)
{
Console.WriteLine($"[{DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss")}] {message}");
}
}
}
  • 第 30 行加入 MyOutput 靜態方法,這裡沒有使用 Debug 模組,在 VS Code 中開啟 Debug output 比較麻煩,這裡直接使用系統的終端螢幕顯示。
  • 第 27 行使用泛型啟動 Web Server 比較特殊,也因此可以用同一個方法啟動不同類型的 Web Server。也可以直接使用方法鏈接改變預設的的 Web Server 與監聽的 Port。
Program.cs
1
2
3
4
5
6
7
8
9
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder
.UseStartup<Startup>()
.UseKestrel()
.UseUrls("http://localhost:3000");
});
  • 第 7 行可以不要,預設值本來就是 Kestrel。
  • 第 8 行直接改變監聽的 Port。

這裡直接使用泛型啟動 Startup 類別,現在來看看 Startup.cs。

Startup.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace MyWeb
{
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

app.UseRouting();

app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Hello World!");
});
});
}
}
}

這裡使用的方法與上課的程式碼有些不同,但觀念是一樣的。

  • 第 22 行使用了不同的 IWebHostEnvironment Interface,但用法一樣。
  • 第 31 行用 app.UseEndPoints 定義 HTTP Endpoints,可以直接在此加入另外一個端點。
Startup.cs
1
2
3
4
5
6
7
8
9
10
11
12
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Hello World!");
});

endpoints.MapGet("/tainan", async context =>
{
await context.Response.WriteAsync("Hello Tainan! 哈囉,台南!");
});
});

這是最基本的端點設定,稍後再來看 Middlewares。現在則來看看 Application LifeTime 流程。以下是加入 Application Lifttime 的程式碼,試著自己 Coding,不要用複製/貼上,否則你不會發現魔鬼。

Startup.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using IHostApplicationLifetime = Microsoft.Extensions.Hosting.IHostApplicationLifetime;

namespace MyWeb
{
public class Startup
{
public Startup() {
Program.MyOutput("Startup Contructor called.");
}
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
Program.MyOutput("Startup.ConfigureServices called.");
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IHostApplicationLifetime appLifetime)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

appLifetime.ApplicationStarted.Register(() =>
{
Program.MyOutput("ApplicationLifetime - Started.");
});

appLifetime.ApplicationStopping.Register(() =>
{
Program.MyOutput("ApplicationLifetime - Stopping.");
});

appLifetime.ApplicationStopped.Register(() =>
{
Thread.Sleep(5 * 1000);
Program.MyOutput("ApplicationLifetime - Stopped.");
});

app.UseRouting();

app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Hello World!");
});

endpoints.MapGet("/tainan", async context =>
{
await context.Response.WriteAsync("Hello Tainan! 哈囉,台南!");
});
});

var thread = new Thread(new ThreadStart(() =>
{
Thread.Sleep(5 * 1000);
Program.MyOutput("Trigger stop WebHost.");
appLifetime.StopApplication();
}));

thread.Start();

Program.MyOutput("Startup Configure - Called.");
}
}
}
  • 第 4 行加入 System.Threading 命名空間,因為第 66 行會用到 Thread 類別。
  • 第 11 行加入了一個別名(Alias) IHostApplicationLifetime 因舊的 IApplicationLifetime 已遭廢棄,這裡又同時有兩個 Package 都含有 IHostApplicationLifetime 介面,這裡用一個 Alias 直接指名。IHostApplicationLifetime 會用在第 28 行。
  • 第 28 行加入新的 IWebHostEnvironment 與 Microsoft.Extensions.Hosting.IHostApplicationLifetime 介面,舊的 IHostingEnvironment 與 IApplicationLifetime 已遭廢棄。
  • 第 35 ~ 49 行、66 ~ 73 行沒有不同。

執行結果應該會是:

[2020/04/17 15:19:54] (Main) Application start...
[2020/04/17 15:19:54] Startup Contructor called.
[2020/04/17 15:19:54] Startup.ConfigureServices called.
[2020/04/17 15:19:54] Startup Configure - Called
[2020/04/17 15:19:54] ApplicationLifetime - Started
info: Microsoft.Hosting.Lifetime[0]
Now listening on: http://localhost:3000
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: I:\C#\TrainSample\MyWeb
[2020/04/17 15:19:59] Trigger stop WebHost
[2020/04/17 15:19:59] ApplicationLifetime - Stopping
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
[2020/04/17 15:20:04] ApplicationLifetime - Stopped
[2020/04/17 15:20:04] (Main) Application end...

接著就要來看 Middlewares,在這之前將 66 ~ 73 行程式碼加上註解,否則每次啟動後 5 秒鐘 Web Server 就自動下架了。

Startup.cs
1
2
3
4
5
6
7
8
// var thread = new Thread(new ThreadStart(() =>
// {
// Thread.Sleep(5 * 1000);
// Program.MyOutput("Trigger stop WebHost.");
// appLifetime.StopApplication();
// }));

// thread.Start();

Middlewares

使用 Middlewares 可讓程式碼更有彈性及模組化,可以使用 IApplicationBuilder 介面的 Use 方法註冊。首先我們使用 app.Use( ) 直接在 Startup.cs 中註冊一個 Middleware,但直接在 Startup.cs 中加入 Middleware 程式碼不是好的習慣,會讓程式碼太過龐大很難維護,稍後我們會將 Middlewares 模組化。但我們先來了解 Middlewares 的基本概念。

Startup.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
app.Use(async (context, next) =>
{
if (context.Request.Path == "/tainan")
await context.Response.WriteAsync("ASP.Net Core Rocks! Tainan! \n");
else
{
await next();
await context.Response.WriteAsync("Powered by ASP.NET Core \n");
}
});

app.UseRouting();

app.UseEndpoints(endpoints =>
...

在第 50 行的地方插入這幾行程式碼註冊一個 Middleware,這裡插在 app.UseEndpoints 之前。使用 HttpContext.Request 物件的 Path 屬性取得 Url Path,如果是 /tainan 就回應一段字串,否則執行 next( ) 然後再返回一段字串。現在可以實際來看看它的結果。

先看左邊的圖示 Url Path 是 /tainan,它的結果如我們程式碼返回的字串,然後就結束了。後面的 endpoints.MapGet(“/tainan” …) 並沒有執行,因為我們沒有呼叫 next( ) 繼續往下執行。

右邊則是 / 根 Path,因為呼叫 next( ),所以先執行 endpoints.MapGet(“/“ …) 返回 “Hello World!”, 然後返回呼叫它的地方繼續往下執行,返回 “Powered by ASP.NET Core” 字串。

Middlewares 的位置順序很重要,我們將這段 Middleware 程式碼移到 app.UseEndpoints(endpoints => …) 後面,然後重新啟動。

Startup.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
...
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Hello World!");
});

endpoints.MapGet("/tainan", async context =>
{
await context.Response.WriteAsync("Hello Tainan! 哈囉,台南!");
});
});

app.Use(async (context, next) =>
{
if (context.Request.Path == "/tainan")
await context.Response.WriteAsync("ASP.Net Core Rocks! Tainan! \n");
else
{
await next();
await context.Response.WriteAsync("Powered by ASP.NET Core \n");
}
});
...

現在這個 Middleware 毫無作用。

將它回復到原地方。先前談過將所有 Middlewares 都放在同一個程式碼將很難維護,也很難重複利用,通常都必須模組化程式碼,就來加入一段模組化的 Middleware。

首先在專案目錄下建立子目錄 Middlewares,然後在 Middlewares 目錄按滑鼠右鍵,選擇 “New C# Class”, 你必須安裝 VS Code C# 與 C# Extensions 的 EXTENSIONS。

這兩個 Extensions 是在安裝 .NET 時就會建議的套件。

以下是新的 FirstMiddleware 型態。

FirstMiddleware.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace MyWeb.Middlewares
{
public class FirstMiddleware
{
private readonly RequestDelegate _next;

public FirstMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task Invoke(HttpContext context)
{
await context.Response.WriteAsync($"{nameof(FirstMiddleware)} IN. \n");
await _next(context);
await context.Response.WriteAsync($"{nameof(FirstMiddleware)} OUT. \n");
}
}
}

程式碼與上課的教材沒甚麼差異。現在可以將它加入 Startup.cs。

FirstMiddleware.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
using MyWeb.Middlewares;
...
app.UseMiddleware<FirstMiddleware>();

app.Use(async (context, next) =>
{
if (context.Request.Path == "/tainan")
await context.Response.WriteAsync("ASP.Net Core Rocks! Tainan! \n");
else
{
await next();
await context.Response.WriteAsync("Powered by ASP.NET Core \n");
}
});
...

第 4 行使用 app.UseMiddleware( ) 泛型加入 FirstMiddleware,注意它的位置,將它放在先前 Middleware 之前。

也可以用 C# 擴充方法將 FirstMiddleware 包裝成靜態方法。新建一個子目錄來擺這些 Extensions,然後在這個目錄下建一個新的類別 CustomMiddlewareExtension。

CustomMiddlewareExtension.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
using Microsoft.AspNetCore.Builder;
using MyWeb.Middlewares;

namespace MyWeb.Extensions
{
public static class CustomMiddlewareExtension
{
public static IApplicationBuilder UseFirstMiddleware(this IApplicationBuilder builder)
{
return builder.UseMiddleware<FirstMiddleware>();
}
}
}

現在修改 Startup.cs 改用 CustomMiddlewareExtension。

CustomMiddlewareExtension.cs
1
2
3
4
5
6
7
...
// using MyWeb.Middlewares;
using MyWeb.Extensions;
...
// app.UseMiddleware<FirstMiddleware>();
app.UseFirstMiddleware();
...

這將會有相同的結果,最後的 VS Code 專案目錄結構如下,注意對照一下 namespace 的習慣命名,你也可以將所有的 namespace 都改為與 Program.cs 一樣的 MyWeb,這樣你就不用使用 using import MyWeb.Middlewares namespace,但這似乎不是好的模組命名架構。

祝 有個愉快的 Coding 與學習!

Cache 伺服端快取

在高負載的應用程式中,快取經常扮演著關鍵性的角色,而且幾乎在網路上四處可見。從網頁、圖片與樣式表等靜態資源,以及資料庫查詢結果這類純粹的資料,都經常會涉及到快取的使用。

快取模式在伺服器端,可以緩解硬體 I/O 問題,在客戶端則可緩解網路 I/O 問題。我們在 Node.js 教育訓練中學到的伺服器端的快取機制,就可以拿來應用。

首先我們來看看會產生效能問題的地方,我們看下面這個 GraphQL query:

GraphQL query listEmployees
1
2
3
4
5
6
7
8
9
10
11
12
13
query listEmployees {
totalEmployees
allEmployees {
empno
ename
hiredate
income
department {
dname
loc
}
}
}

在這個 GraphQL query listEmployees 中的 allEmployees 你會發覺問題應會出現在 department 的邊(edge)連結上,看一下他的解析函式,我們在第 7 行它從資料庫實際返回資料的地方加入一個 console.log 來偵測它的行為:

resolvers/employee.js
1
2
3
4
5
6
7
8
9
10
11
...
Employee: {
department: (parent, args, { orademo }) => {
return orademo.department
.findByDeptno(parent.deptno)
.then(result => {
console.log(result.rows[0]);
return result.rows[0]
});
},
...

現在重新從客戶端的 GraphQL Playground 執行 listEmployees query,你可以從伺服器端的終端螢幕上看到:

GraphQL Server console.log
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{ deptno: 10, dname: '會計部', loc: '紐約' }
{ deptno: 30, dname: 'SALES', loc: 'CHICAGO' }
{ deptno: 10, dname: '會計部', loc: '紐約' }
{ deptno: 20, dname: 'RESEARCH', loc: 'DALLAS' }
{ deptno: 20, dname: 'RESEARCH', loc: 'DALLAS' }
{ deptno: 20, dname: 'RESEARCH', loc: 'DALLAS' }
{ deptno: 30, dname: 'SALES', loc: 'CHICAGO' }
{ deptno: 20, dname: 'RESEARCH', loc: 'DALLAS' }
{ deptno: 30, dname: 'SALES', loc: 'CHICAGO' }
{ deptno: 40, dname: 'OPERATIONS', loc: 'BOSTON' }
{ deptno: 30, dname: 'SALES', loc: 'CHICAGO' }
{ deptno: 20, dname: 'RESEARCH', loc: 'DALLAS' }
{ deptno: 10, dname: '會計部', loc: '紐約' }
{ deptno: 30, dname: 'SALES', loc: 'CHICAGO' }
{ deptno: 70, dname: '資訊部', loc: '永康 B4' }
{ deptno: 70, dname: '資訊部', loc: '永康 B4' }
{ deptno: 40, dname: 'OPERATIONS', loc: 'BOSTON' }
{ deptno: 70, dname: '資訊部', loc: '永康 B4' }
{ deptno: 10, dname: '會計部', loc: '紐約' }

這裡有 19 筆員工,因此對資料庫送出了 19 次的要求,資料庫執行了 19 次的查詢,但我們其實只有 5 個部門,所以有 14 次的資料庫讀取操作是重複的,如果你有幾萬個員工,那會怎麼樣? 這是利用快取模式最好的地方,讓我們將快取機制加進來。

首先將 Node.js 教育訓練文件拿出來複習一下,這裡會用到文件第 98 頁的 Promise 快取模式。為了保留原始的程式碼,我們產生一個新的 resolvers/employee-cache.js 程式檔,把快取機制加進來。

在非同步控制流程模式裡,承諾(Promise)能夠大幅簡化非同步程式碼,不過在處理批次及快取處理時,它也能夠提供不錯的助益。若回想一下,承諾有兩項特性能夠讓我們加以應用

  • 多個 then( ) 監聽器可附加至相同的承諾。
  • then( ) 監聽器保證只會被呼叫一次,並且即便是在承諾已解析(resolved)後才附加,也依然可以運作。不僅如此,then() 也保證一定以非同步的方式被呼叫。

前述的第一項特性便是在批次處理請求時所需要的,而第二項特性即表示承諾已經是已解析值的快取,也就能夠自然以一致的非同步方式,回傳快取值。基於以上設想,這就表示以承諾來實現批次及快取處理,是相當簡單明瞭的。

resolvers/employee-cache.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const cache = {};

module.exports = {
Query: {
totalEmployees: (parent, args, { orademo }) =>
orademo.employee.findAll().then(result => result.rows.length),
allEmployees: (parent, args, { orademo }) => {
return orademo.employee.findAll()
.then(result => result.rows );
}
},
Employee: {
department: (parent, args, { orademo }) => {
if (cache[parent.deptno]) {
console.log(`cache hit => ${parent.deptno}:${parent.empno}`);
return cache[parent.deptno];
}

cache[parent.deptno] = orademo.department
.findByDeptno(parent.deptno)
.then(result => {
console.log(`${JSON.stringify(result.rows[0])} => ${parent.deptno}:${parent.empno}`);
setTimeout(() => { delete cache[parent.deptno] }, 30 * 1000);

return result.rows[0]
}).catch (err => {
delete cache[parent.deptno];
});

return cache[parent.deptno];
},
income: (parent) => {
let sal = !isNaN(parseFloat(parent.sal)) ? parseFloat(parent.sal) : 0;
let comm = !isNaN(parseFloat(parent.comm)) ? parseFloat(parent.comm) : 0;
return sal + comm;
}
},
};
  • 在這個例子中我們將使用記憶體存儲快取,第一行我們使用一個物件 cache 當成快取。

  • 我們在第 13 行 department 解析函式中我們加入了快取模式。第 19 行我們對相同部門的資料庫查詢(使用 Promise)存入快取中,這在第 14 行中檢查這個相同部門的 Promise 快取是否已存在,如果存在,則可直接使用這個相同的 Promise,不用再對資料庫提出相同的要求。

  • 第 23 行加入了一個到期時間機制,30 秒後會自動刪除快取。

在這裡,當承諾解析時,設定快取清除時間為 30 秒,並回傳資料庫返回的結果 ,將這個結果傳遞給附加在承諾上的其它 then( ) 監聽器。

然後要修改 resolvers/index.js 改用 employee-cache.js 解析函式。

resolvers/index.js
1
2
3
...
const employeeResolvers = require('./employee-cache');
...

重新啟動 GraphQL 伺服器後,重新執行 query listEmployees,伺服器端的終端螢幕將可以看到快取機制的運作。

query allEmployees GraphQL server console
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cache hit => 10:7782
cache hit => 20:7788
cache hit => 20:7902
cache hit => 20:7369
cache hit => 30:7499
cache hit => 30:7654
cache hit => 30:7844
cache hit => 20:7876
cache hit => 30:7900
cache hit => 10:7934
cache hit => 70:7607
cache hit => 70:7609
cache hit => 40:9011
cache hit => 10:8907
{ deptno: 10, dname: '會計部', loc: '紐約' }
{ deptno: 30, dname: 'SALES', loc: 'CHICAGO' }
{ deptno: 20, dname: 'RESEARCH', loc: 'DALLAS' }
{ deptno: 40, dname: 'OPERATIONS', loc: 'BOSTON' }
{ deptno: 70, dname: '資訊部', loc: '永康 B4' }

這裡我們只對資料庫讀取了 5 次,其他則來自快取。

這種快取方便又優雅,但是它是使用記憶體來儲存快取,與 GraphQL 伺服器共用約 1.5G 的記憶體,如果你的快取非常大,就不適合了。要想辦法將快取移出伺服器,最佳的選擇就是 Redis

使用 Redis 實作快取模式

使用 Redis 實作快取可以解決記憶體的問題,也可以跨不同的 GraphQL 伺服器共享快取機制。最好在專案開始就要考慮效能的問題,將快取模式加進來。當然最好的選擇就是 Redis。

我們要新增一個解析函式檔來實作 Redis Cache 模式,resolvers/employee-redis.js。但在這之前我們要先做一個 Redis 操作的抽象層。

首先要安裝 node_redis 套件,這是一個 Redis 客戶端套件:

redis install
1
npm install redis --save

在 models 子目錄下新建程式檔 models/redisCache.js:

models/redisCache.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const redis = require("redis");

const client = redis.createClient({
host: "10.11.xx.xxx",
port: 6379,
password: "iscat",
db: 0
});

const cachePrefix = "graphql:orademo:cache:";

function RedisCache(cacheName, expire = 30) {
this._cache = cachePrefix + cacheName + ':';
this._expire = expire;
}

RedisCache.prototype.set = function(item, value) {
return new Promise((resolve, reject) => {
const _this = this;
client.set(_this._cache + item, value, "EX", _this._expire, (err, result ) => {
if (err) return reject(err);

return resolve(result);
});
});
}

RedisCache.prototype.get = function(item) {
return new Promise((resolve, reject) => {
const _this = this;
client.get(_this._cache + item, (err, result) => {
if (err) return reject(err);

return resolve(result);
});
});
};

RedisCache.prototype.quit = function() {
client.quit();
};

module.exports = RedisCache;
  • 第 10 行將會是 Redis key 的一部分,稍後我們會看實際存儲到 Redis 時 key 樣子。在 Redis 生態圈習慣上使用冒號 ( : ) 來分隔名稱的不同部分,以此來構建命名空間 (namespace)。

  • 第 12 行 RedisCache 是一個建構式,expire 引數可以訂到期时间,預設值是 30 秒。30 秒一到 Redis 將自動清除資料。

  • 第 17 行 set 方法會將資料存入 Redis。如果原來的鍵(key)已存在,則會將舊資料(value)蓋掉。存入時可同時設定到期時間。

  • 第 28 行 get 方法可以從 Redis 依鍵(key)取出資料,如果資料不存在(鍵不存在)會返回 null 值。

接著我們將這個抽象層應用在 resolvers/employee-redis.js 中。

resolvers/employee-redis.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const RedisCache = require("../models/redisCache");
const cache = new RedisCache('department', 30);
const queues = {};

module.exports = {
Query: {
totalEmployees: (parent, args, { orademo }) =>
orademo.employee.findAll().then(result => result.rows.length),
allEmployees: (parent, args, { orademo }) => {
return orademo.employee.findAll()
.then(result => result.rows );
}
},
Employee: {
department: async ({ deptno, empno }, args, { orademo }) => {
let value = await cache.get(deptno);

if (value) {
console.log(`cache hit: ${value} => ${deptno}:${empno}`);
return JSON.parse(value);
}

if (queues[deptno]) {
console.log(`Batching operation: ${deptno}:${empno}:queues:${Object.keys(queues).length}`);
return queues[deptno];
}

queues[deptno] = orademo.department
.findByDeptno(deptno)
.then(result => {
cache.set(deptno, JSON.stringify(result.rows[0]))
.then((ack) => {
console.log(`${ack}: ${JSON.stringify(result.rows[0])}`);
process.nextTick(() => {
delete queues[deptno];
});
});
return result.rows[0];
}).catch (err => {
delete queues[deptno];
});

return queues[deptno];
},
income: (parent) => {
let sal = !isNaN(parseFloat(parent.sal)) ? parseFloat(parent.sal) : 0;
let comm = !isNaN(parseFloat(parent.comm)) ? parseFloat(parent.comm) : 0;
return sal + comm;
}
},
};
  • 第 2 行使用 RedisCache 建構式建立一個 department 快取,這是個 Redis cache。

  • 第 3 行我們要搭配記憶體快取一起使用。這會在記憶體中存儲一些 Promise 的快取。
    在非同步控制流程模式裡,對資料庫發出的請求,併不會等待資料的回覆,所以當 GraphQL 伺服器對資料庫發出所有的請求時,有可能資料庫都尚未回覆,Redis 中也不會有快取的資料,所以如果不將已經請求過的相同請求(相同部門的請求)存入記憶體快取,就會造成資料庫的重複請求,所以這裡要搭配前面例子記憶體的 Promise 快取,攔截對資料庫的重複請求。一旦資料從資料庫返回並存入 Redis 快取,就會將記憶體中的 Promise 快取消除,之後的請求就會從 Redis 快取取得資料。使用變數 queues 當成記憶體快取。

  • 第 16 行第一優先從 Redis 快取取得資料,如果快取不存在 Redis 會返回 null 值。

  • 第 23 行則是攔截已經送出但尚未回覆的相同資料庫請求,如果存在則直接返回 queues 記憶體快取中相同的 Promise。

  • 第 28 行則是實際對資料庫發出請求的地方,31 行在實際資料返回後將資料存入 Redis 快取中,接著在 35 行刪除記憶體中的 Promise 快取。

現在修改 resolvers/index.js :

resolvers/index.js
1
2
3
...
const employeeResolvers = require('./employee-redis');
...

從新啟動 GraphQL 伺服器後,重新送出先前的 GraphQL listEmployees query:

GraphQL query listEmployees
1
2
3
4
5
6
7
8
9
10
11
12
13
query listEmployees {
totalEmployees
allEmployees {
empno
ename
hiredate
income
department {
dname
loc
}
}
}

現在可從 GraphQL 伺服器終端螢幕上看到:

GraphQL Server console
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Batching operation: 10:7782:queues:2
Batching operation: 20:7788:queues:3
Batching operation: 20:7902:queues:3
Batching operation: 20:7369:queues:3
Batching operation: 30:7499:queues:3
Batching operation: 30:7654:queues:4
Batching operation: 30:7844:queues:4
Batching operation: 20:7876:queues:4
Batching operation: 30:7900:queues:4
Batching operation: 10:7934:queues:4
Batching operation: 70:7607:queues:5
Batching operation: 70:7609:queues:5
Batching operation: 40:9011:queues:5
Batching operation: 10:8907:queues:5
OK: {"deptno":10,"dname":"會計部","loc":"紐約"}
OK: {"deptno":30,"dname":"SALES","loc":"CHICAGO"}
OK: {"deptno":40,"dname":"OPERATIONS","loc":"BOSTON"}
OK: {"deptno":20,"dname":"RESEARCH","loc":"DALLAS"}
OK: {"deptno":70,"dname":"資訊部","loc":"永康 B4"}

因為我們只有 5 個不同的部門,所以只有最後 5 個 OK 是實際從資料庫返回併寫入 Redis 快取時發出的訊息,前面 14 個 Batching operation 則是從記憶體快取中發出的,這裡都沒有用到 Redis 快取,因為資料還來不及寫入 Redis 快取,GraphQL 伺服器就都已經發出所有的請求了。

但是你如果在 30 秒鐘內再從 GraphQL Playground 發出第二次 listEmployees query,則 GraphQL 伺服器的終端螢幕會是:

GraphQL Server console
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cache hit:  {"deptno":10,"dname":"會計部","loc":"紐約"} => 10:7839
cache hit: {"deptno":30,"dname":"SALES","loc":"CHICAGO"} => 30:7698
cache hit: {"deptno":10,"dname":"會計部","loc":"紐約"} => 10:7782
cache hit: {"deptno":20,"dname":"RESEARCH","loc":"DALLAS"} => 20:7566
cache hit: {"deptno":20,"dname":"RESEARCH","loc":"DALLAS"} => 20:7788
cache hit: {"deptno":20,"dname":"RESEARCH","loc":"DALLAS"} => 20:7902
cache hit: {"deptno":20,"dname":"RESEARCH","loc":"DALLAS"} => 20:7369
cache hit: {"deptno":30,"dname":"SALES","loc":"CHICAGO"} => 30:7499
cache hit: {"deptno":40,"dname":"OPERATIONS","loc":"BOSTON"} => 40:7608
cache hit: {"deptno":30,"dname":"SALES","loc":"CHICAGO"} => 30:7654
cache hit: {"deptno":30,"dname":"SALES","loc":"CHICAGO"} => 30:7844
cache hit: {"deptno":20,"dname":"RESEARCH","loc":"DALLAS"} => 20:7876
cache hit: {"deptno":30,"dname":"SALES","loc":"CHICAGO"} => 30:7900
cache hit: {"deptno":10,"dname":"會計部","loc":"紐約"} => 10:7934
cache hit: {"deptno":70,"dname":"資訊部","loc":"永康 B4"} => 70:9006
cache hit: {"deptno":70,"dname":"資訊部","loc":"永康 B4"} => 70:7607
cache hit: {"deptno":70,"dname":"資訊部","loc":"永康 B4"} => 70:7609
cache hit: {"deptno":40,"dname":"OPERATIONS","loc":"BOSTON"} => 40:9011
cache hit: {"deptno":10,"dname":"會計部","loc":"紐約"} => 10:8907

這裡全部從 Redis 快取中取得了資料,完全沒有對 Oracle Database 發出請求。

從 Redis 中可以查詢這些快取資料:

Redis using redis-cli
1
2
3
4
5
6
7
8
9
10
11
12
13
127.0.0.1:6379> scan 0 match graphql*
1) "0"
2) 1) "graphql:orademo:cache:department:40"
2) "graphql:orademo:cache:department:10"
3) "graphql:orademo:cache:department:70"
4) "graphql:orademo:cache:department:30"
5) "graphql:orademo:cache:department:20"
127.0.0.1:6379> get graphql:orademo:cache:department:20
"{\"deptno\":20,\"dname\":\"RESEARCH\",\"loc\":\"DALLAS\"}"
127.0.0.1:6379> ttl graphql:orademo:cache:department:20
(integer) 16
127.0.0.1:6379> exists graphql:orademo:cache:department:20
(integer) 0

“graphql:orademo:cache:department:20” 就是我們設定的鍵(key),第 8 行用鍵取出設定的值,這裡我們存入的是 JSON 字符串。第 10 行可以使用 ttl 在鍵過期前查看剩餘的時間,等待 16 秒後可以使用 exists 判斷鍵是否還存在,這時將會返回 0。

在 Redis 生態圈習慣上使用冒號 ( : ) 來分隔名稱的不同部分,以此來構建命名空間 (namespace)。另外還有一些常見的分隔符,例如句號 ( . )、斜線 ( / ),有些人甚至還會使用管道符號 ( | ),無論使用哪個符號來做分隔符,都要保持分隔符號的一致性。

存在 Redis 的快取可以在各不同的伺服器中分享,是伺服端快取模式的最佳選擇。

過 30 秒後再度查詢,資料已不見了,Redis 會幫我們管理這個到期時間策略。

Redis using redis-cli
1
2
3
4
127.0.0.1:6379> scan 0 match graphql*
1) "0"
2) (empty list or set)
127.0.0.1:6379>

Redis 是一個速度非常快的非關聯式資料庫 (no-relational database),它可以存儲鍵 (key) 與 5 種不同類型的值 (value) 之間的映射 (mapping), 可以將存儲在記憶體的鍵值對數據持久化 (persistence) 到硬碟,可以使用複製來擴展讀取的性能,還可以使用客戶端分片 (client-side sharding) 來擴展寫入的性能。這裡我們只用到一種型態的值 String,存儲的是 JSON 格式字串。

Redis 的數據結構致力於幫助用戶解決問題,而不會像其他資料庫那樣,要求用戶扭曲問題來適應資料庫。Redis 是一個可以用來解決問題的工具,它既擁有其他資料庫不具備的數據結構,又擁有記憶體存儲 ( 這使的 Redis 速度非常快 )、遠程 ( 這使的 Redis 可以與多個客戶端和伺服器進行連接 )、持久化 ( 這使的 Redis 在重啟之後仍然保持重啟之前的數據 ) 和可擴展性 ( 透過主從複製和分片 ) 等多個特性,這使的我們可以以熟悉的方式為各種不同的問題構建解決方案。

使用 Oracle 資料庫

Oracle 還是目前我們使用的主要資料庫,以下會是使用 Oracle 資料庫的範例,還是使用 EMP 與 DEPT。

我們從 models 開始,這裡將會設定 Oracle 資料庫的連結與及資料表的 API。在專案目錄 models 中新增 oradb.config.js:

models/oradb.config.js
1
2
3
4
5
6
7
8
module.exports = {
user: "xxxx",
password: "xxxxxxx",
connectString: "10.11.xx.xxx:1522/xxx.xxx.com.tw",
poolMin: 0,
poolMax: 2,
poolIncrement: 1
};

這裡直接使用 Oracle connection pool,可依需求調整,這裡使用 demo SCHEMA。

接下來是資料庫的抽象層,oradb.database.js,這與之前 Node.js 教育訓練 REST API 的程式碼相同:

models/oradb.database.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
const oracledb = require('oracledb');
const dbConfig = require('./oradb.config');

async function initialize() {
try {
await oracledb.createPool(dbConfig);
} catch(err) {
console.log(err.message);
}
}

async function close() {
try {
await oracledb.getPool().close(10);
} catch (err) {
console.error(err.message);
}
}

function doExecute(statement, binds = [], opts = {}) {
return new Promise(async (resolve, reject) => {
let conn;

opts.outFormat = oracledb.OBJECT;
opts.autoCommit = true;

try {
conn = await oracledb.getConnection();
const result = await conn.execute(statement, binds, opts);
resolve(result);
} catch (err) {
reject(err);
} finally {
if (conn) {
try {
await conn.close();
} catch (err) {
console.log(err);
}
}
}
});
}

function doExecuteMany(statement, binds = [], opts = {}) {
return new Promise(async (resolve, reject) => {
let conn;

opts.outFormat = oracledb.OBJECT;
opts.autoCommit = true;
opts.batchErrors = true;

try {
conn = await oracledb.getConnection();
const result = await conn.executeMany(statement, binds, opts);
resolve(result);
} catch (err) {
reject(err);
} finally {
if (conn) {
try {
await conn.close();
} catch (err) {
console.log(err);
}
}
}
});
}

module.exports = {
initialize,
close,
doExecute,
doExecuteMany
};

這抽象層可重複用在個別的資料表 API 中。

models/orademo.department.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
const oracledb = require("oracledb");
const oradb = require("./oradb.database");
const { setValue, toLowerCaseKeys } = require("./helpers/utils");

function findAll() {
const statement = "select * from dept";

return oradb
.doExecute(statement)
.then(result => toLowerCaseKeys(result))
.catch(err => {
throw err;
});
}

function findByDeptno(deptno) {
if (!deptno) {
return Promise.reject(new Error("Invalid input"));
}

const binds = [];
const statement = "select * from dept where deptno = :deptno";
binds.push(deptno);

return oradb
.doExecute(statement, binds)
.then(result => toLowerCaseKeys(result))
.catch(err => {
throw err;
});
}

function add(data) {
const statement = `
INSERT INTO dept (deptno, dname, loc)
VALUES(:deptno, :dname, :loc)
RETURNING deptno INTO :id
`;

return new Promise(async (resolve, reject) => {
if (typeof data !== "object") {
reject(new Error("Input must be an valid object"));
}

let result;
let binds = {
deptno: setValue(data["deptno"]),
dname: setValue(data["dname"]),
loc: setValue(data["loc"]),
id: {
type: oracledb.STRING,
dir: oracledb.BIND_OUT
}
};

try {
result = await oradb.doExecute(statement, binds);
resolve(result);
} catch (err) {
reject(err);
}
});
}

function update(deptno, data) {
const statement = `
UPDATE dept d
SET d.dname = :dname,
d.loc = :loc
WHERE d.deptno = :deptno
RETURNING ROWID INTO :rid
`;

return new Promise(async (resolve, reject) => {
if (!deptno) {
reject(new Error("Invalid deptno"));
}
if (typeof data !== "object") {
reject(new Error("Input must be an valid object"));
}

let result;
let binds = {
deptno: setValue(deptno),
dname: setValue(data["dname"]),
loc: setValue(data["loc"]),
rid: {
type: oracledb.STRING,
dir: oracledb.BIND_OUT
}
};

try {
result = await oradb.doExecute(statement, binds);
resolve(result);
} catch (err) {
reject(err);
}
});
}

function remove(deptno) {
const statement = `
DELETE FROM dept
WHERE deptno = :deptno
RETURNING deptno INTO :id
`;

return new Promise(async (resolve, reject) => {
if (!deptno) {
reject(new Error("Invalid deptno"));
}

let result;
let binds = {
deptno: setValue(deptno),
id: {
type: oracledb.STRING,
dir: oracledb.BIND_OUT
}
};

try {
result = await oradb.doExecute(statement, binds);
resolve(result);
} catch (err) {
reject(err);
}
});
}

function upsert(data) {
const statement = `
MERGE INTO dept d
USING (select :deptno as deptno,
:dname as dname,
:loc as loc
from dual) p
ON (d.deptno = p.deptno)
WHEN MATCHED THEN
UPDATE SET d.dname = p.dname,
d.loc = p.loc
WHEN NOT MATCHED THEN
INSERT (d.deptno, d.dname, d.loc)
VALUES (p.deptno, p.dname, p.loc)
`;

return new Promise(async (resolve, reject) => {
if (typeof data !== "object") {
reject(new Error("Input must be an array"));
}

if (data instanceof Array && data.length > 0) {
let result;
let binds = [];

data.forEach(value => {
let item = {
deptno: setValue(value["deptno"]),
dname: setValue(value["dname"]),
loc: setValue(value["loc"])
};
binds.push(item);
});

try {
result = await oradb.doExecuteMany(statement, binds);
resolve(result);
} catch (err) {
reject(err);
}
} else {
reject(new Error("Input must be an array"));
}
});
}

module.exports = {
findAll,
findByDeptno,
add,
update,
remove,
upsert
};

Oracle 的 oracledb 驅動程式返回的 JSON 格式資料,屬性都會是大寫型態,這裡使用了一個 toLowerCaseKeys 函式將它全部轉為小寫,這個函式放在 helpers/utils 程式碼中,裡面還包含一些常會用到的小程式碼,在這裡大部份都不會用到,但可留著參考。

models/helpers/utils.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
const crypto = require("crypto");

exports.trim = (str) => str.replace(/^\s*|\s*$/g, '');

exports.setValue = (val) => val || null;

exports.isNull = isNull;

exports.isNotNull = negate(isNull);

exports.isUndefined = val => val === undefined;

exports.isEmpty = s => !s || !s.trim();

exports.isObject = (val) => val && typeof val === 'object';

exports.isArray = (val) => val && Array.isArray(val);

exports.isNumber = (val) => typeof val === 'number' && val === Number(val) && Number.isFinite(val);

exports.validNumber = val => !isNaN(parseFloat(val)) ? parseFloat(val) : null;

exports.normalize = (str) => str.replace(/\-/g, '');

exports.isValid = (str) => {
if(str.length === 0) {
return new Status(false, 'Invalid input. Expected non-empty value!');
} else {
return new Status(true, 'Success!');
}
};

exports.toLowerCaseKeys = toLowerCaseKeys;

exports.checksum = checksum;

function toLowerCaseKeys (obj) {
let newObj, value;

if (obj instanceof Array) {
return obj.map(value => {
if (typeof value === "object") {
value = toLowerCaseKeys(value);
}
return value;
});
} else {
newObj = {};

Object.keys(obj).forEach(origKey => {
let newKey = (origKey.toLowerCase() || origKey).toString();
value = obj[origKey];
if (
value instanceof Array ||
(value !== null && value.constructor === Object)
) {
value = toLowerCaseKeys(value);
}
newObj[newKey] = value;
});
}
return newObj;
}

function checksum(obj) {
const string = Object.keys(obj)
.sort()
.map(prop => String(obj[prop]))
.join("");

return crypto
.createHash("sha1")
.update(string)
.digest("hex");
}

function isNull(val) {
return val === null;
}

function negate(func) {
return function() {
return !func.apply(null, arguments);
};
};

class Status {
constructor(status, message) {
this._status = status;
this._message = messge;
}

get status() {
return this._status;
}

get message() {
return this._message;
}
}

以下則是 EMP API:

models/orademo.employee.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
const oracledb = require('oracledb');
const oradb = require('./oradb.database');
const { setValue, toLowerCaseKeys } = require('./helpers/utils');

function findAll() {
const statement = "select * from emp";

return oradb.doExecute(statement)
.then(result => toLowerCaseKeys(result))
.catch((err) => {
throw err;
});
}

function findByEmpno (empno) {
if (!empno) {
return Promise.reject(new Error("Invalid input"));
}

const binds = [];
const statement = "select * from emp where empno = :empno";
binds.push(empno);

return oradb.doExecute(statement, binds)
.then((result) => toLowerCaseKeys(result))
.catch((err) => {
throw err
});
}

function findByDeptno (deptno) {
if (!deptno) {
return Promise.reject(new Error("Invalid input"));
}

const binds = [];
const statement = "select * from emp where deptno = :deptno";
binds.push(deptno);

return oradb.doExecute(statement, binds)
.then((result) => toLowerCaseKeys(result))
.catch((err) => {
throw err
});
}

function add(data) {
const statement = `
INSERT INTO emp
(empno, ename, job, mgr, hiredate, sal, comm, deptno)
VALUES (:empno, :ename, :job, :mgr, to_date(:hiredate,'yyyy-mm-dd"T"hh24:mi:ss'), :sal, :comm, :deptno)
RETURNING empno INTO :id
`;

return new Promise(async (resolve, reject) => {
if (typeof data !== "object") {
reject(new Error("Input must be an valid object"));
}

let result;
let binds = {
empno: setValue(data["empno"]),
ename: setValue(data["ename"]),
job: setValue(data["job"]),
mgr: setValue(data["mgr"]),
hiredate: setValue(data["hiredate"]),
sal: setValue(data["sal"]),
comm: setValue(data["comm"]),
deptno: setValue(data["deptno"]),
id: {
type: oracledb.STRING,
dir: oracledb.BIND_OUT
}
};

try {
result = await oradb.doExecute(statement, binds);
resolve(result);
} catch (err) {
reject(err);
}
});
}

function update(empno, data) {
const statement = `
UPDATE emp e
SET e.ename = :ename,
e.job = :job,
e.mgr = :mgr,
e.hiredate = to_date(:hiredate,'yyyy-mm-dd"T"hh24:mi:ss'),
e.sal = :sal,
e.comm = :comm,
e.deptno = :deptno
WHERE e.empno = :empno
RETURNING ROWID INTO :rid
`;

return new Promise(async (resolve, reject) => {
if (!empno) {
reject(new Error("Invalid empno"));
}
if (typeof data !== "object") {
reject(new Error("Input must be an valid object"));
}

let result;
let binds = {
empno: setValue(empno),
ename: setValue(data["ename"]),
job: setValue(data["job"]),
mgr: setValue(data["mgr"]),
hiredate: setValue(data["hiredate"]),
sal: setValue(data["sal"]),
comm: setValue(data["comm"]),
deptno: setValue(data["deptno"]),
rid: {
type: oracledb.STRING,
dir: oracledb.BIND_OUT
}
};

try {
result = await oradb.doExecute(statement, binds);
resolve(result);
} catch (err) {
reject(err);
}
});
}

function remove(empno) {
const statement = `
DELETE FROM emp
WHERE empno = :empno
RETURNING empno INTO :id
`;

return new Promise(async (resolve, reject) => {
if (!empno) {
reject(new Error("Invalid empno"));
}

let result;
let binds = {
empno: setValue(empno),
id: {
type: oracledb.STRING,
dir: oracledb.BIND_OUT
}
};

try {
result = await oradb.doExecute(statement, binds);
resolve(result);
} catch (err) {
reject(err);
}
});
}

function upsert(data) {
const statement = `
MERGE INTO emp e
USING (select :empno as empno,
:ename as ename,
:job as job,
:mgr as mgr,
to_date(:hiredate,'yyyy-mm-dd"T"hh24:mi:ss') as hiredate,
:sal as sal,
:comm as comm,
:deptno as deptno
from dual) p
ON (e.empno = p.empno)
WHEN MATCHED THEN
UPDATE SET e.ename = p.ename,
e.job = p.job,
e.mgr = p.mgr,
e.hiredate = p.hiredate,
e.sal = p.sal,
e.comm = p.comm,
e.deptno = p.deptno
WHEN NOT MATCHED THEN
INSERT (e.empno, e.ename, e.job, e.mgr, e.hiredate, e.sal, e.comm, e.deptno)
VALUES (p.empno, p.ename, p.job, p.mgr, p.hiredate, p.sal, p.comm, p.deptno)
`;

return new Promise(async (resolve, reject) => {
if (typeof data !== "object") {
reject(new Error("Input must be an array"));
}

if (data instanceof Array && data.length > 0) {
let result;
let binds = [];

data.forEach(value => {
let item = {
empno: setValue(value["empno"]),
ename: setValue(value["ename"]),
job: setValue(value["job"]),
mgr: setValue(value["mgr"]),
hiredate: setValue(value["hiredate"]),
sal: setValue(value["sal"]),
comm: setValue(value["comm"]),
deptno: setValue(value["deptno"]),
}
binds.push(item);
});

try {
result = await oradb.doExecuteMany(statement, binds);
resolve(result);
} catch (err) {
reject(err);
}
} else {
reject(new Error("Input must be an array"));
}
});
}

module.exports = {
findAll,
findByEmpno,
findByDeptno,
add,
update,
remove,
upsert
};

現在可以來測試看看了:

models/helpers/orademo-test.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const oradb = require('../oradb.database');
const department = require('../orademo.department');

(async function() {
try {
await oradb.initialize();

department.findAll()
.then(result => console.log(result.rows))
.catch(err => console.log(err));
} catch (err) {
console.log(err);
} finally {
await oradb.close();
}
})();

第 6 行連結到資料庫,我們使用 Oracle Connection Pool,稍後我們會在啟動 GraphQL 伺服器時,馬上初始化資料庫的 Connection Pool。 測試順利的話應該會有如下的回應:

results
1
2
3
4
5
6
7
8
9
$ node orademo-test

[
{ deptno: 10, dname: '會計部', loc: '紐約' },
{ deptno: 20, dname: 'RESEARCH', loc: 'DALLAS' },
{ deptno: 30, dname: 'SALES', loc: 'CHICAGO' },
{ deptno: 40, dname: 'OPERATIONS', loc: 'BOSTON' },
{ deptno: 70, dname: '資訊部', loc: '永康 B4' }
]

現在我們使用一個程式碼將兩個 API 併接起來。

models/orademo.js
1
2
exports.employee = require('./orademo.employee');
exports.department = require('./orademo.department');

接著要修改 GraphQL 伺服器的啟動程式 index.js,初始化 Oracle 資料庫的 Connection Pool 連結。使用 Connection Pool 網路連結效能較佳,而且可以控制對資料庫的連結數量。初始化 Oracle Connection 的程式碼與之前用於 Node.js Express 相同,可參考 Node.js 教育訓練文件 第 287 頁。

index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
const { createServer } = require('http');
const express = require("express");
const { ApolloServer, PubSub } = require("apollo-server-express");
const cors = require('cors');
const db = require('./models/dbs');
const orademo = require('./models/orademo');
const userService = require('./services/user.service');
const typeDefs = require('./schema');
const resolvers = require('./resolvers');

/* Initialize Oracle Database connection pool */
const oradb = require('./models/oradb.database');
const dbConfig = require('./models/oradb.config');
const defaultThreadPoolSize = 4;

process.env.UV_THREADPOOL_SIZE = dbConfig.poolMax + defaultThreadPoolSize;

dbInitialize();

const app = express();
app.use(cors());

const context = async ({ req, connection }) => {
let currentUser = null;

try {
const token = req
? req.headers.authorization.split(" ")[1]
: connection.context.Authorization.split(" ")[1];

const payload = token && userService.jwtVerify(token);
currentUser = payload && (await db.users.findOne(payload.appLogin));
} catch (err) {
currentUser = null;
}

return {
db,
orademo,
userService,
currentUser,
pubsub
};
};

const pubsub = new PubSub();

const server = new ApolloServer({
typeDefs,
resolvers,
context
});

server.applyMiddleware({ app });

app.get("/", (req, res) => res.end("Welcome to the GraphQL Sample API"));

const httpServer = createServer(app);
server.installSubscriptionHandlers(httpServer);

httpServer.listen({ port: 4000 }, () => {
console.log(
`GraphQL Server running @ http://localhost:4000${server.graphqlPath}`
);
console.log(
`GraphQL Subscriptions running @ ws://localhost:4000${server.subscriptionsPath}`
);
});

process.on("SIGTERM", () => {
console.log("Received SIGTERM");
dbClose();
});
process.on("SIGINT", () => {
console.log("Received SIGINT");
dbClose();
});
process.on("uncaughtException", err => {
console.log("Uncaught exception");
console.log(err);
dbClose(err);
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at: ", promise, " reason: ", reason);
dbClose(reason);
});

async function dbInitialize() {
try {
console.log("Initializing database");
await oradb.initialize();
} catch (err) {
console.error(err);
process.exit(1);
}
}

async function dbClose(err) {
let error = err;
console.log("Close database connection pool");

try {
await oradb.close();
} catch (err) {
console.log("Encountered error", err);
error = error || err;
}

if (error) {
process.exit(1);
} else {
process.exit(0);
}
}
  • 第 6 行加入 orademo API。

  • 第 12 ~ 18 行初始化 Oracle Database connection pool。UV_THREADPOOL_SIZE 必須在使用 threadpool 的第一個調用之前設置。 這是 因為 threadpool 是在第一次使用時創建的,一旦創建,它的大小是固定的。

  • 第 39 行將 orademo 加入 GraphQL context。這將會用在 GraphQL Resolvers 解析函式。

  • 第 70 行以後加上幾個 Signal 監聽器,當 Node.js process 收到這些信號時,將會發出 Signal Events,收到這些事件時關閉 Oracle Database connection pool。

現在可以開始設計 GraphQL Query 了,從 Schema 開始:

schema/department.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { gql } = require("apollo-server-express");

module.exports = gql`
extend type Query {
totalDepartments: Int
allDepartments: [Department]
}

type Department {
deptno: ID!
dname: String
loc: String
employees(job: String): [Employee!]!
}
`;

第 13 行這裡加入了一個邊(edge)的連結,employees 欄位是資料庫中沒有的欄位,而且他可以接受一個引數 job。返回的則是一個 Employee 型態的陣列,Employee 型態會在稍後定義。

schema/employee.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { gql } = require("apollo-server-express");

module.exports = gql`
extend type Query {
totalEmployees: Int!
allEmployees: [Employee!]!
}

type Employee {
empno: ID!
ename: String
job: String
mgr: Int
hiredate: DateTime
sal: Float
comm: Float
income: Float
department: Department
}
`;

這裡第 17 與 18 行也是資料庫中沒有的欄位,income 在資料庫中不存在, department 則是個邊連結。這裡的 Schema 欄位都用小寫格式,從 Oracle API 中的資料屬性也都必須是小寫,否則預設的解析函式將無法解析。

記得要修改 schema/index.js 將新的 Schema 併接進來。

schema/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...

const employeeSchema = require('./employee');
const departmentSchema = require('./department');

...

module.exports = [
linkSchema,
userSchema,
employeeSchema,
departmentSchema,
photoSchema,
photoConnectionSchema,
subscriptionSchema
];

再來就是實作解析函式了:

resolvers/department.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = {
Query: {
totalDepartments: (parent, args, { orademo }) =>
orademo.department.findAll().then(result => result.rows.length ),
allDepartments: (parent, args, { orademo }) => {
return orademo.department.findAll()
.then(result => result.rows );
}
},
Department: {
employees: (parent, { job }, { orademo }) => {
return orademo.employee
.findByDeptno(parent.deptno)
.then(result => {
if (typeof job === 'string') {
return result.rows.filter(value => value.job === job);
}
return result.rows
});
}
},
};

第 11 行 employees 欄位是資料庫中沒有的欄位,所以這裏要實作他的客製解析函式,他可選擇性的接受一個引數 job,可過濾職位型態。

resolvers/employee.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = {
Query: {
totalEmployees: (parent, args, { orademo }) =>
orademo.employee.findAll().then(result => result.rows.length),
allEmployees: (parent, args, { orademo }) => {
return orademo.employee.findAll()
.then(result => result.rows );
}
},
Employee: {
department: (parent, args, { orademo }) => {
return orademo.department
.findByDeptno(parent.deptno)
.then(result => result.rows[0] );
},
income: (parent) => {
let sal = !isNaN(parseFloat(parent.sal)) ? parseFloat(parent.sal) : 0;
let comm = !isNaN(parseFloat(parent.comm)) ? parseFloat(parent.comm) : 0;
return sal + comm;
}
},
};

這裡則客製了兩個解析函式 department 與 income。記得也要將新增的兩個 resolvers 併接起來:

resolvers/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
...
const employeeResolvers = require('./employee');
const departmentResolvers = require('./department');
...

module.exports = [
userResolvers,
photoResolvers,
employeeResolvers,
departmentResolvers,
photoConnectionResolvers,
subscriptionResolvers
];

可以測試 Query 了。

query departments
1
2
3
4
5
6
7
8
9
10
11
12
13
query departments {
totalDepartments
allDepartments {
dname
deptno
loc
employees
{
ename
job
income
}
}

可以在 employees 欄位選擇性的加入 job 引數:

query departments
1
2
3
4
5
6
7
8
9
10
11
12
13
14
query departments {
totalDepartments
allDepartments {
dname
deptno
loc
employees(job: "MANAGER")
{
ename
job
income
}
}
}

這裡我只想知道主管是誰。

一樣可以測試一下 employee 查詢:

query employees
1
2
3
4
5
6
7
8
9
10
11
12
13
query Employees {
totalEmployees
allEmployees {
empno
ename
hiredate
income
department {
dname
loc
}
}
}

這個 query 應該沒甚麼問題,他可以透過 department 欄位實作資料的連結,這在 GraphQL 中我們稱為 “邊” (Edges),這與 SQL 裡的 join類似。在 SQL 中 join 越多,效能就會愈差,GraphQL 當然也不會有例外。GraphQL 也可以嵌套的連結,多層的嵌套可能會凍結伺服器的效能,要仔細評估一下。

接續下一篇 建立 GraphQL API (9)

分頁 Pagination

使用 GraphQL,幾乎可以肯定會遇到一個含有項目列表 (lists of items) 的應用程序,稱為分頁 (Pagination) 功能。

在應用程序中存儲的用戶照片會變成很長的列表,並且當用戶端應用程序請求顯示照片時,立即從資料庫中檢索所有照片可能會導致嚴重的性能瓶頸。分頁允許您將項目列表拆分為多個列表,稱為頁面 (pages)。 通常用限制 (limit) 和偏移量 (offset) 定義頁面。 這樣,您可以以項目頁面 (one page of items) 進行請求,而當用戶希望查看更多項目時,可以請求另一頁項目。

有兩種不同的分頁方法可在 GraphQL 中實現分頁。 第一種方法是較簡單的方法,稱為 Offset/Limit-based Pagination 的分頁方法。 另一種進階的方法是 Cursor-based pagination 的分頁方法,這是在應用程序中允許分頁比較複雜的方法之一。

Offset/Limit Pagination

Offset/Limit-based pagination 並不是很難實現。 limit 說明您要從整個列表中檢索多少個項目,offset 則說明要從整個列表中開始的位置。 使用不同的偏移量 (offset) ,可以在整個項目列表中移動並檢索具有限制 (limit) 數量的子列表(頁面)。

看起來似乎很簡單,但這也需要資料庫端的實作,以下是 PostgreSQL SQL:

PostgreSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
db01=> select empno, ename from emp
db01-> order by empno;
empno | ename
-------+--------
7369 | SMITH
7499 | ALLEN
7566 | 陳賜珉
7607 | バック
7608 | 馬小九
7609 | 蔡大一
7654 | 葉習堃
7698 | BLAKE
7782 | 陳瑞
7788 | SCOTT
7839 | KING
7844 | 하찮고
7876 | ADAMS
7900 | JAMES
7902 | FORD
7934 | 楊喆
8907 | 牸祢
9006 | 李逸君
9011 | 文英蔡

加上 offset 與 limit,注意這裡的 order by 是必須的。offset 是從哪個位置開始,limit 則是限制筆數。

1
2
3
4
5
6
7
8
9
10
11
db01=> select empno, ename from emp
db01-> order by empno
db01-> offset 5 limit 5;
empno | ename
-------+--------
7609 | 蔡大一
7654 | 葉習堃
7698 | BLAKE
7782 | 陳瑞
7788 | SCOTT
(5 rows)

PostgreSQL 一直都有支援 SQL 分頁功能,Oracle 則直到 Oracle 12c 才有支援,在 12c 之前則需使用一些技巧。如果未來是以 Web App 為策略,則 Oracle Database 升級要盡早規畫。以下在 YK11 的 SQL:

Oracle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
YK11> select empno, ename from apexdemo.emp
2 order by empno;

EMPNO ENAME
---------- ------------------------------
7369 史密斯
7499 ALLEN
7521 WARD
7566 JONES
7654 馬丁
7698 布萊克
7782 陳瑞
7788 SCOTT
7839 KING
7844 TURNER
7876 ADAMS
7900 陳瑞
7902 福特
7934 米勒

14 rows selected.

Oracle 的 limit 有一些不同:

1
2
3
4
5
6
7
8
9
10
11
YK11> select empno, ename from apexdemo.emp
2 order by empno
3 offset 5 rows fetch next 5 rows only;

EMPNO ENAME
---------- ------------------------------
7698 布萊克
7782 陳瑞
7788 SCOTT
7839 KING
7844 TURNER

有了資料庫端的配合,GraphQL 就可以加入分頁的功能,例如:

Pagination Example
1
2
3
4
5
6
7
8
...
module.exports = gql`
extend type Query {
totalPhotos: Int!
allPhotos(offset: Int, limit: Int): [Photo!]!
}
...
...

即使這種方法比較簡單,它也有一些缺點。當偏移量變得很大時,資料庫查詢將花費更長的時間,這可能會導致 UI 等待下一頁數據時客戶端性能下降。 另外,Offset/Limit-based pagination 無法處理兩次查詢之間的已刪除項目。例如,如果您查詢某一頁並且有其他人刪除了你已查詢過的某個項目,則下一頁上的偏移量將是錯誤的,因為該項目的數量減少了一個。 使用 Offset/Limit-based pagination 無法輕鬆克服此問題,這就是為什麼可能需要 Cursor-based pagination 的原因。

Cursor-based Pagination

在 Cursor-based pagination 分頁中,offset 被一個稱為 cursor 的標識符所取代,而不像 Offset/Limit pagination 的項目進行計數。Cursor 可以用來表示 “從 Cursor Y 開始給我 limit 個項目的列表”。 所以 cursor 儲存的是一個欄位的值,用來取代 offset,當要求下一個分頁時使用 cursor 當為起使值,你可以使用 Primary Key,或使用日期(例如,資料庫中某個項目的創建日期)來標識列表中項目,這都是常用的方法。這裡要注意,資料庫 SQL 返回的資料不能保證其順序,所以 order by 通常都是必須的。

在我們的例子,照片的 ID 是按時間建立的,所以就使用它當成 cursor 的值。如果你的 ID 是使用 uuid 亂碼產生的,你則需要新增一個例如 createdAt 的時間欄位當作是 cursor 的值。在資料庫中記得加個 index 在 createdAt 欄位上。

這裡不管使用 ID 或 createdAt,有一個重要的特性,兩個欄位的值都不會改變,所以 cursor 具有穩定的位置,不像 offset 的不定性,有可能會有不同的起始值。

GraphQL 伺服器以標準化的方式公開它的連接 (Connections)。 在查詢中,連接模型 (connection model) 提供了用於對結果集進行切片和分頁的標準機制。 在響應回應中,連接模型提供了一種提供 cursor 的標準方法,以及一種在出現更多結果時告訴客戶端的方法。

連接模型有一些規範讓我們可以遵循,細節可以參考 Relay Cursor Connections Specification,裡面有詳盡的介紹。

這裡我們要來新增一個可以有分頁功能的照片清單。首先我們看一下資料庫模組抽象層程式,如何用來支援前端的分頁功能。

models/database.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
Database.prototype.findAll = function( opts = {}) {
return new Promise((resolve, reject) => {
const options = { ...opts };
let _this = this;
let results = [];
try {
_this._db
.createValueStream( options )
.on("data", value => results.push(value))
.on("end", () => resolve(results))
.on("error", err => reject(err));
} catch (err) {
reject(err);
}
});
};
...

findAll( ) 函式我們預留了可選擇性參數的物件 opts,在第 9 行呼叫時會使用到此選擇性參數物件,這個選擇性參數物件可以包含以下的特性:

  • gt(大於),gte(大於或等於):定義資料流傳輸範圍的下限。 此範圍內僅包含鍵 (Key) 大於(或等於)此選項的項目。 當 reverse = true 時,順序將顛倒,但 stream 傳輸的項目將相同。
  • lt(小於),lte(小於或等於):定義資料流傳輸範圍的上限。 該範圍內僅包含鍵小於(或等於)此選項的項目。 當 reverse = true 時,順序將顛倒,但資料流傳輸的項目將相同。
  • reverse (boolean, default: false):以相反的順序讀取資料流項目。 注意,由於像 LevelDB 這樣的存儲方式,反向搜索可能比正向搜索要慢。
  • limit(number,default:-1):限制此資料流的項目數。 此數字代表最大項目數,如果您先到達範圍末尾,則返回的項目可能少於 limit 數。 值 -1 表示沒有限制。 當 reverse = true 時,將返回具有最高鍵的項目,而不是最低鍵。

我們可修改原來的 Query Schema 的 allPhotos,但是我們先保留它,以免這裡出了問題無法回復,尤其如果是線上系統,可能會造成問題。所以我們要新增一個新的 Query Schema,這樣可以較無壓力的轉換。在 schema 子目錄下新增 photoConnection.js :

schema/photoConnection.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const { gql } = require("apollo-server-express");

module.exports = gql`
extend type Query {
allPhotosWithPagination(
after: String
first: Int
before: String
last: Int
): PhotoConnection!
}

type PhotoConnection {
pageInfo: PageInfo!
edges: [PhotoEdge!]!
totalCount: Int
photos: [Photo!]!
}

type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

type PhotoEdge {
node: Photo!
cursor: String!
}
`;

這裡的 Query allPhotosWithPagination 提供了 4 個參數 after、first、before 與 last。

  • after 與 first 是一對的,支援下一頁的功能。
  • before 與 last 是一對的,支援上一頁的功能。

因此 first 與 last 不能同時用。而它會返回一個 Connection 模型物件 PhotoConnection。

Connection 模型中一個分頁稱為一個 Connection,對於 Connection 返回的資料有一些規範,一定要包含某些特性,需要包含:

  • pageInfo:
    • startCursor
    • endCursor
    • hasPreviousPage
    • hasNextPage
  • edges : 這是一個陣列,包含所返回的項目,也就是目前這一頁(Connection)的項目。
    • node : 實際返回的資料項目,這裡就是實際照片的資料。
    • cursor : cursor 的值,在這個例子就是照片的 ID key 值,通常會做 base64 編譯,這樣對資料隱匿性比較好,也比較不容易讓前端誤會資料的用途。

除了規範的特性,你也可加入任何的資訊,這裡我們加入了 totalCount 與 photos。totalCount 是資料庫中照片的總數,photos 的資料與 edges 中的 node 相同,如果你不需要分頁功能則可以直接使用這個 photos 即可。

將它併接到 schema/index.js

schema/index.js
1
2
3
4
...
const photoConnectionSchema = require('./photoConnection');
...
module.exports = [linkSchema, userSchema, photoSchema, photoConnectionSchema, subscriptionSchema];

再來我們要實作 allPhotosWithPagination 的解析函式,這是分頁功能複雜的地方:

resolvers/photoConnection.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
const toCursorHash = string => Buffer.from(string).toString('base64');
const fromCursorHash = string => Buffer.from(string, 'base64').toString('ascii');

module.exports = {
Query: {
allPhotosWithPagination: async (parent, { after, first, before, last }, { db: { photos } }) => {
let result, allCount, unlimitedCount;
const opts = {};

if (typeof after === 'string' && typeof before === 'string' ) {
throw new Error('after and before cannot use same time');
}
if (typeof first === 'number' && typeof last === 'number') {
throw new Error('first and last cannot use same time');
}
if (typeof after === 'string' && typeof last === 'number') {
throw new Error('after must be with first');
}
if (typeof before === 'string' && typeof first === 'number') {
throw new Error('before must be with last');
}

// Forward pagination
if (typeof first === 'number') {
if (first < 0 ) throw new Error('first cannot be less than 0');
opts.limit = first
};
if (typeof after === 'string') opts.gt = fromCursorHash(after);

// Backward pagination
if (typeof last === 'number') {
if (last < 0 ) throw new Error('last cannot be less than 0');
opts.limit = last;
}
if (typeof before === 'string') opts.lt = fromCursorHash(before);
if (typeof last === 'number' || typeof before === 'string') opts.reverse = true;

try {
allCount = await photos.allCount();
unlimitedCount = await photos.allCount({ ...opts, limit: undefined });
result = await photos.findAll( opts );
} catch (error) {
throw error;
}

if (opts && opts.reverse) result.reverse();

const edges = result.map(value => {
return {
node: { ...value },
cursor: toCursorHash( value.id )
};
});

return {
pageInfo: {
startCursor: edges.length > 0 ? edges[0].cursor : undefined,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : undefined,
hasPreviousPage:
typeof last === 'number'
? last < unlimitedCount
: ( typeof before === 'string' ? false : allCount > unlimitedCount),
hasNextPage:
typeof first === 'number'
? first < unlimitedCount
: ( typeof after === 'string' ? false : allCount > unlimitedCount)
},
edges,
totalCount: allCount,
photos: result
};
}
}
};
  • 第 1、2 行是 base64 編譯與解意函式。
  • 第 6 行傳入了 4 個參數 after、first、before 與 last。after 與 first 是一對的,支援下一頁的功能,before 與 last 是一對的,支援上一頁的功能,因此 first 與 last 不能同時用。 第 10 到 21 行就是在檢查這些關聯。
  • 第 24 到 28 行設定下一頁的資料庫執行參數。
  • 第 30 到 36 行設定上一頁的資料庫執行參數。注意 36 行的 opts.reverse 設定為 true。所以會以相反的順序讀取資料流項目
  • 第 39 行是資料庫中所有照片的筆數。
  • 第 40 行可以知道還剩多少筆數,可讓我們知道是不是還有下一頁或上一頁。
  • 第 41 行是實際返回的資料項目。
  • 第 46 行如果使用 last,它是相反方向搜尋的,返回的順序也是相反的,將它迴轉,以免客戶端顯示的順序一團亂。
  • 第 55 行返回 PhotoConnection 型態的物件。

將解析函式併接到 resolvers/index.js

resolvers/index.js
1
2
3
4
...
const photoConnectionResolvers = require('./photoConnection');

module.exports = [userResolvers, photoResolvers, subscriptionResolvers, photoConnectionResolvers];

現在可以由 GraphQL Playground 測試一下,仔細觀察一下返回的資料:

Playground
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
query allPhotos {
allPhotosWithPagination (
first: 5
) {
totalCount,
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
edges {
node {
id
created
postedBy {
appLogin
}
}
cursor
}
photos {
id
}
}
}

這個查詢我們只用了一個引數 first: 5,所以會返回第一筆到第 5 筆的資料,資料就類似如下:

GraphQL Query result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"data": {
"allPhotosWithPagination": {
"totalCount": 16,
"pageInfo": {
"startCursor": "MQ==",
"endCursor": "MTU3NjEwOTIxNTk3Nw==",
"hasNextPage": true,
"hasPreviousPage": false
},
"edges": [
{
"node": {
"id": "1",
"created": "2018-03-27T16:00:00.000Z",
"postedBy": {
"appLogin": "blake"
}
},
"cursor": "MQ=="
},
...

如果需要下一頁的資料就必須加入 after 引數,它的值則是上一個查詢中 pageInfo.endCursor 的值。hasNextPage 與 hasPreviousPage 則可讓你控制應用程式的上一頁與下一頁的按鈕。

Playground
1
2
3
4
5
6
query allPhotos {
allPhotosWithPagination (
first: 5
after: "MTU3NjEwOTIxNTk3Nw=="
) {
...

使用分頁功能時一定會有一些效能上的損失,這不只會出現在客戶端,資料庫端的效能耗損也需注意,使用上須衡量一下。

接續下一篇 建立 GraphQL API (8)

訂閱 Subscription

即時更新是現代 web 與行動 app 不可或缺的功能。目前可在網路與行動 app 之間即時傳送資料的技術是 WebSocket。你可以使用 WebSocket 協定在 TCP 通訊端開啟雙向通訊通道。這意味著網頁與 app 可以透過一個連結來傳送與接收資料。這項技術可讓你即時且直接從伺服器推送更新到網頁上。

到目前為止,我們都用 HTTP 協定來實作 GraphQL 查詢與 mutation。HTTP 提供了在用戶端與伺服器之間傳送與接收資料的手段,但它無法協助我們連接伺服器與監聽狀態的改變。在 WebSocket 出現前,監聽伺服器上的狀態改變唯一的方式就是不斷的傳送 HTTP 請求給伺服器來確定是不是有所改變。

但是如果我們想要充分利用新的網路技術,除了 HTTP 請求之外, GraphQL 也必須能夠支援 WebSocket 上的即時資料傳輸。GraphQL 的解決方案就是訂閱 (subscription)

之前我們已經實作了伺服端支援 Photos 的訂閱了,這裡我們要從客戶端訂閱 newPhoto。訂閱要透過 WebSocket 來運行,為了在客戶端啟用 WebSocket,我們需要再安裝一些套件:

npm install
1
npm install apollo-link-ws apollo-utilities subscriptions-transport-ws --save

我們要在 Apollo Client 組太中加入 WebSocket 連結,我們要修改起始的檔案 main.js:

src/main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import Vue from 'vue'
import App from './App.vue'
import VueApollo from 'vue-apollo'
import {ApolloClient, InMemoryCache, split, HttpLink, ApolloLink } from 'apollo-boost'
import { persistCache } from 'apollo-cache-persist'
import { WebSocketLink } from 'apollo-link-ws'
import { getMainDefinition } from 'apollo-utilities'

Vue.config.productionTip = false
Vue.use(VueApollo);

const cache = new InMemoryCache();
persistCache({
cache,
storage: localStorage
});

if (localStorage['apollo-cache-persist']) {
let cacheData = JSON.parse(localStorage['apollo-cache-persist']);
cache.restore(cacheData);
}

const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql'
});

const wsLink = new WebSocketLink({
uri: 'ws://localhost:4000/graphql',
options: {
reconnect: true
}
});

const authLink = new ApolloLink((operation, forward) => {
operation.setContext(context => ({
headers: {
...context.headers,
Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBMb2dpbiI6InNjb3R0IiwibmFtZSI6IuiAgeiZjiBTY290dCIsImlhdCI6MTU3NDkwMzA5MSwiZXhwIjoxNTc0OTQ2MjkxfQ.AssjGPY21khE5Xl7K2ZmTVeSerQzrXyOvXKMbEpPL0k'
}
}));
return forward(operation);
});

const httpAuthLink = authLink.concat(httpLink);

const link = split(
({ query }) => {
const { kind, operation } = getMainDefinition(query);
return kind === "OperationDefinition" && operation === "subscription";
},
wsLink,
httpAuthLink
);

const client = new ApolloClient({ cache, link });

const apolloProvider = new VueApollo({
defaultClient: client
});

new Vue({
apolloProvider,
render: h => h(App),
}).$mount('#app')

因修改的蠻多的,所以列出整個 main.js。

第 4 行從 apollo-boost 匯入 split,我們要用它來分開 HTTP 請求與 WebSocket 的 GraphQL 操作。如果操作是 mutation 或 query,Apollo Client 會送出 HTTP 請求。如果操作是 subscription,用戶端會連接 WebSocket。

在 Apollo Client 底層,網路請求是用 ApolloLink 來管理的。在目前 app 中,它負責傳送 HTTP 請求給 GraphQL 伺服器。每當我們用 Apollo Client 傳送操作時,那個操作就會被送到一個 Apollo Link 來處理網路請求。我們也可以使用 Apollo Link 來處理 WebSocket 上的網路。

在第 23 與 27 行,我們需要設定兩種連結來支援 WebSocket: HttpLink 與 WebSocketLink。httpLink 可透過網路傳送 HTTP 請求給 http://localhost:4000/graphql,而 wsLink 可連接 ws://localhost:4000/graphql 以及透過 WebSocket 接收資料。

連結是可組合的,也就是說,你可以將它們接在一起以建立自訂的管道來處理 GraphQL 操作。除了傳送一個操作給一個 ApolloLink 之外,我們也可以透過可重複使用的連結鏈來傳送一個操作,在操作到達連結鏈的最後一個節點之前,每一個節點都可以處理它,最後一個節點則負責處理請求並回傳結果。

在第 34 到 44 行我們加入了一個自訂的 Apollo Link,以 httpLink 建立一個連結鏈,它的工作是為操作加上一個授權標頭。

我們將 httpLink 接到 authLink 來處理 HTTP 請求的使用者授權。請留意,這個 .contact 函式與你在 JavaScript 串接陣列中看到的函式不同。這是個串接 Apollo Link 的特殊函式。串接之後,我們幫這個連結取一個比較好辨識的名稱 httpAuthLink,來更清楚的描述它的行為。當操作被送到這個連結時,它會先被傳給 authLink,在那裡加上授權標頭,再傳給 httpLink 來處理網路請求。如果你熟悉 Express 的中介軟體 (middleware),它們都有類似的模式。

第 46 行,實作了一個 split 連結,它會檢查我們的操作是不是 subscription,如果它是 subscription,就用 wsLink 來處理網路,否則使用 httpLink。第 1 個引數是條件式,它使用 getMainDefinition 函式來檢查操作的 query AST,如果這個操作是個 subscription,條件式會回傳 true。當條件式回傳 true 時,link 會回傳 wsLink。當條件式回傳 false 時,link 會回傳 httpAuthLink。

最後,第 55 行我們更改了 Apollo Client 組態,透過將 link 與 cache 傳給它來使用自訂的連結。

現在用戶端可以開始處裡訂閱了,我們將要用 Apollo Client 送出第一個訂閱操作。

監聽新照片

我們要在 Vue 元件中建立一個 subscribe

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
...
mounted() {
const observer = this.$apollo.subscribe({
query: gql`subscription newPhoto {
newPhoto {
id
name
description
category
postedBy {
name
}
}
}`,
variables: {}
});

observer.subscribe({
next (data) {
console.log(data)
},
error (error) {
console.error(error)
}
});
},
...

這裡使用標準的 $apollo.subscribe( ) 方法來訂閱 GraphQL subscripton,該訂閱將在銷毀組件時會自動終止。目前我們只將它顯示在瀏覽器的 console,現在將收到的新照片直接更新客戶端的快取,vue-apollo 則會連動 Vue 屬性的綁定,重新喧染 UI。

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
...
mounted() {
const client = this.$apollo.getClient();

const LISTEN_FOR_PHOTOS = gql`subscription newPhoto {
newPhoto {
id
name
description
category
postedBy {
name
}
}
}`;

const LISTPHOTOS_QUERY = gql`query listPhotos {
totalPhotos
allPhotos {
id
name
description
}
}`;

const observer = this.$apollo.subscribe({
query: LISTEN_FOR_PHOTOS,
variables: {}
});

observer.subscribe({
next ( {data: { newPhoto }} ) {
const cacheData = client.readQuery({
query: LISTPHOTOS_QUERY
});

cacheData.totalPhotos += 1;
cacheData.allPhotos.push({
id: newPhoto.id,
name: newPhoto.name,
description: newPhoto.description,
__typename: newPhoto.__typename
});

client.writeQuery({
query: LISTPHOTOS_QUERY,
data: cacheData
});
},
error (error) {
console.error(error)
}
});
},
...

第 3 行取得 Apollo Client 實例。

第 5 行定義 GraphQL subscription。當在使用 Apollo Client 時,習慣上會將 query 定義在一個常數上,如果有許多不同的元件共用,也可將它模組化,集中在一個檔案中。

第 17 行定義 GraphQL query,與 subscription 一樣將它定義在一個常數上。

第 33 行抓取 GraphQL query listPhotos 的快取資料。

第 37、38 行更改從快取抓出來的資料。

第 45 行寫回快取。

除了使用 Apollo subscribe 的標準模式外,也可以使用 Vue Apollo 的另一種簡易模式:

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
apollo: {
...
$subscribe: {
newPhoto: {
query: LISTEN_FOR_PHOTOS,
result(result, key) {
console.log(result);
console.log(key);
},
error(err) {
console.lot(err.message);
}
}
}
},
...

這是最後 src/components/HelloWorld.vue 最後的樣子,僅供參考。你如果在這個元件介面中按下新增照片,你的照片數量會增加兩個,這當然是錯的,但為什麼,自己想想吧!

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<h1>{{ helloTainan }}</h1>
<button @click="addPhoto">Click to add new photo</button>
<p />
<button @click="clearUsers">Click to clear users</button>
<h1>Total Users: {{ totalUsers }} Total Photos: {{ totalPhotos }}</h1>
<ul>
<li v-for="user in allUsers" :key="user.appLogin">
{{ user.appLogin }} {{ user.name }}
</li>
</ul>
</div>
</template>

<script>
import { gql } from 'apollo-boost'

const LISTEN_FOR_PHOTOS = gql`subscription newPhoto {
newPhoto {
id
name
description
category
postedBy {
name
}
}
}`;

const LISTPHOTOS_QUERY = gql`query listPhotos {
totalPhotos
allPhotos {
id
name
description
}
}`;

export default {
name: 'HelloWorld',
props: {
msg: String
},
data() {
return {
helloTainan: undefined,
totalUsers: undefined,
totalPhotos: undefined,
loading: true
}
},
apollo: {
helloTainan: {
query: gql`query hello {
helloTainan: sayHello
}`
},
allUsers: {
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
result ({ data, loading }) {
this.totalUsers = data && data.totalUsers;
this.loading = loading;
},
fetchPolicy: 'cache-and-network'
},
allPhotos: {
query: LISTPHOTOS_QUERY,
result({ data, loading }) {
this.totalPhotos = data.totalPhotos;
this.loading = loading;
},
fetchPolicy: 'cache-and-network'
},
$subscribe: {
newPhoto: {
query: LISTEN_FOR_PHOTOS,
result(result, key) {
console.log(result);
console.log(key);
},
error(err) {
console.lot(err.message);
}
}
}
},
methods: {
addPhoto() {
const newPhoto = {
name: "範例照片 from Vue mutation !",
description: `我們資料庫的範例照片 ${new Date().toISOString()}`
};

this.$apollo.mutate({
mutation: gql`mutation addPhoto($input: PostPhotoInput!) {
postPhoto(input: $input) {
id
name
description
}
}`,
variables: {
input: newPhoto,
},
update: (store, { data: { postPhoto }}) => {
const LISTPHOTOS_QUERY = gql`query listPhotos {
totalPhotos
allPhotos {
id
name
description
}
}`;
const cacheData = store.readQuery({
query: LISTPHOTOS_QUERY
});

cacheData.totalPhotos += 1;
cacheData.allPhotos.push(postPhoto);

store.writeQuery({
query: LISTPHOTOS_QUERY,
data: cacheData
});

console.log(postPhoto);
console.log(cacheData);
},
optimisticResponse: {
__typename: 'Mutation',
postPhoto: {
__typename: 'Photo',
id: -1,
...newPhoto
}
}
}).then((data) => {
console.log(data);
//this.$apollo.queries.allPhotos.refetch();
}).catch((error) => {
console.error(error);
});
},
clearUsers() {
const client = this.$apollo.getClient();
client.writeQuery({
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
data: {
totalUsers: 0,
allUsers: []
}
});
}
},
mounted() {
const client = this.$apollo.getClient();

const observer = this.$apollo.subscribe({
query: LISTEN_FOR_PHOTOS,
variables: {}
});

observer.subscribe({
next ( {data: { newPhoto }} ) {
const cacheData = client.readQuery({
query: LISTPHOTOS_QUERY
});

cacheData.totalPhotos += 1;
cacheData.allPhotos.push({
id: newPhoto.id,
name: newPhoto.name,
description: newPhoto.description,
__typename: newPhoto.__typename
});

client.writeQuery({
query: LISTPHOTOS_QUERY,
data: cacheData
});
},
error (error) {
console.error(error)
}
});
},
created() {
const client = this.$apollo.getClient();

const data = client.readQuery({
query: LISTPHOTOS_QUERY
});
console.log(data);
}
}
</script>

事實上,這個元件並不需要 subscription,我們先前實作 mutation 時有實作 Optimistic UI,快取中已加入了新增的照片。這裡的 subscription 又加了一次。

除了標準的 Apollo subscription 與簡易的 subscription 之外,如果需要從訂閱更新智能查詢 (Smart Query) 結果,最好的方法是使用 subscribeToMore 智能查詢 (Smart Subscriptions) 方法。我們可以將 query allPhotos 與 subscription newPhoto 結合:

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
...
allPhotos: {
query: LISTPHOTOS_QUERY,
result({ data, loading }) {
this.totalPhotos = data.totalPhotos;
this.loading = loading;
},
fetchPolicy: 'cache-and-network',
subscribeToMore: {
document: LISTEN_FOR_PHOTOS,
variables: { },
updateQuery: (previousResult, { subscriptionData }) => {
const newPhoto = {
id: subscriptionData.data.newPhoto.id,
name: subscriptionData.data.newPhoto.name,
description: subscriptionData.data.newPhoto.description,
__typename: subscriptionData.data.newPhoto.__typename,
};

return {
totalPhotos: previousResult.totalPhotos + 1,
allPhotos: [
...previousResult.allPhotos,
newPhoto
]
};
},
}
},
...

或是另外一種寫法:

src/component/WorldHello.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
// Smart Subscriptions
this.$apollo.queries.allPhotos.subscribeToMore({
document: LISTEN_FOR_PHOTOS,
variables: { },
updateQuery: ({ totalPhotos, allPhotos }, { subscriptionData: { data: { newPhoto } } }) => {
const { id, name, description, __typename } = newPhoto;

return {
totalPhotos: totalPhotos + 1,
allPhotos: [
...allPhotos,
{ id, name, description, __typename }
]
};
},
});
...

改變一下第 6 行 updateQuery 引數的寫法,讓程式碼看起來清爽些。使用 JavaScript 的解構賦值 (Destructuring assignment) 語法。

在這個例子,我們將新增照片所需授權的權杖寫死在 main.js 中,我們可以加個認證按鈕,認證成功後可將權杖儲存到 localStorege 中:

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
...
methods: {
...
authentication() {
this.$apollo.mutate({
mutation: gql`mutation login($login: String! $password: String) {
appAuth(login: $login password: $password) {
user {
appLogin
name
}
token
}
}`,
variables: {
login: "scott",
password: "tiger"
}
}).then( ({ data }) => {
console.log('Authentication successful');
localStorage.setItem('token', data.appAuth.token);
}).catch (err => {
console.log(err.message);
localStorage.removeItem('token');
});
}
},
...

在 template 中加個按鈕。 這裡的 login 與 password 寫死在程式碼中,你應該實作一個 HTML Form Input 來讓使用者輸入。

src/components/HelloWorld.vue
1
2
3
...
<button @click="authentication">Click to authentication</button>
...

最後修改 main.js,從 localStorege 種抓取權杖。

src/main.js
1
2
3
4
5
6
7
8
9
10
11
...
const authLink = new ApolloLink((operation, forward) => {
operation.setContext(context => ({
headers: {
...context.headers,
Authorization: `Bearer ${localStorage.getItem('token')}`
}
}));
return forward(operation);
});
...

祝有個美好的一天與體驗!