这几天当插画师的老婆要求我为她找一些设计资源并且聚合起来,方便她去查阅和使用。本质上这种东西就是一个导航站是很简单的,有很多建站工具、静态页面生成工具,甚至只需要“手动”做个 html 页面也就可以满足。
但是职业习惯(造轮子),不想因为是个简单的需求就去做一个简单的东西。既然是个需求我觉得也是个方向,如果这个资源站可以是一个有价值和品质的产品呢?那就需要花些心思。
按我希望的资源站本身能拥有聚合功能,比如我们喜欢某些资源可以自己提交,也可以将站内的资源收为已用。有点像pinterest、花瓣网,只不过是它的资源的针对性是有差异的。并且也希望它在桌面端、移动端都有所表现,因为资源本身就要随时随时可以使用,也便于应用,但按正常这样下去半个月没了。
可是老婆催的紧,希望我半天完成“交付使用”。
那么这次我们就一起来打造一个基于实时数据库 + graphql 为技术核心的资源站产品。先用半天时间实现出有自适应能力的前端、支持 pwa、打包为 android、ios、桌面端、ssr、spa,后端支持实时数据查询。而这一切内容只需要两项核心技术的搭配。
主要技术栈:
hasura Graphql 基于 Postgres 的即时 GraphQL
作为第一家GraphQL-as-a-Service公司,Hasura推出了其开源GraphQL引擎,这是目前唯一可立即将GraphQL-as-a-Service添加到现有基于Postgres应用程序中的解决方案
Quasar (基于 vuejs 的一个真正框架)
今天主要的目标是将所有点先串起来,然后实现一个基础,先将最核心的资源内容展现出来。目标效果是这样的:
这个产品将会用到的技术栈
- hasura Graphql 基于 Postgres 的即时 GraphQL
作为第一家GraphQL-as-a-Service公司,Hasura推出了其开源GraphQL引擎,这是目前唯一可立即将GraphQL-as-a-Service添加到现有基于Postgres应用程序中的解决方案
- nestjs 服务端,用于一些必要的逻辑、权限处理
- graphql-client
- postgres 数据库
- vuejs(quasar)
- docker 服务容器
今天规划篇主要的目标是将所有点先串起来,然后实现一个基础,先将最核心的资源内容展现出来。目标效果是这样的:
image.png
那么我们开始实践:
1、Hasura GraphQL Engine 服务搭建
要使用Hasura GraphQL引擎,需要:
- 运行Hasura GraphQL引擎并访问Postgres数据库
- 使用连接到Hasura GraphQL引擎的Hasura控制台(一个管理UI)来帮助构建模式并运行GraphQL查询
Hasura控制台用于查询和更新数据库,并生成对应的
query
mutation
delete
insert
update
subscription
基于 docker-compose 编排文件,一个文件搞定全部数据服务环境
version: '3.6'
services:
postgres:
image: postgres:12
restart: always
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: postgrespassword
graphql-engine:
image: hasura/graphql-engine:v1.2.0-beta.2
ports:
- "8080:8080"
depends_on:
- "postgres"
restart: always
environment:
HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
## uncomment next line to set an admin secret
# HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey
volumes:
db_data:
运行 docker-compose
docker-compose up -d
进入Hasura控制台访问 [http://localhost:8080/console/api-explorer](http://localhost:8080/console/api-explorer)
因为数据库已经建立表过一些表,Hasura 服务是可以自动识别和追踪的,识别之后会生成如上图所见各表的 graphql 查询 schema,这样所有的基于 graphql 的增删改查、级联查询就都已经就绪,相当于你的 api 服务基本已经完成,如果业务层面主要是数据形式操作的话,服务系统就完成了。
2、建立客户端
由于我们的目标最终是形成一个可供多端使用的资源站系统,需要 移动端 app、桌面端、浏览器端都能够有对应的终端,这里选用了一款框架 https://quasar.dev/ ,它可以一套 vuejs 代码生成所有终端,并且它有独立的 cli 环境、完善的UI组件,非常实用。
好,现在开始建立客户端开发环境和创建项目工程
# Node.js >= 8.9.0 is required.
$ yarn global add @quasar/cli
quasar create picker-client
完成后,修改 package 的 scripts 如下
// package.json
"scripts": {
"dev": "quasar dev",
"build": "quasar build",
"build:pwa": "quasar build -m pwa"
}
quasar 和普通的 vuecli 建立的项目有些不同,它提供了一个套整体解决方案,插件以及它的相关环境是在一个 /quasar.conf.js 文件中进行配置,它的结构如下:
module.exports = function (ctx) {
console.log(ctx)
// Example output on console:
{
dev: true,
prod: false,
mode: { spa: true },
modeName: 'spa',
target: {},
targetName: undefined,
arch: {},
archName: undefined,
debug: undefined
}
// context gets generated based on the parameters
// with which you run "quasar dev" or "quasar build"
}
更具体的配置可以参考 https://quasar.dev/quasar-cli/quasar-conf-js
运行
npx quasar dev
其它发布模式可参考:
image.png上述按 cli 指引就已经建立好基础的前端工程,但由于我们的需求是基于 graphql 的客户端,并且需要前端工程是基于 Typescript编写, 所以在结构上面还有一些调整。下面详细介绍:
Typescript 支持
1、首先增加 typescript 支持,在quasar.config.js 中 增加 supoortTS: true
2、创建 tsconfig.json
{
"compilerOptions": {
"allowJs": true,
"sourceMap": true,
"target": "es6",
"strict": true,
"experimentalDecorators": true,
"module": "esnext",
"moduleResolution": "node",
"baseUrl": ".",
"types": [
"quasar"
]
},
"exclude": ["node_modules"]
}
graphql 支持
1、 创建 .graphqlconfig 文件
{
"name": "Untitled GraphQL Schema",
"schemaPath": "schema.gql",
"extensions": {
"endpoints": {
"Default GraphQL Endpoint": {
"url": "http://localhost:8080/v1/graphql",
"headers": {
"user-agent": "JS GraphQL"
},
"introspect": false
}
}
}
}
2、添加 graphql 支持
yaran add graphql apollo-client apollo-link-http applo-link-context graphql-tag
封装 apolloClient
quasar 这个框架有 graphql 模块,但对 typescript 支持不太好,所以我自己封装了下,并增加了依赖注入的支持,采用 inversify 这个 lib
先添加支持
yarn add inversify inversify-props
创建 apolloClient.service.interface.ts
import ApolloClient from 'apollo-client'
export default interface ApolloClientServiceInterface {
client: ApolloClient<any>
}
创建 apolloClient.service.ts
import fetch from 'node-fetch'
import { ApolloClient } from 'apollo-client'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { createHttpLink } from 'apollo-link-http'
import { ApolloLink } from 'apollo-link'
import { onError } from 'apollo-link-error'
import { injectable } from 'inversify-props'
import { uid } from 'quasar'
import ApolloClientServiceInterface from './apolloClient.service.interface'
@injectable()
class ApolloClientService implements ApolloClientServiceInterface {
public client: ApolloClient<any>
public readonly uid: string = uid()
public constructor (
) {
const httpLink = createHttpLink({
uri: 'http://localhost:8080/v1/graphql',
fetch: fetch as any
})
const logoutLink = onError((error) => {
const errorRes = error.response
if (errorRes) {
const errors = errorRes.errors
if (errors && errors.length) {
const error = errors[0]
}
}
})
const apolloClient = new ApolloClient({
link: ApolloLink.from([
logoutLink,
httpLink
]),
cache: new InMemoryCache(),
connectToDevTools: true,
defaultOptions: {
query: {
fetchPolicy: 'network-only',
errorPolicy: 'all'
},
mutate: {
errorPolicy: 'all'
}
}
})
this.client = apolloClient
}
}
export default ApolloClientService
以上两个文件是做为 apollo 服务的客户端的一个简单封装,然后再增加一个基础服务类,用于 CRUD
创建 baseApolloCrud.service.ts
import BaseCrudServiceInterface from '../baseCrud.service.interface'
import ApolloClientService from './apolloClient.service'
import { injectSingleton } from '../../diContainer'
import { injectable } from 'inversify-props'
import {PaginatedList} from '../../../interfaces/page.interface';
@injectable()
export default abstract class BaseApolloCrudService<DTO, T> implements BaseCrudServiceInterface<DTO, T> {
/**
* Our Apollo Client instance.
* Note: Will (and should) be a singleton.
*/
@injectSingleton(ApolloClientService)
public readonly apolloClientService!: ApolloClientService
abstract create (dto: DTO): Promise<T>
abstract delete (id: string): Promise<any>
abstract get (params?: any): Promise<PaginatedList<T>>
abstract getById (id: string): Promise<T>
abstract update (dto: DTO): Promise<T>
}
这个类用到了反射机制,注入了 apolloClientService 实例,供子服务直接调用查询,熟悉面向对象语言的朋友应该比较熟悉这种感觉,但 ts 更加灵活些。
创建 baseCrud.service.interface.ts 进一步解耦
import {PaginatedList} from '../../interfaces/page.interface';
export default interface BaseCrudServiceInterface<DTO, T> {
get (params?: any): Promise<PaginatedList<T>> // TODO: Type this PaginatedList
getById (id: string): Promise<T>
create (dto: DTO): Promise<T>
update (dto: DTO): Promise<T>
delete (id: number): Promise<any>
}
创建 baseCrud.service.ts 接口实现类
import { injectable } from 'inversify-props'
import BaseApolloCrudService from './apollo/baseApolloCrud.service'
@injectable()
export default abstract class BaseCrudService<DTO, T> extends BaseApolloCrudService<DTO, T> {
}
这个类继承 BaseApolloCrudService 并且是个抽象类,做为服务基类进行扩展
然后创建 diContainer.ts
import 'reflect-metadata'
import { container } from 'inversify-props'
import { Cookies, QSsrContext } from 'quasar'
import { Store } from 'vuex'
import { RootState } from '../store/types'
import ApolloClientService from './_base/apollo/apolloClient.service';
import PostService from './posts/post.service';
import PostServiceInterface from './posts/post.service.interface';
import PostCurdDto from './posts/dto/postCurd.dto';
import Post from './posts/post.model';
import StoreService from "./_base/store.service";
export const buildDependencyContainer = (ssrContext: QSsrContext, store: Store<RootState>) => {
// console.log('Binding dependencies: ', ssrContext, store)
container.unbindAll()
const cookies = process.env.SERVER
? Cookies.parseSSR(ssrContext)
: Cookies
// Singletons
container.bind<ApolloClientService>(ApolloClientService).toSelf().inSingletonScope()
container.bind<StoreService>(StoreService).toSelf().inSingletonScope()
// Transient (instance per)
container.addTransient<PostServiceInterface<PostCurdDto, Post>>(PostService)
return container
}
export { container }
export function injectSingleton (type: any): any {
return function (target: any, targetKey: string, index?: number): any {
Reflect.deleteProperty(target, targetKey)
Reflect.defineProperty(target, targetKey, {
get () {
return container.get(type)
},
set (value) {
return value
}
})
}
}
这个是做为全局依赖注入的容器类,把它引入到 store/index.ts 中
import Vue from 'vue'
import Vuex, { Store } from 'vuex'
import { buildDependencyContainer } from '../modules/diContainer'
import { QSsrContext } from 'quasar'
import {RootState} from "./types";
import {ui} from "./modules/ui";
Vue.use(Vuex)
let store: Store<RootState>
export default function ({ ssrContext }: { ssrContext: QSsrContext }) {
store = new Vuex.Store( {
modules: {
// book,
// auth,
ui,
// user
},
// enable strict mode (adds overhead!)
// for dev mode only
strict: !!process.env.DEV
})
buildDependencyContainer(ssrContext, store)
return store
}
export { store }
这样基础的配制工作都已完成,看配置过程可能稍显复杂,但是事实上没有过多的逻辑,当这些配置完成后,我们的系统已经完成了大半,下一步就是具体的前端实现,因为前端过于简单,可参考文尾的源码。
总结:
这篇文章中主要是实践了Hasura GraphQL Engine 服务,因为有了这种实时数据服务的存在,我们可以省去大量的服务端开发,可以轻松定制各种垮平台的业务。而客户端虽然 quasar 非常好,但事实上不是仅有它才可以做到,还有很多的解决方案,比如普通的 vuejs 程序、react、gatsbyjs、gridsome 等等。
后面我还会持续完善这个小产品,让它成为一个非常强大的资源产品。
源码与访问地址:
https://github.com/baisheng/picker-client
也可以直接点我个人页面底部的导航查看效果。
网友评论