前言:通常情况下,每个公司都会有自己的基础信息库,比如存储的省市区县等等。而在实际开发中,我们可能不止一次要用到全国省市区县三级联动的效果。下面我就总结一下自己在开发中用到的三级联动代码,包括数据库脚本,C#,IBatisNet和javascript实现的前后端代码,类似实现其实也同样可以扩展到三级类别的实现上。本文贴代码为主,有兴趣的可以下载示例看一下,也许对您有帮助。
1、开发环境和组织结构介绍
环境:VS2010+SqlServer2005Express+IBatisNet
如果您已经熟悉本文作者一贯的编码风格,应该已经猜到了各项目及各个文件夹的大概作用。
2、SQL Server数据库脚本
上图AreaDAL项目下,DataBase文件夹内包含着一个Area.sql脚本文件。这个脚本我是从网上下载然后稍作部分列名和字段的改进。原文url已经不记得了,对原作者表示非常抱歉。请注意,数据库中我国的台湾省缺少对应的城市和区县。
3、javascript脚本
在客户端实现联动的效果,我们可以通过下面的两种途径实现
(1)、二维数组
一个省份对应的城市的javascript二维数组结构是这样的:
var provinceCityArr = new Array(10);
var provinceId = 1234; // 江苏id
var cityArr = new Array('3456', '南京', '3457', '苏州'); //数字是城市id,汉字是城市名称
provinceCityArr[0] = new Array(provinceId, cityArr); //一个provinceId 对应一个city数组
一个城市和对应的区县类同上面的一个省份对应的城市(如果javascript可以有c#那样现成的数据结构如哈希表或者字典等等,这一方面的工作将非常简单)。
当我们选择不同的省份或者不同的城市的时候,就会触发这两个事件,这两个事件(包括下面介绍的json实现中)是在服务端(页面类文件)通过下面的形式注册的:
this.ddlProvince.Attributes.Add("onchange", "displayFirst(" + this.SelectFirstId + ", " + this.SelectSecondId + "," + this.SelectThirdId + ") ");
this.ddlCity.Attributes.Add("onchange", "displaySecond(" + this.SelectSecondId + ", " + this.SelectThirdId + ")");
而核心的两个javascript函数displayFirst和displaySecond就是对数组的遍历匹配和控件option的填充而已:
//必须先引用selectUtil.js
/*
第一层级联
参数说明:
oSelectFirst:第一个select控件
oSelectSecond:第二个select控件
oSelectThird:第三个select控件
oArr:注册在客户端的第一个级联数组
可选参数:
oSecondValue:第一层级联的关联值 比如某一省份下对应选中的城市值
*/
function displayFirst(oSelectFirst, oSelectSecond, oSelectThird, oArr) {
try {
var parentKey = oSelectFirst.options[oSelectFirst.selectedIndex].value;
if (oArr.length > 0) {
if (parentKey.length > 0) { //说明选择的不是“请选择”
for (var ii = 0; ii < oArr.length; ii++) {
var item = oArr[ii];
if (parseInt(item[0]) == parseInt(parentKey)) {
var arrItems = item[1];
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
for (var i = 0; i < arrItems.length / 2; i++) {
selectOptionAdd(oSelectSecond, arrItems[i * 2 + 1], arrItems[i * 2] + "_" + arrItems[i * 2 + 1]); // key_value
//selectOptionAdd(oSelectSecond, arrItems[i * 2 + 1], arrItems[i * 2]);//key
}
if (arguments.length > 4) {
oSelectSecond.value = arguments[4]; //初始化选项用
}
break;
}
}
}
else {
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
}
removeSelItems(oSelectThird);
selectOptionAdd(oSelectThird, "请选择", "");
}
}
catch (e) {
alert(e.message);
}
}
/*
第二层级联
参数说明:
oSelectFirst:第一个select控件 (这里对应城市)
oSelectSecond:第二个select控件(这里对应区县)
oArr:注册在客户端的第二个级联数组
可选参数:
oThirdValue:第二层级联的关联值 比如某一城市下对应选中的区县值
*/
function displaySecond(oSelectFirst, oSelectSecond, oArr) {
try {
var parentKey = oSelectFirst.options[oSelectFirst.selectedIndex].value.split('_')[0];
if (oArr.length > 0) {
if (parentKey.length > 0) { //说明选择的不是“请选择”
for (var ii = 0; ii < oArr.length; ii++) {
var item = oArr[ii];
if (parseInt(item[0]) == parseInt(parentKey)) {
var arrItems = item[1];
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
for (var i = 0; i < arrItems.length / 2; i++) {
selectOptionAdd(oSelectSecond, arrItems[i * 2 + 1], arrItems[i * 2] + "_" + arrItems[i * 2 + 1]); //key_value
//selectOptionAdd(oSelectSecond, arrItems[i * 2 + 1], arrItems[i * 2]);//key
}
if (arguments.length > 3) {
oSelectSecond.value = arguments[3]; //初始化选项用
}
break;
}
}
}
else {
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
}
}
}
catch (e) {
alert(e.message);
}
}
说明一下,上面注释中的selectUtil.js文件是一个对select控件操作的函数集合文件,您可以参考这一篇。
(2)、json
当我们选择省份的时候,都会借助jQuery发出一个同步ajax请求,返回省份对应的城市区县json。我们在服务端组织好的json数据格式如下所示:
var areaJson =
{
"ProvinceId": 1234, "ProvinceName": "江苏",
"Cities": // 数组
[
{
"CityId": 3456, "CityName": "南京",
"Counties":
[
{ "CountyId": 34560, "CountyName": "玄武区" },
{ "CountyId": 34561, "CountyName": "秦淮区" }
]
},
{ "CityId": 3457,
"CityName": "苏州",
"Counties":
[
{ "CountyId": 34570, "CountyName": "吴中区" },
{ "CountyId": 34571, "CountyName": "昆山市" }
]
}
]
}
其实,在我实现的代码里,在实现省份对应城市,城市对应区县的时候,json最终还是映射成数组,然后按照选中的option,给关联的select控件填充数据,和(1)非常类似,但是js数组结构发生了变化:
//必须先引用jQuery.js和selectUtil.js
var jqRequestUrl = "/Handler/AreaHandler.ashx";
var areaJson = null; //级联json
/*
第一层级联
参数说明:
oSelectFirst:第一个select控件
oSelectSecond:第二个select控件
oSelectThird:第三个select控件
可选参数:
oSecondValue:第一层级联的关联值 比如某一省份下对应选中的城市值
*/
function displayFirst(oSelectFirst, oSelectSecond, oSelectThird) {
try {
var parentKey = oSelectFirst.options[oSelectFirst.selectedIndex].value;
if (String(parentKey).length > 0) {
getAreaJson(parentKey);
}
var oArr = null;
if (areaJson != null) {
oArr = areaJson.Cities; //城市
}
if (parentKey.length > 0) {//说明选择的不是“请选择”
if (oArr != null) {
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
for (var ii = 0; ii < oArr.length; ii++) {
var item = oArr[ii];
var cityId = item.CityId;
var cityName = item.CityName;
selectOptionAdd(oSelectSecond, cityName, cityId + "_" + cityName); // key_value
//selectOptionAdd(oSelectSecond,cityName, cityId);//key
}
if (arguments.length > 3) {
oSelectSecond.value = arguments[3]; //初始化选项用
}
}
else {
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
}
}
else {
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
}
removeSelItems(oSelectThird);
selectOptionAdd(oSelectThird, "请选择", "");
}
catch (e) {
alert(e.message);
}
}
/*
第二层级联
参数说明:
oSelectFirst:第一个select控件 (这里对应城市)
oSelectSecond:第二个select控件(这里对应区县)
可选参数:
oThirdValue:第二层级联的关联值 比如某一城市下对应选中的区县值
*/
function displaySecond(oSelectFirst, oSelectSecond) {
try {
var parentKey = oSelectFirst.options[oSelectFirst.selectedIndex].value.split('_')[0];
var oArr = null;
if (areaJson != null) {
var oFirstArr = areaJson.Cities; //城市
for (var i = 0; i < oFirstArr.length; i++) {
if (oFirstArr[i].CityId == parentKey) {
oArr = oFirstArr[i].Counties; //区县
break;
}
}
}
if (parentKey.length > 0) { //说明选择的不是“请选择”
if (oArr != null) {
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
for (var ii = 0; ii < oArr.length; ii++) {
var item = oArr[ii];
var countyId = item.CountyId;
var countyName = item.CountyName;
selectOptionAdd(oSelectSecond, countyName, countyId + "_" + countyName); //key_value
//selectOptionAdd(oSelectSecond, countyName, countyId);//key
}
if (arguments.length > 2) {
oSelectSecond.value = arguments[2]; //初始化选项用
}
}
else {
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
}
}
else {
removeSelItems(oSelectSecond);
selectOptionAdd(oSelectSecond, "请选择", "");
}
}
catch (e) {
alert(e.message);
}
}
//从服务端取json数据
function getAreaJson(provinceId) {
//第一个参数表示同步调用
$.ajax({
async: false,
cache: false,
type: "POST",
url: jqRequestUrl,
data: "action=getareajson&provinceid=" + provinceId,
success: function (html) {
areaJson = eval('(' + html + ')'); //eval转换成json
}
});
}
比较起来,应该比(1)更方便一些,而且效率应该比(1)高,因为遍历的次数大大减少了。
大家可以运行代码试试看,点击”Get Value”按钮,在服务端会输出您选择的数据:
其实,在客户端脚本编程中,我们大部分精力都花在初始化(通过cascadeInit函数)和数据节点匹配以及填充上。
4、C#和IBatisNet实现部分
主要是通过IBatisNet作为ORM进行数据层的操作,通过IBatis取数据等等具体细节我就不具体介绍了,这里主要来谈谈对取出数据的缓存。我们发现省市区县这类数据的一个重要特点就是它们相对稳定,变更的情况很少,而且不是敏感的数据,数据量说多也不多,说少也不少,合理利用缓存可以提升系统性能。下面就简单总结一下项目中对于不常改动的基础数据如省市县,品类等等的可以采取的几种不同的缓存方案:
(1)、静态字段缓存
用static变量进行缓存(其实我们大多时候缓存的是一个引用类型,变量存放的只是一个指针引用),大家可能会觉得不可行,尤其是知道asp.net中页面静态字段造成的问题,大家可能更加不信任这种方案。我觉得某些情况下可以使用静态变量来缓存,尤其是对于那些只读而且永远不会过期的数据。静态变量有一个非常突出的好处,就是对于开发者而言,就是定义一处静态变量,系统全局都可以调用,不用担心它“不翼而飞”,而对于所有使用的用户来说,他们所使用的都是同一份数据:
public static IList<Province> listProvinces = null;//省份 对外公开
private static IDictionary<int, Province> dictProvinces = null;
private static IDictionary<int, City> dictCities = null;
private static IDictionary<int, County> dictCounties = null;
但是,静态字段缓存的一个缺点就是,它没有提供缓存过期方案,也没有线程安全机制,一旦创建后,静态变量不能被回收(但是可以将它置为null空引用,它所引用的数据对象就可以被GC回收)。还好,通过静态字段缓存大多数情况下不用考虑线程安全,因为多数情况下都是只读的数据,不会发生不一致的情况,而对于缓存过期方案,我们可以利用一个定时器代替,当然这和asp.net所提供的缓存过期方案是完全不同的,比如考虑到可能对数据进行的小部分修改,本文的程序中,我在里面加了个Timer,控制某一时刻(凌晨3点)定时更新静态字段:
public const long timerPeriodTime = 1000 * 60 * 60 * 1;//每小时timer触发一次
private static System.Timers.Timer areaTimer = null;
/// <summary>
/// 设置timer 每天凌晨3点重新访问数据库 获取相关数据
/// </summary>
private static void TimerSetUp()
{
areaTimer = new System.Timers.Timer();
areaTimer.Elapsed += new ElapsedEventHandler(TimerArea_Elapsed);//附加公告事件
areaTimer.Interval = timerPeriodTime;
areaTimer.Enabled = true;
/*当 AutoReset 设置为 false 时,Timer 只在第一个 Interval 过后引发一次 Elapsed 事件。
若要保持以 Interval 时间间隔引发 Elapsed 事件,请将 AutoReset 设置为 true。*/
areaTimer.AutoReset = true;
}
/// <summary>
/// 定时更新
/// </summary>
/// <param name="sender"></param>
private static void TimerArea_Elapsed(object sender, ElapsedEventArgs e)
{
if (DateTime.Now.Hour % 24 != 3)//凌晨3点访问数据库
{
return;
}
try
{
WaitCallback wcb = new WaitCallback(AsyncInitArea);
int workerThreads, availabeThreads;
ThreadPool.GetAvailableThreads(out workerThreads, out availabeThreads);
if (workerThreads > 0)//可用线程数>0
{
ThreadPool.QueueUserWorkItem(wcb, "定时获取地区信息");//异步
}
else
{
InitArea();
}
}
catch (Exception ex)
{
throw ex;
}
}
private static void AsyncInitArea(object objState)
{
InitArea();
}
当然,如果利用下面(2)介绍asp.net的应用程序缓存,这个Timer完全用不到,可以省去上面的代码。
(2)、asp.net缓存
大家都知道asp.net缓存有几种不同的类别。通常我们可以借助System.Web.HttpRuntime.Cache或者System.Web.Caching.Cache进行数据缓存处理。这种缓存方案也就是asp.net的应用程序数据缓存,它的优势在于它向开发人员提供了完整的依赖、过期、线程同步等的支持,也是我们最常见也最放心使用的。
(3)、IBatisNet缓存
IBatisNet自生带有缓存功能,但是需要修改配置文件,还要改进取数据的代码,而且有时候它还不稳定。虽然这是一个可供选择的解决方案,但是开发中相对用的很少,毕竟我们对缓存的控制欲常常达到了“丧心病狂”的地步,而不会过分信任这种包装过的缓存方式。
(4)、分布式缓存系统
分布式缓存,顾名思义,从表面理解,就是一台机器内存不够,将要缓存的数据分布到不同机器上存储,同时它必须确保数据不能丢失,在多台机器上都有备份。需要注意的是,缓存的数据需要序列化和反序列化,比较折腾cpu的运算能力,还有就是在网络环境下进行数据传输会占用带宽,所以对于一个大量的数据集合,通常都分割存储成多个key:value字典的形式,而不是一个key,一个大量的数据集合(我曾经在使用Memcached的过程中犯过这样的失误)。
通常,对于流量较大的大中型站点,有条件的话,几乎都会借助于分布式缓存系统如Memcached、MongoDB等等。而在本文示例中,我选择了最简单静态字段缓存。实际项目中,(2)和(4)用的相对多一些,当然通过IBatisNet的缓存也是没有问题的。
最后需要说明的是,这个联动效果我在IE9、最新的FireFox和Chrome上测试全部通过,其他浏览器(或不同版本)没有测试。
update:我刚刚注册完InfoQ的时候,点击浏览器的后退按钮,发现它的国家对应省份不见了,这好像就有问题了,不知道是不是浏览器的问题:
(最下面的必填项:州/省…数据丢失了)