一、HTTP協議特色html
首先這裏要知道HTTP協議的特色:短連接、無狀態!前端
在不考慮本地緩存的狀況舉例來講:我們在鏈接博客園的時候,當tcp鏈接後,我會把我本身的http頭髮給博客園服務器,服務器端就會看到我請求的URL,server端就會根據URL分發到相應的視圖處理(Django的views裏)。最後給我返回這個頁面,當返回以後鏈接就斷開了。python
短鏈接:git
服務器爲何要斷開?不少狀況下我還會打開頁面,我請求一次鏈接斷開了爲何會這樣?爲何不創建長期的鏈接?這個是HTTP設計的考慮,在大併發的狀況下,若是鏈接不斷開,我不知道你何時點,你可能馬上就點有可能10分鐘1個小時或者其餘時間點,那麼你就會佔着這個鏈接(是很浪費的,而且鏈接是有限的),因此當返回後server端就會斷開這個鏈接。github
無狀態:web
服務器不保存客戶端的任何狀態,每一次客戶端鏈接服務器的時候都要把相關的信息發給客戶端告訴客戶端你是誰,服務端不會保存你是誰?ajax
那麼問題來了,爲何咱們在登陸京東以後登陸一次以後,服務器就不會讓我們在登陸了,根據我們以前的博客的Session和Cookie。服務器端會在用戶登陸的時候,在服務器端生成一個sessionID(有有效期)而且返回給客戶。客戶端會把這個seesionID存到Cookie裏。這樣登陸以後就不須要再輸入密碼!sql
二、WEBqq通訊實現django
首先看下面的圖json
根據WEBQQ的工做來看下,首先C1要發送一條數據給C2首先得經過WEB Server進行中轉,首先我們這知道了,正常狀況下當C1發送給WEB Server以後,WEB Server就直接返回了,WEB Server就斷開了C1的鏈接了,那麼WEB Server會主動給C2發送信息嗎?
WEB 服務器默認是被動接收請求的,若是你沒打開瀏覽器,博客園能夠給你發信息嗎?即使你打開了瀏覽器,你獲取到數據以後就斷開了,你看到的是本地緩存的數據。 你和服務器之間就沒有聯繫了。若是服務器想把數據發送給C2那的等C2鏈接過來,服務器一看有一條C2的數據而後發給C2.那麼問題又來了?他知道C2何時鏈接過來嗎?服務端不知道C2何時鏈接過來服務端又想能時時把數據發送給C2怎麼作呢?《輪詢》
輪詢方式:
短輪詢:
C2客戶端有個循環,去Server端取數據。不斷的循環去取(會對Server端形成壓力)
C2客戶端有個時間段的循環,每隔1分鐘去取一次,可是不是時時的,這樣也很差。
長輪詢:
上面的方式也是不可取的那怎麼作呢:有沒有這麼一種方法:當C2請求過來接收的時候,Server端沒有C2的數據,Server端沒有辦法主動讓C2等着那怎麼辦呢?把C2的請求掛起,當有數據的時候在把數據馬上返回,而且多久仍是沒有數據就把這個連接返回!
這樣全部的連接就變成有意義的請求。我不給他斷開他就不會發新的請求!
本質上仍是輪詢,可是他發請求的頻率就很是低了!
可是有個問題:他本質上仍是一個短連接(這裏慢慢想下其實不難理解),若是消息頻繁的話,他仍是不斷的從新創建連接。這樣也會對服務器形成影響!每收一條消息都得往返兩次。他其實也是不夠高效的。
真正的WEBQQ就是用的這個原理來實現的!(由於WEB Socket只有部分瀏覽器支持(H5標準)IE不支持,在中國的這個環境下IE使用率仍是較高的因此不能普及,因此這個方法仍是OK得)
還有一個方法就是,真正的長鏈接,在瀏覽器上起一個Socket客戶端而後鏈接到服務端,他倆創建一個Socket通道,這樣就和Socket Server和Socket Client同樣這樣他們之間的數據傳輸就是,時時的了!這個就叫作WEB Socket !!!!!
Socket Server和Socket Client和WEB Socket的區別就是WEB Socket啓動在瀏覽器上! 0 0 !
好比咱們在支持H5的瀏覽器上好比Google的瀏覽器輕鬆起一個WEB Socket,可是這個不只僅要客戶端支持,Server端也得支持才能夠!
sock = new WebSocket("ws://www.baidu.com")
首先用戶的好友在哪一個表裏?在用戶表裏那麼他就的關聯本身了而且是多對多的關係,你能夠有多個朋友,你朋友也能夠有多個朋友!
class UserProfile(models.Model): ''' 用戶表 ''' #使用Django提供的用戶表,直接繼承就能夠了.在原生的User表裏擴展!(原生的User表裏就有用戶名和密碼) #必定要使用OneToOne,若是是正常的ForeignKey的話就表示User中的記錄能夠對應UserProfile中的多條記錄! #而且OneToOne的實現不是在SQL級別實現的而是在代碼基本實現的! user = models.OneToOneField(User) #名字 name = models.CharField(max_length=32) #屬組 groups = models.ManyToManyField("UserGroup") #朋友 friends = models.ManyToManyField('self',related_name='my_friends')
而後在創建一個APP而後APP名稱爲:web_chat 他調用WEB裏的UserProfile用戶信息,而後在web_chat的models裏新建立一個表:QQGroup!(複習不一樣APP間的Model調用~)
#/usr/bin/env python #-*- coding:utf-8 -*- from __future__ import unicode_literals from django.db import models from web.models import UserProfile # Create your models here. class QQGroup(models.Model): ''' QQ組表 ''' #組名 name = models.CharField(max_length=64,unique=True) #註釋 description = models.CharField(max_length=255,default="The Admin is so lazy,The Noting to show you ....") ''' 下面的members和admins在作跨APP關聯的時候,關聯的表不能使用雙引號!而且在調用,Django的User表的時候也不能加雙引號。 ''' #成員 members = models.ManyToManyField(UserProfile,blank=True) #管理員 admins = models.ManyToManyField(UserProfile,blank=True,related_name='group_admins') ''' 若是在一張表中,一樣調用了另外一張表一樣的加related_name ''' #最大成員數量 max_member_nums = models.IntegerField(default=200) def __unicode__(self): return self.name
這裏:members和admins在作跨APP關聯的時候,關聯的表不能使用雙引號!而且在調用,Django的User表的時候也不能加雙引號。
一、URL相關
在以前作不一樣APP的時候,咱們都是輸入徹底的URL,咱們能夠定義一個別名來使用它很方便!
別名的好處:若是說那天想修改url裏的這個url名稱了,是否是全部前端都得修改!而且在有好幾層的時候怎麼改使用別名就會很是方便了!
projecet下的總URL
#!/usr/bin/env python #-*- coding:utf-8 -*- """Creazy_BBS URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/1.9/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ from django.conf.urls import url from django.conf.urls import include from django.contrib import admin from web import views from web import urls as web_urls from web_chat import urls as chat_urls urlpatterns = [ url(r'^admin/', admin.site.urls), #include-app web url(r'^web/', include(web_urls)), #include-app web_chat url(r'^chat/', include(chat_urls)), #指定默認的URL, url(r'',views.index,name='index'), ]
web app中的URL指定相應的別名
from django.conf.urls import url import views urlpatterns = [ url(r'category/(\d+)/$',views.category,name='category'), url(r'article_detaill/(\d+)/$',views.article_detaill,name='article_detaill'), url(r'article/new/$',views.new_article,name='new_article'), url(r'account/logout$',views.acount_logout,name='logout'), url(r'account/login',views.acount_login,name='login'), ]
web_chat app中的別名
from django.conf.urls import url import views urlpatterns = [ url(r'^dashboard/$', views.dashboard,name='web_chat'), ]
在前端引用的時候須要注意:例以下面兩個就須要使用別名來指定,格式也必須正確!
<li><a href="{% url 'new_article' %}">發帖</a></li> <li><a href="{% url 'logout' %}">用戶註銷</a></li>
二、使用Django自帶的模塊判斷用戶是否登陸
#/usr/bin/env python #-*- coding:utf-8 -*- from django.shortcuts import render #導入Django自帶的判斷用戶是否登陸的模塊 from django.contrib.auth.decorators import login_required # Create your views here. #應用裝飾器 @login_required def dashboard(request): return render(request,'web_chat/dashboard.html')
而後在settings裏配置,若是沒有登陸轉向的URL
LOGIN_URL = '/web/account/login/'
三、事件鏈
//頁面加載完成後 $(document).ready(function () { //delegate 事件鏈,把多個事件進行綁定 //給body下的textarea進行綁定,當回車鍵按下後執行的函數 $("body").delegate("textarea", "keydown",function(e){ if(e.which == 13) {//若是13這個按鍵(回車,能夠經過console.log輸出實際按下的那個鍵),執行下面的函數 //send msg button clicked var msg_text = $("textarea").val(); if ($.trim(msg_text).length > 0){ //若是去除空格後,大於0 //console.log(msg_text); //SendMsg(msg_text); //把數據進行發送 } //把數據發送到聊天框裏 AddSentMsgIntoBox(msg_text); $("textarea").val(''); } });//end body });//頁面也在完成,結束
這裏須要注意,在$(document).ready中調用的函數不能寫在$(document).ready中,$(document).ready你已加載就執行了,$(document).ready本身也是一個函數,你$(document).ready執行完以後就不存在了,就釋放了,你在$(document).ready中定義的函數,外面就沒法調用了。
四、聊天內容自動擴展而且能夠感受內容進行自動滑動
首先配置聊天的窗口樣式:
.chat_contener { width: 100%; height: 490px; background-color: black; opacity: 0.6; overflow: auto; }
而後配置,當咱們發送數據的時候自動的滾動
//定義發送到聊天框函數 function AddSentMsgIntoBox(msg_text){ //拼接聊天內容 /*氣泡實現 <div class="clearfix"> <div class="arrow"></div> <div class="content_send"><div style="margin-top: 10px;margin-left: 5px;">Hello Shuaige</div></div> </div> */ var msg_ele = "<div class='clearfix' style='padding-top:10px'>" + "<div class='arrow'>" + "</div>" + "<div class='content_send'>" + "<div style='margin-top: 10px;margin-left: 5px;'>" + msg_text + "</div>" + "</div>"; $(".chat_contener").append(msg_ele); //animate 動畫效果 $('.chat_contener').animate({ scrollTop: $('.chat_contener')[0].scrollHeight}, 500 );//動畫效果結束 }//發送到聊天框函數結束
正常狀況下來講我們在寫一個Ajax請求的時候都是這麼寫的:
$.ajax({ url:'/save_hostinfo/', type:'POST', tradition: true, data:{data:JSON.stringify(change_info)}, success:function(arg){ //成功接收的返回值(返回條目) var callback_dict = $.parseJSON(arg);//這裏把字符串轉換爲對象 //而後我們就能夠判斷 if(callback_dict){//執行成功了 //設置5秒鐘後隱藏 setTimeout("hide()",5000); var change_infos = '修改了'+callback_dict['change_count']+'條數據'; $('#handle_status').text(change_infos).removeClass('hide') }else{//若是爲False執行失敗了 alert(callback_dict.error) } } })
還有另外一種方式:
//向後端發送數據 $.post("{% url 'send_msg' %}" ,{'data':JSON.stringify(msg_dic)},function(callback){ console.log(callback); });//向發送數據結束 //解釋: // $.post 或者 $.get 是調用ajax方法 //("URL路徑" ,{'data':JSON.stringify(msg_dic)},function(callback){}) // // 這個第一個參數爲指定的ULR 第二個參數爲發送的內容 第3個參數爲回調函數和返回的值!!
在作Django的Form表單的時候學了,直接在提交表單哪裏加上csrftoken就能夠了,那Ajax怎麼進行認證呢?可使用下面的方法進行認證
//獲取CSRF參數 function GetCsrfToken(){ return $("input[name='csrfmiddlewaretoken']").val() } //發送消息 function SendMsg(msg_text){ var contact_id = $('#chat_hander h2').attr("contact_id"); //獲取發送給誰消息 var contact_type = $('#chat_hander h2').attr("contact_type");//獲取聊天類型 var msg_dic = { 'contact_type':contact_type, 'to':contact_id, 'from':"{{ request.user.userprofile.id }}", 'from_name':"{{ request.user.userprofile.name }}", 'msg':msg_text }; //向後端發送數據 $.post("{% url 'send_msg' %}" ,{'data':JSON.stringify(msg_dic),'csrfmiddlewaretoken':GetCsrfToken()},function(callback){ console.log(callback); });//向發送數據結束 //解釋: // $.post 或者 $.get 是調用ajax方法 //("URL路徑" ,{'data':JSON.stringify(msg_dic)},function(callback){}) // // 這個第一個參數爲指定的ULR 第二個參數爲發送的內容 第3個參數爲回調函數和返回的值!! }//發送消息結束
那有沒有一勞永逸的方式呢:
function getCookie(name) { var cookieValue = null; if (document.cookie && document.cookie != '') { var cookies = document.cookie.split(';'); for (var i = 0; i < cookies.length; i++) { var cookie = jQuery.trim(cookies[i]); // Does this cookie string begin with the name we want? if (cookie.substring(0, name.length + 1) == (name + '=')) { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } } } return cookieValue; } var csrftoken = getCookie('csrftoken');
function csrfSafeMethod(method) { // these HTTP methods do not require CSRF protection return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); } $.ajaxSetup({ beforeSend: function(xhr, settings) { if (!csrfSafeMethod(settings.type) && !this.crossDomain) { xhr.setRequestHeader("X-CSRFToken", csrftoken); } } });
還有一個插件,他實現了「一勞永逸的上半部分,下半部分仍是得須要寫:JavaScript Cookie library 」 ,其實也不是不少本身寫的就能夠了。
首先要知道以下幾點:C1發給C2消息,消息被髮送到服務端以後,當服務端請求過來以後C2接收到消息以後消息就服務端的數據就沒有意義了。因此不能使用Mysql、這樣的數據置於Redis和Memcache也是沒有必要的,固然排除支持數據誇不一樣設備能夠把數據持久化!
那我們怎麼作呢?想象一下數據被C2接收走以後,server端的數據就沒有意義了,用消息隊列方式是否是更好一點呢?
定義一個隊列,隊列不能寫在接收函數哪裏,寫個全局的隊列便可,而且不能建立一個隊列,而是爲每一個用戶建立一個隊列。
import Queue GLOBAL_MQ = { } def new_msg(request): if request.method == 'POST': print request.POST.get('data') #獲取用戶發過來的數據 data = json.loads(request.POST.get('data')) send_to = data['to'] #判斷隊列裏是否有這個用戶名,若是沒有新建一個隊列 if send_to not in GLOBAL_MQ: GLOBAL_MQ[send_to] = Queue.Queue() data['timestamp'] = time.time() GLOBAL_MQ[send_to].put(data) return HttpResponse(GLOBAL_MQ[send_to].qsize()) else: #由於隊列裏目前存的是字符串因此咱們須要先給他轉換爲字符串 request_user = str(request.user.userprofile.id) msg_lists = [] #判斷是否在隊列裏 if request_user in GLOBAL_MQ: #判斷有多少條消息 stored_msg_nums = GLOBAL_MQ[request_user].qsize() #把消息循環加入到列表中併發送 for i in range(stored_msg_nums): msg_lists.append(GLOBAL_MQ[request_user].get()) return HttpResponse(json.dumps(msg_lists))
先看下使用下面的方法是否可行:
#由於隊列裏目前存的是字符串因此咱們須要先給他轉換爲字符串 request_user = str(request.user.userprofile.id) msg_lists = [] #判斷是否在隊列裏 if request_user in GLOBAL_MQ: #判斷有多少條消息 stored_msg_nums = GLOBAL_MQ[request_user].qsize() #若是沒有新消息 if stored_msg_nums == 0: print "\033[41;1m沒有消息等待,15秒.....\033[0m" msg_lists.append(GLOBAL_MQ[request_user].get()) ''' 若是隊列裏面有沒有消息,get就會阻塞,等待有新消息以後會繼續往下走,這裏若是阻塞到這裏了,等有新消息過來以後,把消息加入到 msg_lists中後,for循環仍是不執行的由於,這個stored_msg_mums是在上面生成的變量下面for調用這個變量的時候他仍是爲0 等返回以後再取得時候,如今stored_msg_nums不是0了,就執行執行for循環了,而後發送數據 ''' #把消息循環加入到列表中併發送 print "\033[43;1等待已超時......15秒.....\033[0m" for i in range(stored_msg_nums): msg_lists.append(GLOBAL_MQ[request_user].get(timeout=15)) else: #建立一個新隊列給這個用戶 GLOBAL_MQ[str(request.user.userprofile.id)] = Queue.Queue() return HttpResponse(json.dumps(msg_lists))
可是爲何不等待不超時呢?反倒重複的進行鏈接呢?我服務端不是已經給他阻塞了嗎?
這個上面的問題就涉及到Client段的JS的:
//循環接收消息 var RefreshNewMsgs = setInterval(function(){ //接收消息 GetNewMsgs(); },3000);
你每一次的的請求,都是一個新的線程,當這個循環結束後自動釋放可是,連接發到服務端就被阻塞了,過了一會setInterval又有一個新的鏈接向服務端,因此服務端每次阻塞的都是一個新的線程,就沒有實現我們想要的效果!
setInterval每一次都新起一個線程!!!
那怎麼解決這個問題呢?本身調本身實現一個遞歸!
看代碼:
//接收消息 function GetNewMsgs(){ $.get("{% url 'get_new_msg' %}",function(callback){ console.log("----->new msg:",callback); var msg_list = JSON.parse(callback); var current_open_session_id = $('#chat_hander h2').attr("contact_id");//獲取當前打開的ID var current_open_session_type = $('#chat_hander h2').attr("contact_type");//獲取當前打開的類型,是單獨聊天仍是羣組聊天 $.each(msg_list, function (index,msg_item) { //接收到的消息的to,是我本身 from是誰發過來的,若是是當前打開的ID和from相同說明,我如今正在和他聊天直接顯示便可 if(msg_item.from == current_open_session_id){ AddRecvMsgToChatBox(msg_item) }//判斷擋牆打開ID接收 }) })}//接收消息結束
GetNewMsgs是不是一個AJAX啊!他請求完以後會執行一個回調函數啊! 這個回調函數執行的時候是否是表明這個請求結束了?在請求結束執行這個回調函數的時候我在執行如下GetNewMsgs()不就好了,又發起一個請求?
//接收消息 function GetNewMsgs(){ $.get("{% url 'get_new_msg' %}",function(callback){ console.log("----->new msg:",callback); var msg_list = JSON.parse(callback); var current_open_session_id = $('#chat_hander h2').attr("contact_id");//獲取當前打開的ID var current_open_session_type = $('#chat_hander h2').attr("contact_type");//獲取當前打開的類型,是單獨聊天仍是羣組聊天 $.each(msg_list, function (index,msg_item) { //接收到的消息的to,是我本身 from是誰發過來的,若是是當前打開的ID和from相同說明,我如今正在和他聊天直接顯示便可 if(msg_item.from == current_open_session_id){ AddRecvMsgToChatBox(msg_item) }//判斷擋牆打開ID接收 });//結束循環 console.log('run.....agin.....'); GetNewMsgs(); })}//接收消息結束
而後把他加載到頁面加載完後自動執行中:
//循環接收消息 GetNewMsgs();
Views函數也須要從新寫下:(由於隊列裏若是沒有數據,設置爲timeout的話就會拋異常,因此咱們的抓異常~~)
代碼以下:
def new_msg(request): if request.method == 'POST': print request.POST.get('data') #獲取用戶發過來的數據 data = json.loads(request.POST.get('data')) send_to = data['to'] #判斷隊列裏是否有這個用戶名,若是沒有新建一個隊列 if send_to not in GLOBAL_MQ: GLOBAL_MQ[send_to] = Queue.Queue() data['timestamp'] = time.strftime("%Y-%m-%d %X", time.localtime()) GLOBAL_MQ[send_to].put(data) return HttpResponse(GLOBAL_MQ[send_to].qsize()) else: #由於隊列裏目前存的是字符串因此咱們須要先給他轉換爲字符串 request_user = str(request.user.userprofile.id) msg_lists = [] #判斷是否在隊列裏 if request_user in GLOBAL_MQ: #判斷有多少條消息 stored_msg_nums = GLOBAL_MQ[request_user].qsize() try: #若是沒有新消息 if stored_msg_nums == 0: print "\033[41;1m沒有消息等待,15秒.....\033[0m" msg_lists.append(GLOBAL_MQ[request_user].get(timeout=15)) ''' 若是隊列裏面有沒有消息,get就會阻塞,等待有新消息以後會繼續往下走,這裏若是阻塞到這裏了,等有新消息過來以後,把消息加入到 msg_lists中後,for循環仍是不執行的由於,這個stored_msg_mums是在上面生成的變量下面for調用這個變量的時候他仍是爲0 等返回以後再取得時候,如今stored_msg_nums不是0了,就執行執行for循環了,而後發送數據 ''' except Exception as e: print ('error:',e) print "\033[43;1等待已超時......15秒.....\033[0m" # 把消息循環加入到列表中併發送 for i in range(stored_msg_nums): msg_lists.append(GLOBAL_MQ[request_user].get()) else: #建立一個新隊列給這個用戶 GLOBAL_MQ[str(request.user.userprofile.id)] = Queue.Queue() return HttpResponse(json.dumps(msg_lists))
漂亮問題解決:
消息實時效果實現,NICE
這個在python中,若是這麼遞歸,最多1000層,他的等前面的函數執行完後退出!看下面的結果這個CallMyself(n+1)遞歸下面的print是永遠不執行的。
#!/usr/bin/env python #-*- coding:utf-8 -*- # Tim Luo LuoTianShuai def CallMyself(n): print('level:',n) CallMyself(n+1) print('\033[32;1m測試輸出\033[0m') return 0 CallMyself(1)
可是在JS中它不是這樣的,你會發現這個print還會執行,說面函數執行完了。
有這麼一種狀況,如今我和ALEX聊天,我切換到和武Sir聊天了,可是窗口的內容還在怎麼辦?以下圖:
怎麼作呢?多層?若是200我的呢?
怎麼作呢?
能夠這樣,我在和Alex聊天的時候,切換到武Sir以後,把和Alex老師聊天內容保存起來,當和武Sir結束聊天后,在返回來和Alex老師聊天的時候在把Alex老師內容展示,把和武Sir聊天內容存起來,其餘亦如此!
//定義一個全局變量存儲用戶信息 GLOBAL_SESSION_CACHE = { 'single_contact':{}, 'group_contact':{}, }; //點擊用戶打開連天窗口 function OpenDialogBox(ele){ //獲取與誰聊天 var contact_id = $(ele).attr("contact_id"); var contact_name = $(ele).attr("chat_to"); var contact_type = $(ele).attr("contact_type"); //先把當前聊天的內容存儲起來 DumpSession(); //當前聊天內容存儲結束 //修改聊天框與誰聊天 var chat_to_info = "<h2 style='color:whitesmoke;text-align:center;' contact_type='"+ contact_type +"' contact_id='"+ contact_id+ "'>" + contact_name + "</h2>"; $('#chat_hander').html(chat_to_info); $('.chat_contener').html(LoadSession(contact_id,contact_type)); //清除未讀消息顯示 var unread_msg_num_ele = $(ele).find('span')[0]; $(unread_msg_num_ele).text(0); $(unread_msg_num_ele).addClass('hide') }//打開聊天窗口結束 //存儲未打開的聊天內容 function DumpSession2(contact_id,contact_type,content) { if(contact_id){ GLOBAL_SESSION_CACHE[contact_type][contact_id] = content; } } //加載新的聊天窗口,把要打開的聊天內容從新加載上 function LoadSession(current_contact_id,current_contact_type) { //經過hasOwnProperty判斷key是否存在 if(GLOBAL_SESSION_CACHE[current_contact_type].hasOwnProperty(current_contact_id)){ var session_html = GLOBAL_SESSION_CACHE[current_contact_type][current_contact_id]; }else{ var session_html = ''; } //把內容返回 return session_html $('.chat_contener').html(session_html); }; //加載新窗口結束
更多參考:http://www.cnblogs.com/alex3714/articles/5311625.html