Day04_谷粒商城(谷粒商城高级篇三)摘要


文章目录

  • 笔记链接:
  • P211 认证服务环境的搭建
  • P212 注册页面—验证码倒计时
  • P213 注册页面—整合短信服务1
  • P214 注册页面—整合短信服务2
  • P215 注册页面—注册功能1
  • P216 注册页面—注册功能2
  • P217 注册页面—注册功能3
  • P218 注册页面—注册功能4
  • P219 登陆页面—用户名密码登录
  • P220 登陆页面—完成微博登录1
  • P221 登陆页面—完成微博登录2
  • P222 登陆页面—完成微博登录3
  • P223 登陆页面—完成微博登录4
  • P224 登陆页面—完成微博登录5
  • P225 SpringSession—session不共享、不跨域问题
  • P226 SpringSession—session问题的解决
  • P227 SpringSession—SpringSession整合redis1
  • P228 SpringSession—SpringSession整合redis2
  • P229 SpringSession—SpringSession整合redis3
  • P230 页面调整
  • P231 单点登录1
  • P232 单点登录2
  • P233 单点登录3
  • P234 单点登录4
  • P235 单点登录5
          • 1.为什么要单点登录?
          • 2.单点登录的原理?
          • 3.单点登录代码的实现
  • P236 购物车—环境的搭建
  • P237 购物车—数据模型的分析1
  • P238 购物车—数据模型的分析2
  • P239 购物车—拦截器鉴别用户
  • P240 页面调整
  • P241 添加商品到购物车1
  • P242 添加商品到购物车2
  • P243 添加商品到购物车3
  • P244 获取购物车
  • P245 选中购物车项
  • P246 修改购物项数量
  • P247 删除购物项

笔记链接: 笔记链接:谷粒商城-个人笔记(高级篇三)
P211 认证服务环境的搭建
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P212 注册页面—验证码倒计时
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P213 注册页面—整合短信服务1 总说:
p213和p214的这些操作是完成用户在注册页面的发送验证码的操做:前台发送/sms/sendcode的请求给后台的gulimall-auth-server,然后gulimall-auth-server会先验证一下验证码是否在60秒前发送过,如果没有就使用OpenFeign远程调用gulimall-thrid-party的sendCode方法完成第三方服务的发送验证码功能
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P214 注册页面—整合短信服务2
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P215 注册页面—注册功能1 总说:
【Day04_谷粒商城(谷粒商城高级篇三)摘要】p215和p216和p217和p218的这些操作是完成用户在注册页面的注册功能:我们之前把验证码发给用户了,用户会填好验证码和注册信息后发送给gulimall-auth-server,然后gulimall-auth-server会进行初步校验(校验密码格式、手机号格式 + 验证码校验),校验通过会利用OpenFeign远程调用gulimall-member的regist()方法来进行会员注册,会员注册肯定有失败有成功,对于那些注册失败我们使用异常机制
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

请认真看这一节的注释,看不懂就回去看视频。这一节还是讲了不少有用的东西
P216 注册页面—注册功能2
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P217 注册页面—注册功能3 给用户密码加密的三种方式对比:MD5加密、盐值加密、BCrypt加密
P218 注册页面—注册功能4 本届内容就是完成测试
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P219 登陆页面—用户名密码登录 总说:
前台发送 /login到后台的gulimall-auth-server模块中,然后gulimall-auth-server会使用OpenFeign远程调用gulimall-member的login()方法,在该方法中进行用户名密码比对完成登录
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P220 登陆页面—完成微博登录1
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P221 登陆页面—完成微博登录2 P221讲了去微博开放平台进行社交登录申请与测试
有两个地址很重要,
(1)是“ 在登录页引导用户至授权页”的地址:
这一步是前台完成的,前台html中的url要写成
Get https://api.weibo.com/oauth2/authorize?client_id=1917008757&response_type=code&redirect_uri=http://gulimall.com/oauth2.0/weibo/success

client_id:是你创建网站应用时的app key,
redirect_uri是用户使用微博登录后重定向到哪里去。
我们指定redirect_uri=http://gulimall.com/oauth2.0/weibo/success也就是说用户用户使用微博登录后,相当于发送 /oauth2.0/weibo/success到后台的gulimall-auth-server模块中,那么gulimall-auth-server会使用code换取token,这就涉及到换取token的url:
(2)是换取token的url
这一步是后台完成的,后台发送这样的url才能获取到token
POST https://api.weibo.com/oauth2/access_token?client_id=1917008757&client_secret=94d9cc62c60d5f9f3d0c62389593024f&grant_type=authorization_code&redirect_uri=http://auth.gulimall.com/oauth2.0/weibo/success&code=CODE

client_id: 创建网站应用时的app key;
client_secret: 创建网站应用时的app secret
redirect_uri: 认证完成后的跳转链接(需要和平台高级设置一致);
code:换取令牌的认证码
后台发送这么个请求就可以根据用户授权返回的code换取token(换回来的不仅仅是token,还有uid用户id、expires_in令牌的过期时间等等),拿到token就可以向微博官方发送别的请求换取用户信息
P222 登陆页面—完成微博登录3
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P223 登陆页面—完成微博登录4 P224 登陆页面—完成微博登录5 p222—p224就是编码与测试
总说:
①前台发送 /oauth2.0/weibo/success到后台的gulimall-auth-server模块中,然后gulimall-auth-server会先使用code换取token,然后拿着token到OpenFeign远程调用gulimall-memberoauth2Login()方法,在该方法中会先判断用户是否是第一次用微博登录,如果是第一次的话我们就得给该用户注册(到微博里面查询该用户的基本信息存储到咱们的数据库里面);如果该用户之前已经用微博登陆过,那就到数据库中更新一下token
②前台用户用微博登录后我们会拿到用户的code,后台用code到微博里面换取token这样才能用token访问到用户基本信息;用户每登陆一次访问微博的token就会变一次,所以当用户下次用微博登陆时我们需要到数据库更新一下token
P225 SpringSession—session不共享、不跨域问题
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

(1)session不能跨域问题
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

(2) 分布式下session共享问题
多台服务器都有会员服务,你在A服务器上把用户信息保存到内存上了,下次如果落在B服务器上,即使浏览器带着cookie来了,由于B服务器内存肯定没有存储用户信息,这也是问题。
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P226 SpringSession—session问题的解决
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

  1. session复制
    用户登录后,A服务器得到session后,把session也复制到别的机器上,显然这种处理很不好
  2. 客户端存储
    把session存储到浏览器上,肯定相当不安全
  3. hash一致性
    根据用户,到指定的机器上登录。但是远程调用还是不好解决
  4. redis统一存储
    最终的选择方案,把session放到redis中,这样每个微服务都可以获取到session
P227 SpringSession—SpringSession整合redis1 P228 SpringSession—SpringSession整合redis2 P229 SpringSession—SpringSession整合redis3 总说:
浏览器会在auth.gulimall.com里面登录成功,auth.gulimall.com会将登陆成功的用户的从数据库查到的用户相关信息存到session里面,而且存session时不是存到自己的内存里面而是存到redis里面,然后auth.gulimall.com给浏览器发cookie,而且发的cookie的作用域不能仅仅是auth.gulimall.com而是要放大服务到.gulimall.com,此时浏览器访问其它任何服务都会带上这个cookie。
如果你把redis里面的session情况,那就是把登陆过的用户信息清空,虽然前台的浏览器访问后台时携带了cookie信息,但是到redis里面查不到用户信息,所以你就得重新登陆。而且我们设置了redis里面的session默认30分钟过期,也就是30分钟后redis里面的用户信息就没有了
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P230 页面调整
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P231 单点登录1 P232 单点登录2 P233 单点登录3 P234 单点登录4 P235 单点登录5 1.为什么要单点登录?
springsession只能把auth.gulimall.com作用域放大到gulimall.com,解决了同域名的共享session问题,但要是访问同样是尚硅谷的atguigu.com怎么办呢?这种不同的域名也想共享session该怎么做呢?
你在新浪微博里面注册登录了,同时就要保证在新浪体育、新浪新闻里面全都可以拿到session数据
2.单点登录的原理? 两个域名不一样的服务端client1和client2,还有一个负责登录的ssoserver,还有一个浏览器,它们四个之间的故事
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

先说明一下这个路径的含义:http://ssoserver.com:8080/login.html?redirect_url=http:I/client1.com:8081/employees的含义就是让你访问http://ssoserver.com:8080/login.html登陆页面,而 redirect_url=http:I/client1.com:8081/employees的含义是当你完成登陆后会重定向到http:I/client1.com:8081/employees的位置
第1-11步的解析:只有登陆了才能查看员工信息。一开始浏览器访问client1.com的员工信息http:I/client1.com:8081/employees,client1会根据这个url有没有token参数判断是否登录,由于没有token参数也就是没有登陆,服务端会命令浏览器重定向到ssoserver.com的登陆页面http:I/ssoserver.com:8080/login.html?redirect_url=http:I/client1.com:8081/employees,ssoserver.com会判断是否登陆过,没有登陆过就展示这个登陆页面,用户会输入账号密码进行登录,提交登陆请求http:/ssoserver.com:8080/doLogin?usermame,password,redirect_url给ssoserver.com,那么ssoserver.com会保存用户状态到redis,同时ssoserver.com会命令重定向到http: /lclient1.com:8081/employees?token=dadadadsdeuieu(浏览器访问路径),同时ssoserver.com会命令浏览器保存sso_token=dadadadsdeuieu这样式的cookie。浏览器这次就可以访问员工信息了,他的访问路径是刚刚提到的http://lclient1.com:8081/employees?token=dadadadsdeuieu比一开始访问员工信息的http:I/client1.com:8081/employees多了token=dadadadsdeuieu,这就回到第2步了,client1会根据有没有token参数判断是否登录,这次client1会觉得它登陆过了就可以访问员工信息了。
第12-19步解析:这次浏览器要访问客户端2的boss信息http:I/client2.com:8081/boss,client2会根据有没有token参数判断是否登录,由于没有token参数也就是没有登陆,服务端会命令浏览器重定向到ssoserver.com的登陆页面http:I/ssoserver.com:8080/login.html?redirect_url=http:I/client2.com:8081/boss,ssoserver.com会判断是否登陆过,由于浏览器有sso_token=dadadadsdeuieu这样式的cookie,而且从redis能查到,说明它之前在client1或者client2登陆过,ssoserver.com会命令重定向到http:/lclient2.com:8082/boss?token=dadadadsdeuieu,所以浏览器就会访问http://lclient2.com:8082/boss?token=dadadadsdeuieu,这就回到了第2步,client2会根据有没有token参数判断是否登录,登陆过就响应页面。
所以说,以后浏览器无论访问client1还是client2,由于浏览器中保存了cookie,所以ssoserver.com就会判定它登陆过,所以以后都不用登陆。
3.单点登录代码的实现 client1的代码:
@GetMapping(value = "https://www.it610.com/employees") public String employees(Model model, HttpSession session, @RequestParam(value = "https://www.it610.com/article/redisKey", required = false) String redisKey) {if (!StringUtils.isEmpty(redisKey)) {//redisKey非空(也就是token非空),说明去过server端登录过了// 拿着token去服务器,在服务端从redis中查出来用户的username RestTemplate restTemplate=new RestTemplate(); ResponseEntity forEntity = restTemplate.getForEntity("http://ssoserver.com:8080/userInfo?redisKey="+ redisKey, Object.class); Object loginUser = forEntity.getBody(); session.setAttribute("loginUser", loginUser); //设置到自己的session中 } //尝试从自己的session中获取"loginUser" Object loginUser = session.getAttribute("loginUser"); if (loginUser == null) {//又没有token,session里又没有"loginUser",让它去登录页登录 return "redirect:" + "http://ssoserver.com:8080/login.html" + "?url=http://clientA.com/employees"; } else {//自己的session里有"loginUser",即使没有token也说明登录过 List emps = new ArrayList<>(); emps.add("张三"); emps.add("李四"); model.addAttribute("emps", emps); return "employees"; //转到前端页面,前端会把数据拿出来展示 } }
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

client2的代码:
代码一模一样,就是改一下访问路径@GetMapping(value = "https://www.it610.com/boss")
ssoserver的代码:
用户名:
密码:

@Controller public class LoginController { @Autowired private StringRedisTemplate stringRedisTemplate; @ResponseBody @GetMapping("/userInfo") //client1或client2会调用这个方法得到redis中的存储过的user信息 public Object userInfo(@RequestParam("redisKey") String redisKey){// 拿着其他域名转发过来的token去redis里查 Object loginUser = stringRedisTemplate.opsForValue().get(redisKey); return loginUser; } @GetMapping("/login.html") // 子系统都来这 public String loginPage(@RequestParam("url") String url, Model model, @CookieValue(value = "https://www.it610.com/article/redisKey", required = false) String redisKey) {//这是从浏览器中拿到的cookie,非空代表就登录过了 if (!StringUtils.isEmpty(redisKey)) {//非空代表就登录过了 return "redirect:" + url + "?redisKey=" + redisKey; }model.addAttribute("url", url); //没登录过才去登录页 return "login"; } @PostMapping("/doLogin") //在前端输入用户名和密码后就会来到这里,进行server端统一认证 public String doLogin(@RequestParam("username") String username, @RequestParam("password") String password, HttpServletResponse response, @RequestParam(value="https://www.it610.com/article/url",required = false) String url){//确认用户后,生成cookie,浏览器中存储,redis中也存储 if(!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){ //非空就简单认为登录正确String redisKey = UUID.randomUUID().toString().replace("-", ""); //用uuid代替tokenCookie cookie = new Cookie("redisKey", redisKey); response.addCookie(cookie); //浏览器中存储cookiestringRedisTemplate.opsForValue().set(redisKey, username+password+"...", 30, TimeUnit.MINUTES); //redis中存储return "redirect:" + url + "?redisKey=" + redisKey; //重定向时候带着token }// 登录失败,再次登录 return "login"; }}

演示
代码用的网友的,截屏用到老师的,网友喜欢自己起名字,把token改为redisKey什么的,不要计较细节上的不同
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P236 购物车—环境的搭建
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P237 购物车—数据模型的分析1 P238 购物车—数据模型的分析2 总说:
本节内容就是说明了用户购物车里的信息应该使用哪个数据库存储(MySQL还是Redis?),以及使用了Redis后是用List存储这些信息呢还是使用Hash存储这些信息?以及购物车VO、购物项VO的编写
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

在(3)VO编写中为什么不用@Data而是自己写getter和setter方法?因为在购物项VO中计算“总价=单价x数量”这是需要手动计算的,使用@Data只会覆盖了自定义的setter方法;而且在购物车VO中应该是获取总价、获取商品数量,所以有的属性不应该有setter方法。
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P239 购物车—拦截器鉴别用户 总说:
在购物车的所有Controller执行之前,我们先执行一个拦截器。在拦截器里判断用户是否登录,从session中获取不到用户信息就说明他没有登录,没有登录的话就从浏览器中获取一下user-key,如果浏览器中没有user-key那就说明用户是第一次没有登录的状态下进入京东,我们就得创建一个cookie名字叫做user-key,而且设置cookie的作用域、过期时间,假如明天他来了,我们能从浏览器中获取到该用户的user-key。
一个用户进来我们执行的 “ 拦截器—Controller—Service—Dao ” 这一套流程让同一个线程执行,这就使用了ThreadLocal技术,ThreadLocal是同一个线程共享数据,这个线程里面的数据会共享,使用过程就是:
ThreadLocal threadLocal = new ThreadLocal<>(); //创建一个threadLocal threadLocal.set(userInfoTo); //把要共享的数据设置进去 .... UserInfoTo userInfoTo = threadLocal.get(); //后期就可以获取到这个共享的数据

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P240 页面调整 拦截器写了,Controller还有service什么的都没有写,在此之前,我们先打通整个页面(首页可以进入商品页,从商品页添加商品到购物车,然后点击购物车就可以进入购物车页),本节内容是前台代码。
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P241 添加商品到购物车1 P242 添加商品到购物车2 P243 添加商品到购物车3 总说:
因为是拦截器先执行的,所以先得到拦截器ThreadLocal的返回结果UserInfoTo userInfoTo = threadLocal.get(),如果userInfoTo.getUserId()不为空表示账号用户,反之为临时用户 ,然后决定用临时购物车还是用户购物车。将用户购物车信息存到redis中,redis中肯定需要键值对,账号用户的购物车的redis中的key是gulimall:cart:1(1是用户id,表示1号用户的购物车);临时用户的redis中的key是gulimall:cart:uuid其中uuid就是我们拦截器里存下的user-key。redisTemplate.boundHashOps(cartKey)是说以后所有对redis的增删改查都是针对redia中key为cartKey的增删改查。上面的一套逻辑被封装到getCartOps()方法里面了。
添加新商品到购物车,第一步先看redis里面能不能查到skuid,查不到说明购物车里面之前没有添加过此商品,那就需要远程查询此商品的一系列信息;能查到说明购物车有此商品,将数据取出修改数量即可。
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

//原文中的博客少了对getCartItem(Long skuId)方法的解释,你也许会问哪里少了getCartItem()?这还用问吗,你一个Ctrl+F搜一下不久知道了 @Override public CartItemVo getCartItem(Long skuId) {//拿到要操作的购物车信息 BoundHashOperations cartOps = getCartOps(); String redisValue = https://www.it610.com/article/(String) cartOps.get(skuId.toString()); CartItemVo cartItemVo = JSON.parseObject(redisValue, CartItemVo.class); return cartItemVo; }

测试
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P244 获取购物车

总说:
若用户未登录,则直接使用user-key获取购物车数据;否则使用userId获取购物车数据,并将user-key对应临时购物车数据与用户购物车数据合并,并删除临时购物车
csdn上的笔记只是为了清楚地记录了老师这节课操作了什么,而具体代码我的建议就是尽量看idea上的代码,别看csdn上的代码,
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

//原文中的博客少了对getCartItems(String cartKey) 方法的解释* 获取购物车里面的数据 private List getCartItems(String cartKey) {//获取购物车里面的所有商品 BoundHashOperations operations = redisTemplate.boundHashOps(cartKey); List values = operations.values(); if (values != null && values.size() > 0) {List cartItemVoStream = values.stream().map((obj) -> {String str = (String) obj; CartItemVo cartItem = JSON.parseObject(str, CartItemVo.class); return cartItem; }).collect(Collectors.toList()); return cartItemVoStream; } return null; }
//原文中的博客少了对clearCart()方法的解释 @Override public void clearCart(String cartKey) {redisTemplate.delete(cartKey); }

测试
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P245 选中购物车项
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P246 修改购物项数量
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

P247 删除购物项
Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

Day04_谷粒商城(谷粒商城高级篇三)摘要
文章图片

    推荐阅读