我尝试用PostGraphile替换了附带的GraphQL后端,并且还有Vue-Apollo附带的演示聊天应用程序” ApolloChat”
由于当前情况下代码解释不足,我会在之后增补说明。
這篇文章中要做的事情
将附带的 “ApolloChat” GraphQL 服务器,使用 PostGraphile 实现并替换,以将数据持久化到 PostgreSQL 中。以后我们将把附带的 “ApolloChat” 称为 “原始 ApolloChat”,将替换为 PostGraphile 的 “ApolloChat” 称为 “PostGraphile 版 ApolloChat”。
PostGraphile版ApolloChat的源代码
您可以从https://github.com/kanedaq/vue-apollo 获取该项目。要运行它,请执行以下步骤以启动 Web 服务器,并从浏览器访问“http://localhost:8080/demo/”。
git clone https://github.com/kanedaq/vue-apollo
cd vue-apollo/tests/demo/
yarn install
yarn serve
「实施中的懒惰(常见于演示中)」
-
- httpsで通信すべきところもhttpで通信しています。
-
- 本記事ではJWTをローカルストレージに保存していますが、本番環境ではなさらないようお願いします。HTML5のLocal Storageを使ってはいけない(翻訳) (2019-10-10追記)
-
- ログアウト処理はサーバー側は何もせずにtrueを返すだけにし、フロント側でブラウザのローカルストレージのJWTを削除するに留めました。きっちり実装したい場合は、以下のサイト等をご参照ください。
JSON Web Token を失効させる
SPAでログアウトした時にすべき処理って何ですか?
ログイン中に”jwt expired”のために動作が怪しくなったら、ログアウトボタンを押して強制的にJWTを削除してください。
ログイン画面で”jwt expired”のためにログインできなくなったら、ブラウザのローカルストレージに残っているJWT(キー:postgraphile-demo-token)を手動で削除してください。
その他、PostGraphileへの移植に際して、実装の抜け漏れがあるかと思います。
私(C++おじさん)はまだJavaScriptもVue.jsも経験が浅いので、変な箇所がありましたら優しくご指摘ください。
请参考该页面(感谢您)。
GraphiQL无法指定标头的解决方法
Vue:无效的主机标头
注意事项:在子目录中分发Vue.js项目
使用Vue CLI3的DevServer使用WebSocket
尝试在nginx(1.3.13)中使用WebSocket代理
使用Node.js和log4js输出日志
util.inspect的便利性
获取vue-apollo,并尝试运行原始的ApolloChat。
在将GraphQL后端转移到PostGraphile之前,首先尝试运行原始的ApolloChat。我将在~/work目录下进行操作(我使用的是Mac)。
获取 vue-apollo。
cd ~/work
git clone https://github.com/Akryum/vue-apollo
cd vue-apollo
在这里运行”gut log”命令会显示如下内容。
$ git log
commit 9419ffd03d5c0942ac5954572920ffa2281f1851 (HEAD -> master, origin/master, origin/HEAD)
Author: Darryl Hein <dhein@xmmedia.com>
Date: Tue May 28 11:38:41 2019 -0600
docs: Minor spelling correction (#635)
(後略)
在原始ApolloChat的目录中切换,并安装所需的软件包。
cd ~/work/vue-apollo/tests/demo
yarn install
我将立即试运行原版ApolloChat。要查找启动方法,请进行检索。
more package.json
显示如下。
{
"name": "demo",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"apollo:dev": "vue-cli-service apollo:watch",
"apollo:run": "vue-cli-service apollo:run",
"test:e2e:dev": "start-server-and-test apollo:dev http://localhost:4000/.well-known/apollo/server-health test:e2e:dev:client",
"test:e2e:dev:client": "vue-cli-service test:e2e --mode development",
"test:e2e": "start-server-and-test apollo:run http://localhost:4000/.well-known/apollo/server-health test:e2e:client",
"test:e2e:client": "vue-cli-service test:e2e --mode production --headless",
"test": "yarn run lint --no-fix && yarn run test:e2e"
},
在中文中,原始的ApolloChat启动命令是:
– GraphQL服务器: yarn apollo:dev或yarn apollo:run
– Web服务器(Vue.js应用程序发布): yarn serve
看起来不错。
首先,执行“yarn apollo:dev”以启动GraphQL服务器,将显示如下内容。
warning ../../../package.json: No license field
$ vue-cli-service apollo:watch
warning ../../../package.json: No license field
$ vue-cli-service apollo:run --delay
Using default PubSub implementation for subscriptions.
You should provide a different implementation in production (for example with Redis) by exporting it in 'apollo-server/pubsub.js'.
✔️ GraphQL Server is running on http://localhost:4000/graphql
✔️ Type rs to restart the server
按照指示,在浏览器中访问“http://localhost:4000/graphql”,会出现类似官方的GraphiQL的界面。本文将省略不表,但您可以尝试在“~/work/vue-apollo/tests/demo/src/graphql/”目录下的gql文件中编写的GraphQL查询。您可以在界面的左下方输入“HTTP HEADERS”,这样您就可以将登录过程中返回的JWT设置为HEADERS,以便尝试需要登录的查询。
在保持GraphQL服务器的同时,可以在另一个控制台上启动Web服务器。
cd ~/work/vue-apollo/tests/demo
yarn serve
编译完前端后,显示如下。
DONE Compiled successfully in 7780ms 17:51:01
App running at:
- Local: http://localhost:8080/
- Network: unavailable
Note that the development build is not optimized.
To create a production build, run yarn build.
按照指示,在浏览器中访问“http://localhost:8080/”,原始ApolloChat将启动。
首先会出现登录页面。
创建账号页面。
创立账号并登录后,出现的欢迎界面。
进入通用频道,打算发送“你好,世界”的界面。
发送了「你好,世界」后的屏幕。
尝试了各种操作后,发现原版的ApolloChat的GraphQL服务器将数据保存在内存中,而不进行持久化,这意味着一旦关闭GraphQL服务器,所创建的账户和聊天内容将会丢失。
后端实现
那么,让我们开始将原始的ApolloChat的GraphQL服务器迁移到PostGraphile。数据将持久化存储在PostgreSQL中。
环境搭建和后端实现(第一部分):涉及到PostgreSQL的相关事项。
由于之前已经写过一篇名为“尝试使用PostgREST和PostGraphile来提供PostgreSQL操作API”的文章中的“环境设置1”部分与此完全相同,所以本文将略去该部分。
在这个环境设置中,Laradock的workspace中的“/var/www”将被挂载到主机的“~/work”目录上。
创建数据库:环境搭建和后端实施(第二部分)
ER图
我在运行原始的ApolloChat,并查看~/work/vue-apollo/tests/demo/apollo-server/schema.graphql,尝试绘制ER图表。
在这4个表中,我们决定将以下3个表存储在PostgreSQL的apollo_demo模式中,以供登录用户查看。
-
- channels
-
- messages
- users
决定将用户不想展示的以下表格存储在PostgreSQL的apollo_demo_private模式中。
- user_accounts
PostgreSQL的功能之一,模式(schema)类似于命名空间,可以用于将想要显示的对象和不想显示的对象分开,以用于安全目的。
实施工作
为避免混乱,我们将删除原始的GraphQL服务器,并创建一个新的后端文件存储位置来进行开发。
cd ~/work/vue-apollo/tests/demo/
rm -rf apollo-server
mkdir postgraphile-server
我进入Laradock的工作区。
cd ~/work/laradock
docker-compose exec --user=laradock workspace bash
进一步进入psql。默认用户密码为:secret。
psql -h postgres -U default
请创建一个可以连接的PostGraphile版ApolloChat数据库,并确认其可连接性。
你可以将数据库名设置为”demodb”,但名称可以任意取。
create database demodb;
\connect demodb
如果忘记连接到「demodb」数据库,注意会在另一个数据库中创建模式。
请继续执行以下SQL语句,创建模式和数据(稍后会解释内容)。
该SQL语句将保存在“~/work/vue-apollo/tests/demo/postgraphile-server/schema_and_data.sql”中。
另外,JWT的到期时间设置为60分钟。如果需要更改,请修改“60 minutes”这一部分。
begin;
create role apollo_demo_postgraphile login password 'xyz';
create role apollo_demo_anonymous;
grant apollo_demo_anonymous to apollo_demo_postgraphile;
create role apollo_demo_login_user;
grant apollo_demo_login_user to apollo_demo_postgraphile;
create schema apollo_demo;
create schema apollo_demo_private;
create table apollo_demo.users (
id integer primary key generated by default as identity,
nickname text not null,
email text not null unique check (email ~* '^.+@.+\..+$')
);
comment on table apollo_demo.users is 'A user of the chat.';
comment on column apollo_demo.users.id is 'The primary unique identifier for the user.';
comment on column apollo_demo.users.nickname is 'The user’s nickname.';
comment on column apollo_demo.users.email is 'The email address of the user.';
create table apollo_demo.channels (
id text primary key,
name text not null
);
comment on table apollo_demo.channels is 'A channel of the chat.';
comment on column apollo_demo.channels.id is 'The primary key for the channel.';
comment on column apollo_demo.channels.name is 'The channel’s name.';
create table apollo_demo.messages (
id integer primary key generated by default as identity,
channel_id text not null references apollo_demo.channels(id),
user_id integer not null references apollo_demo.users(id),
content text not null,
date_added timestamp not null default current_timestamp,
date_updated timestamp
) ;
comment on table apollo_demo.messages is 'A message written by a user.';
comment on column apollo_demo.messages.id is 'The primary key for the message.';
comment on column apollo_demo.messages.channel_id is 'The id of the channel.';
comment on column apollo_demo.messages.user_id is 'The id of the user.';
comment on column apollo_demo.messages.content is 'The content this has been posted in.';
comment on column apollo_demo.messages.date_added is 'The time this message was added.';
comment on column apollo_demo.messages.date_updated is 'The time this message was updated';
alter default privileges revoke execute on functions from public;
create function apollo_demo_private.set_date_updated() returns trigger as $$
begin
new.date_updated := current_timestamp;
return new;
end;
$$ language plpgsql;
create trigger messages_date_updated before update
on apollo_demo.messages
for each row
execute procedure apollo_demo_private.set_date_updated();
create table apollo_demo_private.user_accounts (
user_id integer primary key references apollo_demo.users(id) on delete cascade,
password_hash text not null
) ;
comment on table apollo_demo_private.user_accounts is 'Private information about a user’s account.';
comment on column apollo_demo_private.user_accounts.user_id is 'The id of the User associated with this account.';
comment on column apollo_demo_private.user_accounts.password_hash is 'An opaque hash of the user password.';
create extension if not exists "pgcrypto";
create function apollo_demo.user_register(
nickname text,
email text,
password text
) returns apollo_demo.users as $$
declare
usr apollo_demo.users;
begin
insert into apollo_demo.users (nickname, email) values
(user_register.nickname, user_register.email)
returning * into usr;
insert into apollo_demo_private.user_accounts (user_id, password_hash) values
(usr.id, crypt(user_register.password, gen_salt('bf')));
return usr;
end;
$$ language plpgsql strict security definer;
comment on function apollo_demo.user_register(text, text, text) is 'Registers a single user and creates an account in our chat.';
create type apollo_demo.jwt_token as (
role text,
user_id integer,
exp integer
);
create type apollo_demo.usr_and_token as (
usr apollo_demo.users,
token apollo_demo.jwt_token
);
create function apollo_demo.user_login(
email text,
password text
) returns apollo_demo.usr_and_token as $$
select ((users.*)::apollo_demo.users, ('apollo_demo_login_user', users.id, extract(epoch from (now() + interval '60 minutes')))::apollo_demo.jwt_token)::apollo_demo.usr_and_token
from apollo_demo.users
inner join apollo_demo_private.user_accounts
on users.id = user_accounts.user_id
where
users.email = user_login.email
and user_accounts.password_hash = crypt(user_login.password, user_accounts.password_hash);
$$ language sql strict security definer;
comment on function apollo_demo.user_login(text, text) is 'Creates a JWT token that will securely identify a user and give them certain permissions.';
create function apollo_demo.user_current() returns apollo_demo.users as $$
select *
from apollo_demo.users
where id = current_setting('jwt.claims.user_id', true)::integer
$$ language sql stable;
comment on function apollo_demo.user_current() is 'Gets the user who was identified by our JWT.';
create function apollo_demo.change_password(
current_password text,
new_password text
)
returns boolean as $$
declare
usr_current apollo_demo.users;
begin
usr_current := apollo_demo.user_current();
if exists (select 1 from apollo_demo_private.user_accounts where user_accounts.user_id = usr_current.id and user_accounts.password_hash = crypt(change_password.current_password, usr_current.password_hash))
then
update apollo_demo_private.user_accounts set password_hash = crypt(change_password.new_password, gen_salt('bf'))
where user_accounts.user_id = usr_current.id;
return true;
else
return false;
end if;
end;
$$ language plpgsql strict security definer;
create function apollo_demo.user_logout(
) returns boolean as $$
begin
return true;
end;
$$ language plpgsql strict security definer;
create function apollo_demo.message_add(
channel_id text,
content text
) returns apollo_demo.messages as $$
declare
message apollo_demo.messages;
begin
INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
(message_add.channel_id, current_setting('jwt.claims.user_id', true)::integer, message_add.content)
RETURNING messages.* into message;
return message;
end;
$$ language plpgsql strict security definer;
CREATE SEQUENCE mock_message_seq
INCREMENT BY 1
START WITH 1
;
create function apollo_demo.mock_message_send(
) returns boolean as $$
begin
INSERT INTO apollo_demo.messages (channel_id, user_id, content)
SELECT 'general', 0, 'How are you doing? ' || nextval('mock_message_seq');
return true;
end;
$$ language plpgsql strict security definer;
grant usage on schema apollo_demo to apollo_demo_anonymous, apollo_demo_login_user;
grant select on table apollo_demo.users to apollo_demo_login_user;
grant update, delete on table apollo_demo.users to apollo_demo_login_user;
grant select on table apollo_demo.channels to apollo_demo_login_user;
grant insert, update, delete on table apollo_demo.channels to apollo_demo_login_user;
grant select on table apollo_demo.messages to apollo_demo_login_user;
grant insert, update, delete on table apollo_demo.messages to apollo_demo_login_user;
grant execute on function apollo_demo.user_register(text, text, text) to apollo_demo_anonymous;
grant execute on function apollo_demo.user_login(text, text) to apollo_demo_anonymous;
grant execute on function apollo_demo.user_current() to apollo_demo_login_user;
grant execute on function apollo_demo.change_password(text, text) to apollo_demo_login_user;
grant execute on function apollo_demo.user_logout() to apollo_demo_anonymous, apollo_demo_login_user;
grant execute on function apollo_demo.message_add(text, text) to apollo_demo_login_user;
grant execute on function apollo_demo.mock_message_send() to apollo_demo_login_user;
alter table apollo_demo.users enable row level security;
alter table apollo_demo.messages enable row level security;
create policy select_users on apollo_demo.users for select
using (true);
create policy select_messages on apollo_demo.messages for select
using (true);
create policy update_users on apollo_demo.users for update to apollo_demo_login_user
using (id = current_setting('jwt.claims.user_id', true)::integer);
create policy delete_users on apollo_demo.users for delete to apollo_demo_login_user
using (id = current_setting('jwt.claims.user_id', true)::integer);
create policy insert_messages on apollo_demo.messages for insert to apollo_demo_login_user
with check (user_id = current_setting('jwt.claims.user_id', true)::integer);
create policy update_messages on apollo_demo.messages for update to apollo_demo_login_user
using (user_id = current_setting('jwt.claims.user_id', true)::integer);
create policy delete_messages on apollo_demo.messages for delete to apollo_demo_login_user
using (user_id = current_setting('jwt.claims.user_id', true)::integer);
INSERT INTO apollo_demo.users (id, nickname, email) VALUES
(0, 'The Bot', 'bot@bot.com');
INSERT INTO apollo_demo_private.user_accounts (user_id, password_hash) VALUES
(0, crypt('bot', gen_salt('bf')));
INSERT INTO apollo_demo.channels (id, name) VALUES
('general', 'General discussion'),
('random', 'Have fun chatting!'),
('help', 'Ask for or give help');
INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
('general', 0, 'Welcome to the chat!');
create type apollo_demo.message_nullable as (
id integer,
channel_id text,
user_id integer,
content text,
date_added timestamp,
date_updated timestamp
) ;
create or replace function apollo_demo.graphql_subscription() returns trigger as $$
declare
v_process_new bool = (TG_OP = 'INSERT' OR TG_OP = 'UPDATE');
v_process_old bool = (TG_OP = 'UPDATE' OR TG_OP = 'DELETE');
v_event text = TG_ARGV[0];
v_topic_template text = TG_ARGV[1];
v_attribute text = TG_ARGV[2];
v_record record;
v_sub text;
v_topic text;
begin
if v_process_new then
v_record = new;
else
v_record = old;
end if;
if v_attribute is not null then
execute 'select $1.' || quote_ident(v_attribute)
using v_record
into v_sub;
end if;
if v_sub is not null then
v_topic = replace(v_topic_template, '$1', v_sub);
else
v_topic = v_topic_template;
end if;
perform pg_notify(v_topic, json_build_object(
'event', v_event,
'newrec', new.*,
'oldrec', old.*,
'type', TG_OP
)::text);
-- RAISE LOG 'v_topic = %', v_topic;
-- RAISE LOG 'json_build_object = %', json_build_object('event', v_event, 'newrec', new.*, 'oldrec', old.*, 'type', TG_OP);
return v_record;
end;
$$ language plpgsql volatile set search_path from current;
DROP TRIGGER IF EXISTS _500_gql_update_message ON apollo_demo.messages;
CREATE TRIGGER _500_gql_update_message
AFTER INSERT OR UPDATE OR DELETE ON apollo_demo.messages
FOR EACH ROW
EXECUTE PROCEDURE apollo_demo.graphql_subscription(
'messageChanged', -- the "event" string, useful for the client to know what happened
'graphql:message:$1', -- the "topic" the event will be published to, as a template
'channel_id' -- If specified, `$1` above will be replaced with NEW.channel_id or OLD.channel_id from the trigger.
);
commit;
如果处理成功,我会退出psql和Laradock的workspace,然后回到主机上。
环境搭建和后端实现(第三部分):与PostGraphile相关。
我們決定使用PostGraphile官方Docker映像。然而,為了運行GraphQL訂閱,需要在映像中安裝JavaScript包。因此,我們將以官方映像為基礎映像創建新的Docker映像。官方映像的Dockerfile在這裡。
创建一个存放新的Dockerfile的位置。
mkdir -p ~/work/laradock/postgraphile_plus
创建一个新的Dockerfile。
FROM graphile/postgraphile
ENV NODE_PATH=/usr/local/lib/node_modules
# RUN yarn global add @graphile/pg-pubsub graphile-build graphql postgraphile graphile-utils log4js
RUN npm install -g @graphile/pg-pubsub graphile-build graphql postgraphile graphile-utils log4js
ENTRYPOINT ["./cli.js"]
在验证中使用JWT。
执行以下操作来生成JWT的共享密钥(私钥)(Linux命令)。该共享密钥将在令牌创建和验证过程中通用使用。
LC_CTYPE=C < /dev/urandom tr -dc A-Za-z0-9 | head -c32
在本页面中,我们使用以下共同密钥。由于它是个秘密钥匙,所以在正式环境中需要特别小心处理。
K3INqoxbNWCB4hYiDnX2zlbIpm8UA5zR
在Laradock的docker-compose.yml文件的末尾添加以下内容。还配置了JWT使用的共享密钥。
### PostGraphile ################################################
postgraphile_demo:
build:
context: ./postgraphile_plus
environment:
DATABASE_URL: "postgres://apollo_demo_postgraphile:xyz@postgres:${POSTGRES_PORT}/demodb"
expose:
- "16000"
command: ["--plugins", "@graphile/pg-pubsub", "--append-plugins", "/root/www/vue-apollo/tests/demo/postgraphile-server/DemoSubscriptionPlugin.js", "--subscriptions", "--connection", "postgres://apollo_demo_postgraphile:xyz@postgres:${POSTGRES_PORT}/demodb", "--port", "16000", "--schema", "apollo_demo", "--default-role", "apollo_demo_anonymous", "--secret", "K3INqoxbNWCB4hYiDnX2zlbIpm8UA5zR", "--token", "apollo_demo.jwt_token", "--export-schema-graphql", "/root/www/vue-apollo/tests/demo/postgraphile-server/schema.graphql"]
ports:
- "16000:16000"
depends_on:
- postgres
restart: on-failure:20
networks:
- backend
volumes:
- ${APP_CODE_PATH_HOST}:/root/www
由于上述的docker-compose.yml文件中,已经配置了使用”DemoSubscriptionPlugin.js”作为PostGraphile的插件,因此也需要创建该文件。插件的详细内容稍后将进行介绍。
请按照以下内容用DemoSubscriptionPlugin.js进行新建:
文件的路径为「~/work/vue-apollo/tests/demo/postgraphile-server」。
const DEBUG_MODE = false
////////////////////////////////////////////////////////////////
// デバッグ用コード。デバッグ時に /root/www/debug.log にログを出力する。
////////////////////////////////////////////////////////////////
const log4js = require('log4js')
const util = require('util')
log4js.configure({
appenders : {
debug : {type : 'file', filename : '/root/www/debug.log'}
},
categories : {
default : {appenders : ['debug'], level : 'debug'},
}
});
var logger = log4js.getLogger('debug');
////////////////////////////////////////////////////////////////
// 以下、https://www.graphile.org/postgraphile/subscriptions/ の
// MySubscriptionPlugin.jsを改変
////////////////////////////////////////////////////////////////
const { makeExtendSchemaPlugin, gql, embed } = require("graphile-utils");
const makeTopic = (_args, context, _resolveInfo) => {
if (DEBUG_MODE) {
logger.debug('makeTopic() in');
logger.debug('_args = ');
logger.debug(util.inspect(_args));
logger.debug('context = ');
logger.debug(util.inspect(context));
// デバッグ時に、JWTが渡されなくてもSubscriptionを有効にしたいなら、ここでreturn
return `graphql:message:${_args.channelId}`;
}
if (context.jwtClaims.user_id) {
return `graphql:message:${_args.channelId}`;
} else {
throw new Error("You're not logged in");
}
};
module.exports = makeExtendSchemaPlugin(({ pgSql: sql }) => ({
typeDefs: gql`
type MessageChanged {
# This is returned directly from the PostgreSQL subscription payload (JSON object)
type: String!
# This is populated by our resolver below
message: Message
# This is returned directly from the PostgreSQL subscription payload (JSON object)
oldrec: MessageNullable
}
extend type Subscription {
messageChanged (channelId: String!): MessageChanged! @pgSubscription(topic: ${embed(
makeTopic
)})
}
`,
resolvers: {
MessageChanged: {
// This method finds the user from the database based on the event
// published by PostgreSQL.
//
// In a future release, we hope to enable you to replace this entire
// method with a small schema directive above, should you so desire. It's
// mostly boilerplate.
async message(
event,
_args,
_context,
{
graphile: { selectGraphQLResultFromTable },
}
) {
if (DEBUG_MODE) {
logger.debug('message() in');
logger.debug('event = ');
logger.debug(util.inspect(event));
}
if (event.type === 'DELETE') {
return null;
}
const rows = await selectGraphQLResultFromTable(
sql.fragment`apollo_demo.messages`,
(tableAlias, sqlBuilder) => {
if (DEBUG_MODE) {
logger.debug('selectGraphQLResultFromTable() in');
}
sqlBuilder.where(
sql.fragment`${sqlBuilder.getTableAlias()}.id = ${sql.value(
event.newrec.id
)}`
);
}
);
if (DEBUG_MODE) {
logger.debug('rows = ');
logger.debug(util.inspect(rows));
}
return rows[0];
},
},
},
}));
建立Docker环境
cd ~/work/laradock
docker-compose up -d postgres pgadmin workspace postgraphile_demo
请稍等片刻。一旦完成,
docker-compose ps
我們看到以下的顯示,請確認狀態已經變成”Up”。
Name Command State Ports
-----------------------------------------------------------------------------------------------------
laradock_docker-in-docker_1 dockerd-entrypoint.sh Up 2375/tcp
laradock_pgadmin_1 docker-entrypoint.sh pgadmin4 Up 0.0.0.0:5050->5050/tcp
laradock_postgraphile_demo_1 ./cli.js --plugins @graphi Up 0.0.0.0:16000->16000/tcp,
... 5000/tcp
laradock_postgres_1 docker-entrypoint.sh postgres Up 0.0.0.0:5432->5432/tcp
laradock_workspace_1 sudo bash -c [ -e /var/run Up 0.0.0.0:2222->22/tcp,
... 0.0.0.0:3389->3389/tcp,
0.0.0.0:14000->4000/tcp,
0.0.0.0:18080->8080/tcp
确认PostGraphile能够连接到PostgreSQL。
docker logs laradock_postgraphile_demo_1
只要没有显示错误信息,PostGraphile就已经成功启动了。
PostGraphile v4.4.1-alpha.4 server listening on port 16000 ?
‣ GraphQL API: http://0.0.0.0:16000/graphql (subscriptions enabled)
‣ GraphiQL GUI/IDE: http://0.0.0.0:16000/graphiql
‣ Postgres connection: postgres://apollo_demo_postgraphile:[SECRET]@postgres/demodb
‣ Postgres schema(s): apollo_demo
‣ Documentation: https://graphile.org/postgraphile/introduction/
* * *
在没有任何启动的情况下,使用docker-compose一次性启动所有容器时,可能会发生一些错误,因为PostGraphile试图在等待PostgreSQL启动之前启动。关于这个现象,我在“PostGraphile(GraphQL)、Vue.js、Apollo、VuetifyでCRUD機能を実装してみた”一文中进行了描述,请参考。
现在在刚才的docker-compose.yml文件中,已经设置了将GraphQL模式导出到”schema.graphql”。在启动PostGraphile时,它会自动读取PostgreSQL的模式信息并生成GraphQL模式,该内容将被导出。现在我们来检查文件是否存在。
$ ls ~/work/vue-apollo/tests/demo/postgraphile-server
DemoSubscriptionPlugin.js schema_and_data.sql
schema.graphql
后端实现解释
以上完成了使用PostGraphile实现类似于原始ApolloChat的GraphQL服务器的工作。剩下的就是修改前端源代码以使用PostGraphile,这样PostGraphile版的ApolloChat就能够运行了。但在此之前,我将解释一下我们在之前的工作中所进行的后端实现。
(说明将在以后写出)
前端改动
由於後端的實現已經完成,所以接下來將著手處理前端的Vue.js應用程式。
原始的ApolloChat啟動URL是「http://localhost:8080/」,但為了避免混淆,我們決定將PostGraphile版本的ApolloChat設置為「http://localhost:8080/demo/」。
续写(2019-06-19)
在本文中,我們將使用Altair GraphQL Client進行GraphQL查詢,並且已經將query和mutation設置在HEADERS中以認識JWT,然而,subscription卻無法被認識。在文章發布後,我發現了Graphql Playground的存在,試了一下發現它能夠認識subscription並且使用JWT,所以使用它會更方便一些。(追加結束)
请用中文改写GraphQL查询,并使用Altair GraphQL客户端进行尝试。
原始的ApolloChat使用的GraphQL查询语句存储在以下目录中。
$ ls ~/work/vue-apollo/tests/demo/src/graphql
channel.gql messageFragment.gql userLogin.gql
channelFragment.gql messageRemove.gql userLogout.gql
channels.gql messageUpdate.gql userRegister.gql
messageAdd.gql userCurrent.gql
messageChanged.gql userFragment.gql
在试验过程中,我们会逐个将这些 gql 文件修改为适用于 PostGraphile 的版本。
请下载并安装Altair GraphQL Client以进行查询。
打开Altair GraphQL Client,将URL字段输入为”http://localhost:16000/graphql”。
以下是PostGraphile自动生成的文件“schema.graphql”,其中包含了GraphQL模式,可供您在创建GraphQL查询时参考。
下面是文件内容的修改前后。
碎片
将以下内容以中文进行同义转述:用户片段.gql
没有变更。
(Means: There are no changes.)
fragment user on User {
id
nickname
}
channelFragment.gql的个别变种
没有变更。
fragment channel on Channel {
id
name
}
messageFragment.gql的意思是将gql片段作为消息。
原创
#import "./userFragment.gql"
fragment message on Message {
id
content
user {
...user
}
dateAdded
dateUpdated
}
更改后
#import "./userFragment.gql"
fragment message on Message {
id
content
userByUserId {
...user
}
dateAdded
dateUpdated
}
无需登录即可提问
用户注册的文件.gql
功能:用户注册
独创的
mutation userRegister ($input: UserRegister!) {
userRegister(input: $input)
}
更改后
mutation userRegister ($input: UserRegisterInput!) {
userRegister(input: $input) {
user {
id
nickname
email
}
}
}
我們將以下內容設定為VARIABLES,然後在Altair GraphQL Client中進行測試。
{
"input": {
"nickname": "foo",
"email": "foo@foo.com",
"password": "p@ss"
}
}
反应
{
"data": {
"userRegister": {
"user": {
"id": 1,
"nickname": "foo",
"email": "foo@foo.com"
}
}
}
}
用户退出登录.gql
功能:退出登录
原创
mutation userLogout {
userLogout
}
更改后
mutation userLogout ($input: UserLogoutInput!) {
userLogout (input: $input) {
boolean
}
}
在设置VARIABLES后,我们将在Altair GraphQL客户端中进行尝试。
{
"input": {
}
}
回应
{
"data": {
"userLogout": {
"boolean": true
}
}
}
用户登录.gql
功能:登录
原版
#import "./userFragment.gql"
mutation userLogin ($email: String!, $password: String!) {
userLogin (email: $email, password: $password) {
user {
...user
email
}
token {
id
userId
expiration
}
}
}
更改后
mutation userLogin ($input: UserLoginInput!) {
userLogin (input: $input) {
usrAndToken {
usr {
id
nickname
email
}
token
}
}
}
我将以下内容设置为变量,并尝试在Altair GraphQL Client中运行。
{
"input": {
"email": "foo@foo.com",
"password": "p@ss"
}
}
回应
{
"data": {
"userLogin": {
"usrAndToken": {
"usr": {
"id": 1,
"nickname": "foo",
"email": "foo@foo.com"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYXBvbGxvX2RlbW9fbG9naW5fdXNlciIsInVzZXJfaWQiOjEsImV4cCI6MTU2MDg0NjM3NiwiaWF0IjoxNTYwODQyNzc2LCJhdWQiOiJwb3N0Z3JhcGhpbGUiLCJpc3MiOiJwb3N0Z3JhcGhpbGUifQ.uE6rXeIWDuFaLaAqE56OisICQKzntl5e4L4znSryoXU"
}
}
}
}
需要登录才能查询的问题
因为在先前的登录处理响应中返回了JWT令牌,所以我们将其设置在Altair GraphQL客户端的HEADERS中,格式如下。
Authorization
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYXBvbGxvX2RlbW9fbG9naW5fdXNlciIsInVzZXJfaWQiOjEsImV4cCI6MTU2MDg0NjM3NiwiaWF0IjoxNTYwODQyNzc2LCJhdWQiOiJwb3N0Z3JhcGhpbGUiLCJpc3MiOiJwb3N0Z3JhcGhpbGUifQ.uE6rXeIWDuFaLaAqE56OisICQKzntl5e4L4znSryoXU
如果以后询问时收到响应”jwt expired”,请从Altair GraphQL客户端的HEADERS中删除JWT,然后重新执行userLogin mutation,并将返回的令牌重新设置到HEADERS中。
用户当前.gql
功能:获取已登录用户的信息
没有变更。
#import "./userFragment.gql"
query userCurrent {
userCurrent {
...user
email
}
}
我将在Altair GraphQL客户端中尝试执行。我直接输入了所导入的片段。
响应
{
"data": {
"userCurrent": {
"id": 1,
"nickname": "foo",
"email": "foo@foo.com"
}
}
}
频道.gql
功能:获取所有频道
原版
#import "./channelFragment.gql"
query channels {
channels {
...channel
}
}
变更后
#import "./channelFragment.gql"
query channels {
allChannels {
nodes {
...channel
}
}
}
我将尝试使用Altair GraphQL Client。
回应
{
"data": {
"allChannels": {
"nodes": [
{
"id": "general",
"name": "General discussion"
},
{
"id": "help",
"name": "Ask for or give help"
},
{
"id": "random",
"name": "Have fun chatting!"
}
]
}
}
}
频道.gql
功能:通过id搜索频道,并获取该频道的所有消息。
能力:根据ID搜索频道,并获取该频道的所有消息。
原始的
#import "./channelFragment.gql"
#import "./messageFragment.gql"
query channel ($id: ID!) {
channel (id: $id) {
...channel
messages {
...message
}
}
}
更改后
#import "./channelFragment.gql"
#import "./messageFragment.gql"
query channel ($id: String!) {
channelById (id: $id) {
...channel
messagesByChannelId {
nodes {
...message
}
}
}
}
我将在 Altair GraphQL 客户端中设置 VARIABLES 并进行尝试。
{
"id": "general"
}
响应
{
"data": {
"channelById": {
"id": "general",
"name": "General discussion",
"messagesByChannelId": {
"nodes": [
{
"id": 1,
"content": "Welcome to the chat!",
"userByUserId": {
"id": 0,
"nickname": "The Bot"
},
"dateAdded": "2019-06-18T07:10:27.671412",
"dateUpdated": null
}
]
}
}
}
}
消息添加.gql
功能:在指定的频道中添加消息
原创
#import "./messageFragment.gql"
mutation messageAdd ($input: MessageAdd!) {
messageAdd (input: $input) {
...message
}
}
更改之后 zhī
#import "./messageFragment.gql"
mutation messageAdd ($input: MessageAddInput!) {
messageAdd (input: $input) {
message {
...message
}
}
}
我将使用以下设置在Altair GraphQL客户端中尝试。
{
"input": {
"channelId": "general",
"content": "ハローワールド"
}
}
回应
{
"data": {
"messageAdd": {
"message": {
"id": 2,
"content": "ハローワールド",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T07:44:02.534676",
"dateUpdated": null
}
}
}
}
只需提供一种中文原生选项:
在这里再次发出通道查询,可以确认已添加了“你好,世界”消息。
更新信息.gql
功能:消息更改(在ApolloChat中未使用)
原文
#import "./messageFragment.gql"
mutation messageUpdate ($input: MessageUpdate!) {
messageUpdate (input: $input) {
...message
}
}
修改后
#import "./messageFragment.gql"
mutation messageUpdate ($input: UpdateMessageByIdInput!) {
updateMessageById (input: $input) {
message {
...message
}
}
}
我将对之前使用messageAdd mutation添加的消息进行修改。VARIABLES是指。
{
"input": {
"messagePatch": {
"content": "こんにちは世界"
},
"id": 2
}
}
回应
{
"data": {
"updateMessageById": {
"message": {
"id": 2,
"content": "こんにちは世界",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T07:44:02.534676",
"dateUpdated": "2019-06-18T07:48:16.794758"
}
}
}
}
当您在此处发出通道查询时,您可以确认“Hello World”消息已更改为“你好世界”。
删除信息.gql
功能:消息删除(ApolloChat未使用)
原创
#import "./messageFragment.gql"
mutation messageRemove ($id: ID!) {
messageRemove (id: $id) {
...message
}
}
更改后
#import "./messageFragment.gql"
mutation messageRemove ($input: DeleteMessageByIdInput!) {
deleteMessageById (input: $input) {
message {
...message
}
}
}
我将尝试删除在先前的messageUpdate变异中更改的消息。 VARIABLES 是…
{
"input": {
"id": 2
}
}
回应
{
"data": {
"deleteMessageById": {
"message": {
"id": 2,
"content": "こんにちは世界",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T07:44:02.534676",
"dateUpdated": "2019-06-18T07:48:16.794758"
}
}
}
}
在这里发出频道查询,可以确认已经删除了“你好世界”的消息。
消息已更改.gql
功能:订阅服务。若有人在指定频道中添加、修改或删除消息,服务器将发送通知。
「翻译为汉语后的结果」
#import "./messageFragment.gql"
subscription messageChanged ($channelId: ID!) {
messageChanged (channelId: $channelId) {
type
message {
...message
}
}
}
更改之后
#import "./messageFragment.gql"
fragment messageNullable on MessageNullable {
id
channelId
userId
content
dateAdded
dateUpdated
}
subscription messageChanged ($channelId: String!) {
messageChanged (channelId: $channelId) {
type
message {
...message
}
oldrec {
...messageNullable
}
}
}
关于订阅试用的议题
在Altair GraphQL Client中,只需在HEADERS中设置JWT令牌,即可尝试登录后的查询和变更操作,但是,订阅操作却无法识别该令牌。
关于这个问题,可以参考以下推文:
我了解到了Altair GraphQL Client的存在。我试着立刻使用它与PostGraphile配合,将JWT令牌设置到头部并尝试使用,查询和变更操作都能识别令牌,但是订阅操作(WebSocket)却不能识别。是我的使用方法出了问题吗?不过这个工具还是很方便的。
嘿,对于混淆感到抱歉。在订阅中,由于使用了Websockets(不允许指定头信息https://t.co/m4iPzKn7bV),你无法指定头信息。当开始订阅进行身份验证时,无法将连接参数传递给订阅服务器。
Apollo的订阅认证的描述来自于https://t.co/QqE3fDtT7z— Sir Muel I ?? (@imolorhe) 2019年6月7日。
由于这种情况,我们决定在后端放宽安全性,以便即使不登录也可以尝试使用messageChanged订阅。我们将在PostGraphile插件「DemoSubscriptionPlugin.js」中将DEBUG_MODE设置为true。
- const DEBUG_MODE = false
+ const DEBUG_MODE = true
(後略)
为了启用更改后的DemoSubscriptionPlugin.js,需要重新启动PostGraphile容器。
cd ~/work/laradock
docker stop laradock_postgraphile_demo_1
docker-compose up -d postgres pgadmin workspace postgraphile_demo
现在可以等待接收`messageChanged`的消息了。
此外,为了让`messageChanged`能够在创建发送数据时从PostgreSQL中获取数据,我们会在暂时降低PostgreSQL的安全性。
进入Laradock的workspace,然后进入psql。
docker-compose exec --user=laradock workspace bash
psql -h postgres -U default demodb
匿名用户将被赋予每个表的select权限。
grant select on table apollo_demo.messages to apollo_demo_anonymous;
grant select on table apollo_demo.users to apollo_demo_anonymous;
grant select on table apollo_demo.channels to apollo_demo_anonymous;
现在可以接收到messageChanged。
由于仍然需要使用psql,请保持其运行状态。
那么,让我们将以下内容设置到VARIABLES中,并尝试在Altair GraphQL Client中执行。
{
"channelId": "general"
}
等待接收信息的屏幕。
在这个状态下,使用psql发出以下的INSERT语句。
INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
('general', 1, '美味しい!');
回应
{
"data": {
"messageChanged": {
"type": "INSERT",
"message": {
"id": 3,
"content": "美味しい!",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T08:02:26.293796",
"dateUpdated": null
},
"oldrec": null
}
}
}
通过psql INSERT命令返回了一个数据响应。
对于这个数据,使用psql发出UPDATE语句。
UPDATE apollo_demo.messages SET
content = '美味しい!'
WHERE id = 3;
回应
{
"data": {
"messageChanged": {
"type": "UPDATE",
"message": {
"id": 3,
"content": "美味しい!",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T08:02:26.293796",
"dateUpdated": "2019-06-18T08:07:13.937846"
},
"oldrec": {
"id": 3,
"channelId": null,
"userId": null,
"content": "美味しい!",
"dateAdded": null,
"dateUpdated": null
}
}
}
}
更新后的数据在响应中返回了。
我对oldrec(UPDATE前的行)的多数列为空值感到不满。这次在PostgreSQL中定义模式时,列名采用了蛇形命名法。当PostGraphile自动生成GraphQL模式时,它会将蛇形命名法转换为驼峰命名法。似乎对于这些已经进行了转换的列,没有传递任何值,导致它们变成了空值。在AppolloChat中,只使用了oldrec.id,所以正确传递值的实现将成为以后的课题,我们将继续进行下去。
对返回的数据,使用psql执行DELETE语句。
DELETE FROM apollo_demo.messages
WHERE id = 3;
对于”レスポンス”这个词可以有以下翻译:
– 回应
– 响应
– 反应
– 应答
– 反馈
– 回应时间
{
"data": {
"messageChanged": {
"type": "DELETE",
"message": null,
"oldrec": {
"id": 3,
"channelId": null,
"userId": null,
"content": "美味しい!",
"dateAdded": null,
"dateUpdated": null
}
}
}
}
我也收到了关于删除的通知。
通过执行以下REVOKE语句,在完成messageChanged订阅的尝试后,恢复之前放宽的安全措施。
revoke select on table apollo_demo.messages from apollo_demo_anonymous;
revoke select on table apollo_demo.users from apollo_demo_anonymous;
revoke select on table apollo_demo.channels from apollo_demo_anonymous;
在这种状态下,如果从psql再次执行上述的INSERT语句,会收到以下错误,并且可以了解到PostgreSQL方面的安全已经恢复。
回应
{
"errors": [
{
"message": "permission denied for table messages",
"locations": [
{
"line": 25,
"column": 5
}
],
"path": [
"messageChanged",
"message"
]
}
],
"data": {
"messageChanged": {
"type": "INSERT",
"message": null,
"oldrec": null
}
}
}
我会退出psql和Laradock的workspace,然后返回到主机。
我会点击Altair GraphQL Client的停止按钮来停止messageChanged订阅。
接下来,我们将修复PostGraphile插件”DemoSubscriptionPlugin.js”的安全问题。
将DEBUG_MODE设置为false,如下所示。
- const DEBUG_MODE = true
+ const DEBUG_MODE = false
(後略)
为了使修改后的DemoSubscriptionPlugin.js生效,需要重新启动PostGraphile容器。
cd ~/work/laradock
docker stop laradock_postgraphile_demo_1
docker-compose up -d postgres pgadmin workspace postgraphile_demo
即使在这种情况下使用Altair GraphQL客户端尝试启动messageChanged,也会返回错误消息并无法等待接收,我们确认了安全性得到恢复。
回复
{
"errors": [
{
"message": "Cannot read property 'user_id' of null",
"locations": [
{
"line": 23,
"column": 3
}
],
"path": [
"messageChanged"
]
}
]
}
~/工作/vue-apollo/tests/demo/src/components/MockSendMessage.vue
除了现有的gql文件外,Vue组件的源代码中还有直接编写的查询,请在修改查询的同时进行尝试。
查询功能:在general通道发布消息“你好吗?+连续编号”。
原件
<script>
import gql from 'graphql-tag'
export default {
created () {
this.SEND = gql`
mutation mockMessageSend {
mockMessageSend
}
`
},
}
</script>
<template>
<ApolloMutation
:mutation="SEND"
class="mock-send-message"
>
<a
slot-scope="{ mutate }"
type="button"
@click="mutate"
>Send bot message</a>
</ApolloMutation>
</template>
<style lang="stylus" scoped>
.mock-send-message
margin-top 8px
display flex
align-items center
justify-content center
</style>
改变后
<script>
import gql from 'graphql-tag'
export default {
created () {
this.SEND = gql`
mutation mockMessageSend ($input: MockMessageSendInput!) {
mockMessageSend (input: $input) {
boolean
}
}
`
},
}
</script>
<template>
<ApolloMutation
:mutation="SEND"
:variables="{
input: {
},
}"
class="mock-send-message"
>
<a
slot-scope="{ mutate }"
type="button"
@click="mutate"
>Send bot message</a>
</ApolloMutation>
</template>
<style lang="stylus" scoped>
.mock-send-message
margin-top 8px
display flex
align-items center
justify-content center
</style>
我要从GraphQL查询中提取出文本,并在VARIABLES中设定以下内容,然后用Altair GraphQL Client进行尝试。
{
"input": {
}
}
回应
{
"data": {
"mockMessageSend": {
"boolean": true
}
}
}
我们将在后续启动PostGraphile版ApolloChat时确认是否在此次试行中向general频道投稿了“’你好吗?’ + 连续编号”。
通过以上的操作,已经完成了GraphQL查询语句的修改和尝试。
除了前端的GraphQL查询外,其他源代码进行改写
那么,除了查询文之外的更改方面,我们将按照以下方式创建和修改文件。
~/work/vue-apollo/tests/demo/vue.config.js发生了变化。
为了避免CORS错误,我在devServer的proxy部分设置了反向代理(同时支持WebSocket和http)。
module.exports = {
pluginOptions: {
graphqlMock: false,
apolloEngine: false,
},
+ devServer: {
+ host: '0.0.0.0',
+ disableHostCheck: true,
+ proxy: {
+ "^/postgraphile/demo/ws": {
+ target: "http://localhost:16000",
+ ws: true,
+ changeOrigin: true,
+ pathRewrite: {
+ "^/postgraphile/demo/ws": "/"
+ }
+ },
+ "^/postgraphile/demo": {
+ target: "http://localhost:16000",
+ ws: false,
+ pathRewrite: {
+ "^/postgraphile/demo": "/"
+ }
+ },
+ }
+ },
+ publicPath: '/demo',
/* Without vue-cli-plugin-apollo 0.20.0+ */
// chainWebpack: config => {
// config.module
// .rule('vue')
// .use('vue-loader')
// .loader('vue-loader')
// .tap(options => {
// options.transpileOptions = {
// transforms: {
// dangerousTaggedTemplateString: true,
// },
// }
// return options
// })
// }
}
如果您希望在Laradock的nginx中设置反向代理而不是在vue.config.js中进行设置,可以在 ” ~/work/laradock/nginx/site/default.conf ” 中添加以下设置。在启动docker-compose时,将同时启动nginx容器,然后进入workspace容器并使用yarn serve启动Web服务器。然后,通过主机浏览器访问 ” http://localhost/demo/ ” 来启动PostGraphile版ApolloChat。
location /demo/ {
proxy_pass http://workspace:8080/demo/;
}
location /postgraphile/demo/ws/ {
proxy_pass http://postgraphile_demo:16000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /postgraphile/demo/ {
proxy_pass http://postgraphile_demo:16000/;
}
~/work/vue-apollo/tests/demo/.env (New) – 工作/视图-阿波罗/测试/演示/.env
内容:配置联接PostGraphile端点。
VUE_APP_GRAPHQL_HTTP=http://localhost:8080/postgraphile/demo/graphql
VUE_APP_GRAPHQL_WS=ws://localhost:8080/postgraphile/demo/ws/graphql
引用内容将从~/work/vue-apollo/tests/demo/src/vue-apollo.js引用。
供参考,如果要在Laradock的nginx中进行反向代理设置而不是在vue.config.js中设置,可以将.env文件设置如下。
VUE_APP_GRAPHQL_HTTP=http://localhost/postgraphile/demo/graphql
VUE_APP_GRAPHQL_WS=ws://localhost/postgraphile/demo/ws/graphql
~/work/vue-apollo/tests/demo/.graphqlconfig.yml(改变)
我会告诉ApolloChat自动生成的PostGraphile GraphQL模式文件的位置。
projects:
app:
- schemaPath: apollo-server/schema.graphql
+ schemaPath: postgraphile-server/schema.graphql
includes:
- '**/*.gql'
extensions:
endpoints:
default: 'http://localhost:4000/graphql'
~/work/vue-apollo/tests/demo/src/router.js (改动)
为了在demo子目录中发布Vue.js项目,只需要添加一行代码。
import Vue from 'vue'
import Router from 'vue-router'
import UserLogin from './components/UserLogin.vue'
import WelcomeView from './components/WelcomeView.vue'
import ChannelView from './components/ChannelView.vue'
Vue.use(Router)
export default new Router({
mode: 'history',
+ base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: WelcomeView,
},
{
path: '/login',
name: 'login',
component: UserLogin,
},
{
path: '/chan/:id',
name: 'channel',
component: ChannelView,
props: true,
},
],
})
~/work/vue-apollo/tests/demo/src/vue-apollo.js (Modified)
import Vue from 'vue'
import VueApollo from '../../../'
import { createApolloClient, restartWebsockets } from 'vue-cli-plugin-apollo/graphql-client'
+ import { InMemoryCache } from 'apollo-cache-inmemory'
// Install the vue plugin
Vue.use(VueApollo)
// Name of the localStorage item
- const AUTH_TOKEN = 'apollo-token'
+ const AUTH_TOKEN = 'postgraphile-demo-token'
// Config
const defaultOptions = {
// You can use `https` for secure connection (recommended in production)
httpEndpoint: process.env.VUE_APP_GRAPHQL_HTTP || 'http://localhost:4000/graphql',
// You can use `wss` for secure connection (recommended in production)
// Use `null` to disable subscriptions
wsEndpoint: process.env.VUE_APP_GRAPHQL_WS || 'ws://localhost:4000/graphql',
// LocalStorage token
tokenName: AUTH_TOKEN,
// Enable Automatic Query persisting with Apollo Engine
persisting: false,
// Use websockets for everything (no HTTP)
// You need to pass a `wsEndpoint` for this to work
websocketsOnly: false,
// Is being rendered on the server?
ssr: false,
// Override default http link
// link: myLink,
// Override default cache
// cache: myCache,
+ cache: new InMemoryCache(),
// Additional ApolloClient options
// apollo: { ... }
getAuth: tokenName => {
// get the authentication token from local storage if it exists
const token = localStorage.getItem(tokenName)
// return the headers to the context so httpLink can read them
- return token || ''
+ return token ? ('Bearer ' + token) : ''
},
}
// Call this in the Vue app file
export function createProvider (options = {}, { router }) {
// Create apollo client
const { apolloClient, wsClient } = createApolloClient({
...defaultOptions,
...options,
})
apolloClient.wsClient = wsClient
// Create vue apollo provider
const apolloProvider = new VueApollo({
defaultClient: apolloClient,
defaultOptions: {
$query: {
fetchPolicy: 'cache-and-network',
},
},
errorHandler (error) {
if (isUnauthorizedError(error)) {
// Redirect to login page
if (router.currentRoute.name !== 'login') {
router.replace({
name: 'login',
params: {
wantedRoute: router.currentRoute.fullPath,
},
})
}
} else {
console.log('%cError', 'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;', error.message)
}
},
})
return apolloProvider
}
// Manually call this when user log in
export async function onLogin (apolloClient, token) {
- localStorage.setItem(AUTH_TOKEN, JSON.stringify(token))
+ localStorage.setItem(AUTH_TOKEN, token)
if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient)
try {
await apolloClient.resetStore()
} catch (e) {
if (!isUnauthorizedError(e)) {
console.log('%cError on cache reset (login)', 'color: orange;', e.message)
}
}
}
// Manually call this when user log out
export async function onLogout (apolloClient) {
localStorage.removeItem(AUTH_TOKEN)
if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient)
try {
await apolloClient.resetStore()
} catch (e) {
if (!isUnauthorizedError(e)) {
console.log('%cError on cache reset (logout)', 'color: orange;', e.message)
}
}
}
function isUnauthorizedError (error) {
- const { graphQLErrors } = error
- return (graphQLErrors && graphQLErrors.some(e => e.message === 'Unauthorized'))
+ if (error) {
+ if (error.message.indexOf('Unauthorized') >= 0 || error.message.indexOf('permission denied') >= 0 || error.message.indexOf('status code 401') >= 0) {
+ return true
+ }
+ }
+ return false
}
~/work/vue-apollo/tests/demo/src/components/UserLogin.vue (修改)
<script>
import UserCurrent from '../mixins/UserCurrent'
import USER_CURRENT from '../graphql/userCurrent.gql'
import { onLogin } from '../vue-apollo'
export default {
name: 'UserLogin',
mixins: [
UserCurrent,
],
data () {
return {
showRegister: false,
email: '',
password: '',
nickname: '',
}
},
watch: {
// If already logged in redirect to other page
userCurrent (value) {
if (value) {
this.redirect()
}
},
},
methods: {
async onDone (result) {
if (this.showRegister) {
this.showRegister = false
} else {
if (!result.data.userLogin) return
const apolloClient = this.$apollo.provider.defaultClient
// Update token and reset cache
- const { id, userId, expiration } = result.data.userLogin.token
- await onLogin(apolloClient, { id, userId, expiration })
+ await onLogin(apolloClient, result.data.userLogin.usrAndToken.token)
// Update cache
apolloClient.writeQuery({
query: USER_CURRENT,
data: {
- userCurrent: result.data.userLogin.user,
+ userCurrent: result.data.userLogin.usrAndToken.usr,
},
})
}
},
redirect () {
this.$router.replace(this.$route.params.wantedRoute || { name: 'home' })
},
},
}
</script>
<template>
<div class="user-login">
<div class="logo">
<i class="material-icons icon">chat</i>
</div>
<div class="app-name">
Apollo<b>Chat</b>
</div>
<ApolloMutation
:mutation="showRegister
? require('../graphql/userRegister.gql')
: require('../graphql/userLogin.gql')"
:variables="showRegister
? {
input: {
email,
password,
nickname,
},
}
: {
- email,
- password,
+ input: {
+ email,
+ password,
+ },
}"
class="wrapper"
@done="onDone"
>
<form
slot-scope="{ mutate, loading, gqlError: error }"
:key="showRegister"
class="form"
@submit.prevent="mutate()"
>
<input
v-model="email"
class="form-input"
type="email"
name="email"
placeholder="Email"
required
>
<input
v-model="password"
class="form-input"
type="password"
name="password"
placeholder="Password"
required
>
<input
v-if="showRegister"
v-model="nickname"
class="form-input"
name="nickname"
placeholder="Nickname"
required
>
<div v-if="error" class="error">{{ error.message }}</div>
<template v-if="!showRegister">
<button
type="submit"
:disabled="loading"
class="button"
data-id="login"
>Login</button>
<div class="actions">
<a
data-id="create-account"
@click="showRegister = true"
>Create an account</a>
</div>
</template>
<template v-else>
<button
type="submit"
:disabled="loading"
class="button"
data-id="submit-new-account"
>Create new account</button>
<div class="actions">
<a @click="showRegister = false">Go back</a>
</div>
</template>
</form>
</ApolloMutation>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.user-login
height 100%
display flex
flex-direction column
align-items center
justify-content center
.logo
.icon
font-size 80px
color $color
.app-name
font-size 42px
font-weight lighter
margin-bottom 32px
.wrapper
flex auto 0 0
.form
width 100vw
max-width 300px
.form-input,
.button
display block
width 100%
box-sizing border-box
.form-input
margin-bottom 12px
.actions
margin-top 12px
text-align center
font-size 12px
</style>
~/work/vue-apollo/tests/demo/src/components/UserCurrent.vue(被更改)
<script>
import UserCurrent from '../mixins/UserCurrent'
import { onLogout } from '../vue-apollo'
import USER_LOGOUT from '../graphql/userLogout.gql'
export default {
name: 'MessageForm',
mixins: [
UserCurrent,
],
methods: {
async logout () {
+ const apolloClient = this.$apollo.provider.defaultClient
+ onLogout(apolloClient)
await this.$apollo.mutate({
mutation: USER_LOGOUT,
+ variables: {
+ input: {
+ },
+ },
})
- const apolloClient = this.$apollo.provider.defaultClient
- onLogout(apolloClient)
},
},
}
</script>
<template>
<div class="user-current">
<template v-if="userCurrent">
<i class="material-icons user-icon">account_circle</i>
<div class="info">
<div class="nickname">{{ userCurrent.nickname }}</div>
<div class="email">{{ userCurrent.email }}</div>
</div>
<button
class="icon-button"
data-id="logout"
@click="logout()"
>
<i class="material-icons">power_settings_new</i>
</button>
</template>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.user-current
color white
display grid
grid-template-columns auto 1fr auto
grid-template-rows auto
grid-gap 12px
align-items center
margin-bottom 20px
padding 12px 0 12px 12px
.email
font-size 12px
.icon-button
&:not(:hover)
background none
</style>
~/work/vue-apollo/tests/demo/src/components/MessageForm.vue (改变)
<script>
- import MESSAGE_FRAGMENT from '../graphql/messageFragment.gql'
- import USER_FRAGMENT from '../graphql/userFragment.gql'
-
export default {
props: {
channelId: {
type: String,
required: true,
},
},
data () {
return {
newMessage: '',
}
},
methods: {
onDone () {
this.newMessage = ''
this.$refs.input.focus()
},
},
-
- fragments: {
- message: MESSAGE_FRAGMENT,
- user: USER_FRAGMENT,
- },
}
</script>
<template>
<ApolloMutation
- :mutation="gql => gql`
- mutation messageAdd ($input: MessageAdd!) {
- messageAdd (input: $input) {
- ...message
- }
- }
- ${$options.fragments.message}
- ${$options.fragments.user}
- `"
+ :mutation="require('../graphql/messageAdd.gql')"
:variables="{
input: {
channelId,
content: newMessage,
},
}"
class="message-form"
@done="onDone"
>
<input
slot-scope="{ mutate, loading, error }"
ref="input"
v-model="newMessage"
:disabled="loading"
class="form-input"
placeholder="Type a message"
@keyup.enter="newMessage && mutate()"
>
</ApolloMutation>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.message-form
padding 12px
width 100%
box-sizing border-box
.form-input
display block
box-sizing border-box
width 100%
</style>
~/work/vue-apollo/tests/demo/src/components/MessageItem.vue(变更)
<script>
import marked from 'marked'
// Open links in new tab
const renderer = new marked.Renderer()
const linkRenderer = renderer.link
renderer.link = (href, title, text) => {
const html = linkRenderer.call(renderer, href, title, text)
return html.replace(/^<a /, '<a target="_blank" rel="nofollow" ')
}
export default {
props: {
message: {
type: Object,
required: true,
},
},
computed: {
html () {
return marked(this.message.content, { renderer })
},
},
}
</script>
<template>
<div class="message-item">
- <div class="user">{{ message.user.nickname }}</div>
+ <div class="user">{{ message.userByUserId.nickname }}</div>
<div class="content" v-html="html"/>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.message-item
padding 12px 12px
&:hover
background #f8f8f8
.user
color #777
font-size 13px
margin-bottom 2px
.content
word-wrap break-word
>>>
p
margin 0
img
max-width 500px
max-height 500px
</style>
~/work/vue-apollo/tests/demo/src/components/ChannelList.vue(改变)
<script>
import UserCurrent from './UserCurrent.vue'
import MockSendMessage from './MockSendMessage.vue'
- import gql from 'graphql-tag'
export default {
name: 'ChannelList',
components: {
UserCurrent,
MockSendMessage,
},
-
- fragments: {
- channel: gql`
- fragment channel on Channel {
- id
- name
- }
- `,
- },
}
</script>
<template>
<div class="channel-list">
<UserCurrent />
- <ApolloQuery :query="gql => gql`
- query channels {
- channels {
- ...channel
- }
- }
- ${$options.fragments.channel}
- `">
+ <ApolloQuery :query="require('../graphql/channels.gql')">
<template slot-scope="{ result: { data, loading } }">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="data" class="channels">
<router-link
- v-for="channel of data.channels"
+ v-for="channel of data.allChannels.nodes"
:key="channel.id"
:to="{ name: 'channel', params: { id: channel.id } }"
class="channel"
>
<div class="id">#{{ channel.id }}</div>
<div class="name">{{ channel.name }}</div>
</router-link>
</div>
</template>
</ApolloQuery>
<MockSendMessage/>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.channel-list
background desaturate(darken($color, 60%), 95%)
color white
padding 12px
.channel
display block
padding 12px
border-radius 4px
&:hover
background rgba($color, .3)
&.router-link-active
background $color
color white
font-weight bold
.id
font-family monospace
margin-bottom 4px
font-size 14px
.name
font-size 12px
opacity .9
</style>
~/work/vue-apollo/tests/demo/src/components/ChannelView.vue (变更)
<script>
import MessageItem from './MessageItem.vue'
import MessageForm from './MessageForm.vue'
export default {
name: 'ChannelView',
components: {
MessageItem,
MessageForm,
},
props: {
id: {
type: String,
required: true,
},
},
watch: {
id: {
handler () {
this.$_init = false
},
immediate: true,
},
},
methods: {
onMessageChanged (previousResult, { subscriptionData }) {
- const { type, message } = subscriptionData.data.messageChanged
-
- // No list change
- if (type === 'updated') return previousResult
-
- const messages = previousResult.channel.messages.slice()
- // Add or remove item
- if (type === 'added') {
- messages.push(message)
- } else if (type === 'removed') {
- const index = messages.findIndex(m => m.id === message.id)
- if (index !== -1) messages.splice(index, 1)
- }
-
- // New query result
- return {
- channel: {
- ...previousResult.channel,
- messages,
- },
- }
+ const { message, oldrec, type } = subscriptionData.data.messageChanged
+
+ const nodes = previousResult.channelById.messagesByChannelId.nodes.slice()
+ if (type === 'INSERT') {
+ nodes.push(message)
+ } else {
+ const index = nodes.findIndex(m => m.id === oldrec.id)
+ if (index !== -1) {
+ if (type === 'DELETE') {
+ nodes.splice(index, 1)
+ } else if (type === 'UPDATE') {
+ nodes.splice(index, 1, message)
+ }
+ }
+ }
+
+ // New query result
+ return {
+ channelById: {
+ ...previousResult.channelById,
+ messagesByChannelId: {
+ ...previousResult.channelById.messagesByChannelId,
+ nodes,
+ },
+ },
+ }
},
async scrollToBottom (force = false) {
let el = this.$refs.body
// No body element yet
if (!el) {
setTimeout(() => this.scrollToBottom(force), 100)
return
}
// User is scrolling up => no auto scroll
if (!force && el.scrollTop + el.clientHeight < el.scrollHeight - 100) return
// Scroll to bottom
await this.$nextTick()
el.scrollTop = el.scrollHeight
},
onResult (result) {
// The first time we load a channel, we force scroll to bottom
this.scrollToBottom(!this.$_init)
this.$_init = true
},
},
}
</script>
<template>
<div class="channel-view">
<ApolloQuery
:query="require('../graphql/channel.gql')"
:variables="{
id
}"
@result="onResult"
>
<template slot-scope="{ result: { data, loading } }">
<div v-if="!data && loading" class="loading">Loading...</div>
<div v-else-if="data">
<!-- Websockets -->
<ApolloSubscribeToMore
:document="require('../graphql/messageChanged.gql')"
:variables="{
channelId: id,
}"
:updateQuery="onMessageChanged"
/>
<div class="wrapper">
<div class="header">
- <div class="id">#{{ data.channel.id }}</div>
- <div class="name">{{ data.channel.name }}</div>
+ <div class="id">#{{ data.channelById.id }}</div>
+ <div class="name">{{ data.channelById.name }}</div>
</div>
<div ref="body" class="body">
<MessageItem
- v-for="message in data.channel.messages"
+ v-for="message in data.channelById.messagesByChannelId.nodes"
:key="message.id"
:message="message"
/>
</div>
<div class="footer">
<MessageForm :channel-id="id" />
</div>
</div>
</div>
</template>
</ApolloQuery>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.wrapper
height 100vh
display grid
grid-template-columns 1fr
grid-template-rows auto 1fr auto
.header
padding 12px
border-bottom $border
.id
font-family monospace
margin-bottom 4px
.name
color #555
.body
overflow-x hidden
overflow-y auto
.footer
border-top $border
</style>
以上是源代码修改完成。
运行PostGraphile版ApolloChat
那么我将试着运行它。
以下是执行操作,将启动Web服务器(Vue.js应用程序传输)。
由于PostGraphile已在Docker容器中启动,因此不需要单独启动GraphQL服务器。
cd ~/work/vue-apollo/tests/demo
yarn serve
确认能成功编译。
编译后显示如下。
DONE Compiled successfully in 3593ms 10:31:39
App running at:
- Local: http://localhost:8080/demo/
- Network: unavailable
Note that the development build is not optimized.
To create a production build, run yarn build.
按照指示,在浏览器中访问「http://localhost:8080/demo/」,将启动PostGraphile版ApolloChat。
首先会出现登录界面。
我尝试使用Altair GraphQL Client创建的帐户登录。
Email:foo@foo.com
Password:p@ss
我要试着进入一般频道。
当试图执行GraphQL查询时,留下了一条消息。
-
- messageChangedサブスクリプションを試行した時に、psqlからINSERTした「美味しい!」メッセージ
- mockMessageSendミューテーションをAltair GraphQL Clientで試行した時に投稿された「”How are you doing? ” + 連番」
那么,我们来点击左下角的“发送机器人消息”按钮试试看。
「你好吗?+ 连番」增加了一条。
我将从右下角的消息输入栏发布一个”你好世界”。
有人发布了「你好,世界」。
现在我们来试试订阅。
从控制台进入Laradock的工作区,然后进入psql。
cd ~/work/laradock
docker-compose exec --user=laradock workspace bash
psql -h postgres -U default demodb
我将执行以下的SQL语句,并尝试显示消息列表。
SELECT * FROM apollo_demo.messages
ORDER BY id;
显示如下。
id | channel_id | user_id | content | date_added | date_updated
----+------------+---------+----------------------+----------------------------+--------------
1 | general | 0 | Welcome to the chat! | 2019-06-18 07:10:27.671412 |
4 | general | 1 | 美味しい! | 2019-06-18 08:13:57.466309 |
5 | general | 0 | How are you doing? 1 | 2019-06-18 08:26:07.808588 |
6 | general | 0 | How are you doing? 2 | 2019-06-19 01:44:42.855632 |
7 | general | 1 | こんにちは世界 | 2019-06-19 02:33:33.951225 |
我将更新id为5的行,并尝试更新ApolloChat的数据。我会执行以下SQL语句。
UPDATE apollo_demo.messages SET
content = 'Thank you.'
WHERE id = 5;
ApolloChat的界面也进行了更新。
我們將嘗試刪除id=5的行。請執行以下SQL語句。
DELETE FROM apollo_demo.messages
WHERE id = 5;
ApolloChat的屏幕上也已经被删除了。
我們接下來要插入一個新的行,並執行以下的SQL語句。
INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
('general', 1, 'Hello, World!');
这个功能还被添加到了ApolloChat的界面上。